Skip to content

Commit 3add3ff

Browse files
committed
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.
1 parent 324dcdc commit 3add3ff

11 files changed

+242
-29
lines changed

app-common/src/main/AndroidManifest.xml

+3
Original file line numberDiff line numberDiff line change
@@ -3,8 +3,10 @@
33
xmlns:android="http://schemas.android.com/apk/res/android"
44
package="im.angry.openeuicc.common">
55

6+
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
67
<uses-permission android:name="android.permission.READ_PHONE_STATE" />
78
<uses-permission android:name="android.permission.INTERNET" />
9+
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
810

911
<application>
1012
<activity
@@ -31,6 +33,7 @@
3133

3234
<service
3335
android:name="im.angry.openeuicc.service.EuiccChannelManagerService"
36+
android:foregroundServiceType="shortService"
3437
android:exported="false" />
3538
</application>
3639
</manifest>

app-common/src/main/java/im/angry/openeuicc/service/EuiccChannelManagerService.kt

+186-5
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,27 @@
11
package im.angry.openeuicc.service
22

3-
import android.app.Service
43
import android.content.Intent
4+
import android.content.pm.PackageManager
55
import android.os.Binder
66
import android.os.IBinder
7+
import androidx.core.app.NotificationChannelCompat
8+
import androidx.core.app.NotificationCompat
9+
import androidx.core.app.NotificationManagerCompat
10+
import androidx.lifecycle.LifecycleService
11+
import androidx.lifecycle.lifecycleScope
12+
import im.angry.openeuicc.common.R
713
import im.angry.openeuicc.core.EuiccChannelManager
814
import im.angry.openeuicc.util.*
15+
import kotlinx.coroutines.Dispatchers
16+
import kotlinx.coroutines.flow.Flow
17+
import kotlinx.coroutines.flow.MutableSharedFlow
18+
import kotlinx.coroutines.flow.MutableStateFlow
19+
import kotlinx.coroutines.flow.first
20+
import kotlinx.coroutines.flow.onCompletion
21+
import kotlinx.coroutines.flow.transformWhile
22+
import kotlinx.coroutines.launch
23+
import kotlinx.coroutines.withContext
24+
import net.typeblog.lpac_jni.ProfileDownloadCallback
925

