Compare commits

..

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

274 changed files with 3617 additions and 7618 deletions

View file

@ -1,22 +0,0 @@
root = true
[*]
charset = utf-8
indent_size = 2
indent_style = space
end_of_line = lf
insert_final_newline = true
trim_trailing_whitespace = true
max_line_length = 120
[*.{kt,java}]
indent_size = 4
[*.gradle.kts]
indent_size = 4
[*.xml]
indent_size = 4
[.idea/**/*.xml]
indent_size = 2

View file

@ -1,15 +1,11 @@
name: Build Debug APKs
on:
push:
branches:
- '*'
tags:
- '*'
- 'master'
jobs:
build-debug:
runs-on: [ docker, android-app-certs ]
runs-on: [docker, android-app-certs]
container:
volumes:
- android-app-keystore:/keystore
@ -36,27 +32,15 @@ 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
run: ./gradlew --no-daemon assembleDebug
- name: Copy Artifacts
run: |
find . -name 'app*-debug.apk' -exec cp {} . \;
cp -r app/build/magisk/debug ./magisk-debug
run: find . -name 'app*-debug.apk' -exec cp {} . \;
- name: Upload APK Artifacts
- name: Upload Artifacts
uses: https://gitea.angry.im/actions/upload-artifact@v3
with:
name: Debug APKs
compression-level: 0
path: app*-debug.apk
- name: Upload Magisk Artifacts
uses: https://gitea.angry.im/actions/upload-artifact@v3
with:
name: magisk-debug
compression-level: 0
path: magisk-debug

View file

@ -1,12 +1,10 @@
name: Build Release
on:
push:
tags: '*'
jobs:
release:
runs-on: [ docker, android-app-certs ]
runs-on: [docker, android-app-certs]
container:
volumes:
- android-app-keystore:/keystore
@ -33,9 +31,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 +38,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 +47,3 @@ jobs:
# Release details are expected to be edited manually
release-notes: TBD
prerelease: 'true'
title: TBD ${{ github.ref_name }}
tag: ${{ github.ref_name }}
verbose: true

1
.gitignore vendored
View file

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

5
.gitmodules vendored
View file

@ -1,6 +1,3 @@
[submodule "libs/lpac-jni/src/main/jni/lpac"]
path = libs/lpac-jni/src/main/jni/lpac
url = https://github.com/estkme-group/lpac.git
[submodule "libs/lpac-jni/src/main/jni/cJSON"]
path = libs/lpac-jni/src/main/jni/cjson/cjson
url = https://github.com/DaveGamble/cJSON
url = https://github.com/estkme/lpac

21
.idea/.gitignore generated vendored
View file

