From 1509a4cd4202d7fafcd38db38e1171bd95faee99 Mon Sep 17 00:00:00 2001 From: septs Date: Mon, 7 Jul 2025 04:29:17 +0800 Subject: [PATCH 01/11] feat: quick availability --- .../angry/openeuicc/util/PreferenceUtils.kt | 6 + app-unpriv/src/main/AndroidManifest.xml | 5 + .../UnprivilegedCustomizableTextProvider.kt | 13 ++ .../ui/QuickAvailabilityCheckActivity.kt | 125 ++++++++++++++++++ .../openeuicc/ui/UnprivilegedMainActivity.kt | 14 ++ .../openeuicc/util/CompatibilityCheck.kt | 6 +- .../res/layout/activity_quick_available.xml | 64 +++++++++ app-unpriv/src/main/res/values/strings.xml | 10 ++ 8 files changed, 240 insertions(+), 3 deletions(-) create mode 100644 app-unpriv/src/main/java/im/angry/openeuicc/ui/QuickAvailabilityCheckActivity.kt create mode 100644 app-unpriv/src/main/res/layout/activity_quick_available.xml diff --git a/app-common/src/main/java/im/angry/openeuicc/util/PreferenceUtils.kt b/app-common/src/main/java/im/angry/openeuicc/util/PreferenceUtils.kt index 5f4aec4..b853279 100644 --- a/app-common/src/main/java/im/angry/openeuicc/util/PreferenceUtils.kt +++ b/app-common/src/main/java/im/angry/openeuicc/util/PreferenceUtils.kt @@ -38,6 +38,9 @@ internal object PreferenceKeys { val IGNORE_TLS_CERTIFICATE = booleanPreferencesKey("ignore_tls_certificate") val EUICC_MEMORY_RESET = booleanPreferencesKey("euicc_memory_reset") val ISDR_AID_LIST = stringPreferencesKey("isdr_aid_list") + + // ---- Miscellaneous ---- + val QUICK_AVAILABILITY = booleanPreferencesKey("quick_availability") } const val EUICC_DEFAULT_ISDR_AID = "A0000005591010FFFFFFFF8900000100" @@ -87,6 +90,9 @@ open class PreferenceRepository(private val context: Context) { { Base64.getEncoder().encodeToString(it.encodeToByteArray()) }, { Base64.getDecoder().decode(it).decodeToString() }) + // ---- Miscellaneous ---- + val skipQuickAvailabilityFlow = bindFlow(PreferenceKeys.QUICK_AVAILABILITY, false) + protected fun bindFlow( key: Preferences.Key, defaultValue: T, diff --git a/app-unpriv/src/main/AndroidManifest.xml b/app-unpriv/src/main/AndroidManifest.xml index ce985cd..94be44d 100644 --- a/app-unpriv/src/main/AndroidManifest.xml +++ b/app-unpriv/src/main/AndroidManifest.xml @@ -23,6 +23,11 @@ + + = emptyList() + ) + } + + private val conclusion: TextView by lazy { requireViewById(R.id.quick_availability_conclusion) } + private val deviceInformation: TextView by lazy { requireViewById(R.id.quick_availability_device_information) } + private val resultSlots: TextView by lazy { requireViewById(R.id.quick_availability_result_slots) } + private val resultNote: TextView by lazy { requireViewById(R.id.quick_availability_result_note) } + private val doNotShowAgain: CheckBox by lazy { requireViewById(R.id.quick_availability_do_not_show_again) } + private val continueButton: Button by lazy { requireViewById(R.id.quick_availability_button_continue) } + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + enableEdgeToEdge() + setContentView(R.layout.activity_quick_available) + + deviceInformation.text = buildString { + appendLine("BOARD: ${Build.BOARD}") + appendLine("DEVICE: ${Build.DEVICE}") + appendLine("MODEL: ${Build.MODEL}") + appendLine("VERSION.RELEASE: ${Build.VERSION.RELEASE}") + appendLine("VERSION.SDK_INT: ${Build.VERSION.SDK_INT}") + } + + continueButton.setOnClickListener { onContinueToApp() } + } + + override fun onStart() { + super.onStart() + lifecycleScope.launch { onCompatibilityUpdate() } + } + + fun onContinueToApp() { + runBlocking { + preferenceRepository.skipQuickAvailabilityFlow.updatePreference(doNotShowAgain.isChecked) + } + startActivity(packageManager.getLaunchIntentForPackage(packageName)!!.apply { + flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TASK + putExtra(EXTRA_FROM, true) + }) + finish() + } + + suspend fun onCompatibilityUpdate() { + val result = getCompatibilityCheckResult() + conclusion.text = + (appContainer.customizableTextProvider as UnprivilegedCustomizableTextProvider) + .formatQuickAvailabilityConclusion(result.compatibility === Compatibility.COMPATIBLE) + if (result.compatibility != Compatibility.COMPATIBLE) return + resultSlots.isVisible = true + resultSlots.text = getString( + R.string.quick_availability_result_slots, + ListFormatter.getInstance().format(result.slots) + ) + resultNote.isVisible = true + } + + suspend fun getCompatibilityCheckResult(): CompatibilityResult { + val seService = connectSEService(this) + if (!seService.isConnected) { + return CompatibilityResult(Compatibility.NOT_COMPATIBLE) + } + val simReaders = seService.readers.filter { it.isSIM } + if (simReaders.isEmpty()) { + return CompatibilityResult(Compatibility.NOT_COMPATIBLE) + } + val validSlotIds = simReaders.map { reader -> + try { + // Note: we ONLY check the default ISD-R AID, because this test is for the _device_, + // NOT the eUICC. We don't care what AID a potential eUICC might use, all we need to + // check is we can open _some_ AID. + reader.openSession().openLogicalChannel(EUICC_DEFAULT_ISDR_AID.decodeHex())?.close() + reader.slotIndex + } catch (_: SecurityException) { + // Ignore; this is expected when everything works + // ref: https://android.googlesource.com/platform/frameworks/base/+/4fe64fb4712a99d5da9c9a0eb8fd5169b252e1e1/omapi/java/android/se/omapi/Session.java#305 + // SecurityException is only thrown when Channel is constructed, which means everything else needs to succeed + reader.slotIndex + } catch (_: Exception) { + // Ignore + null + } + } + return CompatibilityResult( + Compatibility.COMPATIBLE, + slots = validSlotIds.filterNotNull().map { "SIM$it" } + ) + } +} diff --git a/app-unpriv/src/main/java/im/angry/openeuicc/ui/UnprivilegedMainActivity.kt b/app-unpriv/src/main/java/im/angry/openeuicc/ui/UnprivilegedMainActivity.kt index 1ed0cce..65b3696 100644 --- a/app-unpriv/src/main/java/im/angry/openeuicc/ui/UnprivilegedMainActivity.kt +++ b/app-unpriv/src/main/java/im/angry/openeuicc/ui/UnprivilegedMainActivity.kt @@ -1,11 +1,25 @@ package im.angry.openeuicc.ui import android.content.Intent +import android.os.Bundle import android.view.Menu import android.view.MenuItem import im.angry.easyeuicc.R +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.runBlocking class UnprivilegedMainActivity: MainActivity() { + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + if (intent.getBooleanExtra(QuickAvailabilityCheckActivity.EXTRA_FROM, false)) { + return + } + if (runBlocking { !preferenceRepository.skipQuickAvailabilityFlow.first() }) { + startActivity(Intent(this, QuickAvailabilityCheckActivity::class.java)) + finish() + } + } + override fun onCreateOptionsMenu(menu: Menu): Boolean { super.onCreateOptionsMenu(menu) menuInflater.inflate(R.menu.activity_main_unprivileged, menu) diff --git a/app-unpriv/src/main/java/im/angry/openeuicc/util/CompatibilityCheck.kt b/app-unpriv/src/main/java/im/angry/openeuicc/util/CompatibilityCheck.kt index 94ee9d6..b2b503a 100644 --- a/app-unpriv/src/main/java/im/angry/openeuicc/util/CompatibilityCheck.kt +++ b/app-unpriv/src/main/java/im/angry/openeuicc/util/CompatibilityCheck.kt @@ -33,10 +33,10 @@ suspend fun List.executeAll(callback: () -> Unit) = withCont } } -private val Reader.isSIM: Boolean +val Reader.isSIM: Boolean get() = name.startsWith("SIM") -private val Reader.slotIndex: Int +val Reader.slotIndex: Int get() = (name.replace("SIM", "").toIntOrNull() ?: 1) abstract class CompatibilityCheck(context: Context) { @@ -173,7 +173,7 @@ internal class IsdrChannelAccessCheck(private val context: Context): Compatibili } } - if (result != State.SUCCESS && validSlotIds.size > 0) { + if (result != State.SUCCESS && validSlotIds.isNotEmpty()) { if (!context.packageManager.hasSystemFeature(PackageManager.FEATURE_TELEPHONY_EUICC)) { failureDescription = context.getString( R.string.compatibility_check_isdr_channel_desc_partial_fail, diff --git a/app-unpriv/src/main/res/layout/activity_quick_available.xml b/app-unpriv/src/main/res/layout/activity_quick_available.xml new file mode 100644 index 0000000..a8d7eaa --- /dev/null +++ b/app-unpriv/src/main/res/layout/activity_quick_available.xml @@ -0,0 +1,64 @@ + + + + + + + + + + + + + + + +