Compare commits

..

No commits in common. "master" and "master" have entirely different histories.

64 changed files with 218 additions and 1575 deletions

View file

@ -36,9 +36,6 @@ jobs:
- name: Setup Android SDK
uses: https://gitea.angry.im/actions/setup-android@v3
- name: Run Tests
run: ./gradlew --no-daemon test
- name: Build Debug APKs
run: ./gradlew --no-daemon assembleDebug :app:assembleDebugMagiskModuleDir

View file

@ -33,9 +33,6 @@ jobs:
- name: Setup Android SDK
uses: https://gitea.angry.im/actions/setup-android@v3
- name: Run Tests
run: ./gradlew --no-daemon test
- name: Build Release APK (Unprivileged / EasyEUICC only)
run: ./gradlew --no-daemon :app-unpriv:assembleRelease
@ -43,7 +40,7 @@ jobs:
run: cp app-unpriv/build/outputs/native-debug-symbols/release/native-debug-symbols.zip app-unpriv/build/outputs/apk/release/
- name: Create Release
uses: https://gitea.angry.im/actions/forgejo-release@v2
uses: https://gitea.angry.im/actions/forgejo-release@v1
with:
direction: upload
release-dir: app-unpriv/build/outputs/apk/release
@ -52,6 +49,3 @@ jobs:
# Release details are expected to be edited manually
release-notes: TBD
prerelease: 'true'
title: TBD ${{ github.ref_name }}
tag: ${{ github.ref_name }}
verbose: true

1
.gitignore vendored
View file

@ -1,6 +1,5 @@
/.gradle
/captures
/build
# Configuration files

4
.idea/compiler.xml generated
View file

@ -1,6 +1,6 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="CompilerConfiguration">
<bytecodeTargetLevel target="21" />
<bytecodeTargetLevel target="1.7" />
</component>
</project>
</project>

5
.idea/kotlinc.xml generated
View file

@ -1,7 +1,6 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="KotlinJpsPluginSettings">
<option name="externalSystemId" value="Gradle" />
<option name="version" value="2.3.20" />
<option name="version" value="1.9.24" />
</component>
</project>
</project>

View file

@ -8,7 +8,6 @@ java_library {
},
static_libs: [
"kotlinx_coroutines",
"androidx.core_core-ktx",
],
system_ext_specific: true,
}

View file

@ -103,10 +103,6 @@ There are two ways to include OpenEUICC in your AOSP-based system image:
[`privapp_whitelist_im.angry.openeuicc.xml`]: privapp_whitelist_im.angry.openeuicc.xml "OpenEUICC Privapp Whitelist"
# Documentation
- [Deep Link](docs/DEEP-LINK.md) - How to use the `lpa:` deep link to call {Open/Easy}EUICC from other apps.
# FAQs
- Q: Do you provide prebuilt binaries for OpenEUICC? \

View file