@ -1,7 +1,14 @@
*
!/codeStyles/Project.xml
!/codeStyles/codeStyleConfig.xml
!/vcs.xml
!/kotlinc.xml
!/compiler.xml
!/migrations.xml
/shelf
/caches
/libraries
/assetWizardSettings.xml
/deploymentTargetDropDown.xml
/gradle.xml
/misc.xml
/modules.xml
/navEditor.xml
/runConfigurations.xml
/workspace.xml
/AndroidProjectSystem.xml
**/*.iml

View file

@ -1,47 +1,5 @@
<component name="ProjectCodeStyleConfiguration">
<code_scheme name="Project" version="173">
<JavaCodeStyleSettings>
<option name="IMPORT_LAYOUT_TABLE">
<value>
<package name="android" withSubpackages="true" static="true" />
<package name="androidx" withSubpackages="true" static="true" />
<package name="com" withSubpackages="true" static="true" />
<package name="junit" withSubpackages="true" static="true" />
<package name="net" withSubpackages="true" static="true" />
<package name="org" withSubpackages="true" static="true" />
<package name="java" withSubpackages="true" static="true" />
<package name="javax" withSubpackages="true" static="true" />
<package name="" withSubpackages="true" static="true" />
<emptyLine />
<package name="android" withSubpackages="true" static="false" />
<emptyLine />
<package name="androidx" withSubpackages="true" static="false" />
<emptyLine />
<package name="com" withSubpackages="true" static="false" />
<emptyLine />
<package name="junit" withSubpackages="true" static="false" />
<emptyLine />
<package name="net" withSubpackages="true" static="false" />
<emptyLine />
<package name="org" withSubpackages="true" static="false" />
<emptyLine />
<package name="java" withSubpackages="true" static="false" />
<emptyLine />
<package name="javax" withSubpackages="true" static="false" />
<emptyLine />
<package name="" withSubpackages="true" static="false" />
<emptyLine />
</value>
</option>
</JavaCodeStyleSettings>
<JetCodeStyleSettings>
<option name="PACKAGES_TO_USE_STAR_IMPORTS">
<value>
<package name="im.angry.openeuicc.util" alias="false" withSubpackages="true" />
</value>
</option>
<option name="CODE_STYLE_DEFAULTS" value="KOTLIN_OFFICIAL" />
</JetCodeStyleSettings>
<codeStyleSettings language="XML">
<option name="FORCE_REARRANGE_MODE" value="1" />
<indentOptions>
@ -155,8 +113,5 @@
</rules>
</arrangement>
</codeStyleSettings>
<codeStyleSettings language="kotlin">
<option name="CODE_STYLE_DEFAULTS" value="KOTLIN_OFFICIAL" />
</codeStyleSettings>
</code_scheme>
</component>

View file

@ -1,5 +1,5 @@
<component name="ProjectCodeStyleConfiguration">
<state>
<option name="USE_PER_PROJECT_SETTINGS" value="true" />
<option name="PREFERRED_PROJECT_CODE_STYLE" value="Default" />
</state>
</component>

2
.idea/compiler.xml generated
View file

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

13
.idea/deploymentTargetSelector.xml generated Normal file
View file

@ -0,0 +1,13 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="deploymentTargetSelector">
<selectionStates>
<SelectionState runConfigName="app-unpriv">
<option name="selectionMode" value="DROPDOWN" />
</SelectionState>
<SelectionState runConfigName="app">
<option name="selectionMode" value="DROPDOWN" />
</SelectionState>
</selectionStates>
</component>
</project>

3
.idea/kotlinc.xml generated
View file

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

2
.idea/migrations.xml generated
View file

@ -7,4 +7,4 @@
</set>
</option>
</component>
</project>
</project>

1
.idea/vcs.xml generated
View file

@ -2,7 +2,6 @@
<project version="4">
<component name="VcsDirectoryMappings">
<mapping directory="$PROJECT_DIR$" vcs="Git" />
<mapping directory="$PROJECT_DIR$/libs/lpac-jni/src/main/jni/cjson/cjson" vcs="Git" />
<mapping directory="$PROJECT_DIR$/libs/lpac-jni/src/main/jni/lpac" vcs="Git" />
</component>
</project>

View file

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

121
README.md
View file

@ -2,55 +2,25 @@
A fully free and open-source Local Profile Assistant implementation for Android devices.
__Google is threatening to lock down the Android platform in 2026. OpenEUICC and EasyEUICC, as Free Software projects, will never accept Google's proposed third-party developer verification flow. Join us in Keeping Android Open at <https://keepandroidopen.org/>.__
There are two variants of this project:
There are two variants of this project, OpenEUICC and [EasyEUICC](https://easyeuicc.org):
- OpenEUICC: The full-fledged privileged variant.
- Due to its privilege requirement, OpenEUICC must be placed inside `/system/priv-app` and be signed with the platform certificate.
- The preferred way to including OpenEUICC in a system image is to [build it along with AOSP](#building-aosp).
- __Note__: When privileged, OpenEUICC supports any eUICC chip that implements the SGP.22 standard, internal or external. However, there is __no guarantee__ that external (removable) eSIMs actually follow the standard. Please __DO NOT__ submit bug reports for non-functioning removable eSIMs. They are __NOT__ officially supported unless they also support / are supported by EasyEUICC, the unprivileged variant.
- EasyEUICC: Unprivileged version that can run as a user app.
- This version supports two modes of operation:
1. Inserted, removable eSIMs: Due to obvious security requirements, EasyEUICC is only able to access eSIM chips whose [ARF/ARA](https://source.android.com/docs/core/connect/uicc#arf) contains the hash of EasyEUICC's signing certificate.
2. USB CCID Card Readers: Only `T=0` readers that use the standard [USB CCID protocol](https://en.wikipedia.org/wiki/CCID_(protocol)) are supported. In this mode, EasyEUICC can access any eSIM chip loaded in the card reader regardless of their ARF/ARA, as long as they implement the [SGP.22 standard](https://www.gsma.com/solutions-and-impact/technologies/esim/wp-content/uploads/2021/07/SGP.22-v2.3.pdf).
- Prebuilt release-mode EasyEUICC apks can be downloaded [here](https://gitea.angry.im/PeterCxy/OpenEUICC/releases)
- For removable eSIM chip vendors: to have your chip supported by official builds of EasyEUICC when inserted, include the ARA-M hash `2A2FA878BC7C3354C2CF82935A5945A3EDAE4AFA`
| | OpenEUICC | EasyEUICC |
|:------------------------------|:-------------------------------:|:-------------------:|
| Privileged | Must be installed as system app | No |
| Internal eSIM | Supported | Unsupported |
| External eSIM [^1] | Supported | Supported |
| USB Readers | Supported | Supported |
| Requires allowlisting by eSIM | No | Yes -- except USB |
| System Integration | Partial [^2] | No |
| Minimum Android Version | Android 11 or higher | Android 9 or higher |
__This project is Free Software licensed under GNU GPL v3, WITHOUT the "or later" clause.__ Any modification and derivative work __MUST__ be released under the SAME license, which means, at the very least, that the source code __MUST__ be available upon request.
[^1]: Also known as "Removable eSIM"
[^2]: Carrier Partner API unimplemented yet
__If you are releasing a modification of this app, you are kindly asked to make changes to at least the app name and package name.__
Some side notes:
1. When privileged, OpenEUICC supports any eUICC chip that implements the [SGP.22] standard, internal or external.
However, there is **no guarantee** that external (removable) eSIMs actually follow the standard.
Please **DO NOT** submit bug reports for non-functioning removable eSIMs.
They are **NOT** officially supported unless they also support / are supported by EasyEUICC, the unprivileged
variant.
2. Both variants support accessing eUICC chips through USB CCID readers,
regardless of whether the chip contains the correct ARA-M hash to allow for unprivileged access.
However, only `T=0` readers that use the standard [USB CCID protocol][usb-ccid] are supported.
3. Prebuilt release-mode EasyEUICC apks can be downloaded [here][releases].
For OpenEUICC, no official release is currently provided and only debug mode APKs and Magisk modules can be found in
the [CI page][actions].
4. For removable eSIM chip vendors: to have your chip supported by official builds of EasyEUICC when inserted,
include the ARA-M hash `2A2FA878BC7C3354C2CF82935A5945A3EDAE4AFA`.
[sgp.22]: https://www.gsma.com/solutions-and-impact/technologies/esim/gsma_resources/sgp-22-v2-2-2/ "SGP.22 v2.2.2"
[usb-ccid]: https://en.wikipedia.org/wiki/CCID_%28protocol%29 "USB CCID Protocol"
[releases]: https://gitea.angry.im/PeterCxy/OpenEUICC/releases "EasyEUICC Releases"
[actions]: https://gitea.angry.im/PeterCxy/OpenEUICC/actions "OpenEUICC Actions"
**This project is Free Software licensed under GNU GPL v3, WITHOUT the "or later" clause.**
Any modification and derivative work **MUST** be released under the SAME license, which means, at the very least, that
the source code **MUST** be available upon request.
**If you are releasing a modification of this app, you are kindly asked to make changes to at least the app name and
package name.**
# Building (Gradle)
Building (Gradle)
===
Make sure you have all submodules cloned and updated by running
@ -83,54 +53,41 @@ For EasyEUICC:
./gradlew :app-unpriv:assembleRelease
```
# Building (AOSP)
Building (AOSP)
===
There are two ways to include OpenEUICC in your AOSP-based system image:
1. Include this project and its [dependencies](https://gitea.angry.im/PeterCxy/android_prebuilts_openeuicc-deps) inside
the AOSP tree.
1. Include this project and its [dependencies](https://gitea.angry.im/PeterCxy/android_prebuilts_openeuicc-deps) inside the AOSP tree.
- If inclusion in `manifest.xml` is required, remember to set the `sync-s` option to clone submodules.
- The module name is `OpenEUICC`. You can include it in `PRODUCT_PACKAGES`, or simply build it standalone using `mm`.
- Compilation of this project is **only** tested against the latest AOSP release version. The app itself should be compatible with older AOSP versions, but the source may not compile against an older AOSP source tree.
2. If compilation against AOSP source tree is not possible, consider [building with gradle](#building-gradle) and import the apk as a prebuilt.
- No official `Android.bp` is provided for this case but it should be straightforward to write.
- You might want to include `privapp_whitelist_im.angry.openeuicc.xml` as well.
- If inclusion in `manifest.xml` is required, remember to set the `sync-s` option to clone submodules.
- The module name is `OpenEUICC`. You can include it in `PRODUCT_PACKAGES`, or simply build it standalone using `mm`.
- Compilation of this project is **only** tested against the latest AOSP release version. The app itself should be
compatible with older AOSP versions, but the source may not compile against an older AOSP source tree.
FAQs
===
2. If compilation against AOSP source tree is not possible, consider [building with gradle](#building-gradle) and import
the apk as a prebuilt.
- Q: Do you provide prebuilt binaries for OpenEUICC?
- A: Debug-mode APKs are available continuously as an artifact of the [Actions](https://gitea.angry.im/PeterCxy/OpenEUICC/actions) CI used by this project. However, these debug-mode APKs are **not** intended for inclusion inside system images, nor are they supported by the developer in any sense. If you are a custom ROM developer, either include the entire OpenEUICC repository in your AOSP source tree, or generate an APK using `gradle` and import that as a prebuilt system app. Note that you might want `privapp_whitelist_im.angry.openeuicc.xml` as well.
- No official `Android.bp` is provided for this case but it should be straightforward to write.
- You might want to include [`privapp_whitelist_im.angry.openeuicc.xml`] as well.
- Q: AOSP's Settings app seems to be confused by OpenEUICC (for example, disabling / enabling profiles from the Networks page do not work properly)
- A: When your device has internal eSIM chip(s) __and__ you have inserted a removable eSIM chip, the Settings app can misbehave since it was never designed for this scenario. __Please prefer using OpenEUICC's own management interface whenever possible.__ In the future, there might be an option to exclude removable SIMs from being reported to the Android system.
[`privapp_whitelist_im.angry.openeuicc.xml`]: privapp_whitelist_im.angry.openeuicc.xml "OpenEUICC Privapp Whitelist"
- Q: Can EasyEUICC manage my phone's internal eSIM?
- A: No. For EasyEUICC to work, the eSIM chip MUST proactively grant access via its ARA-M field.
# Documentation
- Q: Removable eSIMs? Are they a joke?
- A: No, even though the name "removable embedded SIM" can sound like an oxymoron. In fact, there can be many advantages to these chips compared to fully embedded ones. For example, the ability to transfer eSIM profiles without carrier support or approval, or the ability to use eSIM on devices that do not and may never get the support, such as Wi-Fi hotspots.
- [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? \
A: Debug-mode APKs and Magisk modules are available continuously as an artifact of the [Actions] CI used by this
project. However, these debug-mode APKs are **not** intended for inclusion inside system images, nor are they
supported by the developer in any sense. If you are a custom ROM developer, either include the entire OpenEUICC
repository in your AOSP source tree, or generate an APK using `gradle` and import that as a prebuilt system app. Note
that you might want [`privapp_whitelist_im.angry.openeuicc.xml`] as well.
- Q: Can EasyEUICC manage my phone's internal eSIM? \
A: No. For EasyEUICC to work, the eSIM chip MUST proactively grant access via its ARA-M field.
- Q: Removable eSIMs? Are they a joke? \
A: No, even though the name "removable embedded SIM" can sound like an oxymoron. In fact, there can be many advantages
to these chips compared to fully embedded ones. For example, the ability to transfer eSIM profiles without carrier
support or approval, or the ability to use eSIM on devices that do not and may never get the support, such as Wi-Fi
hotspots.
# Copyright
Copyright
===
Everything except `libs/lpac-jni` and `art/`:
```
Copyright 2022-2026 OpenEUICC contributors
Copyright 2022-2024 OpenEUICC contributors
This program is free software; you can redistribute it and/or
modify it under the terms of the GNU General Public License
@ -149,7 +106,7 @@ Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
`libs/lpac-jni`:
```
Copyright (C) 2022-2026 OpenEUICC contributiors
Copyright (C) 2022-2024 OpenEUICC contributiors
This library is free software; you can redistribute it and/or
modify it under the terms of the GNU Lesser General Public
@ -165,4 +122,4 @@ License along with this library; if not, write to the Free Software
Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA
```
`art/`: Courtesy of [Aikoyori](https://github.com/Aikoyori), CC NC-SA 4.0.
`art/`: Courtesy of [Aikoyori](https://github.com/Aikoyori), CC NC-SA 4.0.

View file

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

View file

@ -1,11 +1,13 @@
package im.angry.openeuicc.common
import androidx.test.ext.junit.runners.AndroidJUnit4
import androidx.test.platform.app.InstrumentationRegistry
import org.junit.Assert.assertEquals
import androidx.test.ext.junit.runners.AndroidJUnit4
import org.junit.Test
import org.junit.runner.RunWith
import org.junit.Assert.*
/**
* Instrumented test, which will execute on an Android device.
*
@ -19,4 +21,4 @@ class ExampleInstrumentedTest {
val appContext = InstrumentationRegistry.getInstrumentation().targetContext
assertEquals("im.angry.openeuicc.common.test", appContext.packageName)
}
}
}

View file

@ -1,13 +1,12 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools"
<manifest xmlns:tools="http://schemas.android.com/tools"
xmlns:android="http://schemas.android.com/apk/res/android"
package="im.angry.openeuicc.common">
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
<uses-permission android:name="android.permission.READ_PHONE_STATE" />
<uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
<uses-permission android:name="android.permission.WAKE_LOCK" />
<application
android:enableOnBackInvokedCallback="true"
@ -29,23 +28,9 @@
android:label="@string/pref_advanced_logs" />
<activity
android:name="im.angry.openeuicc.ui.IsdrAidListActivity"
android:label="@string/isdr_aid_list" />
<activity
android:exported="true"
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" />
@ -55,17 +40,14 @@
<!-- Accepts URIs that begin with "lpa:" -->
<!-- for example: "LPA:1$..." -->
<!-- refs: https://www.iana.org/assignments/uri-schemes/prov/lpa -->
<data android:scheme="lpa" />
<data
android:scheme="LPA"
tools:ignore="AppLinkUrlError" />
<data android:sspPrefix="1$" />
<data android:scheme="lpa"/>
<data android:sspPrefix="1$"/>
</intent-filter>
</activity>
<activity-alias
android:name="im.angry.openeuicc.ui.DirectProfileDownloadActivity"
android:exported="true"
android:name="im.angry.openeuicc.ui.DirectProfileDownloadActivity"
android:targetActivity="im.angry.openeuicc.ui.wizard.DownloadWizardActivity" />
<activity
@ -75,7 +57,7 @@
<service
android:name="im.angry.openeuicc.service.EuiccChannelManagerService"
android:exported="false"
android:foregroundServiceType="shortService" />
android:foregroundServiceType="shortService"
android:exported="false" />
</application>
</manifest>

View file

@ -1,93 +1,88 @@
package im.angry.openeuicc.core
import android.content.Context
import android.hardware.usb.UsbDevice
import android.hardware.usb.UsbInterface
import android.hardware.usb.UsbManager
import android.se.omapi.SEService
import android.util.Log
import im.angry.openeuicc.common.R
import im.angry.openeuicc.core.usb.UsbApduInterface
import im.angry.openeuicc.core.usb.UsbCcidContext
import im.angry.openeuicc.core.usb.bulkPair
import im.angry.openeuicc.core.usb.endpoints
import im.angry.openeuicc.util.*
import java.lang.IllegalArgumentException
open class DefaultEuiccChannelFactory(protected val context: Context) : EuiccChannelFactory {
private var seService: SEService? = null
private val usbManager by lazy {
context.getSystemService(Context.USB_SERVICE) as UsbManager
}
private suspend fun ensureSEService() {
if (seService == null || !seService!!.isConnected) {
seService = connectSEService(context)
}
}
override suspend fun tryOpenEuiccChannel(
port: UiccPortInfoCompat,
isdrAid: ByteArray,
seId: EuiccChannel.SecureElementId,
): EuiccChannel? = try {
override suspend fun tryOpenEuiccChannel(port: UiccPortInfoCompat): EuiccChannel? {
if (port.portIndex != 0) {
Log.w(
DefaultEuiccChannelManager.TAG,
"OMAPI channel attempted on non-zero portId, this may or may not work."
)
Log.w(DefaultEuiccChannelManager.TAG, "OMAPI channel attempted on non-zero portId, this may or may not work.")
}
ensureSEService()
Log.i(
DefaultEuiccChannelManager.TAG,
"Trying OMAPI for physical slot ${port.card.physicalSlotIndex}"
)
EuiccChannelImpl(
context.getString(R.string.channel_type_omapi),
port,
OmapiApduInterface(
seService!!,
Log.i(DefaultEuiccChannelManager.TAG, "Trying OMAPI for physical slot ${port.card.physicalSlotIndex}")
try {
return EuiccChannelImpl(
context.getString(R.string.omapi),
port,
context.preferenceRepository.verboseLoggingFlow
),
isdrAid,
seId,
context.preferenceRepository.verboseLoggingFlow,
context.preferenceRepository.ignoreTLSCertificateFlow,
context.preferenceRepository.httpProxyFlow,
context.preferenceRepository.es10xMssFlow,
)
} catch (_: IllegalArgumentException) {
// Failed
Log.w(
DefaultEuiccChannelManager.TAG,
"OMAPI APDU interface unavailable for physical slot ${port.card.physicalSlotIndex} with ISD-R AID: ${isdrAid.encodeHex()}."
)
null
intrinsicChannelName = null,
OmapiApduInterface(
seService!!,
port,
context.preferenceRepository.verboseLoggingFlow
),
context.preferenceRepository.verboseLoggingFlow,
context.preferenceRepository.ignoreTLSCertificateFlow,
).also {
Log.i(DefaultEuiccChannelManager.TAG, "Is OMAPI channel, setting MSS to 60")
it.lpa.setEs10xMss(60)
}
} catch (e: IllegalArgumentException) {
// Failed
Log.w(
DefaultEuiccChannelManager.TAG,
"OMAPI APDU interface unavailable for physical slot ${port.card.physicalSlotIndex}."
)
}
return null
}
override fun tryOpenUsbEuiccChannel(
ccidCtx: UsbCcidContext,
isdrAid: ByteArray,
seId: EuiccChannel.SecureElementId
): EuiccChannel? = try {
EuiccChannelImpl(
context.getString(R.string.channel_type_usb),
override fun tryOpenUsbEuiccChannel(usbDevice: UsbDevice, usbInterface: UsbInterface): EuiccChannel? {
val (bulkIn, bulkOut) = usbInterface.endpoints.bulkPair
if (bulkIn == null || bulkOut == null) return null
val conn = usbManager.openDevice(usbDevice) ?: return null
if (!conn.claimInterface(usbInterface, true)) return null
return EuiccChannelImpl(
context.getString(R.string.usb),
FakeUiccPortInfoCompat(FakeUiccCardInfoCompat(EuiccChannelManager.USB_CHANNEL_ID)),
intrinsicChannelName = usbDevice.productName,
UsbApduInterface(
ccidCtx
conn,
bulkIn,
bulkOut,
context.preferenceRepository.verboseLoggingFlow
),
isdrAid,
seId,
context.preferenceRepository.verboseLoggingFlow,
context.preferenceRepository.ignoreTLSCertificateFlow,
context.preferenceRepository.httpProxyFlow,
context.preferenceRepository.es10xMssFlow,
)
} catch (_: IllegalArgumentException) {
// Failed
Log.w(
DefaultEuiccChannelManager.TAG,
"USB APDU interface unavailable for ISD-R AID: ${isdrAid.encodeHex()}."
)
null
}
override fun cleanup() {
seService?.shutdown()
seService = null
}
}
}

View file

@ -5,15 +5,13 @@ import android.hardware.usb.UsbDevice
import android.hardware.usb.UsbManager
import android.telephony.SubscriptionManager
import android.util.Log
import im.angry.openeuicc.core.usb.UsbCcidContext
import im.angry.openeuicc.core.usb.interfaces
import im.angry.openeuicc.core.usb.smartCard
import im.angry.openeuicc.core.usb.interfaces
import im.angry.openeuicc.di.AppContainer
import im.angry.openeuicc.util.*
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.flow
import kotlinx.coroutines.flow.flowOn
import kotlinx.coroutines.flow.merge
@ -32,7 +30,7 @@ open class DefaultEuiccChannelManager(
private val channelCache = mutableListOf<EuiccChannel>()
private var usbChannels = mutableListOf<EuiccChannel>()
private var usbChannel: EuiccChannel? = null
private val lock = Mutex()
@ -51,94 +49,25 @@ open class DefaultEuiccChannelManager(
protected open val uiccCards: Collection<UiccCardInfoCompat>
get() = (0..<tm.activeModemCountCompat).map { FakeUiccCardInfoCompat(it) }
private suspend inline fun tryOpenChannelWithKnownAids(
openFn: (ByteArray, EuiccChannel.SecureElementId) -> EuiccChannel?
): List<EuiccChannel> {
var isdrAidList =
parseIsdrAidList(appContainer.preferenceRepository.isdrAidListFlow.first())
val ret = mutableListOf<EuiccChannel>()
val openedAids = mutableListOf<ByteArray>()
var hasReset = false
var vendorDecider: VendorAidDecider? = null
var seId = 0
outer@ while (true) {
for (aid in isdrAidList) {
if (vendorDecider != null && !vendorDecider.shouldOpenMore(openedAids, aid)) {
break@outer
}
val channel =
openFn(aid, EuiccChannel.SecureElementId.createFromInt(seId))?.let { channel ->
if (channel.valid) {
seId += 1
channel
} else {
channel.close()
null
}
}
if (!hasReset) {
val res = channel?.queryVendorAidListTransformation(isdrAidList)
if (res != null) {
// Reset the for loop since we needed to replace the AID list due to vendor-specific code
Log.i(TAG, "AID list replaced, resetting open attempt")
isdrAidList = res.first
vendorDecider = res.second
seId = 0
ret.clear()
openedAids.clear()
channel.close()
hasReset = true // Don't let anything reset again
continue@outer
}
}
if (channel != null) {
ret.add(channel)
openedAids.add(aid)
// Don't try opening more than 1 channel unless there is a vendor
// implementation for deciding when we should stop opening more channels
if (vendorDecider == null) {
break@outer
}
}
}
// If we get here we should exit, since the inner loop completed without resetting
break
}
// Set the hasMultipleSE field now since we only get to know that after we have iterated all AIDs
// This also flips a flag in EuiccChannelImpl and prevents the field from being set again
ret.forEach { it.hasMultipleSE = (seId > 1) }
return ret
}
private suspend fun tryOpenEuiccChannel(
port: UiccPortInfoCompat,
): List<EuiccChannel>? {
private suspend fun tryOpenEuiccChannel(port: UiccPortInfoCompat): EuiccChannel? {
lock.withLock {
if (port.card.physicalSlotIndex == EuiccChannelManager.USB_CHANNEL_ID) {
return usbChannels
return if (usbChannel != null && usbChannel!!.valid) {
usbChannel
} else {
usbChannel = null
null
}
}
// First get all channels for the requested port
val existing =
channelCache.filter { it.slotId == port.card.physicalSlotIndex && it.portId == port.portIndex }
if (existing.isNotEmpty()) {
if (existing.all { it.valid && it.logicalSlotId == port.logicalSlotIndex }) {
channelCache.find { it.slotId == port.card.physicalSlotIndex && it.portId == port.portIndex }
if (existing != null) {
if (existing.valid && port.logicalSlotIndex == existing.logicalSlotId) {
return existing
} else {
// If any channel shouldn't be considered valid anymore, close all existing for the same slot / port
// and reopen
existing.forEach {
it.close()
channelCache.remove(it)
}
existing.close()
channelCache.remove(existing)
}
}
@ -147,42 +76,32 @@ open class DefaultEuiccChannelManager(
return null
}
// This function is not responsible for managing USB channels (see the initial check)
val channels =
tryOpenChannelWithKnownAids { isdrAid, seId ->
euiccChannelFactory.tryOpenEuiccChannel(
port,
isdrAid,
seId
)
}
val channel = euiccChannelFactory.tryOpenEuiccChannel(port) ?: return null
if (channels.isNotEmpty()) {
channelCache.addAll(channels)
return channels
if (channel.valid) {
channelCache.add(channel)
return channel
} else {
Log.i(
TAG,
"Was able to open channel for logical slot ${port.logicalSlotIndex}, but the channel is invalid (cannot get eID or profiles without errors). This slot might be broken, aborting."
)
channel.close()
return null
}
}
}
protected suspend fun findEuiccChannelByLogicalSlot(
logicalSlotId: Int,
seId: EuiccChannel.SecureElementId
): EuiccChannel? =
protected suspend fun findEuiccChannelByLogicalSlot(logicalSlotId: Int): EuiccChannel? =
withContext(Dispatchers.IO) {
if (logicalSlotId == EuiccChannelManager.USB_CHANNEL_ID) {
return@withContext usbChannels.find { it.seId == seId }
return@withContext usbChannel
}
for (card in uiccCards) {
for (port in card.ports) {
if (port.logicalSlotIndex == logicalSlotId) {
return@withContext tryOpenEuiccChannel(port)?.find { it.seId == seId }
return@withContext tryOpenEuiccChannel(port)
}
}
}
@ -190,35 +109,23 @@ open class DefaultEuiccChannelManager(
null
}
/**
* Find all EuiccChannels associated with a _physical_ slot, including all secure elements
* on cards with multiple of them.
*/
private suspend fun findAllEuiccChannelsByPhysicalSlot(physicalSlotId: Int): List<EuiccChannel>? {
if (physicalSlotId == EuiccChannelManager.USB_CHANNEL_ID) {
return usbChannels.ifEmpty { null }
return usbChannel?.let { listOf(it) }
}
for (card in uiccCards) {
if (card.physicalSlotIndex != physicalSlotId) continue
return card.ports.mapNotNull { tryOpenEuiccChannel(it) }
.flatten()
.ifEmpty { null }
}
return null
}
/**
* Finds all EuiccChannels associated with a physical slot + port. Note that this
* may return multiple in case there are multiple SEs.
*/
private suspend fun findEuiccChannelsByPort(
physicalSlotId: Int,
portId: Int,
): List<EuiccChannel>? =
private suspend fun findEuiccChannelByPort(physicalSlotId: Int, portId: Int): EuiccChannel? =
withContext(Dispatchers.IO) {
if (physicalSlotId == EuiccChannelManager.USB_CHANNEL_ID) {
return@withContext usbChannels.ifEmpty { null }
return@withContext usbChannel
}
uiccCards.find { it.physicalSlotIndex == physicalSlotId }?.let { card ->
@ -241,17 +148,15 @@ open class DefaultEuiccChannelManager(
return@withContext listOf(0)
}
findAllEuiccChannelsByPhysicalSlot(physicalSlotId)?.map { it.portId }?.toSet()?.toList()
?: listOf()
findAllEuiccChannelsByPhysicalSlot(physicalSlotId)?.map { it.portId } ?: listOf()
}
override suspend fun <R> withEuiccChannel(
physicalSlotId: Int,
portId: Int,
seId: EuiccChannel.SecureElementId,
fn: suspend (EuiccChannel) -> R
): R {
val channel = findEuiccChannelsByPort(physicalSlotId, portId)?.find { it.seId == seId }
val channel = findEuiccChannelByPort(physicalSlotId, portId)
?: throw EuiccChannelManager.EuiccChannelNotFoundException()
val wrapper = EuiccChannelWrapper(channel)
try {
@ -265,10 +170,9 @@ open class DefaultEuiccChannelManager(
override suspend fun <R> withEuiccChannel(
logicalSlotId: Int,
seId: EuiccChannel.SecureElementId,
fn: suspend (EuiccChannel) -> R
): R {
val channel = findEuiccChannelByLogicalSlot(logicalSlotId, seId)
val channel = findEuiccChannelByLogicalSlot(logicalSlotId)
?: throw EuiccChannelManager.EuiccChannelNotFoundException()
val wrapper = EuiccChannelWrapper(channel)
try {
@ -281,48 +185,34 @@ open class DefaultEuiccChannelManager(
}
override suspend fun waitForReconnect(physicalSlotId: Int, portId: Int, timeoutMillis: Long) {
val numChannelsBefore = if (physicalSlotId == EuiccChannelManager.USB_CHANNEL_ID) {
usbChannels.size
if (physicalSlotId == EuiccChannelManager.USB_CHANNEL_ID) {
usbChannel?.close()
usbChannel = null
} else {
// Don't use find* methods since they reopen channels if not found
channelCache.filter { it.slotId == physicalSlotId && it.portId == portId }.size
}
val resetChannels = {
if (physicalSlotId == EuiccChannelManager.USB_CHANNEL_ID) {
usbChannels.forEach { it.close() }
usbChannels.clear()
} else {
// If there is already a valid channel, we close it proactively
channelCache.filter { it.slotId == physicalSlotId && it.portId == portId }.forEach { it.close() }
// If there is already a valid channel, we close it proactively
// Sometimes the current channel can linger on for a bit even after it should have become invalid
channelCache.find { it.slotId == physicalSlotId && it.portId == portId }?.apply {
if (valid) close()
}
}
resetChannels()
withTimeout(timeoutMillis) {
while (true) {
try {
val channels = if (physicalSlotId == EuiccChannelManager.USB_CHANNEL_ID) {
val channel = if (physicalSlotId == EuiccChannelManager.USB_CHANNEL_ID) {
// tryOpenUsbEuiccChannel() will always try to reopen the channel, even if
// a USB channel already exists
tryOpenUsbEuiccChannel()
usbChannels
usbChannel!!
} else {
// tryOpenEuiccChannel() will automatically dispose of invalid channels
// and recreate when needed
findEuiccChannelsByPort(physicalSlotId, portId)!!
findEuiccChannelByPort(physicalSlotId, portId)!!
}
check(channels.isNotEmpty()) { "No channel" }
check(channels.all { it.valid }) { "Invalid channel" }
check(numChannelsBefore > 0 && channels.size >= numChannelsBefore) { "Less channels than before" }
check(channel.valid) { "Invalid channel" }
break
} catch (e: Exception) {
Log.d(
TAG,
"Slot $physicalSlotId port $portId reconnect failure, retrying in 1000 ms"
)
resetChannels()
Log.d(TAG, "Slot $physicalSlotId port $portId reconnect failure, retrying in 1000 ms")
}
delay(1000)
}
@ -351,58 +241,26 @@ open class DefaultEuiccChannelManager(
}
})
override fun flowEuiccSecureElements(
slotId: Int,
portId: Int
): Flow<EuiccChannel.SecureElementId> = flow {
findEuiccChannelsByPort(slotId, portId)?.forEach {
emit(it.seId)
}
}
override suspend fun tryOpenUsbEuiccChannel(): Pair<UsbDevice?, Boolean> =
withContext(Dispatchers.IO) {
usbManager.deviceList.values.forEach { device ->
Log.i(
TAG,
"Scanning USB device ${device.vendorId.toUInt().toString(16)}:${
device.productId.toUInt().toString(16)
}"
)
Log.i(TAG, "Scanning USB device ${device.deviceId}:${device.vendorId}")
val iface = device.interfaces.smartCard ?: return@forEach
// If we don't have permission, tell UI code that we found a candidate device, but we
// need permission to be able to do anything with it
if (!usbManager.hasPermission(device)) return@withContext Pair(device, false)
Log.i(
TAG,
"Found CCID interface on ${device.deviceId}:${device.vendorId}, and has permission; trying to open channel"
)
val ccidCtx =
UsbCcidContext.createFromUsbDevice(context, device, iface) ?: return@forEach
Log.i(TAG, "Found CCID interface on ${device.deviceId}:${device.vendorId}, and has permission; trying to open channel")
try {
val channels = tryOpenChannelWithKnownAids { isdrAid, seId ->
euiccChannelFactory.tryOpenUsbEuiccChannel(ccidCtx, isdrAid, seId)
}
if (channels.isNotEmpty() && channels[0].valid) {
ccidCtx.allowDisconnect = true
usbChannels.clear()
usbChannels.addAll(channels)
val channel = euiccChannelFactory.tryOpenUsbEuiccChannel(device, iface)
if (channel != null && channel.lpa.valid) {
usbChannel = channel
return@withContext Pair(device, true)
}
} catch (e: Exception) {
// Ignored -- skip forward
e.printStackTrace()
}
ccidCtx.allowDisconnect = true
ccidCtx.disconnect()
Log.i(
TAG,
"No valid eUICC channel found on USB device ${device.deviceId}:${device.vendorId}"
)
Log.i(TAG, "No valid eUICC channel found on USB device ${device.deviceId}:${device.vendorId}")
}
return@withContext Pair(null, false)
}
@ -412,9 +270,9 @@ open class DefaultEuiccChannelManager(
channel.close()
}
usbChannels.forEach { it.close() }
usbChannels.clear()
usbChannel?.close()
usbChannel = null
channelCache.clear()
euiccChannelFactory.cleanup()
}
}
}

View file

@ -1,7 +1,5 @@
package im.angry.openeuicc.core
import android.os.Parcel
import android.os.Parcelable
import im.angry.openeuicc.util.*
import net.typeblog.lpac_jni.ApduInterface
import net.typeblog.lpac_jni.LocalProfileAssistant
@ -15,67 +13,6 @@ interface EuiccChannel {
val logicalSlotId: Int
val portId: Int
/**
* A semi-obscure wrapper over the integer ID of a secure element on a card.
*
* Because the ID is arbitrary, this is intended to discourage the use of the
* integer value directly. Additionally, it prevents accidentally calling the
* wrong function in EuiccChannelManager with a ton of integer parameters.
*/
class SecureElementId private constructor(val id: Int) : Parcelable {
companion object {
val DEFAULT = SecureElementId(0)
/**
* Create a SecureElementId from an integer ID. You should not call this directly
* unless you know what you're doing.
*
* This is currently only ever used in the download flow.
*/
fun createFromInt(id: Int): SecureElementId =
SecureElementId(id)
@Suppress("unused")
@JvmField
val CREATOR = object : Parcelable.Creator<SecureElementId> {
override fun createFromParcel(parcel: Parcel): SecureElementId =
createFromInt(parcel.readInt())
override fun newArray(size: Int): Array<SecureElementId?> = arrayOfNulls(size)
}
}
override fun hashCode(): Int =
id.hashCode()
override fun equals(other: Any?): Boolean =
if (other is SecureElementId) {
this.id == other.id
} else {
super.equals(other)
}
override fun describeContents(): Int = id
override fun writeToParcel(parcel: Parcel, flags: Int) {
parcel.writeInt(id)
}
}
/**
* Some chips support multiple SEs on one chip. The seId here is intended
* to distinguish channels opened from these different SEs.
*/
val seId: SecureElementId
/**
* Does this channel belong to a chip that supports multiple SEs?
* Note that this is only made `var` to make initialization a bit less annoying --
* this should never be set again after the channel is originally opened.
* Attempting to do so will yield an exception.
*/
var hasMultipleSE: Boolean
val lpa: LocalProfileAssistant
val valid: Boolean
@ -85,15 +22,17 @@ interface EuiccChannel {
*/
val atr: ByteArray?
/**
* Intrinsic name of this channel. For device-internal SIM slots,
* this should be null; for USB readers, this should be the name of
* the reader device.
*/
val intrinsicChannelName: String?
/**
* The underlying APDU interface for this channel
*/
val apduInterface: ApduInterface
/**
* The AID of the ISD-R channel currently in use
*/
val isdrAid: ByteArray
fun close()
}
}

View file

@ -1,22 +1,15 @@
package im.angry.openeuicc.core
import im.angry.openeuicc.core.usb.UsbCcidContext
import android.hardware.usb.UsbDevice
import android.hardware.usb.UsbInterface
import im.angry.openeuicc.util.*
// This class is here instead of inside DI because it contains a bit more logic than just
// "dumb" dependency injection.
interface EuiccChannelFactory {
suspend fun tryOpenEuiccChannel(
port: UiccPortInfoCompat,
isdrAid: ByteArray,
seId: EuiccChannel.SecureElementId
): EuiccChannel?
suspend fun tryOpenEuiccChannel(port: UiccPortInfoCompat): EuiccChannel?
fun tryOpenUsbEuiccChannel(
ccidCtx: UsbCcidContext,
isdrAid: ByteArray,
seId: EuiccChannel.SecureElementId
): EuiccChannel?
fun tryOpenUsbEuiccChannel(usbDevice: UsbDevice, usbInterface: UsbInterface): EuiccChannel?
/**
* Release all resources used by this EuiccChannelFactory
@ -24,4 +17,4 @@ interface EuiccChannelFactory {
* re-acquired when this happens
*/
fun cleanup()
}
}

View file

@ -1,9 +1,8 @@
package im.angry.openeuicc.core
import im.angry.openeuicc.util.*
import im.angry.openeuicc.util.UiccPortInfoCompat
import im.angry.openeuicc.util.decodeHex
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.runBlocking
import net.typeblog.lpac_jni.ApduInterface
import net.typeblog.lpac_jni.LocalProfileAssistant
import net.typeblog.lpac_jni.impl.HttpInterfaceImpl
@ -12,26 +11,26 @@ import net.typeblog.lpac_jni.impl.LocalProfileAssistantImpl
class EuiccChannelImpl(
override val type: String,
override val port: UiccPortInfoCompat,
override val intrinsicChannelName: String?,
override val apduInterface: ApduInterface,
override val isdrAid: ByteArray,
override val seId: EuiccChannel.SecureElementId,
verboseLoggingFlow: Flow<Boolean>,
ignoreTLSCertificateFlow: Flow<Boolean>,
httpProxyFlow: Flow<String>,
es10xMssFlow: Flow<Int>,
ignoreTLSCertificateFlow: Flow<Boolean>
) : EuiccChannel {
companion object {
// TODO: This needs to go somewhere else.
val ISDR_AID = "A0000005591010FFFFFFFF8900000100".decodeHex()
}
override val slotId = port.card.physicalSlotIndex
override val logicalSlotId = port.logicalSlotIndex
override val portId = port.portIndex
override val lpa: LocalProfileAssistant =
LocalProfileAssistantImpl(
isdrAid,
ISDR_AID,
apduInterface,
HttpInterfaceImpl(verboseLoggingFlow, ignoreTLSCertificateFlow, httpProxyFlow),
).also {
it.setEs10xMss(runBlocking { es10xMssFlow.first().toByte() })
}
HttpInterfaceImpl(verboseLoggingFlow, ignoreTLSCertificateFlow)
)
override val atr: ByteArray?
get() = (apduInterface as? ApduInterfaceAtrProvider)?.atr
@ -39,16 +38,5 @@ class EuiccChannelImpl(
override val valid: Boolean
get() = lpa.valid
private var hasMultipleSEInitialized = false
override var hasMultipleSE: Boolean = false
set(value) {
if (hasMultipleSEInitialized) {
throw IllegalStateException("already initialized")
}
field = value
}
override fun close() = lpa.close()
}

View file

@ -10,7 +10,7 @@ import kotlinx.coroutines.flow.Flow
* or when this instance is destroyed.
*
* To precisely control the lifecycle of this object itself (and thus its cached channels),
* all other components must access EuiccChannelManager objects through EuiccChannelManagerService.
* all other compoents must access EuiccChannelManager objects through EuiccChannelManagerService.
* Holding references independent of EuiccChannelManagerService is unsupported.
*/
interface EuiccChannelManager {
@ -37,14 +37,6 @@ interface EuiccChannelManager {
*/
fun flowAllOpenEuiccPorts(): Flow<Pair<Int, Int>>
/**
* Iterate over all the Secure Elements available on one eUICC.
*
* This is going to almost always return only 1 result, except in the case where
* a card has multiple SEs.
*/
fun flowEuiccSecureElements(slotId: Int, portId: Int): Flow<EuiccChannel.SecureElementId>
/**
* Scan all possible USB devices for CCID readers that may contain eUICC cards.
* If found, try to open it for access, and add it to the internal EuiccChannel cache
@ -75,7 +67,7 @@ interface EuiccChannelManager {
*/
suspend fun findAvailablePorts(physicalSlotId: Int): List<Int>
class EuiccChannelNotFoundException : Exception("EuiccChannel not found")
class EuiccChannelNotFoundException: Exception("EuiccChannel not found")
/**
* Find a EuiccChannel by its slot and port, then run a callback with a reference to it.
@ -86,12 +78,19 @@ interface EuiccChannelManager {
*
* If a channel for that slot / port is not found, EuiccChannelNotFoundException is thrown
*/
suspend fun <R> withEuiccChannel(physicalSlotId: Int, portId: Int, seId: EuiccChannel.SecureElementId, fn: suspend (EuiccChannel) -> R): R
suspend fun <R> withEuiccChannel(
physicalSlotId: Int,
portId: Int,
fn: suspend (EuiccChannel) -> R
): R
/**
* Same as withEuiccChannel(Int, Int, SecureElementId, (EuiccChannel) -> R) but instead uses logical slot ID
* Same as withEuiccChannel(Int, Int, (EuiccChannel) -> R) but instead uses logical slot ID
*/
suspend fun <R> withEuiccChannel(logicalSlotId: Int, seId: EuiccChannel.SecureElementId, fn: suspend (EuiccChannel) -> R): R
suspend fun <R> withEuiccChannel(
logicalSlotId: Int,
fn: suspend (EuiccChannel) -> R
): R
/**
* Invalidate all EuiccChannels previously cached by this Manager
@ -106,4 +105,4 @@ interface EuiccChannelManager {
suspend fun notifyEuiccProfilesChanged(logicalSlotId: Int) {
// no-op by default
}
}
}

View file

@ -26,25 +26,18 @@ class EuiccChannelWrapper(orig: EuiccChannel) : EuiccChannel {
get() = channel.logicalSlotId
override val portId: Int
get() = channel.portId
override val seId: EuiccChannel.SecureElementId
get() = channel.seId
private val lpaDelegate = lazy {
LocalProfileAssistantWrapper(channel.lpa)
}
override val lpa: LocalProfileAssistant by lpaDelegate
override val valid: Boolean
get() = channel.valid
override val intrinsicChannelName: String?
get() = channel.intrinsicChannelName
override val apduInterface: ApduInterface
get() = channel.apduInterface
override val atr: ByteArray?
get() = channel.atr
override val isdrAid: ByteArray
get() = channel.isdrAid
override var hasMultipleSE: Boolean
get() = channel.hasMultipleSE
set(value) {
channel.hasMultipleSE = value
}
override fun close() = channel.close()
@ -55,4 +48,4 @@ class EuiccChannelWrapper(orig: EuiccChannel) : EuiccChannel {
(lpa as LocalProfileAssistantWrapper).invalidateWrapper()
}
}
}
}

View file

@ -1,6 +1,5 @@
package im.angry.openeuicc.core
import net.typeblog.lpac_jni.ProfileDownloadInput
import net.typeblog.lpac_jni.EuiccInfo2
import net.typeblog.lpac_jni.LocalProfileAssistant
import net.typeblog.lpac_jni.LocalProfileInfo
@ -41,8 +40,13 @@ class LocalProfileAssistantWrapper(orig: LocalProfileAssistant) :
override fun deleteProfile(iccid: String): Boolean = lpa.deleteProfile(iccid)
override fun downloadProfile(input: ProfileDownloadInput, callback: ProfileDownloadCallback) =
lpa.downloadProfile(input, callback)
override fun downloadProfile(
smdp: String,
matchingId: String?,
imei: String?,
confirmationCode: String?,
callback: ProfileDownloadCallback
) = lpa.downloadProfile(smdp, matchingId, imei, confirmationCode, callback)
override fun deleteNotification(seqNumber: Long): Boolean = lpa.deleteNotification(seqNumber)
@ -59,4 +63,4 @@ class LocalProfileAssistantWrapper(orig: LocalProfileAssistant) :
fun invalidateWrapper() {
_inner = null
}
}
}

View file

@ -15,7 +15,7 @@ class OmapiApduInterface(
private val service: SEService,
private val port: UiccPortInfoCompat,
private val verboseLoggingFlow: Flow<Boolean>
) : ApduInterface, ApduInterfaceAtrProvider {
): ApduInterface, ApduInterfaceAtrProvider {
companion object {
const val TAG = "OmapiApduInterface"
}
@ -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 {
@ -91,4 +83,4 @@ class OmapiApduInterface(
throw e
}
}
}
}

View file

@ -1,113 +1,57 @@
package im.angry.openeuicc.core.usb
import android.hardware.usb.UsbDeviceConnection
import android.hardware.usb.UsbEndpoint
import android.util.Log
import im.angry.openeuicc.core.ApduInterfaceAtrProvider
import im.angry.openeuicc.util.decodeHex
import im.angry.openeuicc.util.encodeHex
import im.angry.openeuicc.util.*
import kotlinx.coroutines.flow.Flow
import net.typeblog.lpac_jni.ApduInterface
class UsbApduInterface(
private val ccidCtx: UsbCcidContext
private val conn: UsbDeviceConnection,
private val bulkIn: UsbEndpoint,
private val bulkOut: UsbEndpoint,
private val verboseLoggingFlow: Flow<Boolean>
) : ApduInterface, ApduInterfaceAtrProvider {
companion object {
private const val TAG = "UsbApduInterface"
}
override val atr: ByteArray?
get() = ccidCtx.atr
private lateinit var ccidDescription: UsbCcidDescription
private lateinit var transceiver: UsbCcidTransceiver
override var atr: ByteArray? = null
override val valid: Boolean
get() = channels.isNotEmpty()
private var channels = mutableSetOf<Int>()
// ATR parser
// Specs: ISO/IEC 7816-3:2006 8.2 Answer-to-Reset
// See also: https://en.wikipedia.org/wiki/Answer_to_reset
class ParsedAtr private constructor(
val ts: Byte?,
val t0: Byte?,
val ta1: Byte?,
val tb1: Byte?,
val tc1: Byte?,
val td1: Byte?,
val ta2: Byte?,
val tb2: Byte?,
val tc2: Byte?,
val td2: Byte?
) {
companion object {
fun parse(atr: ByteArray): ParsedAtr {
val ts = atr[0]
val t0 = atr[1]
val tx1 = arrayOf<Byte?>(null, null, null, null)
val tx2 = arrayOf<Byte?>(null, null, null, null)
var pointer = 2
for (i in 0..3) {
if (t0.toInt() and (0x10 shl i) != 0) {
tx1[i] = atr[pointer]
pointer++
}
}
val td1 = tx1[3] ?: 0
for (i in 0..3) {
if (td1.toInt() and (0x10 shl i) != 0) {
tx2[i] = atr[pointer]
pointer++
}
}
return ParsedAtr(
ts = ts, t0 = t0, ta1 = tx1[0], tb1 = tx1[1], tc1 = tx1[2], td1 = tx1[3],
ta2 = tx2[0], tb2 = tx2[1], tc2 = tx2[2], td2 = tx2[3],
)
}
}
}
override fun connect() {
ccidCtx.connect()
ccidDescription = UsbCcidDescription.fromRawDescriptors(conn.rawDescriptors)!!
if (ccidCtx.useTpdu) {
// Send parameter selection
// Specs: USB-CCID 3.2.1 TPDU level of exchange
val parsedAtr = ParsedAtr.parse(atr!!)
val ta1 = parsedAtr.ta1 ?: 0x11.toByte()
val pts1 = ta1 // TODO: Check that reader supports baud rate proposed by the card
val pps = byteArrayOf(0xff.toByte(), 0x10.toByte(), pts1, 0x00.toByte())
Log.d(TAG, "PTS1=${pts1} PPS: ${pps.encodeHex()}")
ccidCtx.transceiver.sendXfrBlock(pps)
// Send Set Parameters
// Specs: USB-CCID 6.1.7 PC_to_RDR_SetParameters
val param = byteArrayOf(
pts1,
(if (parsedAtr.ts == 0x3F.toByte()) 0x02 else 0x00),
parsedAtr.tc1 ?: 0,
parsedAtr.tc2 ?: 0x0A,
0x00
)
Log.d(TAG, "Param: ${param.encodeHex()}")
ccidCtx.transceiver.sendParamBlock(param)
if (!ccidDescription.hasT0Protocol) {
throw IllegalArgumentException("Unsupported card reader; T=0 support is required")
}
// Send Terminal Capabilities
// Specs: ETSI TS 102 221 v15.0.0 - 11.1.19 TERMINAL CAPABILITY
val terminalCapabilities = buildCmd(
0x80.toByte(), 0xaa.toByte(), 0x00, 0x00,
"A9088100820101830107".decodeHex(),
le = null,
)
transmitApduByChannel(terminalCapabilities, 0)
transceiver = UsbCcidTransceiver(conn, bulkIn, bulkOut, ccidDescription, verboseLoggingFlow)
try {
// 6.1.1.1 PC_to_RDR_IccPowerOn (Page 20 of 40)
// https://www.usb.org/sites/default/files/DWG_Smart-Card_USB-ICC_ICCD_rev10.pdf
atr = transceiver.iccPowerOn().data
} catch (e: Exception) {
e.printStackTrace()
throw e
}
}
override fun disconnect() = ccidCtx.disconnect()
override fun disconnect() {
conn.close()
atr = null
}
override fun logicalChannelOpen(aid: ByteArray): Int {
// OPEN LOGICAL CHANNEL
@ -127,7 +71,6 @@ class UsbApduInterface(
val channelId = resp[0].toInt()
Log.d(TAG, "channelId = $channelId")
channels.add(channelId)
// Then, select AID
val selectAid = selectByDfCmd(aid, channelId.toByte())
@ -135,11 +78,11 @@ class UsbApduInterface(
if (!isSuccessResponse(selectAidResp)) {
Log.d(TAG, "Select DF failed : ${selectAidResp.encodeHex()}")
logicalChannelClose(channelId)
Log.d(TAG, "Closed logical channel $channelId due to select DF failure")
return -1
}
channels.add(channelId)
return channelId
}
@ -197,7 +140,7 @@ class UsbApduInterface(
// OR the channel mask into the CLA byte
realTx[0] = ((realTx[0].toInt() and 0xFC) or channel.toInt()).toByte()
var resp = ccidCtx.transceiver.sendXfrBlock(realTx).data!!
var resp = transceiver.sendXfrBlock(realTx).data!!
if (resp.size < 2) throw RuntimeException("APDU response smaller than 2 (sw1 + sw2)!")
@ -208,7 +151,7 @@ class UsbApduInterface(
// 0x6C = wrong le
// so we fix the le field here
realTx[realTx.size - 1] = resp[resp.size - 1]
resp = ccidCtx.transceiver.sendXfrBlock(realTx).data!!
resp = transceiver.sendXfrBlock(realTx).data!!
} else if (sw1 == 0x61) {
// 0x61 = X bytes available
// continue reading by GET RESPONSE
@ -218,7 +161,7 @@ class UsbApduInterface(
realTx[0], 0xC0.toByte(), 0x00, 0x00, sw2.toByte()
)
val tmp = ccidCtx.transceiver.sendXfrBlock(getResponseCmd).data!!
val tmp = transceiver.sendXfrBlock(getResponseCmd).data!!
resp = resp.sliceArray(0 until (resp.size - 2)) + tmp
@ -229,4 +172,4 @@ class UsbApduInterface(
return resp
}
}
}

View file

@ -1,93 +0,0 @@
package im.angry.openeuicc.core.usb
import android.content.Context
import android.hardware.usb.UsbDevice
import android.hardware.usb.UsbDeviceConnection
import android.hardware.usb.UsbEndpoint
import android.hardware.usb.UsbInterface
import android.hardware.usb.UsbManager
import im.angry.openeuicc.util.preferenceRepository
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.runBlocking
/**
* A wrapper over an usb device + interface, manages the lifecycle independent
* of the APDU interface exposed to lpac-jni.
*
* This allows us to try multiple AIDs on each interface without opening / closing
* the USB connection numerous times.
*/
class UsbCcidContext private constructor(
private val conn: UsbDeviceConnection,
private val bulkIn: UsbEndpoint,
private val bulkOut: UsbEndpoint,
val verboseLoggingFlow: Flow<Boolean>,
val useTpdu: Boolean
) {
companion object {
fun createFromUsbDevice(
context: Context,
usbDevice: UsbDevice,
usbInterface: UsbInterface
): UsbCcidContext? = runCatching {
val (bulkIn, bulkOut) = usbInterface.endpoints.bulkPair
if (bulkIn == null || bulkOut == null) return@runCatching null
val conn = context.getSystemService(UsbManager::class.java).openDevice(usbDevice)
?: return@runCatching null
if (!conn.claimInterface(usbInterface, true)) return@runCatching null
val forceTpduMode = runBlocking { context.preferenceRepository.forceTpduModeFlow.first() }
val useTpdu = forceTpduMode || isKnownTpduReader(usbDevice.vendorId, usbDevice.productId)
UsbCcidContext(
conn,
bulkIn,
bulkOut,
context.preferenceRepository.verboseLoggingFlow,
useTpdu
)
}.getOrNull()
}
/**
* When set to false (the default), the disconnect() method does nothing.
* This allows the separation of device disconnection from lpac-jni's APDU interface.
*/
var allowDisconnect = false
private var initialized = false
lateinit var transceiver: UsbCcidTransceiver
var atr: ByteArray? = null
fun connect() {
if (initialized) {
return
}
val ccidDescription = UsbCcidDescription.fromRawDescriptors(conn.rawDescriptors)!!
if (!ccidDescription.hasT0Protocol) {
throw IllegalArgumentException("Unsupported card reader; T=0 support is required")
}
transceiver = UsbCcidTransceiver(conn, bulkIn, bulkOut, ccidDescription, verboseLoggingFlow)
try {
// 6.1.1.1 PC_to_RDR_IccPowerOn (Page 20 of 40)
// https://www.usb.org/sites/default/files/DWG_Smart-Card_USB-ICC_ICCD_rev10.pdf
atr = transceiver.iccPowerOn().data
} catch (e: Exception) {
e.printStackTrace()
throw e
}
initialized = true
}
fun disconnect() {
if (initialized && allowDisconnect) {
conn.close()
atr = null
}
}
}

View file

@ -95,4 +95,4 @@ data class UsbCcidDescription(
val hasT0Protocol: Boolean
get() = (dwProtocols and MASK_T0_PROTO) != 0
}
}

View file

@ -4,7 +4,7 @@ import android.hardware.usb.UsbDeviceConnection
import android.hardware.usb.UsbEndpoint
import android.os.SystemClock
import android.util.Log
import im.angry.openeuicc.util.encodeHex
import im.angry.openeuicc.util.*
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.runBlocking
@ -158,46 +158,6 @@ class UsbCcidTransceiver(
}
}
private fun receiveParamBlock(expectedSequenceNumber: Byte): ByteArray {
var response: ByteArray?
do {
response = receiveParamBlockImmediate(expectedSequenceNumber)
} while (response!![7] == 0x80.toByte())
return response
}
private fun receiveParamBlockImmediate(expectedSequenceNumber: Byte): ByteArray {
/*
* Some USB CCID devices (notably NitroKey 3) may time-out and need a subsequent poke to
* carry on communications. No particular reason why the number 3 was chosen. If we get a
* zero-sized reply (or a time-out), we try again. Clamped retries prevent an infinite loop
* if things really turn sour.
*/
var attempts = 3
Log.d(TAG, "Receive data block immediate seq=$expectedSequenceNumber")
var readBytes: Int
do {
readBytes = usbConnection.bulkTransfer(
usbBulkIn, inputBuffer, inputBuffer.size, DEVICE_COMMUNICATE_TIMEOUT_MILLIS
)
if (runBlocking { verboseLoggingFlow.first() }) {
Log.d(TAG, "Received $readBytes bytes: ${inputBuffer.encodeHex()}")
}
} while (readBytes <= 0 && attempts-- > 0)
if (inputBuffer[0] != 0x82.toByte()) {
throw UsbTransportException(buildString {
append("USB-CCID error - bad CCID header")
append(", type ")
append("%d (expected %d)".format(inputBuffer[0], MESSAGE_TYPE_RDR_TO_PC_DATA_BLOCK))
if (expectedSequenceNumber != inputBuffer[6]) {
append(", sequence number ")
append("%d (expected %d)".format(inputBuffer[6], expectedSequenceNumber))
}
})
}
return inputBuffer
}
private fun receiveDataBlock(expectedSequenceNumber: Byte): CcidDataBlock {
var response: CcidDataBlock?
do {
@ -323,38 +283,6 @@ class UsbCcidTransceiver(
return ccidDataBlock
}
fun sendParamBlock(
payload: ByteArray
): ByteArray {
val startTime = SystemClock.elapsedRealtime()
val l = payload.size
val sequenceNumber: Byte = currentSequenceNumber++
val headerData = byteArrayOf(
0x61.toByte(),
l.toByte(),
(l shr 8).toByte(),
(l shr 16).toByte(),
(l shr 24).toByte(),
SLOT_NUMBER.toByte(),
sequenceNumber,
0x00.toByte(),
0x00.toByte(),
0x00.toByte()
)
val data: ByteArray = headerData + payload
Log.d(TAG, "USB ParamBlock: ${data.encodeHex()}")
var sentBytes = 0
while (sentBytes < data.size) {
val bytesToSend = usbBulkOut.maxPacketSize.coerceAtMost(data.size - sentBytes)
sendRaw(data, sentBytes, bytesToSend)
sentBytes += bytesToSend
}
val ccidDataBlock = receiveParamBlock(sequenceNumber)
val elapsedTime = SystemClock.elapsedRealtime() - startTime
Log.d(TAG, "USB ParamBlock call took ${elapsedTime}ms")
return ccidDataBlock
}
fun iccPowerOn(): CcidDataBlock {
val startTime = SystemClock.elapsedRealtime()
skipAvailableInput()
@ -414,4 +342,4 @@ class UsbCcidTransceiver(
)
sendRaw(iccPowerCommand, 0, iccPowerCommand.size)
}
}
}

View file

@ -25,12 +25,3 @@ val Iterable<UsbEndpoint>.bulkPair: Pair<UsbEndpoint?, UsbEndpoint?>
endpoints.find { it.direction == UsbConstants.USB_DIR_OUT },
)
}
val KNOWN_TPDU_READERS: Set<Pair<Int, Int>> = setOf(
// USB vendor ID + product ID pairs that require TPDU mode
// Realtek RTS5169 from <https://gitea.angry.im/PeterCxy/OpenEUICC/issues/37>
Pair(0x0bda, 0x0169)
)
fun isKnownTpduReader(vendorId: Int, productId: Int): Boolean =
KNOWN_TPDU_READERS.contains(Pair(vendorId, productId))

View file

@ -16,4 +16,4 @@ interface AppContainer {
val uiComponentFactory: UiComponentFactory
val euiccChannelFactory: EuiccChannelFactory
val customizableTextProvider: CustomizableTextProvider
}
}

View file

@ -1,8 +1,5 @@
package im.angry.openeuicc.di
import android.net.Uri
import im.angry.openeuicc.core.EuiccChannel
interface CustomizableTextProvider {
/**
* Explanation string for when no eUICC is found on the device.
@ -16,18 +13,8 @@ interface CustomizableTextProvider {
val profileSwitchingTimeoutMessage: String
/**
* Display the website link in settings; null if not available.
* Format the name of a logical slot; internal only -- not intended for
* other channels such as USB.
*/
val websiteUri: Uri?
/**
* Format the name of a logical slot -- not for USB channels
*/
fun formatNonUsbChannelName(logicalSlotId: Int): String
/**
* Format the name of a logical slot with a SE ID, in case of multi-SE chips; currently
* this is used in the download flow to distinguish between them on the same chip.
*/
fun formatNonUsbChannelNameWithSeId(logicalSlotId: Int, seId: EuiccChannel.SecureElementId): String
}
fun formatInternalChannelName(logicalSlotId: Int): String
}

View file

@ -42,4 +42,4 @@ open class DefaultAppContainer(context: Context) : AppContainer {
override val customizableTextProvider by lazy {
DefaultCustomizableTextProvider(context)
}
}
}