1026
/**
1127
* An Android Service wrapper for EuiccChannelManager.
@@ -17,8 +33,20 @@ import im.angry.openeuicc.util.*
1733
* instance of EuiccChannelManager. UI components can keep being bound to this service for
1834
* their entire lifecycles, since the whole purpose of them is to expose the current state
1935
* to the user.
36+
*
37+
* Additionally, this service is also responsible for long-running "foreground" tasks that
38+
* are not suitable to be managed by UI components. This includes profile downloading, etc.
39+
* When a UI component needs to run one of these tasks, they have to bind to this service
40+
* and call one of the `launch*` methods, which will run the task inside this service's
41+
* lifecycle context and return a Flow instance for the UI component to subscribe to its
42+
* progress.
2043
*/
21-
class EuiccChannelManagerService : Service(), OpenEuiccContextMarker {
44+
class EuiccChannelManagerService : LifecycleService(), OpenEuiccContextMarker {
45+
companion object {
46+
private const val CHANNEL_ID = "tasks"
47+
private const val FOREGROUND_ID = 1000
48+
}
49+
2250
inner class LocalBinder : Binder() {
2351
val service = this@EuiccChannelManagerService
2452
}
@@ -28,14 +56,167 @@ class EuiccChannelManagerService : Service(), OpenEuiccContextMarker {
2856
}
2957
val euiccChannelManager: EuiccChannelManager by euiccChannelManagerDelegate
3058

31-
override fun onBind(intent: Intent?): IBinder = LocalBinder()
59+
/**
60+
* The state of a "foreground" task (named so due to the need to startForeground())
61+
*/
62+
sealed interface ForegroundTaskState {
63+
data object Idle : ForegroundTaskState
64+
data class InProgress(val progress: Int) : ForegroundTaskState
65+
data class Done(val error: Throwable?) : ForegroundTaskState
66+
}
67+
68+
/**
69+
* This flow emits whenever the service has had a start command, from startService()
70+
* The service self-starts when foreground is required, because other components
71+
* only bind to this service and do not start it per-se.
72+
*/
73+
private val foregroundStarted: MutableSharedFlow<Unit> = MutableSharedFlow()
74+
75+
/**
76+
* This flow is used to emit progress updates when a foreground task is running.
77+
*/
78+
private val foregroundTaskState: MutableStateFlow<ForegroundTaskState> =
79+
MutableStateFlow(ForegroundTaskState.Idle)
80+
81+
override fun onBind(intent: Intent): IBinder {
82+
super.onBind(intent)
83+
return LocalBinder()
84+
}
3285

3386
override fun onDestroy() {
3487
super.onDestroy()
35-
// This is the whole reason of the existence of this service:
36-
// we can clean up opened channels when no one is using them
3788
if (euiccChannelManagerDelegate.isInitialized()) {
3889
euiccChannelManager.invalidate()
3990
}
4091
}
92+
93+
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
94+
return super.onStartCommand(intent, flags, startId).also {
95+
lifecycleScope.launch {
96+
foregroundStarted.emit(Unit)
97+
}
98+
}
99+
}
100+
101+
private fun updateForegroundNotification(title: String, iconRes: Int) {
102+
val channel =
103+
NotificationChannelCompat.Builder(CHANNEL_ID, NotificationManagerCompat.IMPORTANCE_LOW)
104+
.setName(getString(R.string.task_notification))
105+
.setVibrationEnabled(false)
106+
.build()
107+
NotificationManagerCompat.from(this).createNotificationChannel(channel)
108+
109+
val state = foregroundTaskState.value
110+
111+
if (state is ForegroundTaskState.InProgress) {
112+
val notification = NotificationCompat.Builder(this, CHANNEL_ID)
113+
.setContentTitle(title)
114+
.setProgress(100, state.progress, state.progress == 0)
115+
.setSmallIcon(iconRes)
116+
.setPriority(NotificationCompat.PRIORITY_LOW)
117+
.build()
118+
119+
if (state.progress == 0) {
120+
startForeground(FOREGROUND_ID, notification)
121+
} else if (checkSelfPermission(android.Manifest.permission.POST_NOTIFICATIONS) == PackageManager.PERMISSION_GRANTED) {
122+
NotificationManagerCompat.from(this).notify(FOREGROUND_ID, notification)
123+
}
124+
} else {
125+
stopForeground(STOP_FOREGROUND_REMOVE)
126+
}
127+
}
128+
129+
/**
130+
* Launch a potentially blocking foreground task in this service's lifecycle context.
131+
* This function does not block, but returns a Flow that emits ForegroundTaskState
132+
* updates associated with this task.
133+
* The task closure is expected to update foregroundTaskState whenever appropriate.
134+
* If a foreground task is already running, this function returns null.
135+
*/
136+
private fun launchForegroundTask(
137+
title: String,
138+
iconRes: Int,
139+
task: suspend EuiccChannelManagerService.() -> Unit
140+
): Flow<ForegroundTaskState>? {
141+
// Atomically set the state to InProgress. If this returns true, we are
142+
// the only task currently in progress.
143+
if (!foregroundTaskState.compareAndSet(
144+
ForegroundTaskState.Idle,
145+
ForegroundTaskState.InProgress(0)
146+
)
147+
) {
148+
return null
149+
}
150+
151+
lifecycleScope.launch(Dispatchers.Main) {
152+
// Wait until our self-start command has succeeded.
153+
// We can only call startForeground() after that
154+
foregroundStarted.first()
155+
updateForegroundNotification(title, iconRes)
156+
157+
try {
158+
withContext(Dispatchers.IO) {
159+
this@EuiccChannelManagerService.task()
160+
}
161+
foregroundTaskState.value = ForegroundTaskState.Done(null)
162+
} catch (t: Throwable) {
163+
foregroundTaskState.value = ForegroundTaskState.Done(t)
164+
} finally {
165+
stopSelf()
166+
}
167+
168+
updateForegroundNotification(title, iconRes)
169+
}
170+
171+
// We 've launched the coroutine, now we can self-start
172+
// This is required in order to use startForeground()
173+
// This will end up calling onStartCommand(), which will emit
174+
// into foregroundStarted and unblock the coroutine above
175+
startForegroundService(Intent(this, this::class.java))
176+
177+
// We should be the only task running, so we can subscribe to foregroundTaskState
178+
// until we encounter ForegroundTaskState.Done.
179+
return foregroundTaskState.transformWhile {
180+
// Also update our notification when we see an update
181+
updateForegroundNotification(title, iconRes)
182+
emit(it)
183+
it !is ForegroundTaskState.Done
184+
}.onCompletion { foregroundTaskState.value = ForegroundTaskState.Idle }
185+
}
186+
187+
fun launchProfileDownloadTask(
188+
slotId: Int,
189+
portId: Int,
190+
smdp: String,
191+
matchingId: String?,
192+
confirmationCode: String?,
193+
imei: String?
194+
): Flow<ForegroundTaskState>? =
195+
launchForegroundTask(
196+
getString(R.string.task_profile_download),
197+
R.drawable.ic_task_sim_card_download
198+
) {
199+
euiccChannelManager.beginTrackedOperationBlocking(slotId, portId) {
200+
val channel = euiccChannelManager.findEuiccChannelByPort(slotId, portId)
201+
val res = channel!!.lpa.downloadProfile(
202+
smdp,
203+
matchingId,
204+
imei,
205+
confirmationCode,
206+
object : ProfileDownloadCallback {
207+
override fun onStateUpdate(state: ProfileDownloadCallback.DownloadState) {
208+
if (state.progress == 0) return
209+
foregroundTaskState.value =
210+
ForegroundTaskState.InProgress(state.progress)
211+
}
212+
})
213+
214+
if (!res) {
215+
// TODO: Provide more details on the error
216+
throw RuntimeException("Failed to download profile; this is typically caused by another error happened before.")
217+
}
218+
219+
preferenceRepository.notificationDownloadFlow.first()
220+
}
221+
}
41222
}

