Browse Source

feat: quick compatibility check

Co-authored-by: septs <github@septs.pw>
Peter Cai 6 months ago
parent
commit
677b69cedf

+ 5 - 0
app-unpriv/src/main/AndroidManifest.xml

@@ -23,6 +23,11 @@
             </intent-filter>
         </activity>
 
+        <activity
+            android:name="im.angry.openeuicc.ui.QuickCompatibilityActivity"
+            android:exported="false"
+            android:label="@string/quick_compatibility" />
+
         <activity
             android:name="im.angry.openeuicc.ui.CompatibilityCheckActivity"
             android:exported="false"

+ 5 - 0
app-unpriv/src/main/java/im/angry/openeuicc/di/UnprivilegedAppContainer.kt

@@ -1,6 +1,7 @@
 package im.angry.openeuicc.di
 
 import android.content.Context
+import im.angry.openeuicc.util.UnprivilegedPreferenceRepository
 
 class UnprivilegedAppContainer(context: Context) : DefaultAppContainer(context) {
     override val uiComponentFactory by lazy {
@@ -10,4 +11,8 @@ class UnprivilegedAppContainer(context: Context) : DefaultAppContainer(context)
     override val customizableTextProvider by lazy {
         UnprivilegedCustomizableTextProvider(context)
     }
+
+    override val preferenceRepository by lazy {
+        UnprivilegedPreferenceRepository(context)
+    }
 }

+ 5 - 2
app-unpriv/src/main/java/im/angry/openeuicc/di/UnprivilegedUiComponentFactory.kt

@@ -2,12 +2,12 @@ package im.angry.openeuicc.di
 
 import androidx.fragment.app.Fragment
 import im.angry.openeuicc.ui.EuiccManagementFragment
-import im.angry.openeuicc.ui.SettingsFragment
+import im.angry.openeuicc.ui.QuickCompatibilityFragment
 import im.angry.openeuicc.ui.UnprivilegedEuiccManagementFragment
 import im.angry.openeuicc.ui.UnprivilegedNoEuiccPlaceholderFragment
 import im.angry.openeuicc.ui.UnprivilegedSettingsFragment
 
-class UnprivilegedUiComponentFactory : DefaultUiComponentFactory() {
+open class UnprivilegedUiComponentFactory : DefaultUiComponentFactory() {
     override fun createEuiccManagementFragment(slotId: Int, portId: Int): EuiccManagementFragment =
         UnprivilegedEuiccManagementFragment.newInstance(slotId, portId)
 
@@ -16,4 +16,7 @@ class UnprivilegedUiComponentFactory : DefaultUiComponentFactory() {
 
     override fun createSettingsFragment(): Fragment =
         UnprivilegedSettingsFragment()
+
+    open fun createQuickAvailabilityFragment(): Fragment =
+        QuickCompatibilityFragment()
 }

+ 25 - 0
app-unpriv/src/main/java/im/angry/openeuicc/ui/QuickCompatibilityActivity.kt

@@ -0,0 +1,25 @@
+package im.angry.openeuicc.ui
+
+import android.content.Intent
+import android.os.Bundle
+import androidx.activity.enableEdgeToEdge
+import androidx.appcompat.app.AppCompatActivity
+import im.angry.easyeuicc.R
+import im.angry.openeuicc.di.UnprivilegedUiComponentFactory
+import im.angry.openeuicc.util.OpenEuiccContextMarker
+
+class QuickCompatibilityActivity : AppCompatActivity(), OpenEuiccContextMarker {
+    override fun onCreate(savedInstanceState: Bundle?) {
+        super.onCreate(savedInstanceState)
+        enableEdgeToEdge()
+        setContentView(R.layout.activity_quick_compatibility)
+
+        val quickAvailabilityFragment =
+            (appContainer.uiComponentFactory as UnprivilegedUiComponentFactory)
+                .createQuickAvailabilityFragment()
+
+        supportFragmentManager.beginTransaction()
+            .replace(R.id.quick_availability_container, quickAvailabilityFragment)
+            .commit()
+    }
+}

+ 140 - 0
app-unpriv/src/main/java/im/angry/openeuicc/ui/QuickCompatibilityFragment.kt

@@ -0,0 +1,140 @@
+package im.angry.openeuicc.ui
+
+import android.content.pm.PackageManager
+import android.icu.text.ListFormatter
+import android.os.Build
+import android.os.Bundle
+import android.view.LayoutInflater
+import android.view.View
+import android.view.ViewGroup
+import android.widget.Button
+import android.widget.CheckBox
+import android.widget.TextView
+import androidx.core.view.isVisible
+import androidx.fragment.app.Fragment
+import androidx.lifecycle.lifecycleScope
+import im.angry.easyeuicc.R
+import im.angry.openeuicc.util.EUICC_DEFAULT_ISDR_AID
+import im.angry.openeuicc.util.UnprivilegedEuiccContextMarker
+import im.angry.openeuicc.util.connectSEService
+import im.angry.openeuicc.util.decodeHex
+import im.angry.openeuicc.util.isSIM
+import im.angry.openeuicc.util.slotIndex
+import kotlinx.coroutines.launch
+import kotlinx.coroutines.runBlocking
+
+open class QuickCompatibilityFragment : Fragment(), UnprivilegedEuiccContextMarker {
+    companion object {
+        enum class Compatibility {
+            COMPATIBLE,
+            NOT_COMPATIBLE,
+        }
+
+        data class CompatibilityResult(
+            val compatibility: Compatibility,
+            val slots: List<String> = emptyList()
+        )
+    }
+
+    private val conclusion: TextView by lazy {
+        requireView().requireViewById(R.id.quick_availability_conclusion)
+    }
+
+    private val resultSlots: TextView by lazy {
+        requireView().requireViewById(R.id.quick_availability_result_slots)
+    }
+
+    private val resultNotes: TextView by lazy {
+        requireView().requireViewById(R.id.quick_availability_result_notes)
+    }
+
+    private val hidden: CheckBox by lazy {
+        requireView().requireViewById(R.id.quick_availability_hidden)
+    }
+
+    override fun onCreateView(
+        inflater: LayoutInflater,
+        container: ViewGroup?,
+        savedInstanceState: Bundle?
+    ): View = inflater.inflate(R.layout.fragment_quick_compatibility, container, false).apply {
+        requireViewById<TextView>(R.id.quick_availability_device_information)
+            .text = formatDeviceInformation()
+        requireViewById<Button>(R.id.quick_availability_button_continue)
+            .setOnClickListener { onContinueToApp() }
+    }
+
+    override fun onStart() {
+        super.onStart()
+        lifecycleScope.launch {
+            onCompatibilityUpdate(getCompatibilityCheckResult())
+        }
+    }
+
+    private fun onContinueToApp() {
+        runBlocking {
+            preferenceRepository.skipQuickAvailabilityFlow
+                .updatePreference(hidden.isChecked)
+        }
+        requireActivity().finish()
+    }
+
+    private fun onCompatibilityUpdate(result: CompatibilityResult) {
+        conclusion.text = formatConclusion(result)
+        if (result.compatibility != Compatibility.COMPATIBLE) return
+        resultSlots.isVisible = true
+        resultSlots.text = getString(
+            R.string.quick_compatibility_result_slots,
+            ListFormatter.getInstance().format(result.slots)
+        )
+        resultNotes.isVisible = true
+    }
+
+    private suspend fun getCompatibilityCheckResult(): CompatibilityResult {
+        val service = connectSEService(requireContext())
+        if (!service.isConnected) {
+            return CompatibilityResult(Compatibility.NOT_COMPATIBLE)
+        }
+        val slots = service.readers.filter { it.isSIM }.mapNotNull { 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) {
+                null
+            }
+        }
+        if (slots.isEmpty()) {
+            return CompatibilityResult(Compatibility.NOT_COMPATIBLE)
+        }
+        return CompatibilityResult(Compatibility.COMPATIBLE, slots = slots.map { "SIM$it" })
+    }
+
+    open fun formatConclusion(result: CompatibilityResult): String {
+        val usbHost = requireContext().packageManager
+            .hasSystemFeature(PackageManager.FEATURE_USB_HOST)
+        val resId = when (result.compatibility) {
+            Compatibility.COMPATIBLE ->
+                R.string.quick_compatibility_compatible
+
+            Compatibility.NOT_COMPATIBLE -> if (usbHost)
+                R.string.quick_compatibility_not_compatible_but_usb else
+                R.string.quick_compatibility_not_compatible
+        }
+        return getString(resId, getString(R.string.app_name))
+    }
+
+    open fun formatDeviceInformation() = buildString {
+        appendLine("BRAND: ${Build.BRAND}")
+        appendLine("DEVICE: ${Build.DEVICE}")
+        appendLine("MODEL: ${Build.MODEL}")
+        appendLine("VERSION.RELEASE: ${Build.VERSION.RELEASE}")
+        appendLine("VERSION.SDK_INT: ${Build.VERSION.SDK_INT}")
+    }
+}

+ 12 - 1
app-unpriv/src/main/java/im/angry/openeuicc/ui/UnprivilegedMainActivity.kt

@@ -1,11 +1,22 @@
 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 im.angry.openeuicc.util.UnprivilegedEuiccContextMarker
+import kotlinx.coroutines.flow.first
+import kotlinx.coroutines.runBlocking
+
+class UnprivilegedMainActivity : MainActivity(), UnprivilegedEuiccContextMarker {
+    override fun onCreate(savedInstanceState: Bundle?) {
+        super.onCreate(savedInstanceState)
+        if (runBlocking { !preferenceRepository.skipQuickAvailabilityFlow.first() }) {
+            startActivity(Intent(this, QuickCompatibilityActivity::class.java))
+        }
+    }
 
-class UnprivilegedMainActivity: MainActivity() {
     override fun onCreateOptionsMenu(menu: Menu): Boolean {
         super.onCreateOptionsMenu(menu)
         menuInflater.inflate(R.menu.activity_main_unprivileged, menu)

+ 3 - 3
app-unpriv/src/main/java/im/angry/openeuicc/util/CompatibilityCheck.kt

@@ -33,10 +33,10 @@ suspend fun List<CompatibilityCheck>.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,

+ 14 - 0
app-unpriv/src/main/java/im/angry/openeuicc/util/UnprivilegedPreferenceRepository.kt

@@ -0,0 +1,14 @@
+package im.angry.openeuicc.util
+
+import android.content.Context
+import androidx.datastore.preferences.core.booleanPreferencesKey
+
+internal object UnprivilegedPreferenceKeys {
+    // ---- Miscellaneous ----
+    val SKIP_QUICK_AVAILABILITY = booleanPreferencesKey("skip_quick_availability")
+}
+
+class UnprivilegedPreferenceRepository(context: Context) : PreferenceRepository(context) {
+    // ---- Miscellaneous ----
+    val skipQuickAvailabilityFlow = bindFlow(UnprivilegedPreferenceKeys.SKIP_QUICK_AVAILABILITY, false)
+}

+ 6 - 0
app-unpriv/src/main/java/im/angry/openeuicc/util/UnprivilegedUtils.kt

@@ -0,0 +1,6 @@
+package im.angry.openeuicc.util
+
+interface UnprivilegedEuiccContextMarker : OpenEuiccContextMarker {
+    override val preferenceRepository: UnprivilegedPreferenceRepository
+        get() = appContainer.preferenceRepository as UnprivilegedPreferenceRepository
+}

+ 16 - 0
app-unpriv/src/main/res/layout/activity_quick_compatibility.xml

@@ -0,0 +1,16 @@
+<?xml version="1.0" encoding="utf-8"?>
+<androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android"
+    xmlns:app="http://schemas.android.com/apk/res-auto"
+    android:layout_width="match_parent"
+    android:layout_height="match_parent">
+
+    <FrameLayout
+        android:id="@+id/quick_availability_container"
+        android:layout_width="match_parent"
+        android:layout_height="match_parent"
+        app:layout_constraintBottom_toBottomOf="parent"
+        app:layout_constraintLeft_toLeftOf="parent"
+        app:layout_constraintRight_toRightOf="parent"
+        app:layout_constraintTop_toBottomOf="parent" />
+
+</androidx.constraintlayout.widget.ConstraintLayout>

+ 53 - 0
app-unpriv/src/main/res/layout/fragment_quick_compatibility.xml

@@ -0,0 +1,53 @@
+<?xml version="1.0" encoding="utf-8"?>
+<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
+    android:layout_width="match_parent"
+    android:layout_height="match_parent"
+    android:gravity="center"
+    android:orientation="vertical"
+    android:padding="32dp"
+    android:textAlignment="center">
+
+    <TextView
+        android:id="@+id/quick_availability_conclusion"
+        android:layout_width="wrap_content"
+        android:layout_height="wrap_content"
+        android:layout_marginBottom="16dp"
+        android:textAlignment="center"
+        android:textSize="20sp"
+        android:textStyle="bold" />
+
+    <TextView
+        android:id="@+id/quick_availability_device_information"
+        android:layout_width="wrap_content"
+        android:layout_height="wrap_content"
+        android:fontFamily="monospace"
+        android:lineHeight="30dp"
+        android:textAlignment="center" />
+
+    <TextView
+        android:id="@+id/quick_availability_result_slots"
+        android:layout_width="wrap_content"
+        android:layout_height="wrap_content"
+        android:layout_marginBottom="16dp"
+        android:visibility="gone" />
+
+    <TextView
+        android:id="@+id/quick_availability_result_notes"
+        android:layout_width="wrap_content"
+        android:layout_height="wrap_content"
+        android:text="@string/quick_compatibility_result_notes"
+        android:visibility="gone" />
+
+    <CheckBox
+        android:id="@+id/quick_availability_hidden"
+        android:layout_width="match_parent"
+        android:layout_height="wrap_content"
+        android:text="@string/quick_compatibility_hidden" />
+
+    <Button
+        android:id="@+id/quick_availability_button_continue"
+        android:layout_width="match_parent"
+        android:layout_height="wrap_content"
+        android:text="@string/quick_compatibility_button_continue" />
+
+</LinearLayout>

+ 10 - 0
app-unpriv/src/main/res/values/strings.xml

@@ -11,6 +11,16 @@
     <string name="toast_ara_m_copied">ARA-M SHA-1 copied to clipboard</string>
     <string name="toast_prompt_to_enable_sim_toolkit">Please ENABLE your \"%s\" application</string>
 
+    <!-- Quick Compatibility -->
+    <string name="quick_compatibility">Quick Compatibility Check</string>
+    <string name="quick_compatibility_compatible">Your smartphone can manage %s-compatible cards</string>
+    <string name="quick_compatibility_not_compatible">Your smartphone is not compatible with %s</string>
+    <string name="quick_compatibility_not_compatible_but_usb">Your smartphone is not fully compatible with %s. However, you can still use a USB smart card reader for near-full functionality.</string>
+    <string name="quick_compatibility_result_slots">SIM card slots accessible: %s</string>
+    <string name="quick_compatibility_result_notes">Note: these results are for reference only. Even if a SIM slot is not listed above, it <i>may</i> be compatible as well once a SIM card is inserted.</string>
+    <string name="quick_compatibility_hidden">Do not show this message again.</string>
+    <string name="quick_compatibility_button_continue">Continue</string>
+
     <!-- Compatibility Check Descriptions -->
     <string name="compatibility_check_system_features">System Features</string>
     <string name="compatibility_check_system_features_desc">Whether your device has all the required features for managing removable eUICC cards. For example, basic telephony and OMAPI support.</string>