View file

@ -1,23 +1,15 @@
package im.angry.openeuicc.di
import android.content.Context
import android.net.Uri
import im.angry.openeuicc.common.R
import im.angry.openeuicc.core.EuiccChannel
open class DefaultCustomizableTextProvider(private val context: Context) : CustomizableTextProvider {
override val noEuiccExplanation: String
get() = context.getString(R.string.no_euicc)
override val profileSwitchingTimeoutMessage: String
get() = context.getString(R.string.profile_switch_timeout)
get() = context.getString(R.string.enable_disable_timeout)
override val websiteUri: Uri?
get() = null
override fun formatNonUsbChannelName(logicalSlotId: Int): String =
override fun formatInternalChannelName(logicalSlotId: Int): String =
context.getString(R.string.channel_name_format, logicalSlotId)
override fun formatNonUsbChannelNameWithSeId(logicalSlotId: Int, seId: EuiccChannel.SecureElementId): String =
context.getString(R.string.channel_name_format_se, logicalSlotId, seId.id)
}
}

View file

@ -1,20 +1,16 @@
package im.angry.openeuicc.di
import androidx.fragment.app.Fragment
import im.angry.openeuicc.core.EuiccChannel
import androidx.preference.PreferenceFragmentCompat
import im.angry.openeuicc.ui.EuiccManagementFragment
import im.angry.openeuicc.ui.NoEuiccPlaceholderFragment
import im.angry.openeuicc.ui.SettingsFragment
open class DefaultUiComponentFactory : UiComponentFactory {
override fun createEuiccManagementFragment(
slotId: Int,
portId: Int,
seId: EuiccChannel.SecureElementId
): EuiccManagementFragment =
EuiccManagementFragment.newInstance(slotId, portId, seId)
override fun createEuiccManagementFragment(slotId: Int, portId: Int): EuiccManagementFragment =
EuiccManagementFragment.newInstance(slotId, portId)
override fun createNoEuiccPlaceholderFragment(): Fragment = NoEuiccPlaceholderFragment()
override fun createSettingsFragment(): Fragment = SettingsFragment()
}
}

View file

@ -1,16 +1,11 @@
package im.angry.openeuicc.di
import androidx.fragment.app.Fragment
import im.angry.openeuicc.core.EuiccChannel
import androidx.preference.PreferenceFragmentCompat
import im.angry.openeuicc.ui.EuiccManagementFragment
interface UiComponentFactory {
fun createEuiccManagementFragment(
slotId: Int,
portId: Int,
seId: EuiccChannel.SecureElementId
): EuiccManagementFragment
fun createEuiccManagementFragment(slotId: Int, portId: Int): EuiccManagementFragment
fun createNoEuiccPlaceholderFragment(): Fragment
fun createSettingsFragment(): Fragment
}
}

View file

@ -4,7 +4,6 @@ import android.content.Intent
import android.content.pm.PackageManager
import android.os.Binder
import android.os.IBinder
import android.os.PowerManager
import android.util.Log
import androidx.core.app.NotificationChannelCompat
import androidx.core.app.NotificationCompat
@ -12,14 +11,11 @@ import androidx.core.app.NotificationManagerCompat
import androidx.lifecycle.LifecycleService
import androidx.lifecycle.lifecycleScope
import im.angry.openeuicc.common.R
import im.angry.openeuicc.core.EuiccChannel
import im.angry.openeuicc.core.EuiccChannelManager
import im.angry.openeuicc.util.*
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.NonCancellable
import kotlinx.coroutines.TimeoutCancellationException
import kotlinx.coroutines.channels.BufferOverflow
import kotlinx.coroutines.channels.Channel
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableSharedFlow
@ -36,13 +32,10 @@ import kotlinx.coroutines.flow.takeWhile
import kotlinx.coroutines.flow.transformWhile
import kotlinx.coroutines.isActive
import kotlinx.coroutines.launch
import kotlinx.coroutines.runBlocking
import kotlinx.coroutines.withContext
import kotlinx.coroutines.withTimeout
import kotlinx.coroutines.withTimeoutOrNull
import kotlinx.coroutines.yield
import net.typeblog.lpac_jni.ProfileDownloadInput
import net.typeblog.lpac_jni.ProfileDownloadState
import net.typeblog.lpac_jni.ProfileDownloadCallback
/**
* An Android Service wrapper for EuiccChannelManager.
@ -62,8 +55,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"
@ -99,18 +91,12 @@ open class EuiccChannelManagerService : LifecycleService(), OpenEuiccContextMark
}
val euiccChannelManager: EuiccChannelManager by euiccChannelManagerDelegate
private val wakeLock: PowerManager.WakeLock by lazy {
(getSystemService(POWER_SERVICE) as PowerManager).run {
newWakeLock(PowerManager.PARTIAL_WAKE_LOCK, this::class.simpleName)
}
}
/**
* The state of a "foreground" task (named so due to the need to startForeground())
*/
sealed interface ForegroundTaskState {
data object Idle : ForegroundTaskState
data class InProgress(val progress: Int, val context: Any? = null) : ForegroundTaskState
data class InProgress(val progress: Int) : ForegroundTaskState
data class Done(val error: Throwable?) : ForegroundTaskState
}
@ -128,27 +114,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 +129,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 +210,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 +241,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 +252,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 +265,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.
@ -306,11 +275,9 @@ open class EuiccChannelManagerService : LifecycleService(), OpenEuiccContextMark
updateForegroundNotification(title, iconRes)
wakeLock.acquire(10 * 60 * 1000L /*10 minutes*/)
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)
@ -323,20 +290,19 @@ open class EuiccChannelManagerService : LifecycleService(), OpenEuiccContextMark
postForegroundTaskFailureNotification(failureTitle)
}
} finally {
wakeLock.release()
if (isActive) {
stopSelf()
}
}
}
// 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 +323,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 +332,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,63 +356,44 @@ 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(
slotId: Int, portId: Int, seId: EuiccChannel.SecureElementId,
input: ProfileDownloadInput,
): ForegroundTaskHandle =
fun launchProfileDownloadTask(
slotId: Int,
portId: Int,
smdp: String,
matchingId: String?,
confirmationCode: String?,
imei: String?
): 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 ->
val progress = state.downloadProgress
foregroundTaskState.value = ForegroundTaskState.InProgress(
progress,
state
)
if (state is ProfileDownloadState.ConfirmingDownload) {
state.metadata?.let { metadata ->
// TODO: Actually do something here and not just logging?
Log.i(
TAG,
"Downloading profile provider=${metadata.providerName} name=${metadata.name}"
)
) {
euiccChannelManager.beginTrackedOperation(slotId, portId) {
euiccChannelManager.withEuiccChannel(slotId, portId) { channel ->
channel.lpa.downloadProfile(
smdp,
matchingId,
imei,
confirmationCode,
object : ProfileDownloadCallback {
override fun onStateUpdate(state: ProfileDownloadCallback.DownloadState) {
if (state.progress == 0) return
foregroundTaskState.value =
ForegroundTaskState.InProgress(state.progress)
}
// Try to receive a signal for confirmation while blocking this thread
// This of course assumes we're NOT on the main thread here. We aren't,
// because we don't run download on the main thread; see withEuiccChannel.
return@downloadProfile runBlocking {
try {
// We can't wait indefinitely; just time out after 1 minute.
withTimeout(60 * 1000) {
backChannel.receive() as Boolean
}
} catch (_: TimeoutCancellationException) {
// Default to cancelling / aborting here if we didn't receive a confirmation signal
false
}
}
}
true
}
})
}
preferenceRepository.notificationDownloadFlow.first()
@ -457,16 +403,15 @@ open class EuiccChannelManagerService : LifecycleService(), OpenEuiccContextMark
fun launchProfileRenameTask(
slotId: Int,
portId: Int,
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 ->
) {
euiccChannelManager.withEuiccChannel(slotId, portId) { channel ->
channel.lpa.setNickname(
iccid,
name
@ -477,16 +422,15 @@ open class EuiccChannelManagerService : LifecycleService(), OpenEuiccContextMark
fun launchProfileDeleteTask(
slotId: Int,
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 ->
) {
euiccChannelManager.beginTrackedOperation(slotId, portId) {
euiccChannelManager.withEuiccChannel(slotId, portId) { channel ->
channel.lpa.deleteProfile(iccid)
}
@ -499,38 +443,33 @@ open class EuiccChannelManagerService : LifecycleService(), OpenEuiccContextMark
fun launchProfileSwitchTask(
slotId: Int,
portId: Int,
seId: EuiccChannel.SecureElementId,
iccid: String,
enable: Boolean, // Enable or disable the profile indicated in iccid
reconnectTimeoutMillis: Long = 0 // 0 = do not wait for reconnect
): ForegroundTaskHandle =
): ForegroundTaskSubscriberFlow =
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 ->
val refresh = preferenceRepository.refreshAfterSwitchFlow.first()
val response = channel.lpa.switchProfile(iccid, enable, refresh)
if (response || !refresh) {
Pair(response, refresh)
} else {
// refresh failed, but refresh was requested
// Sometimes, we *can* enable or disable the profile, but we cannot
// send the refresh command to the modem because the profile somehow
// makes the modem "busy". In this case, we can still switch by setting
// refresh to false, but then the switch cannot take effect until the
// user resets the modem manually by toggling airplane mode or rebooting.
Pair(
channel.lpa.switchProfile(iccid, enable, refresh = false),
false
)
}
) {
euiccChannelManager.beginTrackedOperation(slotId, portId) {
val (res, refreshed) = euiccChannelManager.withEuiccChannel(
slotId,
portId
) { channel ->
if (!channel.lpa.switchProfile(iccid, enable, refresh = true)) {
// Sometimes, we *can* enable or disable the profile, but we cannot
// send the refresh command to the modem because the profile somehow
// makes the modem "busy". In this case, we can still switch by setting
// refresh to false, but then the switch cannot take effect until the
// user resets the modem manually by toggling airplane mode or rebooting.
Pair(channel.lpa.switchProfile(iccid, enable, refresh = false), false)
} else {
Pair(true, true)
}
}
if (!response) {
if (!res) {
throw RuntimeException("Could not switch profile")
}
@ -557,22 +496,14 @@ open class EuiccChannelManagerService : LifecycleService(), OpenEuiccContextMark
}
}
fun launchMemoryReset(
slotId: Int,
portId: Int,
seId: EuiccChannel.SecureElementId
): ForegroundTaskHandle =
fun launchMemoryReset(slotId: Int, portId: Int): 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()
}
preferenceRepository.notificationDeleteFlow.first()
) {
euiccChannelManager.withEuiccChannel(slotId, portId) { channel ->
channel.lpa.euiccMemoryReset()
}
}
}
}

View file

@ -1,6 +1,7 @@
package im.angry.openeuicc.ui
import android.content.ComponentName
import android.content.Context
import android.content.Intent
import android.content.ServiceConnection
import android.os.Bundle
@ -35,7 +36,7 @@ abstract class BaseEuiccAccessActivity : AppCompatActivity() {
bindService(
Intent(this, EuiccChannelManagerService::class.java),
euiccChannelManagerServiceConnection,
BIND_AUTO_CREATE
Context.BIND_AUTO_CREATE
)
}
@ -48,4 +49,4 @@ abstract class BaseEuiccAccessActivity : AppCompatActivity() {
* When called, euiccChannelManager is guaranteed to have been initialized
*/
abstract fun onInit()
}
}

View file

@ -9,7 +9,7 @@ import androidx.fragment.app.DialogFragment
import com.google.android.material.color.DynamicColors
import im.angry.openeuicc.common.R
abstract class BaseMaterialDialogFragment : DialogFragment() {
abstract class BaseMaterialDialogFragment: DialogFragment() {
override fun onGetLayoutInflater(savedInstanceState: Bundle?): LayoutInflater {
val inflater = super.onGetLayoutInflater(savedInstanceState)
val wrappedContext = ContextThemeWrapper(requireContext(), R.style.Theme_OpenEUICC)
@ -23,4 +23,4 @@ abstract class BaseMaterialDialogFragment : DialogFragment() {
it.window?.setBackgroundDrawableResource(R.drawable.dialog_background)
}
}
}
}

View file