@ -1,5 +1,3 @@
import org.jetbrains.kotlin.gradle.dsl.JvmTarget
plugins {
id("com.android.library")
id("org.jetbrains.kotlin.android")
@ -7,7 +5,7 @@ plugins {
android {
namespace = "im.angry.openeuicc.common"
compileSdk = 37
compileSdk = 35
defaultConfig {
minSdk = 28
@ -15,24 +13,18 @@ android {
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
}
testOptions {
unitTests {
// Robolectric needs real resources (e.g. task titles) to test the real service
isIncludeAndroidResources = true
}
}
buildTypes {
release {
isMinifyEnabled = false
proguardFiles(getDefaultProguardFile("proguard-android-optimize.txt"), "proguard-rules.pro")
}
}
}
kotlin {
compilerOptions {
jvmTarget = JvmTarget.JVM_11
compileOptions {
sourceCompatibility = JavaVersion.VERSION_1_8
targetCompatibility = JavaVersion.VERSION_1_8
}
kotlinOptions {
jvmTarget = "1.8"
}
}
@ -40,7 +32,6 @@ dependencies {
api(project(":libs:lpac-jni"))
api(project(":app-deps"))
testImplementation("junit:junit:4.13.2")
testImplementation("org.robolectric:robolectric:4.17-beta-4")
androidTestImplementation("androidx.test.ext:junit:1.3.0")
androidTestImplementation("androidx.test.espresso:espresso-core:3.7.0")
}
androidTestImplementation("androidx.test.ext:junit:1.1.5")
androidTestImplementation("androidx.test.espresso:espresso-core:3.5.1")
}

View file

@ -35,17 +35,7 @@
<activity
android:name="im.angry.openeuicc.ui.wizard.DownloadWizardActivity"
android:exported="true"
android:label="@string/download_wizard" />
<activity
android:name="im.angry.openeuicc.ui.wizard.DeepLinkConfirmationActivity"
android:clearTaskOnLaunch="true"
android:excludeFromRecents="true"
android:exported="true"
android:finishOnTaskLaunch="true"
android:label="@string/deep_link_confirmation"
android:launchMode="singleInstance"
android:theme="@style/Theme.App.DeepLinkTransparent">
android:label="@string/download_wizard">
<intent-filter>
<action android:name="android.intent.action.VIEW" />

View file

@ -47,7 +47,6 @@ open class DefaultEuiccChannelFactory(protected val context: Context) : EuiccCha
seId,
context.preferenceRepository.verboseLoggingFlow,
context.preferenceRepository.ignoreTLSCertificateFlow,
context.preferenceRepository.httpProxyFlow,
context.preferenceRepository.es10xMssFlow,
)
} catch (_: IllegalArgumentException) {
@ -74,7 +73,6 @@ open class DefaultEuiccChannelFactory(protected val context: Context) : EuiccCha
seId,
context.preferenceRepository.verboseLoggingFlow,
context.preferenceRepository.ignoreTLSCertificateFlow,
context.preferenceRepository.httpProxyFlow,
context.preferenceRepository.es10xMssFlow,
)
} catch (_: IllegalArgumentException) {

View file

@ -17,7 +17,6 @@ class EuiccChannelImpl(
override val seId: EuiccChannel.SecureElementId,
verboseLoggingFlow: Flow<Boolean>,
ignoreTLSCertificateFlow: Flow<Boolean>,
httpProxyFlow: Flow<String>,
es10xMssFlow: Flow<Int>,
) : EuiccChannel {
override val slotId = port.card.physicalSlotIndex
@ -28,7 +27,7 @@ class EuiccChannelImpl(
LocalProfileAssistantImpl(
isdrAid,
apduInterface,
HttpInterfaceImpl(verboseLoggingFlow, ignoreTLSCertificateFlow, httpProxyFlow),
HttpInterfaceImpl(verboseLoggingFlow, ignoreTLSCertificateFlow),
).also {
it.setEs10xMss(runBlocking { es10xMssFlow.first().toByte() })
}

View file

@ -49,16 +49,8 @@ class OmapiApduInterface(
override fun logicalChannelClose(handle: Int) {
val channel = channels[handle]
check(channel != null) { "Invalid logical channel handle $handle" }
try {
if (channel.isOpen) channel.close()
} catch (e: IllegalArgumentException) {
Log.w(TAG, "Logical channel was already closed by the modem", e)
} finally {
synchronized(channels) {
channels.remove(handle)
}
}
if (channel.isOpen) channel.close()
synchronized(channels) { channels.remove(handle) }
}
override fun transmit(handle: Int, tx: ByteArray): ByteArray {

View file

@ -20,6 +20,7 @@ import kotlinx.coroutines.NonCancellable
import kotlinx.coroutines.TimeoutCancellationException
import kotlinx.coroutines.channels.BufferOverflow
import kotlinx.coroutines.channels.Channel
import kotlinx.coroutines.channels.trySendBlocking
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableSharedFlow
@ -62,8 +63,7 @@ import net.typeblog.lpac_jni.ProfileDownloadState
* lifecycle context and return a Flow instance for the UI component to subscribe to its
* progress.
*/
// open so that tests can substitute fakes for the service (see DownloadTaskLauncherTest)
open class EuiccChannelManagerService : LifecycleService(), OpenEuiccContextMarker {
class EuiccChannelManagerService : LifecycleService(), OpenEuiccContextMarker {
companion object {
private const val TAG = "EuiccChannelManagerService"
private const val CHANNEL_ID = "tasks"
@ -128,27 +128,12 @@ open class EuiccChannelManagerService : LifecycleService(), OpenEuiccContextMark
MutableStateFlow(ForegroundTaskState.Idle)
/**
* Every handle represents a subscriber to a foreground task's state updates (via stateFlow),
* and a way to back-communicate (via backChannel).
* A simple wrapper over a flow with taskId added.
*
* taskID is the exact millisecond-precision timestamp when the task is launched.
*/
data class ForegroundTaskHandle(
val taskId: Long,
val stateFlow: Flow<ForegroundTaskState>,
val backChannel: Channel<Any>
)
/**
* Private record of an already launched foreground task. This is separate from
* ForegroundTaskHandle, because we store the original SharedFlow directly.
* This way, we can create new ForegroundTaskHandles by applying independent
* but identical transforms on the original flow.
*/
private data class ForegroundTaskRecord(
val stateFlow: SharedFlow<ForegroundTaskState>,
val backChannel: Channel<Any>
)
class ForegroundTaskSubscriberFlow(val taskId: Long, inner: Flow<ForegroundTaskState>) :
Flow<ForegroundTaskState> by inner
/**
* A cache of subscribers to 5 recently-launched foreground tasks, identified by ID
@ -158,7 +143,8 @@ open class EuiccChannelManagerService : LifecycleService(), OpenEuiccContextMark
* tasks are running. Having this buffer allows the components to re-subscribe even if
* the task completes while they are being recreated.
*/
private val foregroundTaskRecords: MutableMap<Long, ForegroundTaskRecord> = mutableMapOf()
private val foregroundTaskSubscribers: MutableMap<Long, SharedFlow<ForegroundTaskState>> =
mutableMapOf()
override fun onBind(intent: Intent): IBinder {
super.onBind(intent)
@ -238,13 +224,13 @@ open class EuiccChannelManagerService : LifecycleService(), OpenEuiccContextMark
}
/**
* Recover the handle to a foreground task that is recently launched by creating a new subscriber.
* 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): ForegroundTaskHandle? =
foregroundTaskRecords[taskId]?.let {
ForegroundTaskHandle(taskId, it.stateFlow.applyCompletionTransform(), it.backChannel)
fun recoverForegroundTaskSubscriber(taskId: Long): ForegroundTaskSubscriberFlow? =
foregroundTaskSubscribers[taskId]?.let {
ForegroundTaskSubscriberFlow(taskId, it.applyCompletionTransform())
}
/**
@ -269,11 +255,9 @@ open class EuiccChannelManagerService : LifecycleService(), OpenEuiccContextMark
title: String,
failureTitle: String,
iconRes: Int,
task: suspend EuiccChannelManagerService.(Channel<Any>) -> Unit
): ForegroundTaskHandle {
task: suspend EuiccChannelManagerService.() -> Unit
): ForegroundTaskSubscriberFlow {
val taskID = System.currentTimeMillis()
// Buffered so that a subscriber's send() never blocks
val backChannel = Channel<Any>(capacity = Channel.BUFFERED)
// Atomically set the state to InProgress. If this returns true, we are
// the only task currently in progress.
@ -282,10 +266,9 @@ open class EuiccChannelManagerService : LifecycleService(), OpenEuiccContextMark
ForegroundTaskState.InProgress(0)
)
) {
return ForegroundTaskHandle(
return ForegroundTaskSubscriberFlow(
taskID,
flow { emit(ForegroundTaskState.Done(IllegalStateException("There are tasks currently running"))) },
backChannel)
flow { emit(ForegroundTaskState.Done(IllegalStateException("There are tasks currently running"))) })
}
lifecycleScope.launch(Dispatchers.Main) {
@ -296,7 +279,7 @@ open class EuiccChannelManagerService : LifecycleService(), OpenEuiccContextMark
}
if (res == null) {
// The only case where the wait above could time out is if the handle
// The only case where the wait above could time out is if the subscriber
// to the flow is stuck. Or we failed to start foreground.
// In that case, we should just set our state back to Idle -- setting it
// to Done wouldn't help much because nothing is going to then set it Idle.
@ -310,7 +293,7 @@ open class EuiccChannelManagerService : LifecycleService(), OpenEuiccContextMark
try {
withContext(Dispatchers.IO + NonCancellable) { // Any LPA-related task must always complete
this@EuiccChannelManagerService.task(backChannel)
this@EuiccChannelManagerService.task()
}
// This update will be sent by the subscriber (as shown below)
foregroundTaskState.value = ForegroundTaskState.Done(null)
@ -330,13 +313,13 @@ open class EuiccChannelManagerService : LifecycleService(), OpenEuiccContextMark
}
}
// This is the flow we are going to return. We allow multiple handles by
// 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 stateFlow = MutableSharedFlow<ForegroundTaskState>(
val subscriberFlow = MutableSharedFlow<ForegroundTaskState>(
replay = 2,
onBufferOverflow = BufferOverflow.DROP_OLDEST
)
@ -357,7 +340,7 @@ open class EuiccChannelManagerService : LifecycleService(), OpenEuiccContextMark
updateForegroundNotification(title, iconRes)
}
stateFlow.emit(it)
subscriberFlow.emit(it)
}
.onCompletion {
// Reset state back to Idle when we are done.
@ -366,18 +349,17 @@ open class EuiccChannelManagerService : LifecycleService(), OpenEuiccContextMark
// 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
backChannel.close()
}
.collect()
}
foregroundTaskRecords[taskID] = ForegroundTaskRecord(stateFlow.asSharedFlow(), backChannel)
foregroundTaskSubscribers[taskID] = subscriberFlow.asSharedFlow()
if (foregroundTaskRecords.size > 5) {
if (foregroundTaskSubscribers.size > 5) {
// Remove enough elements so that the size is kept at 5
for (key in foregroundTaskRecords.keys.sorted()
.take(foregroundTaskRecords.size - 5)) {
foregroundTaskRecords.remove(key)
for (key in foregroundTaskSubscribers.keys.sorted()
.take(foregroundTaskSubscribers.size - 5)) {
foregroundTaskSubscribers.remove(key)
}
}
@ -391,27 +373,32 @@ open class EuiccChannelManagerService : LifecycleService(), OpenEuiccContextMark
)
)
return ForegroundTaskHandle(
return ForegroundTaskSubscriberFlow(
taskID,
stateFlow.asSharedFlow().applyCompletionTransform(),
backChannel
subscriberFlow.asSharedFlow().applyCompletionTransform()
)
}
open suspend fun waitForForegroundTask() {
suspend fun waitForForegroundTask() {
foregroundTaskState.takeWhile { it != ForegroundTaskState.Idle }
.collect()
}
open fun launchProfileDownloadTask(
fun launchProfileDownloadTask(
slotId: Int, portId: Int, seId: EuiccChannel.SecureElementId,
input: ProfileDownloadInput,
): ForegroundTaskHandle =
// Optionally, a Channel to send confirmation signal when metadata preview is received.
// When we emit a ForegroundTaskState.InProgress with ProfileDownloadState.ConfirmingMetadata,
// the caller can send a true/false value into this channel to either continue immediately or cancel the download.
// Note that there is a timeout of 1 minute, after which we default to cancelling.
// When absent, the default value is just a buffered channel with 1 true value in it, so effectively no-op.
confirmationSignal: Channel<Boolean> = Channel<Boolean>(1).apply { trySendBlocking(true) }
): ForegroundTaskSubscriberFlow =
launchForegroundTask(
getString(R.string.task_profile_download),
getString(R.string.task_profile_download_failure),
R.drawable.ic_task_sim_card_download
) { backChannel ->
) {
euiccChannelManager.beginTrackedOperation(slotId, portId, seId) {
euiccChannelManager.withEuiccChannel(slotId, portId, seId) { channel ->
channel.lpa.downloadProfile(input) { state ->
@ -437,7 +424,7 @@ open class EuiccChannelManagerService : LifecycleService(), OpenEuiccContextMark
try {
// We can't wait indefinitely; just time out after 1 minute.
withTimeout(60 * 1000) {
backChannel.receive() as Boolean
confirmationSignal.receive()
}
} catch (_: TimeoutCancellationException) {
// Default to cancelling / aborting here if we didn't receive a confirmation signal
@ -460,12 +447,12 @@ open class EuiccChannelManagerService : LifecycleService(), OpenEuiccContextMark
seId: EuiccChannel.SecureElementId,
iccid: String,
name: String
): ForegroundTaskHandle =
): ForegroundTaskSubscriberFlow =
launchForegroundTask(
getString(R.string.task_profile_rename),
getString(R.string.task_profile_rename_failure),
R.drawable.ic_task_rename
) { _ ->
) {
euiccChannelManager.withEuiccChannel(slotId, portId, seId) { channel ->
channel.lpa.setNickname(
iccid,
@ -479,12 +466,12 @@ open class EuiccChannelManagerService : LifecycleService(), OpenEuiccContextMark
portId: Int,
seId: EuiccChannel.SecureElementId,
iccid: String
): ForegroundTaskHandle =
): ForegroundTaskSubscriberFlow =
launchForegroundTask(
getString(R.string.task_profile_delete),
getString(R.string.task_profile_delete_failure),
R.drawable.ic_task_delete
) { _ ->
) {
euiccChannelManager.beginTrackedOperation(slotId, portId, seId) {
euiccChannelManager.withEuiccChannel(slotId, portId, seId) { channel ->
channel.lpa.deleteProfile(iccid)
@ -503,12 +490,12 @@ open class EuiccChannelManagerService : LifecycleService(), OpenEuiccContextMark
iccid: String,
enable: Boolean, // Enable or disable the profile indicated in iccid
reconnectTimeoutMillis: Long = 0 // 0 = do not wait for reconnect
): ForegroundTaskHandle =
) =
launchForegroundTask(
getString(R.string.task_profile_switch),
getString(R.string.task_profile_switch_failure),
R.drawable.ic_task_switch
) { _ ->
) {
euiccChannelManager.beginTrackedOperation(slotId, portId, seId) {
val (response, refreshed) =
euiccChannelManager.withEuiccChannel(slotId, portId, seId) { channel ->
@ -561,12 +548,12 @@ open class EuiccChannelManagerService : LifecycleService(), OpenEuiccContextMark
slotId: Int,
portId: Int,
seId: EuiccChannel.SecureElementId
): ForegroundTaskHandle =
): ForegroundTaskSubscriberFlow =
launchForegroundTask(
getString(R.string.task_euicc_memory_reset),
getString(R.string.task_euicc_memory_reset_failure),
R.drawable.ic_euicc_memory_reset
) { _ ->
) {
euiccChannelManager.beginTrackedOperation(slotId, portId, seId) {
euiccChannelManager.withEuiccChannel(slotId, portId, seId) { channel ->
channel.lpa.euiccMemoryReset()

View file

@ -248,7 +248,7 @@ open class EuiccManagementFragment : Fragment(), EuiccProfilesChangedListener,
slotId, portId, seId, iccid, enable,
reconnectTimeoutMillis = 30 * 1000
)
.stateFlow.waitDone()
.waitDone()
when (err) {
null -> {}

View file

@ -97,7 +97,6 @@ class EuiccMemoryResetFragment : DialogFragment(), EuiccChannelFragmentMarker {
euiccChannelManagerService.waitForForegroundTask()
euiccChannelManagerService.launchMemoryReset(slotId, portId, seId)
.stateFlow
.onStart {
parentFragment?.notifyEuiccProfilesChanged()

View file

@ -91,7 +91,6 @@ class ProfileDeleteFragment : DialogFragment(), EuiccChannelFragmentMarker {
ensureEuiccChannelManager()
euiccChannelManagerService.waitForForegroundTask()
euiccChannelManagerService.launchProfileDeleteTask(slotId, portId, seId, iccid)
.stateFlow
.onStart {
parentFragment?.notifyEuiccProfilesChanged()
runCatching(::dismiss)

View file

@ -108,7 +108,7 @@ class ProfileRenameFragment : BaseMaterialDialogFragment(), EuiccChannelFragment
ensureEuiccChannelManager()
euiccChannelManagerService.waitForForegroundTask()
val response = euiccChannelManagerService
.launchProfileRenameTask(slotId, portId, seId, iccid, newName).stateFlow.waitDone()
.launchProfileRenameTask(slotId, portId, seId, iccid, newName).waitDone()
when (response) {
is LocalProfileAssistant.ProfileNameTooLongException -> {

View file

@ -5,11 +5,9 @@ import android.net.Uri
import android.os.Build
import android.os.Bundle
import android.provider.Settings
import android.widget.EditText
import android.widget.Toast
import androidx.lifecycle.lifecycleScope
import androidx.preference.CheckBoxPreference
import androidx.preference.EditTextPreference
import androidx.preference.ListPreference
import androidx.preference.Preference
import androidx.preference.PreferenceCategory
@ -76,9 +74,6 @@ open class SettingsFragment : PreferenceFragmentCompat(), OpenEuiccContextMarker
requirePreference<CheckBoxPreference>("pref_advanced_force_tpdu_mode")
.bindBooleanFlow(preferenceRepository.forceTpduModeFlow)
requirePreference<EditTextPreference>("pref_advanced_http_proxy")
.bindStringFlow(preferenceRepository.httpProxyFlow)
requirePreference<CheckBoxPreference>("pref_developer_unfiltered_profile_list")
.bindBooleanFlow(preferenceRepository.unfilteredProfileListFlow)
@ -150,19 +145,6 @@ open class SettingsFragment : PreferenceFragmentCompat(), OpenEuiccContextMarker
}
}
private fun EditTextPreference.bindStringFlow(flow: PreferenceFlowWrapper<String>) {
lifecycleScope.launch {
flow.collect(::setText)
}
setOnPreferenceChangeListener { _, newValue ->
lifecycleScope.launch {
flow.updatePreference(newValue as String)
}
true
}
}
private fun ListPreference.bindIntFlow(flow: PreferenceFlowWrapper<Int>, defaultValue: Int) {
lifecycleScope.launch {
flow.collect { value = it.toString() }

View file

@ -1,48 +0,0 @@
package im.angry.openeuicc.ui.wizard
import android.content.Intent
import android.os.Bundle
import android.widget.Toast
import androidx.appcompat.app.AlertDialog
import androidx.appcompat.app.AppCompatActivity
import androidx.core.net.toUri
import im.angry.openeuicc.common.R
import im.angry.openeuicc.util.*
class DeepLinkConfirmationActivity : AppCompatActivity() {
private val dialog by lazy {
AlertDialog.Builder(this, R.style.AlertDialogTheme)
.setTitle(R.string.deep_link_confirmation_dialog_title)
.setCancelable(false)
.setMessage(R.string.deep_link_confirmation_dialog_description)
.setNegativeButton(R.string.deep_link_confirmation_cancel_button) { _, _ ->
finish()
}
.setPositiveButton(R.string.deep_link_confirmation_setup_button) { _, _ ->
val intent = Intent(this, DownloadWizardActivity::class.java).apply {
data = "lpa:$lpaString".toUri()
}
startActivity(intent)
finish()
}
.create()
}
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
window.setBackgroundDrawableResource(android.R.color.transparent)
if (lpaString != null) {
dialog.show()
} else {
val resId = R.string.deep_link_confirmation_unable_to_read_url
Toast.makeText(this, resId, Toast.LENGTH_SHORT).show()
finish()
}
}
private val lpaString by lazy {
val lpaUri = intent?.data?.normalizeScheme()?.takeIf { it.scheme == "lpa" } ?: return@lazy null
runCatching { LPAString.parse(lpaUri.schemeSpecificPart) }.getOrNull()
}
}

View file

@ -135,8 +135,8 @@ class DownloadWizardActivity : BaseEuiccAccessActivity() {
// If we get an LPA string from deep-link intents, extract from there.
// Note that `onRestoreInstanceState` could override this with user input,
// but that _is_ the desired behavior.
val uri = intent.data?.normalizeScheme() ?: return
if (uri.scheme == "lpa") {
val uri = intent.data ?: return
if (uri.scheme.contentEquals("lpa", ignoreCase = true)) {
val parsed = LPAString.parse(uri.schemeSpecificPart)
state.smdp = parsed.address
state.matchingId = parsed.matchingId
@ -258,10 +258,7 @@ class DownloadWizardActivity : BaseEuiccAccessActivity() {
val clazz = Class.forName(state.currentStepFragmentClassName!!)
showFragment(clazz.getDeclaredConstructor().newInstance() as DownloadWizardStepFragment)
} else {
val nextFrag = if (DownloadWizardLowPowerFragment.isBatteryLow(this))
DownloadWizardLowPowerFragment() else
DownloadWizardSlotSelectFragment()
showFragment(nextFrag)
showFragment(DownloadWizardSlotSelectFragment())
}
}

View file

@ -1,41 +0,0 @@
package im.angry.openeuicc.ui.wizard
import android.content.Context
import android.content.Intent
import android.content.IntentFilter
import android.os.BatteryManager
import android.os.Bundle
import android.os.PowerManager
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import im.angry.openeuicc.common.R
class DownloadWizardLowPowerFragment : DownloadWizardActivity.DownloadWizardStepFragment() {
companion object {
/** Battery level threshold (in percentage) below which the battery is considered low. */
private const val BATTERY_LEVEL_THRESHOLD = 20
fun isBatteryLow(context: Context): Boolean {
val pm = context.getSystemService(PowerManager::class.java)
if (pm.isPowerSaveMode) return true
val filter = IntentFilter(Intent.ACTION_BATTERY_CHANGED)
val intent = context.registerReceiver(null, filter) ?: return false
val level = intent.getIntExtra(BatteryManager.EXTRA_LEVEL, 0)
return level <= BATTERY_LEVEL_THRESHOLD
}
}
override val hasNext: Boolean
get() = true
override val hasPrev: Boolean
get() = true
override fun createNextFragment() = DownloadWizardSlotSelectFragment()
override fun createPrevFragment() = null
override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View =
inflater.inflate(R.layout.fragment_download_low_power, container, /* attachToRoot = */ false)
}

View file

@ -91,14 +91,14 @@ class DownloadWizardProgressFragment : DownloadWizardActivity.DownloadWizardStep
showProgressBar(-1) // set indeterminate first
ensureEuiccChannelManager()
val handle = startDownloadOrSubscribe()
val subscriber = startDownloadOrSubscribe()
if (handle == null) {
if (subscriber == null) {
requireActivity().finish()
return@launch
}
handle.stateFlow.onEach {
subscriber.onEach {
when (it) {
is EuiccChannelManagerService.ForegroundTaskState.Done -> {
hideProgressBar()
@ -134,24 +134,27 @@ class DownloadWizardProgressFragment : DownloadWizardActivity.DownloadWizardStep
}
}
private suspend fun startDownloadOrSubscribe(): EuiccChannelManagerService.ForegroundTaskHandle? =
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 (logicalSlotId, seId) = DownloadWizardSlotSelectFragment.decodeSyntheticSlotId(state.selectedSyntheticSlotId)
val (slotId, portId) = euiccChannelManager.withEuiccChannel(
logicalSlotId,
seId
) { 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
// NOTE: Keep using launchProfileDownload() here; the UI ↔ service
// contract it implements (launch + auto-confirm metadata via the back
// channel) is covered by DownloadTaskLauncherTest.
val ret = launchProfileDownload(
euiccChannelManagerService,
euiccChannelManager,
logicalSlotId,
seId,
val ret = euiccChannelManagerService.launchProfileDownloadTask(
slotId, portId, seId,
ProfileDownloadInput(state.smdp, state.matchingId, state.imei, state.confirmationCode)
)

View file

@ -1,39 +0,0 @@
package im.angry.openeuicc.ui.wizard
import im.angry.openeuicc.core.EuiccChannel
import im.angry.openeuicc.core.EuiccChannelManager
import im.angry.openeuicc.service.EuiccChannelManagerService
import net.typeblog.lpac_jni.ProfileDownloadInput
/**
* Launch a profile download through EuiccChannelManagerService on behalf of a UI
* component, and auto-confirm the metadata-confirmation step (for now).
*
* This is a helper function in order to run tests against.
*
* TODO: Remove this when we move towards more testable UI components (like Jetpack Compose).
* Also probably won't need this when we implement real confirmation in UI.
*/
suspend fun launchProfileDownload(
service: EuiccChannelManagerService,
manager: EuiccChannelManager,
logicalSlotId: Int,
seId: EuiccChannel.SecureElementId,
input: ProfileDownloadInput,
autoConfirm: Boolean = true
): EuiccChannelManagerService.ForegroundTaskHandle {
service.waitForForegroundTask()
val (slotId, portId) = manager.withEuiccChannel(logicalSlotId, seId) { channel ->
Pair(channel.slotId, channel.portId)
}
val handle = service.launchProfileDownloadTask(slotId, portId, seId, input)
if (autoConfirm) {
// TODO: Ask user to confirm metadata for real if we need it
handle.backChannel.send(true)
}
return handle
}

View file

@ -40,7 +40,6 @@ internal object PreferenceKeys {
val IGNORE_TLS_CERTIFICATE = booleanPreferencesKey("ignore_tls_certificate")
val ISDR_AID_LIST = stringPreferencesKey("isdr_aid_list")
val ES10X_MSS = intPreferencesKey("es10x_mss")
val HTTP_PROXY = stringPreferencesKey("http_proxy")
}
const val EUICC_DEFAULT_ISDR_AID = "A0000005591010FFFFFFFF8900000100"
@ -102,7 +101,6 @@ open class PreferenceRepository(private val context: Context) {
{ Base64.getEncoder().encodeToString(it.encodeToByteArray()) },
{ Base64.getDecoder().decode(it).decodeToString() })
val es10xMssFlow = bindFlow(PreferenceKeys.ES10X_MSS, 63)
val httpProxyFlow = bindFlow(PreferenceKeys.HTTP_PROXY, "")
protected fun <T> bindFlow(
key: Preferences.Key<T>,

View file

@ -1,36 +0,0 @@
<?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">
<TextView
android:id="@+id/download_low_power_title"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginStart="60dp"
android:layout_marginTop="20dp"
android:layout_marginEnd="60dp"
android:layout_marginBottom="20dp"
android:gravity="center_horizontal"
android:text="@string/download_wizard_low_power"
android:textSize="20sp"
app:layout_constrainedWidth="true"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent" />
<TextView
android:layout_width="0dp"
android:layout_height="wrap_content"
android:padding="10dp"
android:text="@string/download_wizard_low_power_content"
android:textIsSelectable="true"
android:textSize="20sp"
app:layout_constrainedHeight="true"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@id/download_low_power_title" />
</androidx.constraintlayout.widget.ConstraintLayout>

View file

@ -55,8 +55,6 @@
<string name="download_wizard">ダウンロードウィザード</string>
<string name="download_wizard_back">戻る</string>
<string name="download_wizard_next">次へ</string>
<string name="download_wizard_low_power">バッテリー残量低下</string>
<string name="download_wizard_low_power_content">バッテリー残量低下で突然シャットダウンする可能性があるため、<b>ダウンロード処理が中断される場合があります</b>。\n\nリスクがあることをご理解の上、「<b>次へ</b>」をクリックしてダウンロードを続行してください。</string>
<string name="download_wizard_slot_removed">選択した SIM が削除されました</string>
<string name="download_wizard_slot_select">ダウンロードする eSIM を選択または確認:</string>
<string name="download_wizard_slot_type">タイプ:</string>
@ -110,12 +108,6 @@
<string name="download_wizard_error_suggest_contact_carrier">サポートについては、通信事業者にお問い合わせください。</string>
<string name="download_wizard_error_suggest_contact_reissue">この eSIM プロファイルを再発行するには、通信事業者にお問い合わせください。</string>
<string name="download_wizard_error_suggest_network_unreachable">別のネットワークに接続後 (例: Wi-Fi とデータを切り替え) を行った後に再度お試しください。</string>
<string name="deep_link_confirmation">ディープリンク確認</string>
<string name="deep_link_confirmation_dialog_title">eSIM をセットアップ</string>
<string name="deep_link_confirmation_dialog_description">"セットアップに必要な一部のデバイス情報がキャリアに送信されます。"</string>
<string name="deep_link_confirmation_cancel_button">キャンセル</string>
<string name="deep_link_confirmation_setup_button">セットアップ</string>
<string name="deep_link_confirmation_unable_to_read_url">"eSIM のセットアップリンクに問題があります。キャリアにご連絡ください。"</string>
<string name="logs_saved_message">ログは共有したパスに保存されました。別のアプリで共有しますか?</string>
<string name="profile_rename_new_name">新しいニックネーム</string>
<string name="profile_rename_encoding_error">ニックネームを UTF-8 にエンコードできませんでした</string>
@ -186,9 +178,6 @@
<string name="pref_advanced_language_desc">アプリの言語を設定します。</string>
<string name="pref_advanced_logs">ログ</string>
<string name="pref_advanced_logs_desc">アプリの最新デバッグログを表示します。</string>
<string name="pref_advanced_http_proxy">HTTP プロキシ</string>
<string name="pref_advanced_http_proxy_desc">送信リクエスト用の HTTP プロキシ</string>
<string name="pref_advanced_http_proxy_dialog_message">プロキシは次の形式で入力してください「http://&lt;host&gt;:&lt;port&gt;」。プロキシを無効にするには、空欄のままにしてください。</string>
<string name="pref_developer">開発者オプション</string>
<string name="pref_developer_refresh_after_switch">モデムに更新コマンドを送信</string>
<string name="pref_developer_refresh_after_switch_desc">プロファイルを切り替えた後にモデムに更新コマンドを送信するかどうかを設定します。クラッシュが発生する場合は、この機能を無効化してください。</string>

View file

@ -46,12 +46,6 @@
<string name="profile_download_confirmation_code_required">确认码 (必须)</string>
<string name="profile_download_imei">IMEI (可选)</string>
<string name="download_wizard_error_suggest_network_unreachable">请连接到其他网络(例如在 Wi-Fi 和数据之间切换)后重试。</string>
<string name="deep_link_confirmation">深度链接确认</string>
<string name="deep_link_confirmation_dialog_title">设置您的 eSIM</string>
<string name="deep_link_confirmation_dialog_description">"设置所需的一些设备信息将被发送到运营商。"</string>
<string name="deep_link_confirmation_cancel_button">取消</string>
<string name="deep_link_confirmation_setup_button">设置</string>
<string name="deep_link_confirmation_unable_to_read_url">"eSIM 的设置链接有问题,请联系您的运营商寻求帮助。"</string>
<string name="logs_saved_message">日志已保存到指定路径。需要通过其他 App 分享吗?</string>
<string name="profile_rename_new_name">新昵称</string>
<string name="profile_rename_encoding_error">无法将昵称编码为 UTF-8</string>
@ -88,13 +82,8 @@
<string name="pref_advanced_verbose_logging_desc">详细日志中包含敏感信息,开启此功能后请仅与你信任的人共享你的日志。</string>
<string name="pref_advanced_force_tpdu_mode">强制 USB 读卡器使用 TPDU 模式</string>
<string name="pref_advanced_force_tpdu_mode_desc">强制所有 USB CCID 读卡器使用 TPDU 模式。仅在您的 USB 读卡器仅支持 TPDU 模式时启用。</string>
<string name="pref_advanced_language">语言</string>
<string name="pref_advanced_language_desc">选择 App 语言</string>
<string name="pref_advanced_logs">日志</string>
<string name="pref_advanced_logs_desc">查看应用程序的最新调试日志</string>
<string name="pref_advanced_http_proxy">HTTP 代理</string>
<string name="pref_advanced_http_proxy_desc">用于出站请求的 HTTP 代理</string>
<string name="pref_advanced_http_proxy_dialog_message">代理服务器地址应采用此格式 \"http://&lt;host&gt;:&lt;port&gt;\"。留空则表示禁用代理。</string>
<string name="pref_developer_isdr_aid_list_desc">某些品牌的可移除 eSIM 可能会使用自己的非标准 ISD-R AID导致第三方应用无法访问。此 App 可以尝试使用此列表中添加的非标准 AID但不能保证它们一定有效。</string>
<string name="pref_developer_es10x_mss_desc">全局 ES10x MSS</string>
<string-array name="pref_developer_es10x_entry_keys">
@ -114,8 +103,6 @@
<string name="download_wizard">下载向导</string>
<string name="download_wizard_back">返回</string>
<string name="download_wizard_next">下一步</string>
<string name="download_wizard_low_power">低电量警告</string>
<string name="download_wizard_low_power_content">由于电量不足或电池电量低时突然关机,<b>您的下载过程可能会意外中断</b>。\n\n如果您认为继续下载无风险请点击 “<b>下一步</b>” 以开始下载。</string>
<string name="download_wizard_slot_removed">您选择的 SIM 已被移除</string>
<string name="download_wizard_slot_select">请选择或确认下载目标 eSIM 卡槽:</string>
<string name="download_wizard_slot_type">类型:</string>
@ -163,6 +150,8 @@
<string name="euicc_info_no"></string>
<string name="developer_options_steps">还有 %d 步成为开发者</string>
<string name="developer_options_enabled">你现在是开发者了!</string>
<string name="pref_advanced_language">语言</string>
<string name="pref_advanced_language_desc">选择 App 语言</string>
<string name="pref_developer">开发者选项</string>
<string name="pref_developer_refresh_after_switch_desc">切换配置文件后是否向基带发送刷新命令。如果发现崩溃,请尝试禁用此功能。</string>
<string name="pref_developer_unfiltered_profile_list">显示未经过滤的配置文件列表</string>

View file

@ -46,12 +46,6 @@
<string name="profile_download_confirmation_code_required">確認碼 (必須)</string>
<string name="profile_download_imei">IMEI (可選)</string>
<string name="download_wizard_error_suggest_network_unreachable">請連線到其他網路(例如在 Wi-Fi 和行動網路之間切換)後重試。</string>
<string name="deep_link_confirmation">深層連結確認</string>
<string name="deep_link_confirmation_dialog_title">設定您的 eSIM</string>
<string name="deep_link_confirmation_dialog_description">"設定所需的一些裝置資訊將被傳送到電信業者。"</string>
<string name="deep_link_confirmation_cancel_button">取消</string>
<string name="deep_link_confirmation_setup_button">設定</string>
<string name="deep_link_confirmation_unable_to_read_url">"eSIM 的設定連結有問題,請聯絡您的電信業者尋求協助。"</string>
<string name="logs_saved_message">日誌已儲存到指定路徑。需要透過其他 App 分享嗎?</string>
<string name="profile_rename_new_name">新名稱</string>
<string name="profile_rename_encoding_error">無法將名稱編碼為 UTF-8</string>
@ -84,9 +78,6 @@
<string name="pref_advanced_verbose_logging_desc">詳細日誌中包含敏感資訊,開啟此功能後請僅與你信任的人共享你的日誌。</string>
<string name="pref_advanced_force_tpdu_mode">強制 USB 讀卡機使用 TPDU 模式</string>
<string name="pref_advanced_force_tpdu_mode_desc">強制所有 USB CCID 讀卡機使用 TPDU 模式。僅在您的 USB 讀卡機僅支援 TPDU 模式時啟用。</string>
<string name="pref_advanced_http_proxy">HTTP 代理伺服器</string>
<string name="pref_advanced_http_proxy_desc">用於出站請求的 HTTP 代理</string>
<string name="pref_advanced_http_proxy_dialog_message">代理伺服器的格式應為「http://&lt;host&gt;:&lt;port&gt;」。若要「停用」代理伺服器,請保持空白。</string>
<string name="pref_advanced_logs">日誌</string>
<string name="pref_advanced_logs_desc">檢視應用程式的最新除錯日誌</string>
<string name="pref_notifications_switch_desc">傳送 <i>切換</i> 設定檔的通知\n注意這個狀態的通知不一定有用。</string>
@ -108,8 +99,6 @@
<string name="download_wizard">下載精靈</string>
<string name="download_wizard_back">返回</string>
<string name="download_wizard_next">下一步</string>
<string name="download_wizard_low_power">低電量警告</string>
<string name="download_wizard_low_power_content">由於電量不足或電池電量低時突然關機,<b>您的下載過程可能會意外中斷</b>。\n\n如果您認為繼續下載無風險請點選「<b>下一步</b>」以開始下載。</string>
<string name="download_wizard_slot_removed">您選擇的 SIM 卡已被移除</string>
<string name="download_wizard_slot_select">請選擇或確認下載目標 eSIM 卡槽:</string>
<string name="download_wizard_slot_type">類型:</string>

View file

@ -74,8 +74,6 @@
<string name="download_wizard">Download Wizard</string>
<string name="download_wizard_back">Back</string>
<string name="download_wizard_next">Next</string>
<string name="download_wizard_low_power">WARNING: Low Battery</string>
<string name="download_wizard_low_power_content">Your download process may be unexpectedly interrupted due to sudden shutdown.\n\nClick "Next" to acknowledge this risk and continue download.</string>
<string name="download_wizard_slot_removed">Selected SIM has been removed</string>
<string name="download_wizard_slot_select">Select or confirm the eSIM you would like to download to:</string>
<string name="download_wizard_slot_type">Type:</string>
@ -131,13 +129,6 @@
<string name="download_wizard_error_suggest_contact_reissue">Please contact your carrier to reissue this eSIM profile.</string>
<string name="download_wizard_error_suggest_network_unreachable">Please try again after connecting to a different network (e.g. switching between Wi-Fi and data).</string>
<string name="deep_link_confirmation">Deep Link Confirmation</string>
<string name="deep_link_confirmation_dialog_title">Setup your eSIM</string>
<string name="deep_link_confirmation_dialog_description">"Some device info that's needed for setup will be sent to your carrier."</string>
<string name="deep_link_confirmation_cancel_button">Cancel</string>
<string name="deep_link_confirmation_setup_button">Setup</string>
<string name="deep_link_confirmation_unable_to_read_url">"There's an issue with the setup link for your eSIM. Contact your carrier for help."</string>
<string name="logs_saved_message">Logs have been saved to the selected path. Would you like to share the log through another app?</string>
<string name="profile_rename_new_name">New nickname</string>
@ -223,9 +214,6 @@
<string name="pref_advanced_language_desc">Select app language</string>
<string name="pref_advanced_logs">Logs</string>
<string name="pref_advanced_logs_desc">View recent debug logs of the application</string>
<string name="pref_advanced_http_proxy">HTTP Proxy</string>
<string name="pref_advanced_http_proxy_desc">HTTP proxy for outgoing requests</string>
<string name="pref_advanced_http_proxy_dialog_message">Specify a proxy in the format \"http://&lt;host&gt;:&lt;port&gt;\". Leave blank to DISABLE.</string>
<string name="pref_developer">Developer Options</string>
<string name="pref_developer_refresh_after_switch">Send refresh command to modem</string>
<string name="pref_developer_refresh_after_switch_desc">Whether to send a refresh command to the modem after switching profiles. Try disabling this if you see crashes.</string>

View file

@ -1,7 +0,0 @@
<resources>
<style name="Theme.App.DeepLinkTransparent" parent="Theme.Material3.DayNight.NoActionBar">
<item name="android:windowIsTranslucent">true</item>
<item name="android:windowBackground">@android:color/transparent</item>
<item name="android:backgroundDimEnabled">true</item>
</style>
</resources>

View file

@ -24,7 +24,6 @@
<PreferenceCategory
app:iconSpaceReserved="false"
app:key="pref_advanced"
app:title="@string/pref_advanced">
<CheckBoxPreference
app:iconSpaceReserved="false"
@ -44,13 +43,6 @@
app:summary="@string/pref_advanced_force_tpdu_mode_desc"
app:title="@string/pref_advanced_force_tpdu_mode" />
<EditTextPreference
app:dialogMessage="@string/pref_advanced_http_proxy_dialog_message"
app:iconSpaceReserved="false"
app:key="pref_advanced_http_proxy"
app:summary="@string/pref_advanced_http_proxy_desc"
app:title="@string/pref_advanced_http_proxy" />
<Preference
app:iconSpaceReserved="false"
app:isPreferenceVisible="false"

View file

@ -0,0 +1,16 @@
package im.angry.openeuicc.common
import org.junit.Assert.assertEquals
import org.junit.Test
/**
* Example local unit test, which will execute on the development machine (host).
*
* See [testing documentation](http://d.android.com/tools/testing).
*/
class ExampleUnitTest {
@Test
fun addition_isCorrect() {
assertEquals(4, 2 + 2)
}
}

View file

@ -1,135 +0,0 @@
package im.angry.openeuicc.service
import android.content.Intent
import im.angry.openeuicc.core.EuiccChannel
import im.angry.openeuicc.service.EuiccChannelManagerService.ForegroundTaskState
import im.angry.openeuicc.testutil.MockEuiccChannel
import im.angry.openeuicc.testutil.MockEuiccChannelManager
import im.angry.openeuicc.testutil.MockLpa
import im.angry.openeuicc.testutil.TestOpenEuiccApplication
import im.angry.openeuicc.testutil.awaitMainLooper
import android.os.Looper
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.async
import kotlinx.coroutines.coroutineScope
import kotlinx.coroutines.runBlocking
import org.robolectric.Shadows.shadowOf
import net.typeblog.lpac_jni.ProfileDownloadInput
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertNull
import org.junit.Before
import org.junit.Test
import org.junit.runner.RunWith
import org.robolectric.Robolectric
import org.robolectric.RobolectricTestRunner
import org.robolectric.annotation.Config
/**
* Tests the real EuiccChannelManagerService (Robolectric) with only the manager /
* channel / LPA mocked -- i.e. the exact service that runs in production.
*
* These tests pin the profile-download confirmation contract that regressed
* during the "merge back-communication channels" refactor: the task MUST block
* at ProfileDownloadState.ConfirmingDownload until a Boolean is sent on the
* handle's back channel. `true` continues the download, `false` cancels it.
* UI callers are expected to send that signal through launchProfileDownload()
* (see DownloadTaskLauncherTest).
*/
@RunWith(RobolectricTestRunner::class)
@Config(application = TestOpenEuiccApplication::class)
class EuiccChannelManagerServiceTest {
private val seId = EuiccChannel.SecureElementId.DEFAULT
private val input = ProfileDownloadInput(
address = "smdp.example.com",
matchingId = "matching-id",
imei = "imei",
confirmationCode = "conf-code"
)
private lateinit var service: EuiccChannelManagerService
private lateinit var lpa: MockLpa
@Before
fun setUp() {
val channel = MockEuiccChannel(slotId = 1, portId = 2)
lpa = channel.lpa as MockLpa
TestOpenEuiccApplication.mockEuiccChannelManager = MockEuiccChannelManager(channel)
service = Robolectric.buildService(EuiccChannelManagerService::class.java).get()
}
/**
* The service self-starts via startForegroundService(); Robolectric does not
* deliver that to onStartCommand() on its own, so simulate the system call.
* Must be invoked after the launch coroutine has had a chance to subscribe to
* foregroundStarted (otherwise the emission is dropped -- foregroundStarted
* has no replay). In production the system delivers the start asynchronously,
* so the subscriber is always ready by then.
*/
private fun startService() {
shadowOf(Looper.getMainLooper()).idle()
service.onStartCommand(Intent(), 0, 1)
}
private suspend fun awaitTaskDone(
handle: EuiccChannelManagerService.ForegroundTaskHandle
): ForegroundTaskState.Done = coroutineScope {
val states = mutableListOf<ForegroundTaskState>()
val collector = async(Dispatchers.Default) { handle.stateFlow.collect { states += it } }
try {
awaitMainLooper {
states.any { it is ForegroundTaskState.Done }
}
} finally {
collector.cancel()
}
states.last() as ForegroundTaskState.Done
}
@Test
fun `download task blocks at ConfirmingDownload until the subscriber sends true`() = runBlocking {
val handle = service.launchProfileDownloadTask(1, 2, seId, input)
startService()
// The task must reach the LPA and stall at the confirmation step.
awaitMainLooper { lpa.downloadStarted.isCompleted }
assertFalse(
"download must be waiting for confirmation",
lpa.downloadReturned.isCompleted
)
// A subscriber (e.g. DownloadWizardProgressFragment) confirms via the back channel.
handle.backChannel.send(true)
// The LPA callback must have been told to continue.
assertEquals(true, lpa.awaitDownloadResult())
// The task must then complete successfully.
val done = awaitTaskDone(handle)
assertNull(done.error)
}
@Test
fun `download task cancels when the subscriber sends false`() = runBlocking {
val handle = service.launchProfileDownloadTask(1, 2, seId, input)
startService()
awaitMainLooper { lpa.downloadStarted.isCompleted }
assertFalse(
"download must be waiting for confirmation",
lpa.downloadReturned.isCompleted
)
// A subscriber decides to cancel.
handle.backChannel.send(false)
// The LPA callback must have been told to cancel.
assertEquals(false, lpa.awaitDownloadResult())
// The task itself still completes (the cancellation is communicated via the
// callback result, not an exception).
val done = awaitTaskDone(handle)
assertNull(done.error)
}
}

View file

@ -1,209 +0,0 @@
package im.angry.openeuicc.testutil
import im.angry.openeuicc.core.EuiccChannel
import im.angry.openeuicc.core.EuiccChannelManager
import im.angry.openeuicc.util.FakeUiccCardInfoCompat
import im.angry.openeuicc.util.FakeUiccPortInfoCompat
import kotlinx.coroutines.CompletableDeferred
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.flowOf
import net.typeblog.lpac_jni.ApduInterface
import net.typeblog.lpac_jni.EuiccInfo2
import net.typeblog.lpac_jni.LocalProfileAssistant
import net.typeblog.lpac_jni.LocalProfileInfo
import net.typeblog.lpac_jni.LocalProfileNotification
import net.typeblog.lpac_jni.ProfileDownloadCallback
import net.typeblog.lpac_jni.ProfileDownloadInput
import net.typeblog.lpac_jni.ProfileDownloadState
/**
* Mocks for the dependencies *below* EuiccChannelManagerService (manager
* channel LPA), used by Robolectric tests that exercise the real service.
*
* The service itself is never mocked; tests build it with
* Robolectric.buildService() and only swap out what the service talks to.
*/
/**
* A LocalProfileAssistant whose downloadProfile() simulates a real download:
* it reports the states in [preConfirmationStates] in order, then reports
* ProfileDownloadState.ConfirmingDownload and blocks until the caller resolves
* the confirmation (which, in production, the service does by waiting on the
* task's back channel). The callback's return value is recorded so tests can
* assert whether the download was confirmed or cancelled.
*
* By default (no pre-confirmation states) it jumps straight to
* ConfirmingDownload, matching the historical behavior of this mock.
*
* Optional controls to simulate realistic downloads:
* - [failAfter]: throw a ProfileDownloadException right after emitting this
* state, simulating e.g. the SM-DP+ rejecting the device during
* authentication (before any metadata confirmation happens).
* - [holdAt]: park downloadProfile() (blocking the service's IO thread) after
* emitting this state until [releaseHold] is called, so tests can inspect
* the UI mid-download.
*/
class MockLpa(
// Mutable so tests sharing a common setUp() can configure the mock right
// before starting the download.
var preConfirmationStates: List<ProfileDownloadState> = emptyList(),
var failAfter: ProfileDownloadState? = null,
var holdAt: ProfileDownloadState? = null,
) : LocalProfileAssistant {
/** Completes as soon as downloadProfile() is entered (task reached the LPA). */
val downloadStarted = CompletableDeferred<Unit>()
/** Completes with the callback result once downloadProfile() returns. */
val downloadReturned = CompletableDeferred<Boolean>()
/** The input passed to downloadProfile(). */
var downloadInput: ProfileDownloadInput? = null
/** Completes once the given state class has been emitted to the callback. */
private val stateReached = mutableMapOf<kotlin.reflect.KClass<out ProfileDownloadState>, CompletableDeferred<Unit>>()
/** Released by the test to let a download parked at [holdAt] continue. */
private val holdRelease = CompletableDeferred<Unit>()
/** The exception thrown when [failAfter] is reached; mutable so tests can inject a realistic one. */
var failure: LocalProfileAssistant.ProfileDownloadException = LocalProfileAssistant.ProfileDownloadException(
lpaErrorReason = "ES10B_ERROR_REASON_UNDEFINED",
lastHttpResponse = null,
lastHttpException = null,
lastApduResponse = null,
lastApduException = null,
)
override fun downloadProfile(input: ProfileDownloadInput, callback: ProfileDownloadCallback) {
downloadInput = input
downloadStarted.complete(Unit)
for (state in preConfirmationStates) {
// The callback is invoked first so that the service can record the
// progress update before the mock advances or fails.
callback.onStatusUpdate(state)
stateReached.getOrPut(state::class) { CompletableDeferred() }.complete(Unit)
// ProfileDownloadState subclasses are plain classes without equals(),
// so compare by class, not by instance.
if (failAfter != null && state::class == failAfter!!::class) {
throw failure
}
if (holdAt != null && state::class == holdAt!!::class) {
// Park the download here (on the service's IO thread) so the test
// can inspect intermediate UI state; the test must call releaseHold().
kotlinx.coroutines.runBlocking { holdRelease.await() }
}
}
// This blocks the caller (the service's IO thread) until the back channel
// receives a Boolean -- exactly like a real download reaching the
// ConfirmingDownload step.
val result = callback.onStatusUpdate(ProfileDownloadState.ConfirmingDownload(null))
downloadReturned.complete(result)
}
/** Whether the given state class has already been emitted by downloadProfile(). */
fun isStateReached(state: ProfileDownloadState): Boolean =
stateReached[state::class]?.isCompleted == true
/** Releases a download parked at [holdAt]. */
fun releaseHold() {
holdRelease.complete(Unit)
}
suspend fun awaitDownloadResult(): Boolean = downloadReturned.await()
// ---- trivial implementations for the rest of the LPA surface ----
override val valid = true
override val profiles = emptyList<LocalProfileInfo>()
override val notifications = emptyList<LocalProfileNotification>()
override val eID = "mock-eid"
override val euiccInfo2: EuiccInfo2? = null
override fun setEs10xMss(mss: Byte) {}
override fun enableProfile(iccid: String, refresh: Boolean): Boolean = true
override fun disableProfile(iccid: String, refresh: Boolean): Boolean = true
override fun deleteProfile(iccid: String): Boolean = true
override fun deleteNotification(seqNumber: Long): Boolean = true
override fun handleNotification(seqNumber: Long): Boolean = true
override fun euiccMemoryReset() {}
override fun setNickname(iccid: String, nickname: String) {}
override fun close() {}
}
/**
* A EuiccChannel that always "finds" the given LPA and physical slot/port.
*/
class MockEuiccChannel(
override val slotId: Int,
override val portId: Int,
override val lpa: LocalProfileAssistant = MockLpa()
) : EuiccChannel {
override val type = "mock"
override val port = FakeUiccPortInfoCompat(FakeUiccCardInfoCompat(slotId))
override val logicalSlotId = slotId
override val seId = EuiccChannel.SecureElementId.DEFAULT
override var hasMultipleSE = false
override val valid = true
override val atr: ByteArray? = null
override val apduInterface = object : ApduInterface {
override fun connect() {}
override fun disconnect() {}
override fun logicalChannelOpen(aid: ByteArray): Int = 1
override fun logicalChannelClose(handle: Int) {}
override fun transmit(handle: Int, tx: ByteArray): ByteArray = ByteArray(0)
override val valid = true
}
override val isdrAid = ByteArray(0)
override fun close() {}
}
/**
* A EuiccChannelManager that hands out the same [channel] for every request and
* records how it was asked to resolve channels (slot/port pairs).
*/
class MockEuiccChannelManager(val channel: EuiccChannel) : EuiccChannelManager {
/** Every withEuiccChannel(physical slot/port) request, in order. */
val physicalChannelRequests = mutableListOf<Pair<Int, Int>>()
/** Every withEuiccChannel(logical slot) request, in order. */
val logicalChannelRequests = mutableListOf<Int>()
override fun flowInternalEuiccPorts(): Flow<Pair<Int, Int>> =
flowOf(Pair(channel.slotId, channel.portId))
override fun flowAllOpenEuiccPorts(): Flow<Pair<Int, Int>> =
flowOf(Pair(channel.slotId, channel.portId))
override fun flowEuiccSecureElements(slotId: Int, portId: Int): Flow<EuiccChannel.SecureElementId> =
flowOf(channel.seId)
override suspend fun tryOpenUsbEuiccChannel(): Pair<android.hardware.usb.UsbDevice?, Boolean> =
null to true
override suspend fun waitForReconnect(physicalSlotId: Int, portId: Int, timeoutMillis: Long) {}
override suspend fun findFirstAvailablePort(physicalSlotId: Int): Int = channel.portId
override suspend fun findAvailablePorts(physicalSlotId: Int): List<Int> = listOf(channel.portId)
override suspend fun <R> withEuiccChannel(
physicalSlotId: Int,
portId: Int,
seId: EuiccChannel.SecureElementId,
fn: suspend (EuiccChannel) -> R
): R {
physicalChannelRequests += physicalSlotId to portId
return fn(channel)
}
override suspend fun <R> withEuiccChannel(
logicalSlotId: Int,
seId: EuiccChannel.SecureElementId,
fn: suspend (EuiccChannel) -> R
): R {
logicalChannelRequests += logicalSlotId
return fn(channel)
}
override fun invalidate() {}
}

View file

@ -1,25 +0,0 @@
package im.angry.openeuicc.testutil
import android.os.Looper
import kotlinx.coroutines.withTimeout
import kotlinx.coroutines.yield
import org.robolectric.Shadows.shadowOf
import kotlin.time.Duration.Companion.milliseconds
/**
* The service runs its foreground-task machinery on Dispatchers.Main (the
* Robolectric main looper), which is paused by default. Tests must pump the
* looper while they wait for service-side effects; this helper idles the main
* looper until [condition] becomes true (or [timeoutMillis] elapses).
*/
suspend fun awaitMainLooper(
timeoutMillis: Long = 10_000,
condition: () -> Boolean
) {
withTimeout(timeoutMillis.milliseconds) {
while (!condition()) {
shadowOf(Looper.getMainLooper()).idle()
yield()
}
}
}

View file

@ -1,43 +0,0 @@
package im.angry.openeuicc.testutil
import android.app.Service
import android.content.Context
import im.angry.openeuicc.OpenEuiccApplication
import im.angry.openeuicc.core.EuiccChannelManager
import im.angry.openeuicc.core.EuiccChannelManagerFactory
import im.angry.openeuicc.di.AppContainer
import im.angry.openeuicc.di.DefaultAppContainer
import im.angry.openeuicc.util.PreferenceRepository
/**
* Application used by Robolectric tests: swaps the app container's
* EuiccChannelManagerFactory so that the *real* EuiccChannelManagerService
* receives a mock EuiccChannelManager (and LPA) when it asks for one.
*
* The mock must be installed before the service is built:
* `TestOpenEuiccApplication.mockEuiccChannelManager = ...`
*/
class TestOpenEuiccApplication : OpenEuiccApplication() {
companion object {
lateinit var mockEuiccChannelManager: EuiccChannelManager
}
override val appContainer: AppContainer by lazy {
TestAppContainer(this, mockEuiccChannelManager)
}
}
class TestAppContainer(
context: Context,
private val mockEuiccChannelManager: EuiccChannelManager
) : DefaultAppContainer(context) {
override val euiccChannelManagerFactory: EuiccChannelManagerFactory =
object : EuiccChannelManagerFactory {
override fun createEuiccChannelManager(serviceContext: Service): EuiccChannelManager =
mockEuiccChannelManager
}
// Real PreferenceRepository (DataStore-backed) is fine: it returns defaults.
override val preferenceRepository: PreferenceRepository =
PreferenceRepository(context)
}

View file

@ -1,220 +0,0 @@
package im.angry.openeuicc.ui.wizard
import android.content.Intent
import im.angry.openeuicc.core.EuiccChannel
import im.angry.openeuicc.service.EuiccChannelManagerService
import im.angry.openeuicc.testutil.MockEuiccChannel
import im.angry.openeuicc.testutil.MockEuiccChannelManager
import im.angry.openeuicc.testutil.MockLpa
import im.angry.openeuicc.testutil.TestOpenEuiccApplication
import im.angry.openeuicc.testutil.awaitMainLooper
import android.os.Looper
import kotlinx.coroutines.async
import kotlinx.coroutines.coroutineScope
import kotlinx.coroutines.runBlocking
import kotlinx.coroutines.yield
import org.robolectric.Shadows.shadowOf
import kotlinx.coroutines.withTimeout
import net.typeblog.lpac_jni.ProfileDownloadInput
import net.typeblog.lpac_jni.ProfileDownloadState
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertNull
import org.junit.Assert.assertTrue
import org.junit.Before
import org.junit.Test
import org.junit.runner.RunWith
import org.robolectric.Robolectric
import org.robolectric.RobolectricTestRunner
import org.robolectric.annotation.Config
import kotlin.time.Duration.Companion.milliseconds
/**
* Tests launchProfileDownload(), the freestanding implementation of the
* UI service contract for profile download, against the REAL
* EuiccChannelManagerService (built with Robolectric). Only the manager /
* channel / LPA below the service are mocked (see testutil.Mocks).
*
* DownloadWizardProgressFragment MUST call launchProfileDownload() and not
* re-implement the launch + auto-confirmation inline; the tests here only
* protect code that actually runs in production. If you change the behavior
* verified in this file, update the fragment at the same time.
*/
@RunWith(RobolectricTestRunner::class)
@Config(application = TestOpenEuiccApplication::class)
class DownloadTaskLauncherTest {
private val seId = EuiccChannel.SecureElementId.DEFAULT
private val input = ProfileDownloadInput(
address = "smdp.example.com",
matchingId = "matching-id",
imei = "imei",
confirmationCode = "conf-code"
)
private lateinit var service: EuiccChannelManagerService
private lateinit var channel: MockEuiccChannel
private lateinit var manager: MockEuiccChannelManager
@Before
fun setUp() {
channel = MockEuiccChannel(slotId = 1, portId = 2)
manager = MockEuiccChannelManager(channel)
TestOpenEuiccApplication.mockEuiccChannelManager = manager
service = Robolectric.buildService(EuiccChannelManagerService::class.java).get()
}
/**
* The service self-starts via startForegroundService(); Robolectric does not
* deliver that to onStartCommand() on its own, so simulate the system call.
* Must be invoked after the launch coroutine has had a chance to subscribe to
* foregroundStarted (otherwise the emission is dropped -- foregroundStarted
* has no replay). In production the system delivers the start asynchronously,
* so the subscriber is always ready by then.
*
* When launchProfileDownload is running inside an `async` coroutine, we must
* first yield() so it can call launchProfileDownloadTask() and park at
* backChannel.send(true); only then does the service's launch coroutine exist
* and subscribe to foregroundStarted.
*/
private suspend fun startService() {
yield()
shadowOf(Looper.getMainLooper()).idle()
service.onStartCommand(Intent(), 0, 1)
}
private suspend fun awaitTaskDone(
handle: EuiccChannelManagerService.ForegroundTaskHandle
): EuiccChannelManagerService.ForegroundTaskState.Done = coroutineScope {
// Collect on the test thread (inherited from runBlocking): flow emissions always
// run the collect body on this coroutine's dispatcher, so adds happen on the same
// thread that awaitMainLooper reads from -- no concurrent access, plain list is safe.
val states = mutableListOf<EuiccChannelManagerService.ForegroundTaskState>()
val collector = async { handle.stateFlow.collect { states += it } }
try {
awaitMainLooper {
states.any { it is EuiccChannelManagerService.ForegroundTaskState.Done }
}
} finally {
collector.cancel()
}
states.last() as EuiccChannelManagerService.ForegroundTaskState.Done
}
@Test
fun `launchProfileDownload launches through the real service and auto-confirms`() = runBlocking {
val lpa = channel.lpa as MockLpa
// launchProfileDownload returns immediately: the back channel is buffered,
// so the auto-confirmation does not block the caller (the download itself
// only starts after the start command is delivered below).
val handle = withTimeout(10_000.milliseconds) {
val deferred = async { launchProfileDownload(service, manager, logicalSlotId = 0, seId, input) }
// Deliver the start command so the service machinery can run.
startService()
awaitMainLooper { deferred.isCompleted }
deferred.await()
}
// Wait until the download has actually reached the LPA before asserting
// what the service did with it.
awaitMainLooper { lpa.downloadStarted.isCompleted }
// The service must have been asked to download with the physical slot/port
// resolved through the manager (1, 2) and the exact input the UI passed.
assertEquals(1 to 2, manager.physicalChannelRequests.first())
assertEquals(input, lpa.downloadInput)
// The critical part that regressed once: the UI MUST send a confirmation on
// the back channel; otherwise the download stalls and then cancels. Here the
// LPA's callback must have seen `true`.
assertEquals(true, lpa.awaitDownloadResult())
// The task must have completed with no error.
val done = awaitTaskDone(handle)
assertNull(done.error)
}
@Test
fun `autoConfirm=false leaves confirmation to the UI`() = runBlocking {
val lpa = channel.lpa as MockLpa
val handle = launchProfileDownload(
service, manager, logicalSlotId = 0, seId, input, autoConfirm = false
)
startService()
// The download must reach the LPA and stall at the confirmation step...
awaitMainLooper { lpa.downloadStarted.isCompleted }
assertFalse(
"download must not complete before a manual confirmation",
lpa.downloadReturned.isCompleted
)
// ...until the UI confirms through the back channel itself.
handle.backChannel.send(true)
assertEquals(true, lpa.awaitDownloadResult())
}
@Test
fun `launchProfileDownload returns before the confirmation step so the caller can observe progress`() =
runBlocking {
// Simulate a download that emits an intermediate state (Preparing)
// before ConfirmingDownload and parks there until the test releases it.
val lpa = MockLpa(
preConfirmationStates = listOf(ProfileDownloadState.Preparing()),
holdAt = ProfileDownloadState.Preparing(),
)
channel = MockEuiccChannel(slotId = 1, portId = 2, lpa = lpa)
manager = MockEuiccChannelManager(channel)
TestOpenEuiccApplication.mockEuiccChannelManager = manager
service = Robolectric.buildService(EuiccChannelManagerService::class.java).get()
val deferred = async { launchProfileDownload(service, manager, logicalSlotId = 0, seId, input) }
startService()
// The download is now parked at Preparing (before ConfirmingDownload).
awaitMainLooper { lpa.isStateReached(ProfileDownloadState.Preparing()) }
// launchProfileDownload must NOT be blocked on the auto-confirmation
// back channel: the caller has to be able to subscribe to progress
// while the download is still in its early steps. Otherwise the UI
// misses every intermediate state (and dies without showing anything
// if the download fails before ConfirmingDownload).
assertTrue(
"launchProfileDownload must return while the download is still at Preparing",
deferred.isCompleted
)
val handle = deferred.await()
// The caller must be able to observe the intermediate progress state
// that was emitted before ConfirmingDownload. Collect on the test
// thread (inherited from runBlocking) so the list is never mutated
// concurrently, like in awaitTaskDone().
val states = mutableListOf<EuiccChannelManagerService.ForegroundTaskState>()
val collector = async {
handle.stateFlow.collect { states += it }
}
try {
withTimeout(10_000.milliseconds) {
while (states.none {
it is EuiccChannelManagerService.ForegroundTaskState.InProgress &&
it.context is ProfileDownloadState.Preparing
}) {
shadowOf(Looper.getMainLooper()).idle()
yield()
}
}
} finally {
collector.cancel()
}
// Let the download finish; the auto-confirmation already sent by
// launchProfileDownload must continue it normally.
lpa.releaseHold()
val done = awaitTaskDone(handle)
assertNull(done.error)
assertEquals(true, lpa.awaitDownloadResult())
}
}

View file

@ -1,347 +0,0 @@
package im.angry.openeuicc.ui.wizard
import android.content.ComponentName
import android.content.Intent
import android.os.Looper
import android.view.View
import android.widget.Button
import android.widget.ImageView
import android.widget.ProgressBar
import android.widget.TextView
import androidx.recyclerview.widget.RecyclerView
import com.google.android.material.textfield.TextInputLayout
import im.angry.openeuicc.common.R
import im.angry.openeuicc.service.EuiccChannelManagerService
import im.angry.openeuicc.testutil.MockEuiccChannel
import im.angry.openeuicc.testutil.MockEuiccChannelManager
import im.angry.openeuicc.testutil.MockLpa
import im.angry.openeuicc.testutil.TestOpenEuiccApplication
import im.angry.openeuicc.testutil.awaitMainLooper
import kotlinx.coroutines.runBlocking
import net.typeblog.lpac_jni.LocalProfileAssistant
import net.typeblog.lpac_jni.ProfileDownloadInput
import net.typeblog.lpac_jni.ProfileDownloadState
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNotNull
import org.junit.Assert.assertTrue
import org.junit.Before
import org.junit.Test
import org.junit.runner.RunWith
import org.robolectric.Robolectric
import org.robolectric.RobolectricTestRunner
import org.robolectric.RuntimeEnvironment
import org.robolectric.Shadows.shadowOf
import org.robolectric.annotation.Config
/**
* A Robolectric UI test that drives the REAL DownloadWizardActivity through the
* whole wizard flow -- slot select -> method select -> details -> download
* progress -- until the download completes.
*
* Only the dependencies *below* EuiccChannelManagerService are mocked (manager ->
* channel -> LPA, see testutil.Mocks). The activity, every wizard fragment, the
* in-process service binding and the entire foreground-task machinery are the
* real production code, so this test protects the actual UI flow that a user
* experiences, not just the launchProfileDownload() contract in isolation.
*
* The MockLpa simulates a download that reaches ProfileDownloadState.ConfirmingDownload
* and blocks until the subscriber confirms through the task's back channel --
* exactly what a real download does before finishing.
*/
@RunWith(RobolectricTestRunner::class)
@Config(application = TestOpenEuiccApplication::class)
class DownloadWizardFlowTest {
private val input = ProfileDownloadInput(
address = "smdp.example.com",
matchingId = "matching-id",
imei = "imei",
confirmationCode = "conf-code"
)
private lateinit var activity: DownloadWizardActivity
private lateinit var service: EuiccChannelManagerService
private lateinit var channel: MockEuiccChannel
private lateinit var manager: MockEuiccChannelManager
@Before
fun setUp() {
channel = MockEuiccChannel(slotId = 1, portId = 2)
manager = MockEuiccChannelManager(channel)
TestOpenEuiccApplication.mockEuiccChannelManager = manager
// Robolectric does not auto-create services on bindService(); register the
// REAL EuiccChannelManagerService (with the mock manager below it) as the
// binding target so that BaseEuiccAccessActivity connects to it in-process.
service = Robolectric.buildService(EuiccChannelManagerService::class.java).get()
val bindIntent = Intent(
RuntimeEnvironment.getApplication(),
EuiccChannelManagerService::class.java
)
shadowOf(RuntimeEnvironment.getApplication())
.setComponentNameAndServiceForBindServiceForIntent(
bindIntent,
ComponentName(
RuntimeEnvironment.getApplication(),
EuiccChannelManagerService::class.java
),
service.onBind(Intent())
)
val intent = DownloadWizardActivity.newIntent(
RuntimeEnvironment.getApplication(),
logicalSlotId = 0
)
val controller = Robolectric.buildActivity(DownloadWizardActivity::class.java, intent)
// The activity does not declare a theme itself; it inherits the host
// app's application-level theme (Theme.OpenEUICC), which Robolectric
// does not merge in for a library module. AppCompatActivity requires an
// AppCompat theme, so apply it before onCreate() runs.
controller.get().setTheme(R.style.Theme_OpenEUICC)
activity = controller.setup().get()
idle()
// Wait until the in-process service connection is up and onInit() has run.
runBlocking {
awaitMainLooper { activity.euiccChannelManagerLoaded.isCompleted }
}
idle()
}
private fun idle() {
shadowOf(Looper.getMainLooper()).idle()
}
private fun currentFragment(): DownloadWizardActivity.DownloadWizardStepFragment? =
activity.supportFragmentManager
.findFragmentById(R.id.step_fragment_container)
as? DownloadWizardActivity.DownloadWizardStepFragment
/**
* RecyclerView children are only laid out when the Robolectric main looper
* runs the view traversal; if that hasn't happened yet, force a layout so
* that findViewHolderForAdapterPosition() works.
*/
private fun RecyclerView.ensureLaidOut() {
idle()
if (childCount == 0) {
measure(
View.MeasureSpec.makeMeasureSpec(1080, View.MeasureSpec.EXACTLY),
View.MeasureSpec.makeMeasureSpec(1920, View.MeasureSpec.EXACTLY)
)
layout(0, 0, 1080, 1920)
idle()
}
}
private fun clickListItem(recyclerView: RecyclerView, position: Int) {
recyclerView.ensureLaidOut()
val holder = recyclerView.findViewHolderForAdapterPosition(position)
assertNotNull("list item $position should be visible", holder)
holder!!.itemView.performClick()
idle()
}
private fun clickNext() {
activity.findViewById<Button>(R.id.download_wizard_next).performClick()
idle()
}
/**
* Drive the wizard through slot select -> method select -> details form ->
* download progress, and kick the service's foreground task machinery by
* simulating the start command (Robolectric does not deliver
* startForegroundService() on its own).
*/
private suspend fun startDownload() {
// ---- Slot select: the mock manager exposes exactly one eUICC ----
val slotList = activity.findViewById<RecyclerView>(R.id.download_slot_list)
awaitMainLooper { (slotList.adapter?.itemCount ?: 0) > 0 }
assertEquals(1, slotList.adapter?.itemCount)
clickListItem(slotList, 0)
// ---- Next -> method select ----
clickNext()
assertTrue(currentFragment() is DownloadWizardMethodSelectFragment)
// ---- Pick the manual entry -> details form ----
val methodList = activity.findViewById<RecyclerView>(R.id.download_method_list)
awaitMainLooper { (methodList.adapter?.itemCount ?: 0) == 4 }
clickListItem(methodList, 3) // download_wizard_method_manual
assertTrue(currentFragment() is DownloadWizardDetailsFragment)
// ---- Fill the form exactly as the user would ----
val fields = mapOf(
R.id.profile_download_server to input.address,
R.id.profile_download_code to input.matchingId!!,
R.id.profile_download_confirmation_code to input.confirmationCode!!,
R.id.profile_download_imei to input.imei!!
)
fields.forEach { (id, text) ->
activity.findViewById<TextInputLayout>(id).editText!!.setText(text)
}
idle()
// ---- Next -> download progress; the download launches via the REAL service ----
clickNext()
assertTrue(currentFragment() is DownloadWizardProgressFragment)
// The service self-starts via startForegroundService(); Robolectric does
// not deliver that to onStartCommand() on its own (see
// DownloadTaskLauncherTest), so simulate the system call once the task
// has had a chance to subscribe to foregroundStarted. The mock manager
// having resolved the logical slot proves the launch has been reached.
awaitMainLooper { manager.logicalChannelRequests.isNotEmpty() }
idle()
service.onStartCommand(Intent(), 0, 1)
idle()
}
private fun progressItemHolder(position: Int): RecyclerView.ViewHolder {
val progressList = activity.findViewById<RecyclerView>(R.id.download_progress_list)
progressList.ensureLaidOut()
val holder = progressList.findViewHolderForAdapterPosition(position)
assertNotNull("progress item $position should be visible", holder)
return holder!!
}
/** Asserts that the given progress step currently shows a checkmark and no error text. */
private fun assertProgressItemDone(position: Int) {
val holder = progressItemHolder(position)
val icon = holder.itemView.findViewById<ImageView>(R.id.download_progress_icon)
val errorTitle = holder.itemView.findViewById<TextView>(R.id.download_progress_item_error_title)
assertEquals("step $position should show a checkmark", View.VISIBLE, icon.visibility)
assertEquals("step $position should not show an error", View.GONE, errorTitle.visibility)
}
/** Asserts that the given progress step currently shows the indeterminate spinner. */
private fun assertProgressItemInProgress(position: Int) {
val holder = progressItemHolder(position)
val progressBar =
holder.itemView.findViewById<ProgressBar>(R.id.download_progress_icon_progress)
assertEquals("step $position should show the progress spinner", View.VISIBLE, progressBar.visibility)
}
/** Asserts that the given progress step currently shows the error state. */
private fun assertProgressItemError(position: Int) {
val holder = progressItemHolder(position)
val errorTitle = holder.itemView.findViewById<TextView>(R.id.download_progress_item_error_title)
assertEquals("step $position should show the error message", View.VISIBLE, errorTitle.visibility)
}
@Test
fun `wizard runs the full flow through the real service to a completed download`() = runBlocking {
val lpa = channel.lpa as MockLpa
startDownload()
// ---- The download must complete and the UI must reflect it ----
awaitMainLooper { lpa.downloadReturned.isCompleted }
awaitMainLooper { currentFragment()?.hasNext == true }
// The LPA received the exact input the UI passed, and the metadata
// confirmation was auto-approved through the back channel.
assertEquals(input, lpa.downloadInput)
assertEquals(true, lpa.awaitDownloadResult())
// The service resolved the physical slot/port through the manager and
// launched the download with them.
assertEquals(1 to 2, manager.physicalChannelRequests.first())
// UI: every step the download reached shows a checkmark; nothing is stuck
// in progress; the wizard offers to continue (i.e. no error).
for (i in 0..2) {
assertProgressItemDone(i)
}
// hasNext is true and no error -> the Next button must be visible so the
// user can leave the (successful) download screen.
assertEquals(
View.VISIBLE,
activity.findViewById<Button>(R.id.download_wizard_next).visibility
)
// Clicking Next after a successful download must finish the wizard
// (createNextFragment() returns null when there is no error).
clickNext()
assertTrue("wizard should finish after a successful download", activity.isFinishing)
}
@Test
fun `wizard streams intermediate progress while the download is still running`() = runBlocking {
val lpa = channel.lpa as MockLpa
// Simulate a real download that walks through Preparing, Connecting and
// Authenticating before asking for metadata confirmation, and park it at
// Authenticating so we can inspect the UI mid-download.
lpa.preConfirmationStates = listOf(
ProfileDownloadState.Preparing(),
ProfileDownloadState.Connecting(),
ProfileDownloadState.Authenticating(),
)
lpa.holdAt = ProfileDownloadState.Authenticating()
startDownload()
// Wait until the download has actually reached (and is parked at) the
// authentication step.
awaitMainLooper { lpa.isStateReached(ProfileDownloadState.Authenticating()) }
// The UI must have streamed the progress live: the earlier steps already
// show checkmarks and the current step shows the spinner -- it must NOT
// jump straight from "nothing" to "done" at the end.
assertProgressItemDone(0)
assertProgressItemDone(1)
assertProgressItemInProgress(2)
// Let the download continue; it must complete normally afterwards.
lpa.releaseHold()
awaitMainLooper { lpa.downloadReturned.isCompleted }
awaitMainLooper { currentFragment()?.hasNext == true }
assertEquals(true, lpa.awaitDownloadResult())
// The steps reached before the confirmation are all done; the download
// finishes without leaving anything stuck in progress.
for (i in 0..2) {
assertProgressItemDone(i)
}
}
@Test
fun `wizard shows the reached steps and an error when the download fails before confirmation`() = runBlocking {
val lpa = channel.lpa as MockLpa
// The SM-DP+ rejects the device during authentication (step 3), i.e.
// before any metadata confirmation happens -- this is the failure mode
// that used to leave the progress UI stuck with no checkmarks at all.
lpa.preConfirmationStates = listOf(
ProfileDownloadState.Preparing(),
ProfileDownloadState.Connecting(),
ProfileDownloadState.Authenticating(),
)
lpa.failAfter = ProfileDownloadState.Authenticating()
lpa.failure = LocalProfileAssistant.ProfileDownloadException(
lpaErrorReason = "ES10B_ERROR_REASON_UNSUPPORTED_CRT_VALUES",
lastHttpResponse = null,
lastHttpException = null,
lastApduResponse = null,
lastApduException = null,
)
startDownload()
// The download must fail and the wizard must then offer the diagnostics step.
awaitMainLooper { currentFragment()?.hasNext == true }
// The steps reached before the failure must be marked done...
assertProgressItemDone(0)
assertProgressItemDone(1)
// ...and the failing step must show the error.
assertProgressItemError(2)
// The error must have been recorded so the diagnostics step can show it:
// Next must lead to the diagnostics fragment (only created when an error
// is present).
clickNext()
assertTrue(
"next must lead to the diagnostics step after a failed download",
currentFragment() is DownloadWizardDiagnosticsFragment
)
}
}

View file

@ -1,4 +0,0 @@
# Pin the SDK level used by Robolectric tests in this module.
# The current behavior would otherwise default to minSdk (28), but an explicit
# setting keeps CI deterministic across Robolectric upgrades.
sdk=28

View file

@ -12,7 +12,7 @@ java_defaults {
"androidx.swiperefreshlayout_swiperefreshlayout",
"androidx.cardview_cardview",
"androidx.viewpager2_viewpager2",
"androidx.datastore_datastore-preferences",
"OpenEUICC_androidx.datastore_datastore-preferences",
"OpenEUICC_com.journeyapps_zxing-android-embedded",
],
}
}

View file

@ -1,4 +1,3 @@
import org.jetbrains.kotlin.gradle.dsl.JvmTarget
import org.lineageos.generatebp.GenerateBpPlugin
import org.lineageos.generatebp.GenerateBpPluginExtension
import org.lineageos.generatebp.models.Module
@ -14,7 +13,7 @@ apply {
android {
namespace = "im.angry.openeuicc_deps"
compileSdk = 37
compileSdk = 34
defaultConfig {
minSdk = 28
@ -32,41 +31,43 @@ android {
)
}
}
}
kotlin {
compilerOptions {
jvmTarget = JvmTarget.JVM_11
compileOptions {
sourceCompatibility = JavaVersion.VERSION_1_8
targetCompatibility = JavaVersion.VERSION_1_8
}
kotlinOptions {
jvmTarget = "1.8"
}
}
dependencies {
api("androidx.core:core-ktx:1.19.0")
api("androidx.appcompat:appcompat:1.7.1")
api("com.google.android.material:material:1.14.0")
api("androidx.constraintlayout:constraintlayout:2.2.2")
api("androidx.core:core-ktx:1.12.0")
api("androidx.appcompat:appcompat:1.6.1")
api("com.google.android.material:material:1.10.0")
api("androidx.constraintlayout:constraintlayout:2.1.4")
//noinspection KtxExtensionAvailable
api("androidx.preference:preference:1.2.1")
api("androidx.lifecycle:lifecycle-runtime-ktx:2.11.0")
api("androidx.lifecycle:lifecycle-service:2.11.0")
api("androidx.swiperefreshlayout:swiperefreshlayout:1.2.0")
api("androidx.lifecycle:lifecycle-runtime-ktx:2.6.2")
api("androidx.lifecycle:lifecycle-service:2.6.2")
api("androidx.swiperefreshlayout:swiperefreshlayout:1.1.0")
api("androidx.cardview:cardview:1.0.0")
api("androidx.viewpager2:viewpager2:1.1.0")
api("androidx.datastore:datastore-preferences:1.2.1")
api("androidx.datastore:datastore-preferences:1.0.0")
api("com.journeyapps:zxing-android-embedded:4.3.0")
testImplementation("junit:junit:4.13.2")
androidTestImplementation("androidx.test.ext:junit:1.3.0")
androidTestImplementation("androidx.test.espresso:espresso-core:3.7.0")
androidTestImplementation("androidx.test.ext:junit:1.1.5")
androidTestImplementation("androidx.test.espresso:espresso-core:3.5.1")
}
configure<GenerateBpPluginExtension> {
targetSdk.set(android.compileSdk!!)
availableInAOSP.set { module: Module ->
when {
module.group == "androidx.datastore" -> false
module.group.startsWith("androidx") -> true
module.group == "com.google.android.material" -> true
module.group.startsWith("org.jetbrains") -> true
else -> false
}
}
}
}

View file

@ -1,5 +1,4 @@
import im.angry.openeuicc.build.*
import org.jetbrains.kotlin.gradle.dsl.JvmTarget
plugins {
id("com.android.application")
@ -18,21 +17,17 @@ apply {
android {
namespace = "im.angry.easyeuicc"
compileSdk = 37
compileSdk = 35
ndkVersion = "26.1.10909125"
defaultConfig {
applicationId = "im.angry.easyeuicc"
minSdk = 28
targetSdk = 37
targetSdk = 35
emitAssetStatements("https://easyeuicc.org", "https://preview.easyeuicc.org")
}
buildFeatures {
buildConfig = true
}
buildTypes {
defaultConfig {
versionNameSuffix = "-unpriv"
@ -42,15 +37,15 @@ android {
proguardFiles(getDefaultProguardFile("proguard-android-optimize.txt"), "proguard-rules.pro")
}
}
}
kotlin {
compilerOptions {
jvmTarget = JvmTarget.JVM_11
compileOptions {
sourceCompatibility = JavaVersion.VERSION_1_8
targetCompatibility = JavaVersion.VERSION_1_8
}
kotlinOptions {
jvmTarget = "1.8"
}
}
dependencies {
implementation(project(":app-common"))
testImplementation("junit:junit:4.13.2")
}

View file

@ -5,9 +5,8 @@ import android.content.pm.PackageManager.GET_SIGNING_CERTIFICATES
import android.net.Uri
import android.util.Base64
import androidx.core.net.toUri
import androidx.core.os.ConfigurationCompat
import im.angry.easyeuicc.BuildConfig
import im.angry.easyeuicc.R
import im.angry.openeuicc.common.BuildConfig
import im.angry.openeuicc.core.EuiccChannel
import im.angry.openeuicc.util.*
import javax.crypto.Mac
@ -16,7 +15,6 @@ import javax.crypto.spec.SecretKeySpec
class UnprivilegedCustomizableTextProvider(private val context: Context) : DefaultCustomizableTextProvider(context) {
override val websiteUri: Uri
get() {
val language = ConfigurationCompat.getLocales(context.resources.configuration).get(0)?.toLanguageTag() ?: ""
val parts = arrayOf(
context.selfAppVersion, // show user current version
context.selfAppVersionCode.toString(36), // check is upgradable
@ -37,7 +35,6 @@ class UnprivilegedCustomizableTextProvider(private val context: Context) : Defau
Base64.URL_SAFE or Base64.NO_WRAP or Base64.NO_PADDING,
)
return context.getString(R.string.pref_info_website_url).toUri().buildUpon()
.appendQueryParameter("hl", language) // host language for localized website
.appendQueryParameter("v", message)
.appendQueryParameter("v", signed)
.build()

View file

@ -11,7 +11,6 @@ import android.view.ViewGroup
import android.widget.Button
import android.widget.CheckBox
import android.widget.TextView
import androidx.core.os.ConfigurationCompat
import androidx.core.view.isVisible
import androidx.fragment.app.Fragment
import androidx.lifecycle.lifecycleScope
@ -92,9 +91,6 @@ open class QuickCompatibilityFragment : Fragment(), UnprivilegedEuiccContextMark
private fun onCompatibilityUpdate(result: CompatibilityResult) {
conclusion.text = formatConclusion(result)
if (result.compatibility == Compatibility.COMPATIBLE) {
val listFormatter = ListFormatter.getInstance(
ConfigurationCompat.getLocales(resources.configuration).get(0)
)
// Don't show the message again, ever, if the result is compatible
runBlocking {
preferenceRepository.skipQuickCompatibilityFlow
@ -103,7 +99,7 @@ open class QuickCompatibilityFragment : Fragment(), UnprivilegedEuiccContextMark
resultSlots.isVisible = true
resultSlots.text = getString(
R.string.quick_compatibility_result_slots,
listFormatter.format(result.slotsOmapi)
ListFormatter.getInstance().format(result.slotsOmapi)
)
resultSlotsIsdr.isVisible = true
resultSlotsIsdr.text =
@ -112,7 +108,7 @@ open class QuickCompatibilityFragment : Fragment(), UnprivilegedEuiccContextMark
if (result.slotsIsdr.isEmpty()) {
getString(R.string.quick_compatibility_unknown)
} else {
listFormatter.format(result.slotsIsdr)
ListFormatter.getInstance().format(result.slotsIsdr)
}
)
resultNotes.isVisible = true

View file

@ -2,7 +2,6 @@ import com.android.build.gradle.internal.api.ApkVariantOutputImpl
import im.angry.openeuicc.build.MagiskModuleDirTask
import im.angry.openeuicc.build.MySigningPlugin
import im.angry.openeuicc.build.MyVersioningPlugin
import org.jetbrains.kotlin.gradle.dsl.JvmTarget
plugins {
id("com.android.application")
@ -16,12 +15,12 @@ apply {
android {
namespace = "im.angry.openeuicc"
compileSdk = 37
compileSdk = 35
defaultConfig {
applicationId = "im.angry.openeuicc"
minSdk = 30
targetSdk = 37
targetSdk = 35
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
}
@ -35,11 +34,12 @@ android {
proguardFiles(getDefaultProguardFile("proguard-android-optimize.txt"), "proguard-rules.pro")
}
}
}
kotlin {
compilerOptions {
jvmTarget = JvmTarget.JVM_11
compileOptions {
sourceCompatibility = JavaVersion.VERSION_1_8
targetCompatibility = JavaVersion.VERSION_1_8
}
kotlinOptions {
jvmTarget = "1.8"
}
}
@ -49,8 +49,8 @@ dependencies {
implementation(project(":libs:lpac-jni"))
implementation(project(":app-common"))
testImplementation("junit:junit:4.13.2")
androidTestImplementation("androidx.test.ext:junit:1.3.0")
androidTestImplementation("androidx.test.espresso:espresso-core:3.7.0")
androidTestImplementation("androidx.test.ext:junit:1.1.5")
androidTestImplementation("androidx.test.espresso:espresso-core:3.5.1")
}
val modulePropsTemplate = mutableMapOf(

View file

@ -42,7 +42,6 @@ class PrivilegedEuiccChannelFactory(context: Context) : DefaultEuiccChannelFacto
seId,
context.preferenceRepository.verboseLoggingFlow,
context.preferenceRepository.ignoreTLSCertificateFlow,
context.preferenceRepository.httpProxyFlow,
context.preferenceRepository.es10xMssFlow,
)
} catch (_: IllegalArgumentException) {

View file

@ -1,21 +1,10 @@
package im.angry.openeuicc.di
import android.content.Context
import android.net.Uri
import androidx.core.net.toUri
import androidx.core.os.ConfigurationCompat
import im.angry.openeuicc.R
class PrivilegedCustomizableTextProvider(private val context: Context) :
DefaultCustomizableTextProvider(context) {
override val websiteUri: Uri
get() {
val language = ConfigurationCompat.getLocales(context.resources.configuration).get(0)?.toLanguageTag() ?: ""
return context.getString(R.string.pref_info_website_url).toUri().buildUpon()
.appendQueryParameter("hl", language) // host language for localized website
.build()
}
override val noEuiccExplanation: String
get() = context.getString(R.string.no_euicc_priv)
}
}

View file

@ -261,7 +261,7 @@ class OpenEuiccService : EuiccService(), OpenEuiccContextMarker {
EuiccChannel.SecureElementId.DEFAULT,
iccid
)
.stateFlow.waitDone() == null
.waitDone() == null
return@withEuiccChannelManager if (success) {
RESULT_OK
@ -374,7 +374,7 @@ class OpenEuiccService : EuiccService(), OpenEuiccContextMarker {
foundIccid,
enable,
30 * 1000
).stateFlow.waitDone()
).waitDone()
if (res != null) return@withEuiccChannelManager RESULT_FIRST_USER
@ -407,7 +407,7 @@ class OpenEuiccService : EuiccService(), OpenEuiccContextMarker {
iccid,
nickname!!
)
.stateFlow.waitDone()) == null
.waitDone()) == null
euiccChannelManager.withEuiccChannel(slotId, port, seId) { channel ->
appContainer.subscriptionManager.tryRefreshCachedEuiccInfo(channel.cardId)

View file

@ -22,7 +22,7 @@ class PrivilegedSettingsFragment : SettingsFragment(), PrivilegedEuiccContextMar
override fun onCreatePreferences(savedInstanceState: Bundle?, rootKey: String?) {
super.onCreatePreferences(savedInstanceState, rootKey)
addPreferencesFromResource(R.xml.pref_privileged_settings)
mergePreferenceOverlay("pref_advanced_overlay", "pref_advanced")
mergePreferenceOverlay("pref_developer_overlay", "pref_developer")
// It's stupid to _disable_ things for privileged, but for now, the per-app locale picker
// is not usable for apps signed with the platform key.
@ -35,7 +35,7 @@ class PrivilegedSettingsFragment : SettingsFragment(), PrivilegedEuiccContextMar
}
// Force use TelephonyManager API
requirePreference<CheckBoxPreference>("pref_advanced_removable_telephony_manager")
requirePreference<CheckBoxPreference>("pref_developer_removable_telephony_manager")
.bindBooleanFlow(preferenceRepository.removableTelephonyManagerFlow)
}
}

View file

@ -18,6 +18,6 @@
<string name="lui_skip">スキップ</string>
<string name="lui_download">eSIM をダウンロード</string>
<!-- Preference -->
<string name="pref_advanced_telephony_manager_removable">TelephonyManager を常に使用可能にする</string>
<string name="pref_advanced_telephony_manager_removable_desc">デフォルトでは、非特権モード (EasyEUICC など) での動作と一致させるためにリムーバブル eSIM に対しては OMAPI のみを試行します。ただし、一部デバイスでは正常に動作しない場合があります。このオプションを選択するとリムーバブル eSIM でも TelephonyManager の使用が強制されます。</string>
<string name="pref_developer_telephony_manager_removable">TelephonyManager を常に使用可能にする</string>
<string name="pref_developer_telephony_manager_removable_desc">デフォルトでは、非特権モード (EasyEUICC など) での動作と一致させるためにリムーバブル eSIM に対しては OMAPI のみを試行します。ただし、一部デバイスでは正常に動作しない場合があります。このオプションを選択するとリムーバブル eSIM でも TelephonyManager の使用が強制されます。</string>
</resources>

View file

@ -17,6 +17,6 @@
<string name="lui_skip">跳过</string>
<string name="lui_download">下载 eSIM</string>
<string name="channel_type_telephony_manager">TelephonyManager (特权)</string>
<string name="pref_advanced_telephony_manager_removable">全局使用 TelephonyManager</string>
<string name="pref_advanced_telephony_manager_removable_desc">在默认情况下,可移除 eSIM 将仅使用 OMAPI。这与非特权模式 (EasyEUICC) 一致。在某些设备上 OMAPI 可能存在问题 — 选择此选项以强制使用 TelephonyManager。</string>
</resources>
<string name="pref_developer_telephony_manager_removable">全局使用 TelephonyManager</string>
<string name="pref_developer_telephony_manager_removable_desc">在默认情况下,可移除 eSIM 将仅使用 OMAPI。这与非特权模式 (EasyEUICC) 一致。在某些设备上 OMAPI 可能存在问题 — 选择此选项以强制使用 TelephonyManager。</string>
</resources>

View file

@ -17,6 +17,6 @@
<string name="lui_skip">跳過</string>
<string name="lui_download">下載 eSIM</string>
<string name="channel_type_telephony_manager">TelephonyManager (特權)</string>
<string name="pref_advanced_telephony_manager_removable">全域使用 TelephonyManager</string>
<string name="pref_advanced_telephony_manager_removable_desc">在預設情況下,可移除 eSIM 將僅使用 OMAPI。這與非特權模式 (EasyEUICC) 一致。在某些裝置上 OMAPI 可能有問題 — 選擇此選項以強制使用 TelephonyManager。</string>
</resources>
<string name="pref_developer_telephony_manager_removable">全域使用 TelephonyManager</string>
<string name="pref_developer_telephony_manager_removable_desc">在預設情況下,可移除 eSIM 將僅使用 OMAPI。這與非特權模式 (EasyEUICC) 一致。在某些裝置上 OMAPI 可能有問題 — 選擇此選項以強制使用 TelephonyManager。</string>
</resources>

View file

@ -24,7 +24,6 @@
<string name="lui_download">Download eSIM</string>
<!-- Preference -->
<string name="pref_advanced_telephony_manager_removable">Use TelephonyManager everywhere</string>
<string name="pref_advanced_telephony_manager_removable_desc">By default, only OMAPI is attempted for removable eSIMs to match what is done in unprivileged mode (i.e. EasyEUICC). This may not work well on some devices. Select this option to force the use of TelephonyManager even for removable eSIMs.</string>
<string name="pref_info_website_url" translatable="false">https://openeuicc.com</string>
</resources>
<string name="pref_developer_telephony_manager_removable">Use TelephonyManager everywhere</string>
<string name="pref_developer_telephony_manager_removable_desc">By default, only OMAPI is attempted for removable eSIMs to match what is done in unprivileged mode (i.e. EasyEUICC). This may not work well on some devices. Select this option to force the use of TelephonyManager even for removable eSIMs.</string>
</resources>

View file

@ -2,11 +2,11 @@
<PreferenceScreen xmlns:app="http://schemas.android.com/apk/res-auto">
<PreferenceCategory
app:isPreferenceVisible="false"
app:key="pref_advanced_overlay">
app:key="pref_developer_overlay">
<CheckBoxPreference
app:iconSpaceReserved="false"
app:key="pref_advanced_removable_telephony_manager"
app:summary="@string/pref_advanced_telephony_manager_removable_desc"
app:title="@string/pref_advanced_telephony_manager_removable" />
app:key="pref_developer_removable_telephony_manager"
app:summary="@string/pref_developer_telephony_manager_removable_desc"
app:title="@string/pref_developer_telephony_manager_removable" />
</PreferenceCategory>
</PreferenceScreen>
</PreferenceScreen>

View file

@ -3,22 +3,10 @@ plugins {
// The following Android-related plugins are already depended upon by buildSrc, hence unnecessary.
// id("com.android.application") version "8.1.2" apply false
// id("com.android.library") version "8.1.2" apply false
//id("org.jetbrains.kotlin.android") version "2.4.10" apply false
//id("org.jetbrains.kotlin.multiplatform") version "2.4.10" apply false
id("org.jetbrains.kotlin.android") version "1.9.20" apply false
id("org.jetbrains.kotlin.multiplatform") version "1.9.20" apply false
}
tasks.register<Delete>("clean") {
delete = setOf(rootProject.layout.buildDirectory)
}
// Print per-test results (which tests ran, passed, failed, were skipped)
// instead of Gradle's default summary-only output.
subprojects {
tasks.withType<org.gradle.api.tasks.testing.Test>().configureEach {
testLogging {
events("passed", "skipped", "failed")
// Uncomment to also capture each test's stdout/stderr:
// showStandardStreams = true
}
}
}
tasks.create<Delete>("clean") {
delete = setOf(rootProject.buildDir)
}

View file

@ -8,5 +8,5 @@ plugins {
}
dependencies {
implementation("com.android.tools.build:gradle:9.3.0")
}
implementation("com.android.tools.build:gradle:8.1.2")
}

View file

@ -10,18 +10,24 @@ import java.io.ByteArrayOutputStream
val Project.gitVersionCode: Int
get() =
try {
providers.exec {
val stdout = ByteArrayOutputStream()
exec {
commandLine("git", "rev-list", "--first-parent", "--count", "HEAD")
}.standardOutput.asText.get().trim('\n').toInt()
standardOutput = stdout
}
stdout.toString("utf-8").trim('\n').toInt()
} catch (_: Exception) {
0
}
fun Project.getGitVersionName(vararg args: String): String =
try {
providers.exec {
val stdout = ByteArrayOutputStream()
exec {
commandLine("git", "describe", "--always", "--tags", "--dirty", *args)
}.standardOutput.asText.get().trim('\n').removePrefix("unpriv-")
standardOutput = stdout
}
stdout.toString("utf-8").trim('\n').removePrefix("unpriv-")
} catch (_: Exception) {
"Unknown"
}

View file

@ -1,30 +0,0 @@
# {Open/Easy}EUICC Deep Link
Since: OpenEUICC v1.3.0 or EasyEUICC v1.3.0
## Web Intent
HTML example:
```html
<a href="lpa:1$rsp.example.com$matching-id">Open {Open/Easy}EUICC</a>
```
## Android Intent
Kotlin example:
```kotlin
val lpaUri = Uri.parse("lpa:1\$rsp.example.com\$matching-id")
startActivity(Intent(Intent.ACTION_VIEW, lpaUri))
```
To check whether any installed app can handle this deep link, call [PackageManager#queryIntentActivities] before calling
`startActivity`.
[PackageManager#queryIntentActivities]: https://developer.android.com/reference/android/content/pm/PackageManager#queryIntentActivities(android.content.Intent,%20int)
## References
- [About deep links](https://developer.android.com/training/app-links)
- [Android Intents with Chrome](https://developer.chrome.com/docs/android/intents)

View file

@ -21,15 +21,6 @@ kotlin.code.style=official
# resources declared in the library itself and none from the library's dependencies,
# thereby reducing the size of the R class for that library
android.nonTransitiveRClass=true
android.defaults.buildfeatures.buildconfig=true
android.nonFinalResIds=false
android.suppressUnsupportedCompileSdk=35
android.defaults.buildfeatures.resvalues=true
android.sdk.defaultTargetSdkToCompileSdkIfUnset=false
android.enableAppCompileTimeRClass=false
android.usesSdkInManifest.disallowed=false
android.uniquePackageNames=false
android.dependency.useConstraints=true
android.r8.strictFullModeForKeepRules=false
android.r8.optimizedResourceShrinking=false
android.builtInKotlin=false
android.newDsl=false

View file

@ -1,6 +1,6 @@
distributionBase=GRADLE_USER_HOME
distributionPath=wrapper/dists
distributionUrl=https\://services.gradle.org/distributions/gradle-9.5.0-bin.zip
distributionUrl=https\://services.gradle.org/distributions/gradle-8.10-bin.zip
networkTimeout=10000
validateDistributionUrl=true
zipStoreBase=GRADLE_USER_HOME

View file

@ -1,15 +1,14 @@
import org.jetbrains.kotlin.gradle.dsl.JvmTarget
plugins {
id("com.android.library")
id("org.jetbrains.kotlin.android")
}
android {
compileSdk = 37
compileSdk = 32
defaultConfig {
minSdk = 30
targetSdk = 32
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
}
@ -20,13 +19,14 @@ android {
proguardFiles(getDefaultProguardFile("proguard-android-optimize.txt"), "proguard-rules.pro")
}
}
namespace = "im.angry.openeuicc.hidden_apis_shim"
}
kotlin {
compilerOptions {
jvmTarget = JvmTarget.JVM_11
compileOptions {
sourceCompatibility = JavaVersion.VERSION_1_8
targetCompatibility = JavaVersion.VERSION_1_8
}
kotlinOptions {
jvmTarget = "1.8"
}
namespace = "im.angry.openeuicc.hidden_apis_shim"
}
dependencies {
@ -37,4 +37,4 @@ dependencies {
testImplementation("junit:junit:4.13.2")
androidTestImplementation("androidx.test.ext:junit:1.1.3")
androidTestImplementation("androidx.test.espresso:espresso-core:3.4.0")
}
}

View file

@ -3,10 +3,15 @@ plugins {
}
android {
compileSdk = 37
compileSdk = 31
namespace = "im.angry.hidden.apis"
}
java {
sourceCompatibility = JavaVersion.VERSION_1_7
targetCompatibility = JavaVersion.VERSION_1_7
}
dependencies {
implementation("org.jetbrains:annotations:15.0")
}
}

View file

@ -1,5 +1,3 @@
import org.jetbrains.kotlin.gradle.dsl.JvmTarget
plugins {
id("com.android.library")
id("org.jetbrains.kotlin.android")
@ -7,7 +5,7 @@ plugins {
android {
namespace = "net.typeblog.lpac_jni"
compileSdk = 37
compileSdk = 35
ndkVersion = "26.1.10909125"
defaultConfig {
@ -37,19 +35,21 @@ android {
path("src/main/jni/lpac-jni.mk")
}
}
}
kotlin {
compilerOptions {
jvmTarget = JvmTarget.JVM_11
compileOptions {
sourceCompatibility = JavaVersion.VERSION_1_8
targetCompatibility = JavaVersion.VERSION_1_8
}
kotlinOptions {
jvmTarget = "1.8"
}
}
dependencies {
implementation("androidx.core:core-ktx:1.19.0")
implementation(platform("org.jetbrains.kotlin:kotlin-bom:1.8.22"))
implementation("androidx.appcompat:appcompat:1.7.1")
implementation("androidx.core:core-ktx:1.12.0")
implementation(platform("org.jetbrains.kotlin:kotlin-bom:1.8.0"))
implementation("androidx.appcompat:appcompat:1.6.1")
implementation("com.google.android.material:material:1.10.0")
testImplementation("junit:junit:4.13.2")
androidTestImplementation("androidx.test.ext:junit:1.1.5")
androidTestImplementation("androidx.test.espresso:espresso-core:3.5.1")
}
}

View file

@ -1,16 +1,11 @@
package net.typeblog.lpac_jni.impl
import android.net.Uri
import android.util.Log
import androidx.core.net.toUri
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.runBlocking
import net.typeblog.lpac_jni.HttpInterface
import java.net.InetSocketAddress
import java.net.Proxy
import java.net.URL
import java.net.URLConnection
import java.security.SecureRandom
import javax.net.ssl.HttpsURLConnection
import javax.net.ssl.SSLContext
@ -18,11 +13,9 @@ import javax.net.ssl.SSLSocketFactory
import javax.net.ssl.TrustManager
import javax.net.ssl.TrustManagerFactory
class HttpInterfaceImpl(
private val verboseLoggingFlow: Flow<Boolean>,
private val ignoreTLSCertificateFlow: Flow<Boolean>,
private val httpProxyFlow: Flow<String>
private val ignoreTLSCertificateFlow: Flow<Boolean>
) : HttpInterface {
companion object {
private const val TAG = "HttpInterfaceImpl"
@ -47,9 +40,7 @@ class HttpInterfaceImpl(
}
try {
val proxy = runBlocking { httpProxyFlow.first().toUri().normalizeScheme() }
val conn = parsedUrl.openConnection(proxy) as HttpsURLConnection
val conn = parsedUrl.openConnection() as HttpsURLConnection
conn.connectTimeout = 2000
if (url.contains("handleNotification")) {
@ -101,22 +92,10 @@ class HttpInterfaceImpl(
return sslContext.socketFactory
}
private fun URL.openConnection(proxy: Uri): URLConnection {
if (proxy.scheme == null || proxy.host == null || proxy.port == -1) return openConnection()
val type = when (proxy.scheme) {
"http", "https" -> Proxy.Type.HTTP
"socks", "socks5" -> Proxy.Type.SOCKS
"direct" -> return openConnection(Proxy.NO_PROXY)
else -> return openConnection() // fallback to system proxy
}
val proxy = Proxy(type, /* sa = */ InetSocketAddress(/* hostname = */ proxy.host, proxy.port))
return openConnection(proxy)
}
override fun usePublicKeyIds(pkids: Array<String>) {
val trustManagerFactory = TrustManagerFactory.getInstance("PKIX").apply {
init(keyIdToKeystore(pkids))
}
trustManagers = trustManagerFactory.trustManagers
}
}
}