diff --git a/ui/sampledata/interface_names.json b/ui/sampledata/interface_names.json index 1c41cb224..52bb652c7 100644 --- a/ui/sampledata/interface_names.json +++ b/ui/sampledata/interface_names.json @@ -28,6 +28,19 @@ { "checked": true }, { "checked": false }, { "checked": true } + ], + "shortcut": [ + { "shortcut": true }, + { "shortcut": false }, + { "shortcut": false }, + { "shortcut": false }, + { "shortcut": false }, + { "shortcut": false }, + { "shortcut": true }, + { "shortcut": true }, + { "shortcut": true }, + { "shortcut": false }, + { "shortcut": false } ] } ] diff --git a/ui/src/main/java/com/wireguard/android/activity/TunnelToggleActivity.kt b/ui/src/main/java/com/wireguard/android/activity/TunnelToggleActivity.kt index 59b9349f5..f13ecec85 100644 --- a/ui/src/main/java/com/wireguard/android/activity/TunnelToggleActivity.kt +++ b/ui/src/main/java/com/wireguard/android/activity/TunnelToggleActivity.kt @@ -22,18 +22,27 @@ import com.wireguard.android.backend.Tunnel import com.wireguard.android.util.ErrorMessages import kotlinx.coroutines.launch -@RequiresApi(Build.VERSION_CODES.N) class TunnelToggleActivity : AppCompatActivity() { private val permissionActivityResultLauncher = registerForActivityResult(ActivityResultContracts.StartActivityForResult()) { toggleTunnelWithPermissionsResult() } private fun toggleTunnelWithPermissionsResult() { - val tunnel = Application.getTunnelManager().lastUsedTunnel ?: return lifecycleScope.launch { + val tunnelAction = when(intent.action) { + "com.wireguard.android.action.SET_TUNNEL_UP" -> Tunnel.State.UP + "com.wireguard.android.action.SET_TUNNEL_DOWN" -> Tunnel.State.DOWN + else -> Tunnel.State.TOGGLE // Implicit toggle to keep previous behaviour + } + + val tunnel = when(val tunnelName = intent.getStringExtra("tunnel")) { + null -> Application.getTunnelManager().lastUsedTunnel + else -> Application.getTunnelManager().getTunnels().find { it.name == tunnelName } + } ?: return@launch // If we failed to identify the tunnel, just return + try { - tunnel.setStateAsync(Tunnel.State.TOGGLE) + tunnel.setStateAsync(tunnelAction) } catch (e: Throwable) { - TileService.requestListeningState(this@TunnelToggleActivity, ComponentName(this@TunnelToggleActivity, QuickTileService::class.java)) + updateTileService() val error = ErrorMessages[e] val message = getString(R.string.toggle_error, error) Log.e(TAG, message, e) @@ -41,11 +50,21 @@ class TunnelToggleActivity : AppCompatActivity() { finishAffinity() return@launch } - TileService.requestListeningState(this@TunnelToggleActivity, ComponentName(this@TunnelToggleActivity, QuickTileService::class.java)) + updateTileService() finishAffinity() } } + /** + * TileService is only available for API 24+, if it's available it'll be updated, + * otherwise it's ignored. + */ + private fun updateTileService() { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) { + TileService.requestListeningState(this@TunnelToggleActivity, ComponentName(this@TunnelToggleActivity, QuickTileService::class.java)) + } + } + override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) lifecycleScope.launch { diff --git a/ui/src/main/java/com/wireguard/android/fragment/BaseFragment.kt b/ui/src/main/java/com/wireguard/android/fragment/BaseFragment.kt index d5c1723f9..38de51b0c 100644 --- a/ui/src/main/java/com/wireguard/android/fragment/BaseFragment.kt +++ b/ui/src/main/java/com/wireguard/android/fragment/BaseFragment.kt @@ -7,6 +7,7 @@ package com.wireguard.android.fragment import android.content.Context import android.util.Log import android.view.View +import android.widget.CompoundButton import android.widget.Toast import androidx.activity.result.contract.ActivityResultContracts import androidx.databinding.DataBindingUtil @@ -108,6 +109,18 @@ abstract class BaseFragment : Fragment(), OnSelectedTunnelChangedListener { } } + fun setShortcutState(view: CompoundButton, checked: Boolean) { + val tunnel = when (val binding = DataBindingUtil.findBinding(view)) { + is TunnelDetailFragmentBinding -> binding.tunnel + is TunnelListItemBinding -> binding.item + else -> return + } ?: return + val activity = activity ?: return + activity.lifecycleScope.launch { + tunnel.setShortcutsAsync(checked) + } + } + companion object { private const val TAG = "WireGuard/BaseFragment" } diff --git a/ui/src/main/java/com/wireguard/android/model/ObservableTunnel.kt b/ui/src/main/java/com/wireguard/android/model/ObservableTunnel.kt index aa237aeef..fe0474a04 100644 --- a/ui/src/main/java/com/wireguard/android/model/ObservableTunnel.kt +++ b/ui/src/main/java/com/wireguard/android/model/ObservableTunnel.kt @@ -139,6 +139,24 @@ class ObservableTunnel internal constructor( suspend fun deleteAsync() = manager.delete(this) + suspend fun setShortcutsAsync(hasShortcuts: Boolean) = manager.setShortcuts(this, hasShortcuts) + + @get:Bindable + var hasShortcut: Boolean? = null + get() { + if (field == null) + // Opportunistically fetch this if we don't have a cached one, and rely on data bindings to update it eventually + applicationScope.launch { + try { + field = manager.hasShortcut(this@ObservableTunnel) + } catch (e: Throwable) { + Log.e(TAG, Log.getStackTraceString(e)) + } + } + return field + } + private set + companion object { private const val TAG = "WireGuard/ObservableTunnel" diff --git a/ui/src/main/java/com/wireguard/android/model/ShortcutManager.kt b/ui/src/main/java/com/wireguard/android/model/ShortcutManager.kt new file mode 100644 index 000000000..b1c821704 --- /dev/null +++ b/ui/src/main/java/com/wireguard/android/model/ShortcutManager.kt @@ -0,0 +1,55 @@ +/* + * Copyright © 2017-2022 WireGuard LLC. All Rights Reserved. + * SPDX-License-Identifier: Apache-2.0 + */ + +package com.wireguard.android.model + +import android.content.Context +import android.content.Intent +import androidx.core.content.pm.ShortcutInfoCompat +import androidx.core.content.pm.ShortcutManagerCompat +import androidx.core.graphics.drawable.IconCompat +import com.wireguard.android.BuildConfig +import com.wireguard.android.R +import com.wireguard.android.activity.TunnelToggleActivity + +class ShortcutManager(private val context: Context) { + + private fun upIdFor(name: String): String = "$name-UP" + private fun downIdFor(name: String): String = "$name-DOWN" + + private fun createShortcutIntent(action: String, tunnelName: String): Intent = + Intent(context, TunnelToggleActivity::class.java).apply { + setPackage(BuildConfig.APPLICATION_ID) + setAction(action) + putExtra("tunnel", tunnelName) + } + + fun addShortcuts(name: String) { + val upIntent = createShortcutIntent("com.wireguard.android.action.SET_TUNNEL_UP", name) + val shortcutUp = ShortcutInfoCompat.Builder(context, upIdFor(name)) + .setShortLabel(context.getString(R.string.shortcut_label_short_up, name)) + .setLongLabel(context.getString(R.string.shortcut_label_long_up, name)) + .setIcon(IconCompat.createWithResource(context, R.drawable.ic_baseline_arrow_circle_up_24)) + .setIntent(upIntent) + .build() + ShortcutManagerCompat.pushDynamicShortcut(context, shortcutUp) + + val downIntent = createShortcutIntent("com.wireguard.android.action.SET_TUNNEL_DOWN", name) + val shortcutDown = ShortcutInfoCompat.Builder(context, downIdFor(name)) + .setShortLabel(context.getString(R.string.shortcut_label_short_down, name)) + .setLongLabel(context.getString(R.string.shortcut_label_long_down, name)) + .setIcon(IconCompat.createWithResource(context, R.drawable.ic_baseline_arrow_circle_down_24)) + .setIntent(downIntent) + .build() + ShortcutManagerCompat.pushDynamicShortcut(context, shortcutDown) + } + + fun removeShortcuts(name: String) { + ShortcutManagerCompat.removeDynamicShortcuts(context, listOf(upIdFor(name), downIdFor(name))) + } + + fun hasShortcut(name: String) = + ShortcutManagerCompat.getDynamicShortcuts(context).any { it.id.startsWith(name) } +} \ No newline at end of file diff --git a/ui/src/main/java/com/wireguard/android/model/TunnelManager.kt b/ui/src/main/java/com/wireguard/android/model/TunnelManager.kt index ba873fa66..da97b385d 100644 --- a/ui/src/main/java/com/wireguard/android/model/TunnelManager.kt +++ b/ui/src/main/java/com/wireguard/android/model/TunnelManager.kt @@ -41,6 +41,7 @@ class TunnelManager(private val configStore: ConfigStore) : BaseObservable() { private val tunnels = CompletableDeferred>() private val context: Context = get() private val tunnelMap: ObservableSortedKeyedArrayList = ObservableSortedKeyedArrayList(TunnelComparator) + private val shortcutManager = ShortcutManager(context) private var haveLoaded = false private fun addToList(name: String, config: Config?, state: Tunnel.State): ObservableTunnel { @@ -65,6 +66,10 @@ class TunnelManager(private val configStore: ConfigStore) : BaseObservable() { // Make sure nothing touches the tunnel. if (wasLastUsed) lastUsedTunnel = null + // Make sure we also remove any existing shortcuts. + if (shortcutManager.hasShortcut(tunnel.name)) { + shortcutManager.removeShortcuts(tunnel.name) + } tunnelMap.remove(tunnel) try { if (originalState == Tunnel.State.UP) @@ -170,6 +175,11 @@ class TunnelManager(private val configStore: ConfigStore) : BaseObservable() { // Make sure nothing touches the tunnel. if (wasLastUsed) lastUsedTunnel = null + // Make sure we also remove any existing shortcuts. We will add them back with the new name. + val hadShortcuts = shortcutManager.hasShortcut(tunnel.name) + if (hadShortcuts) { + shortcutManager.removeShortcuts(tunnel.name) + } tunnelMap.remove(tunnel) var throwable: Throwable? = null var newName: String? = null @@ -189,6 +199,10 @@ class TunnelManager(private val configStore: ConfigStore) : BaseObservable() { tunnelMap.add(tunnel) if (wasLastUsed) lastUsedTunnel = tunnel + // Add back previous shortcuts with the new name (if any). + if (hadShortcuts) { + shortcutManager.addShortcuts(tunnel.name) + } if (throwable != null) throw throwable newName!! @@ -211,6 +225,18 @@ class TunnelManager(private val configStore: ConfigStore) : BaseObservable() { newState } + suspend fun setShortcuts(tunnel: ObservableTunnel, hasShortcuts: Boolean) = withContext(Dispatchers.Main.immediate) { + if (hasShortcuts) { + shortcutManager.addShortcuts(tunnel.name) + } else { + shortcutManager.removeShortcuts(tunnel.name) + } + } + + suspend fun hasShortcut(tunnel: ObservableTunnel) = withContext(Dispatchers.Main.immediate) { + return@withContext shortcutManager.hasShortcut(tunnel.name) + } + class IntentReceiver : BroadcastReceiver() { override fun onReceive(context: Context, intent: Intent?) { applicationScope.launch { diff --git a/ui/src/main/res/drawable/avd_star_to_border.xml b/ui/src/main/res/drawable/avd_star_to_border.xml new file mode 100644 index 000000000..9bf8bedbc --- /dev/null +++ b/ui/src/main/res/drawable/avd_star_to_border.xml @@ -0,0 +1,22 @@ + + + + + + + + + + diff --git a/ui/src/main/res/drawable/avd_star_to_full.xml b/ui/src/main/res/drawable/avd_star_to_full.xml new file mode 100644 index 000000000..011c8957d --- /dev/null +++ b/ui/src/main/res/drawable/avd_star_to_full.xml @@ -0,0 +1,22 @@ + + + + + + + + + + diff --git a/ui/src/main/res/drawable/ic_action_shortcut.xml b/ui/src/main/res/drawable/ic_action_shortcut.xml new file mode 100644 index 000000000..a7bc85f72 --- /dev/null +++ b/ui/src/main/res/drawable/ic_action_shortcut.xml @@ -0,0 +1,26 @@ + + + + + + + + + + + \ No newline at end of file diff --git a/ui/src/main/res/drawable/ic_baseline_arrow_circle_down_24.xml b/ui/src/main/res/drawable/ic_baseline_arrow_circle_down_24.xml new file mode 100644 index 000000000..772735f00 --- /dev/null +++ b/ui/src/main/res/drawable/ic_baseline_arrow_circle_down_24.xml @@ -0,0 +1,5 @@ + + + diff --git a/ui/src/main/res/drawable/ic_baseline_arrow_circle_up_24.xml b/ui/src/main/res/drawable/ic_baseline_arrow_circle_up_24.xml new file mode 100644 index 000000000..b9950afa2 --- /dev/null +++ b/ui/src/main/res/drawable/ic_baseline_arrow_circle_up_24.xml @@ -0,0 +1,5 @@ + + + diff --git a/ui/src/main/res/drawable/ic_baseline_star.xml b/ui/src/main/res/drawable/ic_baseline_star.xml new file mode 100644 index 000000000..94ec88cd5 --- /dev/null +++ b/ui/src/main/res/drawable/ic_baseline_star.xml @@ -0,0 +1,17 @@ + + + + + diff --git a/ui/src/main/res/drawable/ic_baseline_star_border.xml b/ui/src/main/res/drawable/ic_baseline_star_border.xml new file mode 100644 index 000000000..0d8b535f4 --- /dev/null +++ b/ui/src/main/res/drawable/ic_baseline_star_border.xml @@ -0,0 +1,16 @@ + + + + + diff --git a/ui/src/main/res/layout/tunnel_list_item.xml b/ui/src/main/res/layout/tunnel_list_item.xml index 3d5b02d1e..61ab0aebf 100644 --- a/ui/src/main/res/layout/tunnel_list_item.xml +++ b/ui/src/main/res/layout/tunnel_list_item.xml @@ -33,7 +33,7 @@ android:background="@drawable/list_item_background" android:descendantFocusability="beforeDescendants" android:focusable="true" - android:nextFocusRight="@+id/tunnel_switch" + android:nextFocusRight="@+id/tunnel_shortcut_toggle" android:paddingHorizontal="16dp" android:paddingVertical="8dp"> @@ -49,6 +49,20 @@ android:textAppearance="?attr/textAppearanceBodyLarge" tools:text="@sample/interface_names.json/names/names/name" /> + + + + + M 12 17.27 L 18.18 21 L 16.54 13.97 L 19.27 11.605 L 22 9.24 L 14.81 8.63 L 12 2 L 9.19 8.63 L 2 9.24 L 7.46 13.97 L 5.82 21 L 12 17.27 M 12.005 12.495 L 12.005 12.495 L 12.005 12.495 L 12.005 12.495 L 12.005 12.495 L 12.005 12.495 L 12.005 12.495 L 12.005 12.495 L 12.005 12.495 L 12.005 12.495 L 12.005 12.495 L 12.005 12.495 + M 12 17.27 L 18.18 21 L 16.55 13.97 L 22 9.24 L 22 9.24 L 14.81 8.62 L 12 2 L 9.19 8.63 L 2 9.24 L 7.46 13.97 L 5.82 21 L 12 17.27 M 12 15.4 L 8.24 17.67 L 9.24 13.39 L 5.92 10.51 L 10.3 10.13 L 12 6.1 L 13.71 10.14 L 18.09 10.52 L 14.77 13.4 L 15.77 17.68 L 12 15.4 L 12 15.4 + \ No newline at end of file diff --git a/ui/src/main/res/values/strings.xml b/ui/src/main/res/values/strings.xml index df3d33406..5d454f94d 100644 --- a/ui/src/main/res/values/strings.xml +++ b/ui/src/main/res/values/strings.xml @@ -257,4 +257,9 @@ Authenticate to view private key Authentication failure Authentication failure: %s + Add shortcuts + %s UP + Tunnel %s up + %s DOWN + Tunnel %s down