@ -23,32 +23,22 @@ import im.angry.openeuicc.common.R
import im.angry.openeuicc.core.EuiccChannel
import im.angry.openeuicc.core.EuiccChannelManager
import im.angry.openeuicc.util.*
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import net.typeblog.lpac_jni.impl.PKID_GSMA_LIVE_CI
import net.typeblog.lpac_jni.impl.PKID_GSMA_TEST_CI
// https://euicc-manual.osmocom.org/docs/pki/eum/accredited.json
// ref: <https://regex101.com/r/5FFz8u>
private val RE_SAS = Regex(
"""^[A-Z]{2}-[A-Z]{2}(?:-UP)?-\d{4}T?(?:-\d+)?T?$""",
setOf(RegexOption.IGNORE_CASE),
)
class EuiccInfoActivity : BaseEuiccAccessActivity(), OpenEuiccContextMarker {
companion object {
private val YES_NO = Pair(R.string.euicc_info_yes, R.string.euicc_info_no)
private val YES_NO = Pair(R.string.yes, R.string.no)
}
private lateinit var swipeRefresh: SwipeRefreshLayout
private lateinit var infoList: RecyclerView
private var logicalSlotId: Int = -1
private var seId: EuiccChannel.SecureElementId = EuiccChannel.SecureElementId.DEFAULT
data class Item(
@get:StringRes
@StringRes
val titleResId: Int,
val content: String?,
val copiedToastResId: Int? = null,
@ -59,6 +49,7 @@ class EuiccInfoActivity : BaseEuiccAccessActivity(), OpenEuiccContextMarker {
super.onCreate(savedInstanceState)
setContentView(R.layout.activity_euicc_info)
setSupportActionBar(requireViewById(R.id.toolbar))
setupToolbarInsets()
supportActionBar!!.setDisplayHomeAsUpEnabled(true)
swipeRefresh = requireViewById(R.id.swipe_refresh)
@ -69,27 +60,18 @@ class EuiccInfoActivity : BaseEuiccAccessActivity(), OpenEuiccContextMarker {
}
logicalSlotId = intent.getIntExtra("logicalSlotId", 0)
seId = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
intent.getParcelableExtra("seId", EuiccChannel.SecureElementId::class.java)
} else {
@Suppress("DEPRECATION")
intent.getParcelableExtra("seId")
} ?: EuiccChannel.SecureElementId.DEFAULT
setChannelTitle(
if (logicalSlotId == EuiccChannelManager.USB_CHANNEL_ID)
getString(R.string.channel_name_format_usb) else
appContainer.customizableTextProvider.formatNonUsbChannelName(logicalSlotId)
)
val channelTitle = if (logicalSlotId == EuiccChannelManager.USB_CHANNEL_ID) {
getString(R.string.usb)
} else {
appContainer.customizableTextProvider.formatInternalChannelName(logicalSlotId)
}
title = getString(R.string.euicc_info_activity_title, channelTitle)
swipeRefresh.setOnRefreshListener { refresh() }
setupRootViewSystemBarInsets(
window.decorView.rootView, arrayOf(
this::activityToolbarInsetHandler,
mainViewPaddingInsetHandler(infoList)
)
)
setupRootViewInsets(infoList)
}
override fun onOptionsItemSelected(item: MenuItem): Boolean = when (item.itemId) {
@ -101,10 +83,6 @@ class EuiccInfoActivity : BaseEuiccAccessActivity(), OpenEuiccContextMarker {
else -> super.onOptionsItemSelected(item)
}
private fun setChannelTitle(title: CharSequence) {
super.setTitle(getString(R.string.euicc_info_activity_title, title))
}
override fun onInit() {
refresh()
}
@ -113,24 +91,8 @@ class EuiccInfoActivity : BaseEuiccAccessActivity(), OpenEuiccContextMarker {
swipeRefresh.isRefreshing = true
lifecycleScope.launch {
euiccChannelManager.withEuiccChannel(logicalSlotId, seId) { channel ->
if (channel.hasMultipleSE) {
withContext(Dispatchers.Main) {
val title = if (logicalSlotId == EuiccChannelManager.USB_CHANNEL_ID) {
getString(R.string.channel_name_format_usb_se, seId.id)
} else {
appContainer.customizableTextProvider.formatNonUsbChannelNameWithSeId(logicalSlotId, seId)
}
setChannelTitle(title)
}
}
val items = buildEuiccInfoItems(channel)
withContext(Dispatchers.Main) {
(infoList.adapter!! as EuiccInfoAdapter).euiccInfoItems = items
}
}
(infoList.adapter!! as EuiccInfoAdapter).euiccInfoItems =
euiccChannelManager.withEuiccChannel(logicalSlotId, ::buildEuiccInfoItems)
swipeRefresh.isRefreshing = false
}
@ -140,28 +102,19 @@ class EuiccInfoActivity : BaseEuiccAccessActivity(), OpenEuiccContextMarker {
add(Item(R.string.euicc_info_access_mode, channel.type))
add(Item(R.string.euicc_info_removable, formatByBoolean(channel.port.card.isRemovable, YES_NO)))
add(Item(R.string.euicc_info_eid, channel.lpa.eID, copiedToastResId = R.string.toast_eid_copied))
if (!channel.isdrAid.contentEquals(EUICC_DEFAULT_ISDR_AID.decodeHex())) {
// Only show if it's not the default ISD-R AID
add(Item(R.string.euicc_info_isdr_aid, channel.isdrAid.encodeHex()))
}
channel.tryParseEuiccVendorInfo()?.let { vendorInfo ->
// @formatter:off
vendorInfo.skuName?.let { add(Item(R.string.euicc_info_sku, it)) }
vendorInfo.serialNumber?.let { add(Item(R.string.euicc_info_sn, it, copiedToastResId = R.string.toast_sn_copied)) }
vendorInfo.serialNumber?.let { add(Item(R.string.euicc_info_sn, it)) }
vendorInfo.firmwareVersion?.let { add(Item(R.string.euicc_info_fw_ver, it)) }
// @formatter:on
vendorInfo.bootloaderVersion?.let { add(Item(R.string.euicc_info_bl_ver, it)) }
}
channel.lpa.euiccInfo2?.let { info ->
add(Item(R.string.euicc_info_sgp22_version, info.sgp22Version.toString()))
info.sasAccreditationNumber.trim().takeIf(RE_SAS::matches)
?.let { add(Item(R.string.euicc_info_sas_accreditation_number, it.uppercase())) }
val nvramText = buildString {
append(formatFreeSpace(info.freeNvram))
append(' ')
append(getString(R.string.euicc_info_free_nvram_hint))
}
add(Item(R.string.euicc_info_free_nvram, nvramText))
channel.lpa.euiccInfo2.let { info ->
add(Item(R.string.euicc_info_sgp22_version, info?.sgp22Version.toString()))
add(Item(R.string.euicc_info_firmware_version, info?.euiccFirmwareVersion.toString()))
add(Item(R.string.euicc_info_globalplatform_version, info?.globalPlatformVersion.toString()))
add(Item(R.string.euicc_info_pp_version, info?.ppVersion.toString()))
add(Item(R.string.euicc_info_sas_accreditation_number, info?.sasAccreditationNumber))
add(Item(R.string.euicc_info_free_nvram, info?.freeNvram?.let(::formatFreeSpace)))
}
channel.lpa.euiccInfo2?.euiccCiPKIdListForSigning.orEmpty().let { signers ->
// SGP.28 v1.0, eSIM CI Registration Criteria (Page 5 of 9, 2019-10-24)
@ -169,20 +122,25 @@ class EuiccInfoActivity : BaseEuiccAccessActivity(), OpenEuiccContextMarker {
// FS.27 v2.0, Security Guidelines for UICC Profiles (Page 25 of 27, 2024-01-30)
// https://www.gsma.com/solutions-and-impact/technologies/security/wp-content/uploads/2024/01/FS.27-Security-Guidelines-for-UICC-Credentials-v2.0-FINAL-23-July.pdf#page=25
val resId = when {
signers.isEmpty() -> R.string.euicc_info_unknown // the case is not mp, but it is not common
signers.isEmpty() -> R.string.unknown // the case is not mp, but it's is not common
PKID_GSMA_LIVE_CI.any(signers::contains) -> R.string.euicc_info_ci_gsma_live
PKID_GSMA_TEST_CI.any(signers::contains) -> R.string.euicc_info_ci_gsma_test
else -> R.string.euicc_info_ci_unknown
}
add(Item(R.string.euicc_info_ci_type, getString(resId)))
}
val atr = channel.atr?.encodeHex() ?: getString(R.string.euicc_info_unavailable)
val atr = channel.atr?.encodeHex() ?: getString(R.string.information_unavailable)
add(Item(R.string.euicc_info_atr, atr, copiedToastResId = R.string.toast_atr_copied))
}
@Suppress("SameParameterValue")
private fun formatByBoolean(b: Boolean, res: Pair<Int, Int>): String =
getString(if (b) res.first else res.second)
getString(
if (b) {
res.first
} else {
res.second
}
)
inner class EuiccInfoViewHolder(root: View) : ViewHolder(root) {
private val title: TextView = root.requireViewById(R.id.euicc_info_title)
@ -209,7 +167,7 @@ class EuiccInfoActivity : BaseEuiccAccessActivity(), OpenEuiccContextMarker {
fun bind(item: Item) {
copiedToastResId = item.copiedToastResId
title.setText(item.titleResId)
content.text = item.content ?: getString(R.string.euicc_info_unknown)
content.text = item.content ?: getString(R.string.unknown)
}
}
@ -233,4 +191,4 @@ class EuiccInfoActivity : BaseEuiccAccessActivity(), OpenEuiccContextMarker {
holder.bind(euiccInfoItems[position])
}
}
}
}

View file

@ -19,6 +19,8 @@ import android.widget.PopupMenu
import android.widget.TextView
import android.widget.Toast
import androidx.appcompat.app.AlertDialog
import androidx.core.view.ViewCompat
import androidx.core.view.WindowInsetsCompat
import androidx.core.view.isVisible
import androidx.core.view.updateLayoutParams
import androidx.fragment.app.Fragment
@ -27,8 +29,8 @@ import androidx.recyclerview.widget.LinearLayoutManager
import androidx.recyclerview.widget.RecyclerView
import androidx.swiperefreshlayout.widget.SwipeRefreshLayout
import com.google.android.material.floatingactionbutton.FloatingActionButton
import net.typeblog.lpac_jni.LocalProfileInfo
import im.angry.openeuicc.common.R
import im.angry.openeuicc.core.EuiccChannel
import im.angry.openeuicc.service.EuiccChannelManagerService
import im.angry.openeuicc.service.EuiccChannelManagerService.Companion.waitDone
import im.angry.openeuicc.ui.wizard.DownloadWizardActivity
@ -36,23 +38,19 @@ import im.angry.openeuicc.util.*
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.TimeoutCancellationException
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.launch
import kotlinx.coroutines.runBlocking
import kotlinx.coroutines.withContext
import net.typeblog.lpac_jni.LocalProfileInfo
import net.typeblog.lpac_jni.ProfileClass
open class EuiccManagementFragment : Fragment(), EuiccProfilesChangedListener,
EuiccChannelFragmentMarker {
companion object {
const val TAG = "EuiccManagementFragment"
fun newInstance(
slotId: Int,
portId: Int,
seId: EuiccChannel.SecureElementId
): EuiccManagementFragment =
newInstanceEuicc(EuiccManagementFragment::class.java, slotId, portId, seId)
fun newInstance(slotId: Int, portId: Int): EuiccManagementFragment =
newInstanceEuicc(EuiccManagementFragment::class.java, slotId, portId)
}
private lateinit var swipeRefresh: SwipeRefreshLayout
@ -60,7 +58,6 @@ open class EuiccManagementFragment : Fragment(), EuiccProfilesChangedListener,
private lateinit var profileList: RecyclerView
private var logicalSlotId: Int = -1
private lateinit var eid: String
private var enabledProfile: LocalProfileInfo? = null
private val adapter = EuiccProfileAdapter()
@ -92,22 +89,18 @@ open class EuiccManagementFragment : Fragment(), EuiccProfilesChangedListener,
val origFabMarginRight = (fab.layoutParams as ViewGroup.MarginLayoutParams).rightMargin
val origFabMarginBottom = (fab.layoutParams as ViewGroup.MarginLayoutParams).bottomMargin
ViewCompat.setOnApplyWindowInsetsListener(fab) { v, insets ->
val bars = insets.getInsets(WindowInsetsCompat.Type.systemBars())
setupRootViewSystemBarInsets(
view, arrayOf(
mainViewPaddingInsetHandler(profileList),
{ insets ->
fab.updateLayoutParams<ViewGroup.MarginLayoutParams> {
rightMargin = origFabMarginRight + insets.right
bottomMargin = origFabMarginBottom + insets.bottom
}
}
))
v.updateLayoutParams<ViewGroup.MarginLayoutParams> {
rightMargin = origFabMarginRight + bars.right
bottomMargin = origFabMarginBottom + bars.bottom
}
profileList.addOnScrollListener(object : RecyclerView.OnScrollListener() {
override fun onScrollStateChanged(view: RecyclerView, newState: Int) =
if (newState == RecyclerView.SCROLL_STATE_IDLE) fab.show() else fab.hide()
})
WindowInsetsCompat.CONSUMED
}
setupRootViewInsets(profileList)
return view
}
@ -120,8 +113,10 @@ open class EuiccManagementFragment : Fragment(), EuiccProfilesChangedListener,
LinearLayoutManager(view.context, LinearLayoutManager.VERTICAL, false)
fab.setOnClickListener {
val intent = DownloadWizardActivity.newIntent(requireContext(), logicalSlotId, seId)
startActivity(intent)
Intent(requireContext(), DownloadWizardActivity::class.java).apply {
putExtra("selectedLogicalSlot", logicalSlotId)
startActivity(this)
}
}
}
@ -146,14 +141,13 @@ open class EuiccManagementFragment : Fragment(), EuiccProfilesChangedListener,
menu.findItem(R.id.euicc_info).isVisible =
logicalSlotId != -1
menu.findItem(R.id.euicc_memory_reset).isVisible =
enabledProfile == null
runBlocking { preferenceRepository.euiccMemoryResetFlow.first() }
}
override fun onOptionsItemSelected(item: MenuItem) = when (item.itemId) {
R.id.show_notifications -> {
Intent(requireContext(), NotificationsActivity::class.java).apply {
putExtra("logicalSlotId", logicalSlotId)
putExtra("seId", seId)
startActivity(this)
}
true
@ -162,14 +156,13 @@ open class EuiccManagementFragment : Fragment(), EuiccProfilesChangedListener,
R.id.euicc_info -> {
Intent(requireContext(), EuiccInfoActivity::class.java).apply {
putExtra("logicalSlotId", logicalSlotId)
putExtra("seId", seId)
startActivity(this)
}
true
}
R.id.euicc_memory_reset -> {
EuiccMemoryResetFragment.newInstance(slotId, portId, seId, eid)
EuiccMemoryResetFragment.newInstance(slotId, portId, eid)
.show(childFragmentManager, EuiccMemoryResetFragment.TAG)
true
}
@ -214,7 +207,6 @@ open class EuiccManagementFragment : Fragment(), EuiccProfilesChangedListener,
val profiles = withEuiccChannel { channel ->
logicalSlotId = channel.logicalSlotId
eid = channel.lpa.eID
enabledProfile = channel.lpa.profiles.enabled
euiccChannelManager.notifyEuiccProfilesChanged(channel.logicalSlotId)
if (unfilteredProfileListFlow.value)
channel.lpa.profiles
@ -231,8 +223,11 @@ open class EuiccManagementFragment : Fragment(), EuiccProfilesChangedListener,
}
private suspend fun showSwitchFailureText() = withContext(Dispatchers.Main) {
val resId = R.string.toast_profile_enable_failed
Toast.makeText(context, resId, Toast.LENGTH_LONG).show()
Toast.makeText(
context,
R.string.toast_profile_enable_failed,
Toast.LENGTH_LONG
).show()
}
private fun enableOrDisableProfile(iccid: String, enable: Boolean) {
@ -243,12 +238,13 @@ open class EuiccManagementFragment : Fragment(), EuiccProfilesChangedListener,
ensureEuiccChannelManager()
euiccChannelManagerService.waitForForegroundTask()
val err = euiccChannelManagerService
.launchProfileSwitchTask(
slotId, portId, seId, iccid, enable,
reconnectTimeoutMillis = 30 * 1000
)
.stateFlow.waitDone()
val err = euiccChannelManagerService.launchProfileSwitchTask(
slotId,
portId,
iccid,
enable,
reconnectTimeoutMillis = 30 * 1000
).waitDone()
when (err) {
null -> {}
@ -256,11 +252,17 @@ open class EuiccManagementFragment : Fragment(), EuiccProfilesChangedListener,
// This is only really fatal for internal eSIMs
if (!isUsb) {
withContext(Dispatchers.Main) {
AlertDialog.Builder(requireContext(), R.style.AlertDialogTheme)
.setMessage(R.string.profile_switch_did_not_refresh)
.setPositiveButton(android.R.string.ok) { _, _ -> requireActivity().finish() }
.setOnDismissListener { _ -> requireActivity().finish() }
.show()
AlertDialog.Builder(requireContext()).apply {
setMessage(R.string.switch_did_not_refresh)
setPositiveButton(android.R.string.ok) { dialog, _ ->
dialog.dismiss()
requireActivity().finish()
}
setOnDismissListener { _ ->
requireActivity().finish()
}
show()
}
}
}
}
@ -270,11 +272,17 @@ open class EuiccManagementFragment : Fragment(), EuiccProfilesChangedListener,
// Prevent this Fragment from being used again
invalid = true
// Timed out waiting for SIM to come back online, we can no longer assume that the LPA is still valid
AlertDialog.Builder(requireContext(), R.style.AlertDialogTheme)
.setMessage(appContainer.customizableTextProvider.profileSwitchingTimeoutMessage)
.setPositiveButton(android.R.string.ok) { _, _ -> requireActivity().finish() }
.setOnDismissListener { _ -> requireActivity().finish() }
.show()
AlertDialog.Builder(requireContext()).apply {
setMessage(appContainer.customizableTextProvider.profileSwitchingTimeoutMessage)
setPositiveButton(android.R.string.ok) { dialog, _ ->
dialog.dismiss()
requireActivity().finish()
}
setOnDismissListener { _ ->
requireActivity().finish()
}
show()
}
}
}
@ -286,20 +294,19 @@ open class EuiccManagementFragment : Fragment(), EuiccProfilesChangedListener,
}
}
protected open fun populatePopupWithProfileActions(
popup: PopupMenu,
profile: LocalProfileInfo
) {
protected open fun populatePopupWithProfileActions(popup: PopupMenu, profile: LocalProfileInfo) {
popup.inflate(R.menu.profile_options)
if (!profile.isEnabled) return
popup.menu.findItem(R.id.enable).isVisible = false
popup.menu.findItem(R.id.delete).isVisible = false
if (profile.isEnabled) {
popup.menu.findItem(R.id.enable).isVisible = false
popup.menu.findItem(R.id.delete).isVisible = false
// We hide the disable option by default to avoid "bricking" some cards that won't get
// recognized again by the phone's modem. However, we don't have that worry if we are
// accessing it through a USB card reader, or when the user explicitly opted in
if (!isUsb && !disableSafeguardFlow.value) return
popup.menu.findItem(R.id.disable).isVisible = true
// We hide the disable option by default to avoid "bricking" some cards that won't get
// recognized again by the phone's modem. However we don't have that worry if we are
// accessing it through a USB card reader, or when the user explicitly opted in
if (isUsb || disableSafeguardFlow.value) {
popup.menu.findItem(R.id.disable).isVisible = true
}
}
}
sealed class ViewHolder(root: View) : RecyclerView.ViewHolder(root) {
@ -314,7 +321,7 @@ open class EuiccManagementFragment : Fragment(), EuiccProfilesChangedListener,
}
}
inner class FooterViewHolder : ViewHolder(FrameLayout(requireContext())) {
inner class FooterViewHolder: ViewHolder(FrameLayout(requireContext())) {
init {
itemView.layoutParams = ViewGroup.LayoutParams(
ViewGroup.LayoutParams.MATCH_PARENT,
@ -340,7 +347,6 @@ open class EuiccManagementFragment : Fragment(), EuiccProfilesChangedListener,
private val profileClassLabel: TextView = root.requireViewById(R.id.profile_class_label)
private val profileClass: TextView = root.requireViewById(R.id.profile_class)
private val profileMenu: ImageButton = root.requireViewById(R.id.profile_menu)
private val profileSeqNumber: TextView = root.requireViewById(R.id.profile_sequence_number)
init {
iccid.setOnClickListener {
@ -360,13 +366,10 @@ open class EuiccManagementFragment : Fragment(), EuiccProfilesChangedListener,
true
}
profileMenu.setOnClickListener {
showOptionsMenu()
}
profileMenu.setOnClickListener { showOptionsMenu() }
}
private lateinit var profile: LocalProfileInfo
private var canEnable: Boolean = false
fun setProfile(profile: LocalProfileInfo) {
this.profile = profile
@ -374,9 +377,9 @@ open class EuiccManagementFragment : Fragment(), EuiccProfilesChangedListener,
state.setText(
if (profile.isEnabled) {
R.string.profile_state_enabled
R.string.enabled
} else {
R.string.profile_state_disabled
R.string.disabled
}
)
provider.text = profile.providerName
@ -384,29 +387,15 @@ open class EuiccManagementFragment : Fragment(), EuiccProfilesChangedListener,
profileClass.isVisible = unfilteredProfileListFlow.value
profileClass.setText(
when (profile.profileClass) {
ProfileClass.Testing -> R.string.profile_class_testing
ProfileClass.Provisioning -> R.string.profile_class_provisioning
ProfileClass.Operational -> R.string.profile_class_operational
LocalProfileInfo.Clazz.Testing -> R.string.profile_class_testing
LocalProfileInfo.Clazz.Provisioning -> R.string.profile_class_provisioning
LocalProfileInfo.Clazz.Operational -> R.string.profile_class_operational
}
)
iccid.text = profile.iccid
iccid.transformationMethod = PasswordTransformationMethod.getInstance()
}
fun setProfileSequenceNumber(index: Int) {
profileSeqNumber.text = root.context.getString(
R.string.profile_sequence_number_format,
index,
)
}
fun setEnabledProfile(enabledProfile: LocalProfileInfo?) {
// cannot cross profile class enable profile
// e.g: testing -> operational or operational -> testing
canEnable = enabledProfile == null ||
enabledProfile.profileClass == profile.profileClass
}
private fun showOptionsMenu() {
// Prevent users from doing multiple things at once
if (invalid || swipeRefresh.isRefreshing) return
@ -421,45 +410,23 @@ open class EuiccManagementFragment : Fragment(), EuiccProfilesChangedListener,
private fun onMenuItemClicked(item: MenuItem): Boolean =
when (item.itemId) {
R.id.enable -> {
if (canEnable) {
enableOrDisableProfile(profile.iccid, true)
} else {
val resId = R.string.toast_profile_enable_cross_class
Toast.makeText(requireContext(), resId, Toast.LENGTH_LONG)
.show()
}
enableOrDisableProfile(profile.iccid, true)
true
}
R.id.disable -> {
enableOrDisableProfile(profile.iccid, false)
true
}
R.id.rename -> {
ProfileRenameFragment.newInstance(
slotId,
portId,
seId,
profile.iccid,
profile.displayName
)
ProfileRenameFragment.newInstance(slotId, portId, profile.iccid, profile.displayName)
.show(childFragmentManager, ProfileRenameFragment.TAG)
true
}
R.id.delete -> {
ProfileDeleteFragment.newInstance(
slotId,
portId,
seId,
profile.iccid,
profile.displayName
)
ProfileDeleteFragment.newInstance(slotId, portId, profile.iccid, profile.displayName)
.show(childFragmentManager, ProfileDeleteFragment.TAG)
true
}
else -> false
}
}
@ -471,11 +438,9 @@ open class EuiccManagementFragment : Fragment(), EuiccProfilesChangedListener,
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): ViewHolder =
when (ViewHolder.Type.fromInt(viewType)) {
ViewHolder.Type.PROFILE -> {
val view = LayoutInflater.from(parent.context)
.inflate(R.layout.euicc_profile, parent, false)
val view = LayoutInflater.from(parent.context).inflate(R.layout.euicc_profile, parent, false)
ProfileViewHolder(view)
}
ViewHolder.Type.FOOTER -> {
FooterViewHolder()
}
@ -486,11 +451,9 @@ open class EuiccManagementFragment : Fragment(), EuiccProfilesChangedListener,
position < profiles.size -> {
ViewHolder.Type.PROFILE.value
}
position >= profiles.size && position < profiles.size + footerViews.size -> {
ViewHolder.Type.FOOTER.value
}
else -> -1
}
@ -498,10 +461,7 @@ open class EuiccManagementFragment : Fragment(), EuiccProfilesChangedListener,
when (holder) {
is ProfileViewHolder -> {
holder.setProfile(profiles[position])
holder.setEnabledProfile(profiles.enabled)
holder.setProfileSequenceNumber(position + 1)
}
is FooterViewHolder -> {
holder.attach(footerViews[position - profiles.size])
}
@ -516,4 +476,4 @@ open class EuiccManagementFragment : Fragment(), EuiccProfilesChangedListener,
override fun getItemCount(): Int = profiles.size + footerViews.size
}
}
}

View file

@ -8,11 +8,18 @@ import android.widget.EditText
import android.widget.Toast
import androidx.appcompat.app.AlertDialog
import androidx.fragment.app.DialogFragment
import androidx.fragment.app.Fragment
import androidx.lifecycle.lifecycleScope
import im.angry.openeuicc.common.R
import im.angry.openeuicc.core.EuiccChannel
import im.angry.openeuicc.service.EuiccChannelManagerService.Companion.waitDone
import im.angry.openeuicc.util.*
import im.angry.openeuicc.util.EuiccChannelFragmentMarker
import im.angry.openeuicc.util.EuiccProfilesChangedListener
import im.angry.openeuicc.util.ensureEuiccChannelManager
import im.angry.openeuicc.util.euiccChannelManagerService
import im.angry.openeuicc.util.newInstanceEuicc
import im.angry.openeuicc.util.notifyEuiccProfilesChanged
import im.angry.openeuicc.util.portId
import im.angry.openeuicc.util.slotId
import kotlinx.coroutines.flow.onStart
import kotlinx.coroutines.launch
@ -22,8 +29,8 @@ class EuiccMemoryResetFragment : DialogFragment(), EuiccChannelFragmentMarker {
private const val FIELD_EID = "eid"
fun newInstance(slotId: Int, portId: Int, seId: EuiccChannel.SecureElementId, eid: String) =
newInstanceEuicc(EuiccMemoryResetFragment::class.java, slotId, portId, seId) {
fun newInstance(slotId: Int, portId: Int, eid: String) =
newInstanceEuicc(EuiccMemoryResetFragment::class.java, slotId, portId) {
putString(FIELD_EID, eid)
}
}
@ -96,12 +103,11 @@ class EuiccMemoryResetFragment : DialogFragment(), EuiccChannelFragmentMarker {
ensureEuiccChannelManager()
euiccChannelManagerService.waitForForegroundTask()
euiccChannelManagerService.launchMemoryReset(slotId, portId, seId)
.stateFlow
euiccChannelManagerService.launchMemoryReset(slotId, portId)
.onStart {
parentFragment?.notifyEuiccProfilesChanged()
val resId = R.string.toast_euicc_memory_reset_finished
val resId = R.string.toast_euicc_memory_reset_finitshed
toast = Toast.makeText(requireContext(), resId, Toast.LENGTH_LONG)
runCatching(::dismiss)

View file

@ -1,77 +0,0 @@
package im.angry.openeuicc.ui
import android.os.Bundle
import android.text.Editable
import android.view.Menu
import android.view.MenuItem
import android.widget.EditText
import android.widget.Toast
import androidx.activity.enableEdgeToEdge
import androidx.appcompat.app.AppCompatActivity
import androidx.lifecycle.lifecycleScope
import im.angry.openeuicc.common.R
import im.angry.openeuicc.util.*
import kotlinx.coroutines.flow.collect
import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.launch
class IsdrAidListActivity : AppCompatActivity() {
private lateinit var isdrAidListEditor: EditText
override fun onCreate(savedInstanceState: Bundle?) {
enableEdgeToEdge()
super.onCreate(savedInstanceState)
setContentView(R.layout.activity_isdr_aid_list)
setSupportActionBar(requireViewById(R.id.toolbar))
supportActionBar!!.setDisplayHomeAsUpEnabled(true)
isdrAidListEditor = requireViewById(R.id.isdr_aid_list_editor)
setupRootViewSystemBarInsets(
window.decorView.rootView, arrayOf(
this::activityToolbarInsetHandler,
mainViewPaddingInsetHandler(isdrAidListEditor)
)
)
lifecycleScope.launch {
preferenceRepository.isdrAidListFlow.onEach {
isdrAidListEditor.text = Editable.Factory.getInstance().newEditable(it)
}.collect()
}
}
override fun onCreateOptionsMenu(menu: Menu?): Boolean {
menuInflater.inflate(R.menu.activity_isdr_aid_list, menu)
return true
}
override fun onOptionsItemSelected(item: MenuItem): Boolean =
when (item.itemId) {
R.id.save -> {
lifecycleScope.launch {
preferenceRepository.isdrAidListFlow.updatePreference(isdrAidListEditor.text.toString())
Toast.makeText(
this@IsdrAidListActivity,
R.string.isdr_aid_list_saved,
Toast.LENGTH_SHORT
).show()
}
true
}
R.id.reset -> {
lifecycleScope.launch {
preferenceRepository.isdrAidListFlow.removePreference()
}
true
}
android.R.id.home -> {
finish()
true
}
else -> super.onOptionsItemSelected(item)
}
}

View file

@ -51,18 +51,14 @@ class LogsActivity : AppCompatActivity() {
super.onCreate(savedInstanceState)
setContentView(R.layout.activity_logs)
setSupportActionBar(requireViewById(R.id.toolbar))
setupToolbarInsets()
supportActionBar!!.setDisplayHomeAsUpEnabled(true)
swipeRefresh = requireViewById(R.id.swipe_refresh)
scrollView = requireViewById(R.id.scroll_view)
logText = requireViewById(R.id.log_text)
setupRootViewSystemBarInsets(
window.decorView.rootView, arrayOf(
this::activityToolbarInsetHandler,
mainViewPaddingInsetHandler(scrollView)
)
)
setupRootViewInsets(scrollView)
swipeRefresh.setOnRefreshListener {
lifecycleScope.launch {
@ -88,12 +84,10 @@ class LogsActivity : AppCompatActivity() {
finish()
true
}
R.id.save -> {
saveLogs()
true
}
else -> super.onOptionsItemSelected(item)
}
@ -113,4 +107,4 @@ class LogsActivity : AppCompatActivity() {
scrollView.fullScroll(View.FOCUS_DOWN)
}
}
}
}

View file

@ -16,9 +16,6 @@ import android.view.MenuItem
import android.view.View
import android.widget.ProgressBar
import androidx.activity.enableEdgeToEdge
import androidx.core.content.pm.ShortcutInfoCompat
import androidx.core.content.pm.ShortcutManagerCompat
import androidx.core.graphics.drawable.IconCompat
import androidx.fragment.app.Fragment
import androidx.lifecycle.lifecycleScope
import androidx.viewpager2.adapter.FragmentStateAdapter
@ -26,9 +23,7 @@ import androidx.viewpager2.widget.ViewPager2
import com.google.android.material.tabs.TabLayout
import com.google.android.material.tabs.TabLayoutMediator
import im.angry.openeuicc.common.R
import im.angry.openeuicc.core.EuiccChannel
import im.angry.openeuicc.core.EuiccChannelManager
import im.angry.openeuicc.ui.wizard.DownloadWizardActivity
import im.angry.openeuicc.util.*
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.collect
@ -52,30 +47,18 @@ open class MainActivity : BaseEuiccAccessActivity(), OpenEuiccContextMarker {
private var refreshing = false
private data class Page(
val id: Long,
val logicalSlotId: Int,
val title: String,
val createFragment: () -> Fragment
)
private val pages: MutableList<Page> = mutableListOf()
private var nextPageId = 0L
private fun newPage(
logicalSlotId: Int,
title: String,
createFragment: () -> Fragment
): Page = Page(nextPageId++, logicalSlotId, title, createFragment)
private val pagerAdapter by lazy {
object : FragmentStateAdapter(this) {
override fun getItemCount() = pages.size
override fun createFragment(position: Int): Fragment = pages[position].createFragment()
override fun getItemId(position: Int): Long = pages[position].id
override fun containsItem(itemId: Long): Boolean = pages.any { it.id == itemId }
}
}
@ -95,6 +78,7 @@ open class MainActivity : BaseEuiccAccessActivity(), OpenEuiccContextMarker {
super.onCreate(savedInstanceState)
setContentView(R.layout.activity_main)
setSupportActionBar(requireViewById(R.id.toolbar))
setupToolbarInsets()
loadingProgress = requireViewById(R.id.loading)
tabs = requireViewById(R.id.main_tabs)
viewPager = requireViewById(R.id.view_pager)
@ -110,12 +94,6 @@ open class MainActivity : BaseEuiccAccessActivity(), OpenEuiccContextMarker {
addAction(UsbManager.ACTION_USB_DEVICE_ATTACHED)
addAction(UsbManager.ACTION_USB_DEVICE_DETACHED)
})
setupRootViewSystemBarInsets(
window.decorView.rootView, arrayOf(
this::activityToolbarInsetHandler
), consume = false
)
}
override fun onDestroy() {
@ -134,12 +112,10 @@ open class MainActivity : BaseEuiccAccessActivity(), OpenEuiccContextMarker {
startActivity(Intent(this, SettingsActivity::class.java))
true
}
R.id.reload -> {
refresh()
true
}
else -> super.onOptionsItemSelected(item)
}
@ -150,10 +126,15 @@ open class MainActivity : BaseEuiccAccessActivity(), OpenEuiccContextMarker {
}
private fun ensureNotificationPermissions() {
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.TIRAMISU) return
val permissions = arrayOf(android.Manifest.permission.POST_NOTIFICATIONS)
if (permissions.all { checkSelfPermission(it) == PackageManager.PERMISSION_GRANTED }) return
requestPermissions(permissions, PERMISSION_REQUEST_CODE)
val needsNotificationPerms = Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU;
val notificationPermsGranted =
needsNotificationPerms && checkSelfPermission(android.Manifest.permission.POST_NOTIFICATIONS) == PackageManager.PERMISSION_GRANTED
if (needsNotificationPerms && !notificationPermsGranted) {
requestPermissions(
arrayOf(android.Manifest.permission.POST_NOTIFICATIONS),
PERMISSION_REQUEST_CODE
)
}
}
private suspend fun init(fromUsbEvent: Boolean = false) {
@ -173,40 +154,29 @@ open class MainActivity : BaseEuiccAccessActivity(), OpenEuiccContextMarker {
euiccChannelManager.flowInternalEuiccPorts().onEach { (slotId, portId) ->
Log.d(TAG, "slot $slotId port $portId")
euiccChannelManager.flowEuiccSecureElements(slotId, portId).onEach { seId ->
euiccChannelManager.withEuiccChannel(slotId, portId, seId) { channel ->
if (preferenceRepository.verboseLoggingFlow.first()) {
Log.d(TAG, channel.lpa.eID)
}
// Request the system to refresh the list of profiles every time we start
// Note that this is currently supposed to be no-op when unprivileged,
// but it could change in the future
euiccChannelManager.notifyEuiccProfilesChanged(channel.logicalSlotId)
val channelName = if (channel.hasMultipleSE) {
appContainer.customizableTextProvider.formatNonUsbChannelNameWithSeId(
channel.logicalSlotId,
channel.seId
)
} else {
appContainer.customizableTextProvider.formatNonUsbChannelName(channel.logicalSlotId)
}
newPages.add(newPage(channel.logicalSlotId, channelName) {
appContainer.uiComponentFactory.createEuiccManagementFragment(
slotId,
portId,
seId
)
})
euiccChannelManager.withEuiccChannel(slotId, portId) { channel ->
if (preferenceRepository.verboseLoggingFlow.first()) {
Log.d(TAG, channel.lpa.eID)
}
}.collect()
// Request the system to refresh the list of profiles every time we start
// Note that this is currently supposed to be no-op when unprivileged,
// but it could change in the future
euiccChannelManager.notifyEuiccProfilesChanged(channel.logicalSlotId)
val channelName =
appContainer.customizableTextProvider.formatInternalChannelName(channel.logicalSlotId)
newPages.add(Page(channel.logicalSlotId, channelName) {
appContainer.uiComponentFactory.createEuiccManagementFragment(slotId, portId)
})
}
}.collect()
// If USB readers exist, add them at the very last
// We use a wrapper fragment to handle logic specific to USB readers
usbDevice?.let {
newPages.add(newPage(EuiccChannelManager.USB_CHANNEL_ID, getString(R.string.channel_name_format_usb)) {
UsbCcidReaderPermissionFragment()
val productName = it.productName ?: getString(R.string.usb)
newPages.add(Page(EuiccChannelManager.USB_CHANNEL_ID, productName) {
UsbCcidReaderFragment()
})
}
viewPager.visibility = View.VISIBLE
@ -214,7 +184,7 @@ open class MainActivity : BaseEuiccAccessActivity(), OpenEuiccContextMarker {
if (newPages.size > 1) {
tabs.visibility = View.VISIBLE
} else if (newPages.isEmpty()) {
newPages.add(newPage(-1, "") {
newPages.add(Page(-1, "") {
appContainer.uiComponentFactory.createNoEuiccPlaceholderFragment()
})
}
@ -239,12 +209,10 @@ open class MainActivity : BaseEuiccAccessActivity(), OpenEuiccContextMarker {
viewPager.currentItem = 0
}
if (pages.isNotEmpty()) {
if (pages.size > 0) {
ensureNotificationPermissions()
}
ShortcutManagerCompat.setDynamicShortcuts(this, buildShortcuts().take(4))
refreshing = false
}
@ -263,44 +231,4 @@ open class MainActivity : BaseEuiccAccessActivity(), OpenEuiccContextMarker {
init(fromUsbEvent) // will set refreshing = false
}
}
protected open fun buildShortcuts(): List<ShortcutInfoCompat> {
val downloadShortcut = ShortcutInfoCompat.Builder(this, "download")
.setShortLabel(getString(R.string.profile_download))
.setIcon(IconCompat.createWithResource(this, R.drawable.ic_task_sim_card_download))
.setIntent(DownloadWizardActivity.newIntent(this).apply { action = Intent.ACTION_VIEW })
.build()
return listOf(downloadShortcut)
}
fun instantiateUsbTabs(seIds: List<EuiccChannel.SecureElementId>) {
val existingUsbPageIndex = pages.indexOfFirst { it.logicalSlotId == EuiccChannelManager.USB_CHANNEL_ID }
if (existingUsbPageIndex == -1) return
val usbPages =
seIds.map { seId ->
val name = if (seIds.size == 1) {
getString(R.string.channel_name_format_usb)
} else {
getString(R.string.channel_name_format_usb_se, seId.id)
}
newPage(EuiccChannelManager.USB_CHANNEL_ID, name) {
appContainer.uiComponentFactory.createEuiccManagementFragment(
EuiccChannelManager.USB_CHANNEL_ID,
0,
seId
)
}
}
// Add before removing to avoid out-of-bounds problems
pages.addAll(existingUsbPageIndex, usbPages)
// Remove the old USB reader page
pages.removeAt(existingUsbPageIndex + usbPages.size)
if (pages.size > 1) {
tabs.visibility = View.VISIBLE
}
pagerAdapter.notifyDataSetChanged()
}
}
}

View file

@ -7,7 +7,7 @@ import android.view.ViewGroup
import android.widget.TextView
import androidx.fragment.app.Fragment
import im.angry.openeuicc.common.R
import im.angry.openeuicc.util.OpenEuiccContextMarker
import im.angry.openeuicc.util.*
class NoEuiccPlaceholderFragment : Fragment(), OpenEuiccContextMarker {
override fun onCreateView(
@ -20,4 +20,4 @@ class NoEuiccPlaceholderFragment : Fragment(), OpenEuiccContextMarker {
textView.text = appContainer.customizableTextProvider.noEuiccExplanation
return view
}
}
}

View file

@ -1,7 +1,6 @@
package im.angry.openeuicc.ui
import android.annotation.SuppressLint
import android.os.Build
import android.os.Bundle
import android.text.Html
import android.view.ContextMenu
@ -21,7 +20,6 @@ import androidx.recyclerview.widget.LinearLayoutManager
import androidx.recyclerview.widget.RecyclerView
import androidx.swiperefreshlayout.widget.SwipeRefreshLayout
import im.angry.openeuicc.common.R
import im.angry.openeuicc.core.EuiccChannel
import im.angry.openeuicc.core.EuiccChannelManager
import im.angry.openeuicc.util.*
import kotlinx.coroutines.Dispatchers
@ -29,54 +27,45 @@ import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import net.typeblog.lpac_jni.LocalProfileNotification
class NotificationsActivity : BaseEuiccAccessActivity(), OpenEuiccContextMarker {
class NotificationsActivity: BaseEuiccAccessActivity(), OpenEuiccContextMarker {
private lateinit var swipeRefresh: SwipeRefreshLayout
private lateinit var notificationList: RecyclerView
private val notificationAdapter = NotificationAdapter()
private var logicalSlotId = -1
private var seId = EuiccChannel.SecureElementId.DEFAULT
override fun onCreate(savedInstanceState: Bundle?) {
enableEdgeToEdge()
super.onCreate(savedInstanceState)
setContentView(R.layout.activity_notifications)
setSupportActionBar(requireViewById(R.id.toolbar))
setupToolbarInsets()
supportActionBar!!.setDisplayHomeAsUpEnabled(true)
swipeRefresh = requireViewById(R.id.swipe_refresh)
notificationList = requireViewById(R.id.recycler_view)
setupRootViewSystemBarInsets(
window.decorView.rootView, arrayOf(
this::activityToolbarInsetHandler,
mainViewPaddingInsetHandler(notificationList)
)
)
setupRootViewInsets(notificationList)
}
override fun onInit() {
notificationList.apply {
val context = this@NotificationsActivity
adapter = notificationAdapter
layoutManager = LinearLayoutManager(context, RecyclerView.VERTICAL, false)
addItemDecoration(DividerItemDecoration(context, LinearLayoutManager.VERTICAL))
registerForContextMenu(this)
}
notificationList.layoutManager =
LinearLayoutManager(this, LinearLayoutManager.VERTICAL, false)
notificationList.addItemDecoration(DividerItemDecoration(this, LinearLayoutManager.VERTICAL))
notificationList.adapter = notificationAdapter
registerForContextMenu(notificationList)
logicalSlotId = intent.getIntExtra("logicalSlotId", 0)
seId = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
intent.getParcelableExtra("seId", EuiccChannel.SecureElementId::class.java)
} else {
@Suppress("DEPRECATION")
intent.getParcelableExtra("seId")
} ?: EuiccChannel.SecureElementId.DEFAULT
setChannelTitle(
if (logicalSlotId == EuiccChannelManager.USB_CHANNEL_ID)
getString(R.string.channel_name_format_usb) else
appContainer.customizableTextProvider.formatNonUsbChannelName(logicalSlotId)
)
// This is slightly different from the MainActivity logic
// due to the length (we don't want to display the full USB product name)
val channelTitle = if (logicalSlotId == EuiccChannelManager.USB_CHANNEL_ID) {
getString(R.string.usb)
} else {
appContainer.customizableTextProvider.formatInternalChannelName(logicalSlotId)
}
title = getString(R.string.profile_notifications_detailed_format, channelTitle)
swipeRefresh.setOnRefreshListener {
refresh()
@ -97,22 +86,19 @@ class NotificationsActivity : BaseEuiccAccessActivity(), OpenEuiccContextMarker
finish()
true
}
R.id.help -> {
AlertDialog.Builder(this, R.style.AlertDialogTheme)
.setMessage(R.string.profile_notifications_help)
.setPositiveButton(android.R.string.ok) { _, _ -> }
.show()
AlertDialog.Builder(this, R.style.AlertDialogTheme).apply {
setMessage(R.string.profile_notifications_help)
setPositiveButton(android.R.string.ok) { dialog, _ ->
dialog.dismiss()
}
show()
}
true
}
else -> super.onOptionsItemSelected(item)
}
private fun setChannelTitle(title: CharSequence) {
super.setTitle(getString(R.string.profile_notifications_detailed_format, title))
}
private fun launchTask(task: suspend () -> Unit) {
swipeRefresh.isRefreshing = true
@ -128,39 +114,29 @@ class NotificationsActivity : BaseEuiccAccessActivity(), OpenEuiccContextMarker
}
private fun refresh() {
launchTask {
notificationAdapter.notifications = withEuiccChannel { channel ->
if (channel.hasMultipleSE) {
withContext(Dispatchers.Main) {
val channelTitle = if (logicalSlotId == EuiccChannelManager.USB_CHANNEL_ID) {
getString(R.string.channel_name_format_usb_se, seId.id)
} else {
appContainer.customizableTextProvider.formatNonUsbChannelNameWithSeId(logicalSlotId, seId)
}
setChannelTitle(channelTitle)
}
}
launchTask {
notificationAdapter.notifications =
euiccChannelManager.withEuiccChannel(logicalSlotId) { channel ->
val nameMap = buildMap {
for (profile in channel.lpa.profiles) {
put(profile.iccid, profile.displayName)
}
}
val nameMap = channel.lpa.profiles
.associate { Pair(it.iccid, it.displayName) }
channel.lpa.notifications.map {
LocalProfileNotificationWrapper(it, nameMap[it.iccid] ?: "???")
}
}
}
channel.lpa.notifications.map {
LocalProfileNotificationWrapper(it, nameMap[it.iccid] ?: "???")
}
}
}
}
private suspend fun <R> withEuiccChannel(fn: suspend (EuiccChannel) -> R) =
euiccChannelManager.withEuiccChannel(logicalSlotId, seId, fn)
data class LocalProfileNotificationWrapper(
val inner: LocalProfileNotification,
val profileName: String
)
@SuppressLint("ClickableViewAccessibility")
inner class NotificationViewHolder(private val root: View) :
inner class NotificationViewHolder(private val root: View):
RecyclerView.ViewHolder(root), View.OnCreateContextMenuListener, OnMenuItemClickListener {
private val address: TextView = root.requireViewById(R.id.notification_address)
private val sequenceNumber: TextView =
@ -194,8 +170,7 @@ class NotificationsActivity : BaseEuiccAccessActivity(), OpenEuiccContextMarker
LocalProfileNotification.Operation.Delete -> R.string.profile_notification_operation_delete
LocalProfileNotification.Operation.Enable -> R.string.profile_notification_operation_enable
LocalProfileNotification.Operation.Disable -> R.string.profile_notification_operation_disable
}
)
})
fun updateNotification(value: LocalProfileNotificationWrapper) {
notification = value
@ -206,13 +181,10 @@ class NotificationsActivity : BaseEuiccAccessActivity(), OpenEuiccContextMarker
value.inner.seqNumber
)
profileName.text = Html.fromHtml(
root.context.getString(
R.string.profile_notification_name_format,
root.context.getString(R.string.profile_notification_name_format,
operationToLocalizedText(value.inner.profileManagementOperation),
value.profileName, value.inner.iccid
),
Html.FROM_HTML_MODE_COMPACT
)
value.profileName, value.inner.iccid),
Html.FROM_HTML_MODE_COMPACT)
}
override fun onCreateContextMenu(
@ -232,7 +204,7 @@ class NotificationsActivity : BaseEuiccAccessActivity(), OpenEuiccContextMarker
R.id.notification_process -> {
launchTask {
withContext(Dispatchers.IO) {
withEuiccChannel { channel ->
euiccChannelManager.withEuiccChannel(logicalSlotId) { channel ->
channel.lpa.handleNotification(notification.inner.seqNumber)
}
}
@ -241,11 +213,10 @@ class NotificationsActivity : BaseEuiccAccessActivity(), OpenEuiccContextMarker
}
true
}
R.id.notification_delete -> {
launchTask {
withContext(Dispatchers.IO) {
withEuiccChannel { channel ->
euiccChannelManager.withEuiccChannel(logicalSlotId) { channel ->
channel.lpa.deleteNotification(notification.inner.seqNumber)
}
}
@ -254,12 +225,11 @@ class NotificationsActivity : BaseEuiccAccessActivity(), OpenEuiccContextMarker
}
true
}
else -> false
}
}
inner class NotificationAdapter : RecyclerView.Adapter<NotificationViewHolder>() {
inner class NotificationAdapter: RecyclerView.Adapter<NotificationViewHolder>() {
var notifications: List<LocalProfileNotificationWrapper> = listOf()
@SuppressLint("NotifyDataSetChanged")
set(value) {
@ -279,4 +249,4 @@ class NotificationsActivity : BaseEuiccAccessActivity(), OpenEuiccContextMarker
holder.updateNotification(notifications[position])
}
}
}

View file

@ -9,7 +9,6 @@ import androidx.appcompat.app.AlertDialog
import androidx.fragment.app.DialogFragment
import androidx.lifecycle.lifecycleScope
import im.angry.openeuicc.common.R
import im.angry.openeuicc.core.EuiccChannel
import im.angry.openeuicc.service.EuiccChannelManagerService.Companion.waitDone
import im.angry.openeuicc.util.*
import kotlinx.coroutines.flow.onStart
@ -21,11 +20,11 @@ class ProfileDeleteFragment : DialogFragment(), EuiccChannelFragmentMarker {
private const val FIELD_ICCID = "iccid"
private const val FIELD_NAME = "name"
fun newInstance(slotId: Int, portId: Int, seId: EuiccChannel.SecureElementId, iccid: String, name: String) =
newInstanceEuicc(ProfileDeleteFragment::class.java, slotId, portId, seId) {
fun newInstance(slotId: Int, portId: Int, iccid: String, name: String) =
newInstanceEuicc(ProfileDeleteFragment::class.java, slotId, portId) {
putString(FIELD_ICCID, iccid)
putString(FIELD_NAME, name)
}
}
}
private val iccid by lazy {
@ -54,13 +53,12 @@ class ProfileDeleteFragment : DialogFragment(), EuiccChannelFragmentMarker {
get() = requireDialog() as AlertDialog
override fun onCreateDialog(savedInstanceState: Bundle?): Dialog =
AlertDialog.Builder(requireContext(), R.style.AlertDialogTheme)
.setMessage(getString(R.string.profile_delete_confirm, name))
.setView(editText)
// Set listener to null to prevent auto closing
.setPositiveButton(android.R.string.ok, null)
.setNegativeButton(android.R.string.cancel, null)
.create()
AlertDialog.Builder(requireContext(), R.style.AlertDialogTheme).apply {
setMessage(getString(R.string.profile_delete_confirm, name))
setView(editText)
setPositiveButton(android.R.string.ok, null) // Set listener to null to prevent auto closing
setNegativeButton(android.R.string.cancel, null)
}.create()
override fun onResume() {
super.onResume()
@ -90,8 +88,7 @@ class ProfileDeleteFragment : DialogFragment(), EuiccChannelFragmentMarker {
requireParentFragment().lifecycleScope.launch {
ensureEuiccChannelManager()
euiccChannelManagerService.waitForForegroundTask()
euiccChannelManagerService.launchProfileDeleteTask(slotId, portId, seId, iccid)
.stateFlow
euiccChannelManagerService.launchProfileDeleteTask(slotId, portId, iccid)
.onStart {
parentFragment?.notifyEuiccProfilesChanged()
runCatching(::dismiss)
@ -99,4 +96,4 @@ class ProfileDeleteFragment : DialogFragment(), EuiccChannelFragmentMarker {
.waitDone()
}
}
}
}

View file

@ -12,7 +12,6 @@ import androidx.appcompat.widget.Toolbar
import androidx.lifecycle.lifecycleScope
import com.google.android.material.textfield.TextInputLayout
import im.angry.openeuicc.common.R
import im.angry.openeuicc.core.EuiccChannel
import im.angry.openeuicc.service.EuiccChannelManagerService.Companion.waitDone
import im.angry.openeuicc.util.*
import kotlinx.coroutines.launch
@ -25,13 +24,11 @@ class ProfileRenameFragment : BaseMaterialDialogFragment(), EuiccChannelFragment
const val TAG = "ProfileRenameFragment"
fun newInstance(
slotId: Int, portId: Int, seId: EuiccChannel.SecureElementId,
iccid: String, currentName: String
) = newInstanceEuicc(ProfileRenameFragment::class.java, slotId, portId, seId) {
putString(FIELD_ICCID, iccid)
putString(FIELD_CURRENT_NAME, currentName)
}
fun newInstance(slotId: Int, portId: Int, iccid: String, currentName: String) =
newInstanceEuicc(ProfileRenameFragment::class.java, slotId, portId) {
putString(FIELD_ICCID, iccid)
putString(FIELD_CURRENT_NAME, currentName)
}
}
private lateinit var toolbar: Toolbar
@ -68,7 +65,7 @@ class ProfileRenameFragment : BaseMaterialDialogFragment(), EuiccChannelFragment
super.onViewCreated(view, savedInstanceState)
profileRenameNewName.editText!!.setText(currentName)
toolbar.apply {
setTitle(R.string.profile_rename)
setTitle(R.string.rename)
setNavigationOnClickListener {
if (!renaming) dismiss()
}
@ -108,7 +105,7 @@ class ProfileRenameFragment : BaseMaterialDialogFragment(), EuiccChannelFragment
ensureEuiccChannelManager()
euiccChannelManagerService.waitForForegroundTask()
val response = euiccChannelManagerService
.launchProfileRenameTask(slotId, portId, seId, iccid, newName).stateFlow.waitDone()
.launchProfileRenameTask(slotId, portId, iccid, newName).waitDone()
when (response) {
is LocalProfileAssistant.ProfileNameTooLongException -> {
@ -131,4 +128,4 @@ class ProfileRenameFragment : BaseMaterialDialogFragment(), EuiccChannelFragment
}
}
}
}
}

View file

@ -8,7 +8,7 @@ import im.angry.openeuicc.OpenEuiccApplication
import im.angry.openeuicc.common.R
import im.angry.openeuicc.util.*
class SettingsActivity : AppCompatActivity() {
class SettingsActivity: AppCompatActivity() {
private val appContainer
get() = (application as OpenEuiccApplication).appContainer
@ -17,14 +17,8 @@ class SettingsActivity : AppCompatActivity() {
super.onCreate(savedInstanceState)
setContentView(R.layout.activity_settings)
setSupportActionBar(requireViewById(R.id.toolbar))
setupToolbarInsets()
supportActionBar!!.setDisplayHomeAsUpEnabled(true)
setupRootViewSystemBarInsets(
window.decorView.rootView, arrayOf(
this::activityToolbarInsetHandler
), consume = false
)
val settingsFragment = appContainer.uiComponentFactory.createSettingsFragment()
supportFragmentManager.beginTransaction()
.replace(R.id.settings_container, settingsFragment)
@ -37,7 +31,6 @@ class SettingsActivity : AppCompatActivity() {
finish()
true
}
else -> super.onOptionsItemSelected(item)
}
}
}

View file

@ -5,12 +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
import androidx.preference.PreferenceFragmentCompat
@ -19,8 +16,9 @@ import im.angry.openeuicc.util.*
import kotlinx.coroutines.flow.collect
import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.launch
import kotlinx.coroutines.runBlocking
open class SettingsFragment : PreferenceFragmentCompat(), OpenEuiccContextMarker {
open class SettingsFragment: PreferenceFragmentCompat() {
private lateinit var developerPref: PreferenceCategory
// Hidden developer options switch
@ -36,7 +34,7 @@ open class SettingsFragment : PreferenceFragmentCompat(), OpenEuiccContextMarker
// Show / hide developer preference based on whether it is enabled
lifecycleScope.launch {
preferenceRepository.developerOptionsEnabledFlow
.onEach(developerPref::setVisible)
.onEach { developerPref.isVisible = it }
.collect()
}
@ -49,9 +47,10 @@ open class SettingsFragment : PreferenceFragmentCompat(), OpenEuiccContextMarker
requirePreference<Preference>("pref_advanced_language").apply {
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.TIRAMISU) return@apply
val uri = Uri.fromParts("package", requireContext().packageName, null)
intent = Intent(Settings.ACTION_APP_LOCALE_SETTINGS, uri)
isVisible = intent!!.resolveActivity(requireContext().packageManager) != null
isVisible = true
intent = Intent(Settings.ACTION_APP_LOCALE_SETTINGS).apply {
data = Uri.fromParts("package", requireContext().packageName, null)
}
}
requirePreference<Preference>("pref_advanced_logs").apply {
@ -73,34 +72,14 @@ open class SettingsFragment : PreferenceFragmentCompat(), OpenEuiccContextMarker
requirePreference<CheckBoxPreference>("pref_advanced_verbose_logging")
.bindBooleanFlow(preferenceRepository.verboseLoggingFlow)
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)
requirePreference<CheckBoxPreference>("pref_developer_ignore_tls_certificate")
.bindBooleanFlow(preferenceRepository.ignoreTLSCertificateFlow)
requirePreference<CheckBoxPreference>("pref_developer_refresh_after_switch")
.bindBooleanFlow(preferenceRepository.refreshAfterSwitchFlow)
requirePreference<ListPreference>("pref_developer_es10x_mss")
.bindIntFlow(preferenceRepository.es10xMssFlow, 63)
requirePreference<Preference>("pref_developer_isdr_aid_list").apply {
intent = Intent(requireContext(), IsdrAidListActivity::class.java)
}
requirePreference<Preference>("pref_info_website").apply {
val uri = appContainer.customizableTextProvider.websiteUri ?: return@apply
isVisible = true
summary = uri.buildUpon().clearQuery().build().toString()
intent = Intent(/* action = */ Intent.ACTION_VIEW, uri)
}
requirePreference<CheckBoxPreference>("pref_developer_euicc_memory_reset")
.bindBooleanFlow(preferenceRepository.euiccMemoryResetFlow)
}
protected fun <T : Preference> requirePreference(key: CharSequence) =
@ -108,74 +87,57 @@ open class SettingsFragment : PreferenceFragmentCompat(), OpenEuiccContextMarker
override fun onStart() {
super.onStart()
setupRootViewSystemBarInsets(requireView(), arrayOf(
mainViewPaddingInsetHandler(requireView().requireViewById(R.id.recycler_view))
))
setupRootViewInsets(requireView().requireViewById(R.id.recycler_view))
}
@Suppress("UNUSED_PARAMETER")
private fun onAppVersionClicked(pref: Preference): Boolean {
if (developerPref.isVisible) return false
val now = System.currentTimeMillis()
numClicks = if (now - lastClickTimestamp >= 1000) 1 else numClicks + 1
if (now - lastClickTimestamp >= 1000) {
numClicks = 1
} else {
numClicks++
}
lastClickTimestamp = now
lifecycleScope.launch {
preferenceRepository.developerOptionsEnabledFlow.updatePreference(numClicks >= 7)
if (numClicks == 7) {
lifecycleScope.launch {
preferenceRepository.developerOptionsEnabledFlow.updatePreference(true)
lastToast?.cancel()
Toast.makeText(
requireContext(),
R.string.developer_options_enabled,
Toast.LENGTH_SHORT
).show()
}
} else if (numClicks > 1) {
lastToast?.cancel()
lastToast = Toast.makeText(
requireContext(),
getString(R.string.developer_options_steps, 7 - numClicks),
Toast.LENGTH_SHORT
)
lastToast!!.show()
}
val toastText = when {
numClicks == 7 -> getString(R.string.developer_options_enabled)
numClicks > 1 -> getString(R.string.developer_options_steps, 7 - numClicks)
else -> return true
}
lastToast?.cancel()
lastToast = Toast.makeText(requireContext(), toastText, Toast.LENGTH_SHORT)
lastToast!!.show()
return true
}
protected fun CheckBoxPreference.bindBooleanFlow(flow: PreferenceFlowWrapper<Boolean>) {
lifecycleScope.launch {
flow.collect(::setChecked)
flow.collect { isChecked = it }
}
setOnPreferenceChangeListener { _, newValue ->
lifecycleScope.launch {
runBlocking {
flow.updatePreference(newValue as Boolean)
}
true
}
}
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() }
}
setOnPreferenceChangeListener { _, newValue ->
lifecycleScope.launch {
flow.updatePreference((newValue as String).toIntOrNull() ?: defaultValue)
}
true
}
}
protected fun mergePreferenceOverlay(overlayKey: String, targetKey: String) {
val overlayCat = requirePreference<PreferenceCategory>(overlayKey)
val targetCat = requirePreference<PreferenceCategory>(targetKey)
@ -193,4 +155,4 @@ open class SettingsFragment : PreferenceFragmentCompat(), OpenEuiccContextMarker
overlayCat.parent?.removePreference(overlayCat)
}
}
}

View file

@ -14,22 +14,23 @@ import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import android.widget.Button
import android.widget.ProgressBar
import android.widget.TextView
import androidx.fragment.app.Fragment
import androidx.fragment.app.commit
import androidx.lifecycle.lifecycleScope
import im.angry.openeuicc.common.R
import im.angry.openeuicc.core.EuiccChannelManager
import im.angry.openeuicc.util.*
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.toList
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
/**
* A fragment to handle USB reader-specific permission flow. If/after
* permission is granted, this fragment simply calls back to MainActivity
* to instantiate the corresponding EuiccManagementFragment(s) for the USB
* reader.
* A wrapper fragment over EuiccManagementFragment where we handle
* logic specific to USB devices. This is mainly USB permission
* requests, and the fact that USB devices may or may not be
* available by the time the user selects it from MainActivity.
*
* Having this fragment allows MainActivity to be (mostly) agnostic
* of the underlying implementation of different types of channels.
@ -39,7 +40,7 @@ import kotlinx.coroutines.withContext
* Note that for now we assume there will only be one USB card reader
* device. This is also an implicit assumption in EuiccChannelManager.
*/
class UsbCcidReaderPermissionFragment : Fragment(), OpenEuiccContextMarker {
class UsbCcidReaderFragment : Fragment(), OpenEuiccContextMarker {
companion object {
const val ACTION_USB_PERMISSION = "im.angry.openeuicc.USB_PERMISSION"
}
@ -68,7 +69,7 @@ class UsbCcidReaderPermissionFragment : Fragment(), OpenEuiccContextMarker {
private lateinit var text: TextView
private lateinit var permissionButton: Button
private lateinit var loadingProgress: View
private lateinit var loadingProgress: ProgressBar
private var usbDevice: UsbDevice? = null
@ -141,23 +142,28 @@ class UsbCcidReaderPermissionFragment : Fragment(), OpenEuiccContextMarker {
euiccChannelManager.tryOpenUsbEuiccChannel()
}
loadingProgress.visibility = View.GONE
usbDevice = device
if (device != null && !canOpen && !usbManager.hasPermission(device)) {
loadingProgress.visibility = View.GONE
text.text = getString(R.string.usb_permission_needed)
text.visibility = View.VISIBLE
permissionButton.visibility = View.VISIBLE
} else if (device != null && canOpen) {
val seIds = withContext(Dispatchers.IO) {
euiccChannelManager.flowEuiccSecureElements(EuiccChannelManager.USB_CHANNEL_ID, 0).toList()
childFragmentManager.commit {
replace(
R.id.child_container,
appContainer.uiComponentFactory.createEuiccManagementFragment(
slotId = EuiccChannelManager.USB_CHANNEL_ID,
portId = 0
)
)
}
(requireActivity() as MainActivity).instantiateUsbTabs(seIds)
} else {
loadingProgress.visibility = View.GONE
text.text = getString(R.string.usb_failed)
text.visibility = View.VISIBLE
permissionButton.visibility = View.GONE
}
}
}
}

View file

@ -7,10 +7,10 @@ import androidx.preference.PreferenceCategory
import androidx.preference.PreferenceViewHolder
@Suppress("unused")
class LongSummaryPreferenceCategory : PreferenceCategory {
constructor(ctx: Context) : super(ctx)
constructor(ctx: Context, attrs: AttributeSet) : super(ctx, attrs)
constructor(ctx: Context, attrs: AttributeSet, defStyle: Int) : super(ctx, attrs, defStyle)
class LongSummaryPreferenceCategory: PreferenceCategory {
constructor(ctx: Context): super(ctx)
constructor(ctx: Context, attrs: AttributeSet): super(ctx, attrs)
constructor(ctx: Context, attrs: AttributeSet, defStyle: Int): super(ctx, attrs, defStyle)
override fun onBindViewHolder(holder: PreferenceViewHolder) {
super.onBindViewHolder(holder)

View file

@ -1,51 +0,0 @@
package im.angry.openeuicc.ui.widget
import android.content.Context
import android.util.AttributeSet
import android.view.ViewGroup
import com.google.android.material.R
import com.google.android.material.tabs.TabLayout
/**
* A TabLayout that automatically switches to MODE_SCROLLABLE when
* child tabs overflow the full width of the layout.
*/
class DynamicModeTabLayout @JvmOverloads constructor(
context: Context,
attrs: AttributeSet? = null,
defStyleAttr: Int = R.attr.tabStyle
) : TabLayout(context, attrs, defStyleAttr) {
init {
addOnLayoutChangeListener { _, _, _, _, _, _, _, _, _ ->
updateModeIfNecessary()
}
}
private fun updateModeIfNecessary() {
if (width <= 0 || tabCount == 0) return
val tabStrip = getChildAt(0) as? ViewGroup ?: return
val totalTabWidth = (0 until tabStrip.childCount).sumOf { index ->
val tabView = tabStrip.getChildAt(index)
val layoutParams = tabView.layoutParams as? MarginLayoutParams
tabView.measure(
MeasureSpec.makeMeasureSpec(0, MeasureSpec.UNSPECIFIED),
MeasureSpec.makeMeasureSpec(0, MeasureSpec.UNSPECIFIED)
)
tabView.measuredWidth + (layoutParams?.leftMargin ?: 0) + (layoutParams?.rightMargin ?: 0)
}
val availableWidth = width - paddingLeft - paddingRight
val shouldScroll = totalTabWidth > availableWidth
val targetMode = if (shouldScroll) MODE_SCROLLABLE else MODE_FIXED
val targetGravity = if (shouldScroll) GRAVITY_START else GRAVITY_FILL
if (tabMode != targetMode) {
tabMode = targetMode
}
if (tabGravity != targetGravity) {
tabGravity = targetGravity
}
}
}

View file

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

View file

@ -1,11 +1,8 @@
package im.angry.openeuicc.ui.wizard
import android.app.assist.AssistContent
import android.content.Context
import android.content.Intent
import android.os.Bundle
import android.view.View
import android.view.WindowManager
import android.view.inputmethod.InputMethodManager
import android.widget.Button
import android.widget.ProgressBar
@ -19,7 +16,6 @@ import androidx.core.view.updatePadding
import androidx.fragment.app.Fragment
import androidx.lifecycle.lifecycleScope
import im.angry.openeuicc.common.R
import im.angry.openeuicc.core.EuiccChannel
import im.angry.openeuicc.core.EuiccChannelManager
import im.angry.openeuicc.ui.BaseEuiccAccessActivity
import im.angry.openeuicc.util.*
@ -27,26 +23,10 @@ import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import net.typeblog.lpac_jni.LocalProfileAssistant
class DownloadWizardActivity : BaseEuiccAccessActivity() {
companion object {
const val TAG = "DownloadWizardActivity"
private const val FIELD_LOGICAL_SLOT_ID = "selectedLogicalSlot"
fun newIntent(
context: Context,
logicalSlotId: Int = 0,
seId: EuiccChannel.SecureElementId = EuiccChannel.SecureElementId.DEFAULT
) = Intent(context, DownloadWizardActivity::class.java).apply {
val selectedSyntheticSlotId = DownloadWizardSlotSelectFragment
.encodeSyntheticSlotId(logicalSlotId, seId)
putExtra(FIELD_LOGICAL_SLOT_ID, selectedSyntheticSlotId)
}
}
class DownloadWizardActivity: BaseEuiccAccessActivity() {
data class DownloadWizardState(
var currentStepFragmentClassName: String?,
var selectedSyntheticSlotId: Int,
var selectedLogicalSlot: Int,
var smdp: String,
var matchingId: String?,
var confirmationCode: String?,
@ -85,7 +65,7 @@ class DownloadWizardActivity : BaseEuiccAccessActivity() {
state = DownloadWizardState(
currentStepFragmentClassName = null,
selectedSyntheticSlotId = intent.getIntExtra(FIELD_LOGICAL_SLOT_ID, 0),
selectedLogicalSlot = intent.getIntExtra("selectedLogicalSlot", 0),
smdp = "",
matchingId = null,
confirmationCode = null,
@ -113,20 +93,27 @@ class DownloadWizardActivity : BaseEuiccAccessActivity() {
val navigation = requireViewById<View>(R.id.download_wizard_navigation)
val origHeight = navigation.layoutParams.height
val fragmentRoot = requireViewById<View>(R.id.step_fragment_container)
ViewCompat.setOnApplyWindowInsetsListener(window.decorView.rootView) { _, insets ->
ViewCompat.setOnApplyWindowInsetsListener(navigation) { v, insets ->
val bars = insets.getInsets(
WindowInsetsCompat.Type.systemBars()
or WindowInsetsCompat.Type.displayCutout()
or WindowInsetsCompat.Type.ime()
or WindowInsetsCompat.Type.displayCutout()
or WindowInsetsCompat.Type.ime()
)
v.updatePadding(bars.left, 0, bars.right, bars.bottom)
val newParams = navigation.layoutParams
newParams.height = origHeight + bars.bottom
navigation.layoutParams = newParams
WindowInsetsCompat.CONSUMED
}
fragmentRoot.updatePadding(bars.left, bars.top, bars.right, 0)
navigation.updatePadding(bars.left, 0, bars.right, bars.bottom)
val newNavParams = navigation.layoutParams
newNavParams.height = origHeight + bars.bottom
navigation.layoutParams = newNavParams
val fragmentRoot = requireViewById<View>(R.id.step_fragment_container)
ViewCompat.setOnApplyWindowInsetsListener(fragmentRoot) { v, insets ->
val bars = insets.getInsets(
WindowInsetsCompat.Type.systemBars()
or WindowInsetsCompat.Type.displayCutout()
)
v.updatePadding(bars.left, bars.top, bars.right, 0)
WindowInsetsCompat.CONSUMED
}
}
@ -135,8 +122,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
if (uri?.scheme == "lpa") {
val parsed = LPAString.parse(uri.schemeSpecificPart)
state.smdp = parsed.address
state.matchingId = parsed.matchingId
@ -163,7 +150,7 @@ class DownloadWizardActivity : BaseEuiccAccessActivity() {
override fun onSaveInstanceState(outState: Bundle) {
super.onSaveInstanceState(outState)
outState.putString("currentStepFragmentClassName", state.currentStepFragmentClassName)
outState.putInt("selectedLogicalSlot", state.selectedSyntheticSlotId)
outState.putInt("selectedLogicalSlot", state.selectedLogicalSlot)
outState.putString("smdp", state.smdp)
outState.putString("matchingId", state.matchingId)
outState.putString("confirmationCode", state.confirmationCode)
@ -179,20 +166,16 @@ class DownloadWizardActivity : BaseEuiccAccessActivity() {
"currentStepFragmentClassName",
state.currentStepFragmentClassName
)
state.selectedSyntheticSlotId =
savedInstanceState.getInt("selectedSyntheticSlotId", state.selectedSyntheticSlotId)
state.selectedLogicalSlot =
savedInstanceState.getInt("selectedLogicalSlot", state.selectedLogicalSlot)
state.smdp = savedInstanceState.getString("smdp", state.smdp)
state.matchingId = savedInstanceState.getString("matchingId", state.matchingId)
state.imei = savedInstanceState.getString("imei", state.imei)
state.downloadStarted =
savedInstanceState.getBoolean("downloadStarted", state.downloadStarted)
state.downloadTaskID = savedInstanceState.getLong("downloadTaskID", state.downloadTaskID)
state.confirmationCode =
savedInstanceState.getString("confirmationCode", state.confirmationCode)
state.confirmationCodeRequired = savedInstanceState.getBoolean(
"confirmationCodeRequired",
state.confirmationCodeRequired
)
state.confirmationCode = savedInstanceState.getString("confirmationCode", state.confirmationCode)
state.confirmationCodeRequired = savedInstanceState.getBoolean("confirmationCodeRequired", state.confirmationCodeRequired)
}
private fun onPrevPressed() {
@ -216,13 +199,10 @@ class DownloadWizardActivity : BaseEuiccAccessActivity() {
progressBar.isIndeterminate = true
lifecycleScope.launch(Dispatchers.Main) {
if (state.selectedSyntheticSlotId >= 0) {
if (state.selectedLogicalSlot >= 0) {
try {
val (slotId, seId) = DownloadWizardSlotSelectFragment.decodeSyntheticSlotId(
state.selectedSyntheticSlotId
)
// This is run on IO by default
euiccChannelManager.withEuiccChannel(slotId, seId) { channel ->
euiccChannelManager.withEuiccChannel(state.selectedLogicalSlot) { channel ->
// Be _very_ sure that the channel we got is valid
if (!channel.valid) throw EuiccChannelManager.EuiccChannelNotFoundException()
}
@ -258,10 +238,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())
}
}
@ -274,14 +251,6 @@ class DownloadWizardActivity : BaseEuiccAccessActivity() {
supportFragmentManager.beginTransaction().setCustomAnimations(enterAnim, exitAnim)
.replace(R.id.step_fragment_container, nextFrag)
.commit()
// Sync screen on state
if (nextFrag.keepScreenOn) {
window.addFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON)
} else {
window.clearFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON)
}
refreshButtons()
}
@ -311,8 +280,6 @@ class DownloadWizardActivity : BaseEuiccAccessActivity() {
protected val state: DownloadWizardState
get() = (requireActivity() as DownloadWizardActivity).state
open val keepScreenOn = false
abstract val hasNext: Boolean
abstract val hasPrev: Boolean
abstract fun createNextFragment(): DownloadWizardStepFragment?
@ -349,4 +316,4 @@ class DownloadWizardActivity : BaseEuiccAccessActivity() {
open fun beforeNext() {}
}
}
}

View file

@ -1,14 +1,14 @@
package im.angry.openeuicc.ui.wizard
import android.os.Bundle
import android.util.Patterns
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import android.widget.EditText
import android.widget.Toast
import androidx.core.widget.addTextChangedListener
import com.google.android.material.textfield.TextInputLayout
import im.angry.openeuicc.common.R
import im.angry.openeuicc.util.*
class DownloadWizardDetailsFragment : DownloadWizardActivity.DownloadWizardStepFragment() {
private var inputComplete = false
@ -18,28 +18,17 @@ class DownloadWizardDetailsFragment : DownloadWizardActivity.DownloadWizardStepF
override val hasPrev: Boolean
get() = true
private val address: EditText by lazy {
requireView().requireViewById<TextInputLayout>(R.id.profile_download_server).editText!!
}
private val matchingId: EditText by lazy {
requireView().requireViewById<TextInputLayout>(R.id.profile_download_code).editText!!
}
private val confirmationCodeInput: TextInputLayout by lazy {
requireView().requireViewById(R.id.profile_download_confirmation_code)
}
private val confirmationCode: EditText by lazy {
confirmationCodeInput.editText!!
}
private val imei: EditText by lazy {
requireView().requireViewById<TextInputLayout>(R.id.profile_download_imei).editText!!
}
private lateinit var smdp: TextInputLayout
private lateinit var matchingId: TextInputLayout
private lateinit var confirmationCode: TextInputLayout
private lateinit var imei: TextInputLayout
private fun saveState() {
state.smdp = address.text.toString().trim()
state.smdp = smdp.editText!!.text.toString().trim()
// Treat empty inputs as null -- this is important for the download step
state.matchingId = matchingId.text.toString().trim().ifBlank { null }
state.confirmationCode = confirmationCode.text.toString().trim().ifBlank { null }
state.imei = imei.text.toString().ifBlank { null }
state.matchingId = matchingId.editText!!.text.toString().trim().ifBlank { null }
state.confirmationCode = confirmationCode.editText!!.text.toString().trim().ifBlank { null }
state.imei = imei.editText!!.text.toString().ifBlank { null }
}
override fun beforeNext() = saveState()
@ -54,30 +43,40 @@ class DownloadWizardDetailsFragment : DownloadWizardActivity.DownloadWizardStepF
DownloadWizardMethodSelectFragment()
}
override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View =
inflater.inflate(R.layout.fragment_download_details, container, /* attachToRoot = */ false)
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
address.addTextChangedListener(onTextChanged = ::handlePasteLPAString)
address.addTextChangedListener { updateInputCompleteness() }
matchingId.addTextChangedListener(onTextChanged = ::handlePasteLPAString)
confirmationCode.addTextChangedListener { updateInputCompleteness() }
override fun onCreateView(
inflater: LayoutInflater,
container: ViewGroup?,
savedInstanceState: Bundle?
): View? {
val view = inflater.inflate(R.layout.fragment_download_details, container, false)
smdp = view.requireViewById(R.id.profile_download_server)
matchingId = view.requireViewById(R.id.profile_download_code)
confirmationCode = view.requireViewById(R.id.profile_download_confirmation_code)
imei = view.requireViewById(R.id.profile_download_imei)
smdp.editText!!.addTextChangedListener {
updateInputCompleteness()
}
confirmationCode.editText!!.addTextChangedListener {
updateInputCompleteness()
}
return view
}
override fun onStart() {
super.onStart()
address.setText(state.smdp)
matchingId.setText(state.matchingId)
confirmationCode.setText(state.confirmationCode)
imei.setText(state.imei)
smdp.editText!!.setText(state.smdp)
matchingId.editText!!.setText(state.matchingId)
confirmationCode.editText!!.setText(state.confirmationCode)
imei.editText!!.setText(state.imei)
updateInputCompleteness()
if (state.confirmationCodeRequired) {
confirmationCodeInput.requestFocus()
confirmationCodeInput.setHint(R.string.profile_download_confirmation_code_required)
confirmationCode.editText!!.requestFocus()
confirmationCode.editText!!.hint =
getString(R.string.profile_download_confirmation_code_required)
} else {
confirmationCodeInput.setHint(R.string.profile_download_confirmation_code)
confirmationCode.editText!!.hint =
getString(R.string.profile_download_confirmation_code)
}
}
@ -86,44 +85,11 @@ class DownloadWizardDetailsFragment : DownloadWizardActivity.DownloadWizardStepF
saveState()
}
private fun handlePasteLPAString(text: CharSequence?, start: Int, before: Int, count: Int) {
if (start > 0 || before > 0) return // only handle insertions at the beginning
if (text == null || !text.startsWith("LPA:", ignoreCase = true)) return
val parsed = LPAString.parse(text)
address.setText(parsed.address)
matchingId.setText(parsed.matchingId)
if (parsed.confirmationCodeRequired) confirmationCode.requestFocus()
}
private fun updateInputCompleteness() {
inputComplete = isValidAddress(address.text)
inputComplete = Patterns.DOMAIN_NAME.matcher(smdp.editText!!.text).matches()
if (state.confirmationCodeRequired) {
inputComplete = inputComplete && confirmationCode.text.isNotEmpty()
inputComplete = inputComplete && confirmationCode.editText!!.text.isNotEmpty()
}
refreshButtons()
}
}
private fun isValidAddress(input: CharSequence): Boolean {
if (!input.contains('.')) return false
var fqdn = input
var port = 443
if (input.contains(':')) {
val portIndex = input.lastIndexOf(':')
fqdn = input.take(portIndex)
port = input.substring(portIndex + 1, input.length).toIntOrNull(10) ?: 0
}
// see https://en.wikipedia.org/wiki/Port_(computer_networking)
if (port !in 1..0xffff) return false
// see https://en.wikipedia.org/wiki/Fully_qualified_domain_name
if (fqdn.isEmpty() || fqdn.length > 255) return false
for (part in fqdn.split('.')) {
if (part.isEmpty() || part.length > 64) return false
if (part.first() == '-' || part.last() == '-') return false
for (c in part) {
if (c.isLetterOrDigit() || c == '-') continue
return false
}
}
return true
}
}

View file

@ -8,7 +8,6 @@ import android.view.ViewGroup
import android.widget.TextView
import im.angry.openeuicc.common.R
import im.angry.openeuicc.util.*
import org.json.JSONObject
import java.util.Date
class DownloadWizardDiagnosticsFragment : DownloadWizardActivity.DownloadWizardStepFragment() {
@ -87,10 +86,9 @@ class DownloadWizardDiagnosticsFragment : DownloadWizardActivity.DownloadWizardS
ret.appendLine()
val str = resp.data.decodeToString(throwOnInvalidSequence = false)
ret.appendLine(
if (str.startsWith('{')) {
JSONObject(str).toString(2)
str.prettyPrintJson()
} else {
str
}
@ -138,4 +136,4 @@ class DownloadWizardDiagnosticsFragment : DownloadWizardActivity.DownloadWizardS
ret.toString()
}
}
}

View file

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

View file

@ -1,5 +1,6 @@
package im.angry.openeuicc.ui.wizard
import android.app.AlertDialog
import android.content.ClipboardManager
import android.graphics.BitmapFactory
import android.os.Bundle
@ -10,8 +11,6 @@ import android.widget.ImageView
import android.widget.TextView
import android.widget.Toast
import androidx.activity.result.contract.ActivityResultContracts
import androidx.appcompat.app.AlertDialog
import androidx.core.net.toUri
import androidx.lifecycle.lifecycleScope
import androidx.recyclerview.widget.DividerItemDecoration
import androidx.recyclerview.widget.LinearLayoutManager
@ -51,7 +50,8 @@ class DownloadWizardMethodSelectFragment : DownloadWizardActivity.DownloadWizard
}
}
}
decoded.getOrNull()?.let(::processLpaString)
decoded.getOrNull()?.let { processLpaString(it) }
}
}
@ -84,51 +84,56 @@ class DownloadWizardMethodSelectFragment : DownloadWizardActivity.DownloadWizard
override fun createPrevFragment(): DownloadWizardActivity.DownloadWizardStepFragment =
DownloadWizardSlotSelectFragment()
override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View =
inflater.inflate(R.layout.fragment_download_method_select, container, false).apply {
requireViewById<RecyclerView>(R.id.download_method_list).apply {
adapter = DownloadMethodAdapter()
layoutManager = LinearLayoutManager(context, LinearLayoutManager.VERTICAL, false)
addItemDecoration(DividerItemDecoration(context, LinearLayoutManager.VERTICAL))
}
}
override fun onCreateView(
inflater: LayoutInflater,
container: ViewGroup?,
savedInstanceState: Bundle?
): View? {
val view = inflater.inflate(R.layout.fragment_download_method_select, container, false)
val recyclerView = view.requireViewById<RecyclerView>(R.id.download_method_list)
recyclerView.adapter = DownloadMethodAdapter()
recyclerView.layoutManager =
LinearLayoutManager(view.context, LinearLayoutManager.VERTICAL, false)
recyclerView.addItemDecoration(
DividerItemDecoration(
requireContext(),
LinearLayoutManager.VERTICAL
)
)
return view
}
private fun handleLoadFromClipboard() {
val clipboard = requireContext().getSystemService(ClipboardManager::class.java)
val text = clipboard.primaryClip?.getItemAt(0)?.text
if (text == null) {
val resId = R.string.profile_download_no_lpa_string
Toast.makeText(requireContext(), resId, Toast.LENGTH_SHORT).show()
Toast.makeText(
requireContext(),
R.string.profile_download_no_lpa_string,
Toast.LENGTH_SHORT
).show()
return
}
processLpaString(text.toString())
}
private fun extractLpaString(input: String): String {
val prefix = "LPA:1$"
if (input.startsWith(prefix, ignoreCase = true)) return input
val uri = input.toUri()
return uri.queryParameterNames.flatMap(uri::getQueryParameters)
.firstOrNull { it.startsWith(prefix, ignoreCase = true) }
?: throw IllegalArgumentException("No LPA string found in input")
}
private fun processLpaString(input: String) {
try {
val parsed = LPAString.parse(extractLpaString(input))
val parsed = LPAString.parse(input)
state.smdp = parsed.address
state.matchingId = parsed.matchingId
state.confirmationCodeRequired = parsed.confirmationCodeRequired
gotoNextFragment(DownloadWizardDetailsFragment())
} catch (_: IllegalArgumentException) {
AlertDialog.Builder(requireContext(), R.style.AlertDialogTheme)
.setTitle(R.string.profile_download_incorrect_lpa_string)
.setMessage(R.string.profile_download_incorrect_lpa_string_message)
.setCancelable(true)
.setNegativeButton(android.R.string.cancel, null)
.show()
} catch (e: IllegalArgumentException) {
AlertDialog.Builder(requireContext()).apply {
setTitle(R.string.profile_download_incorrect_lpa_string)
setMessage(R.string.profile_download_incorrect_lpa_string_message)
setCancelable(true)
setNegativeButton(android.R.string.cancel, null)
show()
}
}
}
@ -139,8 +144,9 @@ class DownloadWizardMethodSelectFragment : DownloadWizardActivity.DownloadWizard
fun bind(item: DownloadMethod) {
icon.setImageResource(item.iconRes)
title.setText(item.titleRes)
// If the user elected to use another download method, reset the confirmation code flag too
root.setOnClickListener {
// If the user elected to use another download method, reset the confirmation code flag
// too
state.confirmationCodeRequired = false
item.onClick()
}
@ -148,7 +154,10 @@ class DownloadWizardMethodSelectFragment : DownloadWizardActivity.DownloadWizard
}
private inner class DownloadMethodAdapter : RecyclerView.Adapter<DownloadMethodViewHolder>() {
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): DownloadMethodViewHolder {
override fun onCreateViewHolder(
parent: ViewGroup,
viewType: Int
): DownloadMethodViewHolder {
val view = LayoutInflater.from(parent.context)
.inflate(R.layout.download_method_item, parent, false)
return DownloadMethodViewHolder(view)
@ -156,7 +165,9 @@ class DownloadWizardMethodSelectFragment : DownloadWizardActivity.DownloadWizard
override fun getItemCount(): Int = downloadMethods.size
override fun onBindViewHolder(holder: DownloadMethodViewHolder, position: Int) =
override fun onBindViewHolder(holder: DownloadMethodViewHolder, position: Int) {
holder.bind(downloadMethods[position])
}
}
}
}

View file

@ -7,7 +7,6 @@ import android.view.ViewGroup
import android.widget.ImageView
import android.widget.ProgressBar
import android.widget.TextView
import androidx.annotation.StringRes
import androidx.lifecycle.lifecycleScope
import androidx.recyclerview.widget.DividerItemDecoration
import androidx.recyclerview.widget.LinearLayoutManager
@ -18,11 +17,23 @@ import im.angry.openeuicc.util.*
import kotlinx.coroutines.flow.collect
import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.launch
import net.typeblog.lpac_jni.ProfileDownloadInput
import net.typeblog.lpac_jni.ProfileDownloadState
import net.typeblog.lpac_jni.LocalProfileAssistant
import net.typeblog.lpac_jni.ProfileDownloadCallback
class DownloadWizardProgressFragment : DownloadWizardActivity.DownloadWizardStepFragment() {
companion object {
/**
* An array of LPA-side state types, mapping 1:1 to progressItems
*/
val LPA_PROGRESS_STATES = arrayOf(
ProfileDownloadCallback.DownloadState.Preparing,
ProfileDownloadCallback.DownloadState.Connecting,
ProfileDownloadCallback.DownloadState.Authenticating,
ProfileDownloadCallback.DownloadState.Downloading,
ProfileDownloadCallback.DownloadState.Finalizing,
)
}
private enum class ProgressState {
NotStarted,
InProgress,
@ -31,24 +42,23 @@ class DownloadWizardProgressFragment : DownloadWizardActivity.DownloadWizardStep
}
private data class ProgressItem(
@StringRes val titleRes: Int,
var state: ProgressState = ProgressState.NotStarted,
var errorMessage: SimplifiedErrorMessages? = null,
val titleRes: Int,
var state: ProgressState
)
private val progressItems = arrayOf(
ProgressItem(R.string.download_wizard_progress_step_preparing),
ProgressItem(R.string.download_wizard_progress_step_connecting),
ProgressItem(R.string.download_wizard_progress_step_authenticating),
ProgressItem(R.string.download_wizard_progress_step_downloading),
ProgressItem(R.string.download_wizard_progress_step_finalizing)
ProgressItem(R.string.download_wizard_progress_step_preparing, ProgressState.NotStarted),
ProgressItem(R.string.download_wizard_progress_step_connecting, ProgressState.NotStarted),
ProgressItem(
R.string.download_wizard_progress_step_authenticating,
ProgressState.NotStarted
),
ProgressItem(R.string.download_wizard_progress_step_downloading, ProgressState.NotStarted),
ProgressItem(R.string.download_wizard_progress_step_finalizing, ProgressState.NotStarted)
)
private val adapter = ProgressItemAdapter()
// We don't want to turn off the screen during a download
override val keepScreenOn = true
private var isDone = false
override val hasNext: Boolean
@ -91,14 +101,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()
@ -109,13 +119,8 @@ class DownloadWizardProgressFragment : DownloadWizardActivity.DownloadWizardStep
// Change the state of the last InProgress item to success (or error)
progressItems.forEachIndexed { index, progressItem ->
if (progressItem.state == ProgressState.InProgress) {
if (state.downloadError == null) {
progressItem.state = ProgressState.Done
} else {
progressItem.state = ProgressState.Error
progressItem.errorMessage =
SimplifiedErrorMessages.fromDownloadError(state.downloadError!!)
}
progressItem.state =
if (state.downloadError == null) ProgressState.Done else ProgressState.Error
}
adapter.notifyItemChanged(index)
@ -125,8 +130,9 @@ class DownloadWizardProgressFragment : DownloadWizardActivity.DownloadWizardStep
refreshButtons()
}
is EuiccChannelManagerService.ForegroundTaskState.InProgress ->
updateProgress(it.context as? ProfileDownloadState ?: return@onEach)
is EuiccChannelManagerService.ForegroundTaskState.InProgress -> {
updateProgress(it.progress)
}
else -> {}
}
@ -134,25 +140,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 {
val (logicalSlotId, seId) = DownloadWizardSlotSelectFragment.decodeSyntheticSlotId(state.selectedSyntheticSlotId)
euiccChannelManagerService.waitForForegroundTask()
val (slotId, portId) = euiccChannelManager.withEuiccChannel(state.selectedLogicalSlot) { 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,
ProfileDownloadInput(state.smdp, state.matchingId, state.imei, state.confirmationCode)
val ret = euiccChannelManagerService.launchProfileDownloadTask(
slotId,
portId,
state.smdp,
state.matchingId,
state.confirmationCode,
state.imei
)
state.downloadTaskID = ret.taskId
@ -160,19 +168,11 @@ class DownloadWizardProgressFragment : DownloadWizardActivity.DownloadWizardStep
ret
}
private fun updateProgress(state: ProfileDownloadState) {
val progress = state.downloadProgress
private fun updateProgress(progress: Int) {
showProgressBar(progress)
val stateIndex = when (state) {
is ProfileDownloadState.Preparing -> 0
is ProfileDownloadState.Connecting -> 1
is ProfileDownloadState.Authenticating -> 2
// TODO: Actually implement metadata confirmation (a dialog or something else)
is ProfileDownloadState.ConfirmingDownload -> 2
is ProfileDownloadState.Downloading -> 3
is ProfileDownloadState.Finalizing -> 4
}
val lpaState = ProfileDownloadCallback.lookupStateFromProgress(progress)
val stateIndex = LPA_PROGRESS_STATES.indexOf(lpaState)
if (stateIndex > 0) {
for (i in (0..<stateIndex)) {
@ -194,15 +194,9 @@ class DownloadWizardProgressFragment : DownloadWizardActivity.DownloadWizardStep
private val progressBar =
root.requireViewById<ProgressBar>(R.id.download_progress_icon_progress)
private val icon = root.requireViewById<ImageView>(R.id.download_progress_icon)
private val errorTitle =
root.requireViewById<TextView>(R.id.download_progress_item_error_title)
private val errorSuggestion =
root.requireViewById<TextView>(R.id.download_progress_item_error_suggestion)
fun bind(item: ProgressItem) {
title.text = getString(item.titleRes)
errorTitle.visibility = View.GONE
errorSuggestion.visibility = View.GONE
when (item.state) {
ProgressState.NotStarted -> {
@ -225,15 +219,6 @@ class DownloadWizardProgressFragment : DownloadWizardActivity.DownloadWizardStep
progressBar.visibility = View.GONE
icon.setImageResource(R.drawable.ic_error_outline)
icon.visibility = View.VISIBLE
item.errorMessage?.titleResId?.let {
errorTitle.visibility = View.VISIBLE
errorTitle.text = getString(it)
}
item.errorMessage?.suggestResId?.let {
errorSuggestion.visibility = View.VISIBLE
errorSuggestion.text = getString(it)
}
}
}
}
@ -252,4 +237,4 @@ class DownloadWizardProgressFragment : DownloadWizardActivity.DownloadWizardStep
holder.bind(progressItems[position])
}
}
}
}

View file

@ -7,45 +7,37 @@ import android.view.View
import android.view.ViewGroup
import android.widget.CheckBox
import android.widget.TextView
import androidx.appcompat.app.AlertDialog
import androidx.lifecycle.lifecycleScope
import androidx.recyclerview.widget.DividerItemDecoration
import androidx.recyclerview.widget.LinearLayoutManager
import androidx.recyclerview.widget.RecyclerView
import androidx.recyclerview.widget.RecyclerView.ViewHolder
import im.angry.openeuicc.common.R
import im.angry.openeuicc.core.EuiccChannel
import im.angry.openeuicc.core.EuiccChannelManager
import im.angry.openeuicc.util.*
import kotlinx.coroutines.flow.flatMapConcat
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.toList
import kotlinx.coroutines.launch
import net.typeblog.lpac_jni.LocalProfileInfo
class DownloadWizardSlotSelectFragment : DownloadWizardActivity.DownloadWizardStepFragment() {
companion object {
internal fun encodeSyntheticSlotId(logicalSlotId: Int, seId: EuiccChannel.SecureElementId): Int =
(logicalSlotId shl 16) + seId.id
internal fun decodeSyntheticSlotId(id: Int): Pair<Int, EuiccChannel.SecureElementId> =
Pair(id shr 16, EuiccChannel.SecureElementId.createFromInt(id and 0xFF))
const val LOW_NVRAM_THRESHOLD =
30 * 1024 // < 30 KiB, alert about potential download failure
}
private data class SlotInfo(
val logicalSlotId: Int,
val isRemovable: Boolean,
val hasMultiplePorts: Boolean,
val hasMultipleSEs: Boolean,
val portId: Int,
val seId: EuiccChannel.SecureElementId,
val eID: String,
val freeSpace: Int,
val imei: String,
val enabledProfileName: String?,
) {
// A synthetic slot ID used to uniquely identify this slot + SE chip in the download process
// We assume we don't have anywhere near 2^16 ports...
val syntheticSlotId: Int = (logicalSlotId shl 16) + seId.id
}
val intrinsicChannelName: String?,
)
private var loaded = false
@ -65,6 +57,25 @@ class DownloadWizardSlotSelectFragment : DownloadWizardActivity.DownloadWizardSt
override fun createPrevFragment(): DownloadWizardActivity.DownloadWizardStepFragment? = null
override fun beforeNext() {
super.beforeNext()
if (adapter.selected.freeSpace < LOW_NVRAM_THRESHOLD) {
val activity = requireActivity()
AlertDialog.Builder(requireContext()).apply {
setTitle(R.string.profile_download_low_nvram_title)
setMessage(R.string.profile_download_low_nvram_message)
setCancelable(true)
setPositiveButton(android.R.string.ok, null)
setNegativeButton(android.R.string.cancel) { _, _ ->
activity.finish()
}
show()
}
}
}
override fun onCreateView(
inflater: LayoutInflater,
container: ViewGroup?,
@ -75,12 +86,7 @@ class DownloadWizardSlotSelectFragment : DownloadWizardActivity.DownloadWizardSt
recyclerView.adapter = adapter
recyclerView.layoutManager =
LinearLayoutManager(view.context, LinearLayoutManager.VERTICAL, false)
recyclerView.addItemDecoration(
DividerItemDecoration(
requireContext(),
LinearLayoutManager.VERTICAL
)
)
recyclerView.addItemDecoration(DividerItemDecoration(requireContext(), LinearLayoutManager.VERTICAL))
return view
}
@ -92,41 +98,37 @@ class DownloadWizardSlotSelectFragment : DownloadWizardActivity.DownloadWizardSt
}
@SuppressLint("NotifyDataSetChanged", "MissingPermission")
@OptIn(kotlinx.coroutines.FlowPreview::class)
private suspend fun init() {
ensureEuiccChannelManager()
showProgressBar(-1)
val slots = euiccChannelManager.flowAllOpenEuiccPorts().flatMapConcat { (slotId, portId) ->
euiccChannelManager.flowEuiccSecureElements(slotId, portId).map { seId ->
euiccChannelManager.withEuiccChannel(slotId, portId, seId) { channel ->
SlotInfo(
channel.logicalSlotId,
channel.port.card.isRemovable,
channel.port.card.ports.size > 1,
channel.hasMultipleSE,
channel.portId,
channel.seId,
channel.lpa.eID,
channel.lpa.euiccInfo2?.freeNvram ?: 0,
try {
telephonyManager.getImei(channel.logicalSlotId) ?: ""
} catch (e: Exception) {
""
},
channel.lpa.profiles.enabled?.displayName,
)
}
val slots = euiccChannelManager.flowAllOpenEuiccPorts().map { (slotId, portId) ->
euiccChannelManager.withEuiccChannel(slotId, portId) { channel ->
SlotInfo(
channel.logicalSlotId,
channel.port.card.isRemovable,
channel.port.card.ports.size > 1,
channel.portId,
channel.lpa.eID,
channel.lpa.euiccInfo2?.freeNvram ?: 0,
try {
telephonyManager.getImei(channel.logicalSlotId) ?: ""
} catch (e: Exception) {
""
},
channel.lpa.profiles.enabled?.displayName,
channel.intrinsicChannelName,
)
}
}.toList().sortedBy { it.syntheticSlotId }
}.toList().sortedBy { it.logicalSlotId }
adapter.slots = slots
// Ensure we always have a selected slot by default
val selectedIdx = slots.indexOfFirst { it.syntheticSlotId == state.selectedSyntheticSlotId }
val selectedIdx = slots.indexOfFirst { it.logicalSlotId == state.selectedLogicalSlot }
adapter.currentSelectedIdx = if (selectedIdx > 0) {
selectedIdx
} else {
if (slots.isNotEmpty()) {
state.selectedSyntheticSlotId = slots[0].syntheticSlotId
state.selectedLogicalSlot = slots[0].logicalSlotId
}
0
}
@ -166,8 +168,7 @@ class DownloadWizardSlotSelectFragment : DownloadWizardActivity.DownloadWizardSt
adapter.notifyItemChanged(lastIdx)
adapter.notifyItemChanged(curIdx)
// Selected index isn't logical slot ID directly, needs a conversion
state.selectedSyntheticSlotId =
adapter.slots[adapter.currentSelectedIdx].syntheticSlotId
state.selectedLogicalSlot = adapter.slots[adapter.currentSelectedIdx].logicalSlotId
state.imei = adapter.slots[adapter.currentSelectedIdx].imei
}
@ -186,22 +187,12 @@ class DownloadWizardSlotSelectFragment : DownloadWizardActivity.DownloadWizardSt
}
title.text = if (item.logicalSlotId == EuiccChannelManager.USB_CHANNEL_ID) {
if (item.hasMultipleSEs) {
root.context.getString(R.string.channel_name_format_usb_se, item.seId.id)
} else {
root.context.getString(R.string.channel_name_format_usb)
}
} else if (item.hasMultipleSEs) {
appContainer.customizableTextProvider.formatNonUsbChannelNameWithSeId(
item.logicalSlotId,
item.seId
)
item.intrinsicChannelName ?: root.context.getString(R.string.usb)
} else {
appContainer.customizableTextProvider.formatNonUsbChannelName(item.logicalSlotId)
appContainer.customizableTextProvider.formatInternalChannelName(item.logicalSlotId)
}
eID.text = item.eID
activeProfile.text = item.enabledProfileName
?: root.context.getString(R.string.profile_no_enabled_profile)
activeProfile.text = item.enabledProfileName ?: root.context.getString(R.string.unknown)
freeSpace.text = formatFreeSpace(item.freeSpace)
checkBox.isChecked = adapter.currentSelectedIdx == idx
}
@ -215,8 +206,7 @@ class DownloadWizardSlotSelectFragment : DownloadWizardActivity.DownloadWizardSt
get() = slots[currentSelectedIdx]
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): SlotItemHolder {
val root = LayoutInflater.from(parent.context)
.inflate(R.layout.download_slot_item, parent, false)
val root = LayoutInflater.from(parent.context).inflate(R.layout.download_slot_item, parent, false)
return SlotItemHolder(root)
}
@ -226,4 +216,4 @@ class DownloadWizardSlotSelectFragment : DownloadWizardActivity.DownloadWizardSt
holder.bind(slots[position], position)
}
}
}
}

