ソースを参照

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.
Peter Cai 1 年間 前
コミット
3add3ffa90

+ 3 - 0
app-common/src/main/AndroidManifest.xml

@@ -3,8 +3,10 @@
     xmlns:android="http://schemas.android.com/apk/res/android"
     package="im.angry.openeuicc.common">
 
+    <uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
     <uses-permission android:name="android.permission.READ_PHONE_STATE" />
     <uses-permission android:name="android.permission.INTERNET" />
+    <uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
 
     <application>
         <activity
@@ -31,6 +33,7 @@
 
         <service
             android:name="im.angry.openeuicc.service.EuiccChannelManagerService"
+            android:foregroundServiceType="shortService"
             android:exported="false" />
     </application>
 </manifest>

+ 186 - 5
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<Unit> = MutableSharedFlow()
+
+    /**
+     * This flow is used to emit progress updates when a foreground task is running.
+     */
+    private val foregroundTaskState: MutableStateFlow<ForegroundTaskState> =
+        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<ForegroundTaskState>? {
+        // 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<ForegroundTaskState>? =
+        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()
+            }
+        }
 }

+ 3 - 2
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<Unit>()
     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()
         }

+ 17 - 0
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
         }
     }

+ 18 - 22
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
-                    }
-                }
-            })
-
-        if (!res) {
-            // TODO: Provide more details on the error
-            throw RuntimeException("Failed to download profile; this is typically caused by another error happened before.")
-        }
+            imei
+        )!!.onEach {
+            progress.isIndeterminate = false
+            if (it is EuiccChannelManagerService.ForegroundTaskState.InProgress) {
+                progress.progress = it.progress
+            } else {
+                progress.progress = 100
+            }
+        }.last()
 
-        // 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) {

+ 3 - 0
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> T.isUsb: Boolean where T: Fragment, T: EuiccChannelFragmentMarker
 
 val <T> T.euiccChannelManager: EuiccChannelManager where T: Fragment, T: EuiccChannelFragmentMarker
     get() = (requireActivity() as BaseEuiccAccessActivity).euiccChannelManager
+val <T> T.euiccChannelManagerService: EuiccChannelManagerService where T: Fragment, T: EuiccChannelFragmentMarker
+    get() = (requireActivity() as BaseEuiccAccessActivity).euiccChannelManagerService
 val <T> T.channel: EuiccChannel where T: Fragment, T: EuiccChannelFragmentMarker
     get() =
         euiccChannelManager.findEuiccChannelByPortBlocking(slotId, portId)!!

+ 5 - 0
app-common/src/main/res/drawable/ic_task_sim_card_download.xml

@@ -0,0 +1,5 @@
+<vector xmlns:android="http://schemas.android.com/apk/res/android" android:height="24dp" android:tint="#000000" android:viewportHeight="24" android:viewportWidth="24" android:width="24dp">
+      
+    <path android:fillColor="@android:color/white" android:pathData="M18,2h-8L4,8v12c0,1.1 0.9,2 2,2h12c1.1,0 2,-0.9 2,-2V4C20,2.9 19.1,2 18,2zM12,17l-4,-4h3V9.02L13,9v4h3L12,17z"/>
+    
+</vector>

+ 3 - 0
app-common/src/main/res/values/strings.xml

@@ -33,6 +33,9 @@
     <string name="usb_permission_needed">Permission is needed to access the USB smart card reader.</string>
     <string name="usb_failed">Cannot connect to eSIM via a USB smart card reader.</string>
 
+    <string name="task_notification">Long-running Tasks</string>
+    <string name="task_profile_download">Downloading eSIM profile</string>
+
     <string name="profile_download">New eSIM</string>
     <string name="profile_download_server">Server (RSP / SM-DP+)</string>
     <string name="profile_download_code">Activation Code</string>

+ 1 - 0
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",

+ 1 - 0
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")

+ 2 - 0
privapp_whitelist_im.angry.openeuicc.xml

@@ -5,5 +5,7 @@
         <permission name="android.permission.WRITE_EMBEDDED_SUBSCRIPTIONS" />
         <permission name="android.permission.MODIFY_PHONE_STATE" />
         <permission name="android.permission.SECURE_ELEMENT_PRIVILEGED_OPERATION" />
+        <permission name="android.permission.FOREGROUND_SERVICE" />
+        <permission name="android.permission.POST_NOTIFICATIONS" />
     </privapp-permissions>
 </permissions>