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