refactor: Launch profile download task inside EuiccChannelManagerService
All checks were successful
/ build-debug (push) Successful in 4m39s

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.
This commit is contained in:
Peter Cai 2024-09-29 14:09:17 -04:00
parent 324dcdc563
commit 3add3ffa90
11 changed files with 242 additions and 29 deletions

View file

@ -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>

View file

@ -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()
}
}
}

View file

@ -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()
}

View file

@ -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
}
}

View file

@ -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) {

View file

@ -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)!!

View file

@ -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>

View file

@ -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>

View file

@ -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",

View file

@ -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")

View file

@ -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>