From 879cb1a105e899a4e381f6ec758da20c1b9081f5 Mon Sep 17 00:00:00 2001 From: septs Date: Thu, 6 Feb 2025 22:08:40 +0800 Subject: [PATCH 1/3] refactor: download wizard --- .../service/EuiccChannelManagerService.kt | 13 +- .../ui/wizard/DownloadWizardActivity.kt | 66 ++++----- .../wizard/DownloadWizardDetailsFragment.kt | 57 +++---- .../DownloadWizardMethodSelectFragment.kt | 8 +- .../wizard/DownloadWizardProgressFragment.kt | 48 ++---- .../DownloadWizardSlotSelectFragment.kt | 4 +- .../im/angry/openeuicc/util/ActivationCode.kt | 139 +++++++++++++++--- .../openeuicc/util/ActivationCodeTest.kt | 65 ++++++++ 8 files changed, 272 insertions(+), 128 deletions(-) create mode 100644 app-common/src/test/java/im/angry/openeuicc/util/ActivationCodeTest.kt 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 760f1af..5414dad 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 @@ -370,10 +370,7 @@ class EuiccChannelManagerService : LifecycleService(), OpenEuiccContextMarker { fun launchProfileDownloadTask( slotId: Int, portId: Int, - smdp: String, - matchingId: String?, - confirmationCode: String?, - imei: String? + activationCode: ActivationCode ): ForegroundTaskSubscriberFlow = launchForegroundTask( getString(R.string.task_profile_download), @@ -383,10 +380,10 @@ class EuiccChannelManagerService : LifecycleService(), OpenEuiccContextMarker { euiccChannelManager.beginTrackedOperation(slotId, portId) { euiccChannelManager.withEuiccChannel(slotId, portId) { channel -> channel.lpa.downloadProfile( - smdp, - matchingId, - imei, - confirmationCode, + activationCode.address, + activationCode.matchingId, + activationCode.imei, + activationCode.confirmationCode, object : ProfileDownloadCallback { override fun onStateUpdate(state: ProfileDownloadCallback.DownloadState) { if (state.progress == 0) return 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 e342dee..0c81bac 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 @@ -1,5 +1,6 @@ package im.angry.openeuicc.ui.wizard +import android.os.Build import android.os.Bundle import android.view.View import android.view.inputmethod.InputMethodManager @@ -19,17 +20,13 @@ import im.angry.openeuicc.ui.BaseEuiccAccessActivity import im.angry.openeuicc.util.* import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch -import kotlinx.coroutines.withContext import net.typeblog.lpac_jni.LocalProfileAssistant class DownloadWizardActivity: BaseEuiccAccessActivity() { data class DownloadWizardState( var currentStepFragmentClassName: String?, var selectedLogicalSlot: Int, - var smdp: String, - var matchingId: String?, - var confirmationCode: String?, - var imei: String?, + var activationCode: ActivationCode, var downloadStarted: Boolean, var downloadTaskID: Long, var downloadError: LocalProfileAssistant.ProfileDownloadException?, @@ -61,15 +58,12 @@ class DownloadWizardActivity: BaseEuiccAccessActivity() { }) state = DownloadWizardState( - null, - intent.getIntExtra("selectedLogicalSlot", 0), - "", - null, - null, - null, - false, - -1, - null + currentStepFragmentClassName = null, + selectedLogicalSlot = intent.getIntExtra("selectedLogicalSlot", 0), + activationCode = ActivationCode(), + downloadStarted = false, + downloadTaskID = -1, + downloadError = null ) progressBar = requireViewById(R.id.progress) @@ -115,28 +109,24 @@ class DownloadWizardActivity: BaseEuiccAccessActivity() { 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.putParcelable("activationCode", state.activationCode) outState.putBoolean("downloadStarted", state.downloadStarted) outState.putLong("downloadTaskID", state.downloadTaskID) } 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) + state.currentStepFragmentClassName = savedInstanceState + .getString("currentStepFragmentClassName", state.currentStepFragmentClassName) + state.selectedLogicalSlot = savedInstanceState + .getInt("selectedLogicalSlot", state.selectedLogicalSlot) + state.activationCode = (if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) + savedInstanceState.getParcelable("activationCode", ActivationCode::class.java) else + savedInstanceState.getParcelable("activationCode")) ?: ActivationCode() + state.downloadStarted = savedInstanceState + .getBoolean("downloadStarted", state.downloadStarted) + state.downloadTaskID = savedInstanceState + .getLong("downloadTaskID", state.downloadTaskID) } private fun onPrevPressed() { @@ -238,8 +228,11 @@ class DownloadWizardActivity: BaseEuiccAccessActivity() { } abstract class DownloadWizardStepFragment : Fragment(), OpenEuiccContextMarker { + private val activity: DownloadWizardActivity + get() = requireActivity() as DownloadWizardActivity + protected val state: DownloadWizardState - get() = (requireActivity() as DownloadWizardActivity).state + get() = activity.state abstract val hasNext: Boolean abstract val hasPrev: Boolean @@ -247,20 +240,19 @@ class DownloadWizardActivity: BaseEuiccAccessActivity() { abstract fun createPrevFragment(): DownloadWizardStepFragment? protected fun gotoNextFragment(next: DownloadWizardStepFragment? = null) { - val realNext = next ?: createNextFragment() - (requireActivity() as DownloadWizardActivity).showFragment( - realNext!!, + activity.showFragment( + next ?: createNextFragment()!!, R.anim.slide_in_right, R.anim.slide_out_left ) } protected fun hideProgressBar() { - (requireActivity() as DownloadWizardActivity).progressBar.visibility = View.GONE + activity.progressBar.visibility = View.GONE } protected fun showProgressBar(progressValue: Int) { - (requireActivity() as DownloadWizardActivity).progressBar.apply { + activity.progressBar.apply { visibility = View.VISIBLE if (progressValue >= 0) { isIndeterminate = false @@ -272,7 +264,7 @@ class DownloadWizardActivity: BaseEuiccAccessActivity() { } protected fun refreshButtons() { - (requireActivity() as DownloadWizardActivity).refreshButtons() + activity.refreshButtons() } open fun beforeNext() {} 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 index 5fa8002..0abe1c3 100644 --- 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 @@ -1,10 +1,10 @@ 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 android.widget.EditText import androidx.core.widget.addTextChangedListener import com.google.android.material.textfield.TextInputLayout import im.angry.openeuicc.common.R @@ -17,17 +17,19 @@ class DownloadWizardDetailsFragment : DownloadWizardActivity.DownloadWizardStepF 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 + private lateinit var address: EditText + private lateinit var matchingId: EditText + private lateinit var confirmationCode: EditText + private lateinit var imei: EditText private fun saveState() { - 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 } + state.activationCode.let { + it.address = address.text.toString().trim() + // Treat empty inputs as null -- this is important for the download step + it.matchingId = matchingId.text.toString().trim() + it.confirmationCode = confirmationCode.text.toString().trim() + it.imei = imei.text.toString().trim() + } } override fun beforeNext() = saveState() @@ -42,24 +44,23 @@ class DownloadWizardDetailsFragment : DownloadWizardActivity.DownloadWizardStepF 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 + ): View = inflater.inflate(R.layout.fragment_download_details, container, false).apply { + address = requireViewById(R.id.profile_download_server).editText!! + matchingId = requireViewById(R.id.profile_download_code).editText!! + confirmationCode = requireViewById(R.id.profile_download_confirmation_code).editText!! + imei = requireViewById(R.id.profile_download_imei).editText!! + + address.addTextChangedListener { updateInputCompleteness() } } 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) + state.activationCode.let { + address.setText(it.address) + matchingId.setText(it.matchingId) + confirmationCode.setText(it.confirmationCode) + imei.setText(it.imei) + } updateInputCompleteness() } @@ -69,7 +70,13 @@ class DownloadWizardDetailsFragment : DownloadWizardActivity.DownloadWizardStepF } private fun updateInputCompleteness() { - inputComplete = Patterns.DOMAIN_NAME.matcher(smdp.editText!!.text).matches() + saveState() + inputComplete = try { + state.activationCode.validate() + true + } catch (e: IllegalArgumentException) { + false + } refreshButtons() } } \ 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 index 2846fd7..8f319e5 100644 --- 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 @@ -126,10 +126,9 @@ class DownloadWizardMethodSelectFragment : DownloadWizardActivity.DownloadWizard private fun processLpaString(input: String) { try { - val parsed = ActivationCode.fromString(input) - state.smdp = parsed.address - state.matchingId = parsed.matchingId - if (parsed.confirmationCodeRequired) { + state.activationCode.fromToken(input) + state.activationCode.validate() + if (state.activationCode.confirmationCodeRequired) { AlertDialog.Builder(requireContext()).apply { setTitle(R.string.profile_download_required_confirmation_code) setMessage(R.string.profile_download_required_confirmation_code_message) @@ -176,6 +175,5 @@ class DownloadWizardMethodSelectFragment : DownloadWizardActivity.DownloadWizard 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 index 1b816d4..844061b 100644 --- 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 @@ -79,19 +79,11 @@ class DownloadWizardProgressFragment : DownloadWizardActivity.DownloadWizardStep 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 + ): View = inflater.inflate(R.layout.fragment_download_progress, container, false).apply { + val view = requireViewById(R.id.download_progress_list) + view.adapter = adapter + view.layoutManager = LinearLayoutManager(context, LinearLayoutManager.VERTICAL, false) + view.addItemDecoration(DividerItemDecoration(context, LinearLayoutManager.VERTICAL)) } override fun onStart() { @@ -119,8 +111,9 @@ class DownloadWizardProgressFragment : DownloadWizardActivity.DownloadWizardStep // Change the state of the last InProgress item to success (or error) progressItems.forEachIndexed { index, progressItem -> if (progressItem.state == ProgressState.InProgress) { - progressItem.state = - if (state.downloadError == null) ProgressState.Done else ProgressState.Error + progressItem.state = if (state.downloadError == null) + ProgressState.Done else + ProgressState.Error } adapter.notifyItemChanged(index) @@ -147,25 +140,15 @@ class DownloadWizardProgressFragment : DownloadWizardActivity.DownloadWizardStep } else { euiccChannelManagerService.waitForForegroundTask() - val (slotId, portId) = euiccChannelManager.withEuiccChannel(state.selectedLogicalSlot) { channel -> - Pair(channel.slotId, channel.portId) - } + 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 + euiccChannelManagerService + .launchProfileDownloadTask(slotId, portId, state.activationCode) + .also { state.downloadTaskID = it.taskId } } private fun updateProgress(progress: Int) { @@ -189,10 +172,9 @@ class DownloadWizardProgressFragment : DownloadWizardActivity.DownloadWizardStep } } - private inner class ProgressItemHolder(val root: View) : RecyclerView.ViewHolder(root) { + private inner class ProgressItemHolder(root: View) : RecyclerView.ViewHolder(root) { private val title = root.requireViewById(R.id.download_progress_item_title) - private val progressBar = - root.requireViewById(R.id.download_progress_icon_progress) + 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) { 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 5510fb0..39743cd 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 @@ -130,7 +130,7 @@ class DownloadWizardSlotSelectFragment : DownloadWizardActivity.DownloadWizardSt } if (slots.isNotEmpty()) { - state.imei = slots[adapter.currentSelectedIdx].imei + state.activationCode.imei = slots[adapter.currentSelectedIdx].imei } adapter.notifyDataSetChanged() @@ -165,7 +165,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 + state.activationCode.imei = adapter.slots[adapter.currentSelectedIdx].imei } fun bind(item: SlotInfo, idx: Int) { diff --git a/app-common/src/main/java/im/angry/openeuicc/util/ActivationCode.kt b/app-common/src/main/java/im/angry/openeuicc/util/ActivationCode.kt index 3aca0d6..339ce47 100644 --- a/app-common/src/main/java/im/angry/openeuicc/util/ActivationCode.kt +++ b/app-common/src/main/java/im/angry/openeuicc/util/ActivationCode.kt @@ -1,23 +1,126 @@ package im.angry.openeuicc.util -data class ActivationCode( - val address: String, - val matchingId: String? = null, - val oid: String? = null, - val confirmationCodeRequired: Boolean = false, -) { - companion object { - fun fromString(input: String): ActivationCode { - val components = input.removePrefix("LPA:").split('$') - if (components.size < 2 || components[0] != "1") { - throw IllegalArgumentException("Invalid activation code format") - } - return ActivationCode( - address = components[1].trim(), - matchingId = components.getOrNull(2)?.trim()?.ifBlank { null }, - oid = components.getOrNull(3)?.trim()?.ifBlank { null }, - confirmationCodeRequired = components.getOrNull(4)?.trim() == "1" - ) +import android.os.Parcel +import android.os.Parcelable +import java.util.Objects + +class ActivationCode : Parcelable, Cloneable { + var address: String + var matchingId: String + var oid: String + var confirmationCodeRequired: Boolean + get() = field || confirmationCode.isNotBlank() + var confirmationCode: String + var imei: String + + constructor() { + address = "" + matchingId = "" + oid = "" + confirmationCodeRequired = false + confirmationCode = "" + imei = "" + } + + private constructor(parcel: Parcel) { + address = parcel.readString() ?: "" + matchingId = parcel.readString() ?: "" + oid = parcel.readString() ?: "" + confirmationCodeRequired = parcel.readByte() != 0.toByte() + confirmationCode = parcel.readString() ?: "" + imei = parcel.readString() ?: "" + } + + fun validate() { + require(address.isBlank()) { "SM-DP+ address is required" } + require(address.contains('.')) { "SM-DP+ address is invalid" } + require(address.split('.').all { it.isSegment() }) { "SM-DP+ address is invalid" } + if (matchingId.isNotBlank()) { + require(matchingId.isSegment()) { "Matching ID is invalid" } + } + if (oid.isNotBlank()) { + require(oid.contains('.')) { "OID is invalid" } + require(oid.split('.').all { it.toIntOrNull() != null }) { "OID is invalid" } + } + if (confirmationCodeRequired) { + require(confirmationCode.isNotBlank()) { "Confirmation code is required" } + } + if (imei.isNotBlank()) { + require(imei.length in 14..16) { "IMEI must be 14-16 digits" } + require(imei.all(Char::isDigit)) { "IMEI must be all digits" } } } + + fun fromToken(token: String) { + val components = token.removePrefix("LPA:").split('$') + .map { it.trim().ifEmpty { null } } + if (components.size < 2 || components[0] != "1" || components[1] == null) { + throw IllegalArgumentException("Invalid activation code format") + } + address = components[1]!! + matchingId = components.getOrNull(2) ?: "" + oid = components.getOrNull(3) ?: "" + confirmationCodeRequired = components.getOrNull(4) == "1" + } + + override fun toString(): String { + val parts = listOf( + "1", + address, + matchingId, + oid, + if (confirmationCodeRequired) "1" else "" + ) + return parts.joinToString("$").trimEnd('$') + } + + private fun String.isSegment() = all { + it.isLetterOrDigit() || it == '-' + } + + override fun writeToParcel(parcel: Parcel, flags: Int) { + parcel.writeString(address) + parcel.writeString(matchingId) + parcel.writeString(oid) + parcel.writeByte(if (confirmationCodeRequired) 1 else 0) + parcel.writeString(confirmationCode) + parcel.writeString(imei) + } + + override fun describeContents(): Int = 0 + + override fun hashCode(): Int = Objects.hash( + address, + matchingId, + oid, + confirmationCodeRequired, + confirmationCode, + imei + ) + + override fun equals(other: Any?): Boolean { + if (this === other) return true + if (other !is ActivationCode) return false + if (other.address != address) return false + if (other.matchingId != matchingId) return false + if (other.oid != oid) return false + if (other.confirmationCodeRequired != confirmationCodeRequired) return false + if (other.confirmationCode != confirmationCode) return false + if (other.imei != imei) return false + return true + } + + public override fun clone() = ActivationCode().also { + it.address = address + it.matchingId = matchingId + it.oid = oid + it.confirmationCodeRequired = confirmationCodeRequired + it.confirmationCode = confirmationCode + it.imei = imei + } + + companion object CREATOR : Parcelable.Creator { + override fun createFromParcel(parcel: Parcel) = ActivationCode(parcel) + override fun newArray(size: Int): Array = arrayOfNulls(size) + } } \ No newline at end of file diff --git a/app-common/src/test/java/im/angry/openeuicc/util/ActivationCodeTest.kt b/app-common/src/test/java/im/angry/openeuicc/util/ActivationCodeTest.kt new file mode 100644 index 0000000..9f046bf --- /dev/null +++ b/app-common/src/test/java/im/angry/openeuicc/util/ActivationCodeTest.kt @@ -0,0 +1,65 @@ +package im.angry.openeuicc.util + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertThrows +import org.junit.Test + +class ActivationCodeTest { + /** + * @see {https://www.gsma.com/esim/wp-content/uploads/2020/06/SGP.22-v2.2.2.pdf#page=112} + */ + @Suppress("SpellCheckingInspection") + private val expectedFixtures = buildMap { + val ac = ActivationCode() + ac.address = "SMDP.GSMA.COM" + ac.matchingId = "04386-AGYFT-A74Y8-3F815" + // if SM-DP+ OID and Confirmation Code Required Flag are not present + put("1\$SMDP.GSMA.COM\$04386-AGYFT-A74Y8-3F815", ac.clone()) + // if SM-DP+ OID is not present and Confirmation Code Required Flag is present + ac.confirmationCodeRequired = true + put("1\$SMDP.GSMA.COM\$04386-AGYFT-A74Y8-3F815\$\$1", ac.clone()) + // if SM-DP+ OID and Confirmation Code Required flag are present + ac.oid = "1.3.6.1.4.1.31746" + put("1\$SMDP.GSMA.COM\$04386-AGYFT-A74Y8-3F815\$1.3.6.1.4.1.31746\$1", ac.clone()) + // if SM-DP+ OID is present and Confirmation Code Required Flag is not present + ac.confirmationCodeRequired = false + put("1\$SMDP.GSMA.COM\$04386-AGYFT-A74Y8-3F815\$1.3.6.1.4.1.31746", ac.clone()) + // if SM-DP+ OID is present, Activation token is left blank and Confirmation Code Required Flag is not present + ac.matchingId = "" + put("1\$SMDP.GSMA.COM\$\$1.3.6.1.4.1.31746", ac.clone()) + } + + @Suppress("SpellCheckingInspection") + private val unexpectedFixtures = listOf( + "", "LPA:", + "1", "LPA:1", + "1$", "LPA:1$", + "1$$", "LPA:1$$", + "2\$SMDP.GSMA.COM", "LPA:2\$SMDP.GSMA.COM", + ) + + @Test + fun testParsing() { + val actual = ActivationCode() + for ((input, expected) in expectedFixtures) { + actual.fromToken(input) + assertEquals(expected.address, actual.address) + assertEquals(expected.matchingId, actual.matchingId) + assertEquals(expected.oid, actual.oid) + assertEquals(expected.confirmationCodeRequired, actual.confirmationCodeRequired) + assertEquals(expected, actual) + assertEquals(expected.toString(), input) + assertEquals(expected.hashCode(), actual.hashCode()) + } + } + + @Test + fun testUnexpected() { + for (fixture in unexpectedFixtures) { + assertThrows(IllegalArgumentException::class.java) { + val activationCode = ActivationCode() + activationCode.fromToken(fixture) + } + } + } +} \ No newline at end of file -- 2.45.3 From 7b046207945ef90818a97410e7af1dcc27eb14c2 Mon Sep 17 00:00:00 2001 From: septs Date: Thu, 6 Feb 2025 23:33:47 +0800 Subject: [PATCH 2/3] refactor: simplify state management --- .../ui/wizard/DownloadWizardActivity.kt | 77 +++++++++++-------- .../DownloadWizardMethodSelectFragment.kt | 9 +-- .../DownloadWizardSlotSelectFragment.kt | 1 - .../java/im/angry/openeuicc/util/Toast.kt | 18 +++++ 4 files changed, 65 insertions(+), 40 deletions(-) create mode 100644 app-common/src/main/java/im/angry/openeuicc/util/Toast.kt 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 0c81bac..cf44c5f 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 @@ -6,7 +6,6 @@ import android.view.View import android.view.inputmethod.InputMethodManager import android.widget.Button import android.widget.ProgressBar -import android.widget.Toast import androidx.activity.OnBackPressedCallback import androidx.activity.enableEdgeToEdge import androidx.core.view.ViewCompat @@ -24,13 +23,46 @@ import net.typeblog.lpac_jni.LocalProfileAssistant class DownloadWizardActivity: BaseEuiccAccessActivity() { data class DownloadWizardState( - var currentStepFragmentClassName: String?, + var currentStepFragmentClassName: String? = null, var selectedLogicalSlot: Int, - var activationCode: ActivationCode, - var downloadStarted: Boolean, - var downloadTaskID: Long, - var downloadError: LocalProfileAssistant.ProfileDownloadException?, - ) + var activationCode: ActivationCode = ActivationCode(), + var downloadStarted: Boolean = false, + var downloadTaskID: Long = -1, + var downloadError: LocalProfileAssistant.ProfileDownloadException? = null, + ) { + companion object { + private const val FRAGMENT = "currentStepFragmentClassName" + private const val SLOT = "selectedLogicalSlot" + private const val ACT_CODE = "activationCode" + private const val DL_STARTED = "downloadStarted" + private const val DL_ID = "downloadTaskID" + } + + fun onSaveInstanceState(outState: Bundle) { + outState.putString(FRAGMENT, currentStepFragmentClassName) + outState.putInt(SLOT, selectedLogicalSlot) + outState.putParcelable(ACT_CODE, activationCode) + outState.putBoolean(DL_STARTED, downloadStarted) + outState.putLong(DL_ID, downloadTaskID) + } + + fun onRestoreInstanceState(savedInstanceState: Bundle) { + currentStepFragmentClassName = savedInstanceState + .getString(FRAGMENT, currentStepFragmentClassName) + selectedLogicalSlot = savedInstanceState + .getInt(SLOT, selectedLogicalSlot) + activationCode = savedInstanceState + .getCompatParcelable(ACT_CODE, ActivationCode::class.java) ?: ActivationCode() + downloadStarted = savedInstanceState + .getBoolean(DL_STARTED, downloadStarted) + downloadTaskID = savedInstanceState + .getLong(DL_ID, downloadTaskID) + } + + private fun Bundle.getCompatParcelable(key: String, clazz: Class): T? = + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) + getParcelable(key, clazz) else getParcelable(key) + } private lateinit var state: DownloadWizardState @@ -58,12 +90,7 @@ class DownloadWizardActivity: BaseEuiccAccessActivity() { }) state = DownloadWizardState( - currentStepFragmentClassName = null, selectedLogicalSlot = intent.getIntExtra("selectedLogicalSlot", 0), - activationCode = ActivationCode(), - downloadStarted = false, - downloadTaskID = -1, - downloadError = null ) progressBar = requireViewById(R.id.progress) @@ -107,26 +134,12 @@ class DownloadWizardActivity: BaseEuiccAccessActivity() { override fun onSaveInstanceState(outState: Bundle) { super.onSaveInstanceState(outState) - outState.putString("currentStepFragmentClassName", state.currentStepFragmentClassName) - outState.putInt("selectedLogicalSlot", state.selectedLogicalSlot) - outState.putParcelable("activationCode", state.activationCode) - outState.putBoolean("downloadStarted", state.downloadStarted) - outState.putLong("downloadTaskID", state.downloadTaskID) + state.onSaveInstanceState(outState) } override fun onRestoreInstanceState(savedInstanceState: Bundle) { super.onRestoreInstanceState(savedInstanceState) - state.currentStepFragmentClassName = savedInstanceState - .getString("currentStepFragmentClassName", state.currentStepFragmentClassName) - state.selectedLogicalSlot = savedInstanceState - .getInt("selectedLogicalSlot", state.selectedLogicalSlot) - state.activationCode = (if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) - savedInstanceState.getParcelable("activationCode", ActivationCode::class.java) else - savedInstanceState.getParcelable("activationCode")) ?: ActivationCode() - state.downloadStarted = savedInstanceState - .getBoolean("downloadStarted", state.downloadStarted) - state.downloadTaskID = savedInstanceState - .getLong("downloadTaskID", state.downloadTaskID) + state.onRestoreInstanceState(savedInstanceState) } private fun onPrevPressed() { @@ -158,11 +171,9 @@ class DownloadWizardActivity: BaseEuiccAccessActivity() { if (!channel.valid) throw EuiccChannelManager.EuiccChannelNotFoundException() } } catch (e: EuiccChannelManager.EuiccChannelNotFoundException) { - Toast.makeText( - this@DownloadWizardActivity, - R.string.download_wizard_slot_removed, - Toast.LENGTH_LONG - ).show() + this@DownloadWizardActivity + .makeLongToast(R.string.download_wizard_slot_removed) + .show() finish() } } 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 index 8f319e5..bf0a528 100644 --- 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 @@ -9,7 +9,6 @@ import android.view.View import android.view.ViewGroup import android.widget.ImageView import android.widget.TextView -import android.widget.Toast import androidx.activity.result.contract.ActivityResultContracts import androidx.lifecycle.lifecycleScope import androidx.recyclerview.widget.DividerItemDecoration @@ -113,11 +112,9 @@ class DownloadWizardMethodSelectFragment : DownloadWizardActivity.DownloadWizard val text = clipboard.primaryClip?.getItemAt(0)?.text if (text == null) { - Toast.makeText( - requireContext(), - R.string.profile_download_no_lpa_string, - Toast.LENGTH_SHORT - ).show() + requireContext() + .makeShortToast(R.string.profile_download_no_lpa_string) + .show() return } 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 39743cd..3648eed 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 @@ -19,7 +19,6 @@ import im.angry.openeuicc.util.* import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.toList import kotlinx.coroutines.launch -import net.typeblog.lpac_jni.LocalProfileInfo class DownloadWizardSlotSelectFragment : DownloadWizardActivity.DownloadWizardStepFragment() { companion object { diff --git a/app-common/src/main/java/im/angry/openeuicc/util/Toast.kt b/app-common/src/main/java/im/angry/openeuicc/util/Toast.kt new file mode 100644 index 0000000..9e445f4 --- /dev/null +++ b/app-common/src/main/java/im/angry/openeuicc/util/Toast.kt @@ -0,0 +1,18 @@ +package im.angry.openeuicc.util + +import android.app.Activity +import android.content.Context +import android.widget.Toast +import androidx.annotation.StringRes + +fun Activity.makeLongToast(@StringRes resId: Int): Toast = + Toast.makeText(this, resId, Toast.LENGTH_LONG) + +fun Activity.makeShortToast(@StringRes resId: Int): Toast = + Toast.makeText(this, resId, Toast.LENGTH_SHORT) + +fun Context.makeLongToast(@StringRes resId: Int): Toast = + Toast.makeText(this, resId, Toast.LENGTH_LONG) + +fun Context.makeShortToast(@StringRes resId: Int): Toast = + Toast.makeText(this, resId, Toast.LENGTH_SHORT) \ No newline at end of file -- 2.45.3 From ea6cff894f3eaf62ada75b2d7b8bf784d8a55109 Mon Sep 17 00:00:00 2001 From: septs Date: Mon, 10 Feb 2025 12:42:44 +0800 Subject: [PATCH 3/3] refactor: validate function --- .../im/angry/openeuicc/util/ActivationCode.kt | 55 ++++++++++++------- 1 file changed, 35 insertions(+), 20 deletions(-) diff --git a/app-common/src/main/java/im/angry/openeuicc/util/ActivationCode.kt b/app-common/src/main/java/im/angry/openeuicc/util/ActivationCode.kt index 339ce47..e664d4b 100644 --- a/app-common/src/main/java/im/angry/openeuicc/util/ActivationCode.kt +++ b/app-common/src/main/java/im/angry/openeuicc/util/ActivationCode.kt @@ -32,23 +32,11 @@ class ActivationCode : Parcelable, Cloneable { } fun validate() { - require(address.isBlank()) { "SM-DP+ address is required" } - require(address.contains('.')) { "SM-DP+ address is invalid" } - require(address.split('.').all { it.isSegment() }) { "SM-DP+ address is invalid" } - if (matchingId.isNotBlank()) { - require(matchingId.isSegment()) { "Matching ID is invalid" } - } - if (oid.isNotBlank()) { - require(oid.contains('.')) { "OID is invalid" } - require(oid.split('.').all { it.toIntOrNull() != null }) { "OID is invalid" } - } - if (confirmationCodeRequired) { - require(confirmationCode.isNotBlank()) { "Confirmation code is required" } - } - if (imei.isNotBlank()) { - require(imei.length in 14..16) { "IMEI must be 14-16 digits" } - require(imei.all(Char::isDigit)) { "IMEI must be all digits" } - } + require(isValidDomain(address)) { "SM-DP+ address is invalid" } + require(isValidMatchingId(matchingId)) { "Matching ID is invalid" } + require(isValidOID(oid)) { "OID is invalid" } + require(confirmationCodeRequired && confirmationCode.isNotBlank()) { "Confirmation code is required" } + require(isValidIMEI(imei)) { "IMEI is invalid" } } fun fromToken(token: String) { @@ -74,9 +62,6 @@ class ActivationCode : Parcelable, Cloneable { return parts.joinToString("$").trimEnd('$') } - private fun String.isSegment() = all { - it.isLetterOrDigit() || it == '-' - } override fun writeToParcel(parcel: Parcel, flags: Int) { parcel.writeString(address) @@ -123,4 +108,34 @@ class ActivationCode : Parcelable, Cloneable { override fun createFromParcel(parcel: Parcel) = ActivationCode(parcel) override fun newArray(size: Int): Array = arrayOfNulls(size) } +} + +private fun isValidSegment(segment: String): Boolean { + return segment.all { it.isLetterOrDigit() || it == '-' } +} + +private fun isValidDomain(fqdn: String): Boolean { + val name = fqdn.trimEnd('.') + if (name.length !in 1..<255) return false + if (!name.contains('.')) return false + return name.split('.').all { it.length < 64 && isValidSegment(it) } +} + +private fun isValidMatchingId(matchingId: String): Boolean { + // TODO: matching id string max length in specs not defined + if (matchingId.isBlank()) return true + return isValidSegment(matchingId) +} + +private fun isValidOID(oid: String): Boolean { + if (oid.isBlank()) return true + if (!oid.contains('.')) return false + return oid.split('.').all { it.all(Char::isDigit) } +} + +private fun isValidIMEI(imei: String): Boolean { + // TODO: Strong IMEI validation + if (imei.isBlank()) return true + if (imei.length !in 14..16) return false + return imei.all(Char::isDigit) } \ No newline at end of file -- 2.45.3