Compare commits
No commits in common. "master" and "master" have entirely different histories.
64 changed files with 218 additions and 1575 deletions
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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
1
.gitignore
vendored
|
|
@ -1,6 +1,5 @@
|
|||
/.gradle
|
||||
/captures
|
||||
/build
|
||||
|
||||
# Configuration files
|
||||
|
||||
|
|
|
|||
4
.idea/compiler.xml
generated
4
.idea/compiler.xml
generated
|
|
@ -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
5
.idea/kotlinc.xml
generated
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -8,7 +8,6 @@ java_library {
|
|||
},
|
||||
static_libs: [
|
||||
"kotlinx_coroutines",
|
||||
"androidx.core_core-ktx",
|
||||
],
|
||||
system_ext_specific: true,
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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? \
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
}
|
||||
|
|
@ -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" />
|
||||
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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() })
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -248,7 +248,7 @@ open class EuiccManagementFragment : Fragment(), EuiccProfilesChangedListener,
|
|||
slotId, portId, seId, iccid, enable,
|
||||
reconnectTimeoutMillis = 30 * 1000
|
||||
)
|
||||
.stateFlow.waitDone()
|
||||
.waitDone()
|
||||
|
||||
when (err) {
|
||||
null -> {}
|
||||
|
|
|
|||
|
|
@ -97,7 +97,6 @@ class EuiccMemoryResetFragment : DialogFragment(), EuiccChannelFragmentMarker {
|
|||
euiccChannelManagerService.waitForForegroundTask()
|
||||
|
||||
euiccChannelManagerService.launchMemoryReset(slotId, portId, seId)
|
||||
.stateFlow
|
||||
.onStart {
|
||||
parentFragment?.notifyEuiccProfilesChanged()
|
||||
|
||||
|
|
|
|||
|
|
@ -91,7 +91,6 @@ class ProfileDeleteFragment : DialogFragment(), EuiccChannelFragmentMarker {
|
|||
ensureEuiccChannelManager()
|
||||
euiccChannelManagerService.waitForForegroundTask()
|
||||
euiccChannelManagerService.launchProfileDeleteTask(slotId, portId, seId, iccid)
|
||||
.stateFlow
|
||||
.onStart {
|
||||
parentFragment?.notifyEuiccProfilesChanged()
|
||||
runCatching(::dismiss)
|
||||
|
|
|
|||
|
|
@ -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 -> {
|
||||
|
|
|
|||
|
|
@ -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() }
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
}
|
||||
}
|
||||
|
|
@ -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())
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
|
@ -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)
|
||||
)
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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>,
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
@ -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://<host>:<port>」。プロキシを無効にするには、空欄のままにしてください。</string>
|
||||
<string name="pref_developer">開発者オプション</string>
|
||||
<string name="pref_developer_refresh_after_switch">モデムに更新コマンドを送信</string>
|
||||
<string name="pref_developer_refresh_after_switch_desc">プロファイルを切り替えた後にモデムに更新コマンドを送信するかどうかを設定します。クラッシュが発生する場合は、この機能を無効化してください。</string>
|
||||
|
|
|
|||
|
|
@ -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://<host>:<port>\"。留空则表示禁用代理。</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>
|
||||
|
|
|
|||
|
|
@ -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://<host>:<port>」。若要「停用」代理伺服器,請保持空白。</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>
|
||||
|
|
|
|||
|
|
@ -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://<host>:<port>\". 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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
@ -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() {}
|
||||
}
|
||||
|
|
@ -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()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
|
|
@ -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())
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
@ -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",
|
||||
],
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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")
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -42,7 +42,6 @@ class PrivilegedEuiccChannelFactory(context: Context) : DefaultEuiccChannelFacto
|
|||
seId,
|
||||
context.preferenceRepository.verboseLoggingFlow,
|
||||
context.preferenceRepository.ignoreTLSCertificateFlow,
|
||||
context.preferenceRepository.httpProxyFlow,
|
||||
context.preferenceRepository.es10xMssFlow,
|
||||
)
|
||||
} catch (_: IllegalArgumentException) {
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
|
|
@ -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)
|
||||
}
|
||||
|
|
@ -8,5 +8,5 @@ plugins {
|
|||
}
|
||||
|
||||
dependencies {
|
||||
implementation("com.android.tools.build:gradle:9.3.0")
|
||||
}
|
||||
implementation("com.android.tools.build:gradle:8.1.2")
|
||||
}
|
||||
|
|
@ -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"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
@ -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
|
||||
|
|
|
|||
2
gradle/wrapper/gradle-wrapper.properties
vendored
2
gradle/wrapper/gradle-wrapper.properties
vendored
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
}
|
||||
}
|
||||
|
|
@ -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")
|
||||
}
|
||||
}
|
||||
|
|
@ -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")
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue