From 5bd87bdaa8b23e6907fb6f1f9a43782bbc56048b Mon Sep 17 00:00:00 2001 From: pierre Date: Sat, 27 May 2023 00:00:17 +0200 Subject: [PATCH] feat(nc-native-android): add notification tap action on notification tap, bring app to foreground use a shield icon for the top bar app icon clean code add notes on a pending bug --- .../java/net/nymtech/nyms5/MainActivity.kt | 32 ++++++- .../{Socks5ViewModel.kt => MainViewModel.kt} | 91 ++++++++++--------- .../java/net/nymtech/nyms5/ProxyWorker.kt | 60 +++++++++--- .../app/src/main/res/drawable/shield_24.xml | 5 + 4 files changed, 125 insertions(+), 63 deletions(-) rename nym-connect/native/android/app/src/main/java/net/nymtech/nyms5/{Socks5ViewModel.kt => MainViewModel.kt} (54%) create mode 100644 nym-connect/native/android/app/src/main/res/drawable/shield_24.xml diff --git a/nym-connect/native/android/app/src/main/java/net/nymtech/nyms5/MainActivity.kt b/nym-connect/native/android/app/src/main/java/net/nymtech/nyms5/MainActivity.kt index 4e7ef5dd21..eddcb9e027 100644 --- a/nym-connect/native/android/app/src/main/java/net/nymtech/nyms5/MainActivity.kt +++ b/nym-connect/native/android/app/src/main/java/net/nymtech/nyms5/MainActivity.kt @@ -38,13 +38,14 @@ import androidx.compose.ui.platform.LocalClipboardManager import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.AnnotatedString import androidx.compose.ui.text.font.FontStyle +import androidx.work.WorkInfo import androidx.work.WorkManager class MainActivity : ComponentActivity() { private val tag = "MainActivity" - private val viewModel: Socks5ViewModel by viewModels { - Socks5ViewModelFactory( + private val viewModel: MainViewModel by viewModels { + MainViewModelFactory( workManager = WorkManager.getInstance(applicationContext), nymProxy = App.nymProxy ) @@ -52,9 +53,30 @@ class MainActivity : ComponentActivity() { override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) + Log.d(tag, "____onCreate") + + // observe proxy work progress + WorkManager.getInstance(applicationContext) + .getWorkInfoByIdLiveData(ProxyWorker.workId) + // this observer is tied to the activity lifecycle + .observe(this) { workInfo -> + if (workInfo != null && workInfo.state == WorkInfo.State.RUNNING) { + val progress = + workInfo.progress.getString(ProxyWorker.State) + when (progress) { + ProxyWorker.Work.Status.CONNECTED.name -> { + Log.i(tag, "Nym proxy $progress") + viewModel.setConnected() + } + + else -> Log.i(tag, "Nym proxy $progress") + } + } + } lifecycleScope.launch { repeatOnLifecycle(Lifecycle.State.STARTED) { + Log.d(tag, "____uiState collect") viewModel.uiState.collect { setContent { NymTheme { @@ -72,6 +94,7 @@ class MainActivity : ComponentActivity() { Log.d(tag, "switch ON") viewModel.startProxyWork() } + else -> { Log.d(tag, "switch OFF") viewModel.cancelProxyWork() @@ -108,7 +131,10 @@ fun S5ClientSwitch( Spacer(modifier = modifier.height(2.dp)) } Column(modifier = modifier.padding(16.dp)) { - Row(modifier = modifier.padding(16.dp), verticalAlignment = Alignment.CenterVertically) { + Row( + modifier = modifier.padding(16.dp), + verticalAlignment = Alignment.CenterVertically + ) { Text("Nym proxy") Spacer(modifier = modifier.width(14.dp)) Switch(checked = connected, enabled = !loading, onCheckedChange = { diff --git a/nym-connect/native/android/app/src/main/java/net/nymtech/nyms5/Socks5ViewModel.kt b/nym-connect/native/android/app/src/main/java/net/nymtech/nyms5/MainViewModel.kt similarity index 54% rename from nym-connect/native/android/app/src/main/java/net/nymtech/nyms5/Socks5ViewModel.kt rename to nym-connect/native/android/app/src/main/java/net/nymtech/nyms5/MainViewModel.kt index 89faa7c7ba..36f4858dc7 100644 --- a/nym-connect/native/android/app/src/main/java/net/nymtech/nyms5/Socks5ViewModel.kt +++ b/nym-connect/native/android/app/src/main/java/net/nymtech/nyms5/MainViewModel.kt @@ -19,53 +19,43 @@ import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch -class Socks5ViewModel( +class MainViewModel( private val workManager: WorkManager, private val nymProxy: NymProxy ) : ViewModel() { private val tag = "viewModel" - private val workRequest: OneTimeWorkRequest = OneTimeWorkRequestBuilder() - .setConstraints( - Constraints.Builder().setRequiredNetworkType(NetworkType.CONNECTED).build() - ) - .setExpedited(OutOfQuotaPolicy.RUN_AS_NON_EXPEDITED_WORK_REQUEST) - .addTag(ProxyWorker.workTag) - .setId(ProxyWorker.workId) - .build() + private val workRequest: OneTimeWorkRequest = + OneTimeWorkRequestBuilder() + .setConstraints( + Constraints.Builder() + .setRequiredNetworkType(NetworkType.CONNECTED).build() + ) + .setExpedited(OutOfQuotaPolicy.RUN_AS_NON_EXPEDITED_WORK_REQUEST) + .addTag(ProxyWorker.workTag) + .setId(ProxyWorker.workId) + .build() init { - // observe the proxy work ProxyWorker + Log.d(tag, "____init") + + // TODO ⚠ In some circumstances this `init` block can be run multiple + // time which means the below observer will be registered more than once + // This can leads to multiple sequential calls to `stopClient` + // → nym_socks5_listener panics when this happens, crashing the + // entire App + + // When the work is cancelled "externally" ie. when the user tap the + // "Stop" action on the notification, or when the app is intentionally + // killed the underlying proxy client keeps running in background + // We have to manually call `stopClient` to stop it workManager.getWorkInfoByIdLiveData(ProxyWorker.workId) + // watch "forever", ie. even when the main activity has been stopped .observeForever { workInfo -> if (workInfo?.state == WorkInfo.State.CANCELLED || workInfo?.state == WorkInfo.State.FAILED) { - // when the work is cancelled, ie. from the work notification "Stop" action - _uiState.update { currentState -> - currentState.copy( - connected = false, - loading = true, - ) - } - stopProxy() + cancelProxyWork() Log.d(tag, "proxy work cancelled") } - if (workInfo != null && workInfo.state == WorkInfo.State.RUNNING) { - val progress = workInfo.progress.getString(ProxyWorker.State) - Log.d(tag, "work connection state $progress") - when (progress) { - "CONNECTED" -> if (!_uiState.value.connected || _uiState.value.loading) { - _uiState.update { currentState -> - currentState.copy( - connected = true, - loading = false, - ) - } - Log.i(tag, "Nym proxy connected") - } - - else -> {} - } - } } } @@ -82,15 +72,21 @@ class Socks5ViewModel( } } - data class Socks5State(val connected: Boolean = false, val loading: Boolean = false) + data class ProxyState( + val connected: Boolean = false, + val loading: Boolean = false + ) // Expose screen UI state - private val _uiState = MutableStateFlow(Socks5State()) - val uiState: StateFlow = _uiState.asStateFlow() + private val _uiState = MutableStateFlow(ProxyState()) + val uiState: StateFlow = _uiState.asStateFlow() - private fun stopProxy() { - viewModelScope.launch(Dispatchers.IO) { - nymProxy.stop(callback) + fun setConnected() { + _uiState.update { currentState -> + currentState.copy( + connected = true, + loading = false, + ) } } @@ -119,15 +115,20 @@ class Socks5ViewModel( loading = true, ) } - stopProxy() + viewModelScope.launch(Dispatchers.IO) { + nymProxy.stop(callback) + } } } -class Socks5ViewModelFactory(private val workManager: WorkManager, private val nymProxy: NymProxy) : +class MainViewModelFactory( + private val workManager: WorkManager, + private val nymProxy: NymProxy +) : ViewModelProvider.Factory { override fun create(modelClass: Class): T { - return if (modelClass.isAssignableFrom(Socks5ViewModel::class.java)) { - Socks5ViewModel(workManager, nymProxy) as T + return if (modelClass.isAssignableFrom(MainViewModel::class.java)) { + MainViewModel(workManager, nymProxy) as T } else { throw IllegalArgumentException("Unknown ViewModel class") } diff --git a/nym-connect/native/android/app/src/main/java/net/nymtech/nyms5/ProxyWorker.kt b/nym-connect/native/android/app/src/main/java/net/nymtech/nyms5/ProxyWorker.kt index ae2640d893..923aa0fdb2 100644 --- a/nym-connect/native/android/app/src/main/java/net/nymtech/nyms5/ProxyWorker.kt +++ b/nym-connect/native/android/app/src/main/java/net/nymtech/nyms5/ProxyWorker.kt @@ -3,7 +3,9 @@ package net.nymtech.nyms5 import android.app.Notification import android.app.NotificationChannel import android.app.NotificationManager +import android.app.PendingIntent import android.content.Context +import android.content.Intent import android.os.Build import android.util.Log import androidx.annotation.RequiresApi @@ -33,6 +35,12 @@ class ProxyWorker( val workId: UUID = UUID.randomUUID() const val State = "State" + + enum class Status { + DISCONNECTED, + STARTING, + CONNECTED + } } private val tag = "proxyWorker" @@ -66,20 +74,23 @@ class ProxyWorker( private val callback = object { fun onStart() { Log.d(tag, "⚡ ON START callback") - setProgressAsync(workDataOf(State to "CONNECTED")) + setProgressAsync(workDataOf(State to Status.CONNECTED.name)) } } @RequiresApi(Build.VERSION_CODES.O) override suspend fun doWork(): Result { - setProgress(workDataOf(State to "STARTING")) + setProgress(workDataOf(State to Status.STARTING.name)) // `setForeground` can fail // see https://developer.android.com/guide/background/persistent/getting-started/define-work#coroutineworker try { setForeground(createForegroundInfo()) } catch (e: Throwable) { - Log.w(tag, "failed to make the work run in the context of a foreground service") + Log.w( + tag, + "failed to make the work run in the context of a foreground service" + ) } return try { @@ -96,21 +107,27 @@ class ProxyWorker( .let { spJson.items[it].service_provider_client_id } Log.d(tag, "selected service provider: $serviceProvider") } else { - Log.w(tag, "failed to fetch the service providers list: $res.statusCode") + Log.w( + tag, + "failed to fetch the service providers list: $res.statusCode" + ) Log.w(tag, "using a default service provider $defaultSp") } } catch (e: Throwable) { - Log.e(tag, "an error occurred while fetching the service providers list: $e") + Log.e( + tag, + "an error occurred while fetching the service providers list: $e" + ) Log.w(tag, "using a default service provider $defaultSp") } nymProxy.start(serviceProvider ?: defaultSp, callback) - setProgress(workDataOf(State to "DISCONNECTED")) + setProgress(workDataOf(State to Status.DISCONNECTED.name)) Log.d(tag, "work finished") Result.success() - } catch (throwable: Throwable) { - Log.e(tag, "error: ${throwable.message}") + } catch (e: Throwable) { + Log.e(tag, "error: ${e.message}") Result.failure() } } @@ -118,19 +135,32 @@ class ProxyWorker( private fun createNotification(): Notification { val title = applicationContext.getString(R.string.notification_title) val cancel = applicationContext.getString(R.string.stop_proxy) - // This PendingIntent can be used to cancel the worker - val intent = WorkManager.getInstance(applicationContext) + // this pending intent is used to cancel the worker + val stopPendingIntent = WorkManager.getInstance(applicationContext) .createCancelPendingIntent(id) + // this intent is used for the notification's tap action + // on tap → show to the main activity + val tapIntent = + Intent(applicationContext, MainActivity::class.java).apply { + flags = + Intent.FLAG_ACTIVITY_SINGLE_TOP or Intent.FLAG_ACTIVITY_REORDER_TO_FRONT + } + val tapPendingIntent: PendingIntent = PendingIntent.getActivity( + applicationContext, + 0, + tapIntent, + PendingIntent.FLAG_IMMUTABLE + ) + return NotificationCompat.Builder(applicationContext, channelId) .setContentTitle(title) .setTicker(title) .setContentText("Nym socks5 proxy running") - .setSmallIcon(android.R.drawable.ic_secure) + .setSmallIcon(R.drawable.shield_24) .setOngoing(true) - // Add the cancel action to the notification which can - // be used to cancel the worker - .addAction(android.R.drawable.ic_delete, cancel, intent) + .setContentIntent(tapPendingIntent) + .addAction(android.R.drawable.ic_delete, cancel, stopPendingIntent) .build() } @@ -152,7 +182,7 @@ class ProxyWorker( NotificationChannel( channelId, applicationContext.getString(R.string.notification_channel_name), - NotificationManager.IMPORTANCE_HIGH + NotificationManager.IMPORTANCE_DEFAULT ) ) } diff --git a/nym-connect/native/android/app/src/main/res/drawable/shield_24.xml b/nym-connect/native/android/app/src/main/res/drawable/shield_24.xml new file mode 100644 index 0000000000..e49a1f265d --- /dev/null +++ b/nym-connect/native/android/app/src/main/res/drawable/shield_24.xml @@ -0,0 +1,5 @@ + + +