app-common/src/main/java/im/angry/openeuicc/ui/BaseEuiccAccessActivity.kt

+3-2
Original file line numberDiff line numberDiff line change
@@ -14,11 +14,12 @@ import kotlinx.coroutines.CompletableDeferred
1414
abstract class BaseEuiccAccessActivity : AppCompatActivity() {
1515
val euiccChannelManagerLoaded = CompletableDeferred<Unit>()
1616
lateinit var euiccChannelManager: EuiccChannelManager
17+
lateinit var euiccChannelManagerService: EuiccChannelManagerService
1718

1819
private val euiccChannelManagerServiceConnection = object : ServiceConnection {
1920
override fun onServiceConnected(name: ComponentName?, service: IBinder?) {
20-
euiccChannelManager =
21-
(service!! as EuiccChannelManagerService.LocalBinder).service.euiccChannelManager
21+
euiccChannelManagerService = (service!! as EuiccChannelManagerService.LocalBinder).service
22+
euiccChannelManager = euiccChannelManagerService.euiccChannelManager
2223
euiccChannelManagerLoaded.complete(Unit)
2324
onInit()
2425
}

app-common/src/main/java/im/angry/openeuicc/ui/MainActivity.kt

+17
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,9 @@ import android.content.BroadcastReceiver
55
import android.content.Context
66
import android.content.Intent
77
import android.content.IntentFilter
8+
import android.content.pm.PackageManager
89
import android.hardware.usb.UsbManager
10+
import android.os.Build
911
import android.os.Bundle
1012
import android.telephony.TelephonyManager
1113
import android.util.Log
@@ -30,6 +32,8 @@ import kotlinx.coroutines.withContext
3032
open class MainActivity : BaseEuiccAccessActivity(), OpenEuiccContextMarker {
3133
companion object {
3234
const val TAG = "MainActivity"
35+
36+
const val PERMISSION_REQUEST_CODE = 1000
3337
}
3438

3539
private lateinit var loadingProgress: ProgressBar
@@ -116,6 +120,15 @@ open class MainActivity : BaseEuiccAccessActivity(), OpenEuiccContextMarker {
116120
}
117121
}
118122

123+
private fun ensureNotificationPermissions() {
124+
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU && checkSelfPermission(android.Manifest.permission.POST_NOTIFICATIONS) != PackageManager.PERMISSION_GRANTED) {
125+
requestPermissions(
126+
arrayOf(android.Manifest.permission.POST_NOTIFICATIONS),
127+
PERMISSION_REQUEST_CODE
128+
)
129+
}
130+
}
131+
119132
private suspend fun init(fromUsbEvent: Boolean = false) {
120133
refreshing = true // We don't check this here -- the check happens in refresh()
121134
loadingProgress.visibility = View.VISIBLE
@@ -173,6 +186,10 @@ open class MainActivity : BaseEuiccAccessActivity(), OpenEuiccContextMarker {
173186
viewPager.currentItem = 0
174187
}
175188

189+
if (pages.size > 0) {
190+
ensureNotificationPermissions()
191+
}
192+
176193
refreshing = false
177194
}
178195
}

app-common/src/main/java/im/angry/openeuicc/ui/ProfileDownloadFragment.kt

