-
Notifications
You must be signed in to change notification settings - Fork 411
ui: Implement support for Android Dynamic Shortcuts #55
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: master
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
Original file line number | Diff line number | Diff line change |
---|---|---|
|
@@ -22,30 +22,49 @@ 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 | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. "Keep previous behavior" is not a useful code comment. Just remove that comment. |
||
} | ||
|
||
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) | ||
Toast.makeText(this@TunnelToggleActivity, message, Toast.LENGTH_LONG).show() | ||
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. | ||
*/ | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. This comment doesn't help much either. Get rid of it. |
||
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 { | ||
|
Original file line number | Diff line number | Diff line change |
---|---|---|
|
@@ -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) { | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Can that just be a generic View? We don't actually need any CompoundButton members, right? |
||
val tunnel = when (val binding = DataBindingUtil.findBinding<ViewDataBinding>(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" | ||
} | ||
|
Original file line number | Diff line number | Diff line change |
---|---|---|
|
@@ -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 | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Comment indentation looks a little funny. |
||
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" | ||
|
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -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 { | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Also weird indentation here? |
||
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) } | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. startsWith is not a safe thing here, because tunnel names can be prefixes of each other. |
||
} |
Original file line number | Diff line number | Diff line change |
---|---|---|
|
@@ -41,6 +41,7 @@ class TunnelManager(private val configStore: ConfigStore) : BaseObservable() { | |
private val tunnels = CompletableDeferred<ObservableSortedKeyedArrayList<String, ObservableTunnel>>() | ||
private val context: Context = get() | ||
private val tunnelMap: ObservableSortedKeyedArrayList<String, ObservableTunnel> = 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) | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Can't we just call remove unconditionally, and it'll be a no-op if they're not already there? What do we gain by first checking? |
||
} | ||
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 { | ||
|
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,22 @@ | ||
<!-- | ||
~ Copyright © 2017-2022 WireGuard LLC. All Rights Reserved. | ||
~ SPDX-License-Identifier: Apache-2.0 | ||
--> | ||
|
||
<animated-vector | ||
xmlns:android="http://schemas.android.com/apk/res/android" | ||
xmlns:aapt="http://schemas.android.com/aapt" | ||
android:drawable="@drawable/ic_baseline_star"> | ||
|
||
<target android:name="star"> | ||
<aapt:attr name="android:animation"> | ||
<objectAnimator | ||
android:propertyName="pathData" | ||
android:duration="300" | ||
android:valueFrom="@string/vdpath_star_full" | ||
android:valueTo="@string/vdpath_star_border" | ||
android:valueType="pathType" | ||
android:interpolator="@android:anim/accelerate_decelerate_interpolator"/> | ||
</aapt:attr> | ||
</target> | ||
</animated-vector> |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,22 @@ | ||
<!-- | ||
~ Copyright © 2017-2022 WireGuard LLC. All Rights Reserved. | ||
~ SPDX-License-Identifier: Apache-2.0 | ||
--> | ||
|
||
<animated-vector | ||
xmlns:android="http://schemas.android.com/apk/res/android" | ||
xmlns:aapt="http://schemas.android.com/aapt" | ||
android:drawable="@drawable/ic_baseline_star_border"> | ||
|
||
<target android:name="star"> | ||
<aapt:attr name="android:animation"> | ||
<objectAnimator | ||
android:propertyName="pathData" | ||
android:duration="300" | ||
android:valueFrom="@string/vdpath_star_border" | ||
android:valueTo="@string/vdpath_star_full" | ||
android:valueType="pathType" | ||
android:interpolator="@android:anim/accelerate_decelerate_interpolator"/> | ||
</aapt:attr> | ||
</target> | ||
</animated-vector> |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,26 @@ | ||
<?xml version="1.0" encoding="utf-8"?><!-- | ||
~ Copyright © 2017-2022 WireGuard LLC. All Rights Reserved. | ||
~ SPDX-License-Identifier: Apache-2.0 | ||
--> | ||
|
||
<animated-selector xmlns:android="http://schemas.android.com/apk/res/android"> | ||
<item | ||
android:id="@+id/star_full" | ||
android:drawable="@drawable/ic_baseline_star" | ||
android:state_checked="true"/> | ||
<item | ||
android:id="@+id/star_border" | ||
android:drawable="@drawable/ic_baseline_star_border" | ||
android:state_checked="false"/> | ||
|
||
<transition | ||
android:drawable="@drawable/avd_star_to_full" | ||
android:fromId="@id/star_border" | ||
android:toId="@id/star_full"/> | ||
|
||
<transition | ||
android:drawable="@drawable/avd_star_to_border" | ||
android:fromId="@id/star_full" | ||
android:toId="@id/star_border"/> | ||
|
||
</animated-selector> |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,5 @@ | ||
<vector android:height="24dp" android:tint="#000000" | ||
android:viewportHeight="24" android:viewportWidth="24" | ||
android:width="24dp" xmlns:android="http://schemas.android.com/apk/res/android"> | ||
<path android:fillColor="@android:color/white" android:pathData="M12,4c4.41,0 8,3.59 8,8s-3.59,8 -8,8s-8,-3.59 -8,-8S7.59,4 12,4M12,2C6.48,2 2,6.48 2,12c0,5.52 4.48,10 10,10c5.52,0 10,-4.48 10,-10C22,6.48 17.52,2 12,2L12,2zM13,12l0,-4h-2l0,4H8l4,4l4,-4H13z"/> | ||
</vector> |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,5 @@ | ||
<vector android:height="24dp" android:tint="#000000" | ||
android:viewportHeight="24" android:viewportWidth="24" | ||
android:width="24dp" xmlns:android="http://schemas.android.com/apk/res/android"> | ||
<path android:fillColor="@android:color/white" android:pathData="M12,20c-4.41,0 -8,-3.59 -8,-8s3.59,-8 8,-8s8,3.59 8,8S16.41,20 12,20M12,22c5.52,0 10,-4.48 10,-10c0,-5.52 -4.48,-10 -10,-10C6.48,2 2,6.48 2,12C2,17.52 6.48,22 12,22L12,22zM11,12l0,4h2l0,-4h3l-4,-4l-4,4H11z"/> | ||
</vector> |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,17 @@ | ||
<!-- | ||
~ Copyright © 2017-2022 WireGuard LLC. All Rights Reserved. | ||
~ SPDX-License-Identifier: Apache-2.0 | ||
--> | ||
|
||
<vector | ||
xmlns:android="http://schemas.android.com/apk/res/android" | ||
android:width="24dp" | ||
android:height="24dp" | ||
android:tint="#000000" | ||
android:viewportWidth="24" | ||
android:viewportHeight="24" > | ||
<path | ||
android:name="star" | ||
android:fillColor="@android:color/white" | ||
android:pathData="@string/vdpath_star_full"/> | ||
</vector> |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,16 @@ | ||
<!-- | ||
~ Copyright © 2017-2022 WireGuard LLC. All Rights Reserved. | ||
~ SPDX-License-Identifier: Apache-2.0 | ||
--> | ||
|
||
<vector | ||
xmlns:android="http://schemas.android.com/apk/res/android" | ||
android:width="24dp" | ||
android:height="24dp" | ||
android:viewportHeight="24" android:viewportWidth="24" | ||
android:tint="#000000" > | ||
<path | ||
android:name="star" | ||
android:fillColor="@android:color/white" | ||
android:pathData="@string/vdpath_star_border"/> | ||
</vector> |
Original file line number | Diff line number | Diff line change |
---|---|---|
|
@@ -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" /> | ||
|
||
<com.google.android.material.checkbox.MaterialCheckBox | ||
android:id="@+id/tunnel_shortcut_toggle" | ||
android:layout_width="wrap_content" | ||
android:layout_height="wrap_content" | ||
android:layout_alignBaseline="@id/tunnel_switch" | ||
android:layout_toStartOf="@id/tunnel_switch" | ||
android:button="@drawable/ic_action_shortcut" | ||
android:checked="@{item.hasShortcut == true}" | ||
android:nextFocusRight="@+id/tunnel_switch" | ||
android:onCheckedChanged="@{fragment::setShortcutState}" | ||
android:tooltipText="@string/tooltip_tunnel_shortcut" | ||
app:buttonTint="?attr/colorSecondary" | ||
tools:checked="@sample/interface_names.json/names/shortcut/shortcut" /> | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I wonder if this can be more hidden or less prominent or something. This is not something most users want. Another approach -- and the one we use on iOS -- which I think might be better is to just add toggle shortcuts for the last N used tunnels. |
||
|
||
<com.wireguard.android.widget.ToggleSwitch | ||
android:id="@+id/tunnel_switch" | ||
android:layout_width="wrap_content" | ||
|
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Should these strings be constants somewhere?