refactor: Launch profile download task inside EuiccChannelManagerService
All checks were successful
/ build-debug (push) Successful in 4m39s
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:
parent
324dcdc563
commit
3add3ffa90
11 changed files with 242 additions and 29 deletions
|
@ -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>
|
||||
|
|
|
@ -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()
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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()
|
||||
}
|
||||
|
|
|
@ -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,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) {
|
||||
|
|
|
@ -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)!!
|
||||
|
|
|
@ -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>
|
|
@ -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>
|
||||
|
|
|
@ -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",
|
||||
|
|
|
@ -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")
|
||||
|
|
|
@ -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>
|
||||
|
|
Loading…
Reference in a new issue