View file

@ -1,176 +0,0 @@
package im.angry.openeuicc.ui.wizard
import androidx.annotation.StringRes
import im.angry.openeuicc.common.R
import net.typeblog.lpac_jni.LocalProfileAssistant
import org.json.JSONObject
import java.net.NoRouteToHostException
import java.net.PortUnreachableException
import java.net.SocketException
import java.net.SocketTimeoutException
import java.net.UnknownHostException
import javax.net.ssl.SSLException
enum class SimplifiedErrorMessages(
@StringRes val titleResId: Int,
@StringRes val suggestResId: Int?
) {
ICCIDAlreadyInUse(
R.string.download_wizard_error_iccid_already,
R.string.download_wizard_error_suggest_profile_installed
),
InsufficientMemory(
R.string.download_wizard_error_insufficient_memory,
R.string.download_wizard_error_suggest_insufficient_memory
),
UnsupportedProfile(
R.string.download_wizard_error_unsupported_profile,
null
),
CardInternalError(
R.string.download_wizard_error_card_internal_error,
null
),
EIDNotSupported(
R.string.download_wizard_error_eid_not_supported,
R.string.download_wizard_error_suggest_contact_carrier
),
EIDMismatch(
R.string.download_wizard_error_eid_mismatch,
R.string.download_wizard_error_suggest_contact_reissue
),
UnreleasedProfile(
R.string.download_wizard_error_profile_unreleased,
R.string.download_wizard_error_suggest_contact_reissue
),
UnavailableProfile(
R.string.download_wizard_error_profile_unavailable,
R.string.download_wizard_error_suggest_contact_carrier
),
MatchingIDRefused(
R.string.download_wizard_error_matching_id_refused,
R.string.download_wizard_error_suggest_contact_carrier
),
ProfileRetriesExceeded(
R.string.download_wizard_error_profile_retries_exceeded,
R.string.download_wizard_error_suggest_contact_carrier
),
ConfirmationCodeMissing(
R.string.download_wizard_error_confirmation_code_missing,
R.string.download_wizard_error_suggest_contact_carrier
),
ConfirmationCodeRefused(
R.string.download_wizard_error_confirmation_code_refused,
R.string.download_wizard_error_suggest_contact_carrier
),
ConfirmationCodeRetriesExceeded(
R.string.download_wizard_error_confirmation_code_retries_exceeded,
R.string.download_wizard_error_suggest_contact_carrier
),
ProfileExpired(
R.string.download_wizard_error_profile_expired,
R.string.download_wizard_error_suggest_contact_carrier
),
UnknownHost(
R.string.download_wizard_error_unknown_hostname,
null
),
NetworkUnreachable(
R.string.download_wizard_error_network_unreachable,
R.string.download_wizard_error_suggest_network_unreachable
),
TLSError(
R.string.download_wizard_error_tls_certificate,
null
);
companion object {
private val httpErrors = buildMap {
// @formatter:off
// Stage: InitiateAuthentication
put("8.8.1" to "3.8", UnknownHost) // Invalid SM-DP+ Address.
put("8.8.2" to "3.1", UnsupportedProfile) // None of the proposed Public Key Identifiers is supported by the SM-DP+.
put("8.8.3" to "3.1", UnsupportedProfile) // The SVN indicated by the eUICC is not supported by the SM-DP+.
put("8.8.4" to "3.7", UnsupportedProfile) // The SM-DP+ has no CERT.DPAuth.ECDSA signed by one of the GSMA CI Public Key supported by the eUICC.
// Stage: AuthenticateClient
put("8.1" to "4.8", InsufficientMemory) // eUICC does not have sufficient space for this Profile.
put("8.1.1" to "2.1", EIDNotSupported) // eUICC does not support the EID.
put("8.1.1" to "3.8", EIDMismatch) // EID doesn't match the expected value.
put("8.1.2" to "6.1", UnsupportedProfile) // EUM Certificate is invalid.
put("8.1.2" to "6.3", UnsupportedProfile) // EUM Certificate has expired.
put("8.1.3" to "6.1", UnsupportedProfile) // eUICC Certificate is invalid.
put("8.1.3" to "6.3", UnsupportedProfile) // eUICC Certificate has expired.
put("8.2" to "1.2", UnreleasedProfile) // Profile has not yet been released.
put("8.2.5" to "4.3", UnavailableProfile) // No eligible Profile for this eUICC/Device.
put("8.2.6" to "3.8", MatchingIDRefused) // MatchingID (AC_Token or EventID) is refused.
put("8.8" to "4.2", EIDNotSupported) // eUICC is not supported by the SM-DP+.
put("8.8.5" to "6.4", ProfileRetriesExceeded) // The maximum number of retries for the Profile download order has been exceeded.
put("8.10.1" to "3.9", UnsupportedProfile) // The RSP session identified by the TransactionID is unknown.
put("8.11.1" to "3.9", UnsupportedProfile) // Unknown CI Public Key.
// Stage: GetBoundProfilePackage
put("8.2" to "3.7", UnavailableProfile) // BPP is not available for a new binding.
put("8.2.7" to "2.2", ConfirmationCodeMissing) // Confirmation Code is missing.
put("8.2.7" to "3.8", ConfirmationCodeRefused) // Confirmation Code is refused.
put("8.2.7" to "6.4", ConfirmationCodeRetriesExceeded) // The maximum number of retries for the Confirmation Code has been exceeded.
// Stage: AuthenticateClient, GetBoundProfilePackage
put("8.1" to "6.1", UnsupportedProfile) // eUICC Signature is invalid.
put("8.8.5" to "4.10", ProfileExpired) // The Download order has expired.
// @formatter:on
}
fun fromDownloadError(exc: LocalProfileAssistant.ProfileDownloadException) = when {
exc.lpaErrorReason != "ES10B_ERROR_REASON_UNDEFINED" -> fromLPAErrorReason(exc.lpaErrorReason)
exc.lastHttpResponse?.rcode == 200 -> fromHTTPResponse(exc.lastHttpResponse!!)
exc.lastHttpException != null -> fromHTTPException(exc.lastHttpException!!)
exc.lastApduResponse != null -> fromAPDUResponse(exc.lastApduResponse!!)
else -> null
}
private fun fromLPAErrorReason(reason: String) = when (reason) {
"ES10B_ERROR_REASON_UNSUPPORTED_CRT_VALUES" -> UnsupportedProfile
"ES10B_ERROR_REASON_UNSUPPORTED_REMOTE_OPERATION_TYPE" -> UnsupportedProfile
"ES10B_ERROR_REASON_UNSUPPORTED_PROFILE_CLASS" -> UnsupportedProfile
"ES10B_ERROR_REASON_INSTALL_FAILED_DUE_TO_ICCID_ALREADY_EXISTS_ON_EUICC" -> ICCIDAlreadyInUse
"ES10B_ERROR_REASON_INSTALL_FAILED_DUE_TO_INSUFFICIENT_MEMORY_FOR_PROFILE" -> InsufficientMemory
"ES10B_ERROR_REASON_INSTALL_FAILED_DUE_TO_INTERRUPTION" -> CardInternalError
"ES10B_ERROR_REASON_INSTALL_FAILED_DUE_TO_PE_PROCESSING_ERROR" -> CardInternalError
else -> null
}
private fun fromHTTPResponse(httpResponse: net.typeblog.lpac_jni.HttpInterface.HttpResponse): SimplifiedErrorMessages? {
if (httpResponse.data.first().toInt() != '{'.code) return null
val response = JSONObject(httpResponse.data.decodeToString())
val statusCodeData = response.optJSONObject("header")
?.optJSONObject("functionExecutionStatus")
?.optJSONObject("statusCodeData")
?: return null
val subjectCode = statusCodeData.optString("subjectCode")
val reasonCode = statusCodeData.optString("reasonCode")
return httpErrors[subjectCode to reasonCode]
}
private fun fromHTTPException(exc: Exception) = when (exc) {
is SSLException -> TLSError
is UnknownHostException -> UnknownHost
is NoRouteToHostException -> NetworkUnreachable
is PortUnreachableException -> NetworkUnreachable
is SocketTimeoutException -> NetworkUnreachable
is SocketException -> exc.message
?.contains("Connection reset", ignoreCase = true)
?.let { if (it) NetworkUnreachable else null }
else -> null
}
private fun fromAPDUResponse(resp: ByteArray): SimplifiedErrorMessages? {
val isSuccess = resp.size >= 2 &&
resp[resp.size - 2] == 0x90.toByte() &&
resp[resp.size - 1] == 0x00.toByte()
if (isSuccess) return null
return CardInternalError
}
}
}

