diff --git a/.gitignore b/.gitignore
index 2b15a47..1aa6f8a 100644
--- a/.gitignore
+++ b/.gitignore
@@ -1,20 +1,11 @@
-*.iml
-.gradle
-/local.properties
-/keystore.properties
-/.idea/caches
-/.idea/libraries
-/.idea/modules.xml
-/.idea/workspace.xml
-/.idea/navEditor.xml
-/.idea/assetWizardSettings.xml
-/.idea/deploymentTargetDropDown.xml
-.DS_Store
-/build
+/.gradle
/captures
-.externalNativeBuild
-.cxx
-local.properties
-/libs/**/build
-/buildSrc/build
-/app-deps/libs
\ No newline at end of file
+
+# Configuration files
+
+/keystore.properties
+/local.properties
+
+# macOS
+
+.DS_Store
diff --git a/.idea/.gitignore b/.idea/.gitignore
index 26d3352..0d51aca 100644
--- a/.idea/.gitignore
+++ b/.idea/.gitignore
@@ -1,3 +1,13 @@
-# Default ignored files
-/shelf/
+/shelf
+/caches
+/libraries
+/assetWizardSettings.xml
+/deploymentTargetDropDown.xml
+/gradle.xml
+/misc.xml
+/modules.xml
+/navEditor.xml
+/runConfigurations.xml
/workspace.xml
+
+**/*.iml
\ No newline at end of file
diff --git a/.idea/gradle.xml b/.idea/gradle.xml
deleted file mode 100644
index 589fc6c..0000000
--- a/.idea/gradle.xml
+++ /dev/null
@@ -1,39 +0,0 @@
-
-
-
-
-
-
-
\ No newline at end of file
diff --git a/.idea/misc.xml b/.idea/misc.xml
deleted file mode 100644
index a1f9d9f..0000000
--- a/.idea/misc.xml
+++ /dev/null
@@ -1,25 +0,0 @@
-
-
-
-
-
-
-
-
-
-
-
-
-
\ No newline at end of file
diff --git a/app-common/src/main/java/im/angry/openeuicc/core/LocalProfileAssistantWrapper.kt b/app-common/src/main/java/im/angry/openeuicc/core/LocalProfileAssistantWrapper.kt
index e6a648a..22ece46 100644
--- a/app-common/src/main/java/im/angry/openeuicc/core/LocalProfileAssistantWrapper.kt
+++ b/app-common/src/main/java/im/angry/openeuicc/core/LocalProfileAssistantWrapper.kt
@@ -46,7 +46,7 @@ class LocalProfileAssistantWrapper(orig: LocalProfileAssistant) :
imei: String?,
confirmationCode: String?,
callback: ProfileDownloadCallback
- ): Boolean = lpa.downloadProfile(smdp, matchingId, imei, confirmationCode, callback)
+ ) = lpa.downloadProfile(smdp, matchingId, imei, confirmationCode, callback)
override fun deleteNotification(seqNumber: Long): Boolean = lpa.deleteNotification(seqNumber)
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 9cfd526..c4d16df 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
@@ -15,16 +15,19 @@ import im.angry.openeuicc.core.EuiccChannelManager
import im.angry.openeuicc.util.*
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.NonCancellable
+import kotlinx.coroutines.channels.BufferOverflow
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.MutableStateFlow
+import kotlinx.coroutines.flow.SharedFlow
+import kotlinx.coroutines.flow.asSharedFlow
import kotlinx.coroutines.flow.collect
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.flow
import kotlinx.coroutines.flow.last
import kotlinx.coroutines.flow.onCompletion
-import kotlinx.coroutines.flow.onStart
+import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.flow.takeWhile
import kotlinx.coroutines.flow.transformWhile
import kotlinx.coroutines.isActive
@@ -65,6 +68,18 @@ class EuiccChannelManagerService : LifecycleService(), OpenEuiccContextMarker {
*/
suspend fun Flow.waitDone(): Throwable? =
(this.last() as ForegroundTaskState.Done).error
+
+ /**
+ * Apply transform to a ForegroundTaskState flow so that it completes when a Done is seen.
+ *
+ * This must be applied each time a flow is returned for subscription purposes. If applied
+ * beforehand, we lose the ability to subscribe multiple times.
+ */
+ private fun Flow.applyCompletionTransform() =
+ transformWhile {
+ emit(it)
+ it !is ForegroundTaskState.Done
+ }
}
inner class LocalBinder : Binder() {
@@ -98,6 +113,25 @@ class EuiccChannelManagerService : LifecycleService(), OpenEuiccContextMarker {
private val foregroundTaskState: MutableStateFlow =
MutableStateFlow(ForegroundTaskState.Idle)
+ /**
+ * A simple wrapper over a flow with taskId added.
+ *
+ * taskID is the exact millisecond-precision timestamp when the task is launched.
+ */
+ class ForegroundTaskSubscriberFlow(val taskId: Long, inner: Flow) :
+ Flow by inner
+
+ /**
+ * A cache of subscribers to 5 recently-launched foreground tasks, identified by ID
+ *
+ * Only one can be run at the same time, but those that are done will be kept in this
+ * map for a little while -- because UI components may be stopped and recreated while
+ * tasks are running. Having this buffer allows the components to re-subscribe even if
+ * the task completes while they are being recreated.
+ */
+ private val foregroundTaskSubscribers: MutableMap> =
+ mutableMapOf()
+
override fun onBind(intent: Intent): IBinder {
super.onBind(intent)
return LocalBinder()
@@ -175,12 +209,26 @@ class EuiccChannelManagerService : LifecycleService(), OpenEuiccContextMarker {
NotificationManagerCompat.from(this).notify(TASK_FAILURE_ID, notification)
}
+ /**
+ * Recover the subscriber to a foreground task that is recently launched.
+ *
+ * null if the task doesn't exist, or was launched too long ago.
+ */
+ fun recoverForegroundTaskSubscriber(taskId: Long): ForegroundTaskSubscriberFlow? =
+ foregroundTaskSubscribers[taskId]?.let {
+ ForegroundTaskSubscriberFlow(taskId, it.applyCompletionTransform())
+ }
+
/**
* 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 last update the returned flow will emit is
- * always ForegroundTaskState.Done. The returned flow MUST be started in order for the
- * foreground task to run.
+ * always ForegroundTaskState.Done.
+ *
+ * The returned flow can only be subscribed to once even though the underlying implementation
+ * is a SharedFlow. This is due to the need to apply transformations so that the stream
+ * actually completes. In order to subscribe multiple times, use `recoverForegroundTaskSubscriber`
+ * to acquire another instance.
*
* The task closure is expected to update foregroundTaskState whenever appropriate.
* If a foreground task is already running, this function returns null.
@@ -194,7 +242,9 @@ class EuiccChannelManagerService : LifecycleService(), OpenEuiccContextMarker {
failureTitle: String,
iconRes: Int,
task: suspend EuiccChannelManagerService.() -> Unit
- ): Flow {
+ ): ForegroundTaskSubscriberFlow {
+ val taskID = System.currentTimeMillis()
+
// Atomically set the state to InProgress. If this returns true, we are
// the only task currently in progress.
if (!foregroundTaskState.compareAndSet(
@@ -202,7 +252,9 @@ class EuiccChannelManagerService : LifecycleService(), OpenEuiccContextMarker {
ForegroundTaskState.InProgress(0)
)
) {
- return flow { emit(ForegroundTaskState.Done(IllegalStateException("There are tasks currently running"))) }
+ return ForegroundTaskSubscriberFlow(
+ taskID,
+ flow { emit(ForegroundTaskState.Done(IllegalStateException("There are tasks currently running"))) })
}
lifecycleScope.launch(Dispatchers.Main) {
@@ -244,34 +296,70 @@ class EuiccChannelManagerService : LifecycleService(), OpenEuiccContextMarker {
}
}
+ // This is the flow we are going to return. We allow multiple subscribers by
+ // re-emitting state updates into this flow from another coroutine.
+ // replay = 2 ensures that we at least have 1 previous state whenever subscribed to.
+ // This is helpful when the task completed and is then re-subscribed to due to a
+ // UI recreation event -- this way, the UI will know at least one last progress event
+ // before completion / failure
+ val subscriberFlow = MutableSharedFlow(
+ replay = 2,
+ onBufferOverflow = BufferOverflow.DROP_OLDEST
+ )
+
// We should be the only task running, so we can subscribe to foregroundTaskState
// until we encounter ForegroundTaskState.Done.
// Then, we complete the returned flow, but we also set the state back to Idle.
// The state update back to Idle won't show up in the returned stream, because
// it has been completed by that point.
- return foregroundTaskState.transformWhile {
- // Also update our notification when we see an update
- // But ignore the first progress = 0 update -- that is the current value.
- // we need that to be handled by the main coroutine after it finishes.
- if (it !is ForegroundTaskState.InProgress || it.progress != 0) {
- withContext(Dispatchers.Main) {
- updateForegroundNotification(title, iconRes)
+ lifecycleScope.launch(Dispatchers.Main) {
+ foregroundTaskState
+ .applyCompletionTransform()
+ .onEach {
+ // Also update our notification when we see an update
+ // But ignore the first progress = 0 update -- that is the current value.
+ // we need that to be handled by the main coroutine after it finishes.
+ if (it !is ForegroundTaskState.InProgress || it.progress != 0) {
+ updateForegroundNotification(title, iconRes)
+ }
+
+ subscriberFlow.emit(it)
}
+ .onCompletion {
+ // Reset state back to Idle when we are done.
+ // We do it here because otherwise Idle and Done might become conflated
+ // when emitted by the main coroutine in quick succession.
+ // Doing it here ensures we've seen Done. This Idle event won't be
+ // emitted to the consumer because the subscription has completed here.
+ foregroundTaskState.value = ForegroundTaskState.Idle
+ }
+ .collect()
+ }
+
+ foregroundTaskSubscribers[taskID] = subscriberFlow.asSharedFlow()
+
+ if (foregroundTaskSubscribers.size > 5) {
+ // Remove enough elements so that the size is kept at 5
+ for (key in foregroundTaskSubscribers.keys.sorted()
+ .take(foregroundTaskSubscribers.size - 5)) {
+ foregroundTaskSubscribers.remove(key)
}
- emit(it)
- it !is ForegroundTaskState.Done
- }.onStart {
- // When this Flow is started, we unblock the coroutine launched above by
- // self-starting as a foreground service.
- withContext(Dispatchers.Main) {
- startForegroundService(
- Intent(
- this@EuiccChannelManagerService,
- this@EuiccChannelManagerService::class.java
- )
- )
- }
- }.onCompletion { foregroundTaskState.value = ForegroundTaskState.Idle }
+ }
+
+ // Before we return, and after we have set everything up,
+ // self-start with foreground permission.
+ // This is going to unblock the main coroutine handling the task.
+ startForegroundService(
+ Intent(
+ this@EuiccChannelManagerService,
+ this@EuiccChannelManagerService::class.java
+ )
+ )
+
+ return ForegroundTaskSubscriberFlow(
+ taskID,
+ subscriberFlow.asSharedFlow().applyCompletionTransform()
+ )
}
val isForegroundTaskRunning: Boolean
@@ -289,14 +377,14 @@ class EuiccChannelManagerService : LifecycleService(), OpenEuiccContextMarker {
matchingId: String?,
confirmationCode: String?,
imei: String?
- ): Flow =
+ ): ForegroundTaskSubscriberFlow =
launchForegroundTask(
getString(R.string.task_profile_download),
getString(R.string.task_profile_download_failure),
R.drawable.ic_task_sim_card_download
) {
euiccChannelManager.beginTrackedOperation(slotId, portId) {
- val res = euiccChannelManager.withEuiccChannel(slotId, portId) { channel ->
+ euiccChannelManager.withEuiccChannel(slotId, portId) { channel ->
channel.lpa.downloadProfile(
smdp,
matchingId,
@@ -311,11 +399,6 @@ class EuiccChannelManagerService : LifecycleService(), OpenEuiccContextMarker {
})
}
- 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()
}
}
@@ -325,7 +408,7 @@ class EuiccChannelManagerService : LifecycleService(), OpenEuiccContextMarker {
portId: Int,
iccid: String,
name: String
- ): Flow =
+ ): ForegroundTaskSubscriberFlow =
launchForegroundTask(
getString(R.string.task_profile_rename),
getString(R.string.task_profile_rename_failure),
@@ -347,7 +430,7 @@ class EuiccChannelManagerService : LifecycleService(), OpenEuiccContextMarker {
slotId: Int,
portId: Int,
iccid: String
- ): Flow =
+ ): ForegroundTaskSubscriberFlow =
launchForegroundTask(
getString(R.string.task_profile_delete),
getString(R.string.task_profile_delete_failure),
@@ -370,7 +453,7 @@ class EuiccChannelManagerService : LifecycleService(), OpenEuiccContextMarker {
iccid: String,
enable: Boolean, // Enable or disable the profile indicated in iccid
reconnectTimeoutMillis: Long = 0 // 0 = do not wait for reconnect, useful for USB readers
- ): Flow =
+ ): ForegroundTaskSubscriberFlow =
launchForegroundTask(
getString(R.string.task_profile_switch),
getString(R.string.task_profile_switch_failure),
diff --git a/app-common/src/main/java/im/angry/openeuicc/ui/wizard/DownloadWizardActivity.kt b/app-common/src/main/java/im/angry/openeuicc/ui/wizard/DownloadWizardActivity.kt
index 408c55d..6d810cf 100644
--- a/app-common/src/main/java/im/angry/openeuicc/ui/wizard/DownloadWizardActivity.kt
+++ b/app-common/src/main/java/im/angry/openeuicc/ui/wizard/DownloadWizardActivity.kt
@@ -13,10 +13,19 @@ import androidx.fragment.app.Fragment
import im.angry.openeuicc.common.R
import im.angry.openeuicc.ui.BaseEuiccAccessActivity
import im.angry.openeuicc.util.*
+import net.typeblog.lpac_jni.LocalProfileAssistant
class DownloadWizardActivity: BaseEuiccAccessActivity() {
data class DownloadWizardState(
- var selectedLogicalSlot: Int
+ var currentStepFragmentClassName: String?,
+ var selectedLogicalSlot: Int,
+ var smdp: String,
+ var matchingId: String?,
+ var confirmationCode: String?,
+ var imei: String?,
+ var downloadStarted: Boolean,
+ var downloadTaskID: Long,
+ var downloadError: LocalProfileAssistant.ProfileDownloadException?,
)
private lateinit var state: DownloadWizardState
@@ -26,6 +35,12 @@ class DownloadWizardActivity: BaseEuiccAccessActivity() {
private lateinit var prevButton: Button
private var currentFragment: DownloadWizardStepFragment? = null
+ set(value) {
+ if (this::state.isInitialized) {
+ state.currentStepFragmentClassName = value?.javaClass?.name
+ }
+ field = value
+ }
override fun onCreate(savedInstanceState: Bundle?) {
enableEdgeToEdge()
@@ -33,18 +48,35 @@ class DownloadWizardActivity: BaseEuiccAccessActivity() {
setContentView(R.layout.activity_download_wizard)
onBackPressedDispatcher.addCallback(object : OnBackPressedCallback(true) {
override fun handleOnBackPressed() {
- // TODO: Actually implement this
+ // Make back == prev
+ onPrevPressed()
}
})
state = DownloadWizardState(
- intent.getIntExtra("selectedLogicalSlot", 0)
+ null,
+ intent.getIntExtra("selectedLogicalSlot", 0),
+ "",
+ null,
+ null,
+ null,
+ false,
+ -1,
+ null
)
progressBar = requireViewById(R.id.progress)
nextButton = requireViewById(R.id.download_wizard_next)
prevButton = requireViewById(R.id.download_wizard_back)
+ nextButton.setOnClickListener {
+ onNextPressed()
+ }
+
+ prevButton.setOnClickListener {
+ onPrevPressed()
+ }
+
val navigation = requireViewById(R.id.download_wizard_navigation)
val origHeight = navigation.layoutParams.height
@@ -71,14 +103,76 @@ class DownloadWizardActivity: BaseEuiccAccessActivity() {
}
}
- override fun onInit() {
- progressBar.visibility = View.GONE
- showFragment(DownloadWizardSlotSelectFragment())
+ override fun onSaveInstanceState(outState: Bundle) {
+ super.onSaveInstanceState(outState)
+ outState.putString("currentStepFragmentClassName", state.currentStepFragmentClassName)
+ outState.putInt("selectedLogicalSlot", state.selectedLogicalSlot)
+ outState.putString("smdp", state.smdp)
+ outState.putString("matchingId", state.matchingId)
+ outState.putString("confirmationCode", state.confirmationCode)
+ outState.putString("imei", state.imei)
+ outState.putBoolean("downloadStarted", state.downloadStarted)
+ outState.putLong("downloadTaskID", state.downloadTaskID)
}
- private fun showFragment(nextFrag: DownloadWizardStepFragment) {
+ override fun onRestoreInstanceState(savedInstanceState: Bundle) {
+ super.onRestoreInstanceState(savedInstanceState)
+ state.currentStepFragmentClassName = savedInstanceState.getString(
+ "currentStepFragmentClassName",
+ state.currentStepFragmentClassName
+ )
+ state.selectedLogicalSlot =
+ savedInstanceState.getInt("selectedLogicalSlot", state.selectedLogicalSlot)
+ state.smdp = savedInstanceState.getString("smdp", state.smdp)
+ state.matchingId = savedInstanceState.getString("matchingId", state.matchingId)
+ state.imei = savedInstanceState.getString("imei", state.imei)
+ state.downloadStarted =
+ savedInstanceState.getBoolean("downloadStarted", state.downloadStarted)
+ state.downloadTaskID = savedInstanceState.getLong("downloadTaskID", state.downloadTaskID)
+ }
+
+ private fun onPrevPressed() {
+ if (currentFragment?.hasPrev == true) {
+ val prevFrag = currentFragment?.createPrevFragment()
+ if (prevFrag == null) {
+ finish()
+ } else {
+ showFragment(prevFrag, R.anim.slide_in_left, R.anim.slide_out_right)
+ }
+ }
+ }
+
+ private fun onNextPressed() {
+ if (currentFragment?.hasNext == true) {
+ currentFragment?.beforeNext()
+ val nextFrag = currentFragment?.createNextFragment()
+ if (nextFrag == null) {
+ finish()
+ } else {
+ showFragment(nextFrag, R.anim.slide_in_right, R.anim.slide_out_left)
+ }
+ }
+ }
+
+ override fun onInit() {
+ progressBar.visibility = View.GONE
+
+ if (state.currentStepFragmentClassName != null) {
+ val clazz = Class.forName(state.currentStepFragmentClassName!!)
+ showFragment(clazz.getDeclaredConstructor().newInstance() as DownloadWizardStepFragment)
+ } else {
+ showFragment(DownloadWizardSlotSelectFragment())
+ }
+ }
+
+ private fun showFragment(
+ nextFrag: DownloadWizardStepFragment,
+ enterAnim: Int = 0,
+ exitAnim: Int = 0
+ ) {
currentFragment = nextFrag
- supportFragmentManager.beginTransaction().replace(R.id.step_fragment_container, nextFrag)
+ supportFragmentManager.beginTransaction().setCustomAnimations(enterAnim, exitAnim)
+ .replace(R.id.step_fragment_container, nextFrag)
.commit()
refreshButtons()
}
@@ -107,6 +201,15 @@ class DownloadWizardActivity: BaseEuiccAccessActivity() {
abstract fun createNextFragment(): DownloadWizardStepFragment?
abstract fun createPrevFragment(): DownloadWizardStepFragment?
+ protected fun gotoNextFragment(next: DownloadWizardStepFragment? = null) {
+ val realNext = next ?: createNextFragment()
+ (requireActivity() as DownloadWizardActivity).showFragment(
+ realNext!!,
+ R.anim.slide_in_right,
+ R.anim.slide_out_left
+ )
+ }
+
protected fun hideProgressBar() {
(requireActivity() as DownloadWizardActivity).progressBar.visibility = View.GONE
}
@@ -126,5 +229,7 @@ class DownloadWizardActivity: BaseEuiccAccessActivity() {
protected fun refreshButtons() {
(requireActivity() as DownloadWizardActivity).refreshButtons()
}
+
+ open fun beforeNext() {}
}
}
\ No newline at end of file
diff --git a/app-common/src/main/java/im/angry/openeuicc/ui/wizard/DownloadWizardDetailsFragment.kt b/app-common/src/main/java/im/angry/openeuicc/ui/wizard/DownloadWizardDetailsFragment.kt
new file mode 100644
index 0000000..eb36710
--- /dev/null
+++ b/app-common/src/main/java/im/angry/openeuicc/ui/wizard/DownloadWizardDetailsFragment.kt
@@ -0,0 +1,68 @@
+package im.angry.openeuicc.ui.wizard
+
+import android.os.Bundle
+import android.util.Patterns
+import android.view.LayoutInflater
+import android.view.View
+import android.view.ViewGroup
+import androidx.core.widget.addTextChangedListener
+import com.google.android.material.textfield.TextInputLayout
+import im.angry.openeuicc.common.R
+
+class DownloadWizardDetailsFragment : DownloadWizardActivity.DownloadWizardStepFragment() {
+ private var inputComplete = false
+
+ override val hasNext: Boolean
+ get() = inputComplete
+ override val hasPrev: Boolean
+ get() = true
+
+ private lateinit var smdp: TextInputLayout
+ private lateinit var matchingId: TextInputLayout
+ private lateinit var confirmationCode: TextInputLayout
+ private lateinit var imei: TextInputLayout
+
+ override fun beforeNext() {
+ state.smdp = smdp.editText!!.text.toString().trim()
+ // Treat empty inputs as null -- this is important for the download step
+ state.matchingId = matchingId.editText!!.text.toString().trim().ifBlank { null }
+ state.confirmationCode = confirmationCode.editText!!.text.toString().trim().ifBlank { null }
+ state.imei = imei.editText!!.text.toString().ifBlank { null }
+ }
+
+ override fun createNextFragment(): DownloadWizardActivity.DownloadWizardStepFragment =
+ DownloadWizardProgressFragment()
+
+ override fun createPrevFragment(): DownloadWizardActivity.DownloadWizardStepFragment =
+ DownloadWizardMethodSelectFragment()
+
+ override fun onCreateView(
+ inflater: LayoutInflater,
+ container: ViewGroup?,
+ savedInstanceState: Bundle?
+ ): View? {
+ val view = inflater.inflate(R.layout.fragment_download_details, container, false)
+ smdp = view.requireViewById(R.id.profile_download_server)
+ matchingId = view.requireViewById(R.id.profile_download_code)
+ confirmationCode = view.requireViewById(R.id.profile_download_confirmation_code)
+ imei = view.requireViewById(R.id.profile_download_imei)
+ smdp.editText!!.addTextChangedListener {
+ updateInputCompleteness()
+ }
+ return view
+ }
+
+ override fun onStart() {
+ super.onStart()
+ smdp.editText!!.setText(state.smdp)
+ matchingId.editText!!.setText(state.matchingId)
+ confirmationCode.editText!!.setText(state.confirmationCode)
+ imei.editText!!.setText(state.imei)
+ updateInputCompleteness()
+ }
+
+ private fun updateInputCompleteness() {
+ inputComplete = Patterns.DOMAIN_NAME.matcher(smdp.editText!!.text).matches()
+ refreshButtons()
+ }
+}
\ No newline at end of file
diff --git a/app-common/src/main/java/im/angry/openeuicc/ui/wizard/DownloadWizardDiagnosticsFragment.kt b/app-common/src/main/java/im/angry/openeuicc/ui/wizard/DownloadWizardDiagnosticsFragment.kt
new file mode 100644
index 0000000..6c578dd
--- /dev/null
+++ b/app-common/src/main/java/im/angry/openeuicc/ui/wizard/DownloadWizardDiagnosticsFragment.kt
@@ -0,0 +1,115 @@
+package im.angry.openeuicc.ui.wizard
+
+import android.os.Bundle
+import android.view.LayoutInflater
+import android.view.View
+import android.view.ViewGroup
+import android.widget.TextView
+import im.angry.openeuicc.common.R
+import im.angry.openeuicc.util.*
+
+class DownloadWizardDiagnosticsFragment : DownloadWizardActivity.DownloadWizardStepFragment() {
+ override val hasNext: Boolean
+ get() = true
+ override val hasPrev: Boolean
+ get() = false
+
+ private lateinit var diagnosticTextView: TextView
+
+ override fun createNextFragment(): DownloadWizardActivity.DownloadWizardStepFragment? = null
+
+ override fun createPrevFragment(): DownloadWizardActivity.DownloadWizardStepFragment? = null
+
+ override fun onCreateView(
+ inflater: LayoutInflater,
+ container: ViewGroup?,
+ savedInstanceState: Bundle?
+ ): View? {
+ val view = inflater.inflate(R.layout.fragment_download_diagnostics, container, false)
+ diagnosticTextView = view.requireViewById(R.id.download_wizard_diagnostics_text)
+ return view
+ }
+
+ override fun onStart() {
+ super.onStart()
+ val str = buildDiagnosticsText()
+ if (str == null) {
+ requireActivity().finish()
+ return
+ }
+
+ diagnosticTextView.text = str
+ }
+
+ private fun buildDiagnosticsText(): String? = state.downloadError?.let { err ->
+ val ret = StringBuilder()
+
+ err.lastHttpResponse?.let { resp ->
+ if (resp.rcode != 200) {
+ // Only show the status if it's not 200
+ // Because we can have errors even if the rcode is 200 due to SM-DP+ servers being dumb
+ // and showing 200 might mislead users
+ ret.appendLine(
+ getString(
+ R.string.download_wizard_diagnostics_last_http_status,
+ resp.rcode
+ )
+ )
+ ret.appendLine()
+ }
+
+ ret.appendLine(getString(R.string.download_wizard_diagnostics_last_http_response))
+ ret.appendLine()
+
+ val str = resp.data.decodeToString(throwOnInvalidSequence = false)
+ ret.appendLine(
+ if (str.startsWith('{')) {
+ str.prettyPrintJson()
+ } else {
+ str
+ }
+ )
+
+ ret.appendLine()
+ }
+
+ err.lastHttpException?.let { e ->
+ ret.appendLine(getString(R.string.download_wizard_diagnostics_last_http_exception))
+ ret.appendLine()
+ ret.appendLine("${e.javaClass.name}: ${e.message}")
+ ret.appendLine(e.stackTrace.joinToString("\n"))
+ ret.appendLine()
+ }
+
+ err.lastApduResponse?.let { resp ->
+ val isSuccess =
+ resp.size >= 2 && resp[resp.size - 2] == 0x90.toByte() && resp[resp.size - 1] == 0x00.toByte()
+
+ if (isSuccess) {
+ ret.appendLine(getString(R.string.download_wizard_diagnostics_last_apdu_response_success))
+ } else {
+ // Only show the full APDU response when it's a failure
+ // Otherwise it's going to get very crammed
+ ret.appendLine(
+ getString(
+ R.string.download_wizard_diagnostics_last_apdu_response,
+ resp.encodeHex()
+ )
+ )
+ ret.appendLine()
+
+ ret.appendLine(getString(R.string.download_wizard_diagnostics_last_apdu_response_fail))
+ }
+ }
+
+ err.lastApduException?.let { e ->
+ ret.appendLine(getString(R.string.download_wizard_diagnostics_last_apdu_exception))
+ ret.appendLine()
+ ret.appendLine("${e.javaClass.name}: ${e.message}")
+ ret.appendLine(e.stackTrace.joinToString("\n"))
+ ret.appendLine()
+ }
+
+ ret.toString()
+ }
+}
\ No newline at end of file
diff --git a/app-common/src/main/java/im/angry/openeuicc/ui/wizard/DownloadWizardMethodSelectFragment.kt b/app-common/src/main/java/im/angry/openeuicc/ui/wizard/DownloadWizardMethodSelectFragment.kt
new file mode 100644
index 0000000..4d8a38f
--- /dev/null
+++ b/app-common/src/main/java/im/angry/openeuicc/ui/wizard/DownloadWizardMethodSelectFragment.kt
@@ -0,0 +1,141 @@
+package im.angry.openeuicc.ui.wizard
+
+import android.graphics.BitmapFactory
+import android.os.Bundle
+import android.view.LayoutInflater
+import android.view.View
+import android.view.ViewGroup
+import android.widget.ImageView
+import android.widget.TextView
+import androidx.activity.result.contract.ActivityResultContracts
+import androidx.lifecycle.lifecycleScope
+import androidx.recyclerview.widget.DividerItemDecoration
+import androidx.recyclerview.widget.LinearLayoutManager
+import androidx.recyclerview.widget.RecyclerView
+import androidx.recyclerview.widget.RecyclerView.ViewHolder
+import com.journeyapps.barcodescanner.ScanContract
+import com.journeyapps.barcodescanner.ScanOptions
+import im.angry.openeuicc.common.R
+import im.angry.openeuicc.util.*
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.launch
+import kotlinx.coroutines.withContext
+
+class DownloadWizardMethodSelectFragment : DownloadWizardActivity.DownloadWizardStepFragment() {
+ data class DownloadMethod(
+ val iconRes: Int,
+ val titleRes: Int,
+ val onClick: () -> Unit
+ )
+
+ // TODO: Maybe we should find a better barcode scanner (or an external one?)
+ private val barcodeScannerLauncher = registerForActivityResult(ScanContract()) { result ->
+ result.contents?.let { content ->
+ processLpaString(content)
+ }
+ }
+
+ private val gallerySelectorLauncher =
+ registerForActivityResult(ActivityResultContracts.GetContent()) { result ->
+ if (result == null) return@registerForActivityResult
+
+ lifecycleScope.launch(Dispatchers.IO) {
+ runCatching {
+ requireContext().contentResolver.openInputStream(result)?.let { input ->
+ val bmp = BitmapFactory.decodeStream(input)
+ input.close()
+
+ decodeQrFromBitmap(bmp)?.let {
+ withContext(Dispatchers.Main) {
+ processLpaString(it)
+ }
+ }
+
+ bmp.recycle()
+ }
+ }
+ }
+ }
+
+ val downloadMethods = arrayOf(
+ DownloadMethod(R.drawable.ic_scan_black, R.string.download_wizard_method_qr_code) {
+ barcodeScannerLauncher.launch(ScanOptions().apply {
+ setDesiredBarcodeFormats(ScanOptions.QR_CODE)
+ setOrientationLocked(false)
+ })
+ },
+ DownloadMethod(R.drawable.ic_gallery_black, R.string.download_wizard_method_gallery) {
+ gallerySelectorLauncher.launch("image/*")
+ },
+ DownloadMethod(R.drawable.ic_edit, R.string.download_wizard_method_manual) {
+ gotoNextFragment(DownloadWizardDetailsFragment())
+ }
+ )
+
+ override val hasNext: Boolean
+ get() = false
+ override val hasPrev: Boolean
+ get() = true
+
+ override fun createNextFragment(): DownloadWizardActivity.DownloadWizardStepFragment? =
+ null
+
+ override fun createPrevFragment(): DownloadWizardActivity.DownloadWizardStepFragment =
+ DownloadWizardSlotSelectFragment()
+
+ override fun onCreateView(
+ inflater: LayoutInflater,
+ container: ViewGroup?,
+ savedInstanceState: Bundle?
+ ): View? {
+ val view = inflater.inflate(R.layout.fragment_download_method_select, container, false)
+ val recyclerView = view.requireViewById(R.id.download_method_list)
+ recyclerView.adapter = DownloadMethodAdapter()
+ recyclerView.layoutManager =
+ LinearLayoutManager(view.context, LinearLayoutManager.VERTICAL, false)
+ recyclerView.addItemDecoration(
+ DividerItemDecoration(
+ requireContext(),
+ LinearLayoutManager.VERTICAL
+ )
+ )
+ return view
+ }
+
+ private fun processLpaString(s: String) {
+ val components = s.split("$")
+ if (components.size < 3 || components[0] != "LPA:1") return
+ state.smdp = components[1]
+ state.matchingId = components[2]
+ gotoNextFragment(DownloadWizardDetailsFragment())
+ }
+
+ private class DownloadMethodViewHolder(private val root: View) : ViewHolder(root) {
+ private val icon = root.requireViewById(R.id.download_method_icon)
+ private val title = root.requireViewById(R.id.download_method_title)
+
+ fun bind(item: DownloadMethod) {
+ icon.setImageResource(item.iconRes)
+ title.setText(item.titleRes)
+ root.setOnClickListener { item.onClick() }
+ }
+ }
+
+ private inner class DownloadMethodAdapter : RecyclerView.Adapter() {
+ override fun onCreateViewHolder(
+ parent: ViewGroup,
+ viewType: Int
+ ): DownloadMethodViewHolder {
+ val view = LayoutInflater.from(parent.context)
+ .inflate(R.layout.download_method_item, parent, false)
+ return DownloadMethodViewHolder(view)
+ }
+
+ override fun getItemCount(): Int = downloadMethods.size
+
+ override fun onBindViewHolder(holder: DownloadMethodViewHolder, position: Int) {
+ holder.bind(downloadMethods[position])
+ }
+
+ }
+}
\ No newline at end of file
diff --git a/app-common/src/main/java/im/angry/openeuicc/ui/wizard/DownloadWizardProgressFragment.kt b/app-common/src/main/java/im/angry/openeuicc/ui/wizard/DownloadWizardProgressFragment.kt
new file mode 100644
index 0000000..f6f63fd
--- /dev/null
+++ b/app-common/src/main/java/im/angry/openeuicc/ui/wizard/DownloadWizardProgressFragment.kt
@@ -0,0 +1,239 @@
+package im.angry.openeuicc.ui.wizard
+
+import android.os.Bundle
+import android.view.LayoutInflater
+import android.view.View
+import android.view.ViewGroup
+import android.widget.ImageView
+import android.widget.ProgressBar
+import android.widget.TextView
+import androidx.lifecycle.lifecycleScope
+import androidx.recyclerview.widget.DividerItemDecoration
+import androidx.recyclerview.widget.LinearLayoutManager
+import androidx.recyclerview.widget.RecyclerView
+import im.angry.openeuicc.common.R
+import im.angry.openeuicc.service.EuiccChannelManagerService
+import im.angry.openeuicc.util.*
+import kotlinx.coroutines.flow.collect
+import kotlinx.coroutines.flow.onEach
+import kotlinx.coroutines.launch
+import net.typeblog.lpac_jni.LocalProfileAssistant
+import net.typeblog.lpac_jni.ProfileDownloadCallback
+
+class DownloadWizardProgressFragment : DownloadWizardActivity.DownloadWizardStepFragment() {
+ companion object {
+ /**
+ * An array of LPA-side state types, mapping 1:1 to progressItems
+ */
+ val LPA_PROGRESS_STATES = arrayOf(
+ ProfileDownloadCallback.DownloadState.Preparing,
+ ProfileDownloadCallback.DownloadState.Connecting,
+ ProfileDownloadCallback.DownloadState.Authenticating,
+ ProfileDownloadCallback.DownloadState.Downloading,
+ ProfileDownloadCallback.DownloadState.Finalizing,
+ )
+ }
+
+ private enum class ProgressState {
+ NotStarted,
+ InProgress,
+ Done,
+ Error
+ }
+
+ private data class ProgressItem(
+ val titleRes: Int,
+ var state: ProgressState
+ )
+
+ private val progressItems = arrayOf(
+ ProgressItem(R.string.download_wizard_progress_step_preparing, ProgressState.NotStarted),
+ ProgressItem(R.string.download_wizard_progress_step_connecting, ProgressState.NotStarted),
+ ProgressItem(
+ R.string.download_wizard_progress_step_authenticating,
+ ProgressState.NotStarted
+ ),
+ ProgressItem(R.string.download_wizard_progress_step_downloading, ProgressState.NotStarted),
+ ProgressItem(R.string.download_wizard_progress_step_finalizing, ProgressState.NotStarted)
+ )
+
+ private val adapter = ProgressItemAdapter()
+
+ private var isDone = false
+
+ override val hasNext: Boolean
+ get() = isDone
+ override val hasPrev: Boolean
+ get() = false
+
+ override fun createNextFragment(): DownloadWizardActivity.DownloadWizardStepFragment? =
+ if (state.downloadError != null) {
+ DownloadWizardDiagnosticsFragment()
+ } else {
+ null
+ }
+
+ override fun createPrevFragment(): DownloadWizardActivity.DownloadWizardStepFragment? = null
+
+ override fun onCreateView(
+ inflater: LayoutInflater,
+ container: ViewGroup?,
+ savedInstanceState: Bundle?
+ ): View? {
+ val view = inflater.inflate(R.layout.fragment_download_progress, container, false)
+ val recyclerView = view.requireViewById(R.id.download_progress_list)
+ recyclerView.adapter = adapter
+ recyclerView.layoutManager =
+ LinearLayoutManager(view.context, LinearLayoutManager.VERTICAL, false)
+ recyclerView.addItemDecoration(
+ DividerItemDecoration(
+ requireContext(),
+ LinearLayoutManager.VERTICAL
+ )
+ )
+ return view
+ }
+
+ override fun onStart() {
+ super.onStart()
+
+ lifecycleScope.launch {
+ showProgressBar(-1) // set indeterminate first
+ ensureEuiccChannelManager()
+
+ val subscriber = startDownloadOrSubscribe()
+
+ if (subscriber == null) {
+ requireActivity().finish()
+ return@launch
+ }
+
+ subscriber.onEach {
+ when (it) {
+ is EuiccChannelManagerService.ForegroundTaskState.Done -> {
+ hideProgressBar()
+
+ // Change the state of the last InProgress item to Error
+ progressItems.forEachIndexed { index, progressItem ->
+ if (progressItem.state == ProgressState.InProgress) {
+ progressItem.state = ProgressState.Error
+ }
+
+ adapter.notifyItemChanged(index)
+ }
+
+ state.downloadError =
+ it.error as? LocalProfileAssistant.ProfileDownloadException
+
+ isDone = true
+ refreshButtons()
+ }
+
+ is EuiccChannelManagerService.ForegroundTaskState.InProgress -> {
+ updateProgress(it.progress)
+ }
+
+ else -> {}
+ }
+ }.collect()
+ }
+ }
+
+ private suspend fun startDownloadOrSubscribe(): EuiccChannelManagerService.ForegroundTaskSubscriberFlow? =
+ if (state.downloadStarted) {
+ // This will also return null if task ID is -1 (uninitialized), too
+ euiccChannelManagerService.recoverForegroundTaskSubscriber(state.downloadTaskID)
+ } else {
+ euiccChannelManagerService.waitForForegroundTask()
+
+ val (slotId, portId) = euiccChannelManager.withEuiccChannel(state.selectedLogicalSlot) { channel ->
+ Pair(channel.slotId, channel.portId)
+ }
+
+ // Set started to true even before we start -- in case we get killed in the middle
+ state.downloadStarted = true
+
+ val ret = euiccChannelManagerService.launchProfileDownloadTask(
+ slotId,
+ portId,
+ state.smdp,
+ state.matchingId,
+ state.confirmationCode,
+ state.imei
+ )
+
+ state.downloadTaskID = ret.taskId
+
+ ret
+ }
+
+ private fun updateProgress(progress: Int) {
+ showProgressBar(progress)
+
+ val lpaState = ProfileDownloadCallback.lookupStateFromProgress(progress)
+ val stateIndex = LPA_PROGRESS_STATES.indexOf(lpaState)
+
+ if (stateIndex > 0) {
+ for (i in (0..(R.id.download_progress_item_title)
+ private val progressBar =
+ root.requireViewById(R.id.download_progress_icon_progress)
+ private val icon = root.requireViewById(R.id.download_progress_icon)
+
+ fun bind(item: ProgressItem) {
+ title.text = getString(item.titleRes)
+
+ when (item.state) {
+ ProgressState.NotStarted -> {
+ progressBar.visibility = View.GONE
+ icon.visibility = View.GONE
+ }
+
+ ProgressState.InProgress -> {
+ progressBar.visibility = View.VISIBLE
+ icon.visibility = View.GONE
+ }
+
+ ProgressState.Done -> {
+ progressBar.visibility = View.GONE
+ icon.setImageResource(R.drawable.ic_checkmark_outline)
+ icon.visibility = View.VISIBLE
+ }
+
+ ProgressState.Error -> {
+ progressBar.visibility = View.GONE
+ icon.setImageResource(R.drawable.ic_error_outline)
+ icon.visibility = View.VISIBLE
+ }
+ }
+ }
+ }
+
+ private inner class ProgressItemAdapter : RecyclerView.Adapter() {
+ override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): ProgressItemHolder {
+ val root = LayoutInflater.from(parent.context)
+ .inflate(R.layout.download_progress_item, parent, false)
+ return ProgressItemHolder(root)
+ }
+
+ override fun getItemCount(): Int = progressItems.size
+
+ override fun onBindViewHolder(holder: ProgressItemHolder, position: Int) {
+ holder.bind(progressItems[position])
+ }
+ }
+}
\ No newline at end of file
diff --git a/app-common/src/main/java/im/angry/openeuicc/ui/wizard/DownloadWizardSlotSelectFragment.kt b/app-common/src/main/java/im/angry/openeuicc/ui/wizard/DownloadWizardSlotSelectFragment.kt
index 6720242..c9a9e0f 100644
--- a/app-common/src/main/java/im/angry/openeuicc/ui/wizard/DownloadWizardSlotSelectFragment.kt
+++ b/app-common/src/main/java/im/angry/openeuicc/ui/wizard/DownloadWizardSlotSelectFragment.kt
@@ -26,6 +26,8 @@ class DownloadWizardSlotSelectFragment : DownloadWizardActivity.DownloadWizardSt
val hasMultiplePorts: Boolean,
val portId: Int,
val eID: String,
+ val freeSpace: Int,
+ val imei: String,
val enabledProfileName: String?
)
@@ -38,9 +40,8 @@ class DownloadWizardSlotSelectFragment : DownloadWizardActivity.DownloadWizardSt
override val hasPrev: Boolean
get() = true
- override fun createNextFragment(): DownloadWizardActivity.DownloadWizardStepFragment? {
- TODO("Not yet implemented")
- }
+ override fun createNextFragment(): DownloadWizardActivity.DownloadWizardStepFragment =
+ DownloadWizardMethodSelectFragment()
override fun createPrevFragment(): DownloadWizardActivity.DownloadWizardStepFragment? = null
@@ -65,7 +66,7 @@ class DownloadWizardSlotSelectFragment : DownloadWizardActivity.DownloadWizardSt
}
}
- @SuppressLint("NotifyDataSetChanged")
+ @SuppressLint("NotifyDataSetChanged", "MissingPermission")
private suspend fun init() {
ensureEuiccChannelManager()
showProgressBar(-1)
@@ -77,10 +78,16 @@ class DownloadWizardSlotSelectFragment : DownloadWizardActivity.DownloadWizardSt
channel.port.card.ports.size > 1,
channel.portId,
channel.lpa.eID,
+ channel.lpa.euiccInfo2?.freeNvram ?: 0,
+ try {
+ telephonyManager.getImei(channel.logicalSlotId) ?: ""
+ } catch (e: Exception) {
+ ""
+ },
channel.lpa.profiles.find { it.state == LocalProfileInfo.State.Enabled }?.displayName
)
}
- }.toList()
+ }.toList().sortedBy { it.logicalSlotId }
adapter.slots = slots
// Ensure we always have a selected slot by default
@@ -94,6 +101,10 @@ class DownloadWizardSlotSelectFragment : DownloadWizardActivity.DownloadWizardSt
0
}
+ if (slots.isNotEmpty()) {
+ state.imei = slots[adapter.currentSelectedIdx].imei
+ }
+
adapter.notifyDataSetChanged()
hideProgressBar()
loaded = true
@@ -105,6 +116,7 @@ class DownloadWizardSlotSelectFragment : DownloadWizardActivity.DownloadWizardSt
private val type = root.requireViewById(R.id.slot_item_type)
private val eID = root.requireViewById(R.id.slot_item_eid)
private val activeProfile = root.requireViewById(R.id.slot_item_active_profile)
+ private val freeSpace = root.requireViewById(R.id.slot_item_free_space)
private val checkBox = root.requireViewById(R.id.slot_checkbox)
private var curIdx = -1
@@ -124,6 +136,7 @@ class DownloadWizardSlotSelectFragment : DownloadWizardActivity.DownloadWizardSt
adapter.notifyItemChanged(curIdx)
// Selected index isn't logical slot ID directly, needs a conversion
state.selectedLogicalSlot = adapter.slots[adapter.currentSelectedIdx].logicalSlotId
+ state.imei = adapter.slots[adapter.currentSelectedIdx].imei
}
fun bind(item: SlotInfo, idx: Int) {
@@ -143,6 +156,7 @@ class DownloadWizardSlotSelectFragment : DownloadWizardActivity.DownloadWizardSt
title.text = root.context.getString(R.string.download_wizard_slot_title, item.logicalSlotId)
eID.text = item.eID
activeProfile.text = item.enabledProfileName ?: root.context.getString(R.string.unknown)
+ freeSpace.text = formatFreeSpace(item.freeSpace)
checkBox.isChecked = adapter.currentSelectedIdx == idx
}
}
diff --git a/app-common/src/main/java/im/angry/openeuicc/util/StringUtils.kt b/app-common/src/main/java/im/angry/openeuicc/util/StringUtils.kt
index ebf8729..8d72462 100644
--- a/app-common/src/main/java/im/angry/openeuicc/util/StringUtils.kt
+++ b/app-common/src/main/java/im/angry/openeuicc/util/StringUtils.kt
@@ -27,4 +27,74 @@ fun formatFreeSpace(size: Int): String =
"%.2f KiB".format(size.toDouble() / 1024)
} else {
"$size B"
- }
\ No newline at end of file
+ }
+
+fun String.prettyPrintJson(): String {
+ val ret = StringBuilder()
+ var inQuotes = false
+ var escaped = false
+ val indentSymbolStack = ArrayDeque()
+
+ val addNewLine = {
+ ret.append('\n')
+ repeat(indentSymbolStack.size) {
+ ret.append('\t')
+ }
+ }
+
+ var lastChar = ' '
+
+ for (c in this) {
+ when {
+ !inQuotes && (c == '{' || c == '[') -> {
+ ret.append(c)
+ indentSymbolStack.addLast(c)
+ addNewLine()
+ }
+
+ !inQuotes && (c == '}' || c == ']') -> {
+ indentSymbolStack.removeLast()
+ if (lastChar != ',') {
+ addNewLine()
+ }
+ ret.append(c)
+ }
+
+ !inQuotes && c == ',' -> {
+ ret.append(c)
+ addNewLine()
+ }
+
+ !inQuotes && c == ':' -> {
+ ret.append(c)
+ ret.append(' ')
+ }
+
+ inQuotes && c == '\\' -> {
+ ret.append(c)
+ escaped = true
+ continue
+ }
+
+ !escaped && c == '"' -> {
+ ret.append(c)
+ inQuotes = !inQuotes
+ }
+
+ !inQuotes && c == ' ' -> {
+ // Do nothing -- we ignore spaces outside of quotes by default
+ // This is to ensure predictable formatting
+ }
+
+ else -> ret.append(c)
+ }
+
+ if (escaped) {
+ escaped = false
+ }
+
+ lastChar = c
+ }
+
+ return ret.toString()
+}
\ No newline at end of file
diff --git a/app-common/src/main/res/anim/slide_in_left.xml b/app-common/src/main/res/anim/slide_in_left.xml
new file mode 100644
index 0000000..9078d1f
--- /dev/null
+++ b/app-common/src/main/res/anim/slide_in_left.xml
@@ -0,0 +1,6 @@
+
+
diff --git a/app-common/src/main/res/anim/slide_in_right.xml b/app-common/src/main/res/anim/slide_in_right.xml
new file mode 100644
index 0000000..42aa3f5
--- /dev/null
+++ b/app-common/src/main/res/anim/slide_in_right.xml
@@ -0,0 +1,6 @@
+
+
diff --git a/app-common/src/main/res/anim/slide_out_left.xml b/app-common/src/main/res/anim/slide_out_left.xml
new file mode 100644
index 0000000..1a806a9
--- /dev/null
+++ b/app-common/src/main/res/anim/slide_out_left.xml
@@ -0,0 +1,6 @@
+
+
diff --git a/app-common/src/main/res/anim/slide_out_right.xml b/app-common/src/main/res/anim/slide_out_right.xml
new file mode 100644
index 0000000..f209f38
--- /dev/null
+++ b/app-common/src/main/res/anim/slide_out_right.xml
@@ -0,0 +1,6 @@
+
+
diff --git a/app-unpriv/src/main/res/drawable/ic_checkmark_outline.xml b/app-common/src/main/res/drawable/ic_checkmark_outline.xml
similarity index 100%
rename from app-unpriv/src/main/res/drawable/ic_checkmark_outline.xml
rename to app-common/src/main/res/drawable/ic_checkmark_outline.xml
diff --git a/app-common/src/main/res/drawable/ic_edit.xml b/app-common/src/main/res/drawable/ic_edit.xml
new file mode 100644
index 0000000..3c53db7
--- /dev/null
+++ b/app-common/src/main/res/drawable/ic_edit.xml
@@ -0,0 +1,5 @@
+
+
+
+
+
diff --git a/app-unpriv/src/main/res/drawable/ic_error_outline.xml b/app-common/src/main/res/drawable/ic_error_outline.xml
similarity index 100%
rename from app-unpriv/src/main/res/drawable/ic_error_outline.xml
rename to app-common/src/main/res/drawable/ic_error_outline.xml
diff --git a/app-common/src/main/res/layout/download_method_item.xml b/app-common/src/main/res/layout/download_method_item.xml
new file mode 100644
index 0000000..5b2c2a8
--- /dev/null
+++ b/app-common/src/main/res/layout/download_method_item.xml
@@ -0,0 +1,44 @@
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/app-common/src/main/res/layout/download_progress_item.xml b/app-common/src/main/res/layout/download_progress_item.xml
new file mode 100644
index 0000000..f1d0852
--- /dev/null
+++ b/app-common/src/main/res/layout/download_progress_item.xml
@@ -0,0 +1,45 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/app-common/src/main/res/layout/download_slot_item.xml b/app-common/src/main/res/layout/download_slot_item.xml
index fa06b4c..d0ca176 100644
--- a/app-common/src/main/res/layout/download_slot_item.xml
+++ b/app-common/src/main/res/layout/download_slot_item.xml
@@ -61,6 +61,20 @@
android:layout_height="wrap_content"
android:textSize="14sp" />
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/app-common/src/main/res/layout/fragment_download_diagnostics.xml b/app-common/src/main/res/layout/fragment_download_diagnostics.xml
new file mode 100644
index 0000000..b9a0bc2
--- /dev/null
+++ b/app-common/src/main/res/layout/fragment_download_diagnostics.xml
@@ -0,0 +1,48 @@
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/app-common/src/main/res/layout/fragment_download_method_select.xml b/app-common/src/main/res/layout/fragment_download_method_select.xml
new file mode 100644
index 0000000..a57e186
--- /dev/null
+++ b/app-common/src/main/res/layout/fragment_download_method_select.xml
@@ -0,0 +1,33 @@
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/app-common/src/main/res/layout/fragment_download_progress.xml b/app-common/src/main/res/layout/fragment_download_progress.xml
new file mode 100644
index 0000000..0ec58e4
--- /dev/null
+++ b/app-common/src/main/res/layout/fragment_download_progress.xml
@@ -0,0 +1,33 @@
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/app-common/src/main/res/layout/fragment_download_slot_select.xml b/app-common/src/main/res/layout/fragment_download_slot_select.xml
index 6bd2e5d..3dfe6fd 100644
--- a/app-common/src/main/res/layout/fragment_download_slot_select.xml
+++ b/app-common/src/main/res/layout/fragment_download_slot_select.xml
@@ -9,10 +9,15 @@
android:text="@string/download_wizard_slot_select"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
+ android:gravity="center_horizontal"
android:textSize="20sp"
- android:layout_margin="20sp"
+ android:layout_marginTop="20sp"
+ android:layout_marginBottom="20sp"
+ android:layout_marginStart="60sp"
+ android:layout_marginEnd="60sp"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintEnd_toEndOf="parent"
+ app:layout_constrainedWidth="true"
app:layout_constraintTop_toTopOf="parent" />
Download Wizard
Back
Next
- Confirm the eSIM slot:
+ Select or confirm the eSIM you would like to download to:
Logical slot %d
Type:
Removable
@@ -69,6 +69,26 @@
Internal, port %d
eID:
Active Profile:
+ Free Space:
+ How would you like to download the eSIM profile?
+ Scan a QR code with camera
+ Load a QR code from gallery
+ Enter manually
+ Input or confirm details for downloading your eSIM:
+ Downloading your eSIM…
+ Preparing
+ Establishing connection to server
+ Authenticating your device with server
+ Downloading eSIM profile
+ Loading eSIM profile into storage
+ Error diagnostics
+ Last HTTP status (from server): %d
+ Last HTTP response (from server):
+ Last HTTP exception:
+ Last APDU response (from SIM): %s
+ Last APDU response (from SIM) is successful
+ Last APDU response (from SIM) is a failure
+ Last APDU exception:
New nickname
diff --git a/app-deps/.gitignore b/app-deps/.gitignore
index 42afabf..c23e5a2 100644
--- a/app-deps/.gitignore
+++ b/app-deps/.gitignore
@@ -1 +1,2 @@
-/build
\ No newline at end of file
+/build
+/libs
\ No newline at end of file
diff --git a/buildSrc/.gitignore b/buildSrc/.gitignore
new file mode 100644
index 0000000..6fbe8a4
--- /dev/null
+++ b/buildSrc/.gitignore
@@ -0,0 +1,2 @@
+/.gradle
+/build
\ No newline at end of file
diff --git a/libs/lpac-jni/.gitignore b/libs/lpac-jni/.gitignore
new file mode 100644
index 0000000..42afabf
--- /dev/null
+++ b/libs/lpac-jni/.gitignore
@@ -0,0 +1 @@
+/build
\ No newline at end of file
diff --git a/libs/lpac-jni/src/main/java/net/typeblog/lpac_jni/LocalProfileAssistant.kt b/libs/lpac-jni/src/main/java/net/typeblog/lpac_jni/LocalProfileAssistant.kt
index f256caf..cf870e1 100644
--- a/libs/lpac-jni/src/main/java/net/typeblog/lpac_jni/LocalProfileAssistant.kt
+++ b/libs/lpac-jni/src/main/java/net/typeblog/lpac_jni/LocalProfileAssistant.kt
@@ -1,6 +1,16 @@
package net.typeblog.lpac_jni
+import net.typeblog.lpac_jni.HttpInterface.HttpResponse
+
interface LocalProfileAssistant {
+ @Suppress("ArrayInDataClass")
+ data class ProfileDownloadException(
+ val lastHttpResponse: HttpResponse?,
+ val lastHttpException: Exception?,
+ val lastApduResponse: ByteArray?,
+ val lastApduException: Exception?,
+ ) : Exception("Failed to download profile")
+
val valid: Boolean
val profiles: List
val notifications: List
@@ -22,7 +32,7 @@ interface LocalProfileAssistant {
fun deleteProfile(iccid: String): Boolean
fun downloadProfile(smdp: String, matchingId: String?, imei: String?,
- confirmationCode: String?, callback: ProfileDownloadCallback): Boolean
+ confirmationCode: String?, callback: ProfileDownloadCallback)
fun deleteNotification(seqNumber: Long): Boolean
fun handleNotification(seqNumber: Long): Boolean
diff --git a/libs/lpac-jni/src/main/java/net/typeblog/lpac_jni/ProfileDownloadCallback.kt b/libs/lpac-jni/src/main/java/net/typeblog/lpac_jni/ProfileDownloadCallback.kt
index 579ad58..289ddf6 100644
--- a/libs/lpac-jni/src/main/java/net/typeblog/lpac_jni/ProfileDownloadCallback.kt
+++ b/libs/lpac-jni/src/main/java/net/typeblog/lpac_jni/ProfileDownloadCallback.kt
@@ -1,6 +1,18 @@
package net.typeblog.lpac_jni
interface ProfileDownloadCallback {
+ companion object {
+ fun lookupStateFromProgress(progress: Int): DownloadState =
+ when (progress) {
+ 0 -> DownloadState.Preparing
+ 20 -> DownloadState.Connecting
+ 40 -> DownloadState.Authenticating
+ 60 -> DownloadState.Downloading
+ 80 -> DownloadState.Finalizing
+ else -> throw IllegalArgumentException("Unknown state")
+ }
+ }
+
enum class DownloadState(val progress: Int) {
Preparing(0),
Connecting(20), // Before {server,client} authentication
diff --git a/libs/lpac-jni/src/main/java/net/typeblog/lpac_jni/impl/LocalProfileAssistantImpl.kt b/libs/lpac-jni/src/main/java/net/typeblog/lpac_jni/impl/LocalProfileAssistantImpl.kt
index 51039cd..70606d9 100644
--- a/libs/lpac-jni/src/main/java/net/typeblog/lpac_jni/impl/LocalProfileAssistantImpl.kt
+++ b/libs/lpac-jni/src/main/java/net/typeblog/lpac_jni/impl/LocalProfileAssistantImpl.kt
@@ -5,19 +5,76 @@ import net.typeblog.lpac_jni.LpacJni
import net.typeblog.lpac_jni.ApduInterface
import net.typeblog.lpac_jni.EuiccInfo2
import net.typeblog.lpac_jni.HttpInterface
+import net.typeblog.lpac_jni.HttpInterface.HttpResponse
import net.typeblog.lpac_jni.LocalProfileAssistant
import net.typeblog.lpac_jni.LocalProfileInfo
import net.typeblog.lpac_jni.LocalProfileNotification
import net.typeblog.lpac_jni.ProfileDownloadCallback
class LocalProfileAssistantImpl(
- private val apduInterface: ApduInterface,
- httpInterface: HttpInterface
+ rawApduInterface: ApduInterface,
+ rawHttpInterface: HttpInterface
): LocalProfileAssistant {
companion object {
private const val TAG = "LocalProfileAssistantImpl"
}
+ /**
+ * A thin wrapper over ApduInterface to acquire exceptions and errors transparently
+ */
+ private class ApduInterfaceWrapper(val apduInterface: ApduInterface) :
+ ApduInterface by apduInterface {
+ var lastApduResponse: ByteArray? = null
+ var lastApduException: Exception? = null
+
+ override fun transmit(tx: ByteArray): ByteArray =
+ try {
+ apduInterface.transmit(tx).also {
+ lastApduException = null
+ lastApduResponse = it
+ }
+ } catch (e: Exception) {
+ lastApduResponse = null
+ lastApduException = e
+ throw e
+ }
+ }
+
+ /**
+ * Same for HTTP for diagnostics
+ */
+ private class HttpInterfaceWrapper(val httpInterface: HttpInterface) :
+ HttpInterface by httpInterface {
+ /**
+ * The last HTTP response we have received from the SM-DP+ server.
+ *
+ * This is intended for error diagnosis. However, note that most SM-DP+ servers
+ * respond with 200 even when there is an error. This needs to be taken into
+ * account when designing UI.
+ */
+ var lastHttpResponse: HttpResponse? = null
+
+ /**
+ * The last exception that has been thrown during a HTTP connection
+ */
+ var lastHttpException: Exception? = null
+
+ override fun transmit(url: String, tx: ByteArray, headers: Array): HttpResponse =
+ try {
+ httpInterface.transmit(url, tx, headers).also {
+ lastHttpException = null
+ lastHttpResponse = it
+ }
+ } catch (e: Exception) {
+ lastHttpResponse = null
+ lastHttpException = e
+ throw e
+ }
+ }
+
+ private val apduInterface = ApduInterfaceWrapper(rawApduInterface)
+ private val httpInterface = HttpInterfaceWrapper(rawHttpInterface)
+
private var finalized = false
private var contextHandle: Long = LpacJni.createContext(apduInterface, httpInterface)
@@ -144,15 +201,24 @@ class LocalProfileAssistantImpl(
@Synchronized
override fun downloadProfile(smdp: String, matchingId: String?, imei: String?,
- confirmationCode: String?, callback: ProfileDownloadCallback): Boolean {
- return LpacJni.downloadProfile(
+ confirmationCode: String?, callback: ProfileDownloadCallback) {
+ val res = LpacJni.downloadProfile(
contextHandle,
smdp,
matchingId,
imei,
confirmationCode,
callback
- ) == 0
+ )
+
+ if (res != 0) {
+ throw LocalProfileAssistant.ProfileDownloadException(
+ httpInterface.lastHttpResponse,
+ httpInterface.lastHttpException,
+ apduInterface.lastApduResponse,
+ apduInterface.lastApduException,
+ )
+ }
}
@Synchronized