+18-22
Original file line numberDiff line numberDiff line change
@@ -18,12 +18,13 @@ import com.google.android.material.textfield.TextInputLayout
1818
import com.journeyapps.barcodescanner.ScanContract
1919
import com.journeyapps.barcodescanner.ScanOptions
2020
import im.angry.openeuicc.common.R
21+
import im.angry.openeuicc.service.EuiccChannelManagerService
2122
import im.angry.openeuicc.util.*
2223
import kotlinx.coroutines.Dispatchers
23-
import kotlinx.coroutines.flow.first
24+
import kotlinx.coroutines.flow.last
25+
import kotlinx.coroutines.flow.onEach
2426
import kotlinx.coroutines.launch
2527
import kotlinx.coroutines.withContext
26-
import net.typeblog.lpac_jni.ProfileDownloadCallback
2728
import kotlin.Exception
2829

2930
class ProfileDownloadFragment : BaseMaterialDialogFragment(),
@@ -224,30 +225,25 @@ class ProfileDownloadFragment : BaseMaterialDialogFragment(),
224225
code: String?,
225226
confirmationCode: String?,
226227
imei: String?
227-
) = beginTrackedOperation {
228-
val res = channel.lpa.downloadProfile(
228+
) = withContext(Dispatchers.Main) {
229+
// The service is responsible for launching the actual blocking part on the IO context
230+
val res = euiccChannelManagerService.launchProfileDownloadTask(
231+
slotId,
232+
portId,
229233
server,
230234
code,
231-
imei,
232235
confirmationCode,
233-
object : ProfileDownloadCallback {
234-
override fun onStateUpdate(state: ProfileDownloadCallback.DownloadState) {
235-
lifecycleScope.launch(Dispatchers.Main) {
236-
progress.isIndeterminate = false
237-
progress.progress = state.progress
238-
}
239-
}
240-
})
241-
242-
if (!res) {
243-
// TODO: Provide more details on the error
244-
throw RuntimeException("Failed to download profile; this is typically caused by another error happened before.")
245-
}
236+
imei
237+
)!!.onEach {
238+
progress.isIndeterminate = false
239+
if (it is EuiccChannelManagerService.ForegroundTaskState.InProgress) {
240+
progress.progress = it.progress
241+
} else {
242+
progress.progress = 100
243+
}
244+
}.last()
246245

247-
// If we get here, we are successful
248-
// This function is wrapped in beginTrackedOperation, so by returning the settings value,
249-
// We only send notifications if the user allowed us to
250-
preferenceRepository.notificationDownloadFlow.first()
246+
(res as? EuiccChannelManagerService.ForegroundTaskState.Done)?.error?.let { throw it }
251247
}
252248

253249
override fun onDismiss(dialog: DialogInterface) {

app-common/src/main/java/im/angry/openeuicc/util/EuiccChannelFragmentUtils.kt

+3
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import android.os.Bundle
44
import androidx.fragment.app.Fragment
55
import im.angry.openeuicc.core.EuiccChannel
66
import im.angry.openeuicc.core.EuiccChannelManager
7+
import im.angry.openeuicc.service.EuiccChannelManagerService
78
import im.angry.openeuicc.ui.BaseEuiccAccessActivity
89
import kotlinx.coroutines.Dispatchers
910
import kotlinx.coroutines.withContext
@@ -35,6 +36,8 @@ val <T> T.isUsb: Boolean where T: Fragment, T: EuiccChannelFragmentMarker
3536

3637
val <T> T.euiccChannelManager: EuiccChannelManager where T: Fragment, T: EuiccChannelFragmentMarker
3738
get() = (requireActivity() as BaseEuiccAccessActivity).euiccChannelManager
39+
val <T> T.euiccChannelManagerService: EuiccChannelManagerService where T: Fragment, T: EuiccChannelFragmentMarker
40+
get() = (requireActivity() as BaseEuiccAccessActivity).euiccChannelManagerService
3841
val <T> T.channel: EuiccChannel where T: Fragment, T: EuiccChannelFragmentMarker
3942
get() =
4043
euiccChannelManager.findEuiccChannelByPortBlocking(slotId, portId)!!
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
<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">
2+
3+
<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"/>
4+
5+
</vector>

app-common/src/main/res/values/strings.xml

+3
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,9 @@
3333
<string name="usb_permission_needed">Permission is needed to access the USB smart card reader.</string>
3434
<string name="usb_failed">Cannot connect to eSIM via a USB smart card reader.</string>
3535

36+
<string name="task_notification">Long-running Tasks</string>
37+
<string name="task_profile_download">Downloading eSIM profile</string>
38+
3639
<string name="profile_download">New eSIM</string>
3740
<string name="profile_download_server">Server (RSP / SM-DP+)</string>
3841
<string name="profile_download_code">Activation Code</string>

0 commit comments

Comments
 (0)