Skip to content

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

Open
wants to merge 1 commit into
base: master
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 13 additions & 0 deletions ui/sampledata/interface_names.json
Original file line number Diff line number Diff line change
Expand Up @@ -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 }
]
}
]
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Copy link
Member

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?

"com.wireguard.android.action.SET_TUNNEL_DOWN" -> Tunnel.State.DOWN
else -> Tunnel.State.TOGGLE // Implicit toggle to keep previous behaviour
Copy link
Member

Choose a reason for hiding this comment

The 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.
*/
Copy link
Member

Choose a reason for hiding this comment

The 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 {
Expand Down
13 changes: 13 additions & 0 deletions ui/src/main/java/com/wireguard/android/fragment/BaseFragment.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -108,6 +109,18 @@ abstract class BaseFragment : Fragment(), OnSelectedTunnelChangedListener {
}
}

fun setShortcutState(view: CompoundButton, checked: Boolean) {
Copy link
Member

Choose a reason for hiding this comment

The 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"
}
Expand Down
18 changes: 18 additions & 0 deletions ui/src/main/java/com/wireguard/android/model/ObservableTunnel.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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
Copy link
Member

Choose a reason for hiding this comment

The 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"
Expand Down
55 changes: 55 additions & 0 deletions ui/src/main/java/com/wireguard/android/model/ShortcutManager.kt
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 {
Copy link
Member

Choose a reason for hiding this comment

The 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) }
Copy link
Member

Choose a reason for hiding this comment

The 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.

}
26 changes: 26 additions & 0 deletions ui/src/main/java/com/wireguard/android/model/TunnelManager.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -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)
Copy link
Member

Choose a reason for hiding this comment

The 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)
Expand Down Expand 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
Expand All @@ -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!!
Expand All @@ -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 {
Expand Down
22 changes: 22 additions & 0 deletions ui/src/main/res/drawable/avd_star_to_border.xml
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>
22 changes: 22 additions & 0 deletions ui/src/main/res/drawable/avd_star_to_full.xml
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>
26 changes: 26 additions & 0 deletions ui/src/main/res/drawable/ic_action_shortcut.xml
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>
5 changes: 5 additions & 0 deletions ui/src/main/res/drawable/ic_baseline_arrow_circle_down_24.xml
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>
5 changes: 5 additions & 0 deletions ui/src/main/res/drawable/ic_baseline_arrow_circle_up_24.xml
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>
17 changes: 17 additions & 0 deletions ui/src/main/res/drawable/ic_baseline_star.xml
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>
16 changes: 16 additions & 0 deletions ui/src/main/res/drawable/ic_baseline_star_border.xml
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>
16 changes: 15 additions & 1 deletion ui/src/main/res/layout/tunnel_list_item.xml
Original file line number Diff line number Diff line change
Expand Up @@ -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">

Expand All @@ -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" />
Copy link
Member

Choose a reason for hiding this comment

The 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"
Expand Down
Loading