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