feat: quick availability #196

Open
septs wants to merge 12 commits from septs/OpenEUICC:quick-availability into master
12 changed files with 311 additions and 6 deletions

View file

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

View file

@ -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)
}
}

View file

@ -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.QuickAvailabilityFragment
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 =
QuickAvailabilityFragment()
}

View file

@ -0,0 +1,37 @@
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 QuickAvailabilityActivity : AppCompatActivity(), OpenEuiccContextMarker {
companion object {
const val EXTRA_FROM = "from_quick_availability"
}
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
enableEdgeToEdge()
setContentView(R.layout.activity_quick_availability)
val quickAvailabilityFragment =
(appContainer.uiComponentFactory as UnprivilegedUiComponentFactory)
.createQuickAvailabilityFragment()
supportFragmentManager.beginTransaction()
.replace(R.id.quick_availability_container, quickAvailabilityFragment)
.commit()
}
fun launchMainActivity() {
val intent = packageManager.getLaunchIntentForPackage(packageName)!!
intent.flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TASK
intent.putExtra(EXTRA_FROM, true)
startActivity(intent)
finish()
}
}

View file

@ -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 QuickAvailabilityFragment : 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_availability, 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())
}
}
fun onContinueToApp() {
runBlocking {
preferenceRepository.skipQuickAvailabilityFlow
.updatePreference(hidden.isChecked)
}
(requireActivity() as QuickAvailabilityActivity).launchMainActivity()
}
fun onCompatibilityUpdate(result: CompatibilityResult) {
conclusion.text = formatConclusion(result)
if (result.compatibility != Compatibility.COMPATIBLE) return
resultSlots.isVisible = true
resultSlots.text = getString(
R.string.quick_availability_result_slots,
ListFormatter.getInstance().format(result.slots)
)
resultNotes.isVisible = true
}
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_availability_compatible
Compatibility.NOT_COMPATIBLE -> if (usbHost)
R.string.quick_availability_not_compatible_but_usb else
R.string.quick_availability_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}")
}
}

View file

@ -1,11 +1,27 @@
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.di.UnprivilegedUiComponentFactory
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 (intent.getBooleanExtra(QuickAvailabilityActivity.EXTRA_FROM, false)) {
return
}
if (runBlocking { !preferenceRepository.skipQuickAvailabilityFlow.first() }) {
startActivity(Intent(this, QuickAvailabilityActivity::class.java))
finish()
}
}
class UnprivilegedMainActivity: MainActivity() {
override fun onCreateOptionsMenu(menu: Menu): Boolean {
super.onCreateOptionsMenu(menu)
menuInflater.inflate(R.menu.activity_main_unprivileged, menu)

View file

@ -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,

View file

@ -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)
}

View file

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

View file

@ -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>

View file

@ -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_availability_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_availability_hidden" />
<Button
android:id="@+id/quick_availability_button_continue"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:text="@string/quick_availability_button_continue" />
</LinearLayout>

View file

@ -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 Availability -->
<string name="quick_availability">Quick Availability</string>
<string name="quick_availability_compatible">Your smartphone can use %s compatible cards</string>
<string name="quick_availability_not_compatible">Your smartphone is not compatible with %s</string>
<string name="quick_availability_not_compatible_but_usb">Your smartphone is not compatible with %s, but can be managed using a USB smart card reader</string>
<string name="quick_availability_result_slots">SIM card slots accessible: %s</string>
<string name="quick_availability_result_notes">Note: Quick Compatibility Check results are for reference only. Actual usage may vary based on card insertion.</string>
<string name="quick_availability_hidden">Do not show this message again.</string>
<string name="quick_availability_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>