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:
@@ -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 = {
|
||||
|
||||
+46
-45
@@ -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>
|
||||
Reference in New Issue
Block a user