View file

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

View file

@ -1,6 +1,5 @@
package im.angry.openeuicc.util
import android.os.Build
import android.os.Bundle
import androidx.fragment.app.Fragment
import im.angry.openeuicc.core.EuiccChannel
@ -10,7 +9,6 @@ import im.angry.openeuicc.ui.BaseEuiccAccessActivity
private const val FIELD_SLOT_ID = "slotId"
private const val FIELD_PORT_ID = "portId"
private const val FIELD_SE_ID = "seId"
interface EuiccChannelFragmentMarker : OpenEuiccContextMarker
@ -19,19 +17,12 @@ private typealias BundleSetter = Bundle.() -> Unit
// We must use extension functions because there is no way to add bounds to the type of "self"
// in the definition of an interface, so the only way is to limit where the extension functions
// can be applied.
fun <T> newInstanceEuicc(
clazz: Class<T>,
slotId: Int,
portId: Int,
seId: EuiccChannel.SecureElementId,
addArguments: BundleSetter = {}
): T
where T : Fragment, T : EuiccChannelFragmentMarker =
fun <T> newInstanceEuicc(clazz: Class<T>, slotId: Int, portId: Int, addArguments: BundleSetter = {}): T
where T : Fragment, T : EuiccChannelFragmentMarker =
clazz.getDeclaredConstructor().newInstance().apply {
arguments = Bundle()
arguments!!.putInt(FIELD_SLOT_ID, slotId)
arguments!!.putInt(FIELD_PORT_ID, portId)
arguments!!.putParcelable(FIELD_SE_ID, seId)
arguments!!.addArguments()
}
@ -39,43 +30,31 @@ fun <T> newInstanceEuicc(
// `channel` requires that the channel actually exists in EuiccChannelManager, which is
// not always the case during operations such as switching
val <T> T.slotId: Int
where T : Fragment, T : EuiccChannelFragmentMarker
where T : Fragment, T : EuiccChannelFragmentMarker
get() = requireArguments().getInt(FIELD_SLOT_ID)
val <T> T.portId: Int
where T : Fragment, T : EuiccChannelFragmentMarker
where T : Fragment, T : EuiccChannelFragmentMarker
get() = requireArguments().getInt(FIELD_PORT_ID)
val <T> T.seId: EuiccChannel.SecureElementId
where T : Fragment, T : EuiccChannelFragmentMarker
get() =
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
requireArguments().getParcelable(
FIELD_SE_ID,
EuiccChannel.SecureElementId::class.java
)!!
} else {
@Suppress("DEPRECATION")
requireArguments().getParcelable(FIELD_SE_ID)!!
}
val <T> T.isUsb: Boolean
where T : Fragment, T : EuiccChannelFragmentMarker
where T : Fragment, T : EuiccChannelFragmentMarker
get() = slotId == EuiccChannelManager.USB_CHANNEL_ID
private fun <T> T.requireEuiccActivity(): BaseEuiccAccessActivity
where T : Fragment, T : OpenEuiccContextMarker =
where T : Fragment, T : OpenEuiccContextMarker =
requireActivity() as BaseEuiccAccessActivity
val <T> T.euiccChannelManager: EuiccChannelManager
where T : Fragment, T : OpenEuiccContextMarker
where T : Fragment, T : OpenEuiccContextMarker
get() = requireEuiccActivity().euiccChannelManager
val <T> T.euiccChannelManagerService: EuiccChannelManagerService
where T : Fragment, T : OpenEuiccContextMarker
where T : Fragment, T : OpenEuiccContextMarker
get() = requireEuiccActivity().euiccChannelManagerService
suspend fun <T, R> T.withEuiccChannel(fn: suspend (EuiccChannel) -> R): R
where T : Fragment, T : EuiccChannelFragmentMarker {
where T : Fragment, T : EuiccChannelFragmentMarker {
ensureEuiccChannelManager()
return euiccChannelManager.withEuiccChannel(slotId, portId, seId, fn)
return euiccChannelManager.withEuiccChannel(slotId, portId, fn)
}
suspend fun <T> T.ensureEuiccChannelManager() where T : Fragment, T : OpenEuiccContextMarker =
@ -90,4 +69,4 @@ fun <T> T.notifyEuiccProfilesChanged() where T : Fragment {
interface EuiccProfilesChangedListener {
fun onEuiccProfilesChanged()
}
}

View file

@ -7,16 +7,16 @@ data class LPAString(
val confirmationCodeRequired: Boolean,
) {
companion object {
fun parse(input: CharSequence): LPAString {
var token = input
if (token.startsWith("LPA:", ignoreCase = true)) token = token.drop(4)
val components = token.split('$').map { it.trim().ifBlank { null } }
require(components.getOrNull(0) == "1") { "Invalid AC_Format" }
fun parse(input: String): LPAString {
val components = input.removePrefix("LPA:").split('$')
if (components.size < 2 || components[0] != "1") {
throw IllegalArgumentException("Invalid activation code format")
}
return LPAString(
requireNotNull(components.getOrNull(1)) { "SM-DP+ is required" },
components.getOrNull(2),
components.getOrNull(3),
components.getOrNull(4) == "1"
address = components[1].trim(),
matchingId = components.getOrNull(2)?.trim()?.ifBlank { null },
oid = components.getOrNull(3)?.trim()?.ifBlank { null },
confirmationCodeRequired = components.getOrNull(4)?.trim() == "1"
)
}
}
@ -31,4 +31,4 @@ data class LPAString(
)
return parts.joinToString("$").trimEnd('$')
}
}
}

