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
This commit is contained in:
pierre
2023-05-27 00:00:17 +02:00
parent 7d64618701
commit 5bd87bdaa8
4 changed files with 125 additions and 63 deletions
@@ -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 = {
@@ -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<ProxyWorker>()
.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<ProxyWorker>()
.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<Socks5State> = _uiState.asStateFlow()
private val _uiState = MutableStateFlow(ProxyState())
val uiState: StateFlow<ProxyState> = _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 <T : ViewModel> create(modelClass: Class<T>): 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")
}
@@ -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
)
)
}
@@ -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,1L3,5v6c0,5.55 3.84,10.74 9,12 5.16,-1.26 9,-6.45 9,-12V5l-9,-4z"/>
</vector>