From 3add3ffa909a4c84dd90c0d34accc516a3751aa5 Mon Sep 17 00:00:00 2001 From: Peter Cai Date: Sun, 29 Sep 2024 14:09:17 -0400 Subject: [PATCH] refactor: Launch profile download task inside EuiccChannelManagerService This task is too long to run directly inside the fragment lifecycle. Instead, let's launch it inside the service lifecycle scope and use a MutableStateFlow to notify the UI of progress. This interface is designed to be extensible to other use cases. --- app-common/src/main/AndroidManifest.xml | 3 + .../service/EuiccChannelManagerService.kt | 191 +++++++++++++++++- .../openeuicc/ui/BaseEuiccAccessActivity.kt | 5 +- .../im/angry/openeuicc/ui/MainActivity.kt | 17 ++ .../openeuicc/ui/ProfileDownloadFragment.kt | 40 ++-- .../util/EuiccChannelFragmentUtils.kt | 3 + .../drawable/ic_task_sim_card_download.xml | 5 + app-common/src/main/res/values/strings.xml | 3 + app-deps/Android.bp | 1 + app-deps/build.gradle.kts | 1 + privapp_whitelist_im.angry.openeuicc.xml | 2 + 11 files changed, 242 insertions(+), 29 deletions(-) create mode 100644 app-common/src/main/res/drawable/ic_task_sim_card_download.xml diff --git a/app-common/src/main/AndroidManifest.xml b/app-common/src/main/AndroidManifest.xml index 81632990..a59110b4 100644 --- a/app-common/src/main/AndroidManifest.xml +++ b/app-common/src/main/AndroidManifest.xml @@ -3,8 +3,10 @@ xmlns:android="http://schemas.android.com/apk/res/android" package="im.angry.openeuicc.common"> + + diff --git a/app-common/src/main/java/im/angry/openeuicc/service/EuiccChannelManagerService.kt b/app-common/src/main/java/im/angry/openeuicc/service/EuiccChannelManagerService.kt index 75c58dbc..9a30df31 100644 --- a/app-common/src/main/java/im/angry/openeuicc/service/EuiccChannelManagerService.kt +++ b/app-common/src/main/java/im/angry/openeuicc/service/EuiccChannelManagerService.kt @@ -1,11 +1,27 @@ package im.angry.openeuicc.service -import android.app.Service import android.content.Intent +import android.content.pm.PackageManager import android.os.Binder import android.os.IBinder +import androidx.core.app.NotificationChannelCompat +import androidx.core.app.NotificationCompat +import androidx.core.app.NotificationManagerCompat +import androidx.lifecycle.LifecycleService +import androidx.lifecycle.lifecycleScope +import im.angry.openeuicc.common.R import im.angry.openeuicc.core.EuiccChannelManager import im.angry.openeuicc.util.* +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.onCompletion +import kotlinx.coroutines.flow.transformWhile +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext +import net.typeblog.lpac_jni.ProfileDownloadCallback /** * An Android Service wrapper for EuiccChannelManager. @@ -17,8 +33,20 @@ import im.angry.openeuicc.util.* * instance of EuiccChannelManager. UI components can keep being bound to this service for * their entire lifecycles, since the whole purpose of them is to expose the current state * to the user. + * + * Additionally, this service is also responsible for long-running "foreground" tasks that + * are not suitable to be managed by UI components. This includes profile downloading, etc. + * When a UI component needs to run one of these tasks, they have to bind to this service + * and call one of the `launch*` methods, which will run the task inside this service's + * lifecycle context and return a Flow instance for the UI component to subscribe to its + * progress. */ -class EuiccChannelManagerService : Service(), OpenEuiccContextMarker { +class EuiccChannelManagerService : LifecycleService(), OpenEuiccContextMarker { + companion object { + private const val CHANNEL_ID = "tasks" + private const val FOREGROUND_ID = 1000 + } + inner class LocalBinder : Binder() { val service = this@EuiccChannelManagerService } @@ -28,14 +56,167 @@ class EuiccChannelManagerService : Service(), OpenEuiccContextMarker { } val euiccChannelManager: EuiccChannelManager by euiccChannelManagerDelegate - override fun onBind(intent: Intent?): IBinder = LocalBinder() + /** + * The state of a "foreground" task (named so due to the need to startForeground()) + */ + sealed interface ForegroundTaskState { + data object Idle : ForegroundTaskState + data class InProgress(val progress: Int) : ForegroundTaskState + data class Done(val error: Throwable?) : ForegroundTaskState + } + + /** + * This flow emits whenever the service has had a start command, from startService() + * The service self-starts when foreground is required, because other components + * only bind to this service and do not start it per-se. + */ + private val foregroundStarted: MutableSharedFlow = MutableSharedFlow() + + /** + * This flow is used to emit progress updates when a foreground task is running. + */ + private val foregroundTaskState: MutableStateFlow = + MutableStateFlow(ForegroundTaskState.Idle) + + override fun onBind(intent: Intent): IBinder { + super.onBind(intent) + return LocalBinder() + } override fun onDestroy() { super.onDestroy() - // This is the whole reason of the existence of this service: - // we can clean up opened channels when no one is using them if (euiccChannelManagerDelegate.isInitialized()) { euiccChannelManager.invalidate() } } + + override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int { + return super.onStartCommand(intent, flags, startId).also { + lifecycleScope.launch { + foregroundStarted.emit(Unit) + } + } + } + + private fun updateForegroundNotification(title: String, iconRes: Int) { + val channel = + NotificationChannelCompat.Builder(CHANNEL_ID, NotificationManagerCompat.IMPORTANCE_LOW) + .setName(getString(R.string.task_notification)) + .setVibrationEnabled(false) + .build() + NotificationManagerCompat.from(this).createNotificationChannel(channel) + + val state = foregroundTaskState.value + + if (state is ForegroundTaskState.InProgress) { + val notification = NotificationCompat.Builder(this, CHANNEL_ID) + .setContentTitle(title) + .setProgress(100, state.progress, state.progress == 0) + .setSmallIcon(iconRes) + .setPriority(NotificationCompat.PRIORITY_LOW) + .build() + + if (state.progress == 0) { + startForeground(FOREGROUND_ID, notification) + } else if (checkSelfPermission(android.Manifest.permission.POST_NOTIFICATIONS) == PackageManager.PERMISSION_GRANTED) { + NotificationManagerCompat.from(this).notify(FOREGROUND_ID, notification) + } + } else { + stopForeground(STOP_FOREGROUND_REMOVE) + } + } + + /** + * Launch a potentially blocking foreground task in this service's lifecycle context. + * This function does not block, but returns a Flow that emits ForegroundTaskState + * updates associated with this task. + * The task closure is expected to update foregroundTaskState whenever appropriate. + * If a foreground task is already running, this function returns null. + */ + private fun launchForegroundTask( + title: String, + iconRes: Int, + task: suspend EuiccChannelManagerService.() -> Unit + ): Flow? { + // Atomically set the state to InProgress. If this returns true, we are + // the only task currently in progress. + if (!foregroundTaskState.compareAndSet( + ForegroundTaskState.Idle, + ForegroundTaskState.InProgress(0) + ) + ) { + return null + } + + lifecycleScope.launch(Dispatchers.Main) { + // Wait until our self-start command has succeeded. + // We can only call startForeground() after that + foregroundStarted.first() + updateForegroundNotification(title, iconRes) + + try { + withContext(Dispatchers.IO) { + this@EuiccChannelManagerService.task() + } + foregroundTaskState.value = ForegroundTaskState.Done(null) + } catch (t: Throwable) { + foregroundTaskState.value = ForegroundTaskState.Done(t) + } finally { + stopSelf() + } + + updateForegroundNotification(title, iconRes) + } + + // We 've launched the coroutine, now we can self-start + // This is required in order to use startForeground() + // This will end up calling onStartCommand(), which will emit + // into foregroundStarted and unblock the coroutine above + startForegroundService(Intent(this, this::class.java)) + + // We should be the only task running, so we can subscribe to foregroundTaskState + // until we encounter ForegroundTaskState.Done. + return foregroundTaskState.transformWhile { + // Also update our notification when we see an update + updateForegroundNotification(title, iconRes) + emit(it) + it !is ForegroundTaskState.Done + }.onCompletion { foregroundTaskState.value = ForegroundTaskState.Idle } + } + + fun launchProfileDownloadTask( + slotId: Int, + portId: Int, + smdp: String, + matchingId: String?, + confirmationCode: String?, + imei: String? + ): Flow? = + launchForegroundTask( + getString(R.string.task_profile_download), + R.drawable.ic_task_sim_card_download + ) { + euiccChannelManager.beginTrackedOperationBlocking(slotId, portId) { + val channel = euiccChannelManager.findEuiccChannelByPort(slotId, portId) + val res = channel!!.lpa.downloadProfile( + smdp, + matchingId, + imei, + confirmationCode, + object : ProfileDownloadCallback { + override fun onStateUpdate(state: ProfileDownloadCallback.DownloadState) { + if (state.progress == 0) return + foregroundTaskState.value = + ForegroundTaskState.InProgress(state.progress) + } + }) + + if (!res) { + // TODO: Provide more details on the error + throw RuntimeException("Failed to download profile; this is typically caused by another error happened before.") + } + + preferenceRepository.notificationDownloadFlow.first() + } + } } \ No newline at end of file diff --git a/app-common/src/main/java/im/angry/openeuicc/ui/BaseEuiccAccessActivity.kt b/app-common/src/main/java/im/angry/openeuicc/ui/BaseEuiccAccessActivity.kt index 3c699d7a..ae13962e 100644 --- a/app-common/src/main/java/im/angry/openeuicc/ui/BaseEuiccAccessActivity.kt +++ b/app-common/src/main/java/im/angry/openeuicc/ui/BaseEuiccAccessActivity.kt @@ -14,11 +14,12 @@ import kotlinx.coroutines.CompletableDeferred abstract class BaseEuiccAccessActivity : AppCompatActivity() { val euiccChannelManagerLoaded = CompletableDeferred() lateinit var euiccChannelManager: EuiccChannelManager + lateinit var euiccChannelManagerService: EuiccChannelManagerService private val euiccChannelManagerServiceConnection = object : ServiceConnection { override fun onServiceConnected(name: ComponentName?, service: IBinder?) { - euiccChannelManager = - (service!! as EuiccChannelManagerService.LocalBinder).service.euiccChannelManager + euiccChannelManagerService = (service!! as EuiccChannelManagerService.LocalBinder).service + euiccChannelManager = euiccChannelManagerService.euiccChannelManager euiccChannelManagerLoaded.complete(Unit) onInit() } diff --git a/app-common/src/main/java/im/angry/openeuicc/ui/MainActivity.kt b/app-common/src/main/java/im/angry/openeuicc/ui/MainActivity.kt index 183bbbd9..13075130 100644 --- a/app-common/src/main/java/im/angry/openeuicc/ui/MainActivity.kt +++ b/app-common/src/main/java/im/angry/openeuicc/ui/MainActivity.kt @@ -5,7 +5,9 @@ import android.content.BroadcastReceiver import android.content.Context import android.content.Intent import android.content.IntentFilter +import android.content.pm.PackageManager import android.hardware.usb.UsbManager +import android.os.Build import android.os.Bundle import android.telephony.TelephonyManager import android.util.Log @@ -30,6 +32,8 @@ import kotlinx.coroutines.withContext open class MainActivity : BaseEuiccAccessActivity(), OpenEuiccContextMarker { companion object { const val TAG = "MainActivity" + + const val PERMISSION_REQUEST_CODE = 1000 } private lateinit var loadingProgress: ProgressBar @@ -116,6 +120,15 @@ open class MainActivity : BaseEuiccAccessActivity(), OpenEuiccContextMarker { } } + private fun ensureNotificationPermissions() { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU && checkSelfPermission(android.Manifest.permission.POST_NOTIFICATIONS) != PackageManager.PERMISSION_GRANTED) { + requestPermissions( + arrayOf(android.Manifest.permission.POST_NOTIFICATIONS), + PERMISSION_REQUEST_CODE + ) + } + } + private suspend fun init(fromUsbEvent: Boolean = false) { refreshing = true // We don't check this here -- the check happens in refresh() loadingProgress.visibility = View.VISIBLE @@ -173,6 +186,10 @@ open class MainActivity : BaseEuiccAccessActivity(), OpenEuiccContextMarker { viewPager.currentItem = 0 } + if (pages.size > 0) { + ensureNotificationPermissions() + } + refreshing = false } } diff --git a/app-common/src/main/java/im/angry/openeuicc/ui/ProfileDownloadFragment.kt b/app-common/src/main/java/im/angry/openeuicc/ui/ProfileDownloadFragment.kt index f8399a91..475dd926 100644 --- a/app-common/src/main/java/im/angry/openeuicc/ui/ProfileDownloadFragment.kt +++ b/app-common/src/main/java/im/angry/openeuicc/ui/ProfileDownloadFragment.kt @@ -18,12 +18,13 @@ import com.google.android.material.textfield.TextInputLayout import com.journeyapps.barcodescanner.ScanContract import com.journeyapps.barcodescanner.ScanOptions import im.angry.openeuicc.common.R +import im.angry.openeuicc.service.EuiccChannelManagerService import im.angry.openeuicc.util.* import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.last +import kotlinx.coroutines.flow.onEach import kotlinx.coroutines.launch import kotlinx.coroutines.withContext -import net.typeblog.lpac_jni.ProfileDownloadCallback import kotlin.Exception class ProfileDownloadFragment : BaseMaterialDialogFragment(), @@ -224,30 +225,25 @@ class ProfileDownloadFragment : BaseMaterialDialogFragment(), code: String?, confirmationCode: String?, imei: String? - ) = beginTrackedOperation { - val res = channel.lpa.downloadProfile( + ) = withContext(Dispatchers.Main) { + // The service is responsible for launching the actual blocking part on the IO context + val res = euiccChannelManagerService.launchProfileDownloadTask( + slotId, + portId, server, code, - imei, confirmationCode, - object : ProfileDownloadCallback { - override fun onStateUpdate(state: ProfileDownloadCallback.DownloadState) { - lifecycleScope.launch(Dispatchers.Main) { - progress.isIndeterminate = false - progress.progress = state.progress - } - } - }) + imei + )!!.onEach { + progress.isIndeterminate = false + if (it is EuiccChannelManagerService.ForegroundTaskState.InProgress) { + progress.progress = it.progress + } else { + progress.progress = 100 + } + }.last() - if (!res) { - // TODO: Provide more details on the error - throw RuntimeException("Failed to download profile; this is typically caused by another error happened before.") - } - - // If we get here, we are successful - // This function is wrapped in beginTrackedOperation, so by returning the settings value, - // We only send notifications if the user allowed us to - preferenceRepository.notificationDownloadFlow.first() + (res as? EuiccChannelManagerService.ForegroundTaskState.Done)?.error?.let { throw it } } override fun onDismiss(dialog: DialogInterface) { diff --git a/app-common/src/main/java/im/angry/openeuicc/util/EuiccChannelFragmentUtils.kt b/app-common/src/main/java/im/angry/openeuicc/util/EuiccChannelFragmentUtils.kt index 667ce6e7..f6f20d45 100644 --- a/app-common/src/main/java/im/angry/openeuicc/util/EuiccChannelFragmentUtils.kt +++ b/app-common/src/main/java/im/angry/openeuicc/util/EuiccChannelFragmentUtils.kt @@ -4,6 +4,7 @@ import android.os.Bundle import androidx.fragment.app.Fragment import im.angry.openeuicc.core.EuiccChannel import im.angry.openeuicc.core.EuiccChannelManager +import im.angry.openeuicc.service.EuiccChannelManagerService import im.angry.openeuicc.ui.BaseEuiccAccessActivity import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.withContext @@ -35,6 +36,8 @@ val T.isUsb: Boolean where T: Fragment, T: EuiccChannelFragmentMarker val T.euiccChannelManager: EuiccChannelManager where T: Fragment, T: EuiccChannelFragmentMarker get() = (requireActivity() as BaseEuiccAccessActivity).euiccChannelManager +val T.euiccChannelManagerService: EuiccChannelManagerService where T: Fragment, T: EuiccChannelFragmentMarker + get() = (requireActivity() as BaseEuiccAccessActivity).euiccChannelManagerService val T.channel: EuiccChannel where T: Fragment, T: EuiccChannelFragmentMarker get() = euiccChannelManager.findEuiccChannelByPortBlocking(slotId, portId)!! diff --git a/app-common/src/main/res/drawable/ic_task_sim_card_download.xml b/app-common/src/main/res/drawable/ic_task_sim_card_download.xml new file mode 100644 index 00000000..a2eadb23 --- /dev/null +++ b/app-common/src/main/res/drawable/ic_task_sim_card_download.xml @@ -0,0 +1,5 @@ + + + + + diff --git a/app-common/src/main/res/values/strings.xml b/app-common/src/main/res/values/strings.xml index 7647815e..647a2c48 100644 --- a/app-common/src/main/res/values/strings.xml +++ b/app-common/src/main/res/values/strings.xml @@ -33,6 +33,9 @@ Permission is needed to access the USB smart card reader. Cannot connect to eSIM via a USB smart card reader. + Long-running Tasks + Downloading eSIM profile + New eSIM Server (RSP / SM-DP+) Activation Code diff --git a/app-deps/Android.bp b/app-deps/Android.bp index 3143e3f6..b98d8625 100644 --- a/app-deps/Android.bp +++ b/app-deps/Android.bp @@ -8,6 +8,7 @@ java_defaults { "androidx-constraintlayout_constraintlayout", "androidx.preference_preference", "androidx.lifecycle_lifecycle-runtime-ktx", + "androidx.lifecycle_lifecycle-service", "androidx.swiperefreshlayout_swiperefreshlayout", "androidx.cardview_cardview", "androidx.viewpager2_viewpager2", diff --git a/app-deps/build.gradle.kts b/app-deps/build.gradle.kts index 6cde72d6..2f4d70e5 100644 --- a/app-deps/build.gradle.kts +++ b/app-deps/build.gradle.kts @@ -48,6 +48,7 @@ dependencies { //noinspection KtxExtensionAvailable api("androidx.preference:preference:1.2.1") api("androidx.lifecycle:lifecycle-runtime-ktx:2.6.2") + api("androidx.lifecycle:lifecycle-service:2.6.2") api("androidx.swiperefreshlayout:swiperefreshlayout:1.1.0") api("androidx.cardview:cardview:1.0.0") api("androidx.viewpager2:viewpager2:1.1.0") diff --git a/privapp_whitelist_im.angry.openeuicc.xml b/privapp_whitelist_im.angry.openeuicc.xml index 88d35cc1..0f117b6b 100644 --- a/privapp_whitelist_im.angry.openeuicc.xml +++ b/privapp_whitelist_im.angry.openeuicc.xml @@ -5,5 +5,7 @@ + +