View file

@ -5,8 +5,6 @@ import im.angry.openeuicc.core.EuiccChannel
import im.angry.openeuicc.core.EuiccChannelManager
import net.typeblog.lpac_jni.LocalProfileAssistant
import net.typeblog.lpac_jni.LocalProfileInfo
import net.typeblog.lpac_jni.ProfileClass
import net.typeblog.lpac_jni.ProfileDownloadState
const val TAG = "LPAUtils"
@ -18,21 +16,11 @@ val LocalProfileInfo.isEnabled: Boolean
get() = state == LocalProfileInfo.State.Enabled
val List<LocalProfileInfo>.operational: List<LocalProfileInfo>
get() = filter { it.profileClass == ProfileClass.Operational || it.isEnabled }
get() = filter { it.profileClass == LocalProfileInfo.Clazz.Operational }
val List<LocalProfileInfo>.enabled: LocalProfileInfo?
get() = find { it.isEnabled }
val ProfileDownloadState.downloadProgress: Int
get() = when (this) {
is ProfileDownloadState.Preparing -> 0
is ProfileDownloadState.Connecting -> 20
is ProfileDownloadState.Authenticating -> 40
is ProfileDownloadState.ConfirmingDownload -> 50
is ProfileDownloadState.Downloading -> 60
is ProfileDownloadState.Finalizing -> 80
}
val List<EuiccChannel>.hasMultipleChips: Boolean
get() = distinctBy { it.slotId }.size > 1
@ -91,10 +79,9 @@ fun LocalProfileAssistant.disableActiveProfileKeepIccId(refresh: Boolean): Strin
suspend inline fun EuiccChannelManager.beginTrackedOperation(
slotId: Int,
portId: Int,
seId: EuiccChannel.SecureElementId,
op: () -> Boolean
) {
val latestSeq = withEuiccChannel(slotId, portId, seId) { channel ->
val latestSeq = withEuiccChannel(slotId, portId) { channel ->
channel.lpa.notifications.firstOrNull()?.seqNumber
?: 0
}
@ -104,7 +91,7 @@ suspend inline fun EuiccChannelManager.beginTrackedOperation(
try {
// Note that the exact instance of "channel" might have changed here if reconnected;
// this is why we need to use two distinct calls to withEuiccChannel()
withEuiccChannel(slotId, portId, seId) { channel ->
withEuiccChannel(slotId, portId) { channel ->
channel.lpa.notifications.filter { it.seqNumber > latestSeq }.forEach {
Log.d(TAG, "Handling notification $it")
channel.lpa.handleNotification(it.seqNumber)
@ -116,4 +103,4 @@ suspend inline fun EuiccChannelManager.beginTrackedOperation(
}
}
Log.d(TAG, "Operation complete")
}
}

View file

@ -5,14 +5,11 @@ import androidx.datastore.core.DataStore
import androidx.datastore.preferences.core.Preferences
import androidx.datastore.preferences.core.booleanPreferencesKey
import androidx.datastore.preferences.core.edit
import androidx.datastore.preferences.core.intPreferencesKey
import androidx.datastore.preferences.core.stringPreferencesKey
import androidx.datastore.preferences.preferencesDataStore
import androidx.fragment.app.Fragment
import im.angry.openeuicc.OpenEuiccApplication
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.map
import java.util.Base64
private val Context.dataStore: DataStore<Preferences> by preferencesDataStore(name = "prefs")
@ -31,52 +28,12 @@ internal object PreferenceKeys {
// ---- Advanced ----
val DISABLE_SAFEGUARD_REMOVABLE_ESIM = booleanPreferencesKey("disable_safeguard_removable_esim")
val VERBOSE_LOGGING = booleanPreferencesKey("verbose_logging")
val FORCE_TPDU_MODE = booleanPreferencesKey("force_tpdu_mode")
// ---- Developer Options ----
val DEVELOPER_OPTIONS_ENABLED = booleanPreferencesKey("developer_options_enabled")
val REFRESH_AFTER_SWITCH = booleanPreferencesKey("refresh_after_switch")
val UNFILTERED_PROFILE_LIST = booleanPreferencesKey("unfiltered_profile_list")
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"
internal object PreferenceConstants {
val DEFAULT_AID_LIST = """
# One AID per line. Comment lines start with #.
# Refs: <https://euicc-manual.osmocom.org/docs/lpa/applet-id-oem/>
# eUICC standard
# Even if this AID is deleted here, it will still be attempted as the last resort.
$EUICC_DEFAULT_ISDR_AID
# eSIM.me
A0000005591010000000008900000300
# 5ber.eSIM
A0000005591010FFFFFFFF8900050500
# Xesim
A0000005591010FFFFFFFF8900000177
# LinksField
A000000559104C696E6B736669656C64
# ESTKme SE0
# For multi-SE eSTK.me products, this will always be attempted even if removed from the list
${ESTKme.ESTK_SE0_AID.encodeHex()}
# ESTKme SE1
# For multi-SE eSTK.me products, this will always be attempted even if removed from the list
${ESTKme.ESTK_SE1_AID.encodeHex()}
# ESTKme AUX (deprecated, use SE0 instead)
A06573746B6D65FFFFFFFF4953442D52
""".trimIndent()
val EUICC_MEMORY_RESET = booleanPreferencesKey("euicc_memory_reset")
}
open class PreferenceRepository(private val context: Context) {
@ -89,54 +46,29 @@ open class PreferenceRepository(private val context: Context) {
// ---- Advanced ----
val disableSafeguardFlow = bindFlow(PreferenceKeys.DISABLE_SAFEGUARD_REMOVABLE_ESIM, false)
val verboseLoggingFlow = bindFlow(PreferenceKeys.VERBOSE_LOGGING, false)
val forceTpduModeFlow = bindFlow(PreferenceKeys.FORCE_TPDU_MODE, false)
// ---- Developer Options ----
val refreshAfterSwitchFlow = bindFlow(PreferenceKeys.REFRESH_AFTER_SWITCH, true)
val developerOptionsEnabledFlow = bindFlow(PreferenceKeys.DEVELOPER_OPTIONS_ENABLED, false)
val unfilteredProfileListFlow = bindFlow(PreferenceKeys.UNFILTERED_PROFILE_LIST, false)
val ignoreTLSCertificateFlow = bindFlow(PreferenceKeys.IGNORE_TLS_CERTIFICATE, false)
val isdrAidListFlow = bindFlow(
PreferenceKeys.ISDR_AID_LIST,
PreferenceConstants.DEFAULT_AID_LIST,
{ Base64.getEncoder().encodeToString(it.encodeToByteArray()) },
{ Base64.getDecoder().decode(it).decodeToString() })
val es10xMssFlow = bindFlow(PreferenceKeys.ES10X_MSS, 63)
val httpProxyFlow = bindFlow(PreferenceKeys.HTTP_PROXY, "")
val euiccMemoryResetFlow = bindFlow(PreferenceKeys.EUICC_MEMORY_RESET, false)
protected fun <T> bindFlow(
key: Preferences.Key<T>,
defaultValue: T,
encoder: (T) -> T = { it },
decoder: (T) -> T = { it }
): PreferenceFlowWrapper<T> =
PreferenceFlowWrapper(context, key, defaultValue, encoder, decoder)
protected fun <T> bindFlow(key: Preferences.Key<T>, defaultValue: T): PreferenceFlowWrapper<T> =
PreferenceFlowWrapper(context, key, defaultValue)
}
class PreferenceFlowWrapper<T> private constructor(
private val context: Context,
private val key: Preferences.Key<T>,
inner: Flow<T>,
private val encoder: (T) -> T,
inner: Flow<T>
) : Flow<T> by inner {
internal constructor(
context: Context,
key: Preferences.Key<T>,
defaultValue: T,
encoder: (T) -> T,
decoder: (T) -> T
) : this(
internal constructor(context: Context, key: Preferences.Key<T>, defaultValue: T) : this(
context,
key,
context.dataStore.data.map { it[key]?.let(decoder) ?: defaultValue },
encoder
context.dataStore.data.map { it[key] ?: defaultValue }
)
suspend fun updatePreference(value: T) {
context.dataStore.edit { it[key] = encoder(value) }
context.dataStore.edit { it[key] = value }
}
suspend fun removePreference() {
context.dataStore.edit { it.remove(key) }
}
}
}

View file

@ -1,7 +1,7 @@
package im.angry.openeuicc.util
fun String.decodeHex(): ByteArray {
require(length % 2 == 0) { "Must have an even length" }
check(length % 2 == 0) { "Must have an even length" }
val decodedLength = length / 2
val out = ByteArray(decodedLength)
@ -29,25 +29,72 @@ fun formatFreeSpace(size: Int): String =
"$size B"
}
/**
* Decode a list of potential ISDR AIDs, one per line. Lines starting with '#' are ignored.
* If none is found, at least EUICC_DEFAULT_ISDR_AID is returned.
* If EUICC_DEFAULT_ISDR_AID is not contained in the list, it is always appended as the last
* element.
*/
fun parseIsdrAidList(s: String): List<ByteArray> {
val ret = s.split('\n')
.asSequence()
.map(String::trim)
.filter { !it.startsWith('#') }
.map(String::trim)
.filter(String::isNotEmpty)
.mapNotNull { runCatching(it::decodeHex).getOrNull() }
.toList()
fun String.prettyPrintJson(): String {
val ret = StringBuilder()
var inQuotes = false
var escaped = false
val indentSymbolStack = ArrayDeque<Char>()
return if (!ret.any { it.contentEquals(EUICC_DEFAULT_ISDR_AID.decodeHex()) }) {
ret + EUICC_DEFAULT_ISDR_AID.decodeHex()
} else {
ret
val addNewLine = {
ret.append('\n')
repeat(indentSymbolStack.size) {
ret.append('\t')
}
}
}
var lastChar = ' '
for (c in this) {
when {
!inQuotes && (c == '{' || c == '[') -> {
ret.append(c)
indentSymbolStack.addLast(c)
addNewLine()
}
!inQuotes && (c == '}' || c == ']') -> {
indentSymbolStack.removeLast()
if (lastChar != ',') {
addNewLine()
}
ret.append(c)
}
!inQuotes && c == ',' -> {
ret.append(c)
addNewLine()
}
!inQuotes && c == ':' -> {
ret.append(c)
ret.append(' ')
}
inQuotes && c == '\\' -> {
ret.append(c)
escaped = true
continue
}
!escaped && c == '"' -> {
ret.append(c)
inQuotes = !inQuotes
}
!inQuotes && c == ' ' -> {
// Do nothing -- we ignore spaces outside of quotes by default
// This is to ensure predictable formatting
}
else -> ret.append(c)
}
if (escaped) {
escaped = false
}
lastChar = c
}
return ret.toString()
}

View file

@ -1,9 +1,16 @@
package im.angry.openeuicc.util
import android.content.Context
import android.os.Build
import android.se.omapi.Reader
import android.se.omapi.SEService
import android.telephony.TelephonyManager
import kotlinx.coroutines.runBlocking
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
import kotlin.coroutines.resume
import kotlin.coroutines.resumeWithException
import kotlin.coroutines.suspendCoroutine
val TelephonyManager.activeModemCountCompat: Int
get() = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
@ -48,11 +55,16 @@ interface UiccPortInfoCompat {
val logicalSlotIndex: Int
}
data class FakeUiccCardInfoCompat(override val physicalSlotIndex: Int) : UiccCardInfoCompat {
override val ports: Collection<UiccPortInfoCompat> = listOf(FakeUiccPortInfoCompat(this))
data class FakeUiccCardInfoCompat(
override val physicalSlotIndex: Int,
): UiccCardInfoCompat {
override val ports: Collection<UiccPortInfoCompat> =
listOf(FakeUiccPortInfoCompat(this))
}
data class FakeUiccPortInfoCompat(override val card: UiccCardInfoCompat) : UiccPortInfoCompat {
data class FakeUiccPortInfoCompat(
override val card: UiccCardInfoCompat
): UiccPortInfoCompat {
override val portIndex: Int = 0
override val logicalSlotIndex: Int = card.physicalSlotIndex
}
}

View file

@ -11,7 +11,6 @@ import androidx.activity.result.ActivityResultCaller
import androidx.activity.result.contract.ActivityResultContracts
import androidx.appcompat.app.AlertDialog
import androidx.appcompat.app.AppCompatActivity
import androidx.core.graphics.Insets
import androidx.core.view.ViewCompat
import androidx.core.view.WindowInsetsCompat
import androidx.core.view.updateLayoutParams
@ -36,71 +35,46 @@ fun DialogFragment.setWidthPercent(percentage: Int) {
}
/**
* A handler function for `setupRootViewSystemBarInsets`, which is intended to set up
* insets for the top toolbar, in the case where the activity contains a toolbar with the default
* ID `R.id.toolbar`, and a spacer `R.id.toolbar_spacer` for status bar background.
* Call this method (in onActivityCreated or later)
* to make the dialog near-full screen.
*/
fun AppCompatActivity.activityToolbarInsetHandler(insets: Insets) {
val toolbar = requireViewById<View>(R.id.toolbar)
toolbar.apply {
updateLayoutParams<ViewGroup.MarginLayoutParams> {
topMargin = insets.top
}
updatePadding(insets.left, paddingTop, insets.right, paddingBottom)
}
requireViewById<View>(R.id.toolbar_spacer).updateLayoutParams {
height = toolbar.top
}
fun DialogFragment.setFullScreen() {
dialog?.window?.setLayout(ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.WRAP_CONTENT)
}
/**
* A handler function for `setupRootViewSystemBarInsets`, which is intended to set up
* left, right, and bottom padding for a "main view", usually a RecyclerView or a ScrollView.
*
* It ignores top paddings because that should be handled by the toolbar handler for the activity.
* See above.
*/
fun mainViewPaddingInsetHandler(v: View): (Insets) -> Unit = { insets ->
// Disable clipToPadding to make sure content actually display
if (v is ViewGroup) {
v.clipToPadding = false
}
v.updatePadding(insets.left, v.paddingTop, insets.right, insets.bottom)
}
/**
* A wrapper for `ViewCompat.setOnApplyWindowInsetsListener`, which should only be called
* on a root view of a certain component. For activities, this should usually be `window.decorView.rootView`,
* and for Fragments this should be the outermost layer of view it inflated during creation.
*
* This function takes in an array of handler functions, and is expected to only ever be called
* on views belonging to the same hierarchy. All sibling views should be handled from the array of
* handler functions, rather than a separate call to this function OR `ViewCompat.setOnApplyWindowInsetsListener`.
*
* The reason this function exists is that on some versions of Android, the dispatch of window inset
* events is completely broken. If an inset event is handled by a view, it will never be seen by any of
* its siblings. By wrapping this function and restricting its use to only the "main" view hierarchy and
* handling all sibling views using our own handler functions, we work around that issue.
*
* Note that this function by default returns `WindowInsetCompat.CONSUME`, which will prevent the event from
* being dispatched further to child views. This may be a problem for activities that act as fragment hosts.
* In that case, please set `consume = false` in order for the event to propagate.
*/
fun setupRootViewSystemBarInsets(rootView: View, handlers: Array<(Insets) -> Unit>, consume: Boolean = true) {
ViewCompat.setOnApplyWindowInsetsListener(rootView) { _, insets ->
fun AppCompatActivity.setupToolbarInsets() {
val spacer = requireViewById<View>(R.id.toolbar_spacer)
ViewCompat.setOnApplyWindowInsetsListener(requireViewById(R.id.toolbar)) { v, insets ->
val bars = insets.getInsets(
WindowInsetsCompat.Type.systemBars()
or WindowInsetsCompat.Type.displayCutout()
or WindowInsetsCompat.Type.displayCutout()
)
handlers.forEach { it(bars) }
if (consume) {
WindowInsetsCompat.CONSUMED
} else {
insets
v.updateLayoutParams<ViewGroup.MarginLayoutParams> {
topMargin = bars.top
}
v.updatePadding(bars.left, v.paddingTop, bars.right, v.paddingBottom)
spacer.updateLayoutParams {
height = v.top
}
WindowInsetsCompat.CONSUMED
}
}
fun setupRootViewInsets(view: ViewGroup) {
// Disable clipToPadding to make sure content actually display
view.clipToPadding = false
ViewCompat.setOnApplyWindowInsetsListener(view) { v, insets ->
val bars = insets.getInsets(
WindowInsetsCompat.Type.systemBars()
or WindowInsetsCompat.Type.displayCutout()
)
v.updatePadding(bars.left, v.paddingTop, bars.right, bars.bottom)
WindowInsetsCompat.CONSUMED
}
}
@ -126,25 +100,25 @@ fun <T : ActivityResultCaller> T.setupLogSaving(
}
}
val intent = Intent(Intent.ACTION_SEND).apply {
type = "text/plain"
clipData = ClipData.newUri(context.contentResolver, lastFileName, uri)
putExtra(Intent.EXTRA_TITLE, lastFileName)
putExtra(Intent.EXTRA_STREAM, uri)
addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION)
}
AlertDialog.Builder(context).apply {
setMessage(R.string.logs_saved_message)
setNegativeButton(R.string.no) { _, _ -> }
setPositiveButton(R.string.yes) { _, _ ->
val intent = Intent(Intent.ACTION_SEND).apply {
type = "text/plain"
clipData = ClipData.newUri(context.contentResolver, lastFileName, uri)
putExtra(Intent.EXTRA_TITLE, lastFileName)
putExtra(Intent.EXTRA_STREAM, uri)
addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION)
}
AlertDialog.Builder(context, R.style.AlertDialogTheme)
.setMessage(R.string.logs_saved_message)
.setNegativeButton(android.R.string.cancel) { _, _ -> }
.setPositiveButton(android.R.string.ok) { _, _ ->
context.startActivity(Intent.createChooser(intent, null))
}
.show()
}.show()
}
return {
lastFileName = getLogFileName()
launchSaveIntent.launch(lastFileName)
}
}
}

View file

@ -1,7 +1,6 @@
package im.angry.openeuicc.util
import android.content.Context
import android.content.pm.PackageInfo
import android.content.pm.PackageManager
import android.graphics.Bitmap
import android.se.omapi.SEService
@ -18,18 +17,19 @@ import kotlinx.coroutines.runBlocking
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
import kotlinx.coroutines.withContext
import kotlin.RuntimeException
import kotlin.coroutines.resume
import kotlin.coroutines.resumeWithException
import kotlin.coroutines.suspendCoroutine
val Context.packageInfo: PackageInfo
get() = packageManager.getPackageInfo(packageName, /* flags = */ 0)!!
val Context.selfAppVersion: String
get() = packageInfo.versionName!!
val Context.selfAppVersionCode: Long
get() = packageInfo.longVersionCode
get() =
try {
val pInfo = packageManager.getPackageInfo(packageName, 0)
pInfo.versionName!!
} catch (e: PackageManager.NameNotFoundException) {
throw RuntimeException(e)
}
suspend fun readSelfLog(lines: Int = 2048): String = withContext(Dispatchers.IO) {
try {
@ -54,9 +54,6 @@ interface OpenEuiccContextMarker {
val appContainer: AppContainer
get() = openEuiccApplication.appContainer
val preferenceRepository: PreferenceRepository
get() = appContainer.preferenceRepository
val telephonyManager: TelephonyManager
get() = appContainer.telephonyManager
}
@ -96,12 +93,13 @@ inline fun <T> Bitmap.use(f: (Bitmap) -> T): T =
recycle()
}
fun decodeQrFromBitmap(bmp: Bitmap): String? = runCatching {
val pixels = IntArray(bmp.width * bmp.height)
bmp.getPixels(pixels, 0, bmp.width, 0, 0, bmp.width, bmp.height)
fun decodeQrFromBitmap(bmp: Bitmap): String? =
runCatching {
val pixels = IntArray(bmp.width * bmp.height)
bmp.getPixels(pixels, 0, bmp.width, 0, 0, bmp.width, bmp.height)
val luminanceSource = RGBLuminanceSource(bmp.width, bmp.height, pixels)
val binaryBmp = BinaryBitmap(HybridBinarizer(luminanceSource))
val luminanceSource = RGBLuminanceSource(bmp.width, bmp.height, pixels)
val binaryBmp = BinaryBitmap(HybridBinarizer(luminanceSource))
QRCodeReader().decode(binaryBmp).text
}.getOrNull()
QRCodeReader().decode(binaryBmp).text
}.getOrNull()

View file

@ -1,59 +1,50 @@
package im.angry.openeuicc.util
import android.util.Log
import im.angry.openeuicc.core.ApduInterfaceAtrProvider
import im.angry.openeuicc.core.EuiccChannel
import net.typeblog.lpac_jni.Version
data class EuiccVendorInfo(
val skuName: String? = null,
val serialNumber: String? = null,
val firmwareVersion: String? = null,
val skuName: String?,
val serialNumber: String?,
val bootloaderVersion: String?,
val firmwareVersion: String?,
)
private val EUICC_VENDORS: Array<EuiccVendor> = arrayOf(ESTKme(), SIMLink())
private val EUICC_VENDORS: Array<EuiccVendor> = arrayOf(EstkMe(), SimLink())
fun EuiccChannel.tryParseEuiccVendorInfo(): EuiccVendorInfo? =
EUICC_VENDORS.firstNotNullOfOrNull { it.tryParseEuiccVendorInfo(this) }
fun EuiccChannel.tryParseEuiccVendorInfo(): EuiccVendorInfo? {
EUICC_VENDORS.forEach { vendor ->
vendor.tryParseEuiccVendorInfo(this@tryParseEuiccVendorInfo)?.let {
return it
}
}
fun EuiccChannel.queryVendorAidListTransformation(aidList: List<ByteArray>): Pair<List<ByteArray>, VendorAidDecider>? =
EUICC_VENDORS.firstNotNullOfOrNull { it.transformAidListIfNeeded(this, aidList) }
fun interface VendorAidDecider {
/**
* Given a list of already opened AIDs, should we still attempt to open the next?
*/
fun shouldOpenMore(openedAids: List<ByteArray>, nextAid: ByteArray): Boolean
return null
}
interface EuiccVendor {
fun tryParseEuiccVendorInfo(channel: EuiccChannel): EuiccVendorInfo?
/**
* Removable eSIM products from some vendors may prefer a vendor-specific list of AIDs or
* a specific ordering. For example, multi-SE products from eSTK.me might prefer us trying
* SE0 and SE1 AIDs first instead of the generic GSMA ISD-R AID. This method is intended
* to implement these vendor-specific cases.
*
* This method is called on an already opened `EuiccChannel`. If the method returns a non-null
* value, the channel will be closed and the process that attempts to open all channels will
* be restarted from the beginning. The method will not be called again for the same chip,
* but it should still ensure idempotency when called with an already-transformed input.
*
* The second return value of this method is used to decide when we should stop attempting more
* AIDs from the list.
*/
fun transformAidListIfNeeded(
referenceChannel: EuiccChannel,
aidList: List<ByteArray>
): Pair<List<ByteArray>, VendorAidDecider>? = null
}
class ESTKme : EuiccVendor {
private class EstkMe : EuiccVendor {
companion object {
private val PRODUCT_AID = "A06573746B6D65FFFFFFFFFFFF6D6774".decodeHex()
private val PRODUCT_ATR_FPR = "estk.me".encodeToByteArray()
}
val ESTK_SE0_AID = "A06573746B6D65FFFF4953442D522030".decodeHex()
val ESTK_SE1_AID = "A06573746B6D65FFFF4953442D522031".decodeHex()
private fun checkAtr(channel: EuiccChannel): Boolean {
val iface = channel.apduInterface
if (iface !is ApduInterfaceAtrProvider) return false
val atr = iface.atr ?: return false
for (index in atr.indices) {
if (atr.size - index < PRODUCT_ATR_FPR.size) break
if (atr.sliceArray(index until index + PRODUCT_ATR_FPR.size)
.contentEquals(PRODUCT_ATR_FPR)
) return true
}
return false
}
private fun decodeAsn1String(b: ByteArray): String? {
@ -63,6 +54,8 @@ class ESTKme : EuiccVendor {
}
override fun tryParseEuiccVendorInfo(channel: EuiccChannel): EuiccVendorInfo? {
if (!checkAtr(channel)) return null
val iface = channel.apduInterface
return try {
iface.withLogicalChannel(PRODUCT_AID) { transmit ->
@ -71,11 +64,8 @@ class ESTKme : EuiccVendor {
EuiccVendorInfo(
skuName = invoke(0x03),
serialNumber = invoke(0x00),
firmwareVersion = run {
val bl = invoke(0x01) // bootloader version
val fw = invoke(0x02) // firmware version
if (bl == null || fw == null) null else "$bl-$fw"
},
bootloaderVersion = invoke(0x01),
firmwareVersion = invoke(0x02),
)
}
} catch (e: Exception) {
@ -83,38 +73,9 @@ class ESTKme : EuiccVendor {
null
}
}
override fun transformAidListIfNeeded(
referenceChannel: EuiccChannel,
aidList: List<ByteArray>
): Pair<List<ByteArray>, VendorAidDecider>? {
try {
referenceChannel.apduInterface.withLogicalChannel(PRODUCT_AID) {}
} catch (_: Exception) {
// Not eSTK!
return null
}
// If we get here, this is eSTK, and we need to rearrange aidList such that:
// 1. SE0 and SE1 AIDs are _always_ included in the list
// 2. SE0 and SE1 AIDs are always sorted at the beginning of the list
val expected = listOf(ESTK_SE0_AID, ESTK_SE1_AID, *aidList.filter {
!it.contentEquals(ESTK_SE0_AID) && !it.contentEquals(ESTK_SE1_AID)
}.toTypedArray())
return if (expected == aidList) {
null
} else {
Pair(expected, VendorAidDecider { openedAids, nextAid ->
// Don't open any more channels if we have reached the GSMA default AID and at least 1
// eSTK AID has been opened (note that above we re-sorted them to the top of the list)
!(openedAids.isNotEmpty() && nextAid.contentEquals(EUICC_DEFAULT_ISDR_AID.decodeHex()))
})
}
}
}
class SIMLink : EuiccVendor {
private class SimLink : EuiccVendor {
companion object {
private val EID_PATTERN = Regex("^89044045(84|21)67274948")
}
@ -125,7 +86,6 @@ class SIMLink : EuiccVendor {
if (version == null || EID_PATTERN.find(eid, 0) == null) return null
val versionName = when {
// @formatter:off
version >= Version(37, 4, 3) -> "v3.2 (beta 1)"
version >= Version(37, 1, 41) -> "v3.1 (beta 1)"
version >= Version(36, 18, 5) -> "v3 (final)"
version >= Version(36, 17, 39) -> "v3 (beta)"
@ -142,6 +102,11 @@ class SIMLink : EuiccVendor {
"9eSIM $versionName"
}
return EuiccVendorInfo(skuName = skuName)
return EuiccVendorInfo(
skuName = skuName,
serialNumber = null,
bootloaderVersion = null,
firmwareVersion = null
)
}
}
}

View file

@ -1,6 +1,6 @@
<?xml version="1.0" encoding="utf-8"?>
<translate xmlns:android="http://schemas.android.com/apk/res/android"
android:duration="@android:integer/config_shortAnimTime"
android:fromXDelta="-100%"
android:interpolator="@android:anim/decelerate_interpolator"
android:fromXDelta="-100%"
android:toXDelta="0%" />

View file

@ -1,6 +1,6 @@
<?xml version="1.0" encoding="utf-8"?>
<translate xmlns:android="http://schemas.android.com/apk/res/android"
android:duration="@android:integer/config_shortAnimTime"
android:fromXDelta="100%"
android:interpolator="@android:anim/decelerate_interpolator"
android:fromXDelta="100%"
android:toXDelta="0%" />

View file

@ -1,6 +1,6 @@
<!-- res/anim/slide_out.xml -->
<translate xmlns:android="http://schemas.android.com/apk/res/android"
android:duration="@android:integer/config_shortAnimTime"
android:fromXDelta="0%"
android:interpolator="@android:anim/decelerate_interpolator"
android:fromXDelta="0%"
android:toXDelta="-100%" />

View file

@ -1,6 +1,6 @@
<!-- res/anim/slide_out.xml -->
<translate xmlns:android="http://schemas.android.com/apk/res/android"
android:duration="@android:integer/config_shortAnimTime"
android:fromXDelta="0%"
android:interpolator="@android:anim/decelerate_interpolator"
android:fromXDelta="0%"
android:toXDelta="100%" />

View file

@ -1,5 +1,7 @@
<?xml version="1.0" encoding="utf-8"?>
<shape xmlns:android="http://schemas.android.com/apk/res/android">
<solid android:color="?attr/colorSurface" />
<corners android:radius="?attr/dialogCornerRadius" />
</shape>
<solid
android:color="?attr/colorSurface"/>
<corners
android:radius="?attr/dialogCornerRadius" />
</shape>

View file

@ -1,10 +1,5 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:tint="#FFFFFF"
android:viewportWidth="24"
android:viewportHeight="24">
<path
android:fillColor="@android:color/white"
android:pathData="M19,13h-6v6h-2v-6H5v-2h6V5h2v6h6v2z" />
<vector android:height="24dp" android:tint="#FFFFFF"
android:viewportHeight="24" android:viewportWidth="24"
android:width="24dp" xmlns:android="http://schemas.android.com/apk/res/android">
<path android:fillColor="@android:color/white" android:pathData="M19,13h-6v6h-2v-6H5v-2h6V5h2v6h6v2z"/>
</vector>

View file

@ -1,10 +1,10 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:tint="?attr/colorControlNormal"
android:viewportWidth="24"
android:viewportHeight="24">
<path
android:fillColor="@android:color/white"
android:pathData="M9,16.17L4.83,12l-1.42,1.41L9,19 21,7l-1.41,-1.41z" />
android:viewportHeight="24"
android:tint="?attr/colorControlNormal">
<path
android:fillColor="@android:color/white"
android:pathData="M9,16.17L4.83,12l-1.42,1.41L9,19 21,7l-1.41,-1.41z"/>
</vector>

View file

@ -1,10 +1,5 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:tint="?attr/colorControlNormal"
android:viewportWidth="24"
android:viewportHeight="24">
<path
android:fillColor="@android:color/white"
android:pathData="M16.59,7.58L10,14.17l-3.59,-3.58L5,12l5,5 8,-8zM12,2C6.48,2 2,6.48 2,12s4.48,10 10,10 10,-4.48 10,-10S17.52,2 12,2zM12,20c-4.42,0 -8,-3.58 -8,-8s3.58,-8 8,-8 8,3.58 8,8 -3.58,8 -8,8z" />
<vector android:height="24dp" android:tint="?attr/colorControlNormal"
android:viewportHeight="24" android:viewportWidth="24"
android:width="24dp" xmlns:android="http://schemas.android.com/apk/res/android">
<path android:fillColor="@android:color/white" android:pathData="M16.59,7.58L10,14.17l-3.59,-3.58L5,12l5,5 8,-8zM12,2C6.48,2 2,6.48 2,12s4.48,10 10,10 10,-4.48 10,-10S17.52,2 12,2zM12,20c-4.42,0 -8,-3.58 -8,-8s3.58,-8 8,-8 8,3.58 8,8 -3.58,8 -8,8z"/>
</vector>

View file

@ -1,12 +1,5 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:tint="#000000"
android:viewportWidth="24"
android:viewportHeight="24">
<path
android:fillColor="@android:color/white"
android:pathData="M15.41,7.41L14,6l-6,6 6,6 1.41,-1.41L10.83,12z" />
<vector xmlns:android="http://schemas.android.com/apk/res/android" android:height="24dp" android:tint="#000000" android:viewportHeight="24" android:viewportWidth="24" android:width="24dp">
<path android:fillColor="@android:color/white" android:pathData="M15.41,7.41L14,6l-6,6 6,6 1.41,-1.41L10.83,12z"/>
</vector>

View file

@ -1,12 +1,5 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:tint="#000000"
android:viewportWidth="24"
android:viewportHeight="24">
<path
android:fillColor="@android:color/white"
android:pathData="M10,6L8.59,7.41 13.17,12l-4.58,4.59L10,18l6,-6z" />
<vector xmlns:android="http://schemas.android.com/apk/res/android" android:height="24dp" android:tint="#000000" android:viewportHeight="24" android:viewportWidth="24" android:width="24dp">
<path android:fillColor="@android:color/white" android:pathData="M10,6L8.59,7.41 13.17,12l-4.58,4.59L10,18l6,-6z"/>
</vector>

View file

@ -1,12 +1,5 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:tint="#000000"
android:viewportWidth="24"
android:viewportHeight="24">
<path
android:fillColor="@android:color/white"
android:pathData="M3,17.25V21h3.75L17.81,9.94l-3.75,-3.75L3,17.25zM20.71,7.04c0.39,-0.39 0.39,-1.02 0,-1.41l-2.34,-2.34c-0.39,-0.39 -1.02,-0.39 -1.41,0l-1.83,1.83 3.75,3.75 1.83,-1.83z" />
<vector xmlns:android="http://schemas.android.com/apk/res/android" android:height="24dp" android:tint="#000000" android:viewportHeight="24" android:viewportWidth="24" android:width="24dp">
<path android:fillColor="@android:color/white" android:pathData="M3,17.25V21h3.75L17.81,9.94l-3.75,-3.75L3,17.25zM20.71,7.04c0.39,-0.39 0.39,-1.02 0,-1.41l-2.34,-2.34c-0.39,-0.39 -1.02,-0.39 -1.41,0l-1.83,1.83 3.75,3.75 1.83,-1.83z"/>
</vector>

View file

@ -1,10 +1,5 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:tint="?attr/colorControlNormal"
android:viewportWidth="24"
android:viewportHeight="24">
<path
android:fillColor="@android:color/white"
android:pathData="M11,15h2v2h-2zM11,7h2v6h-2zM11.99,2C6.47,2 2,6.48 2,12s4.47,10 9.99,10C17.52,22 22,17.52 22,12S17.52,2 11.99,2zM12,20c-4.42,0 -8,-3.58 -8,-8s3.58,-8 8,-8 8,3.58 8,8 -3.58,8 -8,8z" />
<vector android:height="24dp" android:tint="?attr/colorControlNormal"
android:viewportHeight="24" android:viewportWidth="24"
android:width="24dp" xmlns:android="http://schemas.android.com/apk/res/android">
<path android:fillColor="@android:color/white" android:pathData="M11,15h2v2h-2zM11,7h2v6h-2zM11.99,2C6.47,2 2,6.48 2,12s4.47,10 9.99,10C17.52,22 22,17.52 22,12S17.52,2 11.99,2zM12,20c-4.42,0 -8,-3.58 -8,-8s3.58,-8 8,-8 8,3.58 8,8 -3.58,8 -8,8z"/>
</vector>

View file

@ -1,10 +1,5 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:tint="?attr/colorControlNormal"
android:viewportWidth="24"
android:viewportHeight="24">
<path
android:fillColor="@android:color/white"
android:pathData="M22,16L22,4c0,-1.1 -0.9,-2 -2,-2L8,2c-1.1,0 -2,0.9 -2,2v12c0,1.1 0.9,2 2,2h12c1.1,0 2,-0.9 2,-2zM11,12l2.03,2.71L16,11l4,5L8,16l3,-4zM2,6v14c0,1.1 0.9,2 2,2h14v-2L4,20L4,6L2,6z" />
<vector android:height="24dp" android:tint="?attr/colorControlNormal"
android:viewportHeight="24" android:viewportWidth="24"
android:width="24dp" xmlns:android="http://schemas.android.com/apk/res/android">
<path android:fillColor="@android:color/white" android:pathData="M22,16L22,4c0,-1.1 -0.9,-2 -2,-2L8,2c-1.1,0 -2,0.9 -2,2v12c0,1.1 0.9,2 2,2h12c1.1,0 2,-0.9 2,-2zM11,12l2.03,2.71L16,11l4,5L8,16l3,-4zM2,6v14c0,1.1 0.9,2 2,2h14v-2L4,20L4,6L2,6z"/>
</vector>

View file

@ -1,11 +1,5 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:autoMirrored="true"
android:tint="?attr/colorControlNormal"
android:viewportWidth="24"
android:viewportHeight="24">
<path
android:fillColor="@android:color/white"
android:pathData="M12,2C6.48,2 2,6.48 2,12s4.48,10 10,10 10,-4.48 10,-10S17.52,2 12,2zM13,19h-2v-2h2v2zM15.07,11.25l-0.9,0.92C13.45,12.9 13,13.5 13,15h-2v-0.5c0,-1.1 0.45,-2.1 1.17,-2.83l1.24,-1.26c0.37,-0.36 0.59,-0.86 0.59,-1.41 0,-1.1 -0.9,-2 -2,-2s-2,0.9 -2,2L8,9c0,-2.21 1.79,-4 4,-4s4,1.79 4,4c0,0.88 -0.36,1.68 -0.93,2.25z" />
<vector android:autoMirrored="true" android:height="24dp"
android:tint="?attr/colorControlNormal" android:viewportHeight="24"
android:viewportWidth="24" android:width="24dp" xmlns:android="http://schemas.android.com/apk/res/android">
<path android:fillColor="@android:color/white" android:pathData="M12,2C6.48,2 2,6.48 2,12s4.48,10 10,10 10,-4.48 10,-10S17.52,2 12,2zM13,19h-2v-2h2v2zM15.07,11.25l-0.9,0.92C13.45,12.9 13,13.5 13,15h-2v-0.5c0,-1.1 0.45,-2.1 1.17,-2.83l1.24,-1.26c0.37,-0.36 0.59,-0.86 0.59,-1.41 0,-1.1 -0.9,-2 -2,-2s-2,0.9 -2,2L8,9c0,-2.21 1.79,-4 4,-4s4,1.79 4,4c0,0.88 -0.36,1.68 -0.93,2.25z"/>
</vector>

View file

@ -1,10 +1,10 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:tint="?attr/colorControlNormal"
android:viewportWidth="24"
android:viewportHeight="24">
<path
android:fillColor="@android:color/white"
android:pathData="M12,8c1.1,0 2,-0.9 2,-2s-0.9,-2 -2,-2 -2,0.9 -2,2 0.9,2 2,2zM12,10c-1.1,0 -2,0.9 -2,2s0.9,2 2,2 2,-0.9 2,-2 -0.9,-2 -2,-2zM12,16c-1.1,0 -2,0.9 -2,2s0.9,2 2,2 2,-0.9 2,-2 -0.9,-2 -2,-2z" />
android:viewportHeight="24"
android:tint="?attr/colorControlNormal">
<path
android:fillColor="@android:color/white"
android:pathData="M12,8c1.1,0 2,-0.9 2,-2s-0.9,-2 -2,-2 -2,0.9 -2,2 0.9,2 2,2zM12,10c-1.1,0 -2,0.9 -2,2s0.9,2 2,2 2,-0.9 2,-2 -0.9,-2 -2,-2zM12,16c-1.1,0 -2,0.9 -2,2s0.9,2 2,2 2,-0.9 2,-2 -0.9,-2 -2,-2z"/>
</vector>

View file

@ -1,16 +1,7 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:tint="?attr/colorControlNormal"
android:viewportWidth="24"
android:viewportHeight="24">
<path
android:fillColor="@android:color/white"
android:pathData="M5,5h2v3h10V5h2v6h2V5c0,-1.1 -0.9,-2 -2,-2h-4.18C14.4,1.84 13.3,1 12,1S9.6,1.84 9.18,3H5C3.9,3 3,3.9 3,5v14c0,1.1 0.9,2 2,2h5v-2H5V5zM12,3c0.55,0 1,0.45 1,1s-0.45,1 -1,1s-1,-0.45 -1,-1S11.45,3 12,3z" />
<path
android:fillColor="@android:color/white"
android:pathData="M18.01,13l-1.42,1.41l1.58,1.58l-6.17,0l0,2l6.17,0l-1.58,1.59l1.42,1.41l3.99,-4z" />
<vector xmlns:android="http://schemas.android.com/apk/res/android" android:height="24dp" android:tint="?attr/colorControlNormal" android:viewportHeight="24" android:viewportWidth="24" android:width="24dp">
<path android:fillColor="@android:color/white" android:pathData="M5,5h2v3h10V5h2v6h2V5c0,-1.1 -0.9,-2 -2,-2h-4.18C14.4,1.84 13.3,1 12,1S9.6,1.84 9.18,3H5C3.9,3 3,3.9 3,5v14c0,1.1 0.9,2 2,2h5v-2H5V5zM12,3c0.55,0 1,0.45 1,1s-0.45,1 -1,1s-1,-0.45 -1,-1S11.45,3 12,3z"/>
<path android:fillColor="@android:color/white" android:pathData="M18.01,13l-1.42,1.41l1.58,1.58l-6.17,0l0,2l6.17,0l-1.58,1.59l1.42,1.41l3.99,-4z"/>
</vector>

View file

@ -1,10 +1,5 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:tint="?attr/colorControlNormal"
android:viewportWidth="24"
android:viewportHeight="24">
<path
android:fillColor="@android:color/white"
android:pathData="M17.65,6.35C16.2,4.9 14.21,4 12,4c-4.42,0 -7.99,3.58 -7.99,8s3.57,8 7.99,8c3.73,0 6.84,-2.55 7.73,-6h-2.08c-0.82,2.33 -3.04,4 -5.65,4 -3.31,0 -6,-2.69 -6,-6s2.69,-6 6,-6c1.66,0 3.14,0.69 4.22,1.78L13,11h7V4l-2.35,2.35z" />
<vector android:height="24dp" android:tint="?attr/colorControlNormal"
android:viewportHeight="24" android:viewportWidth="24"
android:width="24dp" xmlns:android="http://schemas.android.com/apk/res/android">
<path android:fillColor="@android:color/white" android:pathData="M17.65,6.35C16.2,4.9 14.21,4 12,4c-4.42,0 -7.99,3.58 -7.99,8s3.57,8 7.99,8c3.73,0 6.84,-2.55 7.73,-6h-2.08c-0.82,2.33 -3.04,4 -5.65,4 -3.31,0 -6,-2.69 -6,-6s2.69,-6 6,-6c1.66,0 3.14,0.69 4.22,1.78L13,11h7V4l-2.35,2.35z"/>
</vector>

View file

@ -1,10 +1,5 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:tint="?attr/colorControlNormal"
android:viewportWidth="24"
android:viewportHeight="24">
<path
android:fillColor="@android:color/white"
android:pathData="M21,12.4V7l-4,-4H5C3.89,3 3,3.9 3,5v14c0,1.1 0.89,2 2,2h7.4L21,12.4zM15,15c0,1.66 -1.34,3 -3,3s-3,-1.34 -3,-3s1.34,-3 3,-3S15,13.34 15,15zM6,6h9v4H6V6zM19.99,16.25l1.77,1.77L16.77,23H15v-1.77L19.99,16.25zM23.25,16.51l-0.85,0.85l-1.77,-1.77l0.85,-0.85c0.2,-0.2 0.51,-0.2 0.71,0l1.06,1.06C23.45,16 23.45,16.32 23.25,16.51z" />
<vector android:height="24dp" android:tint="?attr/colorControlNormal"
android:viewportHeight="24" android:viewportWidth="24"
android:width="24dp" xmlns:android="http://schemas.android.com/apk/res/android">
<path android:fillColor="@android:color/white" android:pathData="M21,12.4V7l-4,-4H5C3.89,3 3,3.9 3,5v14c0,1.1 0.89,2 2,2h7.4L21,12.4zM15,15c0,1.66 -1.34,3 -3,3s-3,-1.34 -3,-3s1.34,-3 3,-3S15,13.34 15,15zM6,6h9v4H6V6zM19.99,16.25l1.77,1.77L16.77,23H15v-1.77L19.99,16.25zM23.25,16.51l-0.85,0.85l-1.77,-1.77l0.85,-0.85c0.2,-0.2 0.51,-0.2 0.71,0l1.06,1.06C23.45,16 23.45,16.32 23.25,16.51z"/>
</vector>

View file

@ -1,10 +1,10 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:tint="?attr/colorControlNormal"
android:viewportWidth="24"
android:viewportHeight="24">
<path
android:fillColor="@android:color/white"
android:pathData="M9.5,6.5v3h-3v-3H9.5M11,5H5v6h6V5L11,5zM9.5,14.5v3h-3v-3H9.5M11,13H5v6h6V13L11,13zM17.5,6.5v3h-3v-3H17.5M19,5h-6v6h6V5L19,5zM13,13h1.5v1.5H13V13zM14.5,14.5H16V16h-1.5V14.5zM16,13h1.5v1.5H16V13zM13,16h1.5v1.5H13V16zM14.5,17.5H16V19h-1.5V17.5zM16,16h1.5v1.5H16V16zM17.5,14.5H19V16h-1.5V14.5zM17.5,17.5H19V19h-1.5V17.5zM22,7h-2V4h-3V2h5V7zM22,22v-5h-2v3h-3v2H22zM2,22h5v-2H4v-3H2V22zM2,2v5h2V4h3V2H2z" />
android:viewportHeight="24"
android:tint="?attr/colorControlNormal">
<path
android:fillColor="@android:color/white"
android:pathData="M9.5,6.5v3h-3v-3H9.5M11,5H5v6h6V5L11,5zM9.5,14.5v3h-3v-3H9.5M11,13H5v6h6V13L11,13zM17.5,6.5v3h-3v-3H17.5M19,5h-6v6h6V5L19,5zM13,13h1.5v1.5H13V13zM14.5,14.5H16V16h-1.5V14.5zM16,13h1.5v1.5H16V13zM13,16h1.5v1.5H13V16zM14.5,17.5H16V19h-1.5V17.5zM16,16h1.5v1.5H16V16zM17.5,14.5H19V16h-1.5V14.5zM17.5,17.5H19V19h-1.5V17.5zM22,7h-2V4h-3V2h5V7zM22,22v-5h-2v3h-3v2H22zM2,22h5v-2H4v-3H2V22zM2,2v5h2V4h3V2H2z"/>
</vector>

View file

@ -1,12 +1,5 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:tint="#000000"
android:viewportWidth="24"
android:viewportHeight="24">
<path
android:fillColor="@android:color/white"
android:pathData="M6,19c0,1.1 0.9,2 2,2h8c1.1,0 2,-0.9 2,-2V7H6v12zM19,4h-3.5l-1,-1h-5l-1,1H5v2h14V4z" />
<vector xmlns:android="http://schemas.android.com/apk/res/android" android:height="24dp" android:tint="#000000" android:viewportHeight="24" android:viewportWidth="24" android:width="24dp">
<path android:fillColor="@android:color/white" android:pathData="M6,19c0,1.1 0.9,2 2,2h8c1.1,0 2,-0.9 2,-2V7H6v12zM19,4h-3.5l-1,-1h-5l-1,1H5v2h14V4z"/>
</vector>

View file

@ -1,12 +1,5 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:tint="#000000"
android:viewportWidth="24"
android:viewportHeight="24">
<path
android:fillColor="@android:color/white"
android:pathData="M3,17.25V21h3.75L17.81,9.94l-3.75,-3.75L3,17.25zM20.71,7.04c0.39,-0.39 0.39,-1.02 0,-1.41l-2.34,-2.34c-0.39,-0.39 -1.02,-0.39 -1.41,0l-1.83,1.83 3.75,3.75 1.83,-1.83z" />
<vector xmlns:android="http://schemas.android.com/apk/res/android" android:height="24dp" android:tint="#000000" android:viewportHeight="24" android:viewportWidth="24" android:width="24dp">
<path android:fillColor="@android:color/white" android:pathData="M3,17.25V21h3.75L17.81,9.94l-3.75,-3.75L3,17.25zM20.71,7.04c0.39,-0.39 0.39,-1.02 0,-1.41l-2.34,-2.34c-0.39,-0.39 -1.02,-0.39 -1.41,0l-1.83,1.83 3.75,3.75 1.83,-1.83z"/>