Compare commits
No commits in common. "master" and "master" have entirely different histories.
209 changed files with 2526 additions and 8201 deletions
|
@ -1,7 +1,7 @@
|
||||||
on:
|
on:
|
||||||
push:
|
push:
|
||||||
branches:
|
branches:
|
||||||
- '*'
|
- 'master'
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
build-debug:
|
build-debug:
|
||||||
|
@ -33,23 +33,13 @@ jobs:
|
||||||
uses: https://gitea.angry.im/actions/setup-android@v3
|
uses: https://gitea.angry.im/actions/setup-android@v3
|
||||||
|
|
||||||
- name: Build Debug APKs
|
- name: Build Debug APKs
|
||||||
run: ./gradlew --no-daemon assembleDebug :app:assembleDebugMagiskModuleDir
|
run: ./gradlew --no-daemon assembleDebug
|
||||||
|
|
||||||
- name: Copy Artifacts
|
- name: Upload Artifacts
|
||||||
run: |
|
|
||||||
find . -name 'app*-debug.apk' -exec cp {} . \;
|
|
||||||
cp -r app/build/magisk/debug ./magisk-debug
|
|
||||||
|
|
||||||
- name: Upload APK Artifacts
|
|
||||||
uses: https://gitea.angry.im/actions/upload-artifact@v3
|
uses: https://gitea.angry.im/actions/upload-artifact@v3
|
||||||
with:
|
with:
|
||||||
name: Debug APKs
|
name: Debug APKs
|
||||||
compression-level: 0
|
compression-level: 0
|
||||||
path: app*-debug.apk
|
path: |
|
||||||
|
app-unpriv/build/outputs/apk/debug/app-unpriv-debug.apk
|
||||||
- name: Upload Magisk Artifacts
|
app/build/outputs/apk/debug/app-debug.apk
|
||||||
uses: https://gitea.angry.im/actions/upload-artifact@v3
|
|
||||||
with:
|
|
||||||
name: magisk-debug
|
|
||||||
compression-level: 0
|
|
||||||
path: magisk-debug
|
|
||||||
|
|
27
.gitignore
vendored
27
.gitignore
vendored
|
@ -1,11 +1,20 @@
|
||||||
/.gradle
|
*.iml
|
||||||
/captures
|
.gradle
|
||||||
|
|
||||||
# Configuration files
|
|
||||||
|
|
||||||
/keystore.properties
|
|
||||||
/local.properties
|
/local.properties
|
||||||
|
/keystore.properties
|
||||||
# macOS
|
/.idea/caches
|
||||||
|
/.idea/libraries
|
||||||
|
/.idea/modules.xml
|
||||||
|
/.idea/workspace.xml
|
||||||
|
/.idea/navEditor.xml
|
||||||
|
/.idea/assetWizardSettings.xml
|
||||||
|
/.idea/deploymentTargetDropDown.xml
|
||||||
.DS_Store
|
.DS_Store
|
||||||
|
/build
|
||||||
|
/captures
|
||||||
|
.externalNativeBuild
|
||||||
|
.cxx
|
||||||
|
local.properties
|
||||||
|
/libs/**/build
|
||||||
|
/buildSrc/build
|
||||||
|
/app-deps/libs
|
2
.gitmodules
vendored
2
.gitmodules
vendored
|
@ -1,3 +1,3 @@
|
||||||
[submodule "libs/lpac-jni/src/main/jni/lpac"]
|
[submodule "libs/lpac-jni/src/main/jni/lpac"]
|
||||||
path = libs/lpac-jni/src/main/jni/lpac
|
path = libs/lpac-jni/src/main/jni/lpac
|
||||||
url = https://github.com/estkme-group/lpac.git
|
url = https://github.com/estkme/lpac
|
||||||
|
|
10
.idea/.gitignore
generated
vendored
10
.idea/.gitignore
generated
vendored
|
@ -1,7 +1,3 @@
|
||||||
*
|
# Default ignored files
|
||||||
!/codeStyles/Project.xml
|
/shelf/
|
||||||
!/codeStyles/codeStyleConfig.xml
|
/workspace.xml
|
||||||
!/vcs.xml
|
|
||||||
!/kotlinc.xml
|
|
||||||
!/compiler.xml
|
|
||||||
!/migrations.xml
|
|
||||||
|
|
6
.idea/codeStyles/Project.xml
generated
6
.idea/codeStyles/Project.xml
generated
|
@ -1,8 +1,5 @@
|
||||||
<component name="ProjectCodeStyleConfiguration">
|
<component name="ProjectCodeStyleConfiguration">
|
||||||
<code_scheme name="Project" version="173">
|
<code_scheme name="Project" version="173">
|
||||||
<JetCodeStyleSettings>
|
|
||||||
<option name="CODE_STYLE_DEFAULTS" value="KOTLIN_OFFICIAL" />
|
|
||||||
</JetCodeStyleSettings>
|
|
||||||
<codeStyleSettings language="XML">
|
<codeStyleSettings language="XML">
|
||||||
<option name="FORCE_REARRANGE_MODE" value="1" />
|
<option name="FORCE_REARRANGE_MODE" value="1" />
|
||||||
<indentOptions>
|
<indentOptions>
|
||||||
|
@ -116,8 +113,5 @@
|
||||||
</rules>
|
</rules>
|
||||||
</arrangement>
|
</arrangement>
|
||||||
</codeStyleSettings>
|
</codeStyleSettings>
|
||||||
<codeStyleSettings language="kotlin">
|
|
||||||
<option name="CODE_STYLE_DEFAULTS" value="KOTLIN_OFFICIAL" />
|
|
||||||
</codeStyleSettings>
|
|
||||||
</code_scheme>
|
</code_scheme>
|
||||||
</component>
|
</component>
|
1
.idea/codeStyles/codeStyleConfig.xml
generated
1
.idea/codeStyles/codeStyleConfig.xml
generated
|
@ -1,6 +1,5 @@
|
||||||
<component name="ProjectCodeStyleConfiguration">
|
<component name="ProjectCodeStyleConfiguration">
|
||||||
<state>
|
<state>
|
||||||
<option name="USE_PER_PROJECT_SETTINGS" value="true" />
|
|
||||||
<option name="PREFERRED_PROJECT_CODE_STYLE" value="Default" />
|
<option name="PREFERRED_PROJECT_CODE_STYLE" value="Default" />
|
||||||
</state>
|
</state>
|
||||||
</component>
|
</component>
|
12
.idea/compiler.xml
generated
12
.idea/compiler.xml
generated
|
@ -1,6 +1,16 @@
|
||||||
<?xml version="1.0" encoding="UTF-8"?>
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
<project version="4">
|
<project version="4">
|
||||||
<component name="CompilerConfiguration">
|
<component name="CompilerConfiguration">
|
||||||
<bytecodeTargetLevel target="1.7" />
|
<bytecodeTargetLevel target="1.7">
|
||||||
|
<module name="OpenEUICC.app" target="17" />
|
||||||
|
<module name="OpenEUICC.app-common" target="17" />
|
||||||
|
<module name="OpenEUICC.app-deps" target="17" />
|
||||||
|
<module name="OpenEUICC.app-unpriv" target="17" />
|
||||||
|
<module name="OpenEUICC.buildSrc" target="17" />
|
||||||
|
<module name="OpenEUICC.buildSrc.main" target="17" />
|
||||||
|
<module name="OpenEUICC.buildSrc.test" target="17" />
|
||||||
|
<module name="OpenEUICC.libs.hidden-apis-shim" target="17" />
|
||||||
|
<module name="OpenEUICC.libs.lpac-jni" target="17" />
|
||||||
|
</bytecodeTargetLevel>
|
||||||
</component>
|
</component>
|
||||||
</project>
|
</project>
|
29
.idea/gradle.xml
generated
Normal file
29
.idea/gradle.xml
generated
Normal file
|
@ -0,0 +1,29 @@
|
||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<project version="4">
|
||||||
|
<component name="GradleMigrationSettings" migrationVersion="1" />
|
||||||
|
<component name="GradleSettings">
|
||||||
|
<option name="linkedExternalProjectsSettings">
|
||||||
|
<GradleProjectSettings>
|
||||||
|
<option name="testRunner" value="GRADLE" />
|
||||||
|
<option name="distributionType" value="DEFAULT_WRAPPED" />
|
||||||
|
<option name="externalProjectPath" value="$PROJECT_DIR$" />
|
||||||
|
<option name="gradleHome" value="/usr/share/java/gradle" />
|
||||||
|
<option name="gradleJvm" value="jbr-17" />
|
||||||
|
<option name="modules">
|
||||||
|
<set>
|
||||||
|
<option value="$PROJECT_DIR$" />
|
||||||
|
<option value="$PROJECT_DIR$/app" />
|
||||||
|
<option value="$PROJECT_DIR$/app-common" />
|
||||||
|
<option value="$PROJECT_DIR$/app-deps" />
|
||||||
|
<option value="$PROJECT_DIR$/app-unpriv" />
|
||||||
|
<option value="$PROJECT_DIR$/buildSrc" />
|
||||||
|
<option value="$PROJECT_DIR$/libs" />
|
||||||
|
<option value="$PROJECT_DIR$/libs/hidden-apis-shim" />
|
||||||
|
<option value="$PROJECT_DIR$/libs/hidden-apis-stub" />
|
||||||
|
<option value="$PROJECT_DIR$/libs/lpac-jni" />
|
||||||
|
</set>
|
||||||
|
</option>
|
||||||
|
</GradleProjectSettings>
|
||||||
|
</option>
|
||||||
|
</component>
|
||||||
|
</project>
|
2
.idea/kotlinc.xml
generated
2
.idea/kotlinc.xml
generated
|
@ -1,6 +1,6 @@
|
||||||
<?xml version="1.0" encoding="UTF-8"?>
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
<project version="4">
|
<project version="4">
|
||||||
<component name="KotlinJpsPluginSettings">
|
<component name="KotlinJpsPluginSettings">
|
||||||
<option name="version" value="1.9.24" />
|
<option name="version" value="1.9.20" />
|
||||||
</component>
|
</component>
|
||||||
</project>
|
</project>
|
10
.idea/migrations.xml
generated
10
.idea/migrations.xml
generated
|
@ -1,10 +0,0 @@
|
||||||
<?xml version="1.0" encoding="UTF-8"?>
|
|
||||||
<project version="4">
|
|
||||||
<component name="ProjectMigrations">
|
|
||||||
<option name="MigrateToGradleLocalJavaHome">
|
|
||||||
<set>
|
|
||||||
<option value="$PROJECT_DIR$" />
|
|
||||||
</set>
|
|
||||||
</option>
|
|
||||||
</component>
|
|
||||||
</project>
|
|
25
.idea/misc.xml
generated
Normal file
25
.idea/misc.xml
generated
Normal file
|
@ -0,0 +1,25 @@
|
||||||
|
<project version="4">
|
||||||
|
<component name="DesignSurface">
|
||||||
|
<option name="filePathToZoomLevelMap">
|
||||||
|
<map>
|
||||||
|
<entry key="app/src/main/res/drawable/ic_add.xml" value="0.2015" />
|
||||||
|
<entry key="app/src/main/res/layout/activity_main.xml" value="0.19375" />
|
||||||
|
<entry key="app/src/main/res/layout/euicc_profile.xml" value="0.19375" />
|
||||||
|
<entry key="app/src/main/res/layout/fragment_euicc.xml" value="0.19375" />
|
||||||
|
<entry key="app/src/main/res/layout/fragment_profile_download.xml" value="0.19375" />
|
||||||
|
<entry key="app/src/main/res/layout/fragment_profile_rename.xml" value="0.19375" />
|
||||||
|
<entry key="app/src/main/res/menu/activity_main.xml" value="0.19375" />
|
||||||
|
<entry key="app/src/main/res/menu/activity_main_slot_spinner.xml" value="0.19375" />
|
||||||
|
<entry key="app/src/main/res/menu/fragment_profile_download.xml" value="0.19375" />
|
||||||
|
<entry key="app/src/main/res/menu/fragment_profile_rename.xml" value="0.19375" />
|
||||||
|
<entry key="app/src/main/res/menu/profile_options.xml" value="0.19375" />
|
||||||
|
</map>
|
||||||
|
</option>
|
||||||
|
</component>
|
||||||
|
<component name="ProjectRootManager" version="2" languageLevel="JDK_1_7" project-jdk-name="jbr-17" project-jdk-type="JavaSDK">
|
||||||
|
<output url="file://$PROJECT_DIR$/build/classes" />
|
||||||
|
</component>
|
||||||
|
<component name="ProjectType">
|
||||||
|
<option name="id" value="Android" />
|
||||||
|
</component>
|
||||||
|
</project>
|
0
Android.mk
Normal file
0
Android.mk
Normal file
76
README.md
76
README.md
|
@ -2,46 +2,20 @@
|
||||||
|
|
||||||
A fully free and open-source Local Profile Assistant implementation for Android devices.
|
A fully free and open-source Local Profile Assistant implementation for Android devices.
|
||||||
|
|
||||||
There are two variants of this project, OpenEUICC and EasyEUICC:
|
There are two variants of this project:
|
||||||
|
|
||||||
| | OpenEUICC | EasyEUICC |
|
- 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.
|
||||||
| Privileged | Must be installed as system app | No |
|
- The preferred way to including OpenEUICC in a system image is to [build it along with AOSP](#building-aosp).
|
||||||
| Internal eSIM | Supported | Unsupported |
|
- EasyEUICC: Unprivileged version that can run as a user app.
|
||||||
| External eSIM [^1] | Supported | Supported |
|
- This version supports two modes of operation:
|
||||||
| USB Readers | Supported | Supported |
|
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.
|
||||||
| Requires allowlisting by eSIM | No | Yes -- except USB |
|
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).
|
||||||
| System Integration | Partial [^2] | No |
|
- Prebuilt release-mode EasyEUICC apks can be downloaded [here](https://gitea.angry.im/PeterCxy/OpenEUICC/releases)
|
||||||
| Minimum Android Version | Android 11 or higher | Android 9 or higher |
|
- For removable eSIM chip vendors: to have your chip supported by official builds of EasyEUICC when inserted, include the ARA-M hash `2A2FA878BC7C3354C2CF82935A5945A3EDAE4AFA`
|
||||||
|
|
||||||
[^1]: Also known as "Removable eSIM"
|
Building (Gradle)
|
||||||
[^2]: Carrier Partner API unimplemented yet
|
===
|
||||||
|
|
||||||
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)
|
|
||||||
|
|
||||||
Make sure you have all submodules cloned and updated by running
|
Make sure you have all submodules cloned and updated by running
|
||||||
|
|
||||||
|
@ -74,7 +48,8 @@ For EasyEUICC:
|
||||||
./gradlew :app-unpriv:assembleRelease
|
./gradlew :app-unpriv:assembleRelease
|
||||||
```
|
```
|
||||||
|
|
||||||
# Building (AOSP)
|
Building (AOSP)
|
||||||
|
===
|
||||||
|
|
||||||
There are two ways to include OpenEUICC in your AOSP-based system image:
|
There are two ways to include OpenEUICC in your AOSP-based system image:
|
||||||
|
|
||||||
|
@ -84,22 +59,25 @@ There are two ways to include OpenEUICC in your AOSP-based system image:
|
||||||
- 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.
|
- 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.
|
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.
|
- 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.
|
- You might want to include `privapp_whitelist_im.angry.openeuicc.xml` as well.
|
||||||
|
|
||||||
[`privapp_whitelist_im.angry.openeuicc.xml`]: privapp_whitelist_im.angry.openeuicc.xml "OpenEUICC Privapp Whitelist"
|
FAQs
|
||||||
|
===
|
||||||
|
|
||||||
# FAQs
|
- 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.
|
||||||
|
|
||||||
- Q: Do you provide prebuilt binaries for OpenEUICC? \
|
- 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: 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.
|
- 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.
|
||||||
|
|
||||||
- Q: Can EasyEUICC manage my phone's internal eSIM? \
|
- 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.
|
- 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? \
|
- 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.
|
- 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/`:
|
Everything except `libs/lpac-jni` and `art/`:
|
||||||
|
|
||||||
|
|
|
@ -5,7 +5,7 @@ plugins {
|
||||||
|
|
||||||
android {
|
android {
|
||||||
namespace = "im.angry.openeuicc.common"
|
namespace = "im.angry.openeuicc.common"
|
||||||
compileSdk = 35
|
compileSdk = 34
|
||||||
|
|
||||||
defaultConfig {
|
defaultConfig {
|
||||||
minSdk = 28
|
minSdk = 28
|
||||||
|
|
|
@ -3,15 +3,10 @@
|
||||||
xmlns:android="http://schemas.android.com/apk/res/android"
|
xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
package="im.angry.openeuicc.common">
|
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.READ_PHONE_STATE" />
|
||||||
<uses-permission android:name="android.permission.INTERNET" />
|
<uses-permission android:name="android.permission.INTERNET" />
|
||||||
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
|
|
||||||
<uses-permission android:name="android.permission.WAKE_LOCK" />
|
|
||||||
|
|
||||||
<application
|
<application>
|
||||||
android:enableOnBackInvokedCallback="true"
|
|
||||||
tools:targetApi="tiramisu">
|
|
||||||
<activity
|
<activity
|
||||||
android:name="im.angry.openeuicc.ui.SettingsActivity"
|
android:name="im.angry.openeuicc.ui.SettingsActivity"
|
||||||
android:label="@string/pref_settings" />
|
android:label="@string/pref_settings" />
|
||||||
|
@ -21,41 +16,14 @@
|
||||||
android:label="@string/profile_notifications" />
|
android:label="@string/profile_notifications" />
|
||||||
|
|
||||||
<activity
|
<activity
|
||||||
android:name="im.angry.openeuicc.ui.EuiccInfoActivity"
|
android:name="im.angry.openeuicc.ui.DirectProfileDownloadActivity"
|
||||||
android:label="@string/euicc_info" />
|
android:label="@string/profile_download"
|
||||||
|
android:theme="@style/Theme.AppCompat.Translucent" />
|
||||||
|
|
||||||
<activity
|
<activity
|
||||||
android:name="im.angry.openeuicc.ui.LogsActivity"
|
android:name="im.angry.openeuicc.ui.LogsActivity"
|
||||||
android:label="@string/pref_advanced_logs" />
|
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:label="@string/download_wizard">
|
|
||||||
<intent-filter>
|
|
||||||
<action android:name="android.intent.action.VIEW" />
|
|
||||||
|
|
||||||
<category android:name="android.intent.category.DEFAULT" />
|
|
||||||
<category android:name="android.intent.category.BROWSABLE" />
|
|
||||||
|
|
||||||
<!-- 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$" />
|
|
||||||
</intent-filter>
|
|
||||||
</activity>
|
|
||||||
|
|
||||||
<activity-alias
|
|
||||||
android:exported="true"
|
|
||||||
android:name="im.angry.openeuicc.ui.DirectProfileDownloadActivity"
|
|
||||||
android:targetActivity="im.angry.openeuicc.ui.wizard.DownloadWizardActivity" />
|
|
||||||
|
|
||||||
<activity
|
<activity
|
||||||
android:name="com.journeyapps.barcodescanner.CaptureActivity"
|
android:name="com.journeyapps.barcodescanner.CaptureActivity"
|
||||||
android:screenOrientation="fullSensor"
|
android:screenOrientation="fullSensor"
|
||||||
|
@ -63,7 +31,6 @@
|
||||||
|
|
||||||
<service
|
<service
|
||||||
android:name="im.angry.openeuicc.service.EuiccChannelManagerService"
|
android:name="im.angry.openeuicc.service.EuiccChannelManagerService"
|
||||||
android:foregroundServiceType="shortService"
|
|
||||||
android:exported="false" />
|
android:exported="false" />
|
||||||
</application>
|
</application>
|
||||||
</manifest>
|
</manifest>
|
||||||
|
|
|
@ -1,5 +0,0 @@
|
||||||
package im.angry.openeuicc.core
|
|
||||||
|
|
||||||
interface ApduInterfaceAtrProvider {
|
|
||||||
val atr: ByteArray?
|
|
||||||
}
|
|
|
@ -1,86 +1,59 @@
|
||||||
package im.angry.openeuicc.core
|
package im.angry.openeuicc.core
|
||||||
|
|
||||||
import android.content.Context
|
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.se.omapi.SEService
|
||||||
import android.util.Log
|
import android.util.Log
|
||||||
import im.angry.openeuicc.common.R
|
|
||||||
import im.angry.openeuicc.core.usb.UsbApduInterface
|
import im.angry.openeuicc.core.usb.UsbApduInterface
|
||||||
import im.angry.openeuicc.core.usb.UsbCcidContext
|
import im.angry.openeuicc.core.usb.getIoEndpoints
|
||||||
import im.angry.openeuicc.util.*
|
import im.angry.openeuicc.util.*
|
||||||
import java.lang.IllegalArgumentException
|
import java.lang.IllegalArgumentException
|
||||||
|
|
||||||
open class DefaultEuiccChannelFactory(protected val context: Context) : EuiccChannelFactory {
|
open class DefaultEuiccChannelFactory(protected val context: Context) : EuiccChannelFactory {
|
||||||
private var seService: SEService? = null
|
private var seService: SEService? = null
|
||||||
|
|
||||||
|
private val usbManager by lazy {
|
||||||
|
context.getSystemService(Context.USB_SERVICE) as UsbManager
|
||||||
|
}
|
||||||
|
|
||||||
private suspend fun ensureSEService() {
|
private suspend fun ensureSEService() {
|
||||||
if (seService == null || !seService!!.isConnected) {
|
if (seService == null || !seService!!.isConnected) {
|
||||||
seService = connectSEService(context)
|
seService = connectSEService(context)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
override suspend fun tryOpenEuiccChannel(
|
override suspend fun tryOpenEuiccChannel(port: UiccPortInfoCompat): EuiccChannel? {
|
||||||
port: UiccPortInfoCompat,
|
|
||||||
isdrAid: ByteArray
|
|
||||||
): EuiccChannel? = try {
|
|
||||||
if (port.portIndex != 0) {
|
if (port.portIndex != 0) {
|
||||||
Log.w(
|
Log.w(DefaultEuiccChannelManager.TAG, "OMAPI channel attempted on non-zero portId, this may or may not work.")
|
||||||
DefaultEuiccChannelManager.TAG,
|
|
||||||
"OMAPI channel attempted on non-zero portId, this may or may not work."
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
ensureSEService()
|
ensureSEService()
|
||||||
|
|
||||||
Log.i(
|
Log.i(DefaultEuiccChannelManager.TAG, "Trying OMAPI for physical slot ${port.card.physicalSlotIndex}")
|
||||||
DefaultEuiccChannelManager.TAG,
|
try {
|
||||||
"Trying OMAPI for physical slot ${port.card.physicalSlotIndex}"
|
return EuiccChannel(port, OmapiApduInterface(seService!!, port))
|
||||||
)
|
} catch (e: IllegalArgumentException) {
|
||||||
EuiccChannelImpl(
|
|
||||||
context.getString(R.string.channel_type_omapi),
|
|
||||||
port,
|
|
||||||
intrinsicChannelName = null,
|
|
||||||
OmapiApduInterface(
|
|
||||||
seService!!,
|
|
||||||
port,
|
|
||||||
context.preferenceRepository.verboseLoggingFlow
|
|
||||||
),
|
|
||||||
isdrAid,
|
|
||||||
context.preferenceRepository.verboseLoggingFlow,
|
|
||||||
context.preferenceRepository.ignoreTLSCertificateFlow,
|
|
||||||
context.preferenceRepository.es10xMssFlow,
|
|
||||||
)
|
|
||||||
} catch (_: IllegalArgumentException) {
|
|
||||||
// Failed
|
// Failed
|
||||||
Log.w(
|
Log.w(
|
||||||
DefaultEuiccChannelManager.TAG,
|
DefaultEuiccChannelManager.TAG,
|
||||||
"OMAPI APDU interface unavailable for physical slot ${port.card.physicalSlotIndex} with ISD-R AID: ${isdrAid.encodeHex()}."
|
"OMAPI APDU interface unavailable for physical slot ${port.card.physicalSlotIndex}."
|
||||||
)
|
)
|
||||||
null
|
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun tryOpenUsbEuiccChannel(
|
return null
|
||||||
ccidCtx: UsbCcidContext,
|
}
|
||||||
isdrAid: ByteArray
|
|
||||||
): EuiccChannel? = try {
|
override fun tryOpenUsbEuiccChannel(usbDevice: UsbDevice, usbInterface: UsbInterface): EuiccChannel? {
|
||||||
EuiccChannelImpl(
|
val (bulkIn, bulkOut) = usbInterface.getIoEndpoints()
|
||||||
context.getString(R.string.channel_type_usb),
|
if (bulkIn == null || bulkOut == null) return null
|
||||||
|
val conn = usbManager.openDevice(usbDevice) ?: return null
|
||||||
|
if (!conn.claimInterface(usbInterface, true)) return null
|
||||||
|
return EuiccChannel(
|
||||||
FakeUiccPortInfoCompat(FakeUiccCardInfoCompat(EuiccChannelManager.USB_CHANNEL_ID)),
|
FakeUiccPortInfoCompat(FakeUiccCardInfoCompat(EuiccChannelManager.USB_CHANNEL_ID)),
|
||||||
intrinsicChannelName = ccidCtx.productName,
|
UsbApduInterface(conn, bulkIn, bulkOut)
|
||||||
UsbApduInterface(
|
|
||||||
ccidCtx
|
|
||||||
),
|
|
||||||
isdrAid,
|
|
||||||
context.preferenceRepository.verboseLoggingFlow,
|
|
||||||
context.preferenceRepository.ignoreTLSCertificateFlow,
|
|
||||||
context.preferenceRepository.es10xMssFlow,
|
|
||||||
)
|
)
|
||||||
} catch (_: IllegalArgumentException) {
|
|
||||||
// Failed
|
|
||||||
Log.w(
|
|
||||||
DefaultEuiccChannelManager.TAG,
|
|
||||||
"USB APDU interface unavailable for ISD-R AID: ${isdrAid.encodeHex()}."
|
|
||||||
)
|
|
||||||
null
|
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun cleanup() {
|
override fun cleanup() {
|
||||||
|
|
|
@ -5,18 +5,12 @@ import android.hardware.usb.UsbDevice
|
||||||
import android.hardware.usb.UsbManager
|
import android.hardware.usb.UsbManager
|
||||||
import android.telephony.SubscriptionManager
|
import android.telephony.SubscriptionManager
|
||||||
import android.util.Log
|
import android.util.Log
|
||||||
import im.angry.openeuicc.core.usb.UsbCcidContext
|
import im.angry.openeuicc.core.usb.getSmartCardInterface
|
||||||
import im.angry.openeuicc.core.usb.smartCard
|
|
||||||
import im.angry.openeuicc.core.usb.interfaces
|
|
||||||
import im.angry.openeuicc.di.AppContainer
|
import im.angry.openeuicc.di.AppContainer
|
||||||
import im.angry.openeuicc.util.*
|
import im.angry.openeuicc.util.*
|
||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
import kotlinx.coroutines.delay
|
import kotlinx.coroutines.delay
|
||||||
import kotlinx.coroutines.flow.Flow
|
import kotlinx.coroutines.runBlocking
|
||||||
import kotlinx.coroutines.flow.first
|
|
||||||
import kotlinx.coroutines.flow.flow
|
|
||||||
import kotlinx.coroutines.flow.flowOn
|
|
||||||
import kotlinx.coroutines.flow.merge
|
|
||||||
import kotlinx.coroutines.sync.Mutex
|
import kotlinx.coroutines.sync.Mutex
|
||||||
import kotlinx.coroutines.sync.withLock
|
import kotlinx.coroutines.sync.withLock
|
||||||
import kotlinx.coroutines.withContext
|
import kotlinx.coroutines.withContext
|
||||||
|
@ -51,24 +45,6 @@ open class DefaultEuiccChannelManager(
|
||||||
protected open val uiccCards: Collection<UiccCardInfoCompat>
|
protected open val uiccCards: Collection<UiccCardInfoCompat>
|
||||||
get() = (0..<tm.activeModemCountCompat).map { FakeUiccCardInfoCompat(it) }
|
get() = (0..<tm.activeModemCountCompat).map { FakeUiccCardInfoCompat(it) }
|
||||||
|
|
||||||
private suspend inline fun tryOpenChannelFirstValidAid(openFn: (ByteArray) -> EuiccChannel?): EuiccChannel? {
|
|
||||||
val isdrAidList =
|
|
||||||
parseIsdrAidList(appContainer.preferenceRepository.isdrAidListFlow.first())
|
|
||||||
|
|
||||||
return isdrAidList.firstNotNullOfOrNull {
|
|
||||||
Log.i(TAG, "Opening channel, trying ISDR AID ${it.encodeHex()}")
|
|
||||||
|
|
||||||
openFn(it)?.let { channel ->
|
|
||||||
if (channel.valid) {
|
|
||||||
channel
|
|
||||||
} else {
|
|
||||||
channel.close()
|
|
||||||
null
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private suspend fun tryOpenEuiccChannel(port: UiccPortInfoCompat): EuiccChannel? {
|
private suspend fun tryOpenEuiccChannel(port: UiccPortInfoCompat): EuiccChannel? {
|
||||||
lock.withLock {
|
lock.withLock {
|
||||||
if (port.card.physicalSlotIndex == EuiccChannelManager.USB_CHANNEL_ID) {
|
if (port.card.physicalSlotIndex == EuiccChannelManager.USB_CHANNEL_ID) {
|
||||||
|
@ -96,10 +72,9 @@ open class DefaultEuiccChannelManager(
|
||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
|
|
||||||
val channel =
|
val channel = euiccChannelFactory.tryOpenEuiccChannel(port) ?: return null
|
||||||
tryOpenChannelFirstValidAid { euiccChannelFactory.tryOpenEuiccChannel(port, it) }
|
|
||||||
|
|
||||||
if (channel != null) {
|
if (channel.valid) {
|
||||||
channelCache.add(channel)
|
channelCache.add(channel)
|
||||||
return channel
|
return channel
|
||||||
} else {
|
} else {
|
||||||
|
@ -107,12 +82,14 @@ open class DefaultEuiccChannelManager(
|
||||||
TAG,
|
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."
|
"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
|
return null
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
protected suspend fun findEuiccChannelByLogicalSlot(logicalSlotId: Int): EuiccChannel? =
|
override fun findEuiccChannelBySlotBlocking(logicalSlotId: Int): EuiccChannel? =
|
||||||
|
runBlocking {
|
||||||
withContext(Dispatchers.IO) {
|
withContext(Dispatchers.IO) {
|
||||||
if (logicalSlotId == EuiccChannelManager.USB_CHANNEL_ID) {
|
if (logicalSlotId == EuiccChannelManager.USB_CHANNEL_ID) {
|
||||||
return@withContext usbChannel
|
return@withContext usbChannel
|
||||||
|
@ -128,8 +105,27 @@ open class DefaultEuiccChannelManager(
|
||||||
|
|
||||||
null
|
null
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private suspend fun findAllEuiccChannelsByPhysicalSlot(physicalSlotId: Int): List<EuiccChannel>? {
|
override fun findEuiccChannelByPhysicalSlotBlocking(physicalSlotId: Int): EuiccChannel? =
|
||||||
|
runBlocking {
|
||||||
|
withContext(Dispatchers.IO) {
|
||||||
|
if (physicalSlotId == EuiccChannelManager.USB_CHANNEL_ID) {
|
||||||
|
return@withContext usbChannel
|
||||||
|
}
|
||||||
|
|
||||||
|
for (card in uiccCards) {
|
||||||
|
if (card.physicalSlotIndex != physicalSlotId) continue
|
||||||
|
for (port in card.ports) {
|
||||||
|
tryOpenEuiccChannel(port)?.let { return@withContext it }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override suspend fun findAllEuiccChannelsByPhysicalSlot(physicalSlotId: Int): List<EuiccChannel>? {
|
||||||
if (physicalSlotId == EuiccChannelManager.USB_CHANNEL_ID) {
|
if (physicalSlotId == EuiccChannelManager.USB_CHANNEL_ID) {
|
||||||
return usbChannel?.let { listOf(it) }
|
return usbChannel?.let { listOf(it) }
|
||||||
}
|
}
|
||||||
|
@ -142,7 +138,12 @@ open class DefaultEuiccChannelManager(
|
||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
|
|
||||||
private suspend fun findEuiccChannelByPort(physicalSlotId: Int, portId: Int): EuiccChannel? =
|
override fun findAllEuiccChannelsByPhysicalSlotBlocking(physicalSlotId: Int): List<EuiccChannel>? =
|
||||||
|
runBlocking {
|
||||||
|
findAllEuiccChannelsByPhysicalSlot(physicalSlotId)
|
||||||
|
}
|
||||||
|
|
||||||
|
override suspend fun findEuiccChannelByPort(physicalSlotId: Int, portId: Int): EuiccChannel? =
|
||||||
withContext(Dispatchers.IO) {
|
withContext(Dispatchers.IO) {
|
||||||
if (physicalSlotId == EuiccChannelManager.USB_CHANNEL_ID) {
|
if (physicalSlotId == EuiccChannelManager.USB_CHANNEL_ID) {
|
||||||
return@withContext usbChannel
|
return@withContext usbChannel
|
||||||
|
@ -153,155 +154,72 @@ open class DefaultEuiccChannelManager(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
override suspend fun findFirstAvailablePort(physicalSlotId: Int): Int =
|
override fun findEuiccChannelByPortBlocking(physicalSlotId: Int, portId: Int): EuiccChannel? =
|
||||||
withContext(Dispatchers.IO) {
|
runBlocking {
|
||||||
if (physicalSlotId == EuiccChannelManager.USB_CHANNEL_ID) {
|
findEuiccChannelByPort(physicalSlotId, portId)
|
||||||
return@withContext 0
|
|
||||||
}
|
|
||||||
|
|
||||||
findAllEuiccChannelsByPhysicalSlot(physicalSlotId)?.getOrNull(0)?.portId ?: -1
|
|
||||||
}
|
|
||||||
|
|
||||||
override suspend fun findAvailablePorts(physicalSlotId: Int): List<Int> =
|
|
||||||
withContext(Dispatchers.IO) {
|
|
||||||
if (physicalSlotId == EuiccChannelManager.USB_CHANNEL_ID) {
|
|
||||||
return@withContext listOf(0)
|
|
||||||
}
|
|
||||||
|
|
||||||
findAllEuiccChannelsByPhysicalSlot(physicalSlotId)?.map { it.portId } ?: listOf()
|
|
||||||
}
|
|
||||||
|
|
||||||
override suspend fun <R> withEuiccChannel(
|
|
||||||
physicalSlotId: Int,
|
|
||||||
portId: Int,
|
|
||||||
fn: suspend (EuiccChannel) -> R
|
|
||||||
): R {
|
|
||||||
val channel = findEuiccChannelByPort(physicalSlotId, portId)
|
|
||||||
?: throw EuiccChannelManager.EuiccChannelNotFoundException()
|
|
||||||
val wrapper = EuiccChannelWrapper(channel)
|
|
||||||
try {
|
|
||||||
return withContext(Dispatchers.IO) {
|
|
||||||
fn(wrapper)
|
|
||||||
}
|
|
||||||
} finally {
|
|
||||||
wrapper.invalidateWrapper()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
override suspend fun <R> withEuiccChannel(
|
|
||||||
logicalSlotId: Int,
|
|
||||||
fn: suspend (EuiccChannel) -> R
|
|
||||||
): R {
|
|
||||||
val channel = findEuiccChannelByLogicalSlot(logicalSlotId)
|
|
||||||
?: throw EuiccChannelManager.EuiccChannelNotFoundException()
|
|
||||||
val wrapper = EuiccChannelWrapper(channel)
|
|
||||||
try {
|
|
||||||
return withContext(Dispatchers.IO) {
|
|
||||||
fn(wrapper)
|
|
||||||
}
|
|
||||||
} finally {
|
|
||||||
wrapper.invalidateWrapper()
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
override suspend fun waitForReconnect(physicalSlotId: Int, portId: Int, timeoutMillis: Long) {
|
override suspend fun waitForReconnect(physicalSlotId: Int, portId: Int, timeoutMillis: Long) {
|
||||||
if (physicalSlotId == EuiccChannelManager.USB_CHANNEL_ID) {
|
if (physicalSlotId == EuiccChannelManager.USB_CHANNEL_ID) return
|
||||||
usbChannel?.close()
|
|
||||||
usbChannel = null
|
|
||||||
} else {
|
|
||||||
// If there is already a valid channel, we close it proactively
|
// 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
|
// 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 {
|
channelCache.find { it.slotId == physicalSlotId && it.portId == portId }?.apply {
|
||||||
if (valid) close()
|
if (valid) close()
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
withTimeout(timeoutMillis) {
|
withTimeout(timeoutMillis) {
|
||||||
while (true) {
|
while (true) {
|
||||||
try {
|
try {
|
||||||
val channel = if (physicalSlotId == EuiccChannelManager.USB_CHANNEL_ID) {
|
|
||||||
// tryOpenUsbEuiccChannel() will always try to reopen the channel, even if
|
|
||||||
// a USB channel already exists
|
|
||||||
tryOpenUsbEuiccChannel()
|
|
||||||
usbChannel!!
|
|
||||||
} else {
|
|
||||||
// tryOpenEuiccChannel() will automatically dispose of invalid channels
|
// tryOpenEuiccChannel() will automatically dispose of invalid channels
|
||||||
// and recreate when needed
|
// and recreate when needed
|
||||||
findEuiccChannelByPort(physicalSlotId, portId)!!
|
val channel = findEuiccChannelByPortBlocking(physicalSlotId, portId)!!
|
||||||
}
|
|
||||||
check(channel.valid) { "Invalid channel" }
|
check(channel.valid) { "Invalid channel" }
|
||||||
break
|
break
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
Log.d(
|
Log.d(TAG, "Slot $physicalSlotId port $portId reconnect failure, retrying in 1000 ms")
|
||||||
TAG,
|
|
||||||
"Slot $physicalSlotId port $portId reconnect failure, retrying in 1000 ms"
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
delay(1000)
|
delay(1000)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun flowInternalEuiccPorts(): Flow<Pair<Int, Int>> = flow {
|
override suspend fun enumerateEuiccChannels(): List<EuiccChannel> =
|
||||||
uiccCards.forEach { info ->
|
withContext(Dispatchers.IO) {
|
||||||
info.ports.forEach { port ->
|
uiccCards.flatMap { info ->
|
||||||
|
info.ports.mapNotNull { port ->
|
||||||
tryOpenEuiccChannel(port)?.also {
|
tryOpenEuiccChannel(port)?.also {
|
||||||
Log.d(
|
Log.d(
|
||||||
TAG,
|
TAG,
|
||||||
"Found eUICC on slot ${info.physicalSlotIndex} port ${port.portIndex}"
|
"Found eUICC on slot ${info.physicalSlotIndex} port ${port.portIndex}"
|
||||||
)
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
emit(Pair(info.physicalSlotIndex, port.portIndex))
|
override suspend fun enumerateUsbEuiccChannel(): Pair<UsbDevice?, EuiccChannel?> =
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}.flowOn(Dispatchers.IO)
|
|
||||||
|
|
||||||
override fun flowAllOpenEuiccPorts(): Flow<Pair<Int, Int>> =
|
|
||||||
merge(flowInternalEuiccPorts(), flow {
|
|
||||||
if (tryOpenUsbEuiccChannel().second) {
|
|
||||||
emit(Pair(EuiccChannelManager.USB_CHANNEL_ID, 0))
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
override suspend fun tryOpenUsbEuiccChannel(): Pair<UsbDevice?, Boolean> =
|
|
||||||
withContext(Dispatchers.IO) {
|
withContext(Dispatchers.IO) {
|
||||||
usbManager.deviceList.values.forEach { device ->
|
usbManager.deviceList.values.forEach { device ->
|
||||||
Log.i(TAG, "Scanning USB device ${device.deviceId}:${device.vendorId}")
|
Log.i(TAG, "Scanning USB device ${device.deviceId}:${device.vendorId}")
|
||||||
val iface = device.interfaces.smartCard ?: return@forEach
|
val iface = device.getSmartCardInterface() ?: return@forEach
|
||||||
// If we don't have permission, tell UI code that we found a candidate device, but we
|
// 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
|
// need permission to be able to do anything with it
|
||||||
if (!usbManager.hasPermission(device)) return@withContext Pair(device, false)
|
if (!usbManager.hasPermission(device)) return@withContext Pair(device, null)
|
||||||
Log.i(
|
Log.i(TAG, "Found CCID interface on ${device.deviceId}:${device.vendorId}, and has permission; trying to open channel")
|
||||||
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
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
val channel = tryOpenChannelFirstValidAid {
|
val channel = euiccChannelFactory.tryOpenUsbEuiccChannel(device, iface)
|
||||||
euiccChannelFactory.tryOpenUsbEuiccChannel(ccidCtx, it)
|
|
||||||
}
|
|
||||||
if (channel != null && channel.lpa.valid) {
|
if (channel != null && channel.lpa.valid) {
|
||||||
ccidCtx.allowDisconnect = true
|
|
||||||
usbChannel = channel
|
usbChannel = channel
|
||||||
return@withContext Pair(device, true)
|
return@withContext Pair(device, channel)
|
||||||
}
|
}
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
// Ignored -- skip forward
|
// Ignored -- skip forward
|
||||||
e.printStackTrace()
|
e.printStackTrace()
|
||||||
}
|
}
|
||||||
|
Log.i(TAG, "No valid eUICC channel found on USB device ${device.deviceId}:${device.vendorId}")
|
||||||
ccidCtx.allowDisconnect = true
|
|
||||||
ccidCtx.disconnect()
|
|
||||||
|
|
||||||
Log.i(
|
|
||||||
TAG,
|
|
||||||
"No valid eUICC channel found on USB device ${device.deviceId}:${device.vendorId}"
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
return@withContext Pair(null, false)
|
return@withContext Pair(null, null)
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun invalidate() {
|
override fun invalidate() {
|
||||||
|
|
|
@ -3,41 +3,21 @@ package im.angry.openeuicc.core
|
||||||
import im.angry.openeuicc.util.*
|
import im.angry.openeuicc.util.*
|
||||||
import net.typeblog.lpac_jni.ApduInterface
|
import net.typeblog.lpac_jni.ApduInterface
|
||||||
import net.typeblog.lpac_jni.LocalProfileAssistant
|
import net.typeblog.lpac_jni.LocalProfileAssistant
|
||||||
|
import net.typeblog.lpac_jni.impl.HttpInterfaceImpl
|
||||||
|
import net.typeblog.lpac_jni.impl.LocalProfileAssistantImpl
|
||||||
|
|
||||||
interface EuiccChannel {
|
class EuiccChannel(
|
||||||
val type: String
|
val port: UiccPortInfoCompat,
|
||||||
|
apduInterface: ApduInterface,
|
||||||
|
) {
|
||||||
|
val slotId = port.card.physicalSlotIndex // PHYSICAL slot
|
||||||
|
val logicalSlotId = port.logicalSlotIndex
|
||||||
|
val portId = port.portIndex
|
||||||
|
|
||||||
val port: UiccPortInfoCompat
|
val lpa: LocalProfileAssistant = LocalProfileAssistantImpl(apduInterface, HttpInterfaceImpl())
|
||||||
|
|
||||||
val slotId: Int // PHYSICAL slot
|
|
||||||
val logicalSlotId: Int
|
|
||||||
val portId: Int
|
|
||||||
|
|
||||||
val lpa: LocalProfileAssistant
|
|
||||||
|
|
||||||
val valid: Boolean
|
val valid: Boolean
|
||||||
|
get() = lpa.valid
|
||||||
|
|
||||||
/**
|
fun close() = lpa.close()
|
||||||
* Answer to Reset (ATR) value of the underlying interface, if any
|
|
||||||
*/
|
|
||||||
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()
|
|
||||||
}
|
}
|
|
@ -1,17 +1,15 @@
|
||||||
package im.angry.openeuicc.core
|
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.*
|
import im.angry.openeuicc.util.*
|
||||||
|
|
||||||
// This class is here instead of inside DI because it contains a bit more logic than just
|
// This class is here instead of inside DI because it contains a bit more logic than just
|
||||||
// "dumb" dependency injection.
|
// "dumb" dependency injection.
|
||||||
interface EuiccChannelFactory {
|
interface EuiccChannelFactory {
|
||||||
suspend fun tryOpenEuiccChannel(port: UiccPortInfoCompat, isdrAid: ByteArray): EuiccChannel?
|
suspend fun tryOpenEuiccChannel(port: UiccPortInfoCompat): EuiccChannel?
|
||||||
|
|
||||||
fun tryOpenUsbEuiccChannel(
|
fun tryOpenUsbEuiccChannel(usbDevice: UsbDevice, usbInterface: UsbInterface): EuiccChannel?
|
||||||
ccidCtx: UsbCcidContext,
|
|
||||||
isdrAid: ByteArray
|
|
||||||
): EuiccChannel?
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Release all resources used by this EuiccChannelFactory
|
* Release all resources used by this EuiccChannelFactory
|
||||||
|
|
|
@ -1,42 +0,0 @@
|
||||||
package im.angry.openeuicc.core
|
|
||||||
|
|
||||||
import im.angry.openeuicc.util.UiccPortInfoCompat
|
|
||||||
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
|
|
||||||
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,
|
|
||||||
verboseLoggingFlow: Flow<Boolean>,
|
|
||||||
ignoreTLSCertificateFlow: Flow<Boolean>,
|
|
||||||
es10xMssFlow: Flow<Int>,
|
|
||||||
) : EuiccChannel {
|
|
||||||
override val slotId = port.card.physicalSlotIndex
|
|
||||||
override val logicalSlotId = port.logicalSlotIndex
|
|
||||||
override val portId = port.portIndex
|
|
||||||
|
|
||||||
override val lpa: LocalProfileAssistant =
|
|
||||||
LocalProfileAssistantImpl(
|
|
||||||
isdrAid,
|
|
||||||
apduInterface,
|
|
||||||
HttpInterfaceImpl(verboseLoggingFlow, ignoreTLSCertificateFlow),
|
|
||||||
).also {
|
|
||||||
it.setEs10xMss(runBlocking { es10xMssFlow.first().toByte() })
|
|
||||||
}
|
|
||||||
|
|
||||||
override val atr: ByteArray?
|
|
||||||
get() = (apduInterface as? ApduInterfaceAtrProvider)?.atr
|
|
||||||
|
|
||||||
override val valid: Boolean
|
|
||||||
get() = lpa.valid
|
|
||||||
|
|
||||||
override fun close() = lpa.close()
|
|
||||||
}
|
|
|
@ -1,7 +1,6 @@
|
||||||
package im.angry.openeuicc.core
|
package im.angry.openeuicc.core
|
||||||
|
|
||||||
import android.hardware.usb.UsbDevice
|
import android.hardware.usb.UsbDevice
|
||||||
import kotlinx.coroutines.flow.Flow
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* EuiccChannelManager holds references to, and manages the lifecycles of, individual
|
* EuiccChannelManager holds references to, and manages the lifecycles of, individual
|
||||||
|
@ -19,35 +18,19 @@ interface EuiccChannelManager {
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Scan all possible _device internal_ sources for EuiccChannels, as a flow, return their physical
|
* Scan all possible _device internal_ sources for EuiccChannels, return them and have all
|
||||||
* (slotId, portId) and have all scanned channels cached; these channels will remain open
|
* scanned channels cached; these channels will remain open for the entire lifetime of
|
||||||
* for the entire lifetime of this EuiccChannelManager object, unless disconnected externally
|
* this EuiccChannelManager object, unless disconnected externally or invalidate()'d
|
||||||
* or invalidate()'d.
|
|
||||||
*
|
|
||||||
* To obtain a temporary reference to a EuiccChannel, use `withEuiccChannel()`.
|
|
||||||
*/
|
*/
|
||||||
fun flowInternalEuiccPorts(): Flow<Pair<Int, Int>>
|
suspend fun enumerateEuiccChannels(): List<EuiccChannel>
|
||||||
|
|
||||||
/**
|
|
||||||
* Same as flowInternalEuiccPorts(), except that this includes non-device internal eUICC chips
|
|
||||||
* as well. Namely, this includes the USB reader.
|
|
||||||
*
|
|
||||||
* Non-internal readers will only be included if they have been opened properly, i.e. with permissions
|
|
||||||
* granted by the user.
|
|
||||||
*/
|
|
||||||
fun flowAllOpenEuiccPorts(): Flow<Pair<Int, Int>>
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Scan all possible USB devices for CCID readers that may contain eUICC cards.
|
* 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
|
* If found, try to open it for access, and add it to the internal EuiccChannel cache
|
||||||
* as a "port" with id 99. When user interaction is required to obtain permission
|
* as a "port" with id 99. When user interaction is required to obtain permission
|
||||||
* to interact with the device, the second return value will be false.
|
* to interact with the device, the second return value (EuiccChannel) will be null.
|
||||||
*
|
|
||||||
* Returns (usbDevice, canOpen). canOpen is false if either (1) no usb reader is found;
|
|
||||||
* or (2) usb reader is found, but user interaction is required for access;
|
|
||||||
* or (3) usb reader is found, but we are unable to open ISD-R.
|
|
||||||
*/
|
*/
|
||||||
suspend fun tryOpenUsbEuiccChannel(): Pair<UsbDevice?, Boolean>
|
suspend fun enumerateUsbEuiccChannel(): Pair<UsbDevice?, EuiccChannel?>
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Wait for a slot + port to reconnect (i.e. become valid again)
|
* Wait for a slot + port to reconnect (i.e. become valid again)
|
||||||
|
@ -57,40 +40,29 @@ interface EuiccChannelManager {
|
||||||
suspend fun waitForReconnect(physicalSlotId: Int, portId: Int, timeoutMillis: Long = 1000)
|
suspend fun waitForReconnect(physicalSlotId: Int, portId: Int, timeoutMillis: Long = 1000)
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Returns the first mapped & available port ID for a physical slot, or -1 if
|
* Returns the EuiccChannel corresponding to a **logical** slot
|
||||||
* not found.
|
|
||||||
*/
|
*/
|
||||||
suspend fun findFirstAvailablePort(physicalSlotId: Int): Int
|
fun findEuiccChannelBySlotBlocking(logicalSlotId: Int): EuiccChannel?
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Returns all mapped & available port IDs for a physical slot.
|
* Returns the first EuiccChannel corresponding to a **physical** slot
|
||||||
|
* If the physical slot supports MEP and has multiple ports, it is undefined
|
||||||
|
* which of the two channels will be returned.
|
||||||
*/
|
*/
|
||||||
suspend fun findAvailablePorts(physicalSlotId: Int): List<Int>
|
fun findEuiccChannelByPhysicalSlotBlocking(physicalSlotId: Int): EuiccChannel?
|
||||||
|
|
||||||
class EuiccChannelNotFoundException: Exception("EuiccChannel not found")
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Find a EuiccChannel by its slot and port, then run a callback with a reference to it.
|
* Returns all EuiccChannels corresponding to a **physical** slot
|
||||||
* The reference is not supposed to be held outside of the callback. This is enforced via
|
* Multiple channels are possible in the case of MEP
|
||||||
* a wrapper object.
|
|
||||||
*
|
|
||||||
* The callback is run on Dispatchers.IO by default.
|
|
||||||
*
|
|
||||||
* If a channel for that slot / port is not found, EuiccChannelNotFoundException is thrown
|
|
||||||
*/
|
*/
|
||||||
suspend fun <R> withEuiccChannel(
|
suspend fun findAllEuiccChannelsByPhysicalSlot(physicalSlotId: Int): List<EuiccChannel>?
|
||||||
physicalSlotId: Int,
|
fun findAllEuiccChannelsByPhysicalSlotBlocking(physicalSlotId: Int): List<EuiccChannel>?
|
||||||
portId: Int,
|
|
||||||
fn: suspend (EuiccChannel) -> R
|
|
||||||
): R
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Same as withEuiccChannel(Int, Int, (EuiccChannel) -> R) but instead uses logical slot ID
|
* Returns the EuiccChannel corresponding to a **physical** slot and a port ID
|
||||||
*/
|
*/
|
||||||
suspend fun <R> withEuiccChannel(
|
suspend fun findEuiccChannelByPort(physicalSlotId: Int, portId: Int): EuiccChannel?
|
||||||
logicalSlotId: Int,
|
fun findEuiccChannelByPortBlocking(physicalSlotId: Int, portId: Int): EuiccChannel?
|
||||||
fn: suspend (EuiccChannel) -> R
|
|
||||||
): R
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Invalidate all EuiccChannels previously cached by this Manager
|
* Invalidate all EuiccChannels previously cached by this Manager
|
||||||
|
@ -102,7 +74,7 @@ interface EuiccChannelManager {
|
||||||
* This is only expected to be implemented when the application is privileged
|
* This is only expected to be implemented when the application is privileged
|
||||||
* TODO: Remove this from the common interface
|
* TODO: Remove this from the common interface
|
||||||
*/
|
*/
|
||||||
suspend fun notifyEuiccProfilesChanged(logicalSlotId: Int) {
|
fun notifyEuiccProfilesChanged(logicalSlotId: Int) {
|
||||||
// no-op by default
|
// no-op by default
|
||||||
}
|
}
|
||||||
}
|
}
|
|
@ -1,53 +0,0 @@
|
||||||
package im.angry.openeuicc.core
|
|
||||||
|
|
||||||
import im.angry.openeuicc.util.*
|
|
||||||
import net.typeblog.lpac_jni.ApduInterface
|
|
||||||
import net.typeblog.lpac_jni.LocalProfileAssistant
|
|
||||||
|
|
||||||
class EuiccChannelWrapper(orig: EuiccChannel) : EuiccChannel {
|
|
||||||
private var _inner: EuiccChannel? = orig
|
|
||||||
|
|
||||||
private val channel: EuiccChannel
|
|
||||||
get() {
|
|
||||||
if (_inner == null) {
|
|
||||||
throw IllegalStateException("This wrapper has been invalidated")
|
|
||||||
}
|
|
||||||
|
|
||||||
return _inner!!
|
|
||||||
}
|
|
||||||
|
|
||||||
override val type: String
|
|
||||||
get() = channel.type
|
|
||||||
override val port: UiccPortInfoCompat
|
|
||||||
get() = channel.port
|
|
||||||
override val slotId: Int
|
|
||||||
get() = channel.slotId
|
|
||||||
override val logicalSlotId: Int
|
|
||||||
get() = channel.logicalSlotId
|
|
||||||
override val portId: Int
|
|
||||||
get() = channel.portId
|
|
||||||
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 fun close() = channel.close()
|
|
||||||
|
|
||||||
fun invalidateWrapper() {
|
|
||||||
_inner = null
|
|
||||||
|
|
||||||
if (lpaDelegate.isInitialized()) {
|
|
||||||
(lpa as LocalProfileAssistantWrapper).invalidateWrapper()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -1,66 +0,0 @@
|
||||||
package im.angry.openeuicc.core
|
|
||||||
|
|
||||||
import net.typeblog.lpac_jni.EuiccInfo2
|
|
||||||
import net.typeblog.lpac_jni.LocalProfileAssistant
|
|
||||||
import net.typeblog.lpac_jni.LocalProfileInfo
|
|
||||||
import net.typeblog.lpac_jni.LocalProfileNotification
|
|
||||||
import net.typeblog.lpac_jni.ProfileDownloadCallback
|
|
||||||
|
|
||||||
class LocalProfileAssistantWrapper(orig: LocalProfileAssistant) :
|
|
||||||
LocalProfileAssistant {
|
|
||||||
private var _inner: LocalProfileAssistant? = orig
|
|
||||||
|
|
||||||
private val lpa: LocalProfileAssistant
|
|
||||||
get() {
|
|
||||||
if (_inner == null) {
|
|
||||||
throw IllegalStateException("This wrapper has been invalidated")
|
|
||||||
}
|
|
||||||
|
|
||||||
return _inner!!
|
|
||||||
}
|
|
||||||
|
|
||||||
override val valid: Boolean
|
|
||||||
get() = lpa.valid
|
|
||||||
override val profiles: List<LocalProfileInfo>
|
|
||||||
get() = lpa.profiles
|
|
||||||
override val notifications: List<LocalProfileNotification>
|
|
||||||
get() = lpa.notifications
|
|
||||||
override val eID: String
|
|
||||||
get() = lpa.eID
|
|
||||||
override val euiccInfo2: EuiccInfo2?
|
|
||||||
get() = lpa.euiccInfo2
|
|
||||||
|
|
||||||
override fun setEs10xMss(mss: Byte) = lpa.setEs10xMss(mss)
|
|
||||||
|
|
||||||
override fun enableProfile(iccid: String, refresh: Boolean): Boolean =
|
|
||||||
lpa.enableProfile(iccid, refresh)
|
|
||||||
|
|
||||||
override fun disableProfile(iccid: String, refresh: Boolean): Boolean =
|
|
||||||
lpa.disableProfile(iccid, refresh)
|
|
||||||
|
|
||||||
override fun deleteProfile(iccid: String): Boolean = lpa.deleteProfile(iccid)
|
|
||||||
|
|
||||||
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)
|
|
||||||
|
|
||||||
override fun handleNotification(seqNumber: Long): Boolean = lpa.handleNotification(seqNumber)
|
|
||||||
|
|
||||||
override fun euiccMemoryReset() = lpa.euiccMemoryReset()
|
|
||||||
|
|
||||||
override fun setNickname(iccid: String, nickname: String) {
|
|
||||||
lpa.setNickname(iccid, nickname)
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun close() = lpa.close()
|
|
||||||
|
|
||||||
fun invalidateWrapper() {
|
|
||||||
_inner = null
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -3,33 +3,19 @@ package im.angry.openeuicc.core
|
||||||
import android.se.omapi.Channel
|
import android.se.omapi.Channel
|
||||||
import android.se.omapi.SEService
|
import android.se.omapi.SEService
|
||||||
import android.se.omapi.Session
|
import android.se.omapi.Session
|
||||||
import android.util.Log
|
|
||||||
import im.angry.openeuicc.util.*
|
import im.angry.openeuicc.util.*
|
||||||
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.ApduInterface
|
||||||
import java.util.concurrent.atomic.AtomicInteger
|
|
||||||
|
|
||||||
class OmapiApduInterface(
|
class OmapiApduInterface(
|
||||||
private val service: SEService,
|
private val service: SEService,
|
||||||
private val port: UiccPortInfoCompat,
|
private val port: UiccPortInfoCompat
|
||||||
private val verboseLoggingFlow: Flow<Boolean>
|
): ApduInterface {
|
||||||
): ApduInterface, ApduInterfaceAtrProvider {
|
|
||||||
companion object {
|
|
||||||
const val TAG = "OmapiApduInterface"
|
|
||||||
}
|
|
||||||
|
|
||||||
private lateinit var session: Session
|
private lateinit var session: Session
|
||||||
private val index = AtomicInteger(0)
|
private lateinit var lastChannel: Channel
|
||||||
private val channels = mutableMapOf<Int, Channel>()
|
|
||||||
|
|
||||||
override val valid: Boolean
|
override val valid: Boolean
|
||||||
get() = service.isConnected && (this::session.isInitialized && !session.isClosed)
|
get() = service.isConnected && (this::session.isInitialized && !session.isClosed)
|
||||||
|
|
||||||
override val atr: ByteArray?
|
|
||||||
get() = session.atr
|
|
||||||
|
|
||||||
override fun connect() {
|
override fun connect() {
|
||||||
session = service.getUiccReaderCompat(port.logicalSlotIndex + 1).openSession()
|
session = service.getUiccReaderCompat(port.logicalSlotIndex + 1).openSession()
|
||||||
}
|
}
|
||||||
|
@ -39,48 +25,26 @@ class OmapiApduInterface(
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun logicalChannelOpen(aid: ByteArray): Int {
|
override fun logicalChannelOpen(aid: ByteArray): Int {
|
||||||
val channel = session.openLogicalChannel(aid)
|
check(!this::lastChannel.isInitialized) {
|
||||||
check(channel != null) { "Failed to open logical channel (${aid.encodeHex()})" }
|
"Can only open one channel"
|
||||||
val handle = index.incrementAndGet()
|
}
|
||||||
synchronized(channels) { channels[handle] = channel }
|
lastChannel = session.openLogicalChannel(aid)!!;
|
||||||
return handle
|
return 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun logicalChannelClose(handle: Int) {
|
override fun logicalChannelClose(handle: Int) {
|
||||||
val channel = channels[handle]
|
check(handle == 1 && !this::lastChannel.isInitialized) {
|
||||||
check(channel != null) { "Invalid logical channel handle $handle" }
|
"Unknown channel"
|
||||||
if (channel.isOpen) channel.close()
|
}
|
||||||
synchronized(channels) { channels.remove(handle) }
|
lastChannel.close()
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun transmit(handle: Int, tx: ByteArray): ByteArray {
|
override fun transmit(tx: ByteArray): ByteArray {
|
||||||
val channel = channels[handle]
|
check(this::lastChannel.isInitialized) {
|
||||||
check(channel != null) { "Invalid logical channel handle $handle" }
|
"Unknown channel"
|
||||||
|
|
||||||
if (runBlocking { verboseLoggingFlow.first() }) {
|
|
||||||
Log.d(TAG, "OMAPI APDU: ${tx.encodeHex()}")
|
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
return lastChannel.transmit(tx)
|
||||||
for (i in 0..10) {
|
|
||||||
val res = channel.transmit(tx)
|
|
||||||
if (runBlocking { verboseLoggingFlow.first() }) {
|
|
||||||
Log.d(TAG, "OMAPI APDU response: ${res.encodeHex()}")
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (res.size == 2 && res[0] == 0x66.toByte() && res[1] == 0x01.toByte()) {
|
|
||||||
Log.d(TAG, "Received checksum error 0x6601, retrying (count = $i)")
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
return res
|
|
||||||
}
|
|
||||||
|
|
||||||
throw RuntimeException("Retransmit attempts exhausted; this was likely caused by checksum errors")
|
|
||||||
} catch (e: Exception) {
|
|
||||||
Log.e(TAG, "OMAPI APDU exception")
|
|
||||||
e.printStackTrace()
|
|
||||||
throw e
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
|
@ -1,41 +1,49 @@
|
||||||
package im.angry.openeuicc.core.usb
|
package im.angry.openeuicc.core.usb
|
||||||
|
|
||||||
|
import android.hardware.usb.UsbDeviceConnection
|
||||||
|
import android.hardware.usb.UsbEndpoint
|
||||||
import android.util.Log
|
import android.util.Log
|
||||||
import im.angry.openeuicc.core.ApduInterfaceAtrProvider
|
|
||||||
import im.angry.openeuicc.util.*
|
import im.angry.openeuicc.util.*
|
||||||
import net.typeblog.lpac_jni.ApduInterface
|
import net.typeblog.lpac_jni.ApduInterface
|
||||||
|
|
||||||
class UsbApduInterface(
|
class UsbApduInterface(
|
||||||
private val ccidCtx: UsbCcidContext
|
private val conn: UsbDeviceConnection,
|
||||||
) : ApduInterface, ApduInterfaceAtrProvider {
|
private val bulkIn: UsbEndpoint,
|
||||||
|
private val bulkOut: UsbEndpoint
|
||||||
|
): ApduInterface {
|
||||||
companion object {
|
companion object {
|
||||||
private const val TAG = "UsbApduInterface"
|
private const val TAG = "UsbApduInterface"
|
||||||
}
|
}
|
||||||
|
|
||||||
override val atr: ByteArray?
|
private lateinit var ccidDescription: UsbCcidDescription
|
||||||
get() = ccidCtx.atr
|
private lateinit var transceiver: UsbCcidTransceiver
|
||||||
|
|
||||||
override val valid: Boolean
|
private var channelId = -1
|
||||||
get() = channels.isNotEmpty()
|
|
||||||
|
|
||||||
private var channels = mutableSetOf<Int>()
|
|
||||||
|
|
||||||
override fun connect() {
|
override fun connect() {
|
||||||
ccidCtx.connect()
|
ccidDescription = UsbCcidDescription.fromRawDescriptors(conn.rawDescriptors)!!
|
||||||
|
|
||||||
// Send Terminal Capabilities
|
if (!ccidDescription.hasT0Protocol) {
|
||||||
// Specs: ETSI TS 102 221 v15.0.0 - 11.1.19 TERMINAL CAPABILITY
|
throw IllegalArgumentException("Unsupported card reader; T=0 support is required")
|
||||||
val terminalCapabilities = buildCmd(
|
|
||||||
0x80.toByte(), 0xaa.toByte(), 0x00, 0x00,
|
|
||||||
"A9088100820101830107".decodeHex(),
|
|
||||||
le = null,
|
|
||||||
)
|
|
||||||
transmitApduByChannel(terminalCapabilities, 0)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun disconnect() = ccidCtx.disconnect()
|
transceiver = UsbCcidTransceiver(conn, bulkIn, bulkOut, ccidDescription)
|
||||||
|
|
||||||
|
try {
|
||||||
|
transceiver.iccPowerOn()
|
||||||
|
} catch (e: Exception) {
|
||||||
|
e.printStackTrace()
|
||||||
|
throw e
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun disconnect() {
|
||||||
|
conn.close()
|
||||||
|
}
|
||||||
|
|
||||||
override fun logicalChannelOpen(aid: ByteArray): Int {
|
override fun logicalChannelOpen(aid: ByteArray): Int {
|
||||||
|
check(channelId == -1) { "Logical channel already opened" }
|
||||||
|
|
||||||
// OPEN LOGICAL CHANNEL
|
// OPEN LOGICAL CHANNEL
|
||||||
val req = manageChannelCmd(true, 0)
|
val req = manageChannelCmd(true, 0)
|
||||||
|
|
||||||
|
@ -51,7 +59,7 @@ class UsbApduInterface(
|
||||||
return -1
|
return -1
|
||||||
}
|
}
|
||||||
|
|
||||||
val channelId = resp[0].toInt()
|
channelId = resp[0].toInt()
|
||||||
Log.d(TAG, "channelId = $channelId")
|
Log.d(TAG, "channelId = $channelId")
|
||||||
|
|
||||||
// Then, select AID
|
// Then, select AID
|
||||||
|
@ -63,32 +71,32 @@ class UsbApduInterface(
|
||||||
return -1
|
return -1
|
||||||
}
|
}
|
||||||
|
|
||||||
channels.add(channelId)
|
|
||||||
|
|
||||||
return channelId
|
return channelId
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun logicalChannelClose(handle: Int) {
|
override fun logicalChannelClose(handle: Int) {
|
||||||
check(channels.contains(handle)) {
|
check(handle == channelId) { "Logical channel ID mismatch" }
|
||||||
"Invalid logical channel handle $handle"
|
check(channelId != -1) { "Logical channel is not opened" }
|
||||||
}
|
|
||||||
// CLOSE LOGICAL CHANNEL
|
// CLOSE LOGICAL CHANNEL
|
||||||
val req = manageChannelCmd(false, handle.toByte())
|
val req = manageChannelCmd(false, channelId.toByte())
|
||||||
val resp = transmitApduByChannel(req, handle.toByte())
|
val resp = transmitApduByChannel(req, channelId.toByte())
|
||||||
|
|
||||||
if (!isSuccessResponse(resp)) {
|
if (!isSuccessResponse(resp)) {
|
||||||
Log.d(TAG, "CLOSE LOGICAL CHANNEL failed: ${resp.encodeHex()}")
|
Log.d(TAG, "CLOSE LOGICAL CHANNEL failed: ${resp.encodeHex()}")
|
||||||
}
|
}
|
||||||
channels.remove(handle)
|
|
||||||
|
channelId = -1
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun transmit(handle: Int, tx: ByteArray): ByteArray {
|
override fun transmit(tx: ByteArray): ByteArray {
|
||||||
check(channels.contains(handle)) {
|
check(channelId != -1) { "Logical channel is not opened" }
|
||||||
"Invalid logical channel handle $handle"
|
return transmitApduByChannel(tx, channelId.toByte())
|
||||||
}
|
|
||||||
return transmitApduByChannel(tx, handle.toByte())
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
override val valid: Boolean
|
||||||
|
get() = channelId != -1
|
||||||
|
|
||||||
private fun isSuccessResponse(resp: ByteArray): Boolean =
|
private fun isSuccessResponse(resp: ByteArray): Boolean =
|
||||||
resp.size >= 2 && resp[resp.size - 2] == 0x90.toByte() && resp[resp.size - 1] == 0x00.toByte()
|
resp.size >= 2 && resp[resp.size - 2] == 0x90.toByte() && resp[resp.size - 1] == 0x00.toByte()
|
||||||
|
|
||||||
|
@ -122,7 +130,7 @@ class UsbApduInterface(
|
||||||
// OR the channel mask into the CLA byte
|
// OR the channel mask into the CLA byte
|
||||||
realTx[0] = ((realTx[0].toInt() and 0xFC) or channel.toInt()).toByte()
|
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)!")
|
if (resp.size < 2) throw RuntimeException("APDU response smaller than 2 (sw1 + sw2)!")
|
||||||
|
|
||||||
|
@ -133,7 +141,7 @@ class UsbApduInterface(
|
||||||
// 0x6C = wrong le
|
// 0x6C = wrong le
|
||||||
// so we fix the le field here
|
// so we fix the le field here
|
||||||
realTx[realTx.size - 1] = resp[resp.size - 1]
|
realTx[realTx.size - 1] = resp[resp.size - 1]
|
||||||
resp = ccidCtx.transceiver.sendXfrBlock(realTx).data!!
|
resp = transceiver.sendXfrBlock(realTx).data!!
|
||||||
} else if (sw1 == 0x61) {
|
} else if (sw1 == 0x61) {
|
||||||
// 0x61 = X bytes available
|
// 0x61 = X bytes available
|
||||||
// continue reading by GET RESPONSE
|
// continue reading by GET RESPONSE
|
||||||
|
@ -143,7 +151,7 @@ class UsbApduInterface(
|
||||||
realTx[0], 0xC0.toByte(), 0x00, 0x00, sw2.toByte()
|
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
|
resp = resp.sliceArray(0 until (resp.size - 2)) + tmp
|
||||||
|
|
||||||
|
|
|
@ -1,87 +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
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 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 productName: String,
|
|
||||||
val verboseLoggingFlow: Flow<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
|
|
||||||
UsbCcidContext(
|
|
||||||
conn,
|
|
||||||
bulkIn,
|
|
||||||
bulkOut,
|
|
||||||
usbDevice.productName ?: "USB",
|
|
||||||
context.preferenceRepository.verboseLoggingFlow
|
|
||||||
)
|
|
||||||
}.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
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -20,12 +20,12 @@ data class UsbCcidDescription(
|
||||||
|
|
||||||
private const val FEATURE_EXCHANGE_LEVEL_TPDU = 0x10000
|
private const val FEATURE_EXCHANGE_LEVEL_TPDU = 0x10000
|
||||||
private const val FEATURE_EXCHANGE_LEVEL_SHORT_APDU = 0x20000
|
private const val FEATURE_EXCHANGE_LEVEL_SHORT_APDU = 0x20000
|
||||||
private const val FEATURE_EXCHANGE_LEVEL_EXTENDED_APDU = 0x40000
|
private const val FEATURE_EXCHAGE_LEVEL_EXTENDED_APDU = 0x40000
|
||||||
|
|
||||||
// bVoltageSupport Masks
|
// bVoltageSupport Masks
|
||||||
private const val VOLTAGE_5V0: Byte = 1
|
private const val VOLTAGE_5V: Byte = 1
|
||||||
private const val VOLTAGE_3V0: Byte = 2
|
private const val VOLTAGE_3V: Byte = 2
|
||||||
private const val VOLTAGE_1V8: Byte = 4
|
private const val VOLTAGE_1_8V: Byte = 4
|
||||||
|
|
||||||
private const val SLOT_OFFSET = 4
|
private const val SLOT_OFFSET = 4
|
||||||
private const val FEATURES_OFFSET = 40
|
private const val FEATURES_OFFSET = 40
|
||||||
|
@ -71,23 +71,30 @@ data class UsbCcidDescription(
|
||||||
}
|
}
|
||||||
|
|
||||||
enum class Voltage(powerOnValue: Int, mask: Int) {
|
enum class Voltage(powerOnValue: Int, mask: Int) {
|
||||||
// @formatter:off
|
AUTO(0, 0), _5V(1, VOLTAGE_5V.toInt()), _3V(2, VOLTAGE_3V.toInt()), _1_8V(
|
||||||
AUTO(0, 0),
|
3,
|
||||||
V50(1, VOLTAGE_5V0.toInt()),
|
VOLTAGE_1_8V.toInt()
|
||||||
V30(2, VOLTAGE_3V0.toInt()),
|
);
|
||||||
V18(3, VOLTAGE_1V8.toInt());
|
|
||||||
// @formatter:on
|
|
||||||
|
|
||||||
val mask = powerOnValue.toByte()
|
val mask = powerOnValue.toByte()
|
||||||
val powerOnValue = mask.toByte()
|
val powerOnValue = mask.toByte()
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun hasFeature(feature: Int) = (dwFeatures and feature) != 0
|
private fun hasFeature(feature: Int): Boolean =
|
||||||
|
(dwFeatures and feature) != 0
|
||||||
|
|
||||||
val voltages: List<Voltage>
|
val voltages: Array<Voltage>
|
||||||
get() {
|
get() =
|
||||||
if (hasFeature(FEATURE_AUTOMATIC_VOLTAGE)) return listOf(Voltage.AUTO)
|
if (hasFeature(FEATURE_AUTOMATIC_VOLTAGE)) {
|
||||||
return Voltage.entries.filter { (it.mask.toInt() and bVoltageSupport.toInt()) != 0 }
|
arrayOf(Voltage.AUTO)
|
||||||
|
} else {
|
||||||
|
Voltage.values().mapNotNull {
|
||||||
|
if ((it.mask.toInt() and bVoltageSupport.toInt()) != 0) {
|
||||||
|
it
|
||||||
|
} else {
|
||||||
|
null
|
||||||
|
}
|
||||||
|
}.toTypedArray()
|
||||||
}
|
}
|
||||||
|
|
||||||
val hasAutomaticPps: Boolean
|
val hasAutomaticPps: Boolean
|
||||||
|
|
|
@ -5,9 +5,6 @@ import android.hardware.usb.UsbEndpoint
|
||||||
import android.os.SystemClock
|
import android.os.SystemClock
|
||||||
import android.util.Log
|
import android.util.Log
|
||||||
import im.angry.openeuicc.util.*
|
import im.angry.openeuicc.util.*
|
||||||
import kotlinx.coroutines.flow.Flow
|
|
||||||
import kotlinx.coroutines.flow.first
|
|
||||||
import kotlinx.coroutines.runBlocking
|
|
||||||
import java.nio.ByteBuffer
|
import java.nio.ByteBuffer
|
||||||
import java.nio.ByteOrder
|
import java.nio.ByteOrder
|
||||||
|
|
||||||
|
@ -21,8 +18,7 @@ class UsbCcidTransceiver(
|
||||||
private val usbConnection: UsbDeviceConnection,
|
private val usbConnection: UsbDeviceConnection,
|
||||||
private val usbBulkIn: UsbEndpoint,
|
private val usbBulkIn: UsbEndpoint,
|
||||||
private val usbBulkOut: UsbEndpoint,
|
private val usbBulkOut: UsbEndpoint,
|
||||||
private val usbCcidDescription: UsbCcidDescription,
|
private val usbCcidDescription: UsbCcidDescription
|
||||||
private val verboseLoggingFlow: Flow<Boolean>
|
|
||||||
) {
|
) {
|
||||||
companion object {
|
companion object {
|
||||||
private const val TAG = "UsbCcidTransceiver"
|
private const val TAG = "UsbCcidTransceiver"
|
||||||
|
@ -95,7 +91,6 @@ class UsbCcidTransceiver(
|
||||||
data class UsbCcidErrorException(val msg: String, val errorResponse: CcidDataBlock) :
|
data class UsbCcidErrorException(val msg: String, val errorResponse: CcidDataBlock) :
|
||||||
Exception(msg)
|
Exception(msg)
|
||||||
|
|
||||||
@Suppress("ArrayInDataClass")
|
|
||||||
data class CcidDataBlock(
|
data class CcidDataBlock(
|
||||||
val dwLength: Int,
|
val dwLength: Int,
|
||||||
val bSlot: Byte,
|
val bSlot: Byte,
|
||||||
|
@ -183,27 +178,30 @@ class UsbCcidTransceiver(
|
||||||
readBytes = usbConnection.bulkTransfer(
|
readBytes = usbConnection.bulkTransfer(
|
||||||
usbBulkIn, inputBuffer, inputBuffer.size, DEVICE_COMMUNICATE_TIMEOUT_MILLIS
|
usbBulkIn, inputBuffer, inputBuffer.size, DEVICE_COMMUNICATE_TIMEOUT_MILLIS
|
||||||
)
|
)
|
||||||
if (runBlocking { verboseLoggingFlow.first() }) {
|
Log.d(TAG, "Received " + readBytes + " bytes: " + inputBuffer.encodeHex())
|
||||||
Log.d(TAG, "Received $readBytes bytes: ${inputBuffer.encodeHex()}")
|
|
||||||
}
|
|
||||||
} while (readBytes <= 0 && attempts-- > 0)
|
} while (readBytes <= 0 && attempts-- > 0)
|
||||||
if (readBytes < CCID_HEADER_LENGTH) {
|
if (readBytes < CCID_HEADER_LENGTH) {
|
||||||
throw UsbTransportException("USB-CCID error - failed to receive CCID header")
|
throw UsbTransportException("USB-CCID error - failed to receive CCID header")
|
||||||
}
|
}
|
||||||
if (inputBuffer[0] != MESSAGE_TYPE_RDR_TO_PC_DATA_BLOCK.toByte()) {
|
if (inputBuffer[0] != MESSAGE_TYPE_RDR_TO_PC_DATA_BLOCK.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]) {
|
if (expectedSequenceNumber != inputBuffer[6]) {
|
||||||
append(", sequence number ")
|
throw UsbTransportException(
|
||||||
append("%d (expected %d)".format(inputBuffer[6], expectedSequenceNumber))
|
((("USB-CCID error - bad CCID header, type " + inputBuffer[0]) + " (expected " +
|
||||||
|
MESSAGE_TYPE_RDR_TO_PC_DATA_BLOCK) + "), sequence number " + inputBuffer[6]
|
||||||
|
) + " (expected " +
|
||||||
|
expectedSequenceNumber + ")"
|
||||||
|
)
|
||||||
}
|
}
|
||||||
})
|
throw UsbTransportException(
|
||||||
|
"USB-CCID error - bad CCID header type " + inputBuffer[0]
|
||||||
|
)
|
||||||
}
|
}
|
||||||
var result = CcidDataBlock.parseHeaderFromBytes(inputBuffer)
|
var result = CcidDataBlock.parseHeaderFromBytes(inputBuffer)
|
||||||
if (expectedSequenceNumber != result.bSeq) {
|
if (expectedSequenceNumber != result.bSeq) {
|
||||||
throw UsbTransportException("USB-CCID error - expected sequence number $expectedSequenceNumber, got $result")
|
throw UsbTransportException(
|
||||||
|
("USB-CCID error - expected sequence number " +
|
||||||
|
expectedSequenceNumber + ", got " + result)
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
val dataBuffer = ByteArray(result.dwLength)
|
val dataBuffer = ByteArray(result.dwLength)
|
||||||
|
@ -214,7 +212,9 @@ class UsbCcidTransceiver(
|
||||||
usbBulkIn, inputBuffer, inputBuffer.size, DEVICE_COMMUNICATE_TIMEOUT_MILLIS
|
usbBulkIn, inputBuffer, inputBuffer.size, DEVICE_COMMUNICATE_TIMEOUT_MILLIS
|
||||||
)
|
)
|
||||||
if (readBytes < 0) {
|
if (readBytes < 0) {
|
||||||
throw UsbTransportException("USB error - failed reading response data! Header: $result")
|
throw UsbTransportException(
|
||||||
|
"USB error - failed reading response data! Header: $result"
|
||||||
|
)
|
||||||
}
|
}
|
||||||
System.arraycopy(inputBuffer, 0, dataBuffer, bufferedBytes, readBytes)
|
System.arraycopy(inputBuffer, 0, dataBuffer, bufferedBytes, readBytes)
|
||||||
bufferedBytes += readBytes
|
bufferedBytes += readBytes
|
||||||
|
@ -279,7 +279,7 @@ class UsbCcidTransceiver(
|
||||||
}
|
}
|
||||||
val ccidDataBlock = receiveDataBlock(sequenceNumber)
|
val ccidDataBlock = receiveDataBlock(sequenceNumber)
|
||||||
val elapsedTime = SystemClock.elapsedRealtime() - startTime
|
val elapsedTime = SystemClock.elapsedRealtime() - startTime
|
||||||
Log.d(TAG, "USB XferBlock call took ${elapsedTime}ms")
|
Log.d(TAG, "USB XferBlock call took " + elapsedTime + "ms")
|
||||||
return ccidDataBlock
|
return ccidDataBlock
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -287,13 +287,13 @@ class UsbCcidTransceiver(
|
||||||
val startTime = SystemClock.elapsedRealtime()
|
val startTime = SystemClock.elapsedRealtime()
|
||||||
skipAvailableInput()
|
skipAvailableInput()
|
||||||
var response: CcidDataBlock? = null
|
var response: CcidDataBlock? = null
|
||||||
for (voltage in usbCcidDescription.voltages) {
|
for (v in usbCcidDescription.voltages) {
|
||||||
Log.v(TAG, "CCID: attempting to power on with voltage $voltage")
|
Log.v(TAG, "CCID: attempting to power on with voltage $v")
|
||||||
response = try {
|
response = try {
|
||||||
iccPowerOnVoltage(voltage.powerOnValue)
|
iccPowerOnVoltage(v.powerOnValue)
|
||||||
} catch (e: UsbCcidErrorException) {
|
} catch (e: UsbCcidErrorException) {
|
||||||
if (e.errorResponse.bError.toInt() == 7) { // Power select error
|
if (e.errorResponse.bError.toInt() == 7) { // Power select error
|
||||||
Log.v(TAG, "CCID: failed to power on with voltage $voltage")
|
Log.v(TAG, "CCID: failed to power on with voltage $v")
|
||||||
iccPowerOff()
|
iccPowerOff()
|
||||||
Log.v(TAG, "CCID: powered off")
|
Log.v(TAG, "CCID: powered off")
|
||||||
continue
|
continue
|
||||||
|
@ -308,11 +308,8 @@ class UsbCcidTransceiver(
|
||||||
val elapsedTime = SystemClock.elapsedRealtime() - startTime
|
val elapsedTime = SystemClock.elapsedRealtime() - startTime
|
||||||
Log.d(
|
Log.d(
|
||||||
TAG,
|
TAG,
|
||||||
buildString {
|
"Usb transport connected, took " + elapsedTime + "ms, ATR=" +
|
||||||
append("Usb transport connected")
|
response.data?.encodeHex()
|
||||||
append(", took ", elapsedTime, "ms")
|
|
||||||
append(", ATR=", response.data?.encodeHex())
|
|
||||||
}
|
|
||||||
)
|
)
|
||||||
return response
|
return response
|
||||||
}
|
}
|
||||||
|
|
|
@ -6,22 +6,31 @@ import android.hardware.usb.UsbDevice
|
||||||
import android.hardware.usb.UsbEndpoint
|
import android.hardware.usb.UsbEndpoint
|
||||||
import android.hardware.usb.UsbInterface
|
import android.hardware.usb.UsbInterface
|
||||||
|
|
||||||
class UsbTransportException(message: String) : Exception(message)
|
class UsbTransportException(msg: String) : Exception(msg)
|
||||||
|
|
||||||
val UsbDevice.interfaces: Iterable<UsbInterface>
|
fun UsbInterface.getIoEndpoints(): Pair<UsbEndpoint?, UsbEndpoint?> {
|
||||||
get() = (0 until interfaceCount).map(::getInterface)
|
var bulkIn: UsbEndpoint? = null
|
||||||
|
var bulkOut: UsbEndpoint? = null
|
||||||
val Iterable<UsbInterface>.smartCard: UsbInterface?
|
for (i in 0 until endpointCount) {
|
||||||
get() = find { it.interfaceClass == UsbConstants.USB_CLASS_CSCID }
|
val endpoint = getEndpoint(i)
|
||||||
|
if (endpoint.type != UsbConstants.USB_ENDPOINT_XFER_BULK) {
|
||||||
val UsbInterface.endpoints: Iterable<UsbEndpoint>
|
continue
|
||||||
get() = (0 until endpointCount).map(::getEndpoint)
|
|
||||||
|
|
||||||
val Iterable<UsbEndpoint>.bulkPair: Pair<UsbEndpoint?, UsbEndpoint?>
|
|
||||||
get() {
|
|
||||||
val endpoints = filter { it.type == UsbConstants.USB_ENDPOINT_XFER_BULK }
|
|
||||||
return Pair(
|
|
||||||
endpoints.find { it.direction == UsbConstants.USB_DIR_IN },
|
|
||||||
endpoints.find { it.direction == UsbConstants.USB_DIR_OUT },
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
if (endpoint.direction == UsbConstants.USB_DIR_IN) {
|
||||||
|
bulkIn = endpoint
|
||||||
|
} else if (endpoint.direction == UsbConstants.USB_DIR_OUT) {
|
||||||
|
bulkOut = endpoint
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return Pair(bulkIn, bulkOut)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun UsbDevice.getSmartCardInterface(): UsbInterface? {
|
||||||
|
for (i in 0 until interfaceCount) {
|
||||||
|
val anInterface = getInterface(i)
|
||||||
|
if (anInterface.interfaceClass == UsbConstants.USB_CLASS_CSCID) {
|
||||||
|
return anInterface
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null
|
||||||
|
}
|
|
@ -15,5 +15,4 @@ interface AppContainer {
|
||||||
val preferenceRepository: PreferenceRepository
|
val preferenceRepository: PreferenceRepository
|
||||||
val uiComponentFactory: UiComponentFactory
|
val uiComponentFactory: UiComponentFactory
|
||||||
val euiccChannelFactory: EuiccChannelFactory
|
val euiccChannelFactory: EuiccChannelFactory
|
||||||
val customizableTextProvider: CustomizableTextProvider
|
|
||||||
}
|
}
|
|
@ -1,20 +0,0 @@
|
||||||
package im.angry.openeuicc.di
|
|
||||||
|
|
||||||
interface CustomizableTextProvider {
|
|
||||||
/**
|
|
||||||
* Explanation string for when no eUICC is found on the device.
|
|
||||||
* This could be different depending on whether the app is privileged or not.
|
|
||||||
*/
|
|
||||||
val noEuiccExplanation: String
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Shown when we timed out switching between profiles.
|
|
||||||
*/
|
|
||||||
val profileSwitchingTimeoutMessage: String
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Format the name of a logical slot; internal only -- not intended for
|
|
||||||
* other channels such as USB.
|
|
||||||
*/
|
|
||||||
fun formatInternalChannelName(logicalSlotId: Int): String
|
|
||||||
}
|
|
|
@ -38,8 +38,4 @@ open class DefaultAppContainer(context: Context) : AppContainer {
|
||||||
override val euiccChannelFactory by lazy {
|
override val euiccChannelFactory by lazy {
|
||||||
DefaultEuiccChannelFactory(context)
|
DefaultEuiccChannelFactory(context)
|
||||||
}
|
}
|
||||||
|
|
||||||
override val customizableTextProvider by lazy {
|
|
||||||
DefaultCustomizableTextProvider(context)
|
|
||||||
}
|
|
||||||
}
|
}
|
|
@ -1,15 +0,0 @@
|
||||||
package im.angry.openeuicc.di
|
|
||||||
|
|
||||||
import android.content.Context
|
|
||||||
import im.angry.openeuicc.common.R
|
|
||||||
|
|
||||||
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)
|
|
||||||
|
|
||||||
override fun formatInternalChannelName(logicalSlotId: Int): String =
|
|
||||||
context.getString(R.string.channel_name_format, logicalSlotId)
|
|
||||||
}
|
|
|
@ -1,16 +1,13 @@
|
||||||
package im.angry.openeuicc.di
|
package im.angry.openeuicc.di
|
||||||
|
|
||||||
import androidx.fragment.app.Fragment
|
import androidx.fragment.app.Fragment
|
||||||
import androidx.preference.PreferenceFragmentCompat
|
import im.angry.openeuicc.core.EuiccChannel
|
||||||
import im.angry.openeuicc.ui.EuiccManagementFragment
|
import im.angry.openeuicc.ui.EuiccManagementFragment
|
||||||
import im.angry.openeuicc.ui.NoEuiccPlaceholderFragment
|
import im.angry.openeuicc.ui.NoEuiccPlaceholderFragment
|
||||||
import im.angry.openeuicc.ui.SettingsFragment
|
|
||||||
|
|
||||||
open class DefaultUiComponentFactory : UiComponentFactory {
|
open class DefaultUiComponentFactory : UiComponentFactory {
|
||||||
override fun createEuiccManagementFragment(slotId: Int, portId: Int): EuiccManagementFragment =
|
override fun createEuiccManagementFragment(channel: EuiccChannel): EuiccManagementFragment =
|
||||||
EuiccManagementFragment.newInstance(slotId, portId)
|
EuiccManagementFragment.newInstance(channel.slotId, channel.portId)
|
||||||
|
|
||||||
override fun createNoEuiccPlaceholderFragment(): Fragment = NoEuiccPlaceholderFragment()
|
override fun createNoEuiccPlaceholderFragment(): Fragment = NoEuiccPlaceholderFragment()
|
||||||
|
|
||||||
override fun createSettingsFragment(): Fragment = SettingsFragment()
|
|
||||||
}
|
}
|
|
@ -1,11 +1,10 @@
|
||||||
package im.angry.openeuicc.di
|
package im.angry.openeuicc.di
|
||||||
|
|
||||||
import androidx.fragment.app.Fragment
|
import androidx.fragment.app.Fragment
|
||||||
import androidx.preference.PreferenceFragmentCompat
|
import im.angry.openeuicc.core.EuiccChannel
|
||||||
import im.angry.openeuicc.ui.EuiccManagementFragment
|
import im.angry.openeuicc.ui.EuiccManagementFragment
|
||||||
|
|
||||||
interface UiComponentFactory {
|
interface UiComponentFactory {
|
||||||
fun createEuiccManagementFragment(slotId: Int, portId: Int): EuiccManagementFragment
|
fun createEuiccManagementFragment(channel: EuiccChannel): EuiccManagementFragment
|
||||||
fun createNoEuiccPlaceholderFragment(): Fragment
|
fun createNoEuiccPlaceholderFragment(): Fragment
|
||||||
fun createSettingsFragment(): Fragment
|
|
||||||
}
|
}
|
|
@ -1,42 +1,11 @@
|
||||||
package im.angry.openeuicc.service
|
package im.angry.openeuicc.service
|
||||||
|
|
||||||
|
import android.app.Service
|
||||||
import android.content.Intent
|
import android.content.Intent
|
||||||
import android.content.pm.PackageManager
|
|
||||||
import android.os.Binder
|
import android.os.Binder
|
||||||
import android.os.IBinder
|
import android.os.IBinder
|
||||||
import android.os.PowerManager
|
|
||||||
import android.util.Log
|
|
||||||
import androidx.core.app.NotificationChannelCompat
|
|
||||||
import androidx.core.app.NotificationCompat
|
|
||||||
import androidx.core.app.NotificationManagerCompat
|
|
||||||
import androidx.lifecycle.LifecycleService
|
|
||||||
import androidx.lifecycle.lifecycleScope
|
|
||||||
import im.angry.openeuicc.common.R
|
|
||||||
import im.angry.openeuicc.core.EuiccChannelManager
|
import im.angry.openeuicc.core.EuiccChannelManager
|
||||||
import im.angry.openeuicc.util.*
|
import im.angry.openeuicc.util.*
|
||||||
import kotlinx.coroutines.Dispatchers
|
|
||||||
import kotlinx.coroutines.NonCancellable
|
|
||||||
import kotlinx.coroutines.channels.BufferOverflow
|
|
||||||
import kotlinx.coroutines.delay
|
|
||||||
import kotlinx.coroutines.flow.Flow
|
|
||||||
import kotlinx.coroutines.flow.MutableSharedFlow
|
|
||||||
import kotlinx.coroutines.flow.MutableStateFlow
|
|
||||||
import kotlinx.coroutines.flow.SharedFlow
|
|
||||||
import kotlinx.coroutines.flow.asSharedFlow
|
|
||||||
import kotlinx.coroutines.flow.collect
|
|
||||||
import kotlinx.coroutines.flow.first
|
|
||||||
import kotlinx.coroutines.flow.flow
|
|
||||||
import kotlinx.coroutines.flow.last
|
|
||||||
import kotlinx.coroutines.flow.onCompletion
|
|
||||||
import kotlinx.coroutines.flow.onEach
|
|
||||||
import kotlinx.coroutines.flow.takeWhile
|
|
||||||
import kotlinx.coroutines.flow.transformWhile
|
|
||||||
import kotlinx.coroutines.isActive
|
|
||||||
import kotlinx.coroutines.launch
|
|
||||||
import kotlinx.coroutines.withContext
|
|
||||||
import kotlinx.coroutines.withTimeoutOrNull
|
|
||||||
import kotlinx.coroutines.yield
|
|
||||||
import net.typeblog.lpac_jni.ProfileDownloadCallback
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* An Android Service wrapper for EuiccChannelManager.
|
* An Android Service wrapper for EuiccChannelManager.
|
||||||
|
@ -48,41 +17,8 @@ import net.typeblog.lpac_jni.ProfileDownloadCallback
|
||||||
* instance of EuiccChannelManager. UI components can keep being bound to this service for
|
* instance of EuiccChannelManager. UI components can keep being bound to this service for
|
||||||
* their entire lifecycles, since the whole purpose of them is to expose the current state
|
* their entire lifecycles, since the whole purpose of them is to expose the current state
|
||||||
* to the user.
|
* to the user.
|
||||||
*
|
|
||||||
* Additionally, this service is also responsible for long-running "foreground" tasks that
|
|
||||||
* are not suitable to be managed by UI components. This includes profile downloading, etc.
|
|
||||||
* When a UI component needs to run one of these tasks, they have to bind to this service
|
|
||||||
* and call one of the `launch*` methods, which will run the task inside this service's
|
|
||||||
* lifecycle context and return a Flow instance for the UI component to subscribe to its
|
|
||||||
* progress.
|
|
||||||
*/
|
*/
|
||||||
class EuiccChannelManagerService : LifecycleService(), OpenEuiccContextMarker {
|
class EuiccChannelManagerService : Service(), OpenEuiccContextMarker {
|
||||||
companion object {
|
|
||||||
private const val TAG = "EuiccChannelManagerService"
|
|
||||||
private const val CHANNEL_ID = "tasks"
|
|
||||||
private const val FOREGROUND_ID = 1000
|
|
||||||
private const val TASK_FAILURE_ID = 1000
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Utility function to wait for a foreground task to be done, return its
|
|
||||||
* error if any, or null on success.
|
|
||||||
*/
|
|
||||||
suspend fun Flow<ForegroundTaskState>.waitDone(): Throwable? =
|
|
||||||
(this.last() as ForegroundTaskState.Done).error
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Apply transform to a ForegroundTaskState flow so that it completes when a Done is seen.
|
|
||||||
*
|
|
||||||
* This must be applied each time a flow is returned for subscription purposes. If applied
|
|
||||||
* beforehand, we lose the ability to subscribe multiple times.
|
|
||||||
*/
|
|
||||||
private fun Flow<ForegroundTaskState>.applyCompletionTransform() =
|
|
||||||
transformWhile {
|
|
||||||
emit(it)
|
|
||||||
it !is ForegroundTaskState.Done
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
inner class LocalBinder : Binder() {
|
inner class LocalBinder : Binder() {
|
||||||
val service = this@EuiccChannelManagerService
|
val service = this@EuiccChannelManagerService
|
||||||
}
|
}
|
||||||
|
@ -92,436 +28,14 @@ class EuiccChannelManagerService : LifecycleService(), OpenEuiccContextMarker {
|
||||||
}
|
}
|
||||||
val euiccChannelManager: EuiccChannelManager by euiccChannelManagerDelegate
|
val euiccChannelManager: EuiccChannelManager by euiccChannelManagerDelegate
|
||||||
|
|
||||||
private val wakeLock: PowerManager.WakeLock by lazy {
|
override fun onBind(intent: Intent?): IBinder = LocalBinder()
|
||||||
(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) : ForegroundTaskState
|
|
||||||
data class Done(val error: Throwable?) : ForegroundTaskState
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* This flow emits whenever the service has had a start command, from startService()
|
|
||||||
* The service self-starts when foreground is required, because other components
|
|
||||||
* only bind to this service and do not start it per-se.
|
|
||||||
*/
|
|
||||||
private val foregroundStarted: MutableSharedFlow<Unit> = MutableSharedFlow()
|
|
||||||
|
|
||||||
/**
|
|
||||||
* This flow is used to emit progress updates when a foreground task is running.
|
|
||||||
*/
|
|
||||||
private val foregroundTaskState: MutableStateFlow<ForegroundTaskState> =
|
|
||||||
MutableStateFlow(ForegroundTaskState.Idle)
|
|
||||||
|
|
||||||
/**
|
|
||||||
* A simple wrapper over a flow with taskId added.
|
|
||||||
*
|
|
||||||
* taskID is the exact millisecond-precision timestamp when the task is launched.
|
|
||||||
*/
|
|
||||||
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
|
|
||||||
*
|
|
||||||
* Only one can be run at the same time, but those that are done will be kept in this
|
|
||||||
* map for a little while -- because UI components may be stopped and recreated while
|
|
||||||
* tasks are running. Having this buffer allows the components to re-subscribe even if
|
|
||||||
* the task completes while they are being recreated.
|
|
||||||
*/
|
|
||||||
private val foregroundTaskSubscribers: MutableMap<Long, SharedFlow<ForegroundTaskState>> =
|
|
||||||
mutableMapOf()
|
|
||||||
|
|
||||||
override fun onBind(intent: Intent): IBinder {
|
|
||||||
super.onBind(intent)
|
|
||||||
return LocalBinder()
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun onDestroy() {
|
override fun onDestroy() {
|
||||||
super.onDestroy()
|
super.onDestroy()
|
||||||
|
// This is the whole reason of the existence of this service:
|
||||||
|
// we can clean up opened channels when no one is using them
|
||||||
if (euiccChannelManagerDelegate.isInitialized()) {
|
if (euiccChannelManagerDelegate.isInitialized()) {
|
||||||
euiccChannelManager.invalidate()
|
euiccChannelManager.invalidate()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
|
|
||||||
return super.onStartCommand(intent, flags, startId).also {
|
|
||||||
lifecycleScope.launch {
|
|
||||||
foregroundStarted.emit(Unit)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun ensureForegroundTaskNotificationChannel() {
|
|
||||||
val nm = NotificationManagerCompat.from(this)
|
|
||||||
if (nm.getNotificationChannelCompat(CHANNEL_ID) == null) {
|
|
||||||
val channel =
|
|
||||||
NotificationChannelCompat.Builder(
|
|
||||||
CHANNEL_ID,
|
|
||||||
NotificationManagerCompat.IMPORTANCE_LOW
|
|
||||||
)
|
|
||||||
.setName(getString(R.string.task_notification))
|
|
||||||
.setVibrationEnabled(false)
|
|
||||||
.build()
|
|
||||||
nm.createNotificationChannel(channel)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private suspend fun updateForegroundNotification(title: String, iconRes: Int) {
|
|
||||||
ensureForegroundTaskNotificationChannel()
|
|
||||||
|
|
||||||
val nm = NotificationManagerCompat.from(this)
|
|
||||||
val state = foregroundTaskState.value
|
|
||||||
|
|
||||||
if (state is ForegroundTaskState.InProgress) {
|
|
||||||
val notification = NotificationCompat.Builder(this, CHANNEL_ID)
|
|
||||||
.setContentTitle(title)
|
|
||||||
.setProgress(100, state.progress, state.progress == 0)
|
|
||||||
.setSmallIcon(iconRes)
|
|
||||||
.setPriority(NotificationCompat.PRIORITY_LOW)
|
|
||||||
.setOngoing(true)
|
|
||||||
.setOnlyAlertOnce(true)
|
|
||||||
.build()
|
|
||||||
|
|
||||||
if (state.progress == 0) {
|
|
||||||
startForeground(FOREGROUND_ID, notification)
|
|
||||||
} else if (checkSelfPermission(android.Manifest.permission.POST_NOTIFICATIONS) == PackageManager.PERMISSION_GRANTED) {
|
|
||||||
nm.notify(FOREGROUND_ID, notification)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Yield out so that the main looper can handle the notification event
|
|
||||||
// Without this yield, the notification sent above will not be shown in time.
|
|
||||||
yield()
|
|
||||||
} else {
|
|
||||||
stopForeground(STOP_FOREGROUND_REMOVE)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun postForegroundTaskFailureNotification(title: String) {
|
|
||||||
if (checkSelfPermission(android.Manifest.permission.POST_NOTIFICATIONS) != PackageManager.PERMISSION_GRANTED) {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
val notification = NotificationCompat.Builder(this, CHANNEL_ID)
|
|
||||||
.setContentTitle(title)
|
|
||||||
.setSmallIcon(R.drawable.ic_x_black)
|
|
||||||
.build()
|
|
||||||
NotificationManagerCompat.from(this).notify(TASK_FAILURE_ID, notification)
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 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): ForegroundTaskSubscriberFlow? =
|
|
||||||
foregroundTaskSubscribers[taskId]?.let {
|
|
||||||
ForegroundTaskSubscriberFlow(taskId, it.applyCompletionTransform())
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Launch a potentially blocking foreground task in this service's lifecycle context.
|
|
||||||
* This function does not block, but returns a Flow that emits ForegroundTaskState
|
|
||||||
* updates associated with this task. The last update the returned flow will emit is
|
|
||||||
* always ForegroundTaskState.Done.
|
|
||||||
*
|
|
||||||
* The returned flow can only be subscribed to once even though the underlying implementation
|
|
||||||
* is a SharedFlow. This is due to the need to apply transformations so that the stream
|
|
||||||
* actually completes. In order to subscribe multiple times, use `recoverForegroundTaskSubscriber`
|
|
||||||
* to acquire another instance.
|
|
||||||
*
|
|
||||||
* The task closure is expected to update foregroundTaskState whenever appropriate.
|
|
||||||
* If a foreground task is already running, this function returns null.
|
|
||||||
*
|
|
||||||
* To wait for foreground tasks to be available, use waitForForegroundTask().
|
|
||||||
*
|
|
||||||
* The function will set the state back to Idle once it sees ForegroundTaskState.Done.
|
|
||||||
*/
|
|
||||||
private fun launchForegroundTask(
|
|
||||||
title: String,
|
|
||||||
failureTitle: String,
|
|
||||||
iconRes: Int,
|
|
||||||
task: suspend EuiccChannelManagerService.() -> Unit
|
|
||||||
): ForegroundTaskSubscriberFlow {
|
|
||||||
val taskID = System.currentTimeMillis()
|
|
||||||
|
|
||||||
// Atomically set the state to InProgress. If this returns true, we are
|
|
||||||
// the only task currently in progress.
|
|
||||||
if (!foregroundTaskState.compareAndSet(
|
|
||||||
ForegroundTaskState.Idle,
|
|
||||||
ForegroundTaskState.InProgress(0)
|
|
||||||
)
|
|
||||||
) {
|
|
||||||
return ForegroundTaskSubscriberFlow(
|
|
||||||
taskID,
|
|
||||||
flow { emit(ForegroundTaskState.Done(IllegalStateException("There are tasks currently running"))) })
|
|
||||||
}
|
|
||||||
|
|
||||||
lifecycleScope.launch(Dispatchers.Main) {
|
|
||||||
// Wait until our self-start command has succeeded.
|
|
||||||
// We can only call startForeground() after that
|
|
||||||
val res = withTimeoutOrNull(30 * 1000) {
|
|
||||||
foregroundStarted.first()
|
|
||||||
}
|
|
||||||
|
|
||||||
if (res == null) {
|
|
||||||
// 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.
|
|
||||||
foregroundTaskState.value = ForegroundTaskState.Idle
|
|
||||||
return@launch
|
|
||||||
}
|
|
||||||
|
|
||||||
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()
|
|
||||||
}
|
|
||||||
// This update will be sent by the subscriber (as shown below)
|
|
||||||
foregroundTaskState.value = ForegroundTaskState.Done(null)
|
|
||||||
} catch (t: Throwable) {
|
|
||||||
Log.e(TAG, "Foreground task encountered an error")
|
|
||||||
Log.e(TAG, Log.getStackTraceString(t))
|
|
||||||
foregroundTaskState.value = ForegroundTaskState.Done(t)
|
|
||||||
|
|
||||||
if (isActive) {
|
|
||||||
postForegroundTaskFailureNotification(failureTitle)
|
|
||||||
}
|
|
||||||
} finally {
|
|
||||||
wakeLock.release()
|
|
||||||
if (isActive) {
|
|
||||||
stopSelf()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 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 subscriberFlow = MutableSharedFlow<ForegroundTaskState>(
|
|
||||||
replay = 2,
|
|
||||||
onBufferOverflow = BufferOverflow.DROP_OLDEST
|
|
||||||
)
|
|
||||||
|
|
||||||
// We should be the only task running, so we can subscribe to foregroundTaskState
|
|
||||||
// until we encounter ForegroundTaskState.Done.
|
|
||||||
// Then, we complete the returned flow, but we also set the state back to Idle.
|
|
||||||
// The state update back to Idle won't show up in the returned stream, because
|
|
||||||
// it has been completed by that point.
|
|
||||||
lifecycleScope.launch(Dispatchers.Main) {
|
|
||||||
foregroundTaskState
|
|
||||||
.applyCompletionTransform()
|
|
||||||
.onEach {
|
|
||||||
// Also update our notification when we see an update
|
|
||||||
// But ignore the first progress = 0 update -- that is the current value.
|
|
||||||
// we need that to be handled by the main coroutine after it finishes.
|
|
||||||
if (it !is ForegroundTaskState.InProgress || it.progress != 0) {
|
|
||||||
updateForegroundNotification(title, iconRes)
|
|
||||||
}
|
|
||||||
|
|
||||||
subscriberFlow.emit(it)
|
|
||||||
}
|
|
||||||
.onCompletion {
|
|
||||||
// Reset state back to Idle when we are done.
|
|
||||||
// We do it here because otherwise Idle and Done might become conflated
|
|
||||||
// when emitted by the main coroutine in quick succession.
|
|
||||||
// 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
|
|
||||||
}
|
|
||||||
.collect()
|
|
||||||
}
|
|
||||||
|
|
||||||
foregroundTaskSubscribers[taskID] = subscriberFlow.asSharedFlow()
|
|
||||||
|
|
||||||
if (foregroundTaskSubscribers.size > 5) {
|
|
||||||
// Remove enough elements so that the size is kept at 5
|
|
||||||
for (key in foregroundTaskSubscribers.keys.sorted()
|
|
||||||
.take(foregroundTaskSubscribers.size - 5)) {
|
|
||||||
foregroundTaskSubscribers.remove(key)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Before we return, and after we have set everything up,
|
|
||||||
// self-start with foreground permission.
|
|
||||||
// This is going to unblock the main coroutine handling the task.
|
|
||||||
startForegroundService(
|
|
||||||
Intent(
|
|
||||||
this@EuiccChannelManagerService,
|
|
||||||
this@EuiccChannelManagerService::class.java
|
|
||||||
)
|
|
||||||
)
|
|
||||||
|
|
||||||
return ForegroundTaskSubscriberFlow(
|
|
||||||
taskID,
|
|
||||||
subscriberFlow.asSharedFlow().applyCompletionTransform()
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
suspend fun waitForForegroundTask() {
|
|
||||||
foregroundTaskState.takeWhile { it != ForegroundTaskState.Idle }
|
|
||||||
.collect()
|
|
||||||
}
|
|
||||||
|
|
||||||
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
|
|
||||||
) {
|
|
||||||
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)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
preferenceRepository.notificationDownloadFlow.first()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fun launchProfileRenameTask(
|
|
||||||
slotId: Int,
|
|
||||||
portId: Int,
|
|
||||||
iccid: String,
|
|
||||||
name: String
|
|
||||||
): ForegroundTaskSubscriberFlow =
|
|
||||||
launchForegroundTask(
|
|
||||||
getString(R.string.task_profile_rename),
|
|
||||||
getString(R.string.task_profile_rename_failure),
|
|
||||||
R.drawable.ic_task_rename
|
|
||||||
) {
|
|
||||||
euiccChannelManager.withEuiccChannel(slotId, portId) { channel ->
|
|
||||||
channel.lpa.setNickname(
|
|
||||||
iccid,
|
|
||||||
name
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fun launchProfileDeleteTask(
|
|
||||||
slotId: Int,
|
|
||||||
portId: Int,
|
|
||||||
iccid: String
|
|
||||||
): ForegroundTaskSubscriberFlow =
|
|
||||||
launchForegroundTask(
|
|
||||||
getString(R.string.task_profile_delete),
|
|
||||||
getString(R.string.task_profile_delete_failure),
|
|
||||||
R.drawable.ic_task_delete
|
|
||||||
) {
|
|
||||||
euiccChannelManager.beginTrackedOperation(slotId, portId) {
|
|
||||||
euiccChannelManager.withEuiccChannel(slotId, portId) { channel ->
|
|
||||||
channel.lpa.deleteProfile(iccid)
|
|
||||||
}
|
|
||||||
|
|
||||||
preferenceRepository.notificationDeleteFlow.first()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
class SwitchingProfilesRefreshException : Exception()
|
|
||||||
|
|
||||||
fun launchProfileSwitchTask(
|
|
||||||
slotId: Int,
|
|
||||||
portId: Int,
|
|
||||||
iccid: String,
|
|
||||||
enable: Boolean, // Enable or disable the profile indicated in iccid
|
|
||||||
reconnectTimeoutMillis: Long = 0 // 0 = do not wait for reconnect
|
|
||||||
) =
|
|
||||||
launchForegroundTask(
|
|
||||||
getString(R.string.task_profile_switch),
|
|
||||||
getString(R.string.task_profile_switch_failure),
|
|
||||||
R.drawable.ic_task_switch
|
|
||||||
) {
|
|
||||||
euiccChannelManager.beginTrackedOperation(slotId, portId) {
|
|
||||||
val (response, refreshed) =
|
|
||||||
euiccChannelManager.withEuiccChannel(slotId, portId) { 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
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!response) {
|
|
||||||
throw RuntimeException("Could not switch profile")
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!refreshed && slotId != EuiccChannelManager.USB_CHANNEL_ID) {
|
|
||||||
// We may have switched the profile, but we could not refresh. Tell the caller about this
|
|
||||||
// but only if we are talking to a modem and not a USB reader
|
|
||||||
throw SwitchingProfilesRefreshException()
|
|
||||||
}
|
|
||||||
|
|
||||||
if (reconnectTimeoutMillis > 0) {
|
|
||||||
// Add an unconditional delay first to account for any race condition between
|
|
||||||
// the card sending the refresh command and the modem actually refreshing
|
|
||||||
delay(reconnectTimeoutMillis / 10)
|
|
||||||
|
|
||||||
// This throws TimeoutCancellationException if timed out
|
|
||||||
euiccChannelManager.waitForReconnect(
|
|
||||||
slotId,
|
|
||||||
portId,
|
|
||||||
reconnectTimeoutMillis / 10 * 9
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
preferenceRepository.notificationSwitchFlow.first()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
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) {
|
|
||||||
euiccChannelManager.withEuiccChannel(slotId, portId) { channel ->
|
|
||||||
channel.lpa.euiccMemoryReset()
|
|
||||||
}
|
|
||||||
|
|
||||||
preferenceRepository.notificationDeleteFlow.first()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
|
@ -9,18 +9,14 @@ import android.os.IBinder
|
||||||
import androidx.appcompat.app.AppCompatActivity
|
import androidx.appcompat.app.AppCompatActivity
|
||||||
import im.angry.openeuicc.core.EuiccChannelManager
|
import im.angry.openeuicc.core.EuiccChannelManager
|
||||||
import im.angry.openeuicc.service.EuiccChannelManagerService
|
import im.angry.openeuicc.service.EuiccChannelManagerService
|
||||||
import kotlinx.coroutines.CompletableDeferred
|
|
||||||
|
|
||||||
abstract class BaseEuiccAccessActivity : AppCompatActivity() {
|
abstract class BaseEuiccAccessActivity : AppCompatActivity() {
|
||||||
val euiccChannelManagerLoaded = CompletableDeferred<Unit>()
|
|
||||||
lateinit var euiccChannelManager: EuiccChannelManager
|
lateinit var euiccChannelManager: EuiccChannelManager
|
||||||
lateinit var euiccChannelManagerService: EuiccChannelManagerService
|
|
||||||
|
|
||||||
private val euiccChannelManagerServiceConnection = object : ServiceConnection {
|
private val euiccChannelManagerServiceConnection = object : ServiceConnection {
|
||||||
override fun onServiceConnected(name: ComponentName?, service: IBinder?) {
|
override fun onServiceConnected(name: ComponentName?, service: IBinder?) {
|
||||||
euiccChannelManagerService = (service!! as EuiccChannelManagerService.LocalBinder).service
|
euiccChannelManager =
|
||||||
euiccChannelManager = euiccChannelManagerService.euiccChannelManager
|
(service!! as EuiccChannelManagerService.LocalBinder).service.euiccChannelManager
|
||||||
euiccChannelManagerLoaded.complete(Unit)
|
|
||||||
onInit()
|
onInit()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -0,0 +1,40 @@
|
||||||
|
package im.angry.openeuicc.ui
|
||||||
|
|
||||||
|
import androidx.lifecycle.lifecycleScope
|
||||||
|
import im.angry.openeuicc.util.*
|
||||||
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
import kotlinx.coroutines.withContext
|
||||||
|
|
||||||
|
class DirectProfileDownloadActivity : BaseEuiccAccessActivity(), SlotSelectFragment.SlotSelectedListener, OpenEuiccContextMarker {
|
||||||
|
override fun onInit() {
|
||||||
|
lifecycleScope.launch {
|
||||||
|
val knownChannels = withContext(Dispatchers.IO) {
|
||||||
|
euiccChannelManager.enumerateEuiccChannels()
|
||||||
|
}
|
||||||
|
|
||||||
|
when {
|
||||||
|
knownChannels.isEmpty() -> {
|
||||||
|
finish()
|
||||||
|
}
|
||||||
|
knownChannels.hasMultipleChips -> {
|
||||||
|
SlotSelectFragment.newInstance(knownChannels.sortedBy { it.logicalSlotId })
|
||||||
|
.show(supportFragmentManager, SlotSelectFragment.TAG)
|
||||||
|
}
|
||||||
|
else -> {
|
||||||
|
// If the device has only one eSIM "chip" (but may be mapped to multiple slots),
|
||||||
|
// we can skip the slot selection dialog since there is only one chip to save to.
|
||||||
|
onSlotSelected(knownChannels[0].slotId,
|
||||||
|
knownChannels[0].portId)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onSlotSelected(slotId: Int, portId: Int) {
|
||||||
|
ProfileDownloadFragment.newInstance(slotId, portId, finishWhenDone = true)
|
||||||
|
.show(supportFragmentManager, ProfileDownloadFragment.TAG)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onSlotSelectCancelled() = finish()
|
||||||
|
}
|
|
@ -1,204 +0,0 @@
|
||||||
package im.angry.openeuicc.ui
|
|
||||||
|
|
||||||
import android.annotation.SuppressLint
|
|
||||||
import android.content.ClipData
|
|
||||||
import android.content.ClipboardManager
|
|
||||||
import android.os.Build
|
|
||||||
import android.os.Bundle
|
|
||||||
import android.view.LayoutInflater
|
|
||||||
import android.view.MenuItem
|
|
||||||
import android.view.View
|
|
||||||
import android.view.ViewGroup
|
|
||||||
import android.widget.TextView
|
|
||||||
import android.widget.Toast
|
|
||||||
import androidx.activity.enableEdgeToEdge
|
|
||||||
import androidx.annotation.StringRes
|
|
||||||
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 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.launch
|
|
||||||
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 lateinit var swipeRefresh: SwipeRefreshLayout
|
|
||||||
private lateinit var infoList: RecyclerView
|
|
||||||
|
|
||||||
private var logicalSlotId: Int = -1
|
|
||||||
|
|
||||||
data class Item(
|
|
||||||
@StringRes
|
|
||||||
val titleResId: Int,
|
|
||||||
val content: String?,
|
|
||||||
val copiedToastResId: Int? = null,
|
|
||||||
)
|
|
||||||
|
|
||||||
override fun onCreate(savedInstanceState: Bundle?) {
|
|
||||||
enableEdgeToEdge()
|
|
||||||
super.onCreate(savedInstanceState)
|
|
||||||
setContentView(R.layout.activity_euicc_info)
|
|
||||||
setSupportActionBar(requireViewById(R.id.toolbar))
|
|
||||||
setupToolbarInsets()
|
|
||||||
supportActionBar!!.setDisplayHomeAsUpEnabled(true)
|
|
||||||
|
|
||||||
swipeRefresh = requireViewById(R.id.swipe_refresh)
|
|
||||||
infoList = requireViewById<RecyclerView>(R.id.recycler_view).also {
|
|
||||||
it.layoutManager = LinearLayoutManager(this, LinearLayoutManager.VERTICAL, false)
|
|
||||||
it.addItemDecoration(DividerItemDecoration(this, LinearLayoutManager.VERTICAL))
|
|
||||||
it.adapter = EuiccInfoAdapter()
|
|
||||||
}
|
|
||||||
|
|
||||||
logicalSlotId = intent.getIntExtra("logicalSlotId", 0)
|
|
||||||
|
|
||||||
val channelTitle = if (logicalSlotId == EuiccChannelManager.USB_CHANNEL_ID) {
|
|
||||||
getString(R.string.channel_type_usb)
|
|
||||||
} else {
|
|
||||||
appContainer.customizableTextProvider.formatInternalChannelName(logicalSlotId)
|
|
||||||
}
|
|
||||||
|
|
||||||
title = getString(R.string.euicc_info_activity_title, channelTitle)
|
|
||||||
|
|
||||||
swipeRefresh.setOnRefreshListener { refresh() }
|
|
||||||
|
|
||||||
setupRootViewInsets(infoList)
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun onOptionsItemSelected(item: MenuItem): Boolean = when (item.itemId) {
|
|
||||||
android.R.id.home -> {
|
|
||||||
finish()
|
|
||||||
true
|
|
||||||
}
|
|
||||||
|
|
||||||
else -> super.onOptionsItemSelected(item)
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun onInit() {
|
|
||||||
refresh()
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun refresh() {
|
|
||||||
swipeRefresh.isRefreshing = true
|
|
||||||
|
|
||||||
lifecycleScope.launch {
|
|
||||||
(infoList.adapter!! as EuiccInfoAdapter).euiccInfoItems =
|
|
||||||
euiccChannelManager.withEuiccChannel(logicalSlotId, ::buildEuiccInfoItems)
|
|
||||||
|
|
||||||
swipeRefresh.isRefreshing = false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun buildEuiccInfoItems(channel: EuiccChannel) = buildList {
|
|
||||||
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))
|
|
||||||
add(Item(R.string.euicc_info_isdr_aid, channel.isdrAid.encodeHex()))
|
|
||||||
channel.tryParseEuiccVendorInfo()?.let { vendorInfo ->
|
|
||||||
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.firmwareVersion?.let { add(Item(R.string.euicc_info_fw_ver, it)) }
|
|
||||||
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()))
|
|
||||||
add(Item(R.string.euicc_info_firmware_version, info.euiccFirmwareVersion.toString()))
|
|
||||||
add(Item(R.string.euicc_info_gp_version, info.globalPlatformVersion.toString()))
|
|
||||||
add(Item(R.string.euicc_info_pp_version, info.ppVersion.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?.euiccCiPKIdListForSigning.orEmpty().let { signers ->
|
|
||||||
// SGP.28 v1.0, eSIM CI Registration Criteria (Page 5 of 9, 2019-10-24)
|
|
||||||
// https://www.gsma.com/newsroom/wp-content/uploads/SGP.28-v1.0.pdf#page=5
|
|
||||||
// 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'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)
|
|
||||||
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)
|
|
||||||
|
|
||||||
inner class EuiccInfoViewHolder(root: View) : ViewHolder(root) {
|
|
||||||
private val title: TextView = root.requireViewById(R.id.euicc_info_title)
|
|
||||||
private val content: TextView = root.requireViewById(R.id.euicc_info_content)
|
|
||||||
private var copiedToastResId: Int? = null
|
|
||||||
|
|
||||||
init {
|
|
||||||
root.setOnClickListener {
|
|
||||||
if (copiedToastResId != null) {
|
|
||||||
val label = title.text.toString()
|
|
||||||
getSystemService(ClipboardManager::class.java)!!
|
|
||||||
.setPrimaryClip(ClipData.newPlainText(label, content.text))
|
|
||||||
if (Build.VERSION.SDK_INT <= Build.VERSION_CODES.S_V2) {
|
|
||||||
Toast.makeText(
|
|
||||||
this@EuiccInfoActivity,
|
|
||||||
copiedToastResId!!,
|
|
||||||
Toast.LENGTH_SHORT
|
|
||||||
).show()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fun bind(item: Item) {
|
|
||||||
copiedToastResId = item.copiedToastResId
|
|
||||||
title.setText(item.titleResId)
|
|
||||||
content.text = item.content ?: getString(R.string.euicc_info_unknown)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
inner class EuiccInfoAdapter : RecyclerView.Adapter<EuiccInfoViewHolder>() {
|
|
||||||
var euiccInfoItems: List<Item> = listOf()
|
|
||||||
@SuppressLint("NotifyDataSetChanged")
|
|
||||||
set(newVal) {
|
|
||||||
field = newVal
|
|
||||||
notifyDataSetChanged()
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): EuiccInfoViewHolder {
|
|
||||||
val root = LayoutInflater.from(parent.context)
|
|
||||||
.inflate(R.layout.euicc_info_item, parent, false)
|
|
||||||
return EuiccInfoViewHolder(root)
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun getItemCount(): Int = euiccInfoItems.size
|
|
||||||
|
|
||||||
override fun onBindViewHolder(holder: EuiccInfoViewHolder, position: Int) {
|
|
||||||
holder.bind(euiccInfoItems[position])
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -4,9 +4,9 @@ import android.annotation.SuppressLint
|
||||||
import android.content.ClipData
|
import android.content.ClipData
|
||||||
import android.content.ClipboardManager
|
import android.content.ClipboardManager
|
||||||
import android.content.Intent
|
import android.content.Intent
|
||||||
import android.os.Build
|
|
||||||
import android.os.Bundle
|
import android.os.Bundle
|
||||||
import android.text.method.PasswordTransformationMethod
|
import android.text.method.PasswordTransformationMethod
|
||||||
|
import android.util.Log
|
||||||
import android.view.LayoutInflater
|
import android.view.LayoutInflater
|
||||||
import android.view.Menu
|
import android.view.Menu
|
||||||
import android.view.MenuInflater
|
import android.view.MenuInflater
|
||||||
|
@ -19,10 +19,6 @@ import android.widget.PopupMenu
|
||||||
import android.widget.TextView
|
import android.widget.TextView
|
||||||
import android.widget.Toast
|
import android.widget.Toast
|
||||||
import androidx.appcompat.app.AlertDialog
|
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
|
import androidx.fragment.app.Fragment
|
||||||
import androidx.lifecycle.lifecycleScope
|
import androidx.lifecycle.lifecycleScope
|
||||||
import androidx.recyclerview.widget.LinearLayoutManager
|
import androidx.recyclerview.widget.LinearLayoutManager
|
||||||
|
@ -31,9 +27,7 @@ import androidx.swiperefreshlayout.widget.SwipeRefreshLayout
|
||||||
import com.google.android.material.floatingactionbutton.FloatingActionButton
|
import com.google.android.material.floatingactionbutton.FloatingActionButton
|
||||||
import net.typeblog.lpac_jni.LocalProfileInfo
|
import net.typeblog.lpac_jni.LocalProfileInfo
|
||||||
import im.angry.openeuicc.common.R
|
import im.angry.openeuicc.common.R
|
||||||
import im.angry.openeuicc.service.EuiccChannelManagerService
|
import im.angry.openeuicc.core.EuiccChannelManager
|
||||||
import im.angry.openeuicc.service.EuiccChannelManagerService.Companion.waitDone
|
|
||||||
import im.angry.openeuicc.ui.wizard.DownloadWizardActivity
|
|
||||||
import im.angry.openeuicc.util.*
|
import im.angry.openeuicc.util.*
|
||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
import kotlinx.coroutines.TimeoutCancellationException
|
import kotlinx.coroutines.TimeoutCancellationException
|
||||||
|
@ -41,7 +35,6 @@ import kotlinx.coroutines.flow.StateFlow
|
||||||
import kotlinx.coroutines.flow.first
|
import kotlinx.coroutines.flow.first
|
||||||
import kotlinx.coroutines.flow.stateIn
|
import kotlinx.coroutines.flow.stateIn
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
import kotlinx.coroutines.runBlocking
|
|
||||||
import kotlinx.coroutines.withContext
|
import kotlinx.coroutines.withContext
|
||||||
|
|
||||||
open class EuiccManagementFragment : Fragment(), EuiccProfilesChangedListener,
|
open class EuiccManagementFragment : Fragment(), EuiccProfilesChangedListener,
|
||||||
|
@ -56,8 +49,6 @@ open class EuiccManagementFragment : Fragment(), EuiccProfilesChangedListener,
|
||||||
private lateinit var swipeRefresh: SwipeRefreshLayout
|
private lateinit var swipeRefresh: SwipeRefreshLayout
|
||||||
private lateinit var fab: FloatingActionButton
|
private lateinit var fab: FloatingActionButton
|
||||||
private lateinit var profileList: RecyclerView
|
private lateinit var profileList: RecyclerView
|
||||||
private var logicalSlotId: Int = -1
|
|
||||||
private lateinit var eid: String
|
|
||||||
|
|
||||||
private val adapter = EuiccProfileAdapter()
|
private val adapter = EuiccProfileAdapter()
|
||||||
|
|
||||||
|
@ -69,8 +60,6 @@ open class EuiccManagementFragment : Fragment(), EuiccProfilesChangedListener,
|
||||||
// This gives us access to the "latest" state without having to launch coroutines
|
// This gives us access to the "latest" state without having to launch coroutines
|
||||||
private lateinit var disableSafeguardFlow: StateFlow<Boolean>
|
private lateinit var disableSafeguardFlow: StateFlow<Boolean>
|
||||||
|
|
||||||
private lateinit var unfilteredProfileListFlow: StateFlow<Boolean>
|
|
||||||
|
|
||||||
override fun onCreate(savedInstanceState: Bundle?) {
|
override fun onCreate(savedInstanceState: Bundle?) {
|
||||||
super.onCreate(savedInstanceState)
|
super.onCreate(savedInstanceState)
|
||||||
setHasOptionsMenu(true)
|
setHasOptionsMenu(true)
|
||||||
|
@ -87,21 +76,6 @@ open class EuiccManagementFragment : Fragment(), EuiccProfilesChangedListener,
|
||||||
fab = view.requireViewById(R.id.fab)
|
fab = view.requireViewById(R.id.fab)
|
||||||
profileList = view.requireViewById(R.id.profile_list)
|
profileList = view.requireViewById(R.id.profile_list)
|
||||||
|
|
||||||
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())
|
|
||||||
|
|
||||||
v.updateLayoutParams<ViewGroup.MarginLayoutParams> {
|
|
||||||
rightMargin = origFabMarginRight + bars.right
|
|
||||||
bottomMargin = origFabMarginBottom + bars.bottom
|
|
||||||
}
|
|
||||||
|
|
||||||
WindowInsetsCompat.CONSUMED
|
|
||||||
}
|
|
||||||
|
|
||||||
setupRootViewInsets(profileList)
|
|
||||||
|
|
||||||
return view
|
return view
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -113,15 +87,10 @@ open class EuiccManagementFragment : Fragment(), EuiccProfilesChangedListener,
|
||||||
LinearLayoutManager(view.context, LinearLayoutManager.VERTICAL, false)
|
LinearLayoutManager(view.context, LinearLayoutManager.VERTICAL, false)
|
||||||
|
|
||||||
fab.setOnClickListener {
|
fab.setOnClickListener {
|
||||||
Intent(requireContext(), DownloadWizardActivity::class.java).apply {
|
ProfileDownloadFragment.newInstance(slotId, portId)
|
||||||
putExtra("selectedLogicalSlot", logicalSlotId)
|
.show(childFragmentManager, ProfileDownloadFragment.TAG)
|
||||||
startActivity(this)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun onStart() {
|
|
||||||
super.onStart()
|
|
||||||
refresh()
|
refresh()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -134,39 +103,16 @@ open class EuiccManagementFragment : Fragment(), EuiccProfilesChangedListener,
|
||||||
inflater.inflate(R.menu.fragment_euicc, menu)
|
inflater.inflate(R.menu.fragment_euicc, menu)
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun onPrepareOptionsMenu(menu: Menu) {
|
override fun onOptionsItemSelected(item: MenuItem): Boolean =
|
||||||
super.onPrepareOptionsMenu(menu)
|
when (item.itemId) {
|
||||||
menu.findItem(R.id.show_notifications).isVisible =
|
|
||||||
logicalSlotId != -1
|
|
||||||
menu.findItem(R.id.euicc_info).isVisible =
|
|
||||||
logicalSlotId != -1
|
|
||||||
menu.findItem(R.id.euicc_memory_reset).isVisible =
|
|
||||||
runBlocking { preferenceRepository.euiccMemoryResetFlow.first() }
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun onOptionsItemSelected(item: MenuItem) = when (item.itemId) {
|
|
||||||
R.id.show_notifications -> {
|
R.id.show_notifications -> {
|
||||||
Intent(requireContext(), NotificationsActivity::class.java).apply {
|
Intent(requireContext(), NotificationsActivity::class.java).apply {
|
||||||
putExtra("logicalSlotId", logicalSlotId)
|
putExtra("logicalSlotId", channel.logicalSlotId)
|
||||||
startActivity(this)
|
startActivity(this)
|
||||||
}
|
}
|
||||||
true
|
true
|
||||||
}
|
}
|
||||||
|
|
||||||
R.id.euicc_info -> {
|
|
||||||
Intent(requireContext(), EuiccInfoActivity::class.java).apply {
|
|
||||||
putExtra("logicalSlotId", logicalSlotId)
|
|
||||||
startActivity(this)
|
|
||||||
}
|
|
||||||
true
|
|
||||||
}
|
|
||||||
|
|
||||||
R.id.euicc_memory_reset -> {
|
|
||||||
EuiccMemoryResetFragment.newInstance(slotId, portId, eid)
|
|
||||||
.show(childFragmentManager, EuiccMemoryResetFragment.TAG)
|
|
||||||
true
|
|
||||||
}
|
|
||||||
|
|
||||||
else -> super.onOptionsItemSelected(item)
|
else -> super.onOptionsItemSelected(item)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -181,36 +127,19 @@ open class EuiccManagementFragment : Fragment(), EuiccProfilesChangedListener,
|
||||||
listOf()
|
listOf()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@SuppressLint("NotifyDataSetChanged")
|
||||||
private fun refresh() {
|
private fun refresh() {
|
||||||
if (invalid) return
|
if (invalid) return
|
||||||
swipeRefresh.isRefreshing = true
|
swipeRefresh.isRefreshing = true
|
||||||
|
|
||||||
lifecycleScope.launch {
|
lifecycleScope.launch {
|
||||||
doRefresh()
|
if (!this@EuiccManagementFragment::disableSafeguardFlow.isInitialized) {
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@SuppressLint("NotifyDataSetChanged")
|
|
||||||
protected open suspend fun doRefresh() {
|
|
||||||
ensureEuiccChannelManager()
|
|
||||||
euiccChannelManagerService.waitForForegroundTask()
|
|
||||||
|
|
||||||
if (!::disableSafeguardFlow.isInitialized) {
|
|
||||||
disableSafeguardFlow =
|
disableSafeguardFlow =
|
||||||
preferenceRepository.disableSafeguardFlow.stateIn(lifecycleScope)
|
preferenceRepository.disableSafeguardFlow.stateIn(lifecycleScope)
|
||||||
}
|
}
|
||||||
if (!::unfilteredProfileListFlow.isInitialized) {
|
|
||||||
unfilteredProfileListFlow =
|
|
||||||
preferenceRepository.unfilteredProfileListFlow.stateIn(lifecycleScope)
|
|
||||||
}
|
|
||||||
|
|
||||||
val profiles = withEuiccChannel { channel ->
|
val profiles = withContext(Dispatchers.IO) {
|
||||||
logicalSlotId = channel.logicalSlotId
|
|
||||||
eid = channel.lpa.eID
|
|
||||||
euiccChannelManager.notifyEuiccProfilesChanged(channel.logicalSlotId)
|
euiccChannelManager.notifyEuiccProfilesChanged(channel.logicalSlotId)
|
||||||
if (unfilteredProfileListFlow.value)
|
|
||||||
channel.lpa.profiles
|
|
||||||
else
|
|
||||||
channel.lpa.profiles.operational
|
channel.lpa.profiles.operational
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -221,13 +150,6 @@ open class EuiccManagementFragment : Fragment(), EuiccProfilesChangedListener,
|
||||||
swipeRefresh.isRefreshing = false
|
swipeRefresh.isRefreshing = false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private suspend fun showSwitchFailureText() = withContext(Dispatchers.Main) {
|
|
||||||
Toast.makeText(
|
|
||||||
context,
|
|
||||||
R.string.toast_profile_enable_failed,
|
|
||||||
Toast.LENGTH_LONG
|
|
||||||
).show()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun enableOrDisableProfile(iccid: String, enable: Boolean) {
|
private fun enableOrDisableProfile(iccid: String, enable: Boolean) {
|
||||||
|
@ -235,25 +157,35 @@ open class EuiccManagementFragment : Fragment(), EuiccProfilesChangedListener,
|
||||||
fab.isEnabled = false
|
fab.isEnabled = false
|
||||||
|
|
||||||
lifecycleScope.launch {
|
lifecycleScope.launch {
|
||||||
ensureEuiccChannelManager()
|
beginTrackedOperation {
|
||||||
euiccChannelManagerService.waitForForegroundTask()
|
val (res, refreshed) =
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
|
||||||
val err = euiccChannelManagerService.launchProfileSwitchTask(
|
if (!res) {
|
||||||
slotId,
|
Log.d(TAG, "Failed to enable / disable profile $iccid")
|
||||||
portId,
|
withContext(Dispatchers.Main) {
|
||||||
iccid,
|
Toast.makeText(
|
||||||
enable,
|
context,
|
||||||
reconnectTimeoutMillis = 30 * 1000
|
R.string.toast_profile_enable_failed,
|
||||||
).waitDone()
|
Toast.LENGTH_LONG
|
||||||
|
).show()
|
||||||
|
}
|
||||||
|
return@beginTrackedOperation false
|
||||||
|
}
|
||||||
|
|
||||||
when (err) {
|
if (!refreshed && !isUsb) {
|
||||||
null -> {}
|
|
||||||
is EuiccChannelManagerService.SwitchingProfilesRefreshException -> {
|
|
||||||
// This is only really fatal for internal eSIMs
|
|
||||||
if (!isUsb) {
|
|
||||||
withContext(Dispatchers.Main) {
|
withContext(Dispatchers.Main) {
|
||||||
AlertDialog.Builder(requireContext()).apply {
|
AlertDialog.Builder(requireContext()).apply {
|
||||||
setMessage(R.string.profile_switch_did_not_refresh)
|
setMessage(R.string.switch_did_not_refresh)
|
||||||
setPositiveButton(android.R.string.ok) { dialog, _ ->
|
setPositiveButton(android.R.string.ok) { dialog, _ ->
|
||||||
dialog.dismiss()
|
dialog.dismiss()
|
||||||
requireActivity().finish()
|
requireActivity().finish()
|
||||||
|
@ -264,16 +196,23 @@ open class EuiccManagementFragment : Fragment(), EuiccProfilesChangedListener,
|
||||||
show()
|
show()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
return@beginTrackedOperation true
|
||||||
}
|
}
|
||||||
|
|
||||||
is TimeoutCancellationException -> {
|
if (!isUsb) {
|
||||||
|
try {
|
||||||
|
euiccChannelManager.waitForReconnect(
|
||||||
|
slotId,
|
||||||
|
portId,
|
||||||
|
timeoutMillis = 30 * 1000
|
||||||
|
)
|
||||||
|
} catch (e: TimeoutCancellationException) {
|
||||||
withContext(Dispatchers.Main) {
|
withContext(Dispatchers.Main) {
|
||||||
// Prevent this Fragment from being used again
|
// Prevent this Fragment from being used again
|
||||||
invalid = true
|
invalid = true
|
||||||
// Timed out waiting for SIM to come back online, we can no longer assume that the LPA is still valid
|
// Timed out waiting for SIM to come back online, we can no longer assume that the LPA is still valid
|
||||||
AlertDialog.Builder(requireContext()).apply {
|
AlertDialog.Builder(requireContext()).apply {
|
||||||
setMessage(appContainer.customizableTextProvider.profileSwitchingTimeoutMessage)
|
setMessage(R.string.enable_disable_timeout)
|
||||||
setPositiveButton(android.R.string.ok) { dialog, _ ->
|
setPositiveButton(android.R.string.ok) { dialog, _ ->
|
||||||
dialog.dismiss()
|
dialog.dismiss()
|
||||||
requireActivity().finish()
|
requireActivity().finish()
|
||||||
|
@ -284,11 +223,12 @@ open class EuiccManagementFragment : Fragment(), EuiccProfilesChangedListener,
|
||||||
show()
|
show()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
return@beginTrackedOperation false
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
else -> showSwitchFailureText()
|
preferenceRepository.notificationSwitchFlow.first()
|
||||||
}
|
}
|
||||||
|
|
||||||
refresh()
|
refresh()
|
||||||
fab.isEnabled = true
|
fab.isEnabled = true
|
||||||
}
|
}
|
||||||
|
@ -316,7 +256,7 @@ open class EuiccManagementFragment : Fragment(), EuiccProfilesChangedListener,
|
||||||
|
|
||||||
companion object {
|
companion object {
|
||||||
fun fromInt(value: Int) =
|
fun fromInt(value: Int) =
|
||||||
entries.first { it.value == value }
|
Type.values().first { it.value == value }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -344,10 +284,7 @@ open class EuiccManagementFragment : Fragment(), EuiccProfilesChangedListener,
|
||||||
private val name: TextView = root.requireViewById(R.id.name)
|
private val name: TextView = root.requireViewById(R.id.name)
|
||||||
private val state: TextView = root.requireViewById(R.id.state)
|
private val state: TextView = root.requireViewById(R.id.state)
|
||||||
private val provider: TextView = root.requireViewById(R.id.provider)
|
private val provider: TextView = root.requireViewById(R.id.provider)
|
||||||
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 profileMenu: ImageButton = root.requireViewById(R.id.profile_menu)
|
||||||
private val profileSeqNumber: TextView = root.requireViewById(R.id.profile_sequence_number)
|
|
||||||
|
|
||||||
init {
|
init {
|
||||||
iccid.setOnClickListener {
|
iccid.setOnClickListener {
|
||||||
|
@ -359,17 +296,14 @@ open class EuiccManagementFragment : Fragment(), EuiccProfilesChangedListener,
|
||||||
}
|
}
|
||||||
|
|
||||||
iccid.setOnLongClickListener {
|
iccid.setOnLongClickListener {
|
||||||
requireContext().getSystemService(ClipboardManager::class.java)!!
|
requireContext().getSystemService(ClipboardManager::class.java)
|
||||||
.setPrimaryClip(ClipData.newPlainText("iccid", iccid.text))
|
.setPrimaryClip(ClipData.newPlainText("iccid", iccid.text))
|
||||||
if (Build.VERSION.SDK_INT <= Build.VERSION_CODES.S_V2) Toast
|
Toast.makeText(requireContext(), R.string.toast_iccid_copied, Toast.LENGTH_SHORT)
|
||||||
.makeText(requireContext(), R.string.toast_iccid_copied, Toast.LENGTH_SHORT)
|
|
||||||
.show()
|
.show()
|
||||||
true
|
true
|
||||||
}
|
}
|
||||||
|
|
||||||
profileMenu.setOnClickListener {
|
profileMenu.setOnClickListener { showOptionsMenu() }
|
||||||
showOptionsMenu()
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private lateinit var profile: LocalProfileInfo
|
private lateinit var profile: LocalProfileInfo
|
||||||
|
@ -380,32 +314,16 @@ open class EuiccManagementFragment : Fragment(), EuiccProfilesChangedListener,
|
||||||
|
|
||||||
state.setText(
|
state.setText(
|
||||||
if (profile.isEnabled) {
|
if (profile.isEnabled) {
|
||||||
R.string.profile_state_enabled
|
R.string.enabled
|
||||||
} else {
|
} else {
|
||||||
R.string.profile_state_disabled
|
R.string.disabled
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
provider.text = profile.providerName
|
provider.text = profile.providerName
|
||||||
profileClassLabel.isVisible = unfilteredProfileListFlow.value
|
|
||||||
profileClass.isVisible = unfilteredProfileListFlow.value
|
|
||||||
profileClass.setText(
|
|
||||||
when (profile.profileClass) {
|
|
||||||
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.text = profile.iccid
|
||||||
iccid.transformationMethod = PasswordTransformationMethod.getInstance()
|
iccid.transformationMethod = PasswordTransformationMethod.getInstance()
|
||||||
}
|
}
|
||||||
|
|
||||||
fun setProfileSequenceNumber(index: Int) {
|
|
||||||
profileSeqNumber.text = root.context.getString(
|
|
||||||
R.string.profile_sequence_number_format,
|
|
||||||
index,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun showOptionsMenu() {
|
private fun showOptionsMenu() {
|
||||||
// Prevent users from doing multiple things at once
|
// Prevent users from doing multiple things at once
|
||||||
if (invalid || swipeRefresh.isRefreshing) return
|
if (invalid || swipeRefresh.isRefreshing) return
|
||||||
|
@ -471,7 +389,6 @@ open class EuiccManagementFragment : Fragment(), EuiccProfilesChangedListener,
|
||||||
when (holder) {
|
when (holder) {
|
||||||
is ProfileViewHolder -> {
|
is ProfileViewHolder -> {
|
||||||
holder.setProfile(profiles[position])
|
holder.setProfile(profiles[position])
|
||||||
holder.setProfileSequenceNumber(position + 1)
|
|
||||||
}
|
}
|
||||||
is FooterViewHolder -> {
|
is FooterViewHolder -> {
|
||||||
holder.attach(footerViews[position - profiles.size])
|
holder.attach(footerViews[position - profiles.size])
|
||||||
|
|
|
@ -1,126 +0,0 @@
|
||||||
package im.angry.openeuicc.ui
|
|
||||||
|
|
||||||
import android.graphics.Typeface
|
|
||||||
import android.os.Bundle
|
|
||||||
import android.text.Editable
|
|
||||||
import android.util.Log
|
|
||||||
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.service.EuiccChannelManagerService.Companion.waitDone
|
|
||||||
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
|
|
||||||
|
|
||||||
class EuiccMemoryResetFragment : DialogFragment(), EuiccChannelFragmentMarker {
|
|
||||||
companion object {
|
|
||||||
const val TAG = "EuiccMemoryResetFragment"
|
|
||||||
|
|
||||||
private const val FIELD_EID = "eid"
|
|
||||||
|
|
||||||
fun newInstance(slotId: Int, portId: Int, eid: String) =
|
|
||||||
newInstanceEuicc(EuiccMemoryResetFragment::class.java, slotId, portId) {
|
|
||||||
putString(FIELD_EID, eid)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private val eid: String by lazy { requireArguments().getString(FIELD_EID)!! }
|
|
||||||
|
|
||||||
private val confirmText: String by lazy {
|
|
||||||
getString(R.string.euicc_memory_reset_confirm_text, eid.takeLast(8))
|
|
||||||
}
|
|
||||||
|
|
||||||
private inline val isMatched: Boolean
|
|
||||||
get() = editText.text.toString() == confirmText
|
|
||||||
|
|
||||||
private var confirmed = false
|
|
||||||
|
|
||||||
private var toast: Toast? = null
|
|
||||||
set(value) {
|
|
||||||
toast?.cancel()
|
|
||||||
field = value
|
|
||||||
value?.show()
|
|
||||||
}
|
|
||||||
|
|
||||||
private val editText by lazy {
|
|
||||||
EditText(requireContext()).apply {
|
|
||||||
isLongClickable = false
|
|
||||||
typeface = Typeface.MONOSPACE
|
|
||||||
hint = Editable.Factory.getInstance()
|
|
||||||
.newEditable(getString(R.string.euicc_memory_reset_hint_text, confirmText))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private inline val alertDialog: AlertDialog
|
|
||||||
get() = requireDialog() as AlertDialog
|
|
||||||
|
|
||||||
override fun onCreateDialog(savedInstanceState: Bundle?) =
|
|
||||||
AlertDialog.Builder(requireContext(), R.style.AlertDialogTheme)
|
|
||||||
.setTitle(R.string.euicc_memory_reset_title)
|
|
||||||
.setMessage(getString(R.string.euicc_memory_reset_message, eid, confirmText))
|
|
||||||
.setView(editText)
|
|
||||||
// Set listener to null to prevent auto closing
|
|
||||||
.setNegativeButton(android.R.string.cancel, null)
|
|
||||||
.setPositiveButton(R.string.euicc_memory_reset_invoke_button, null)
|
|
||||||
.create()
|
|
||||||
|
|
||||||
override fun onResume() {
|
|
||||||
super.onResume()
|
|
||||||
alertDialog.setCanceledOnTouchOutside(false)
|
|
||||||
alertDialog.getButton(AlertDialog.BUTTON_POSITIVE)
|
|
||||||
.setOnClickListener { if (!confirmed) confirmation() }
|
|
||||||
alertDialog.getButton(AlertDialog.BUTTON_NEGATIVE)
|
|
||||||
.setOnClickListener { if (!confirmed) dismiss() }
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun confirmation() {
|
|
||||||
toast?.cancel()
|
|
||||||
if (!isMatched) {
|
|
||||||
Log.d(TAG, buildString {
|
|
||||||
appendLine("User input is mismatch:")
|
|
||||||
appendLine(editText.text)
|
|
||||||
appendLine(confirmText)
|
|
||||||
})
|
|
||||||
val resId = R.string.toast_euicc_memory_reset_confirm_text_mismatched
|
|
||||||
toast = Toast.makeText(requireContext(), resId, Toast.LENGTH_LONG)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
confirmed = true
|
|
||||||
preventUserAction()
|
|
||||||
|
|
||||||
requireParentFragment().lifecycleScope.launch {
|
|
||||||
ensureEuiccChannelManager()
|
|
||||||
euiccChannelManagerService.waitForForegroundTask()
|
|
||||||
|
|
||||||
euiccChannelManagerService.launchMemoryReset(slotId, portId)
|
|
||||||
.onStart {
|
|
||||||
parentFragment?.notifyEuiccProfilesChanged()
|
|
||||||
|
|
||||||
val resId = R.string.toast_euicc_memory_reset_finitshed
|
|
||||||
toast = Toast.makeText(requireContext(), resId, Toast.LENGTH_LONG)
|
|
||||||
|
|
||||||
runCatching(::dismiss)
|
|
||||||
}
|
|
||||||
.waitDone()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun preventUserAction() {
|
|
||||||
editText.isEnabled = false
|
|
||||||
alertDialog.setCancelable(false)
|
|
||||||
alertDialog.setCanceledOnTouchOutside(false)
|
|
||||||
alertDialog.getButton(AlertDialog.BUTTON_POSITIVE).isEnabled = false
|
|
||||||
alertDialog.getButton(AlertDialog.BUTTON_NEGATIVE).isEnabled = false
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -1,72 +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.preferenceRepository
|
|
||||||
import im.angry.openeuicc.util.setupToolbarInsets
|
|
||||||
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))
|
|
||||||
setupToolbarInsets()
|
|
||||||
supportActionBar!!.setDisplayHomeAsUpEnabled(true)
|
|
||||||
|
|
||||||
isdrAidListEditor = requireViewById(R.id.isdr_aid_list_editor)
|
|
||||||
|
|
||||||
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)
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -1,14 +1,13 @@
|
||||||
package im.angry.openeuicc.ui
|
package im.angry.openeuicc.ui
|
||||||
|
|
||||||
import android.icu.text.SimpleDateFormat
|
import android.icu.text.SimpleDateFormat
|
||||||
import android.os.Build
|
|
||||||
import android.os.Bundle
|
import android.os.Bundle
|
||||||
import android.view.Menu
|
import android.view.Menu
|
||||||
import android.view.MenuItem
|
import android.view.MenuItem
|
||||||
import android.view.View
|
import android.view.View
|
||||||
import android.widget.ScrollView
|
import android.widget.ScrollView
|
||||||
import android.widget.TextView
|
import android.widget.TextView
|
||||||
import androidx.activity.enableEdgeToEdge
|
import androidx.activity.result.contract.ActivityResultContracts
|
||||||
import androidx.appcompat.app.AppCompatActivity
|
import androidx.appcompat.app.AppCompatActivity
|
||||||
import androidx.lifecycle.lifecycleScope
|
import androidx.lifecycle.lifecycleScope
|
||||||
import androidx.swiperefreshlayout.widget.SwipeRefreshLayout
|
import androidx.swiperefreshlayout.widget.SwipeRefreshLayout
|
||||||
|
@ -17,6 +16,7 @@ import im.angry.openeuicc.util.*
|
||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
import kotlinx.coroutines.withContext
|
import kotlinx.coroutines.withContext
|
||||||
|
import java.io.FileOutputStream
|
||||||
import java.util.Date
|
import java.util.Date
|
||||||
|
|
||||||
class LogsActivity : AppCompatActivity() {
|
class LogsActivity : AppCompatActivity() {
|
||||||
|
@ -26,40 +26,26 @@ class LogsActivity : AppCompatActivity() {
|
||||||
private lateinit var logStr: String
|
private lateinit var logStr: String
|
||||||
|
|
||||||
private val saveLogs =
|
private val saveLogs =
|
||||||
setupLogSaving(
|
registerForActivityResult(ActivityResultContracts.CreateDocument("text/plain")) { uri ->
|
||||||
getLogFileName = {
|
if (uri == null) return@registerForActivityResult
|
||||||
getString(
|
if (!this::logStr.isInitialized) return@registerForActivityResult
|
||||||
R.string.logs_filename_template,
|
contentResolver.openFileDescriptor(uri, "w")?.use {
|
||||||
SimpleDateFormat.getDateTimeInstance().format(Date())
|
FileOutputStream(it.fileDescriptor).use { os ->
|
||||||
)
|
os.write(logStr.encodeToByteArray())
|
||||||
},
|
}
|
||||||
getLogText = ::buildLogText
|
}
|
||||||
)
|
|
||||||
|
|
||||||
private fun buildLogText() = buildString {
|
|
||||||
appendLine("Manufacturer: ${Build.MANUFACTURER}")
|
|
||||||
appendLine("Brand: ${Build.BRAND}")
|
|
||||||
appendLine("Model: ${Build.MODEL}")
|
|
||||||
appendLine("SDK Version: ${Build.VERSION.SDK_INT}")
|
|
||||||
appendLine("App Version: $selfAppVersion")
|
|
||||||
appendLine("-".repeat(10))
|
|
||||||
appendLine(logStr)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun onCreate(savedInstanceState: Bundle?) {
|
override fun onCreate(savedInstanceState: Bundle?) {
|
||||||
enableEdgeToEdge()
|
|
||||||
super.onCreate(savedInstanceState)
|
super.onCreate(savedInstanceState)
|
||||||
setContentView(R.layout.activity_logs)
|
setContentView(R.layout.activity_logs)
|
||||||
setSupportActionBar(requireViewById(R.id.toolbar))
|
setSupportActionBar(requireViewById(R.id.toolbar))
|
||||||
setupToolbarInsets()
|
|
||||||
supportActionBar!!.setDisplayHomeAsUpEnabled(true)
|
supportActionBar!!.setDisplayHomeAsUpEnabled(true)
|
||||||
|
|
||||||
swipeRefresh = requireViewById(R.id.swipe_refresh)
|
swipeRefresh = requireViewById(R.id.swipe_refresh)
|
||||||
scrollView = requireViewById(R.id.scroll_view)
|
scrollView = requireViewById(R.id.scroll_view)
|
||||||
logText = requireViewById(R.id.log_text)
|
logText = requireViewById(R.id.log_text)
|
||||||
|
|
||||||
setupRootViewInsets(scrollView)
|
|
||||||
|
|
||||||
swipeRefresh.setOnRefreshListener {
|
swipeRefresh.setOnRefreshListener {
|
||||||
lifecycleScope.launch {
|
lifecycleScope.launch {
|
||||||
reload()
|
reload()
|
||||||
|
@ -80,12 +66,10 @@ class LogsActivity : AppCompatActivity() {
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun onOptionsItemSelected(item: MenuItem): Boolean = when (item.itemId) {
|
override fun onOptionsItemSelected(item: MenuItem): Boolean = when (item.itemId) {
|
||||||
android.R.id.home -> {
|
|
||||||
finish()
|
|
||||||
true
|
|
||||||
}
|
|
||||||
R.id.save -> {
|
R.id.save -> {
|
||||||
saveLogs()
|
saveLogs.launch(getString(R.string.logs_filename_template,
|
||||||
|
SimpleDateFormat.getDateTimeInstance().format(Date())
|
||||||
|
))
|
||||||
true
|
true
|
||||||
}
|
}
|
||||||
else -> super.onOptionsItemSelected(item)
|
else -> super.onOptionsItemSelected(item)
|
||||||
|
|
|
@ -5,9 +5,7 @@ import android.content.BroadcastReceiver
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
import android.content.Intent
|
import android.content.Intent
|
||||||
import android.content.IntentFilter
|
import android.content.IntentFilter
|
||||||
import android.content.pm.PackageManager
|
|
||||||
import android.hardware.usb.UsbManager
|
import android.hardware.usb.UsbManager
|
||||||
import android.os.Build
|
|
||||||
import android.os.Bundle
|
import android.os.Bundle
|
||||||
import android.telephony.TelephonyManager
|
import android.telephony.TelephonyManager
|
||||||
import android.util.Log
|
import android.util.Log
|
||||||
|
@ -15,7 +13,6 @@ import android.view.Menu
|
||||||
import android.view.MenuItem
|
import android.view.MenuItem
|
||||||
import android.view.View
|
import android.view.View
|
||||||
import android.widget.ProgressBar
|
import android.widget.ProgressBar
|
||||||
import androidx.activity.enableEdgeToEdge
|
|
||||||
import androidx.fragment.app.Fragment
|
import androidx.fragment.app.Fragment
|
||||||
import androidx.lifecycle.lifecycleScope
|
import androidx.lifecycle.lifecycleScope
|
||||||
import androidx.viewpager2.adapter.FragmentStateAdapter
|
import androidx.viewpager2.adapter.FragmentStateAdapter
|
||||||
|
@ -23,12 +20,8 @@ import androidx.viewpager2.widget.ViewPager2
|
||||||
import com.google.android.material.tabs.TabLayout
|
import com.google.android.material.tabs.TabLayout
|
||||||
import com.google.android.material.tabs.TabLayoutMediator
|
import com.google.android.material.tabs.TabLayoutMediator
|
||||||
import im.angry.openeuicc.common.R
|
import im.angry.openeuicc.common.R
|
||||||
import im.angry.openeuicc.core.EuiccChannelManager
|
|
||||||
import im.angry.openeuicc.util.*
|
import im.angry.openeuicc.util.*
|
||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
import kotlinx.coroutines.flow.collect
|
|
||||||
import kotlinx.coroutines.flow.first
|
|
||||||
import kotlinx.coroutines.flow.onEach
|
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
import kotlinx.coroutines.withContext
|
import kotlinx.coroutines.withContext
|
||||||
|
|
||||||
|
@ -36,8 +29,6 @@ import kotlinx.coroutines.withContext
|
||||||
open class MainActivity : BaseEuiccAccessActivity(), OpenEuiccContextMarker {
|
open class MainActivity : BaseEuiccAccessActivity(), OpenEuiccContextMarker {
|
||||||
companion object {
|
companion object {
|
||||||
const val TAG = "MainActivity"
|
const val TAG = "MainActivity"
|
||||||
|
|
||||||
const val PERMISSION_REQUEST_CODE = 1000
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private lateinit var loadingProgress: ProgressBar
|
private lateinit var loadingProgress: ProgressBar
|
||||||
|
@ -47,7 +38,6 @@ open class MainActivity : BaseEuiccAccessActivity(), OpenEuiccContextMarker {
|
||||||
private var refreshing = false
|
private var refreshing = false
|
||||||
|
|
||||||
private data class Page(
|
private data class Page(
|
||||||
val logicalSlotId: Int,
|
|
||||||
val title: String,
|
val title: String,
|
||||||
val createFragment: () -> Fragment
|
val createFragment: () -> Fragment
|
||||||
)
|
)
|
||||||
|
@ -74,11 +64,9 @@ open class MainActivity : BaseEuiccAccessActivity(), OpenEuiccContextMarker {
|
||||||
|
|
||||||
@SuppressLint("WrongConstant", "UnspecifiedRegisterReceiverFlag")
|
@SuppressLint("WrongConstant", "UnspecifiedRegisterReceiverFlag")
|
||||||
override fun onCreate(savedInstanceState: Bundle?) {
|
override fun onCreate(savedInstanceState: Bundle?) {
|
||||||
enableEdgeToEdge()
|
|
||||||
super.onCreate(savedInstanceState)
|
super.onCreate(savedInstanceState)
|
||||||
setContentView(R.layout.activity_main)
|
setContentView(R.layout.activity_main)
|
||||||
setSupportActionBar(requireViewById(R.id.toolbar))
|
setSupportActionBar(requireViewById(R.id.toolbar))
|
||||||
setupToolbarInsets()
|
|
||||||
loadingProgress = requireViewById(R.id.loading)
|
loadingProgress = requireViewById(R.id.loading)
|
||||||
tabs = requireViewById(R.id.main_tabs)
|
tabs = requireViewById(R.id.main_tabs)
|
||||||
viewPager = requireViewById(R.id.view_pager)
|
viewPager = requireViewById(R.id.view_pager)
|
||||||
|
@ -109,7 +97,7 @@ open class MainActivity : BaseEuiccAccessActivity(), OpenEuiccContextMarker {
|
||||||
override fun onOptionsItemSelected(item: MenuItem): Boolean =
|
override fun onOptionsItemSelected(item: MenuItem): Boolean =
|
||||||
when (item.itemId) {
|
when (item.itemId) {
|
||||||
R.id.settings -> {
|
R.id.settings -> {
|
||||||
startActivity(Intent(this, SettingsActivity::class.java))
|
startActivity(Intent(this, SettingsActivity::class.java));
|
||||||
true
|
true
|
||||||
}
|
}
|
||||||
R.id.reload -> {
|
R.id.reload -> {
|
||||||
|
@ -125,76 +113,49 @@ open class MainActivity : BaseEuiccAccessActivity(), OpenEuiccContextMarker {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun ensureNotificationPermissions() {
|
|
||||||
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) {
|
private suspend fun init(fromUsbEvent: Boolean = false) {
|
||||||
refreshing = true // We don't check this here -- the check happens in refresh()
|
refreshing = true // We don't check this here -- the check happens in refresh()
|
||||||
loadingProgress.visibility = View.VISIBLE
|
loadingProgress.visibility = View.VISIBLE
|
||||||
viewPager.visibility = View.GONE
|
viewPager.visibility = View.GONE
|
||||||
tabs.visibility = View.GONE
|
tabs.visibility = View.GONE
|
||||||
// Prevent concurrent access with any running foreground task
|
|
||||||
euiccChannelManagerService.waitForForegroundTask()
|
|
||||||
|
|
||||||
val (usbDevice, _) = withContext(Dispatchers.IO) {
|
val knownChannels = withContext(Dispatchers.IO) {
|
||||||
euiccChannelManager.tryOpenUsbEuiccChannel()
|
euiccChannelManager.enumerateEuiccChannels().onEach {
|
||||||
}
|
Log.d(TAG, "slot ${it.slotId} port ${it.portId}")
|
||||||
|
Log.d(TAG, it.lpa.eID)
|
||||||
val newPages: MutableList<Page> = mutableListOf()
|
|
||||||
|
|
||||||
euiccChannelManager.flowInternalEuiccPorts().onEach { (slotId, portId) ->
|
|
||||||
Log.d(TAG, "slot $slotId port $portId")
|
|
||||||
|
|
||||||
euiccChannelManager.withEuiccChannel(slotId, portId) { channel ->
|
|
||||||
if (preferenceRepository.verboseLoggingFlow.first()) {
|
|
||||||
Log.d(TAG, channel.lpa.eID)
|
|
||||||
}
|
|
||||||
// Request the system to refresh the list of profiles every time we start
|
// 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,
|
// Note that this is currently supposed to be no-op when unprivileged,
|
||||||
// but it could change in the future
|
// but it could change in the future
|
||||||
euiccChannelManager.notifyEuiccProfilesChanged(channel.logicalSlotId)
|
euiccChannelManager.notifyEuiccProfilesChanged(it.logicalSlotId)
|
||||||
|
}
|
||||||
val channelName =
|
}
|
||||||
appContainer.customizableTextProvider.formatInternalChannelName(channel.logicalSlotId)
|
|
||||||
newPages.add(Page(channel.logicalSlotId, channelName) {
|
val (usbDevice, _) = withContext(Dispatchers.IO) {
|
||||||
appContainer.uiComponentFactory.createEuiccManagementFragment(slotId, portId)
|
euiccChannelManager.enumerateUsbEuiccChannel()
|
||||||
})
|
}
|
||||||
|
|
||||||
|
withContext(Dispatchers.Main) {
|
||||||
|
loadingProgress.visibility = View.GONE
|
||||||
|
|
||||||
|
knownChannels.sortedBy { it.logicalSlotId }.forEach { channel ->
|
||||||
|
pages.add(Page(
|
||||||
|
getString(R.string.channel_name_format, channel.logicalSlotId)
|
||||||
|
) { appContainer.uiComponentFactory.createEuiccManagementFragment(channel) })
|
||||||
}
|
}
|
||||||
}.collect()
|
|
||||||
|
|
||||||
// If USB readers exist, add them at the very last
|
// If USB readers exist, add them at the very last
|
||||||
// We use a wrapper fragment to handle logic specific to USB readers
|
// We use a wrapper fragment to handle logic specific to USB readers
|
||||||
usbDevice?.let {
|
usbDevice?.let {
|
||||||
val productName = it.productName ?: getString(R.string.channel_type_usb)
|
pages.add(Page(it.productName ?: getString(R.string.usb)) { UsbCcidReaderFragment() })
|
||||||
newPages.add(Page(EuiccChannelManager.USB_CHANNEL_ID, productName) {
|
|
||||||
UsbCcidReaderFragment()
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
viewPager.visibility = View.VISIBLE
|
viewPager.visibility = View.VISIBLE
|
||||||
|
|
||||||
if (newPages.size > 1) {
|
if (pages.size > 1) {
|
||||||
tabs.visibility = View.VISIBLE
|
tabs.visibility = View.VISIBLE
|
||||||
} else if (newPages.isEmpty()) {
|
} else if (pages.isEmpty()) {
|
||||||
newPages.add(Page(-1, "") {
|
pages.add(Page("") { appContainer.uiComponentFactory.createNoEuiccPlaceholderFragment() })
|
||||||
appContainer.uiComponentFactory.createNoEuiccPlaceholderFragment()
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
|
|
||||||
newPages.sortBy { it.logicalSlotId }
|
|
||||||
|
|
||||||
pages.clear()
|
|
||||||
pages.addAll(newPages)
|
|
||||||
|
|
||||||
loadingProgress.visibility = View.GONE
|
|
||||||
pagerAdapter.notifyDataSetChanged()
|
pagerAdapter.notifyDataSetChanged()
|
||||||
// Reset the adapter so that the current view actually gets cleared
|
// Reset the adapter so that the current view actually gets cleared
|
||||||
// notifyDataSetChanged() doesn't cause the current view to be removed.
|
// notifyDataSetChanged() doesn't cause the current view to be removed.
|
||||||
|
@ -209,12 +170,9 @@ open class MainActivity : BaseEuiccAccessActivity(), OpenEuiccContextMarker {
|
||||||
viewPager.currentItem = 0
|
viewPager.currentItem = 0
|
||||||
}
|
}
|
||||||
|
|
||||||
if (pages.size > 0) {
|
|
||||||
ensureNotificationPermissions()
|
|
||||||
}
|
|
||||||
|
|
||||||
refreshing = false
|
refreshing = false
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private fun refresh(fromUsbEvent: Boolean = false) {
|
private fun refresh(fromUsbEvent: Boolean = false) {
|
||||||
if (refreshing) return
|
if (refreshing) return
|
||||||
|
|
|
@ -4,20 +4,15 @@ import android.os.Bundle
|
||||||
import android.view.LayoutInflater
|
import android.view.LayoutInflater
|
||||||
import android.view.View
|
import android.view.View
|
||||||
import android.view.ViewGroup
|
import android.view.ViewGroup
|
||||||
import android.widget.TextView
|
|
||||||
import androidx.fragment.app.Fragment
|
import androidx.fragment.app.Fragment
|
||||||
import im.angry.openeuicc.common.R
|
import im.angry.openeuicc.common.R
|
||||||
import im.angry.openeuicc.util.*
|
|
||||||
|
|
||||||
class NoEuiccPlaceholderFragment : Fragment(), OpenEuiccContextMarker {
|
class NoEuiccPlaceholderFragment : Fragment() {
|
||||||
override fun onCreateView(
|
override fun onCreateView(
|
||||||
inflater: LayoutInflater,
|
inflater: LayoutInflater,
|
||||||
container: ViewGroup?,
|
container: ViewGroup?,
|
||||||
savedInstanceState: Bundle?
|
savedInstanceState: Bundle?
|
||||||
): View? {
|
): View? {
|
||||||
val view = inflater.inflate(R.layout.fragment_no_euicc_placeholder, container, false)
|
return inflater.inflate(R.layout.fragment_no_euicc_placeholder, container, false)
|
||||||
val textView = view.requireViewById<TextView>(R.id.no_euicc_placeholder)
|
|
||||||
textView.text = appContainer.customizableTextProvider.noEuiccExplanation
|
|
||||||
return view
|
|
||||||
}
|
}
|
||||||
}
|
}
|
|
@ -11,7 +11,6 @@ import android.view.MenuItem.OnMenuItemClickListener
|
||||||
import android.view.View
|
import android.view.View
|
||||||
import android.view.ViewGroup
|
import android.view.ViewGroup
|
||||||
import android.widget.TextView
|
import android.widget.TextView
|
||||||
import androidx.activity.enableEdgeToEdge
|
|
||||||
import androidx.appcompat.app.AlertDialog
|
import androidx.appcompat.app.AlertDialog
|
||||||
import androidx.core.view.forEach
|
import androidx.core.view.forEach
|
||||||
import androidx.lifecycle.lifecycleScope
|
import androidx.lifecycle.lifecycleScope
|
||||||
|
@ -20,6 +19,7 @@ import androidx.recyclerview.widget.LinearLayoutManager
|
||||||
import androidx.recyclerview.widget.RecyclerView
|
import androidx.recyclerview.widget.RecyclerView
|
||||||
import androidx.swiperefreshlayout.widget.SwipeRefreshLayout
|
import androidx.swiperefreshlayout.widget.SwipeRefreshLayout
|
||||||
import im.angry.openeuicc.common.R
|
import im.angry.openeuicc.common.R
|
||||||
|
import im.angry.openeuicc.core.EuiccChannel
|
||||||
import im.angry.openeuicc.core.EuiccChannelManager
|
import im.angry.openeuicc.core.EuiccChannelManager
|
||||||
import im.angry.openeuicc.util.*
|
import im.angry.openeuicc.util.*
|
||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
@ -32,37 +32,34 @@ class NotificationsActivity: BaseEuiccAccessActivity(), OpenEuiccContextMarker {
|
||||||
private lateinit var notificationList: RecyclerView
|
private lateinit var notificationList: RecyclerView
|
||||||
private val notificationAdapter = NotificationAdapter()
|
private val notificationAdapter = NotificationAdapter()
|
||||||
|
|
||||||
private var logicalSlotId = -1
|
private lateinit var euiccChannel: EuiccChannel
|
||||||
|
|
||||||
override fun onCreate(savedInstanceState: Bundle?) {
|
override fun onCreate(savedInstanceState: Bundle?) {
|
||||||
enableEdgeToEdge()
|
|
||||||
super.onCreate(savedInstanceState)
|
super.onCreate(savedInstanceState)
|
||||||
setContentView(R.layout.activity_notifications)
|
setContentView(R.layout.activity_notifications)
|
||||||
setSupportActionBar(requireViewById(R.id.toolbar))
|
setSupportActionBar(requireViewById(R.id.toolbar))
|
||||||
setupToolbarInsets()
|
|
||||||
supportActionBar!!.setDisplayHomeAsUpEnabled(true)
|
supportActionBar!!.setDisplayHomeAsUpEnabled(true)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onInit() {
|
||||||
|
euiccChannel = euiccChannelManager
|
||||||
|
.findEuiccChannelBySlotBlocking(intent.getIntExtra("logicalSlotId", 0))!!
|
||||||
|
|
||||||
swipeRefresh = requireViewById(R.id.swipe_refresh)
|
swipeRefresh = requireViewById(R.id.swipe_refresh)
|
||||||
notificationList = requireViewById(R.id.recycler_view)
|
notificationList = requireViewById(R.id.recycler_view)
|
||||||
|
|
||||||
setupRootViewInsets(notificationList)
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun onInit() {
|
|
||||||
notificationList.layoutManager =
|
notificationList.layoutManager =
|
||||||
LinearLayoutManager(this, LinearLayoutManager.VERTICAL, false)
|
LinearLayoutManager(this, LinearLayoutManager.VERTICAL, false)
|
||||||
notificationList.addItemDecoration(DividerItemDecoration(this, LinearLayoutManager.VERTICAL))
|
notificationList.addItemDecoration(DividerItemDecoration(this, LinearLayoutManager.VERTICAL))
|
||||||
notificationList.adapter = notificationAdapter
|
notificationList.adapter = notificationAdapter
|
||||||
registerForContextMenu(notificationList)
|
registerForContextMenu(notificationList)
|
||||||
|
|
||||||
logicalSlotId = intent.getIntExtra("logicalSlotId", 0)
|
|
||||||
|
|
||||||
// This is slightly different from the MainActivity logic
|
// This is slightly different from the MainActivity logic
|
||||||
// due to the length (we don't want to display the full USB product name)
|
// due to the length (we don't want to display the full USB product name)
|
||||||
val channelTitle = if (logicalSlotId == EuiccChannelManager.USB_CHANNEL_ID) {
|
val channelTitle = if (euiccChannel.logicalSlotId == EuiccChannelManager.USB_CHANNEL_ID) {
|
||||||
getString(R.string.channel_type_usb)
|
getString(R.string.usb)
|
||||||
} else {
|
} else {
|
||||||
appContainer.customizableTextProvider.formatInternalChannelName(logicalSlotId)
|
getString(R.string.channel_name_format, euiccChannel.logicalSlotId)
|
||||||
}
|
}
|
||||||
|
|
||||||
title = getString(R.string.profile_notifications_detailed_format, channelTitle)
|
title = getString(R.string.profile_notifications_detailed_format, channelTitle)
|
||||||
|
@ -103,10 +100,6 @@ class NotificationsActivity: BaseEuiccAccessActivity(), OpenEuiccContextMarker {
|
||||||
swipeRefresh.isRefreshing = true
|
swipeRefresh.isRefreshing = true
|
||||||
|
|
||||||
lifecycleScope.launch {
|
lifecycleScope.launch {
|
||||||
withContext(Dispatchers.IO) {
|
|
||||||
euiccChannelManagerLoaded.await()
|
|
||||||
}
|
|
||||||
|
|
||||||
task()
|
task()
|
||||||
|
|
||||||
swipeRefresh.isRefreshing = false
|
swipeRefresh.isRefreshing = false
|
||||||
|
@ -115,16 +108,15 @@ class NotificationsActivity: BaseEuiccAccessActivity(), OpenEuiccContextMarker {
|
||||||
|
|
||||||
private fun refresh() {
|
private fun refresh() {
|
||||||
launchTask {
|
launchTask {
|
||||||
notificationAdapter.notifications =
|
val profiles = withContext(Dispatchers.IO) {
|
||||||
euiccChannelManager.withEuiccChannel(logicalSlotId) { channel ->
|
euiccChannel.lpa.profiles
|
||||||
val nameMap = buildMap {
|
|
||||||
for (profile in channel.lpa.profiles) {
|
|
||||||
put(profile.iccid, profile.displayName)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
channel.lpa.notifications.map {
|
notificationAdapter.notifications =
|
||||||
LocalProfileNotificationWrapper(it, nameMap[it.iccid] ?: "???")
|
withContext(Dispatchers.IO) {
|
||||||
|
euiccChannel.lpa.notifications.map {
|
||||||
|
val profile = profiles.find { p -> p.iccid == it.iccid }
|
||||||
|
LocalProfileNotificationWrapper(it, profile?.displayName ?: "???")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -139,8 +131,6 @@ class NotificationsActivity: BaseEuiccAccessActivity(), OpenEuiccContextMarker {
|
||||||
inner class NotificationViewHolder(private val root: View):
|
inner class NotificationViewHolder(private val root: View):
|
||||||
RecyclerView.ViewHolder(root), View.OnCreateContextMenuListener, OnMenuItemClickListener {
|
RecyclerView.ViewHolder(root), View.OnCreateContextMenuListener, OnMenuItemClickListener {
|
||||||
private val address: TextView = root.requireViewById(R.id.notification_address)
|
private val address: TextView = root.requireViewById(R.id.notification_address)
|
||||||
private val sequenceNumber: TextView =
|
|
||||||
root.requireViewById(R.id.notification_sequence_number)
|
|
||||||
private val profileName: TextView = root.requireViewById(R.id.notification_profile_name)
|
private val profileName: TextView = root.requireViewById(R.id.notification_profile_name)
|
||||||
|
|
||||||
private lateinit var notification: LocalProfileNotificationWrapper
|
private lateinit var notification: LocalProfileNotificationWrapper
|
||||||
|
@ -162,7 +152,6 @@ class NotificationsActivity: BaseEuiccAccessActivity(), OpenEuiccContextMarker {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
private fun operationToLocalizedText(operation: LocalProfileNotification.Operation) =
|
private fun operationToLocalizedText(operation: LocalProfileNotification.Operation) =
|
||||||
root.context.getText(
|
root.context.getText(
|
||||||
when (operation) {
|
when (operation) {
|
||||||
|
@ -176,10 +165,6 @@ class NotificationsActivity: BaseEuiccAccessActivity(), OpenEuiccContextMarker {
|
||||||
notification = value
|
notification = value
|
||||||
|
|
||||||
address.text = value.inner.notificationAddress
|
address.text = value.inner.notificationAddress
|
||||||
sequenceNumber.text = root.context.getString(
|
|
||||||
R.string.profile_notification_sequence_number_format,
|
|
||||||
value.inner.seqNumber
|
|
||||||
)
|
|
||||||
profileName.text = Html.fromHtml(
|
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),
|
operationToLocalizedText(value.inner.profileManagementOperation),
|
||||||
|
@ -204,9 +189,7 @@ class NotificationsActivity: BaseEuiccAccessActivity(), OpenEuiccContextMarker {
|
||||||
R.id.notification_process -> {
|
R.id.notification_process -> {
|
||||||
launchTask {
|
launchTask {
|
||||||
withContext(Dispatchers.IO) {
|
withContext(Dispatchers.IO) {
|
||||||
euiccChannelManager.withEuiccChannel(logicalSlotId) { channel ->
|
euiccChannel.lpa.handleNotification(notification.inner.seqNumber)
|
||||||
channel.lpa.handleNotification(notification.inner.seqNumber)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
refresh()
|
refresh()
|
||||||
|
@ -216,9 +199,7 @@ class NotificationsActivity: BaseEuiccAccessActivity(), OpenEuiccContextMarker {
|
||||||
R.id.notification_delete -> {
|
R.id.notification_delete -> {
|
||||||
launchTask {
|
launchTask {
|
||||||
withContext(Dispatchers.IO) {
|
withContext(Dispatchers.IO) {
|
||||||
euiccChannelManager.withEuiccChannel(logicalSlotId) { channel ->
|
euiccChannel.lpa.deleteNotification(notification.inner.seqNumber)
|
||||||
channel.lpa.deleteNotification(notification.inner.seqNumber)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
refresh()
|
refresh()
|
||||||
|
|
|
@ -3,67 +3,56 @@ package im.angry.openeuicc.ui
|
||||||
import android.app.Dialog
|
import android.app.Dialog
|
||||||
import android.os.Bundle
|
import android.os.Bundle
|
||||||
import android.text.Editable
|
import android.text.Editable
|
||||||
|
import android.util.Log
|
||||||
import android.widget.EditText
|
import android.widget.EditText
|
||||||
import android.widget.Toast
|
|
||||||
import androidx.appcompat.app.AlertDialog
|
import androidx.appcompat.app.AlertDialog
|
||||||
import androidx.fragment.app.DialogFragment
|
import androidx.fragment.app.DialogFragment
|
||||||
import androidx.lifecycle.lifecycleScope
|
import androidx.lifecycle.lifecycleScope
|
||||||
import im.angry.openeuicc.common.R
|
import im.angry.openeuicc.common.R
|
||||||
import im.angry.openeuicc.service.EuiccChannelManagerService.Companion.waitDone
|
|
||||||
import im.angry.openeuicc.util.*
|
import im.angry.openeuicc.util.*
|
||||||
import kotlinx.coroutines.flow.onStart
|
import kotlinx.coroutines.flow.first
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
|
import java.lang.Exception
|
||||||
|
|
||||||
class ProfileDeleteFragment : DialogFragment(), EuiccChannelFragmentMarker {
|
class ProfileDeleteFragment : DialogFragment(), EuiccChannelFragmentMarker {
|
||||||
companion object {
|
companion object {
|
||||||
const val TAG = "ProfileDeleteFragment"
|
const val TAG = "ProfileDeleteFragment"
|
||||||
private const val FIELD_ICCID = "iccid"
|
|
||||||
private const val FIELD_NAME = "name"
|
|
||||||
|
|
||||||
fun newInstance(slotId: Int, portId: Int, iccid: String, name: String) =
|
fun newInstance(slotId: Int, portId: Int, iccid: String, name: String): ProfileDeleteFragment {
|
||||||
newInstanceEuicc(ProfileDeleteFragment::class.java, slotId, portId) {
|
val instance = newInstanceEuicc(ProfileDeleteFragment::class.java, slotId, portId)
|
||||||
putString(FIELD_ICCID, iccid)
|
instance.requireArguments().apply {
|
||||||
putString(FIELD_NAME, name)
|
putString("iccid", iccid)
|
||||||
|
putString("name", name)
|
||||||
}
|
}
|
||||||
|
return instance
|
||||||
}
|
}
|
||||||
|
|
||||||
private val iccid by lazy {
|
|
||||||
requireArguments().getString(FIELD_ICCID)!!
|
|
||||||
}
|
|
||||||
|
|
||||||
private val name by lazy {
|
|
||||||
requireArguments().getString(FIELD_NAME)!!
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private val editText by lazy {
|
private val editText by lazy {
|
||||||
EditText(requireContext()).apply {
|
EditText(requireContext()).apply {
|
||||||
hint = Editable.Factory.getInstance()
|
hint = Editable.Factory.getInstance().newEditable(
|
||||||
.newEditable(getString(R.string.profile_delete_confirm_input, name))
|
getString(R.string.profile_delete_confirm_input, requireArguments().getString("name")!!)
|
||||||
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private val inputMatchesName: Boolean
|
private val inputMatchesName: Boolean
|
||||||
get() = editText.text.toString() == name
|
get() = editText.text.toString() == requireArguments().getString("name")!!
|
||||||
|
|
||||||
private var toast: Toast? = null
|
|
||||||
|
|
||||||
private var deleting = false
|
private var deleting = false
|
||||||
|
|
||||||
private val alertDialog: AlertDialog
|
override fun onCreateDialog(savedInstanceState: Bundle?): Dialog {
|
||||||
get() = requireDialog() as AlertDialog
|
return AlertDialog.Builder(requireContext(), R.style.AlertDialogTheme).apply {
|
||||||
|
setMessage(getString(R.string.profile_delete_confirm, requireArguments().getString("name")))
|
||||||
override fun onCreateDialog(savedInstanceState: Bundle?): Dialog =
|
|
||||||
AlertDialog.Builder(requireContext(), R.style.AlertDialogTheme).apply {
|
|
||||||
setMessage(getString(R.string.profile_delete_confirm, name))
|
|
||||||
setView(editText)
|
setView(editText)
|
||||||
setPositiveButton(android.R.string.ok, null) // Set listener to null to prevent auto closing
|
setPositiveButton(android.R.string.ok, null) // Set listener to null to prevent auto closing
|
||||||
setNegativeButton(android.R.string.cancel, null)
|
setNegativeButton(android.R.string.cancel, null)
|
||||||
}.create()
|
}.create()
|
||||||
|
}
|
||||||
|
|
||||||
override fun onResume() {
|
override fun onResume() {
|
||||||
super.onResume()
|
super.onResume()
|
||||||
|
val alertDialog = dialog!! as AlertDialog
|
||||||
alertDialog.getButton(AlertDialog.BUTTON_POSITIVE).setOnClickListener {
|
alertDialog.getButton(AlertDialog.BUTTON_POSITIVE).setOnClickListener {
|
||||||
if (!deleting) delete()
|
if (!deleting && inputMatchesName) delete()
|
||||||
}
|
}
|
||||||
alertDialog.getButton(AlertDialog.BUTTON_NEGATIVE).setOnClickListener {
|
alertDialog.getButton(AlertDialog.BUTTON_NEGATIVE).setOnClickListener {
|
||||||
if (!deleting) dismiss()
|
if (!deleting) dismiss()
|
||||||
|
@ -71,29 +60,30 @@ class ProfileDeleteFragment : DialogFragment(), EuiccChannelFragmentMarker {
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun delete() {
|
private fun delete() {
|
||||||
toast?.cancel()
|
|
||||||
if (!inputMatchesName) {
|
|
||||||
val resId = R.string.toast_profile_delete_confirm_text_mismatched
|
|
||||||
toast = Toast.makeText(requireContext(), resId, Toast.LENGTH_LONG).also {
|
|
||||||
it.show()
|
|
||||||
}
|
|
||||||
return
|
|
||||||
}
|
|
||||||
deleting = true
|
deleting = true
|
||||||
|
val alertDialog = dialog!! as AlertDialog
|
||||||
alertDialog.setCanceledOnTouchOutside(false)
|
alertDialog.setCanceledOnTouchOutside(false)
|
||||||
alertDialog.setCancelable(false)
|
alertDialog.setCancelable(false)
|
||||||
alertDialog.getButton(AlertDialog.BUTTON_POSITIVE).isEnabled = false
|
alertDialog.getButton(AlertDialog.BUTTON_POSITIVE).isEnabled = false
|
||||||
alertDialog.getButton(AlertDialog.BUTTON_NEGATIVE).isEnabled = false
|
alertDialog.getButton(AlertDialog.BUTTON_NEGATIVE).isEnabled = false
|
||||||
|
|
||||||
requireParentFragment().lifecycleScope.launch {
|
lifecycleScope.launch {
|
||||||
ensureEuiccChannelManager()
|
try {
|
||||||
euiccChannelManagerService.waitForForegroundTask()
|
doDelete()
|
||||||
euiccChannelManagerService.launchProfileDeleteTask(slotId, portId, iccid)
|
} catch (e: Exception) {
|
||||||
.onStart {
|
Log.d(ProfileDownloadFragment.TAG, "Error deleting profile")
|
||||||
parentFragment?.notifyEuiccProfilesChanged()
|
Log.d(ProfileDownloadFragment.TAG, Log.getStackTraceString(e))
|
||||||
runCatching(::dismiss)
|
} finally {
|
||||||
|
if (parentFragment is EuiccProfilesChangedListener) {
|
||||||
|
(parentFragment as EuiccProfilesChangedListener).onEuiccProfilesChanged()
|
||||||
}
|
}
|
||||||
.waitDone()
|
dismiss()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private suspend fun doDelete() = beginTrackedOperation {
|
||||||
|
channel.lpa.deleteProfile(requireArguments().getString("iccid")!!)
|
||||||
|
preferenceRepository.notificationDeleteFlow.first()
|
||||||
|
}
|
||||||
}
|
}
|
|
@ -0,0 +1,266 @@
|
||||||
|
package im.angry.openeuicc.ui
|
||||||
|
|
||||||
|
import android.annotation.SuppressLint
|
||||||
|
import android.app.Dialog
|
||||||
|
import android.content.DialogInterface
|
||||||
|
import android.graphics.BitmapFactory
|
||||||
|
import android.os.Bundle
|
||||||
|
import android.text.Editable
|
||||||
|
import android.util.Log
|
||||||
|
import android.view.*
|
||||||
|
import android.widget.ProgressBar
|
||||||
|
import android.widget.TextView
|
||||||
|
import android.widget.Toast
|
||||||
|
import androidx.activity.result.contract.ActivityResultContracts
|
||||||
|
import androidx.appcompat.widget.Toolbar
|
||||||
|
import androidx.lifecycle.lifecycleScope
|
||||||
|
import com.google.android.material.textfield.TextInputLayout
|
||||||
|
import com.journeyapps.barcodescanner.ScanContract
|
||||||
|
import com.journeyapps.barcodescanner.ScanOptions
|
||||||
|
import im.angry.openeuicc.common.R
|
||||||
|
import im.angry.openeuicc.util.*
|
||||||
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.flow.first
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
import kotlinx.coroutines.withContext
|
||||||
|
import net.typeblog.lpac_jni.ProfileDownloadCallback
|
||||||
|
import kotlin.Exception
|
||||||
|
|
||||||
|
class ProfileDownloadFragment : BaseMaterialDialogFragment(),
|
||||||
|
Toolbar.OnMenuItemClickListener, EuiccChannelFragmentMarker {
|
||||||
|
companion object {
|
||||||
|
const val TAG = "ProfileDownloadFragment"
|
||||||
|
|
||||||
|
fun newInstance(slotId: Int, portId: Int, finishWhenDone: Boolean = false): ProfileDownloadFragment =
|
||||||
|
newInstanceEuicc(ProfileDownloadFragment::class.java, slotId, portId) {
|
||||||
|
putBoolean("finishWhenDone", finishWhenDone)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private lateinit var toolbar: Toolbar
|
||||||
|
private lateinit var profileDownloadServer: TextInputLayout
|
||||||
|
private lateinit var profileDownloadCode: TextInputLayout
|
||||||
|
private lateinit var profileDownloadConfirmationCode: TextInputLayout
|
||||||
|
private lateinit var profileDownloadIMEI: TextInputLayout
|
||||||
|
private lateinit var profileDownloadFreeSpace: TextView
|
||||||
|
private lateinit var progress: ProgressBar
|
||||||
|
|
||||||
|
private var freeNvram: Int = -1
|
||||||
|
|
||||||
|
private var downloading = false
|
||||||
|
|
||||||
|
private val finishWhenDone by lazy {
|
||||||
|
requireArguments().getBoolean("finishWhenDone", false)
|
||||||
|
}
|
||||||
|
|
||||||
|
private val barcodeScannerLauncher = registerForActivityResult(ScanContract()) { result ->
|
||||||
|
result.contents?.let { content ->
|
||||||
|
Log.d(TAG, content)
|
||||||
|
onScanResult(content)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private val gallerySelectorLauncher = registerForActivityResult(ActivityResultContracts.GetContent()) { result ->
|
||||||
|
if (result == null) return@registerForActivityResult
|
||||||
|
|
||||||
|
lifecycleScope.launch(Dispatchers.IO) {
|
||||||
|
runCatching {
|
||||||
|
requireContext().contentResolver.openInputStream(result)?.let { input ->
|
||||||
|
val bmp = BitmapFactory.decodeStream(input)
|
||||||
|
input.close()
|
||||||
|
|
||||||
|
decodeQrFromBitmap(bmp)?.let {
|
||||||
|
withContext(Dispatchers.Main) {
|
||||||
|
onScanResult(it)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
bmp.recycle()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun onScanResult(result: String) {
|
||||||
|
val components = result.split("$")
|
||||||
|
if (components.size < 3 || components[0] != "LPA:1") return
|
||||||
|
profileDownloadServer.editText?.setText(components[1])
|
||||||
|
profileDownloadCode.editText?.setText(components[2])
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onCreateView(
|
||||||
|
inflater: LayoutInflater,
|
||||||
|
container: ViewGroup?,
|
||||||
|
savedInstanceState: Bundle?
|
||||||
|
): View {
|
||||||
|
val view = inflater.inflate(R.layout.fragment_profile_download, container, false)
|
||||||
|
|
||||||
|
toolbar = view.requireViewById(R.id.toolbar)
|
||||||
|
profileDownloadServer = view.requireViewById(R.id.profile_download_server)
|
||||||
|
profileDownloadCode = view.requireViewById(R.id.profile_download_code)
|
||||||
|
profileDownloadConfirmationCode = view.requireViewById(R.id.profile_download_confirmation_code)
|
||||||
|
profileDownloadIMEI = view.requireViewById(R.id.profile_download_imei)
|
||||||
|
profileDownloadFreeSpace = view.requireViewById(R.id.profile_download_free_space)
|
||||||
|
progress = view.requireViewById(R.id.progress)
|
||||||
|
|
||||||
|
toolbar.inflateMenu(R.menu.fragment_profile_download)
|
||||||
|
|
||||||
|
return view
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
|
||||||
|
super.onViewCreated(view, savedInstanceState)
|
||||||
|
toolbar.apply {
|
||||||
|
setTitle(R.string.profile_download)
|
||||||
|
setNavigationOnClickListener {
|
||||||
|
if (!downloading) {
|
||||||
|
dismiss()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
setOnMenuItemClickListener(this@ProfileDownloadFragment)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onMenuItemClick(item: MenuItem): Boolean = downloading ||
|
||||||
|
when (item.itemId) {
|
||||||
|
R.id.scan -> {
|
||||||
|
barcodeScannerLauncher.launch(ScanOptions().apply {
|
||||||
|
setDesiredBarcodeFormats(ScanOptions.QR_CODE)
|
||||||
|
setOrientationLocked(false)
|
||||||
|
})
|
||||||
|
true
|
||||||
|
}
|
||||||
|
R.id.scan_from_gallery -> {
|
||||||
|
gallerySelectorLauncher.launch("image/*")
|
||||||
|
true
|
||||||
|
}
|
||||||
|
R.id.ok -> {
|
||||||
|
startDownloadProfile()
|
||||||
|
true
|
||||||
|
}
|
||||||
|
else -> false
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onResume() {
|
||||||
|
super.onResume()
|
||||||
|
setWidthPercent(95)
|
||||||
|
}
|
||||||
|
|
||||||
|
@SuppressLint("MissingPermission")
|
||||||
|
override fun onStart() {
|
||||||
|
super.onStart()
|
||||||
|
profileDownloadIMEI.editText!!.text = Editable.Factory.getInstance().newEditable(
|
||||||
|
try {
|
||||||
|
telephonyManager.getImei(channel.logicalSlotId) ?: ""
|
||||||
|
} catch (e: Exception) {
|
||||||
|
""
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
lifecycleScope.launch(Dispatchers.IO) {
|
||||||
|
// Fetch remaining NVRAM
|
||||||
|
val str = channel.lpa.euiccInfo2?.freeNvram?.also {
|
||||||
|
freeNvram = it
|
||||||
|
}?.let { formatFreeSpace(it) }
|
||||||
|
|
||||||
|
withContext(Dispatchers.Main) {
|
||||||
|
profileDownloadFreeSpace.text = getString(R.string.profile_download_free_space,
|
||||||
|
str ?: getText(R.string.unknown))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onCreateDialog(savedInstanceState: Bundle?): Dialog {
|
||||||
|
return super.onCreateDialog(savedInstanceState).also {
|
||||||
|
it.setCanceledOnTouchOutside(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun startDownloadProfile() {
|
||||||
|
val server = profileDownloadServer.editText!!.let {
|
||||||
|
it.text.toString().trim().apply {
|
||||||
|
if (isEmpty()) {
|
||||||
|
it.requestFocus()
|
||||||
|
return@startDownloadProfile
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
val code = profileDownloadCode.editText!!.text.toString().trim()
|
||||||
|
.ifBlank { null }
|
||||||
|
val confirmationCode = profileDownloadConfirmationCode.editText!!.text.toString().trim()
|
||||||
|
.ifBlank { null }
|
||||||
|
val imei = profileDownloadIMEI.editText!!.text.toString().trim()
|
||||||
|
.ifBlank { null }
|
||||||
|
|
||||||
|
downloading = true
|
||||||
|
|
||||||
|
profileDownloadServer.editText!!.isEnabled = false
|
||||||
|
profileDownloadCode.editText!!.isEnabled = false
|
||||||
|
profileDownloadConfirmationCode.editText!!.isEnabled = false
|
||||||
|
profileDownloadIMEI.editText!!.isEnabled = false
|
||||||
|
|
||||||
|
progress.isIndeterminate = true
|
||||||
|
progress.visibility = View.VISIBLE
|
||||||
|
|
||||||
|
lifecycleScope.launch {
|
||||||
|
try {
|
||||||
|
doDownloadProfile(server, code, confirmationCode, imei)
|
||||||
|
} catch (e: Exception) {
|
||||||
|
Log.d(TAG, "Error downloading profile")
|
||||||
|
Log.d(TAG, Log.getStackTraceString(e))
|
||||||
|
Toast.makeText(context, R.string.profile_download_failed, Toast.LENGTH_LONG).show()
|
||||||
|
} finally {
|
||||||
|
if (parentFragment is EuiccProfilesChangedListener) {
|
||||||
|
(parentFragment as EuiccProfilesChangedListener).onEuiccProfilesChanged()
|
||||||
|
}
|
||||||
|
dismiss()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private suspend fun doDownloadProfile(
|
||||||
|
server: String,
|
||||||
|
code: String?,
|
||||||
|
confirmationCode: String?,
|
||||||
|
imei: String?
|
||||||
|
) = beginTrackedOperation {
|
||||||
|
val res = channel.lpa.downloadProfile(
|
||||||
|
server,
|
||||||
|
code,
|
||||||
|
imei,
|
||||||
|
confirmationCode,
|
||||||
|
object : ProfileDownloadCallback {
|
||||||
|
override fun onStateUpdate(state: ProfileDownloadCallback.DownloadState) {
|
||||||
|
lifecycleScope.launch(Dispatchers.Main) {
|
||||||
|
progress.isIndeterminate = false
|
||||||
|
progress.progress = state.progress
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!res) {
|
||||||
|
// TODO: Provide more details on the error
|
||||||
|
throw RuntimeException("Failed to download profile; this is typically caused by another error happened before.")
|
||||||
|
}
|
||||||
|
|
||||||
|
// If we get here, we are successful
|
||||||
|
// This function is wrapped in beginTrackedOperation, so by returning the settings value,
|
||||||
|
// We only send notifications if the user allowed us to
|
||||||
|
preferenceRepository.notificationDownloadFlow.first()
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onDismiss(dialog: DialogInterface) {
|
||||||
|
super.onDismiss(dialog)
|
||||||
|
if (finishWhenDone) {
|
||||||
|
activity?.finish()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onCancel(dialog: DialogInterface) {
|
||||||
|
super.onCancel(dialog)
|
||||||
|
if (finishWhenDone) {
|
||||||
|
activity?.finish()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
|
@ -2,32 +2,34 @@ package im.angry.openeuicc.ui
|
||||||
|
|
||||||
import android.app.Dialog
|
import android.app.Dialog
|
||||||
import android.os.Bundle
|
import android.os.Bundle
|
||||||
|
import android.util.Log
|
||||||
import android.view.LayoutInflater
|
import android.view.LayoutInflater
|
||||||
import android.view.View
|
import android.view.View
|
||||||
import android.view.ViewGroup
|
import android.view.ViewGroup
|
||||||
import android.widget.ProgressBar
|
import android.widget.ProgressBar
|
||||||
import android.widget.Toast
|
import android.widget.Toast
|
||||||
import androidx.annotation.StringRes
|
|
||||||
import androidx.appcompat.widget.Toolbar
|
import androidx.appcompat.widget.Toolbar
|
||||||
import androidx.lifecycle.lifecycleScope
|
import androidx.lifecycle.lifecycleScope
|
||||||
import com.google.android.material.textfield.TextInputLayout
|
import com.google.android.material.textfield.TextInputLayout
|
||||||
import im.angry.openeuicc.common.R
|
import im.angry.openeuicc.common.R
|
||||||
import im.angry.openeuicc.service.EuiccChannelManagerService.Companion.waitDone
|
|
||||||
import im.angry.openeuicc.util.*
|
import im.angry.openeuicc.util.*
|
||||||
|
import kotlinx.coroutines.Dispatchers
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
import net.typeblog.lpac_jni.LocalProfileAssistant
|
import kotlinx.coroutines.withContext
|
||||||
|
import java.lang.Exception
|
||||||
|
import java.lang.RuntimeException
|
||||||
|
|
||||||
class ProfileRenameFragment : BaseMaterialDialogFragment(), EuiccChannelFragmentMarker {
|
class ProfileRenameFragment : BaseMaterialDialogFragment(), EuiccChannelFragmentMarker {
|
||||||
companion object {
|
companion object {
|
||||||
private const val FIELD_ICCID = "iccid"
|
|
||||||
private const val FIELD_CURRENT_NAME = "currentName"
|
|
||||||
|
|
||||||
const val TAG = "ProfileRenameFragment"
|
const val TAG = "ProfileRenameFragment"
|
||||||
|
|
||||||
fun newInstance(slotId: Int, portId: Int, iccid: String, currentName: String) =
|
fun newInstance(slotId: Int, portId: Int, iccid: String, currentName: String): ProfileRenameFragment {
|
||||||
newInstanceEuicc(ProfileRenameFragment::class.java, slotId, portId) {
|
val instance = newInstanceEuicc(ProfileRenameFragment::class.java, slotId, portId)
|
||||||
putString(FIELD_ICCID, iccid)
|
instance.requireArguments().apply {
|
||||||
putString(FIELD_CURRENT_NAME, currentName)
|
putString("iccid", iccid)
|
||||||
|
putString("currentName", currentName)
|
||||||
|
}
|
||||||
|
return instance
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -37,14 +39,6 @@ class ProfileRenameFragment : BaseMaterialDialogFragment(), EuiccChannelFragment
|
||||||
|
|
||||||
private var renaming = false
|
private var renaming = false
|
||||||
|
|
||||||
private val iccid: String by lazy {
|
|
||||||
requireArguments().getString(FIELD_ICCID)!!
|
|
||||||
}
|
|
||||||
|
|
||||||
private val currentName: String by lazy {
|
|
||||||
requireArguments().getString(FIELD_CURRENT_NAME)!!
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun onCreateView(
|
override fun onCreateView(
|
||||||
inflater: LayoutInflater,
|
inflater: LayoutInflater,
|
||||||
container: ViewGroup?,
|
container: ViewGroup?,
|
||||||
|
@ -63,9 +57,8 @@ class ProfileRenameFragment : BaseMaterialDialogFragment(), EuiccChannelFragment
|
||||||
|
|
||||||
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
|
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
|
||||||
super.onViewCreated(view, savedInstanceState)
|
super.onViewCreated(view, savedInstanceState)
|
||||||
profileRenameNewName.editText!!.setText(currentName)
|
|
||||||
toolbar.apply {
|
toolbar.apply {
|
||||||
setTitle(R.string.profile_rename)
|
setTitle(R.string.rename)
|
||||||
setNavigationOnClickListener {
|
setNavigationOnClickListener {
|
||||||
if (!renaming) dismiss()
|
if (!renaming) dismiss()
|
||||||
}
|
}
|
||||||
|
@ -76,6 +69,11 @@ class ProfileRenameFragment : BaseMaterialDialogFragment(), EuiccChannelFragment
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
override fun onStart() {
|
||||||
|
super.onStart()
|
||||||
|
profileRenameNewName.editText!!.setText(requireArguments().getString("currentName"))
|
||||||
|
}
|
||||||
|
|
||||||
override fun onResume() {
|
override fun onResume() {
|
||||||
super.onResume()
|
super.onResume()
|
||||||
setWidthPercent(95)
|
setWidthPercent(95)
|
||||||
|
@ -87,45 +85,35 @@ class ProfileRenameFragment : BaseMaterialDialogFragment(), EuiccChannelFragment
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun showErrorAndCancel(@StringRes resId: Int) {
|
private fun rename() {
|
||||||
Toast.makeText(requireContext(), resId, Toast.LENGTH_LONG).show()
|
val name = profileRenameNewName.editText!!.text.toString().trim()
|
||||||
|
if (name.length >= 64) {
|
||||||
renaming = false
|
Toast.makeText(context, R.string.toast_profile_name_too_long, Toast.LENGTH_LONG).show()
|
||||||
progress.visibility = View.GONE
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun rename() {
|
|
||||||
renaming = true
|
renaming = true
|
||||||
progress.isIndeterminate = true
|
progress.isIndeterminate = true
|
||||||
progress.visibility = View.VISIBLE
|
progress.visibility = View.VISIBLE
|
||||||
|
|
||||||
val newName = profileRenameNewName.editText!!.text.toString().trim()
|
|
||||||
|
|
||||||
lifecycleScope.launch {
|
lifecycleScope.launch {
|
||||||
ensureEuiccChannelManager()
|
try {
|
||||||
euiccChannelManagerService.waitForForegroundTask()
|
doRename(name)
|
||||||
val response = euiccChannelManagerService
|
} catch (e: Exception) {
|
||||||
.launchProfileRenameTask(slotId, portId, iccid, newName).waitDone()
|
Log.d(TAG, "Failed to rename profile")
|
||||||
|
Log.d(TAG, Log.getStackTraceString(e))
|
||||||
when (response) {
|
} finally {
|
||||||
is LocalProfileAssistant.ProfileNameTooLongException -> {
|
if (parentFragment is EuiccProfilesChangedListener) {
|
||||||
showErrorAndCancel(R.string.profile_rename_too_long)
|
(parentFragment as EuiccProfilesChangedListener).onEuiccProfilesChanged()
|
||||||
}
|
}
|
||||||
|
dismiss()
|
||||||
is LocalProfileAssistant.ProfileNameIsInvalidUTF8Exception -> {
|
|
||||||
showErrorAndCancel(R.string.profile_rename_encoding_error)
|
|
||||||
}
|
|
||||||
|
|
||||||
is Throwable -> {
|
|
||||||
showErrorAndCancel(R.string.profile_rename_failure)
|
|
||||||
}
|
|
||||||
|
|
||||||
else -> {
|
|
||||||
parentFragment?.notifyEuiccProfilesChanged()
|
|
||||||
|
|
||||||
runCatching(::dismiss)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private suspend fun doRename(name: String) = withContext(Dispatchers.IO) {
|
||||||
|
if (!channel.lpa.setNickname(requireArguments().getString("iccid")!!, name)) {
|
||||||
|
throw RuntimeException("Profile nickname not changed")
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
|
@ -2,26 +2,17 @@ package im.angry.openeuicc.ui
|
||||||
|
|
||||||
import android.os.Bundle
|
import android.os.Bundle
|
||||||
import android.view.MenuItem
|
import android.view.MenuItem
|
||||||
import androidx.activity.enableEdgeToEdge
|
|
||||||
import androidx.appcompat.app.AppCompatActivity
|
import androidx.appcompat.app.AppCompatActivity
|
||||||
import im.angry.openeuicc.OpenEuiccApplication
|
|
||||||
import im.angry.openeuicc.common.R
|
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
|
|
||||||
|
|
||||||
override fun onCreate(savedInstanceState: Bundle?) {
|
override fun onCreate(savedInstanceState: Bundle?) {
|
||||||
enableEdgeToEdge()
|
|
||||||
super.onCreate(savedInstanceState)
|
super.onCreate(savedInstanceState)
|
||||||
setContentView(R.layout.activity_settings)
|
setContentView(R.layout.activity_settings)
|
||||||
setSupportActionBar(requireViewById(R.id.toolbar))
|
setSupportActionBar(requireViewById(R.id.toolbar))
|
||||||
setupToolbarInsets()
|
|
||||||
supportActionBar!!.setDisplayHomeAsUpEnabled(true)
|
supportActionBar!!.setDisplayHomeAsUpEnabled(true)
|
||||||
val settingsFragment = appContainer.uiComponentFactory.createSettingsFragment()
|
|
||||||
supportFragmentManager.beginTransaction()
|
supportFragmentManager.beginTransaction()
|
||||||
.replace(R.id.settings_container, settingsFragment)
|
.replace(R.id.settings_container, SettingsFragment())
|
||||||
.commit()
|
.commit()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -2,169 +2,60 @@ package im.angry.openeuicc.ui
|
||||||
|
|
||||||
import android.content.Intent
|
import android.content.Intent
|
||||||
import android.net.Uri
|
import android.net.Uri
|
||||||
import android.os.Build
|
|
||||||
import android.os.Bundle
|
import android.os.Bundle
|
||||||
import android.provider.Settings
|
import androidx.datastore.preferences.core.Preferences
|
||||||
import android.widget.Toast
|
|
||||||
import androidx.lifecycle.lifecycleScope
|
import androidx.lifecycle.lifecycleScope
|
||||||
import androidx.preference.CheckBoxPreference
|
import androidx.preference.CheckBoxPreference
|
||||||
import androidx.preference.ListPreference
|
|
||||||
import androidx.preference.Preference
|
import androidx.preference.Preference
|
||||||
import androidx.preference.PreferenceCategory
|
|
||||||
import androidx.preference.PreferenceFragmentCompat
|
import androidx.preference.PreferenceFragmentCompat
|
||||||
import im.angry.openeuicc.common.R
|
import im.angry.openeuicc.common.R
|
||||||
import im.angry.openeuicc.util.*
|
import im.angry.openeuicc.util.*
|
||||||
import kotlinx.coroutines.flow.collect
|
import kotlinx.coroutines.flow.Flow
|
||||||
import kotlinx.coroutines.flow.onEach
|
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
|
import kotlinx.coroutines.runBlocking
|
||||||
|
|
||||||
open class SettingsFragment: PreferenceFragmentCompat() {
|
class SettingsFragment: PreferenceFragmentCompat() {
|
||||||
private lateinit var developerPref: PreferenceCategory
|
|
||||||
|
|
||||||
// Hidden developer options switch
|
|
||||||
private var numClicks = 0
|
|
||||||
private var lastClickTimestamp = -1L
|
|
||||||
private var lastToast: Toast? = null
|
|
||||||
|
|
||||||
override fun onCreatePreferences(savedInstanceState: Bundle?, rootKey: String?) {
|
override fun onCreatePreferences(savedInstanceState: Bundle?, rootKey: String?) {
|
||||||
setPreferencesFromResource(R.xml.pref_settings, rootKey)
|
setPreferencesFromResource(R.xml.pref_settings, rootKey)
|
||||||
|
|
||||||
developerPref = requirePreference("pref_developer")
|
findPreference<Preference>("pref_info_app_version")
|
||||||
|
?.summary = requireContext().selfAppVersion
|
||||||
|
|
||||||
// Show / hide developer preference based on whether it is enabled
|
findPreference<Preference>("pref_info_source_code")
|
||||||
|
?.setOnPreferenceClickListener {
|
||||||
|
startActivity(Intent(Intent.ACTION_VIEW, Uri.parse(it.summary.toString())))
|
||||||
|
true
|
||||||
|
}
|
||||||
|
|
||||||
|
findPreference<Preference>("pref_advanced_logs")
|
||||||
|
?.setOnPreferenceClickListener {
|
||||||
|
startActivity(Intent(requireContext(), LogsActivity::class.java))
|
||||||
|
true
|
||||||
|
}
|
||||||
|
|
||||||
|
findPreference<CheckBoxPreference>("pref_notifications_download")
|
||||||
|
?.bindBooleanFlow(preferenceRepository.notificationDownloadFlow, PreferenceKeys.NOTIFICATION_DOWNLOAD)
|
||||||
|
|
||||||
|
findPreference<CheckBoxPreference>("pref_notifications_delete")
|
||||||
|
?.bindBooleanFlow(preferenceRepository.notificationDeleteFlow, PreferenceKeys.NOTIFICATION_DELETE)
|
||||||
|
|
||||||
|
findPreference<CheckBoxPreference>("pref_notifications_switch")
|
||||||
|
?.bindBooleanFlow(preferenceRepository.notificationSwitchFlow, PreferenceKeys.NOTIFICATION_SWITCH)
|
||||||
|
|
||||||
|
findPreference<CheckBoxPreference>("pref_advanced_disable_safeguard_removable_esim")
|
||||||
|
?.bindBooleanFlow(preferenceRepository.disableSafeguardFlow, PreferenceKeys.DISABLE_SAFEGUARD_REMOVABLE_ESIM)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun CheckBoxPreference.bindBooleanFlow(flow: Flow<Boolean>, key: Preferences.Key<Boolean>) {
|
||||||
lifecycleScope.launch {
|
lifecycleScope.launch {
|
||||||
preferenceRepository.developerOptionsEnabledFlow
|
flow.collect { isChecked = it }
|
||||||
.onEach(developerPref::setVisible)
|
|
||||||
.collect()
|
|
||||||
}
|
|
||||||
|
|
||||||
requirePreference<Preference>("pref_info_app_version").apply {
|
|
||||||
summary = requireContext().selfAppVersion
|
|
||||||
|
|
||||||
// Enable developer options when this is clicked for 7 times
|
|
||||||
setOnPreferenceClickListener(::onAppVersionClicked)
|
|
||||||
}
|
|
||||||
|
|
||||||
requirePreference<Preference>("pref_advanced_language").apply {
|
|
||||||
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.TIRAMISU) return@apply
|
|
||||||
isVisible = true
|
|
||||||
intent = Intent(Settings.ACTION_APP_LOCALE_SETTINGS).apply {
|
|
||||||
data = Uri.fromParts("package", requireContext().packageName, null)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
requirePreference<Preference>("pref_advanced_logs").apply {
|
|
||||||
intent = Intent(requireContext(), LogsActivity::class.java)
|
|
||||||
}
|
|
||||||
|
|
||||||
requirePreference<CheckBoxPreference>("pref_notifications_download")
|
|
||||||
.bindBooleanFlow(preferenceRepository.notificationDownloadFlow)
|
|
||||||
|
|
||||||
requirePreference<CheckBoxPreference>("pref_notifications_delete")
|
|
||||||
.bindBooleanFlow(preferenceRepository.notificationDeleteFlow)
|
|
||||||
|
|
||||||
requirePreference<CheckBoxPreference>("pref_notifications_switch")
|
|
||||||
.bindBooleanFlow(preferenceRepository.notificationSwitchFlow)
|
|
||||||
|
|
||||||
requirePreference<CheckBoxPreference>("pref_advanced_disable_safeguard_removable_esim")
|
|
||||||
.bindBooleanFlow(preferenceRepository.disableSafeguardFlow)
|
|
||||||
|
|
||||||
requirePreference<CheckBoxPreference>("pref_advanced_verbose_logging")
|
|
||||||
.bindBooleanFlow(preferenceRepository.verboseLoggingFlow)
|
|
||||||
|
|
||||||
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<CheckBoxPreference>("pref_developer_euicc_memory_reset")
|
|
||||||
.bindBooleanFlow(preferenceRepository.euiccMemoryResetFlow)
|
|
||||||
|
|
||||||
requirePreference<ListPreference>("pref_developer_es10x_mss")
|
|
||||||
.bindIntFlow(preferenceRepository.es10xMssFlow, 63)
|
|
||||||
|
|
||||||
requirePreference<Preference>("pref_developer_isdr_aid_list").apply {
|
|
||||||
intent = Intent(requireContext(), IsdrAidListActivity::class.java)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
protected fun <T : Preference> requirePreference(key: CharSequence) =
|
|
||||||
findPreference<T>(key)!!
|
|
||||||
|
|
||||||
override fun onStart() {
|
|
||||||
super.onStart()
|
|
||||||
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
|
|
||||||
lastClickTimestamp = now
|
|
||||||
|
|
||||||
lifecycleScope.launch {
|
|
||||||
preferenceRepository.developerOptionsEnabledFlow.updatePreference(numClicks >= 7)
|
|
||||||
}
|
|
||||||
|
|
||||||
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)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
setOnPreferenceChangeListener { _, newValue ->
|
setOnPreferenceChangeListener { _, newValue ->
|
||||||
lifecycleScope.launch {
|
runBlocking {
|
||||||
flow.updatePreference(newValue as Boolean)
|
preferenceRepository.updatePreference(key, newValue as Boolean)
|
||||||
}
|
}
|
||||||
true
|
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)
|
|
||||||
|
|
||||||
val prefs = buildList {
|
|
||||||
for (i in 0..<overlayCat.preferenceCount) {
|
|
||||||
add(overlayCat.getPreference(i))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
prefs.forEach {
|
|
||||||
overlayCat.removePreference(it)
|
|
||||||
targetCat.addPreference(it)
|
|
||||||
}
|
|
||||||
|
|
||||||
overlayCat.parent?.removePreference(overlayCat)
|
|
||||||
}
|
|
||||||
}
|
}
|
|
@ -0,0 +1,93 @@
|
||||||
|
package im.angry.openeuicc.ui
|
||||||
|
|
||||||
|
import android.content.DialogInterface
|
||||||
|
import android.os.Bundle
|
||||||
|
import android.view.LayoutInflater
|
||||||
|
import android.view.View
|
||||||
|
import android.view.ViewGroup
|
||||||
|
import android.widget.ArrayAdapter
|
||||||
|
import android.widget.Spinner
|
||||||
|
import androidx.appcompat.widget.Toolbar
|
||||||
|
import im.angry.openeuicc.common.R
|
||||||
|
import im.angry.openeuicc.core.EuiccChannel
|
||||||
|
import im.angry.openeuicc.util.*
|
||||||
|
|
||||||
|
class SlotSelectFragment : BaseMaterialDialogFragment(), OpenEuiccContextMarker {
|
||||||
|
companion object {
|
||||||
|
const val TAG = "SlotSelectFragment"
|
||||||
|
|
||||||
|
fun newInstance(knownChannels: List<EuiccChannel>): SlotSelectFragment {
|
||||||
|
return SlotSelectFragment().apply {
|
||||||
|
arguments = Bundle().apply {
|
||||||
|
putIntArray("slotIds", knownChannels.map { it.slotId }.toIntArray())
|
||||||
|
putIntArray("logicalSlotIds", knownChannels.map { it.logicalSlotId }.toIntArray())
|
||||||
|
putIntArray("portIds", knownChannels.map { it.portId }.toIntArray())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
interface SlotSelectedListener {
|
||||||
|
fun onSlotSelected(slotId: Int, portId: Int)
|
||||||
|
fun onSlotSelectCancelled()
|
||||||
|
}
|
||||||
|
|
||||||
|
private lateinit var toolbar: Toolbar
|
||||||
|
private lateinit var spinner: Spinner
|
||||||
|
private lateinit var adapter: ArrayAdapter<String>
|
||||||
|
private lateinit var slotIds: IntArray
|
||||||
|
private lateinit var logicalSlotIds: IntArray
|
||||||
|
private lateinit var portIds: IntArray
|
||||||
|
|
||||||
|
override fun onCreateView(
|
||||||
|
inflater: LayoutInflater,
|
||||||
|
container: ViewGroup?,
|
||||||
|
savedInstanceState: Bundle?
|
||||||
|
): View? {
|
||||||
|
val view = inflater.inflate(R.layout.fragment_slot_select, container, false)
|
||||||
|
|
||||||
|
toolbar = view.requireViewById(R.id.toolbar)
|
||||||
|
toolbar.setTitle(R.string.slot_select)
|
||||||
|
toolbar.inflateMenu(R.menu.fragment_slot_select)
|
||||||
|
|
||||||
|
adapter = ArrayAdapter<String>(inflater.context, R.layout.spinner_item)
|
||||||
|
|
||||||
|
spinner = view.requireViewById(R.id.spinner)
|
||||||
|
spinner.adapter = adapter
|
||||||
|
|
||||||
|
return view
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onStart() {
|
||||||
|
super.onStart()
|
||||||
|
|
||||||
|
slotIds = requireArguments().getIntArray("slotIds")!!
|
||||||
|
logicalSlotIds = requireArguments().getIntArray("logicalSlotIds")!!
|
||||||
|
portIds = requireArguments().getIntArray("portIds")!!
|
||||||
|
|
||||||
|
logicalSlotIds.forEach { id ->
|
||||||
|
adapter.add(getString(R.string.channel_name_format, id))
|
||||||
|
}
|
||||||
|
|
||||||
|
toolbar.setNavigationOnClickListener {
|
||||||
|
(requireActivity() as SlotSelectedListener).onSlotSelectCancelled()
|
||||||
|
}
|
||||||
|
toolbar.setOnMenuItemClickListener {
|
||||||
|
val slotId = slotIds[spinner.selectedItemPosition]
|
||||||
|
val portId = portIds[spinner.selectedItemPosition]
|
||||||
|
(requireActivity() as SlotSelectedListener).onSlotSelected(slotId, portId)
|
||||||
|
dismiss()
|
||||||
|
true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onResume() {
|
||||||
|
super.onResume()
|
||||||
|
setWidthPercent(75)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onCancel(dialog: DialogInterface) {
|
||||||
|
super.onCancel(dialog)
|
||||||
|
(requireActivity() as SlotSelectedListener).onSlotSelectCancelled()
|
||||||
|
}
|
||||||
|
}
|
|
@ -20,6 +20,7 @@ import androidx.fragment.app.Fragment
|
||||||
import androidx.fragment.app.commit
|
import androidx.fragment.app.commit
|
||||||
import androidx.lifecycle.lifecycleScope
|
import androidx.lifecycle.lifecycleScope
|
||||||
import im.angry.openeuicc.common.R
|
import im.angry.openeuicc.common.R
|
||||||
|
import im.angry.openeuicc.core.EuiccChannel
|
||||||
import im.angry.openeuicc.core.EuiccChannelManager
|
import im.angry.openeuicc.core.EuiccChannelManager
|
||||||
import im.angry.openeuicc.util.*
|
import im.angry.openeuicc.util.*
|
||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
@ -72,6 +73,7 @@ class UsbCcidReaderFragment : Fragment(), OpenEuiccContextMarker {
|
||||||
private lateinit var loadingProgress: ProgressBar
|
private lateinit var loadingProgress: ProgressBar
|
||||||
|
|
||||||
private var usbDevice: UsbDevice? = null
|
private var usbDevice: UsbDevice? = null
|
||||||
|
private var usbChannel: EuiccChannel? = null
|
||||||
|
|
||||||
override fun onCreateView(
|
override fun onCreateView(
|
||||||
inflater: LayoutInflater,
|
inflater: LayoutInflater,
|
||||||
|
@ -120,7 +122,7 @@ class UsbCcidReaderFragment : Fragment(), OpenEuiccContextMarker {
|
||||||
try {
|
try {
|
||||||
requireContext().unregisterReceiver(usbPermissionReceiver)
|
requireContext().unregisterReceiver(usbPermissionReceiver)
|
||||||
} catch (_: Exception) {
|
} catch (_: Exception) {
|
||||||
// ignore
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -129,7 +131,7 @@ class UsbCcidReaderFragment : Fragment(), OpenEuiccContextMarker {
|
||||||
try {
|
try {
|
||||||
requireContext().unregisterReceiver(usbPermissionReceiver)
|
requireContext().unregisterReceiver(usbPermissionReceiver)
|
||||||
} catch (_: Exception) {
|
} catch (_: Exception) {
|
||||||
// ignore
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -138,26 +140,24 @@ class UsbCcidReaderFragment : Fragment(), OpenEuiccContextMarker {
|
||||||
permissionButton.visibility = View.GONE
|
permissionButton.visibility = View.GONE
|
||||||
loadingProgress.visibility = View.VISIBLE
|
loadingProgress.visibility = View.VISIBLE
|
||||||
|
|
||||||
val (device, canOpen) = withContext(Dispatchers.IO) {
|
val (device, channel) = withContext(Dispatchers.IO) {
|
||||||
euiccChannelManager.tryOpenUsbEuiccChannel()
|
euiccChannelManager.enumerateUsbEuiccChannel()
|
||||||
}
|
}
|
||||||
|
|
||||||
loadingProgress.visibility = View.GONE
|
loadingProgress.visibility = View.GONE
|
||||||
|
|
||||||
usbDevice = device
|
usbDevice = device
|
||||||
|
usbChannel = channel
|
||||||
|
|
||||||
if (device != null && !canOpen && !usbManager.hasPermission(device)) {
|
if (device != null && channel == null && !usbManager.hasPermission(device)) {
|
||||||
text.text = getString(R.string.usb_permission_needed)
|
text.text = getString(R.string.usb_permission_needed)
|
||||||
text.visibility = View.VISIBLE
|
text.visibility = View.VISIBLE
|
||||||
permissionButton.visibility = View.VISIBLE
|
permissionButton.visibility = View.VISIBLE
|
||||||
} else if (device != null && canOpen) {
|
} else if (device != null && channel != null) {
|
||||||
childFragmentManager.commit {
|
childFragmentManager.commit {
|
||||||
replace(
|
replace(
|
||||||
R.id.child_container,
|
R.id.child_container,
|
||||||
appContainer.uiComponentFactory.createEuiccManagementFragment(
|
appContainer.uiComponentFactory.createEuiccManagementFragment(channel)
|
||||||
slotId = EuiccChannelManager.USB_CHANNEL_ID,
|
|
||||||
portId = 0
|
|
||||||
)
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
|
|
|
@ -1,330 +0,0 @@
|
||||||
package im.angry.openeuicc.ui.wizard
|
|
||||||
|
|
||||||
import android.app.assist.AssistContent
|
|
||||||
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
|
|
||||||
import android.widget.Toast
|
|
||||||
import androidx.activity.OnBackPressedCallback
|
|
||||||
import androidx.activity.enableEdgeToEdge
|
|
||||||
import androidx.core.net.toUri
|
|
||||||
import androidx.core.view.ViewCompat
|
|
||||||
import androidx.core.view.WindowInsetsCompat
|
|
||||||
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.EuiccChannelManager
|
|
||||||
import im.angry.openeuicc.ui.BaseEuiccAccessActivity
|
|
||||||
import im.angry.openeuicc.util.*
|
|
||||||
import kotlinx.coroutines.Dispatchers
|
|
||||||
import kotlinx.coroutines.launch
|
|
||||||
import net.typeblog.lpac_jni.LocalProfileAssistant
|
|
||||||
|
|
||||||
class DownloadWizardActivity: BaseEuiccAccessActivity() {
|
|
||||||
data class DownloadWizardState(
|
|
||||||
var currentStepFragmentClassName: String?,
|
|
||||||
var selectedLogicalSlot: Int,
|
|
||||||
var smdp: String,
|
|
||||||
var matchingId: String?,
|
|
||||||
var confirmationCode: String?,
|
|
||||||
var imei: String?,
|
|
||||||
var downloadStarted: Boolean,
|
|
||||||
var downloadTaskID: Long,
|
|
||||||
var downloadError: LocalProfileAssistant.ProfileDownloadException?,
|
|
||||||
var skipMethodSelect: Boolean,
|
|
||||||
var confirmationCodeRequired: Boolean,
|
|
||||||
)
|
|
||||||
|
|
||||||
private lateinit var state: DownloadWizardState
|
|
||||||
|
|
||||||
private lateinit var progressBar: ProgressBar
|
|
||||||
private lateinit var nextButton: Button
|
|
||||||
private lateinit var prevButton: Button
|
|
||||||
|
|
||||||
private var currentFragment: DownloadWizardStepFragment? = null
|
|
||||||
set(value) {
|
|
||||||
if (this::state.isInitialized) {
|
|
||||||
state.currentStepFragmentClassName = value?.javaClass?.name
|
|
||||||
}
|
|
||||||
field = value
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun onCreate(savedInstanceState: Bundle?) {
|
|
||||||
enableEdgeToEdge()
|
|
||||||
super.onCreate(savedInstanceState)
|
|
||||||
setContentView(R.layout.activity_download_wizard)
|
|
||||||
onBackPressedDispatcher.addCallback(object : OnBackPressedCallback(true) {
|
|
||||||
override fun handleOnBackPressed() {
|
|
||||||
// Make back == prev
|
|
||||||
onPrevPressed()
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
state = DownloadWizardState(
|
|
||||||
currentStepFragmentClassName = null,
|
|
||||||
selectedLogicalSlot = intent.getIntExtra("selectedLogicalSlot", 0),
|
|
||||||
smdp = "",
|
|
||||||
matchingId = null,
|
|
||||||
confirmationCode = null,
|
|
||||||
imei = null,
|
|
||||||
downloadStarted = false,
|
|
||||||
downloadTaskID = -1,
|
|
||||||
downloadError = null,
|
|
||||||
skipMethodSelect = false,
|
|
||||||
confirmationCodeRequired = false,
|
|
||||||
)
|
|
||||||
|
|
||||||
handleDeepLink()
|
|
||||||
|
|
||||||
progressBar = requireViewById(R.id.progress)
|
|
||||||
nextButton = requireViewById(R.id.download_wizard_next)
|
|
||||||
prevButton = requireViewById(R.id.download_wizard_back)
|
|
||||||
|
|
||||||
nextButton.setOnClickListener {
|
|
||||||
onNextPressed()
|
|
||||||
}
|
|
||||||
|
|
||||||
prevButton.setOnClickListener {
|
|
||||||
onPrevPressed()
|
|
||||||
}
|
|
||||||
|
|
||||||
val navigation = requireViewById<View>(R.id.download_wizard_navigation)
|
|
||||||
val origHeight = navigation.layoutParams.height
|
|
||||||
|
|
||||||
ViewCompat.setOnApplyWindowInsetsListener(navigation) { v, insets ->
|
|
||||||
val bars = insets.getInsets(
|
|
||||||
WindowInsetsCompat.Type.systemBars()
|
|
||||||
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
|
|
||||||
}
|
|
||||||
|
|
||||||
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
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun handleDeepLink() {
|
|
||||||
// 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 ?: return
|
|
||||||
if (uri.scheme.contentEquals("lpa", ignoreCase = true)) {
|
|
||||||
val parsed = LPAString.parse(uri.schemeSpecificPart)
|
|
||||||
state.smdp = parsed.address
|
|
||||||
state.matchingId = parsed.matchingId
|
|
||||||
state.confirmationCodeRequired = parsed.confirmationCodeRequired
|
|
||||||
state.skipMethodSelect = true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun onProvideAssistContent(outContent: AssistContent?) {
|
|
||||||
super.onProvideAssistContent(outContent)
|
|
||||||
outContent?.webUri = try {
|
|
||||||
val activationCode = LPAString(
|
|
||||||
state.smdp,
|
|
||||||
state.matchingId,
|
|
||||||
null,
|
|
||||||
state.confirmationCode != null,
|
|
||||||
)
|
|
||||||
"LPA:$activationCode".toUri()
|
|
||||||
} catch (_: Exception) {
|
|
||||||
null
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun onSaveInstanceState(outState: Bundle) {
|
|
||||||
super.onSaveInstanceState(outState)
|
|
||||||
outState.putString("currentStepFragmentClassName", state.currentStepFragmentClassName)
|
|
||||||
outState.putInt("selectedLogicalSlot", state.selectedLogicalSlot)
|
|
||||||
outState.putString("smdp", state.smdp)
|
|
||||||
outState.putString("matchingId", state.matchingId)
|
|
||||||
outState.putString("confirmationCode", state.confirmationCode)
|
|
||||||
outState.putString("imei", state.imei)
|
|
||||||
outState.putBoolean("downloadStarted", state.downloadStarted)
|
|
||||||
outState.putLong("downloadTaskID", state.downloadTaskID)
|
|
||||||
outState.putBoolean("confirmationCodeRequired", state.confirmationCodeRequired)
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun onRestoreInstanceState(savedInstanceState: Bundle) {
|
|
||||||
super.onRestoreInstanceState(savedInstanceState)
|
|
||||||
state.currentStepFragmentClassName = savedInstanceState.getString(
|
|
||||||
"currentStepFragmentClassName",
|
|
||||||
state.currentStepFragmentClassName
|
|
||||||
)
|
|
||||||
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)
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun onPrevPressed() {
|
|
||||||
hideIme()
|
|
||||||
|
|
||||||
if (currentFragment?.hasPrev == true) {
|
|
||||||
val prevFrag = currentFragment?.createPrevFragment()
|
|
||||||
if (prevFrag == null) {
|
|
||||||
finish()
|
|
||||||
} else {
|
|
||||||
showFragment(prevFrag, R.anim.slide_in_left, R.anim.slide_out_right)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun onNextPressed() {
|
|
||||||
hideIme()
|
|
||||||
|
|
||||||
nextButton.isEnabled = false
|
|
||||||
progressBar.visibility = View.VISIBLE
|
|
||||||
progressBar.isIndeterminate = true
|
|
||||||
|
|
||||||
lifecycleScope.launch(Dispatchers.Main) {
|
|
||||||
if (state.selectedLogicalSlot >= 0) {
|
|
||||||
try {
|
|
||||||
// This is run on IO by default
|
|
||||||
euiccChannelManager.withEuiccChannel(state.selectedLogicalSlot) { channel ->
|
|
||||||
// Be _very_ sure that the channel we got is valid
|
|
||||||
if (!channel.valid) throw EuiccChannelManager.EuiccChannelNotFoundException()
|
|
||||||
}
|
|
||||||
} catch (e: EuiccChannelManager.EuiccChannelNotFoundException) {
|
|
||||||
Toast.makeText(
|
|
||||||
this@DownloadWizardActivity,
|
|
||||||
R.string.download_wizard_slot_removed,
|
|
||||||
Toast.LENGTH_LONG
|
|
||||||
).show()
|
|
||||||
finish()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
progressBar.visibility = View.GONE
|
|
||||||
nextButton.isEnabled = true
|
|
||||||
|
|
||||||
if (currentFragment?.hasNext == true) {
|
|
||||||
currentFragment?.beforeNext()
|
|
||||||
val nextFrag = currentFragment?.createNextFragment()
|
|
||||||
if (nextFrag == null) {
|
|
||||||
finish()
|
|
||||||
} else {
|
|
||||||
showFragment(nextFrag, R.anim.slide_in_right, R.anim.slide_out_left)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun onInit() {
|
|
||||||
progressBar.visibility = View.GONE
|
|
||||||
|
|
||||||
if (state.currentStepFragmentClassName != null) {
|
|
||||||
val clazz = Class.forName(state.currentStepFragmentClassName!!)
|
|
||||||
showFragment(clazz.getDeclaredConstructor().newInstance() as DownloadWizardStepFragment)
|
|
||||||
} else {
|
|
||||||
showFragment(DownloadWizardSlotSelectFragment())
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun showFragment(
|
|
||||||
nextFrag: DownloadWizardStepFragment,
|
|
||||||
enterAnim: Int = 0,
|
|
||||||
exitAnim: Int = 0
|
|
||||||
) {
|
|
||||||
currentFragment = nextFrag
|
|
||||||
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()
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun refreshButtons() {
|
|
||||||
currentFragment?.let {
|
|
||||||
nextButton.visibility = if (it.hasNext) {
|
|
||||||
View.VISIBLE
|
|
||||||
} else {
|
|
||||||
View.GONE
|
|
||||||
}
|
|
||||||
prevButton.visibility = if (it.hasPrev) {
|
|
||||||
View.VISIBLE
|
|
||||||
} else {
|
|
||||||
View.GONE
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun hideIme() {
|
|
||||||
currentFocus?.let {
|
|
||||||
val imm = getSystemService(InputMethodManager::class.java)
|
|
||||||
imm.hideSoftInputFromWindow(it.windowToken, 0)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
abstract class DownloadWizardStepFragment : Fragment(), OpenEuiccContextMarker {
|
|
||||||
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?
|
|
||||||
abstract fun createPrevFragment(): DownloadWizardStepFragment?
|
|
||||||
|
|
||||||
protected fun gotoNextFragment(next: DownloadWizardStepFragment? = null) {
|
|
||||||
val realNext = next ?: createNextFragment()
|
|
||||||
(requireActivity() as DownloadWizardActivity).showFragment(
|
|
||||||
realNext!!,
|
|
||||||
R.anim.slide_in_right,
|
|
||||||
R.anim.slide_out_left
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
protected fun hideProgressBar() {
|
|
||||||
(requireActivity() as DownloadWizardActivity).progressBar.visibility = View.GONE
|
|
||||||
}
|
|
||||||
|
|
||||||
protected fun showProgressBar(progressValue: Int) {
|
|
||||||
(requireActivity() as DownloadWizardActivity).progressBar.apply {
|
|
||||||
visibility = View.VISIBLE
|
|
||||||
if (progressValue >= 0) {
|
|
||||||
isIndeterminate = false
|
|
||||||
progress = progressValue
|
|
||||||
} else {
|
|
||||||
isIndeterminate = true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
protected fun refreshButtons() {
|
|
||||||
(requireActivity() as DownloadWizardActivity).refreshButtons()
|
|
||||||
}
|
|
||||||
|
|
||||||
open fun beforeNext() {}
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -1,117 +0,0 @@
|
||||||
package im.angry.openeuicc.ui.wizard
|
|
||||||
|
|
||||||
import android.os.Bundle
|
|
||||||
import android.view.LayoutInflater
|
|
||||||
import android.view.View
|
|
||||||
import android.view.ViewGroup
|
|
||||||
import androidx.core.widget.addTextChangedListener
|
|
||||||
import com.google.android.material.textfield.TextInputLayout
|
|
||||||
import im.angry.openeuicc.common.R
|
|
||||||
|
|
||||||
class DownloadWizardDetailsFragment : DownloadWizardActivity.DownloadWizardStepFragment() {
|
|
||||||
private var inputComplete = false
|
|
||||||
|
|
||||||
override val hasNext: Boolean
|
|
||||||
get() = inputComplete
|
|
||||||
override val hasPrev: Boolean
|
|
||||||
get() = true
|
|
||||||
|
|
||||||
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 = smdp.editText!!.text.toString().trim()
|
|
||||||
// Treat empty inputs as null -- this is important for the download step
|
|
||||||
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()
|
|
||||||
|
|
||||||
override fun createNextFragment(): DownloadWizardActivity.DownloadWizardStepFragment =
|
|
||||||
DownloadWizardProgressFragment()
|
|
||||||
|
|
||||||
override fun createPrevFragment(): DownloadWizardActivity.DownloadWizardStepFragment =
|
|
||||||
if (state.skipMethodSelect) {
|
|
||||||
DownloadWizardSlotSelectFragment()
|
|
||||||
} else {
|
|
||||||
DownloadWizardMethodSelectFragment()
|
|
||||||
}
|
|
||||||
|
|
||||||
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()
|
|
||||||
smdp.editText!!.setText(state.smdp)
|
|
||||||
matchingId.editText!!.setText(state.matchingId)
|
|
||||||
confirmationCode.editText!!.setText(state.confirmationCode)
|
|
||||||
imei.editText!!.setText(state.imei)
|
|
||||||
updateInputCompleteness()
|
|
||||||
|
|
||||||
if (state.confirmationCodeRequired) {
|
|
||||||
confirmationCode.editText!!.requestFocus()
|
|
||||||
confirmationCode.editText!!.hint =
|
|
||||||
getString(R.string.profile_download_confirmation_code_required)
|
|
||||||
} else {
|
|
||||||
confirmationCode.editText!!.hint =
|
|
||||||
getString(R.string.profile_download_confirmation_code)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun onPause() {
|
|
||||||
super.onPause()
|
|
||||||
saveState()
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun updateInputCompleteness() {
|
|
||||||
inputComplete = isValidAddress(smdp.editText!!.text)
|
|
||||||
if (state.confirmationCodeRequired) {
|
|
||||||
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.substring(0, portIndex)
|
|
||||||
port = input.substring(portIndex + 1, input.length).toIntOrNull(10) ?: 0
|
|
||||||
}
|
|
||||||
// see https://en.wikipedia.org/wiki/Port_(computer_networking)
|
|
||||||
if (port < 1 || port > 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
|
|
||||||
}
|
|
|
@ -1,141 +0,0 @@
|
||||||
package im.angry.openeuicc.ui.wizard
|
|
||||||
|
|
||||||
import android.icu.text.SimpleDateFormat
|
|
||||||
import android.os.Bundle
|
|
||||||
import android.view.LayoutInflater
|
|
||||||
import android.view.View
|
|
||||||
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() {
|
|
||||||
override val hasNext: Boolean
|
|
||||||
get() = true
|
|
||||||
override val hasPrev: Boolean
|
|
||||||
get() = false
|
|
||||||
|
|
||||||
private lateinit var diagnosticTextView: TextView
|
|
||||||
|
|
||||||
private val saveDiagnostics =
|
|
||||||
setupLogSaving(
|
|
||||||
getLogFileName = {
|
|
||||||
getString(
|
|
||||||
R.string.download_wizard_diagnostics_file_template,
|
|
||||||
SimpleDateFormat.getDateTimeInstance().format(Date())
|
|
||||||
)
|
|
||||||
},
|
|
||||||
getLogText = { diagnosticTextView.text.toString() }
|
|
||||||
)
|
|
||||||
|
|
||||||
override fun createNextFragment(): DownloadWizardActivity.DownloadWizardStepFragment? = null
|
|
||||||
|
|
||||||
override fun createPrevFragment(): DownloadWizardActivity.DownloadWizardStepFragment? = null
|
|
||||||
|
|
||||||
override fun onCreateView(
|
|
||||||
inflater: LayoutInflater,
|
|
||||||
container: ViewGroup?,
|
|
||||||
savedInstanceState: Bundle?
|
|
||||||
): View? {
|
|
||||||
val view = inflater.inflate(R.layout.fragment_download_diagnostics, container, false)
|
|
||||||
view.requireViewById<View>(R.id.download_wizard_diagnostics_save).setOnClickListener {
|
|
||||||
saveDiagnostics()
|
|
||||||
}
|
|
||||||
diagnosticTextView = view.requireViewById(R.id.download_wizard_diagnostics_text)
|
|
||||||
return view
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun onStart() {
|
|
||||||
super.onStart()
|
|
||||||
val str = buildDiagnosticsText()
|
|
||||||
if (str == null) {
|
|
||||||
requireActivity().finish()
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
diagnosticTextView.text = str
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun buildDiagnosticsText(): String? = state.downloadError?.let { err ->
|
|
||||||
val ret = StringBuilder()
|
|
||||||
|
|
||||||
ret.appendLine(
|
|
||||||
getString(
|
|
||||||
R.string.download_wizard_diagnostics_error_code,
|
|
||||||
err.lpaErrorReason
|
|
||||||
)
|
|
||||||
)
|
|
||||||
ret.appendLine()
|
|
||||||
|
|
||||||
err.lastHttpResponse?.let { resp ->
|
|
||||||
if (resp.rcode != 200) {
|
|
||||||
// Only show the status if it's not 200
|
|
||||||
// Because we can have errors even if the rcode is 200 due to SM-DP+ servers being dumb
|
|
||||||
// and showing 200 might mislead users
|
|
||||||
ret.appendLine(
|
|
||||||
getString(
|
|
||||||
R.string.download_wizard_diagnostics_last_http_status,
|
|
||||||
resp.rcode
|
|
||||||
)
|
|
||||||
)
|
|
||||||
ret.appendLine()
|
|
||||||
}
|
|
||||||
|
|
||||||
ret.appendLine(getString(R.string.download_wizard_diagnostics_last_http_response))
|
|
||||||
ret.appendLine()
|
|
||||||
|
|
||||||
val str = resp.data.decodeToString(throwOnInvalidSequence = false)
|
|
||||||
|
|
||||||
ret.appendLine(
|
|
||||||
if (str.startsWith('{')) {
|
|
||||||
JSONObject(str).toString(2)
|
|
||||||
} else {
|
|
||||||
str
|
|
||||||
}
|
|
||||||
)
|
|
||||||
|
|
||||||
ret.appendLine()
|
|
||||||
}
|
|
||||||
|
|
||||||
err.lastHttpException?.let { e ->
|
|
||||||
ret.appendLine(getString(R.string.download_wizard_diagnostics_last_http_exception))
|
|
||||||
ret.appendLine()
|
|
||||||
ret.appendLine("${e.javaClass.name}: ${e.message}")
|
|
||||||
ret.appendLine(e.stackTrace.joinToString("\n"))
|
|
||||||
ret.appendLine()
|
|
||||||
}
|
|
||||||
|
|
||||||
err.lastApduResponse?.let { resp ->
|
|
||||||
val isSuccess =
|
|
||||||
resp.size >= 2 && resp[resp.size - 2] == 0x90.toByte() && resp[resp.size - 1] == 0x00.toByte()
|
|
||||||
|
|
||||||
if (isSuccess) {
|
|
||||||
ret.appendLine(getString(R.string.download_wizard_diagnostics_last_apdu_response_success))
|
|
||||||
} else {
|
|
||||||
// Only show the full APDU response when it's a failure
|
|
||||||
// Otherwise it's going to get very crammed
|
|
||||||
ret.appendLine(
|
|
||||||
getString(
|
|
||||||
R.string.download_wizard_diagnostics_last_apdu_response,
|
|
||||||
resp.encodeHex()
|
|
||||||
)
|
|
||||||
)
|
|
||||||
ret.appendLine()
|
|
||||||
|
|
||||||
ret.appendLine(getString(R.string.download_wizard_diagnostics_last_apdu_response_fail))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
err.lastApduException?.let { e ->
|
|
||||||
ret.appendLine(getString(R.string.download_wizard_diagnostics_last_apdu_exception))
|
|
||||||
ret.appendLine()
|
|
||||||
ret.appendLine("${e.javaClass.name}: ${e.message}")
|
|
||||||
ret.appendLine(e.stackTrace.joinToString("\n"))
|
|
||||||
ret.appendLine()
|
|
||||||
}
|
|
||||||
|
|
||||||
ret.toString()
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -1,173 +0,0 @@
|
||||||
package im.angry.openeuicc.ui.wizard
|
|
||||||
|
|
||||||
import android.app.AlertDialog
|
|
||||||
import android.content.ClipboardManager
|
|
||||||
import android.graphics.BitmapFactory
|
|
||||||
import android.os.Bundle
|
|
||||||
import android.view.LayoutInflater
|
|
||||||
import android.view.View
|
|
||||||
import android.view.ViewGroup
|
|
||||||
import android.widget.ImageView
|
|
||||||
import android.widget.TextView
|
|
||||||
import android.widget.Toast
|
|
||||||
import androidx.activity.result.contract.ActivityResultContracts
|
|
||||||
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 com.journeyapps.barcodescanner.ScanContract
|
|
||||||
import com.journeyapps.barcodescanner.ScanOptions
|
|
||||||
import im.angry.openeuicc.common.R
|
|
||||||
import im.angry.openeuicc.util.*
|
|
||||||
import kotlinx.coroutines.Dispatchers
|
|
||||||
import kotlinx.coroutines.launch
|
|
||||||
import kotlinx.coroutines.withContext
|
|
||||||
|
|
||||||
class DownloadWizardMethodSelectFragment : DownloadWizardActivity.DownloadWizardStepFragment() {
|
|
||||||
data class DownloadMethod(
|
|
||||||
val iconRes: Int,
|
|
||||||
val titleRes: Int,
|
|
||||||
val onClick: () -> Unit
|
|
||||||
)
|
|
||||||
|
|
||||||
// TODO: Maybe we should find a better barcode scanner (or an external one?)
|
|
||||||
private val barcodeScannerLauncher = registerForActivityResult(ScanContract()) { result ->
|
|
||||||
result.contents?.let { content ->
|
|
||||||
processLpaString(content)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private val gallerySelectorLauncher =
|
|
||||||
registerForActivityResult(ActivityResultContracts.GetContent()) { result ->
|
|
||||||
if (result == null) return@registerForActivityResult
|
|
||||||
|
|
||||||
lifecycleScope.launch {
|
|
||||||
val decoded = withContext(Dispatchers.IO) {
|
|
||||||
runCatching {
|
|
||||||
requireContext().contentResolver.openInputStream(result)?.use { input ->
|
|
||||||
BitmapFactory.decodeStream(input).use(::decodeQrFromBitmap)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
decoded.getOrNull()?.let { processLpaString(it) }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
val downloadMethods = arrayOf(
|
|
||||||
DownloadMethod(R.drawable.ic_scan_black, R.string.download_wizard_method_qr_code) {
|
|
||||||
barcodeScannerLauncher.launch(ScanOptions().apply {
|
|
||||||
setDesiredBarcodeFormats(ScanOptions.QR_CODE)
|
|
||||||
setOrientationLocked(false)
|
|
||||||
})
|
|
||||||
},
|
|
||||||
DownloadMethod(R.drawable.ic_gallery_black, R.string.download_wizard_method_gallery) {
|
|
||||||
gallerySelectorLauncher.launch("image/*")
|
|
||||||
},
|
|
||||||
DownloadMethod(R.drawable.ic_paste_go, R.string.download_wizard_method_clipboard) {
|
|
||||||
handleLoadFromClipboard()
|
|
||||||
},
|
|
||||||
DownloadMethod(R.drawable.ic_edit, R.string.download_wizard_method_manual) {
|
|
||||||
gotoNextFragment(DownloadWizardDetailsFragment())
|
|
||||||
}
|
|
||||||
)
|
|
||||||
|
|
||||||
override val hasNext: Boolean
|
|
||||||
get() = false
|
|
||||||
override val hasPrev: Boolean
|
|
||||||
get() = true
|
|
||||||
|
|
||||||
override fun createNextFragment(): DownloadWizardActivity.DownloadWizardStepFragment? =
|
|
||||||
null
|
|
||||||
|
|
||||||
override fun createPrevFragment(): DownloadWizardActivity.DownloadWizardStepFragment =
|
|
||||||
DownloadWizardSlotSelectFragment()
|
|
||||||
|
|
||||||
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) {
|
|
||||||
Toast.makeText(
|
|
||||||
requireContext(),
|
|
||||||
R.string.profile_download_no_lpa_string,
|
|
||||||
Toast.LENGTH_SHORT
|
|
||||||
).show()
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
processLpaString(text.toString())
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun processLpaString(input: String) {
|
|
||||||
try {
|
|
||||||
val parsed = LPAString.parse(input)
|
|
||||||
state.smdp = parsed.address
|
|
||||||
state.matchingId = parsed.matchingId
|
|
||||||
state.confirmationCodeRequired = parsed.confirmationCodeRequired
|
|
||||||
gotoNextFragment(DownloadWizardDetailsFragment())
|
|
||||||
} 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()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private inner class DownloadMethodViewHolder(private val root: View) : ViewHolder(root) {
|
|
||||||
private val icon = root.requireViewById<ImageView>(R.id.download_method_icon)
|
|
||||||
private val title = root.requireViewById<TextView>(R.id.download_method_title)
|
|
||||||
|
|
||||||
fun bind(item: DownloadMethod) {
|
|
||||||
icon.setImageResource(item.iconRes)
|
|
||||||
title.setText(item.titleRes)
|
|
||||||
root.setOnClickListener {
|
|
||||||
// If the user elected to use another download method, reset the confirmation code flag
|
|
||||||
// too
|
|
||||||
state.confirmationCodeRequired = false
|
|
||||||
item.onClick()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private inner class DownloadMethodAdapter : RecyclerView.Adapter<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)
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun getItemCount(): Int = downloadMethods.size
|
|
||||||
|
|
||||||
override fun onBindViewHolder(holder: DownloadMethodViewHolder, position: Int) {
|
|
||||||
holder.bind(downloadMethods[position])
|
|
||||||
}
|
|
||||||
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -1,261 +0,0 @@
|
||||||
package im.angry.openeuicc.ui.wizard
|
|
||||||
|
|
||||||
import android.os.Bundle
|
|
||||||
import android.view.LayoutInflater
|
|
||||||
import android.view.View
|
|
||||||
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
|
|
||||||
import androidx.recyclerview.widget.RecyclerView
|
|
||||||
import im.angry.openeuicc.common.R
|
|
||||||
import im.angry.openeuicc.service.EuiccChannelManagerService
|
|
||||||
import im.angry.openeuicc.util.*
|
|
||||||
import kotlinx.coroutines.flow.collect
|
|
||||||
import kotlinx.coroutines.flow.onEach
|
|
||||||
import kotlinx.coroutines.launch
|
|
||||||
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,
|
|
||||||
Done,
|
|
||||||
Error
|
|
||||||
}
|
|
||||||
|
|
||||||
private data class ProgressItem(
|
|
||||||
@StringRes val titleRes: Int,
|
|
||||||
var state: ProgressState = ProgressState.NotStarted,
|
|
||||||
var errorMessage: SimplifiedErrorMessages? = null,
|
|
||||||
)
|
|
||||||
|
|
||||||
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)
|
|
||||||
)
|
|
||||||
|
|
||||||
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
|
|
||||||
get() = isDone
|
|
||||||
override val hasPrev: Boolean
|
|
||||||
get() = false
|
|
||||||
|
|
||||||
override fun createNextFragment(): DownloadWizardActivity.DownloadWizardStepFragment? =
|
|
||||||
if (state.downloadError != null) {
|
|
||||||
DownloadWizardDiagnosticsFragment()
|
|
||||||
} else {
|
|
||||||
null
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun createPrevFragment(): DownloadWizardActivity.DownloadWizardStepFragment? = null
|
|
||||||
|
|
||||||
override fun onCreateView(
|
|
||||||
inflater: LayoutInflater,
|
|
||||||
container: ViewGroup?,
|
|
||||||
savedInstanceState: Bundle?
|
|
||||||
): View? {
|
|
||||||
val view = inflater.inflate(R.layout.fragment_download_progress, container, false)
|
|
||||||
val recyclerView = view.requireViewById<RecyclerView>(R.id.download_progress_list)
|
|
||||||
recyclerView.adapter = adapter
|
|
||||||
recyclerView.layoutManager =
|
|
||||||
LinearLayoutManager(view.context, LinearLayoutManager.VERTICAL, false)
|
|
||||||
recyclerView.addItemDecoration(
|
|
||||||
DividerItemDecoration(
|
|
||||||
requireContext(),
|
|
||||||
LinearLayoutManager.VERTICAL
|
|
||||||
)
|
|
||||||
)
|
|
||||||
return view
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun onStart() {
|
|
||||||
super.onStart()
|
|
||||||
|
|
||||||
lifecycleScope.launch {
|
|
||||||
showProgressBar(-1) // set indeterminate first
|
|
||||||
ensureEuiccChannelManager()
|
|
||||||
|
|
||||||
val subscriber = startDownloadOrSubscribe()
|
|
||||||
|
|
||||||
if (subscriber == null) {
|
|
||||||
requireActivity().finish()
|
|
||||||
return@launch
|
|
||||||
}
|
|
||||||
|
|
||||||
subscriber.onEach {
|
|
||||||
when (it) {
|
|
||||||
is EuiccChannelManagerService.ForegroundTaskState.Done -> {
|
|
||||||
hideProgressBar()
|
|
||||||
|
|
||||||
state.downloadError =
|
|
||||||
it.error as? LocalProfileAssistant.ProfileDownloadException
|
|
||||||
|
|
||||||
// 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!!)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
adapter.notifyItemChanged(index)
|
|
||||||
}
|
|
||||||
|
|
||||||
isDone = true
|
|
||||||
refreshButtons()
|
|
||||||
}
|
|
||||||
|
|
||||||
is EuiccChannelManagerService.ForegroundTaskState.InProgress ->
|
|
||||||
updateProgress(it.progress)
|
|
||||||
|
|
||||||
else -> {}
|
|
||||||
}
|
|
||||||
}.collect()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private suspend fun startDownloadOrSubscribe(): EuiccChannelManagerService.ForegroundTaskSubscriberFlow? =
|
|
||||||
if (state.downloadStarted) {
|
|
||||||
// This will also return null if task ID is -1 (uninitialized), too
|
|
||||||
euiccChannelManagerService.recoverForegroundTaskSubscriber(state.downloadTaskID)
|
|
||||||
} else {
|
|
||||||
euiccChannelManagerService.waitForForegroundTask()
|
|
||||||
|
|
||||||
val (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
|
|
||||||
|
|
||||||
val ret = euiccChannelManagerService.launchProfileDownloadTask(
|
|
||||||
slotId,
|
|
||||||
portId,
|
|
||||||
state.smdp,
|
|
||||||
state.matchingId,
|
|
||||||
state.confirmationCode,
|
|
||||||
state.imei
|
|
||||||
)
|
|
||||||
|
|
||||||
state.downloadTaskID = ret.taskId
|
|
||||||
|
|
||||||
ret
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun updateProgress(progress: Int) {
|
|
||||||
showProgressBar(progress)
|
|
||||||
|
|
||||||
val lpaState = ProfileDownloadCallback.lookupStateFromProgress(progress)
|
|
||||||
val stateIndex = LPA_PROGRESS_STATES.indexOf(lpaState)
|
|
||||||
|
|
||||||
if (stateIndex > 0) {
|
|
||||||
for (i in (0..<stateIndex)) {
|
|
||||||
if (progressItems[i].state != ProgressState.Done) {
|
|
||||||
progressItems[i].state = ProgressState.Done
|
|
||||||
adapter.notifyItemChanged(i)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (progressItems[stateIndex].state != ProgressState.InProgress) {
|
|
||||||
progressItems[stateIndex].state = ProgressState.InProgress
|
|
||||||
adapter.notifyItemChanged(stateIndex)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private inner class ProgressItemHolder(val root: View) : RecyclerView.ViewHolder(root) {
|
|
||||||
private val title = root.requireViewById<TextView>(R.id.download_progress_item_title)
|
|
||||||
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 -> {
|
|
||||||
progressBar.visibility = View.GONE
|
|
||||||
icon.visibility = View.GONE
|
|
||||||
}
|
|
||||||
|
|
||||||
ProgressState.InProgress -> {
|
|
||||||
progressBar.visibility = View.VISIBLE
|
|
||||||
icon.visibility = View.GONE
|
|
||||||
}
|
|
||||||
|
|
||||||
ProgressState.Done -> {
|
|
||||||
progressBar.visibility = View.GONE
|
|
||||||
icon.setImageResource(R.drawable.ic_checkmark_outline)
|
|
||||||
icon.visibility = View.VISIBLE
|
|
||||||
}
|
|
||||||
|
|
||||||
ProgressState.Error -> {
|
|
||||||
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)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private inner class ProgressItemAdapter : RecyclerView.Adapter<ProgressItemHolder>() {
|
|
||||||
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): ProgressItemHolder {
|
|
||||||
val root = LayoutInflater.from(parent.context)
|
|
||||||
.inflate(R.layout.download_progress_item, parent, false)
|
|
||||||
return ProgressItemHolder(root)
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun getItemCount(): Int = progressItems.size
|
|
||||||
|
|
||||||
override fun onBindViewHolder(holder: ProgressItemHolder, position: Int) {
|
|
||||||
holder.bind(progressItems[position])
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -1,218 +0,0 @@
|
||||||
package im.angry.openeuicc.ui.wizard
|
|
||||||
|
|
||||||
import android.annotation.SuppressLint
|
|
||||||
import android.os.Bundle
|
|
||||||
import android.view.LayoutInflater
|
|
||||||
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.EuiccChannelManager
|
|
||||||
import im.angry.openeuicc.util.*
|
|
||||||
import kotlinx.coroutines.flow.map
|
|
||||||
import kotlinx.coroutines.flow.toList
|
|
||||||
import kotlinx.coroutines.launch
|
|
||||||
|
|
||||||
class DownloadWizardSlotSelectFragment : DownloadWizardActivity.DownloadWizardStepFragment() {
|
|
||||||
companion object {
|
|
||||||
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 portId: Int,
|
|
||||||
val eID: String,
|
|
||||||
val freeSpace: Int,
|
|
||||||
val imei: String,
|
|
||||||
val enabledProfileName: String?,
|
|
||||||
val intrinsicChannelName: String?,
|
|
||||||
)
|
|
||||||
|
|
||||||
private var loaded = false
|
|
||||||
|
|
||||||
private val adapter = SlotInfoAdapter()
|
|
||||||
|
|
||||||
override val hasNext: Boolean
|
|
||||||
get() = loaded && adapter.slots.isNotEmpty()
|
|
||||||
override val hasPrev: Boolean
|
|
||||||
get() = true
|
|
||||||
|
|
||||||
override fun createNextFragment(): DownloadWizardActivity.DownloadWizardStepFragment =
|
|
||||||
if (state.skipMethodSelect) {
|
|
||||||
DownloadWizardDetailsFragment()
|
|
||||||
} else {
|
|
||||||
DownloadWizardMethodSelectFragment()
|
|
||||||
}
|
|
||||||
|
|
||||||
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?,
|
|
||||||
savedInstanceState: Bundle?
|
|
||||||
): View? {
|
|
||||||
val view = inflater.inflate(R.layout.fragment_download_slot_select, container, false)
|
|
||||||
val recyclerView = view.requireViewById<RecyclerView>(R.id.download_slot_list)
|
|
||||||
recyclerView.adapter = adapter
|
|
||||||
recyclerView.layoutManager =
|
|
||||||
LinearLayoutManager(view.context, LinearLayoutManager.VERTICAL, false)
|
|
||||||
recyclerView.addItemDecoration(DividerItemDecoration(requireContext(), LinearLayoutManager.VERTICAL))
|
|
||||||
return view
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun onStart() {
|
|
||||||
super.onStart()
|
|
||||||
if (!loaded) {
|
|
||||||
lifecycleScope.launch { init() }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@SuppressLint("NotifyDataSetChanged", "MissingPermission")
|
|
||||||
private suspend fun init() {
|
|
||||||
ensureEuiccChannelManager()
|
|
||||||
showProgressBar(-1)
|
|
||||||
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.logicalSlotId }
|
|
||||||
adapter.slots = slots
|
|
||||||
|
|
||||||
// Ensure we always have a selected slot by default
|
|
||||||
val selectedIdx = slots.indexOfFirst { it.logicalSlotId == state.selectedLogicalSlot }
|
|
||||||
adapter.currentSelectedIdx = if (selectedIdx > 0) {
|
|
||||||
selectedIdx
|
|
||||||
} else {
|
|
||||||
if (slots.isNotEmpty()) {
|
|
||||||
state.selectedLogicalSlot = slots[0].logicalSlotId
|
|
||||||
}
|
|
||||||
0
|
|
||||||
}
|
|
||||||
|
|
||||||
if (slots.isNotEmpty()) {
|
|
||||||
state.imei = slots[adapter.currentSelectedIdx].imei
|
|
||||||
}
|
|
||||||
|
|
||||||
adapter.notifyDataSetChanged()
|
|
||||||
hideProgressBar()
|
|
||||||
loaded = true
|
|
||||||
refreshButtons()
|
|
||||||
}
|
|
||||||
|
|
||||||
private inner class SlotItemHolder(val root: View) : ViewHolder(root) {
|
|
||||||
private val title = root.requireViewById<TextView>(R.id.slot_item_title)
|
|
||||||
private val type = root.requireViewById<TextView>(R.id.slot_item_type)
|
|
||||||
private val eID = root.requireViewById<TextView>(R.id.slot_item_eid)
|
|
||||||
private val activeProfile = root.requireViewById<TextView>(R.id.slot_item_active_profile)
|
|
||||||
private val freeSpace = root.requireViewById<TextView>(R.id.slot_item_free_space)
|
|
||||||
private val checkBox = root.requireViewById<CheckBox>(R.id.slot_checkbox)
|
|
||||||
|
|
||||||
private var curIdx = -1
|
|
||||||
|
|
||||||
init {
|
|
||||||
root.setOnClickListener(this::onSelect)
|
|
||||||
checkBox.setOnClickListener(this::onSelect)
|
|
||||||
}
|
|
||||||
|
|
||||||
@Suppress("UNUSED_PARAMETER")
|
|
||||||
fun onSelect(view: View) {
|
|
||||||
if (curIdx < 0) return
|
|
||||||
checkBox.isChecked = true
|
|
||||||
if (adapter.currentSelectedIdx == curIdx) return
|
|
||||||
val lastIdx = adapter.currentSelectedIdx
|
|
||||||
adapter.currentSelectedIdx = curIdx
|
|
||||||
adapter.notifyItemChanged(lastIdx)
|
|
||||||
adapter.notifyItemChanged(curIdx)
|
|
||||||
// Selected index isn't logical slot ID directly, needs a conversion
|
|
||||||
state.selectedLogicalSlot = adapter.slots[adapter.currentSelectedIdx].logicalSlotId
|
|
||||||
state.imei = adapter.slots[adapter.currentSelectedIdx].imei
|
|
||||||
}
|
|
||||||
|
|
||||||
fun bind(item: SlotInfo, idx: Int) {
|
|
||||||
curIdx = idx
|
|
||||||
|
|
||||||
type.text = if (item.isRemovable) {
|
|
||||||
root.context.getString(R.string.download_wizard_slot_type_removable)
|
|
||||||
} else if (!item.hasMultiplePorts) {
|
|
||||||
root.context.getString(R.string.download_wizard_slot_type_internal)
|
|
||||||
} else {
|
|
||||||
root.context.getString(
|
|
||||||
R.string.download_wizard_slot_type_internal_port,
|
|
||||||
item.portId
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
title.text = if (item.logicalSlotId == EuiccChannelManager.USB_CHANNEL_ID) {
|
|
||||||
item.intrinsicChannelName ?: root.context.getString(R.string.channel_type_usb)
|
|
||||||
} else {
|
|
||||||
appContainer.customizableTextProvider.formatInternalChannelName(item.logicalSlotId)
|
|
||||||
}
|
|
||||||
eID.text = item.eID
|
|
||||||
activeProfile.text = item.enabledProfileName ?: root.context.getString(R.string.profile_no_enabled_profile)
|
|
||||||
freeSpace.text = formatFreeSpace(item.freeSpace)
|
|
||||||
checkBox.isChecked = adapter.currentSelectedIdx == idx
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private inner class SlotInfoAdapter : RecyclerView.Adapter<SlotItemHolder>() {
|
|
||||||
var slots: List<SlotInfo> = listOf()
|
|
||||||
var currentSelectedIdx = -1
|
|
||||||
|
|
||||||
val selected: SlotInfo
|
|
||||||
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)
|
|
||||||
return SlotItemHolder(root)
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun getItemCount(): Int = slots.size
|
|
||||||
|
|
||||||
override fun onBindViewHolder(holder: SlotItemHolder, position: Int) {
|
|
||||||
holder.bind(slots[position], position)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -1,154 +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
|
|
||||||
),
|
|
||||||
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 {
|
|
||||||
// Stage: AuthenticateClient
|
|
||||||
put("8.1" to "4.8", InsufficientMemory)
|
|
||||||
put("8.1.1" to "2.1", EIDNotSupported)
|
|
||||||
put("8.1.1" to "3.8", EIDMismatch)
|
|
||||||
put("8.2" to "1.2", UnreleasedProfile)
|
|
||||||
put("8.2.6" to "3.8", MatchingIDRefused)
|
|
||||||
put("8.8.5" to "6.4", ProfileRetriesExceeded)
|
|
||||||
|
|
||||||
// Stage: GetBoundProfilePackage
|
|
||||||
put("8.2.7" to "2.2", ConfirmationCodeMissing)
|
|
||||||
put("8.2.7" to "3.8", ConfirmationCodeRefused)
|
|
||||||
put("8.2.7" to "6.4", ConfirmationCodeRetriesExceeded)
|
|
||||||
|
|
||||||
// Stage: AuthenticateClient, GetBoundProfilePackage
|
|
||||||
put("8.8.5" to "4.10", ProfileExpired)
|
|
||||||
}
|
|
||||||
|
|
||||||
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
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -4,69 +4,49 @@ import android.os.Bundle
|
||||||
import androidx.fragment.app.Fragment
|
import androidx.fragment.app.Fragment
|
||||||
import im.angry.openeuicc.core.EuiccChannel
|
import im.angry.openeuicc.core.EuiccChannel
|
||||||
import im.angry.openeuicc.core.EuiccChannelManager
|
import im.angry.openeuicc.core.EuiccChannelManager
|
||||||
import im.angry.openeuicc.service.EuiccChannelManagerService
|
|
||||||
import im.angry.openeuicc.ui.BaseEuiccAccessActivity
|
import im.angry.openeuicc.ui.BaseEuiccAccessActivity
|
||||||
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.withContext
|
||||||
|
|
||||||
private const val FIELD_SLOT_ID = "slotId"
|
interface EuiccChannelFragmentMarker: OpenEuiccContextMarker
|
||||||
private const val FIELD_PORT_ID = "portId"
|
|
||||||
|
|
||||||
interface EuiccChannelFragmentMarker : OpenEuiccContextMarker
|
|
||||||
|
|
||||||
private typealias BundleSetter = Bundle.() -> Unit
|
|
||||||
|
|
||||||
// We must use extension functions because there is no way to add bounds to the type of "self"
|
// 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
|
// in the definition of an interface, so the only way is to limit where the extension functions
|
||||||
// can be applied.
|
// can be applied.
|
||||||
fun <T> newInstanceEuicc(clazz: Class<T>, slotId: Int, portId: Int, addArguments: BundleSetter = {}): T
|
fun <T> newInstanceEuicc(clazz: Class<T>, slotId: Int, portId: Int, addArguments: Bundle.() -> Unit = {}): T where T: Fragment, T: EuiccChannelFragmentMarker {
|
||||||
where T : Fragment, T : EuiccChannelFragmentMarker =
|
val instance = clazz.newInstance()
|
||||||
clazz.getDeclaredConstructor().newInstance().apply {
|
instance.arguments = Bundle().apply {
|
||||||
arguments = Bundle()
|
putInt("slotId", slotId)
|
||||||
arguments!!.putInt(FIELD_SLOT_ID, slotId)
|
putInt("portId", portId)
|
||||||
arguments!!.putInt(FIELD_PORT_ID, portId)
|
addArguments()
|
||||||
arguments!!.addArguments()
|
|
||||||
}
|
}
|
||||||
|
return instance
|
||||||
|
}
|
||||||
|
|
||||||
// Convenient methods to avoid using `channel` for these
|
// Convenient methods to avoid using `channel` for these
|
||||||
// `channel` requires that the channel actually exists in EuiccChannelManager, which is
|
// `channel` requires that the channel actually exists in EuiccChannelManager, which is
|
||||||
// not always the case during operations such as switching
|
// not always the case during operations such as switching
|
||||||
val <T> T.slotId: Int
|
val <T> T.slotId: Int where T: Fragment, T: EuiccChannelFragmentMarker
|
||||||
where T : Fragment, T : EuiccChannelFragmentMarker
|
get() = requireArguments().getInt("slotId")
|
||||||
get() = requireArguments().getInt(FIELD_SLOT_ID)
|
val <T> T.portId: Int where T: Fragment, T: EuiccChannelFragmentMarker
|
||||||
val <T> T.portId: Int
|
get() = requireArguments().getInt("portId")
|
||||||
where T : Fragment, T : EuiccChannelFragmentMarker
|
val <T> T.isUsb: Boolean where T: Fragment, T: EuiccChannelFragmentMarker
|
||||||
get() = requireArguments().getInt(FIELD_PORT_ID)
|
get() = requireArguments().getInt("slotId") == EuiccChannelManager.USB_CHANNEL_ID
|
||||||
val <T> T.isUsb: Boolean
|
|
||||||
where T : Fragment, T : EuiccChannelFragmentMarker
|
|
||||||
get() = slotId == EuiccChannelManager.USB_CHANNEL_ID
|
|
||||||
|
|
||||||
private fun <T> T.requireEuiccActivity(): BaseEuiccAccessActivity
|
val <T> T.euiccChannelManager: EuiccChannelManager where T: Fragment, T: EuiccChannelFragmentMarker
|
||||||
where T : Fragment, T : OpenEuiccContextMarker =
|
get() = (requireActivity() as BaseEuiccAccessActivity).euiccChannelManager
|
||||||
requireActivity() as BaseEuiccAccessActivity
|
val <T> T.channel: EuiccChannel where T: Fragment, T: EuiccChannelFragmentMarker
|
||||||
|
get() =
|
||||||
val <T> T.euiccChannelManager: EuiccChannelManager
|
euiccChannelManager.findEuiccChannelByPortBlocking(slotId, portId)!!
|
||||||
where T : Fragment, T : OpenEuiccContextMarker
|
|
||||||
get() = requireEuiccActivity().euiccChannelManager
|
|
||||||
|
|
||||||
val <T> T.euiccChannelManagerService: EuiccChannelManagerService
|
|
||||||
where T : Fragment, T : OpenEuiccContextMarker
|
|
||||||
get() = requireEuiccActivity().euiccChannelManagerService
|
|
||||||
|
|
||||||
suspend fun <T, R> T.withEuiccChannel(fn: suspend (EuiccChannel) -> R): R
|
|
||||||
where T : Fragment, T : EuiccChannelFragmentMarker {
|
|
||||||
ensureEuiccChannelManager()
|
|
||||||
return euiccChannelManager.withEuiccChannel(slotId, portId, fn)
|
|
||||||
}
|
|
||||||
|
|
||||||
suspend fun <T> T.ensureEuiccChannelManager() where T : Fragment, T : OpenEuiccContextMarker =
|
|
||||||
requireEuiccActivity().euiccChannelManagerLoaded.await()
|
|
||||||
|
|
||||||
fun <T> T.notifyEuiccProfilesChanged() where T : Fragment {
|
|
||||||
if (this !is EuiccProfilesChangedListener) return
|
|
||||||
// Trigger a refresh in the parent fragment -- it should wait until
|
|
||||||
// any foreground task is completed before actually doing a refresh
|
|
||||||
this.onEuiccProfilesChanged()
|
|
||||||
}
|
|
||||||
|
|
||||||
interface EuiccProfilesChangedListener {
|
interface EuiccProfilesChangedListener {
|
||||||
fun onEuiccProfilesChanged()
|
fun onEuiccProfilesChanged()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
suspend fun <T> T.beginTrackedOperation(op: suspend () -> Boolean) where T: Fragment, T: EuiccChannelFragmentMarker {
|
||||||
|
withContext(Dispatchers.IO) {
|
||||||
|
euiccChannelManager.beginTrackedOperationBlocking(slotId, portId) {
|
||||||
|
op()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
|
@ -1,34 +0,0 @@
|
||||||
package im.angry.openeuicc.util
|
|
||||||
|
|
||||||
data class LPAString(
|
|
||||||
val address: String,
|
|
||||||
val matchingId: String?,
|
|
||||||
val oid: String?,
|
|
||||||
val confirmationCodeRequired: Boolean,
|
|
||||||
) {
|
|
||||||
companion object {
|
|
||||||
fun parse(input: String): 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" }
|
|
||||||
return LPAString(
|
|
||||||
requireNotNull(components.getOrNull(1)) { "SM-DP+ is required" },
|
|
||||||
components.getOrNull(2),
|
|
||||||
components.getOrNull(3),
|
|
||||||
components.getOrNull(4) == "1"
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun toString(): String {
|
|
||||||
val parts = arrayOf(
|
|
||||||
"1",
|
|
||||||
address,
|
|
||||||
matchingId ?: "",
|
|
||||||
oid ?: "",
|
|
||||||
if (confirmationCodeRequired) "1" else ""
|
|
||||||
)
|
|
||||||
return parts.joinToString("$").trimEnd('$')
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -3,6 +3,9 @@ package im.angry.openeuicc.util
|
||||||
import android.util.Log
|
import android.util.Log
|
||||||
import im.angry.openeuicc.core.EuiccChannel
|
import im.angry.openeuicc.core.EuiccChannel
|
||||||
import im.angry.openeuicc.core.EuiccChannelManager
|
import im.angry.openeuicc.core.EuiccChannelManager
|
||||||
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.runBlocking
|
||||||
|
import kotlinx.coroutines.withContext
|
||||||
import net.typeblog.lpac_jni.LocalProfileAssistant
|
import net.typeblog.lpac_jni.LocalProfileAssistant
|
||||||
import net.typeblog.lpac_jni.LocalProfileInfo
|
import net.typeblog.lpac_jni.LocalProfileInfo
|
||||||
|
|
||||||
|
@ -16,10 +19,9 @@ val LocalProfileInfo.isEnabled: Boolean
|
||||||
get() = state == LocalProfileInfo.State.Enabled
|
get() = state == LocalProfileInfo.State.Enabled
|
||||||
|
|
||||||
val List<LocalProfileInfo>.operational: List<LocalProfileInfo>
|
val List<LocalProfileInfo>.operational: List<LocalProfileInfo>
|
||||||
get() = filter { it.profileClass == LocalProfileInfo.Clazz.Operational }
|
get() = filter {
|
||||||
|
it.profileClass == LocalProfileInfo.Clazz.Operational
|
||||||
val List<LocalProfileInfo>.enabled: LocalProfileInfo?
|
}
|
||||||
get() = find { it.isEnabled }
|
|
||||||
|
|
||||||
val List<EuiccChannel>.hasMultipleChips: Boolean
|
val List<EuiccChannel>.hasMultipleChips: Boolean
|
||||||
get() = distinctBy { it.slotId }.size > 1
|
get() = distinctBy { it.slotId }.size > 1
|
||||||
|
@ -40,27 +42,22 @@ fun LocalProfileAssistant.switchProfile(
|
||||||
* See EuiccManager.waitForReconnect()
|
* See EuiccManager.waitForReconnect()
|
||||||
*/
|
*/
|
||||||
fun LocalProfileAssistant.disableActiveProfile(refresh: Boolean): Boolean =
|
fun LocalProfileAssistant.disableActiveProfile(refresh: Boolean): Boolean =
|
||||||
profiles.enabled?.let {
|
profiles.find { it.isEnabled }?.let {
|
||||||
Log.i(TAG, "Disabling active profile ${it.iccid}")
|
Log.i(TAG, "Disabling active profile ${it.iccid}")
|
||||||
disableProfile(it.iccid, refresh)
|
disableProfile(it.iccid, refresh)
|
||||||
} ?: true
|
} ?: true
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Disable the current active profile if any. If refresh is true, also cause a refresh command.
|
* Disable the active profile, return a lambda that reverts this action when called.
|
||||||
|
* If refreshOnDisable is true, also cause a eUICC refresh command. Note that refreshing
|
||||||
|
* will disconnect the eUICC and might need some time before being operational again.
|
||||||
* See EuiccManager.waitForReconnect()
|
* See EuiccManager.waitForReconnect()
|
||||||
*
|
|
||||||
* Return the iccid of the profile being disabled, or null if no active profile found or failed to
|
|
||||||
* disable.
|
|
||||||
*/
|
*/
|
||||||
fun LocalProfileAssistant.disableActiveProfileKeepIccId(refresh: Boolean): String? =
|
fun LocalProfileAssistant.disableActiveProfileWithUndo(refreshOnDisable: Boolean): () -> Unit =
|
||||||
profiles.enabled?.let {
|
profiles.find { it.isEnabled }?.let {
|
||||||
Log.i(TAG, "Disabling active profile ${it.iccid}")
|
disableProfile(it.iccid, refreshOnDisable)
|
||||||
if (disableProfile(it.iccid, refresh)) {
|
return { enableProfile(it.iccid) }
|
||||||
it.iccid
|
} ?: { }
|
||||||
} else {
|
|
||||||
null
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Begin a "tracked" operation where notifications may be generated by the eSIM
|
* Begin a "tracked" operation where notifications may be generated by the eSIM
|
||||||
|
@ -76,26 +73,29 @@ fun LocalProfileAssistant.disableActiveProfileKeepIccId(refresh: Boolean): Strin
|
||||||
* should be the concern of op() itself, and this function assumes that when
|
* should be the concern of op() itself, and this function assumes that when
|
||||||
* op() returns, the slotId and portId will correspond to a valid channel again.
|
* op() returns, the slotId and portId will correspond to a valid channel again.
|
||||||
*/
|
*/
|
||||||
suspend inline fun EuiccChannelManager.beginTrackedOperation(
|
inline fun EuiccChannelManager.beginTrackedOperationBlocking(
|
||||||
slotId: Int,
|
slotId: Int,
|
||||||
portId: Int,
|
portId: Int,
|
||||||
op: () -> Boolean
|
op: () -> Boolean
|
||||||
) {
|
) {
|
||||||
val latestSeq = withEuiccChannel(slotId, portId) { channel ->
|
val latestSeq =
|
||||||
channel.lpa.notifications.firstOrNull()?.seqNumber
|
findEuiccChannelByPortBlocking(slotId, portId)!!.lpa.notifications.firstOrNull()?.seqNumber
|
||||||
?: 0
|
?: 0
|
||||||
}
|
|
||||||
Log.d(TAG, "Latest notification is $latestSeq before operation")
|
Log.d(TAG, "Latest notification is $latestSeq before operation")
|
||||||
if (op()) {
|
if (op()) {
|
||||||
Log.d(TAG, "Operation has requested notification handling")
|
Log.d(TAG, "Operation has requested notification handling")
|
||||||
try {
|
try {
|
||||||
// Note that the exact instance of "channel" might have changed here if reconnected;
|
// 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()
|
// so we MUST use the automatic getter for "channel"
|
||||||
withEuiccChannel(slotId, portId) { channel ->
|
findEuiccChannelByPortBlocking(
|
||||||
channel.lpa.notifications.filter { it.seqNumber > latestSeq }.forEach {
|
slotId,
|
||||||
|
portId
|
||||||
|
)?.lpa?.notifications?.filter { it.seqNumber > latestSeq }?.forEach {
|
||||||
Log.d(TAG, "Handling notification $it")
|
Log.d(TAG, "Handling notification $it")
|
||||||
channel.lpa.handleNotification(it.seqNumber)
|
findEuiccChannelByPortBlocking(
|
||||||
}
|
slotId,
|
||||||
|
portId
|
||||||
|
)?.lpa?.handleNotification(it.seqNumber)
|
||||||
}
|
}
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
// Ignore any error during notification handling
|
// Ignore any error during notification handling
|
||||||
|
|
|
@ -5,14 +5,11 @@ import androidx.datastore.core.DataStore
|
||||||
import androidx.datastore.preferences.core.Preferences
|
import androidx.datastore.preferences.core.Preferences
|
||||||
import androidx.datastore.preferences.core.booleanPreferencesKey
|
import androidx.datastore.preferences.core.booleanPreferencesKey
|
||||||
import androidx.datastore.preferences.core.edit
|
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.datastore.preferences.preferencesDataStore
|
||||||
import androidx.fragment.app.Fragment
|
import androidx.fragment.app.Fragment
|
||||||
import im.angry.openeuicc.OpenEuiccApplication
|
import im.angry.openeuicc.OpenEuiccApplication
|
||||||
import kotlinx.coroutines.flow.Flow
|
import kotlinx.coroutines.flow.Flow
|
||||||
import kotlinx.coroutines.flow.map
|
import kotlinx.coroutines.flow.map
|
||||||
import java.util.Base64
|
|
||||||
|
|
||||||
private val Context.dataStore: DataStore<Preferences> by preferencesDataStore(name = "prefs")
|
private val Context.dataStore: DataStore<Preferences> by preferencesDataStore(name = "prefs")
|
||||||
|
|
||||||
|
@ -22,110 +19,34 @@ val Context.preferenceRepository: PreferenceRepository
|
||||||
val Fragment.preferenceRepository: PreferenceRepository
|
val Fragment.preferenceRepository: PreferenceRepository
|
||||||
get() = requireContext().preferenceRepository
|
get() = requireContext().preferenceRepository
|
||||||
|
|
||||||
internal object PreferenceKeys {
|
object PreferenceKeys {
|
||||||
// ---- Profile Notifications ----
|
|
||||||
val NOTIFICATION_DOWNLOAD = booleanPreferencesKey("notification_download")
|
val NOTIFICATION_DOWNLOAD = booleanPreferencesKey("notification_download")
|
||||||
val NOTIFICATION_DELETE = booleanPreferencesKey("notification_delete")
|
val NOTIFICATION_DELETE = booleanPreferencesKey("notification_delete")
|
||||||
val NOTIFICATION_SWITCH = booleanPreferencesKey("notification_switch")
|
val NOTIFICATION_SWITCH = booleanPreferencesKey("notification_switch")
|
||||||
|
|
||||||
// ---- Advanced ----
|
|
||||||
val DISABLE_SAFEGUARD_REMOVABLE_ESIM = booleanPreferencesKey("disable_safeguard_removable_esim")
|
val DISABLE_SAFEGUARD_REMOVABLE_ESIM = booleanPreferencesKey("disable_safeguard_removable_esim")
|
||||||
val VERBOSE_LOGGING = booleanPreferencesKey("verbose_logging")
|
|
||||||
|
|
||||||
// ---- 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 EUICC_MEMORY_RESET = booleanPreferencesKey("euicc_memory_reset")
|
|
||||||
val ISDR_AID_LIST = stringPreferencesKey("isdr_aid_list")
|
|
||||||
val ES10X_MSS = intPreferencesKey("es10x_mss")
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const val EUICC_DEFAULT_ISDR_AID = "A0000005591010FFFFFFFF8900000100"
|
class PreferenceRepository(context: Context) {
|
||||||
|
private val dataStore = context.dataStore
|
||||||
|
|
||||||
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
|
|
||||||
$EUICC_DEFAULT_ISDR_AID
|
|
||||||
|
|
||||||
# ESTKme AUX (deprecated, use SE0 instead)
|
|
||||||
A06573746B6D65FFFFFFFF4953442D52
|
|
||||||
|
|
||||||
# ESTKme SE0
|
|
||||||
A06573746B6D65FFFF4953442D522030
|
|
||||||
|
|
||||||
# eSIM.me
|
|
||||||
A0000005591010000000008900000300
|
|
||||||
|
|
||||||
# 5ber.eSIM
|
|
||||||
A0000005591010FFFFFFFF8900050500
|
|
||||||
|
|
||||||
# Xesim
|
|
||||||
A0000005591010FFFFFFFF8900000177
|
|
||||||
""".trimIndent()
|
|
||||||
}
|
|
||||||
|
|
||||||
open class PreferenceRepository(private val context: Context) {
|
|
||||||
// Expose flows so that we can also handle default values
|
// Expose flows so that we can also handle default values
|
||||||
// ---- Profile Notifications ----
|
// ---- Profile Notifications ----
|
||||||
val notificationDownloadFlow = bindFlow(PreferenceKeys.NOTIFICATION_DOWNLOAD, true)
|
val notificationDownloadFlow: Flow<Boolean> =
|
||||||
val notificationDeleteFlow = bindFlow(PreferenceKeys.NOTIFICATION_DELETE, true)
|
dataStore.data.map { it[PreferenceKeys.NOTIFICATION_DOWNLOAD] ?: true }
|
||||||
val notificationSwitchFlow = bindFlow(PreferenceKeys.NOTIFICATION_SWITCH, false)
|
|
||||||
|
val notificationDeleteFlow: Flow<Boolean> =
|
||||||
|
dataStore.data.map { it[PreferenceKeys.NOTIFICATION_DELETE] ?: true }
|
||||||
|
|
||||||
|
val notificationSwitchFlow: Flow<Boolean> =
|
||||||
|
dataStore.data.map { it[PreferenceKeys.NOTIFICATION_SWITCH] ?: false }
|
||||||
|
|
||||||
// ---- Advanced ----
|
// ---- Advanced ----
|
||||||
val disableSafeguardFlow = bindFlow(PreferenceKeys.DISABLE_SAFEGUARD_REMOVABLE_ESIM, false)
|
val disableSafeguardFlow: Flow<Boolean> =
|
||||||
val verboseLoggingFlow = bindFlow(PreferenceKeys.VERBOSE_LOGGING, false)
|
dataStore.data.map { it[PreferenceKeys.DISABLE_SAFEGUARD_REMOVABLE_ESIM] ?: false }
|
||||||
|
|
||||||
// ---- Developer Options ----
|
suspend fun <T> updatePreference(key: Preferences.Key<T>, value: T) {
|
||||||
val refreshAfterSwitchFlow = bindFlow(PreferenceKeys.REFRESH_AFTER_SWITCH, true)
|
dataStore.edit {
|
||||||
val developerOptionsEnabledFlow = bindFlow(PreferenceKeys.DEVELOPER_OPTIONS_ENABLED, false)
|
it[key] = value
|
||||||
val unfilteredProfileListFlow = bindFlow(PreferenceKeys.UNFILTERED_PROFILE_LIST, false)
|
|
||||||
val ignoreTLSCertificateFlow = bindFlow(PreferenceKeys.IGNORE_TLS_CERTIFICATE, false)
|
|
||||||
val euiccMemoryResetFlow = bindFlow(PreferenceKeys.EUICC_MEMORY_RESET, 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)
|
|
||||||
|
|
||||||
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)
|
|
||||||
}
|
|
||||||
|
|
||||||
class PreferenceFlowWrapper<T> private constructor(
|
|
||||||
private val context: Context,
|
|
||||||
private val key: Preferences.Key<T>,
|
|
||||||
inner: Flow<T>,
|
|
||||||
private val encoder: (T) -> T,
|
|
||||||
) : Flow<T> by inner {
|
|
||||||
internal constructor(
|
|
||||||
context: Context,
|
|
||||||
key: Preferences.Key<T>,
|
|
||||||
defaultValue: T,
|
|
||||||
encoder: (T) -> T,
|
|
||||||
decoder: (T) -> T
|
|
||||||
) : this(
|
|
||||||
context,
|
|
||||||
key,
|
|
||||||
context.dataStore.data.map { it[key]?.let(decoder) ?: defaultValue },
|
|
||||||
encoder
|
|
||||||
)
|
|
||||||
|
|
||||||
suspend fun updatePreference(value: T) {
|
|
||||||
context.dataStore.edit { it[key] = encoder(value) }
|
|
||||||
}
|
}
|
||||||
|
|
||||||
suspend fun removePreference() {
|
|
||||||
context.dataStore.edit { it.remove(key) }
|
|
||||||
}
|
}
|
||||||
}
|
}
|
|
@ -1,7 +1,7 @@
|
||||||
package im.angry.openeuicc.util
|
package im.angry.openeuicc.util
|
||||||
|
|
||||||
fun String.decodeHex(): ByteArray {
|
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 decodedLength = length / 2
|
||||||
val out = ByteArray(decodedLength)
|
val out = ByteArray(decodedLength)
|
||||||
|
@ -28,16 +28,3 @@ fun formatFreeSpace(size: Int): String =
|
||||||
} else {
|
} else {
|
||||||
"$size B"
|
"$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
|
|
||||||
*/
|
|
||||||
fun parseIsdrAidList(s: String): List<ByteArray> =
|
|
||||||
s.split('\n')
|
|
||||||
.map(String::trim)
|
|
||||||
.filter { !it.startsWith('#') }
|
|
||||||
.map(String::trim)
|
|
||||||
.filter(String::isNotEmpty)
|
|
||||||
.mapNotNull { runCatching(it::decodeHex).getOrNull() }
|
|
||||||
.ifEmpty { listOf(EUICC_DEFAULT_ISDR_AID.decodeHex()) }
|
|
||||||
|
|
|
@ -45,8 +45,6 @@ fun SEService.getUiccReaderCompat(slotNumber: Int): Reader {
|
||||||
interface UiccCardInfoCompat {
|
interface UiccCardInfoCompat {
|
||||||
val physicalSlotIndex: Int
|
val physicalSlotIndex: Int
|
||||||
val ports: Collection<UiccPortInfoCompat>
|
val ports: Collection<UiccPortInfoCompat>
|
||||||
val isRemovable: Boolean
|
|
||||||
get() = true // This defaults to removable unless overridden
|
|
||||||
}
|
}
|
||||||
|
|
||||||
interface UiccPortInfoCompat {
|
interface UiccPortInfoCompat {
|
||||||
|
|
|
@ -1,24 +1,9 @@
|
||||||
package im.angry.openeuicc.util
|
package im.angry.openeuicc.util
|
||||||
|
|
||||||
import android.content.ClipData
|
|
||||||
import android.content.Context
|
|
||||||
import android.content.Intent
|
|
||||||
import android.content.res.Resources
|
import android.content.res.Resources
|
||||||
import android.graphics.Rect
|
import android.graphics.Rect
|
||||||
import android.view.View
|
|
||||||
import android.view.ViewGroup
|
import android.view.ViewGroup
|
||||||
import androidx.activity.result.ActivityResultCaller
|
|
||||||
import androidx.activity.result.contract.ActivityResultContracts
|
|
||||||
import androidx.appcompat.app.AlertDialog
|
|
||||||
import androidx.appcompat.app.AppCompatActivity
|
|
||||||
import androidx.core.view.ViewCompat
|
|
||||||
import androidx.core.view.WindowInsetsCompat
|
|
||||||
import androidx.core.view.updateLayoutParams
|
|
||||||
import androidx.core.view.updatePadding
|
|
||||||
import androidx.fragment.app.DialogFragment
|
import androidx.fragment.app.DialogFragment
|
||||||
import androidx.fragment.app.Fragment
|
|
||||||
import im.angry.openeuicc.common.R
|
|
||||||
import java.io.FileOutputStream
|
|
||||||
|
|
||||||
// Source: <https://stackoverflow.com/questions/12478520/how-to-set-dialogfragments-width-and-height>
|
// Source: <https://stackoverflow.com/questions/12478520/how-to-set-dialogfragments-width-and-height>
|
||||||
/**
|
/**
|
||||||
|
@ -41,84 +26,3 @@ fun DialogFragment.setWidthPercent(percentage: Int) {
|
||||||
fun DialogFragment.setFullScreen() {
|
fun DialogFragment.setFullScreen() {
|
||||||
dialog?.window?.setLayout(ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.WRAP_CONTENT)
|
dialog?.window?.setLayout(ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.WRAP_CONTENT)
|
||||||
}
|
}
|
||||||
|
|
||||||
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()
|
|
||||||
)
|
|
||||||
|
|
||||||
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
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fun <T : ActivityResultCaller> T.setupLogSaving(
|
|
||||||
getLogFileName: () -> String,
|
|
||||||
getLogText: () -> String
|
|
||||||
): () -> Unit {
|
|
||||||
var lastFileName = "untitled"
|
|
||||||
|
|
||||||
val launchSaveIntent =
|
|
||||||
registerForActivityResult(ActivityResultContracts.CreateDocument("text/plain")) { uri ->
|
|
||||||
if (uri == null) return@registerForActivityResult
|
|
||||||
|
|
||||||
val context = when (this@setupLogSaving) {
|
|
||||||
is Context -> this@setupLogSaving
|
|
||||||
is Fragment -> requireContext()
|
|
||||||
else -> throw IllegalArgumentException("Must be either Context or Fragment!")
|
|
||||||
}
|
|
||||||
|
|
||||||
context.contentResolver.openFileDescriptor(uri, "w")?.use {
|
|
||||||
FileOutputStream(it.fileDescriptor).use { os ->
|
|
||||||
os.write(getLogText().encodeToByteArray())
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
AlertDialog.Builder(context).apply {
|
|
||||||
setMessage(R.string.logs_saved_message)
|
|
||||||
setNegativeButton(android.R.string.cancel) { _, _ -> }
|
|
||||||
setPositiveButton(android.R.string.ok) { _, _ ->
|
|
||||||
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)
|
|
||||||
}
|
|
||||||
|
|
||||||
context.startActivity(Intent.createChooser(intent, null))
|
|
||||||
}
|
|
||||||
}.show()
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
lastFileName = getLogFileName()
|
|
||||||
launchSaveIntent.launch(lastFileName)
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -54,9 +54,6 @@ interface OpenEuiccContextMarker {
|
||||||
val appContainer: AppContainer
|
val appContainer: AppContainer
|
||||||
get() = openEuiccApplication.appContainer
|
get() = openEuiccApplication.appContainer
|
||||||
|
|
||||||
val preferenceRepository: PreferenceRepository
|
|
||||||
get() = appContainer.preferenceRepository
|
|
||||||
|
|
||||||
val telephonyManager: TelephonyManager
|
val telephonyManager: TelephonyManager
|
||||||
get() = appContainer.telephonyManager
|
get() = appContainer.telephonyManager
|
||||||
}
|
}
|
||||||
|
@ -89,13 +86,6 @@ suspend fun connectSEService(context: Context): SEService = suspendCoroutine { c
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
inline fun <T> Bitmap.use(f: (Bitmap) -> T): T =
|
|
||||||
try {
|
|
||||||
f(this)
|
|
||||||
} finally {
|
|
||||||
recycle()
|
|
||||||
}
|
|
||||||
|
|
||||||
fun decodeQrFromBitmap(bmp: Bitmap): String? =
|
fun decodeQrFromBitmap(bmp: Bitmap): String? =
|
||||||
runCatching {
|
runCatching {
|
||||||
val pixels = IntArray(bmp.width * bmp.height)
|
val pixels = IntArray(bmp.width * bmp.height)
|
||||||
|
|
|
@ -1,112 +0,0 @@
|
||||||
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?,
|
|
||||||
val serialNumber: String?,
|
|
||||||
val bootloaderVersion: String?,
|
|
||||||
val firmwareVersion: String?,
|
|
||||||
)
|
|
||||||
|
|
||||||
private val EUICC_VENDORS: Array<EuiccVendor> = arrayOf(EstkMe(), SimLink())
|
|
||||||
|
|
||||||
fun EuiccChannel.tryParseEuiccVendorInfo(): EuiccVendorInfo? {
|
|
||||||
EUICC_VENDORS.forEach { vendor ->
|
|
||||||
vendor.tryParseEuiccVendorInfo(this@tryParseEuiccVendorInfo)?.let {
|
|
||||||
return it
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return null
|
|
||||||
}
|
|
||||||
|
|
||||||
interface EuiccVendor {
|
|
||||||
fun tryParseEuiccVendorInfo(channel: EuiccChannel): EuiccVendorInfo?
|
|
||||||
}
|
|
||||||
|
|
||||||
private class EstkMe : EuiccVendor {
|
|
||||||
companion object {
|
|
||||||
private val PRODUCT_AID = "A06573746B6D65FFFFFFFFFFFF6D6774".decodeHex()
|
|
||||||
private val PRODUCT_ATR_FPR = "estk.me".encodeToByteArray()
|
|
||||||
}
|
|
||||||
|
|
||||||
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? {
|
|
||||||
if (b.size < 2) return null
|
|
||||||
if (b[b.size - 2] != 0x90.toByte() || b[b.size - 1] != 0x00.toByte()) return null
|
|
||||||
return b.sliceArray(0 until b.size - 2).decodeToString()
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun tryParseEuiccVendorInfo(channel: EuiccChannel): EuiccVendorInfo? {
|
|
||||||
if (!checkAtr(channel)) return null
|
|
||||||
|
|
||||||
val iface = channel.apduInterface
|
|
||||||
return try {
|
|
||||||
iface.withLogicalChannel(PRODUCT_AID) { transmit ->
|
|
||||||
fun invoke(p1: Byte) =
|
|
||||||
decodeAsn1String(transmit(byteArrayOf(0x00, 0x00, p1, 0x00, 0x00)))
|
|
||||||
EuiccVendorInfo(
|
|
||||||
skuName = invoke(0x03),
|
|
||||||
serialNumber = invoke(0x00),
|
|
||||||
bootloaderVersion = invoke(0x01),
|
|
||||||
firmwareVersion = invoke(0x02),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
} catch (e: Exception) {
|
|
||||||
Log.d(TAG, "Failed to get ESTKmeInfo", e)
|
|
||||||
null
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private class SimLink : EuiccVendor {
|
|
||||||
companion object {
|
|
||||||
private val EID_PATTERN = Regex("^89044045(84|21)67274948")
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun tryParseEuiccVendorInfo(channel: EuiccChannel): EuiccVendorInfo? {
|
|
||||||
val eid = channel.lpa.eID
|
|
||||||
val version = channel.lpa.euiccInfo2?.euiccFirmwareVersion
|
|
||||||
if (version == null || EID_PATTERN.find(eid, 0) == null) return null
|
|
||||||
val versionName = when {
|
|
||||||
// @formatter:off
|
|
||||||
version >= Version(37, 1, 41) -> "v3.1 (beta 1)"
|
|
||||||
version >= Version(36, 18, 5) -> "v3 (final)"
|
|
||||||
version >= Version(36, 17, 39) -> "v3 (beta)"
|
|
||||||
version >= Version(36, 17, 4) -> "v2s"
|
|
||||||
version >= Version(36, 9, 3) -> "v2.1"
|
|
||||||
version >= Version(36, 7, 2) -> "v2"
|
|
||||||
// @formatter:on
|
|
||||||
else -> null
|
|
||||||
}
|
|
||||||
|
|
||||||
val skuName = if (versionName == null) {
|
|
||||||
"9eSIM"
|
|
||||||
} else {
|
|
||||||
"9eSIM $versionName"
|
|
||||||
}
|
|
||||||
|
|
||||||
return EuiccVendorInfo(
|
|
||||||
skuName = skuName,
|
|
||||||
serialNumber = null,
|
|
||||||
bootloaderVersion = null,
|
|
||||||
firmwareVersion = null
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -1,6 +0,0 @@
|
||||||
<?xml version="1.0" encoding="utf-8"?>
|
|
||||||
<translate xmlns:android="http://schemas.android.com/apk/res/android"
|
|
||||||
android:duration="@android:integer/config_shortAnimTime"
|
|
||||||
android:interpolator="@android:anim/decelerate_interpolator"
|
|
||||||
android:fromXDelta="-100%"
|
|
||||||
android:toXDelta="0%" />
|
|
|
@ -1,6 +0,0 @@
|
||||||
<?xml version="1.0" encoding="utf-8"?>
|
|
||||||
<translate xmlns:android="http://schemas.android.com/apk/res/android"
|
|
||||||
android:duration="@android:integer/config_shortAnimTime"
|
|
||||||
android:interpolator="@android:anim/decelerate_interpolator"
|
|
||||||
android:fromXDelta="100%"
|
|
||||||
android:toXDelta="0%" />
|
|
|
@ -1,6 +0,0 @@
|
||||||
<!-- res/anim/slide_out.xml -->
|
|
||||||
<translate xmlns:android="http://schemas.android.com/apk/res/android"
|
|
||||||
android:duration="@android:integer/config_shortAnimTime"
|
|
||||||
android:interpolator="@android:anim/decelerate_interpolator"
|
|
||||||
android:fromXDelta="0%"
|
|
||||||
android:toXDelta="-100%" />
|
|
|
@ -1,6 +0,0 @@
|
||||||
<!-- res/anim/slide_out.xml -->
|
|
||||||
<translate xmlns:android="http://schemas.android.com/apk/res/android"
|
|
||||||
android:duration="@android:integer/config_shortAnimTime"
|
|
||||||
android:interpolator="@android:anim/decelerate_interpolator"
|
|
||||||
android:fromXDelta="0%"
|
|
||||||
android:toXDelta="100%" />
|
|
|
@ -1,5 +0,0 @@
|
||||||
<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>
|
|
|
@ -1,5 +0,0 @@
|
||||||
<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>
|
|
|
@ -1,5 +0,0 @@
|
||||||
<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>
|
|
|
@ -1,18 +0,0 @@
|
||||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
|
||||||
android:width="24dp"
|
|
||||||
android:height="24dp"
|
|
||||||
android:viewportWidth="21"
|
|
||||||
android:viewportHeight="21">
|
|
||||||
<path
|
|
||||||
android:pathData="m3.578,6.487c1.385,-2.384 3.966,-3.987 6.922,-3.987 4.418,0 8,3.582 8,8s-3.582,8 -8,8 -8,-3.582 -8,-8"
|
|
||||||
android:strokeWidth="1"
|
|
||||||
android:strokeColor="@android:color/white"
|
|
||||||
android:strokeLineCap="round"
|
|
||||||
android:strokeLineJoin="round" />
|
|
||||||
<path
|
|
||||||
android:pathData="m7.5,6.5l-4,0l-0,-4"
|
|
||||||
android:strokeWidth="1"
|
|
||||||
android:strokeColor="@android:color/white"
|
|
||||||
android:strokeLineCap="round"
|
|
||||||
android:strokeLineJoin="round" />
|
|
||||||
</vector>
|
|
|
@ -1,7 +0,0 @@
|
||||||
<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>
|
|
|
@ -1,5 +0,0 @@
|
||||||
<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>
|
|
|
@ -1,5 +0,0 @@
|
||||||
<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>
|
|
|
@ -1,5 +0,0 @@
|
||||||
<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="M18,2h-8L4,8v12c0,1.1 0.9,2 2,2h12c1.1,0 2,-0.9 2,-2V4C20,2.9 19.1,2 18,2zM12,17l-4,-4h3V9.02L13,9v4h3L12,17z"/>
|
|
||||||
|
|
||||||
</vector>
|
|
|
@ -1,5 +0,0 @@
|
||||||
<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="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>
|
|
|
@ -1,5 +0,0 @@
|
||||||
<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="M19,6.41L17.59,5 12,10.59 6.41,5 5,6.41 10.59,12 5,17.59 6.41,19 12,13.41 17.59,19 19,17.59 13.41,12z"/>
|
|
||||||
|
|
||||||
</vector>
|
|
|
@ -1,74 +0,0 @@
|
||||||
<?xml version="1.0" encoding="utf-8"?>
|
|
||||||
<androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android"
|
|
||||||
android:layout_width="match_parent"
|
|
||||||
android:layout_height="match_parent"
|
|
||||||
xmlns:app="http://schemas.android.com/apk/res-auto">
|
|
||||||
|
|
||||||
<FrameLayout
|
|
||||||
android:id="@+id/step_fragment_container"
|
|
||||||
android:layout_width="0dp"
|
|
||||||
android:layout_height="0dp"
|
|
||||||
app:layout_constraintTop_toTopOf="parent"
|
|
||||||
app:layout_constraintBottom_toTopOf="@id/download_wizard_navigation"
|
|
||||||
app:layout_constraintStart_toStartOf="parent"
|
|
||||||
app:layout_constraintEnd_toEndOf="parent" />
|
|
||||||
|
|
||||||
<View
|
|
||||||
android:id="@+id/guideline"
|
|
||||||
android:layout_width="0dp"
|
|
||||||
android:layout_height="0dp"
|
|
||||||
android:orientation="vertical"
|
|
||||||
android:visibility="invisible"
|
|
||||||
app:layout_constraintBottom_toTopOf="@id/download_wizard_navigation"
|
|
||||||
app:layout_constraintEnd_toEndOf="parent"
|
|
||||||
app:layout_constraintStart_toStartOf="parent" />
|
|
||||||
|
|
||||||
<ProgressBar
|
|
||||||
android:id="@+id/progress"
|
|
||||||
android:layout_width="0dp"
|
|
||||||
android:layout_height="wrap_content"
|
|
||||||
android:indeterminate="true"
|
|
||||||
app:layout_constraintLeft_toLeftOf="parent"
|
|
||||||
app:layout_constraintRight_toRightOf="parent"
|
|
||||||
app:layout_constraintTop_toBottomOf="@id/guideline"
|
|
||||||
app:layout_constraintBottom_toTopOf="@id/download_wizard_navigation"
|
|
||||||
style="@style/Widget.AppCompat.ProgressBar.Horizontal" />
|
|
||||||
|
|
||||||
<androidx.constraintlayout.widget.ConstraintLayout
|
|
||||||
android:id="@+id/download_wizard_navigation"
|
|
||||||
android:layout_width="match_parent"
|
|
||||||
android:layout_height="48dp"
|
|
||||||
android:background="?attr/colorSurfaceContainer"
|
|
||||||
app:layout_constraintBottom_toBottomOf="parent"
|
|
||||||
app:layout_constraintStart_toStartOf="parent"
|
|
||||||
app:layout_constraintEnd_toEndOf="parent">
|
|
||||||
|
|
||||||
<com.google.android.material.button.MaterialButton
|
|
||||||
android:id="@+id/download_wizard_back"
|
|
||||||
android:text="@string/download_wizard_back"
|
|
||||||
android:background="?attr/selectableItemBackground"
|
|
||||||
android:textColor="?attr/colorPrimary"
|
|
||||||
android:layout_width="wrap_content"
|
|
||||||
android:layout_height="48dp"
|
|
||||||
app:icon="@drawable/ic_chevron_left"
|
|
||||||
app:iconGravity="start"
|
|
||||||
app:iconTint="?attr/colorPrimary"
|
|
||||||
app:layout_constraintStart_toStartOf="parent"
|
|
||||||
app:layout_constraintTop_toTopOf="parent" />
|
|
||||||
|
|
||||||
<com.google.android.material.button.MaterialButton
|
|
||||||
android:id="@+id/download_wizard_next"
|
|
||||||
android:text="@string/download_wizard_next"
|
|
||||||
android:background="?attr/selectableItemBackground"
|
|
||||||
android:textColor="?attr/colorPrimary"
|
|
||||||
android:layout_width="wrap_content"
|
|
||||||
android:layout_height="48dp"
|
|
||||||
app:icon="@drawable/ic_chevron_right"
|
|
||||||
app:iconGravity="end"
|
|
||||||
app:iconTint="?attr/colorPrimary"
|
|
||||||
app:layout_constraintEnd_toEndOf="parent"
|
|
||||||
app:layout_constraintTop_toTopOf="parent" />
|
|
||||||
|
|
||||||
</androidx.constraintlayout.widget.ConstraintLayout>
|
|
||||||
|
|
||||||
</androidx.constraintlayout.widget.ConstraintLayout>
|
|
|
@ -1,25 +0,0 @@
|
||||||
<?xml version="1.0" encoding="utf-8"?>
|
|
||||||
<androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android"
|
|
||||||
android:layout_width="match_parent"
|
|
||||||
android:layout_height="match_parent"
|
|
||||||
xmlns:app="http://schemas.android.com/apk/res-auto">
|
|
||||||
|
|
||||||
<include layout="@layout/toolbar_activity" />
|
|
||||||
|
|
||||||
<androidx.swiperefreshlayout.widget.SwipeRefreshLayout
|
|
||||||
android:id="@+id/swipe_refresh"
|
|
||||||
android:layout_width="0dp"
|
|
||||||
android:layout_height="0dp"
|
|
||||||
app:layout_constraintTop_toBottomOf="@id/toolbar"
|
|
||||||
app:layout_constraintStart_toStartOf="parent"
|
|
||||||
app:layout_constraintEnd_toEndOf="parent"
|
|
||||||
app:layout_constraintBottom_toBottomOf="parent">
|
|
||||||
|
|
||||||
<androidx.recyclerview.widget.RecyclerView
|
|
||||||
android:id="@+id/recycler_view"
|
|
||||||
android:layout_width="match_parent"
|
|
||||||
android:layout_height="match_parent" />
|
|
||||||
|
|
||||||
</androidx.swiperefreshlayout.widget.SwipeRefreshLayout>
|
|
||||||
|
|
||||||
</androidx.constraintlayout.widget.ConstraintLayout>
|
|
|
@ -5,7 +5,13 @@
|
||||||
android:layout_height="match_parent"
|
android:layout_height="match_parent"
|
||||||
xmlns:app="http://schemas.android.com/apk/res-auto">
|
xmlns:app="http://schemas.android.com/apk/res-auto">
|
||||||
|
|
||||||
<include layout="@layout/toolbar_activity" />
|
<com.google.android.material.appbar.MaterialToolbar
|
||||||
|
android:id="@+id/toolbar"
|
||||||
|
android:layout_width="0dp"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
app:layout_constraintTop_toTopOf="parent"
|
||||||
|
app:layout_constraintLeft_toLeftOf="parent"
|
||||||
|
app:layout_constraintWidth_percent="1" />
|
||||||
|
|
||||||
<androidx.swiperefreshlayout.widget.SwipeRefreshLayout
|
<androidx.swiperefreshlayout.widget.SwipeRefreshLayout
|
||||||
android:id="@+id/swipe_refresh"
|
android:id="@+id/swipe_refresh"
|
||||||
|
|
|
@ -5,7 +5,13 @@
|
||||||
android:layout_width="match_parent"
|
android:layout_width="match_parent"
|
||||||
android:layout_height="match_parent">
|
android:layout_height="match_parent">
|
||||||
|
|
||||||
<include layout="@layout/toolbar_activity" />
|
<com.google.android.material.appbar.MaterialToolbar
|
||||||
|
android:id="@+id/toolbar"
|
||||||
|
android:layout_width="0dp"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
app:layout_constraintTop_toTopOf="parent"
|
||||||
|
app:layout_constraintLeft_toLeftOf="parent"
|
||||||
|
app:layout_constraintWidth_percent="1" />
|
||||||
|
|
||||||
<com.google.android.material.tabs.TabLayout
|
<com.google.android.material.tabs.TabLayout
|
||||||
android:id="@+id/main_tabs"
|
android:id="@+id/main_tabs"
|
||||||
|
|
|
@ -4,7 +4,13 @@
|
||||||
android:layout_height="match_parent"
|
android:layout_height="match_parent"
|
||||||
xmlns:app="http://schemas.android.com/apk/res-auto">
|
xmlns:app="http://schemas.android.com/apk/res-auto">
|
||||||
|
|
||||||
<include layout="@layout/toolbar_activity" />
|
<com.google.android.material.appbar.MaterialToolbar
|
||||||
|
android:id="@+id/toolbar"
|
||||||
|
android:layout_width="0dp"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
app:layout_constraintTop_toTopOf="parent"
|
||||||
|
app:layout_constraintLeft_toLeftOf="parent"
|
||||||
|
app:layout_constraintWidth_percent="1" />
|
||||||
|
|
||||||
<androidx.swiperefreshlayout.widget.SwipeRefreshLayout
|
<androidx.swiperefreshlayout.widget.SwipeRefreshLayout
|
||||||
android:id="@+id/swipe_refresh"
|
android:id="@+id/swipe_refresh"
|
||||||
|
|
|
@ -4,7 +4,13 @@
|
||||||
android:layout_height="match_parent"
|
android:layout_height="match_parent"
|
||||||
xmlns:app="http://schemas.android.com/apk/res-auto">
|
xmlns:app="http://schemas.android.com/apk/res-auto">
|
||||||
|
|
||||||
<include layout="@layout/toolbar_activity" />
|
<com.google.android.material.appbar.MaterialToolbar
|
||||||
|
android:id="@+id/toolbar"
|
||||||
|
android:layout_width="0dp"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
app:layout_constraintTop_toTopOf="parent"
|
||||||
|
app:layout_constraintLeft_toLeftOf="parent"
|
||||||
|
app:layout_constraintWidth_percent="1" />
|
||||||
|
|
||||||
<FrameLayout
|
<FrameLayout
|
||||||
android:id="@+id/settings_container"
|
android:id="@+id/settings_container"
|
||||||
|
|
|
@ -1,44 +0,0 @@
|
||||||
<?xml version="1.0" encoding="utf-8"?>
|
|
||||||
<androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android"
|
|
||||||
android:layout_width="match_parent"
|
|
||||||
android:layout_height="wrap_content"
|
|
||||||
xmlns:app="http://schemas.android.com/apk/res-auto"
|
|
||||||
android:padding="20dp"
|
|
||||||
android:background="?attr/selectableItemBackground">
|
|
||||||
|
|
||||||
<ImageView
|
|
||||||
android:id="@+id/download_method_icon"
|
|
||||||
android:layout_width="30dp"
|
|
||||||
android:layout_height="30dp"
|
|
||||||
app:tint="?attr/colorAccent"
|
|
||||||
app:layout_constraintTop_toTopOf="parent"
|
|
||||||
app:layout_constraintBottom_toBottomOf="parent"
|
|
||||||
app:layout_constraintStart_toStartOf="parent" />
|
|
||||||
|
|
||||||
<TextView
|
|
||||||
android:id="@+id/download_method_title"
|
|
||||||
android:layout_width="wrap_content"
|
|
||||||
android:layout_height="wrap_content"
|
|
||||||
android:layout_marginStart="20dp"
|
|
||||||
android:layout_marginEnd="20dp"
|
|
||||||
android:textSize="15sp"
|
|
||||||
android:maxLines="1"
|
|
||||||
android:ellipsize="marquee"
|
|
||||||
app:layout_constraintTop_toTopOf="parent"
|
|
||||||
app:layout_constraintBottom_toBottomOf="parent"
|
|
||||||
app:layout_constraintStart_toEndOf="@id/download_method_icon"
|
|
||||||
app:layout_constraintEnd_toStartOf="@id/download_method_chevron"
|
|
||||||
app:layout_constraintHorizontal_bias="0.0"
|
|
||||||
app:layout_constrainedWidth="true" />
|
|
||||||
|
|
||||||
<ImageView
|
|
||||||
android:id="@+id/download_method_chevron"
|
|
||||||
android:src="@drawable/ic_chevron_right"
|
|
||||||
android:layout_width="30dp"
|
|
||||||
android:layout_height="30dp"
|
|
||||||
app:tint="?attr/colorAccent"
|
|
||||||
app:layout_constraintTop_toTopOf="parent"
|
|
||||||
app:layout_constraintBottom_toBottomOf="parent"
|
|
||||||
app:layout_constraintEnd_toEndOf="parent" />
|
|
||||||
|
|
||||||
</androidx.constraintlayout.widget.ConstraintLayout>
|
|
|
@ -1,81 +0,0 @@
|
||||||
<?xml version="1.0" encoding="utf-8"?>
|
|
||||||
<androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android"
|
|
||||||
xmlns:app="http://schemas.android.com/apk/res-auto"
|
|
||||||
android:layout_width="match_parent"
|
|
||||||
android:layout_height="wrap_content">
|
|
||||||
|
|
||||||
<TextView
|
|
||||||
android:id="@+id/download_progress_item_title"
|
|
||||||
android:layout_width="wrap_content"
|
|
||||||
android:layout_height="wrap_content"
|
|
||||||
android:layout_marginHorizontal="20dp"
|
|
||||||
android:textSize="14sp"
|
|
||||||
app:layout_constrainedWidth="true"
|
|
||||||
app:layout_constraintBottom_toBottomOf="@id/download_progress_icon_container"
|
|
||||||
app:layout_constraintEnd_toStartOf="@id/download_progress_icon_container"
|
|
||||||
app:layout_constraintHorizontal_bias="0.0"
|
|
||||||
app:layout_constraintStart_toStartOf="parent"
|
|
||||||
app:layout_constraintTop_toTopOf="@id/download_progress_icon_container"
|
|
||||||
app:layout_constraintVertical_bias="0.5" />
|
|
||||||
|
|
||||||
<FrameLayout
|
|
||||||
android:id="@+id/download_progress_icon_container"
|
|
||||||
android:layout_width="30dp"
|
|
||||||
android:layout_height="30dp"
|
|
||||||
android:layout_margin="20dp"
|
|
||||||
app:layout_constraintBottom_toBottomOf="parent"
|
|
||||||
app:layout_constraintEnd_toEndOf="parent"
|
|
||||||
app:layout_constraintTop_toTopOf="parent"
|
|
||||||
app:layout_constraintVertical_bias="0.0">
|
|
||||||
|
|
||||||
<ProgressBar
|
|
||||||
android:id="@+id/download_progress_icon_progress"
|
|
||||||
android:layout_width="match_parent"
|
|
||||||
android:layout_height="match_parent"
|
|
||||||
android:indeterminate="true"
|
|
||||||
android:visibility="gone" />
|
|
||||||
|
|
||||||
<ImageView
|
|
||||||
android:id="@+id/download_progress_icon"
|
|
||||||
android:layout_width="match_parent"
|
|
||||||
android:layout_height="match_parent"
|
|
||||||
android:visibility="gone"
|
|
||||||
app:tint="?attr/colorPrimary" />
|
|
||||||
|
|
||||||
</FrameLayout>
|
|
||||||
|
|
||||||
<TextView
|
|
||||||
android:id="@+id/download_progress_item_error_title"
|
|
||||||
android:layout_width="wrap_content"
|
|
||||||
android:layout_height="wrap_content"
|
|
||||||
android:layout_marginTop="10dp"
|
|
||||||
android:layout_marginStart="20dp"
|
|
||||||
android:layout_marginEnd="20dp"
|
|
||||||
android:layout_marginBottom="10dp"
|
|
||||||
android:textColor="?attr/colorError"
|
|
||||||
android:textSize="12sp"
|
|
||||||
android:visibility="gone"
|
|
||||||
app:layout_constrainedWidth="true"
|
|
||||||
app:layout_constraintBottom_toTopOf="@id/download_progress_item_error_suggestion"
|
|
||||||
app:layout_constraintEnd_toStartOf="@id/download_progress_icon_container"
|
|
||||||
app:layout_constraintHorizontal_bias="0.0"
|
|
||||||
app:layout_constraintStart_toStartOf="parent"
|
|
||||||
app:layout_constraintTop_toBottomOf="@id/download_progress_item_title" />
|
|
||||||
|
|
||||||
<TextView
|
|
||||||
android:id="@+id/download_progress_item_error_suggestion"
|
|
||||||
android:layout_width="wrap_content"
|
|
||||||
android:layout_height="wrap_content"
|
|
||||||
android:layout_marginStart="20dp"
|
|
||||||
android:layout_marginEnd="20dp"
|
|
||||||
android:textColor="?attr/colorError"
|
|
||||||
android:textSize="12sp"
|
|
||||||
android:visibility="gone"
|
|
||||||
app:layout_constrainedWidth="true"
|
|
||||||
app:layout_constraintBottom_toBottomOf="parent"
|
|
||||||
app:layout_constraintEnd_toStartOf="@id/download_progress_icon_container"
|
|
||||||
app:layout_constraintHorizontal_bias="0.0"
|
|
||||||
app:layout_constraintStart_toStartOf="parent"
|
|
||||||
app:layout_constraintTop_toBottomOf="@id/download_progress_item_title" />
|
|
||||||
|
|
||||||
</androidx.constraintlayout.widget.ConstraintLayout>
|
|
|
@ -1,108 +0,0 @@
|
||||||
<?xml version="1.0" encoding="utf-8"?>
|
|
||||||
<androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android"
|
|
||||||
xmlns:app="http://schemas.android.com/apk/res-auto"
|
|
||||||
android:layout_width="match_parent"
|
|
||||||
android:layout_height="wrap_content"
|
|
||||||
android:paddingBottom="20sp"
|
|
||||||
android:paddingTop="10sp"
|
|
||||||
android:paddingStart="20sp"
|
|
||||||
android:paddingEnd="20sp"
|
|
||||||
android:background="?attr/selectableItemBackground">
|
|
||||||
|
|
||||||
<TextView
|
|
||||||
android:id="@+id/slot_item_title"
|
|
||||||
android:layout_width="0dp"
|
|
||||||
android:layout_height="wrap_content"
|
|
||||||
android:layout_margin="10sp"
|
|
||||||
android:textSize="18sp"
|
|
||||||
app:layout_constraintEnd_toEndOf="parent"
|
|
||||||
app:layout_constraintStart_toStartOf="parent"
|
|
||||||
app:layout_constraintTop_toTopOf="parent" />
|
|
||||||
|
|
||||||
<TextView
|
|
||||||
android:id="@+id/slot_item_type_label"
|
|
||||||
android:layout_width="wrap_content"
|
|
||||||
android:layout_height="wrap_content"
|
|
||||||
android:minWidth="100dp"
|
|
||||||
android:text="@string/download_wizard_slot_type"
|
|
||||||
android:textSize="14sp" />
|
|
||||||
|
|
||||||
<TextView
|
|
||||||
android:id="@+id/slot_item_type"
|
|
||||||
android:layout_width="0dp"
|
|
||||||
android:layout_height="wrap_content"
|
|
||||||
android:textSize="14sp" />
|
|
||||||
|
|
||||||
<TextView
|
|
||||||
android:id="@+id/slot_item_eid_label"
|
|
||||||
android:layout_width="wrap_content"
|
|
||||||
android:layout_height="wrap_content"
|
|
||||||
android:minWidth="100dp"
|
|
||||||
android:text="@string/download_wizard_slot_eid"
|
|
||||||
android:textSize="14sp" />
|
|
||||||
|
|
||||||
<TextView
|
|
||||||
android:id="@+id/slot_item_eid"
|
|
||||||
android:layout_width="0dp"
|
|
||||||
android:layout_height="wrap_content"
|
|
||||||
android:textSize="14sp" />
|
|
||||||
|
|
||||||
<TextView
|
|
||||||
android:id="@+id/slot_item_active_profile_label"
|
|
||||||
android:layout_width="wrap_content"
|
|
||||||
android:layout_height="wrap_content"
|
|
||||||
android:minWidth="100dp"
|
|
||||||
android:text="@string/download_wizard_slot_active_profile"
|
|
||||||
android:textSize="14sp" />
|
|
||||||
|
|
||||||
<TextView
|
|
||||||
android:id="@+id/slot_item_active_profile"
|
|
||||||
android:layout_width="0dp"
|
|
||||||
android:layout_height="wrap_content"
|
|
||||||
android:textSize="14sp" />
|
|
||||||
|
|
||||||
<TextView
|
|
||||||
android:id="@+id/slot_item_free_space_label"
|
|
||||||
android:layout_width="wrap_content"
|
|
||||||
android:layout_height="wrap_content"
|
|
||||||
android:minWidth="100dp"
|
|
||||||
android:text="@string/download_wizard_slot_free_space"
|
|
||||||
android:textSize="14sp" />
|
|
||||||
|
|
||||||
<TextView
|
|
||||||
android:id="@+id/slot_item_free_space"
|
|
||||||
android:layout_width="0dp"
|
|
||||||
android:layout_height="wrap_content"
|
|
||||||
android:textSize="14sp" />
|
|
||||||
|
|
||||||
<androidx.constraintlayout.helper.widget.Flow
|
|
||||||
android:id="@+id/flow1"
|
|
||||||
android:layout_width="0dp"
|
|
||||||
android:layout_height="wrap_content"
|
|
||||||
android:layout_marginStart="10sp"
|
|
||||||
android:layout_marginTop="20sp"
|
|
||||||
android:layout_marginEnd="10sp"
|
|
||||||
app:constraint_referenced_ids="slot_item_type_label,slot_item_type,slot_item_eid_label,slot_item_eid,slot_item_active_profile_label,slot_item_active_profile,slot_item_free_space_label,slot_item_free_space"
|
|
||||||
app:flow_wrapMode="aligned"
|
|
||||||
app:flow_horizontalAlign="start"
|
|
||||||
app:flow_horizontalBias="1"
|
|
||||||
app:flow_horizontalGap="10sp"
|
|
||||||
app:flow_horizontalStyle="packed"
|
|
||||||
app:flow_maxElementsWrap="2"
|
|
||||||
app:flow_verticalBias="0"
|
|
||||||
app:flow_verticalGap="16sp"
|
|
||||||
app:flow_verticalStyle="packed"
|
|
||||||
app:layout_constraintEnd_toStartOf="@id/slot_checkbox"
|
|
||||||
app:layout_constraintStart_toStartOf="parent"
|
|
||||||
app:layout_constraintTop_toBottomOf="@id/slot_item_title" />
|
|
||||||
|
|
||||||
<CheckBox
|
|
||||||
android:id="@+id/slot_checkbox"
|
|
||||||
android:layout_width="48dp"
|
|
||||||
android:layout_height="48dp"
|
|
||||||
app:layout_constraintBottom_toBottomOf="parent"
|
|
||||||
app:layout_constraintEnd_toEndOf="parent"
|
|
||||||
app:layout_constraintStart_toEndOf="@id/flow1"
|
|
||||||
app:layout_constraintTop_toTopOf="parent" />
|
|
||||||
|
|
||||||
</androidx.constraintlayout.widget.ConstraintLayout>
|
|
|
@ -1,30 +0,0 @@
|
||||||
<?xml version="1.0" encoding="utf-8"?>
|
|
||||||
<androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android"
|
|
||||||
android:layout_width="match_parent"
|
|
||||||
android:layout_height="wrap_content"
|
|
||||||
android:background="?android:attr/selectableItemBackground"
|
|
||||||
xmlns:app="http://schemas.android.com/apk/res-auto">
|
|
||||||
|
|
||||||
<TextView
|
|
||||||
android:id="@+id/euicc_info_title"
|
|
||||||
android:layout_width="0dp"
|
|
||||||
android:layout_height="wrap_content"
|
|
||||||
android:layout_marginHorizontal="24dp"
|
|
||||||
android:layout_marginVertical="12dp"
|
|
||||||
android:textStyle="bold"
|
|
||||||
app:layout_constraintStart_toStartOf="parent"
|
|
||||||
app:layout_constraintEnd_toEndOf="parent"
|
|
||||||
app:layout_constraintTop_toTopOf="parent" />
|
|
||||||
|
|
||||||
<TextView
|
|
||||||
android:id="@+id/euicc_info_content"
|
|
||||||
android:layout_width="0dp"
|
|
||||||
android:layout_height="wrap_content"
|
|
||||||
android:layout_marginHorizontal="24dp"
|
|
||||||
android:layout_marginVertical="12dp"
|
|
||||||
app:layout_constraintStart_toStartOf="parent"
|
|
||||||
app:layout_constraintEnd_toEndOf="parent"
|
|
||||||
app:layout_constraintTop_toBottomOf="@id/euicc_info_title"
|
|
||||||
app:layout_constraintBottom_toBottomOf="parent" />
|
|
||||||
|
|
||||||
</androidx.constraintlayout.widget.ConstraintLayout>
|
|
|
@ -28,8 +28,7 @@
|
||||||
app:layout_constraintRight_toLeftOf="@+id/profile_menu"
|
app:layout_constraintRight_toLeftOf="@+id/profile_menu"
|
||||||
app:layout_constraintTop_toTopOf="parent"
|
app:layout_constraintTop_toTopOf="parent"
|
||||||
app:layout_constraintBottom_toTopOf="@+id/state"
|
app:layout_constraintBottom_toTopOf="@+id/state"
|
||||||
app:layout_constraintHorizontal_bias="0"
|
app:layout_constraintHorizontal_bias="0" />
|
||||||
app:layout_constrainedWidth="true" />
|
|
||||||
|
|
||||||
<androidx.appcompat.widget.AppCompatImageButton
|
<androidx.appcompat.widget.AppCompatImageButton
|
||||||
android:id="@+id/profile_menu"
|
android:id="@+id/profile_menu"
|
||||||
|
@ -54,7 +53,7 @@
|
||||||
|
|
||||||
<TextView
|
<TextView
|
||||||
android:id="@+id/provider_label"
|
android:id="@+id/provider_label"
|
||||||
android:text="@string/profile_provider"
|
android:text="@string/provider"
|
||||||
android:layout_width="wrap_content"
|
android:layout_width="wrap_content"
|
||||||
android:layout_height="wrap_content"
|
android:layout_height="wrap_content"
|
||||||
android:layout_marginTop="6dp"
|
android:layout_marginTop="6dp"
|
||||||
|
@ -63,50 +62,23 @@
|
||||||
android:singleLine="true"
|
android:singleLine="true"
|
||||||
app:layout_constraintLeft_toLeftOf="parent"
|
app:layout_constraintLeft_toLeftOf="parent"
|
||||||
app:layout_constraintTop_toBottomOf="@id/state"
|
app:layout_constraintTop_toBottomOf="@id/state"
|
||||||
app:layout_constraintBottom_toTopOf="@+id/profile_class_label"/>
|
app:layout_constraintBottom_toTopOf="@+id/iccid_label"/>
|
||||||
|
|
||||||
<TextView
|
<TextView
|
||||||
android:id="@+id/provider"
|
android:id="@+id/provider"
|
||||||
android:layout_width="wrap_content"
|
android:layout_width="wrap_content"
|
||||||
android:layout_height="wrap_content"
|
android:layout_height="wrap_content"
|
||||||
android:layout_marginTop="6dp"
|
android:layout_marginTop="6dp"
|
||||||
android:layout_marginStart="7dp"
|
android:layout_marginLeft="7dp"
|
||||||
android:textSize="14sp"
|
android:textSize="14sp"
|
||||||
android:singleLine="true"
|
android:singleLine="true"
|
||||||
app:layout_constraintLeft_toRightOf="@id/provider_label"
|
app:layout_constraintLeft_toRightOf="@id/provider_label"
|
||||||
app:layout_constraintTop_toBottomOf="@id/state"
|
app:layout_constraintTop_toBottomOf="@id/state"
|
||||||
app:layout_constraintBottom_toTopOf="@+id/profile_class"/>
|
|
||||||
|
|
||||||
<TextView
|
|
||||||
android:id="@+id/profile_class_label"
|
|
||||||
android:text="@string/profile_class"
|
|
||||||
android:layout_width="wrap_content"
|
|
||||||
android:layout_height="wrap_content"
|
|
||||||
android:layout_marginTop="6dp"
|
|
||||||
android:textSize="14sp"
|
|
||||||
android:textStyle="bold"
|
|
||||||
android:singleLine="true"
|
|
||||||
android:visibility="gone"
|
|
||||||
app:layout_constraintLeft_toLeftOf="parent"
|
|
||||||
app:layout_constraintTop_toBottomOf="@id/provider_label"
|
|
||||||
app:layout_constraintBottom_toTopOf="@+id/iccid_label"/>
|
|
||||||
|
|
||||||
<TextView
|
|
||||||
android:id="@+id/profile_class"
|
|
||||||
android:layout_width="wrap_content"
|
|
||||||
android:layout_height="wrap_content"
|
|
||||||
android:layout_marginTop="6dp"
|
|
||||||
android:layout_marginStart="7dp"
|
|
||||||
android:textSize="14sp"
|
|
||||||
android:singleLine="true"
|
|
||||||
android:visibility="gone"
|
|
||||||
app:layout_constraintLeft_toRightOf="@id/profile_class_label"
|
|
||||||
app:layout_constraintTop_toBottomOf="@id/provider"
|
|
||||||
app:layout_constraintBottom_toTopOf="@+id/iccid"/>
|
app:layout_constraintBottom_toTopOf="@+id/iccid"/>
|
||||||
|
|
||||||
<TextView
|
<TextView
|
||||||
android:id="@+id/iccid_label"
|
android:id="@+id/iccid_label"
|
||||||
android:text="@string/profile_iccid"
|
android:text="@string/iccid"
|
||||||
android:layout_width="wrap_content"
|
android:layout_width="wrap_content"
|
||||||
android:layout_height="wrap_content"
|
android:layout_height="wrap_content"
|
||||||
android:layout_marginTop="6dp"
|
android:layout_marginTop="6dp"
|
||||||
|
@ -114,7 +86,7 @@
|
||||||
android:textStyle="bold"
|
android:textStyle="bold"
|
||||||
android:singleLine="true"
|
android:singleLine="true"
|
||||||
app:layout_constraintLeft_toLeftOf="parent"
|
app:layout_constraintLeft_toLeftOf="parent"
|
||||||
app:layout_constraintTop_toBottomOf="@id/profile_class_label"
|
app:layout_constraintTop_toBottomOf="@id/provider_label"
|
||||||
app:layout_constraintBottom_toBottomOf="parent"/>
|
app:layout_constraintBottom_toBottomOf="parent"/>
|
||||||
|
|
||||||
<TextView
|
<TextView
|
||||||
|
@ -122,21 +94,13 @@
|
||||||
android:layout_width="wrap_content"
|
android:layout_width="wrap_content"
|
||||||
android:layout_height="wrap_content"
|
android:layout_height="wrap_content"
|
||||||
android:layout_marginTop="6dp"
|
android:layout_marginTop="6dp"
|
||||||
android:layout_marginStart="7dp"
|
android:layout_marginLeft="7dp"
|
||||||
android:textSize="14sp"
|
android:textSize="14sp"
|
||||||
android:singleLine="true"
|
android:singleLine="true"
|
||||||
app:layout_constraintLeft_toRightOf="@id/iccid_label"
|
app:layout_constraintLeft_toRightOf="@id/iccid_label"
|
||||||
app:layout_constraintTop_toBottomOf="@id/profile_class"
|
app:layout_constraintTop_toBottomOf="@id/provider"
|
||||||
app:layout_constraintBottom_toBottomOf="parent"/>
|
app:layout_constraintBottom_toBottomOf="parent"/>
|
||||||
|
|
||||||
<TextView
|
|
||||||
android:id="@+id/profile_sequence_number"
|
|
||||||
android:layout_width="wrap_content"
|
|
||||||
android:layout_height="wrap_content"
|
|
||||||
android:layout_marginHorizontal="6dp"
|
|
||||||
app:layout_constraintRight_toRightOf="parent"
|
|
||||||
app:layout_constraintTop_toTopOf="@id/iccid" />
|
|
||||||
|
|
||||||
</androidx.constraintlayout.widget.ConstraintLayout>
|
</androidx.constraintlayout.widget.ConstraintLayout>
|
||||||
|
|
||||||
</com.google.android.material.card.MaterialCardView>
|
</com.google.android.material.card.MaterialCardView>
|
||||||
|
|
|
@ -1,104 +0,0 @@
|
||||||
<?xml version="1.0" encoding="utf-8"?>
|
|
||||||
<ScrollView xmlns:android="http://schemas.android.com/apk/res/android"
|
|
||||||
xmlns:app="http://schemas.android.com/apk/res-auto"
|
|
||||||
android:layout_width="match_parent"
|
|
||||||
android:layout_height="match_parent"
|
|
||||||
android:fillViewport="true">
|
|
||||||
|
|
||||||
<androidx.constraintlayout.widget.ConstraintLayout
|
|
||||||
android:layout_width="match_parent"
|
|
||||||
android:layout_height="wrap_content">
|
|
||||||
|
|
||||||
<TextView
|
|
||||||
android:id="@+id/download_wizard_details_title"
|
|
||||||
android:text="@string/download_wizard_details"
|
|
||||||
android:layout_width="wrap_content"
|
|
||||||
android:layout_height="wrap_content"
|
|
||||||
android:gravity="center_horizontal"
|
|
||||||
android:textSize="20sp"
|
|
||||||
android:layout_marginTop="20dp"
|
|
||||||
android:layout_marginBottom="20dp"
|
|
||||||
android:layout_marginStart="60dp"
|
|
||||||
android:layout_marginEnd="60dp"
|
|
||||||
app:layout_constraintStart_toStartOf="parent"
|
|
||||||
app:layout_constraintEnd_toEndOf="parent"
|
|
||||||
app:layout_constrainedWidth="true"
|
|
||||||
app:layout_constraintTop_toTopOf="parent" />
|
|
||||||
|
|
||||||
<com.google.android.material.textfield.TextInputLayout
|
|
||||||
android:id="@+id/profile_download_server"
|
|
||||||
android:layout_width="0dp"
|
|
||||||
android:layout_height="wrap_content"
|
|
||||||
android:hint="@string/profile_download_server">
|
|
||||||
|
|
||||||
<com.google.android.material.textfield.TextInputEditText
|
|
||||||
android:maxLines="1"
|
|
||||||
android:inputType="text"
|
|
||||||
android:layout_width="match_parent"
|
|
||||||
android:layout_height="match_parent" />
|
|
||||||
|
|
||||||
</com.google.android.material.textfield.TextInputLayout>
|
|
||||||
|
|
||||||
<com.google.android.material.textfield.TextInputLayout
|
|
||||||
android:id="@+id/profile_download_code"
|
|
||||||
android:layout_width="0dp"
|
|
||||||
android:layout_height="wrap_content"
|
|
||||||
android:hint="@string/profile_download_code"
|
|
||||||
app:passwordToggleEnabled="true">
|
|
||||||
|
|
||||||
<com.google.android.material.textfield.TextInputEditText
|
|
||||||
android:maxLines="1"
|
|
||||||
android:layout_width="match_parent"
|
|
||||||
android:layout_height="match_parent"
|
|
||||||
android:inputType="textPassword" />
|
|
||||||
|
|
||||||
</com.google.android.material.textfield.TextInputLayout>
|
|
||||||
|
|
||||||
<com.google.android.material.textfield.TextInputLayout
|
|
||||||
android:id="@+id/profile_download_confirmation_code"
|
|
||||||
android:layout_width="0dp"
|
|
||||||
android:layout_height="wrap_content"
|
|
||||||
android:hint="@string/profile_download_confirmation_code"
|
|
||||||
app:passwordToggleEnabled="true">
|
|
||||||
|
|
||||||
<com.google.android.material.textfield.TextInputEditText
|
|
||||||
android:maxLines="1"
|
|
||||||
android:layout_width="match_parent"
|
|
||||||
android:layout_height="match_parent"
|
|
||||||
android:inputType="textPassword" />
|
|
||||||
|
|
||||||
</com.google.android.material.textfield.TextInputLayout>
|
|
||||||
|
|
||||||
<com.google.android.material.textfield.TextInputLayout
|
|
||||||
android:id="@+id/profile_download_imei"
|
|
||||||
android:layout_width="0dp"
|
|
||||||
android:layout_height="wrap_content"
|
|
||||||
android:layout_marginTop="15dp"
|
|
||||||
android:layout_marginBottom="6dp"
|
|
||||||
android:hint="@string/profile_download_imei"
|
|
||||||
app:passwordToggleEnabled="true">
|
|
||||||
|
|
||||||
<com.google.android.material.textfield.TextInputEditText
|
|
||||||
android:maxLines="1"
|
|
||||||
android:layout_width="match_parent"
|
|
||||||
android:layout_height="match_parent"
|
|
||||||
android:inputType="numberPassword" />
|
|
||||||
|
|
||||||
</com.google.android.material.textfield.TextInputLayout>
|
|
||||||
|
|
||||||
<androidx.constraintlayout.helper.widget.Flow
|
|
||||||
android:layout_width="match_parent"
|
|
||||||
android:layout_height="wrap_content"
|
|
||||||
android:orientation="vertical"
|
|
||||||
android:layout_marginHorizontal="20dp"
|
|
||||||
app:constraint_referenced_ids="profile_download_server,profile_download_code,profile_download_confirmation_code,profile_download_imei"
|
|
||||||
app:flow_verticalGap="16dp"
|
|
||||||
app:layout_constraintStart_toStartOf="parent"
|
|
||||||
app:layout_constraintTop_toBottomOf="@id/download_wizard_details_title"
|
|
||||||
app:layout_constraintBottom_toBottomOf="parent"
|
|
||||||
app:layout_constraintEnd_toEndOf="parent"
|
|
||||||
app:layout_constrainedWidth="true" />
|
|
||||||
|
|
||||||
</androidx.constraintlayout.widget.ConstraintLayout>
|
|
||||||
|
|
||||||
</ScrollView>
|
|
|
@ -1,59 +0,0 @@
|
||||||
<?xml version="1.0" encoding="utf-8"?>
|
|
||||||
<ScrollView xmlns:android="http://schemas.android.com/apk/res/android"
|
|
||||||
xmlns:app="http://schemas.android.com/apk/res-auto"
|
|
||||||
android:layout_width="match_parent"
|
|
||||||
android:layout_height="match_parent"
|
|
||||||
xmlns:tools="http://schemas.android.com/tools"
|
|
||||||
android:fillViewport="true">
|
|
||||||
|
|
||||||
<androidx.constraintlayout.widget.ConstraintLayout
|
|
||||||
android:layout_width="match_parent"
|
|
||||||
android:layout_height="wrap_content">
|
|
||||||
|
|
||||||
<TextView
|
|
||||||
android:id="@+id/download_wizard_diagnostics_title"
|
|
||||||
android:text="@string/download_wizard_diagnostics"
|
|
||||||
android:layout_width="wrap_content"
|
|
||||||
android:layout_height="wrap_content"
|
|
||||||
android:gravity="center_horizontal"
|
|
||||||
android:textSize="20sp"
|
|
||||||
android:layout_marginTop="20dp"
|
|
||||||
android:layout_marginBottom="20dp"
|
|
||||||
android:layout_marginStart="60dp"
|
|
||||||
android:layout_marginEnd="60dp"
|
|
||||||
app:layout_constraintStart_toStartOf="parent"
|
|
||||||
app:layout_constraintEnd_toEndOf="parent"
|
|
||||||
app:layout_constrainedWidth="true"
|
|
||||||
app:layout_constraintTop_toTopOf="parent" />
|
|
||||||
|
|
||||||
<ImageButton
|
|
||||||
android:id="@+id/download_wizard_diagnostics_save"
|
|
||||||
android:src="@drawable/ic_save_as_black"
|
|
||||||
android:layout_margin="20dp"
|
|
||||||
android:layout_width="24dp"
|
|
||||||
android:layout_height="24dp"
|
|
||||||
android:contentDescription="@string/download_wizard_diagnostics_save"
|
|
||||||
app:tint="?attr/colorAccent"
|
|
||||||
app:layout_constraintTop_toTopOf="parent"
|
|
||||||
app:layout_constraintEnd_toEndOf="parent" />
|
|
||||||
|
|
||||||
<TextView
|
|
||||||
android:id="@+id/download_wizard_diagnostics_text"
|
|
||||||
android:layout_width="match_parent"
|
|
||||||
android:layout_height="wrap_content"
|
|
||||||
android:padding="10dp"
|
|
||||||
android:textIsSelectable="true"
|
|
||||||
android:focusable="true"
|
|
||||||
android:textSize="10sp"
|
|
||||||
android:fontFamily="monospace"
|
|
||||||
android:lineSpacingMultiplier="1.1"
|
|
||||||
android:longClickable="true"
|
|
||||||
app:layout_constraintTop_toBottomOf="@id/download_wizard_diagnostics_title"
|
|
||||||
app:layout_constraintStart_toStartOf="parent"
|
|
||||||
app:layout_constraintEnd_toEndOf="parent"
|
|
||||||
app:layout_constraintBottom_toBottomOf="parent"
|
|
||||||
tools:ignore="SmallSp" />
|
|
||||||
|
|
||||||
</androidx.constraintlayout.widget.ConstraintLayout>
|
|
||||||
|
|
||||||
</ScrollView>
|
|
|
@ -1,33 +0,0 @@
|
||||||
<?xml version="1.0" encoding="utf-8"?>
|
|
||||||
<androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android"
|
|
||||||
android:layout_width="match_parent"
|
|
||||||
android:layout_height="match_parent"
|
|
||||||
xmlns:app="http://schemas.android.com/apk/res-auto">
|
|
||||||
|
|
||||||
<TextView
|
|
||||||
android:id="@+id/download_method_select_title"
|
|
||||||
android:text="@string/download_wizard_method_select"
|
|
||||||
android:layout_width="wrap_content"
|
|
||||||
android:layout_height="wrap_content"
|
|
||||||
android:gravity="center_horizontal"
|
|
||||||
android:textSize="20sp"
|
|
||||||
android:layout_marginTop="20dp"
|
|
||||||
android:layout_marginBottom="20dp"
|
|
||||||
android:layout_marginStart="60dp"
|
|
||||||
android:layout_marginEnd="60dp"
|
|
||||||
app:layout_constraintStart_toStartOf="parent"
|
|
||||||
app:layout_constraintEnd_toEndOf="parent"
|
|
||||||
app:layout_constrainedWidth="true"
|
|
||||||
app:layout_constraintTop_toTopOf="parent" />
|
|
||||||
|
|
||||||
<androidx.recyclerview.widget.RecyclerView
|
|
||||||
android:id="@+id/download_method_list"
|
|
||||||
android:layout_width="0dp"
|
|
||||||
android:layout_height="wrap_content"
|
|
||||||
app:layout_constraintTop_toBottomOf="@id/download_method_select_title"
|
|
||||||
app:layout_constraintStart_toStartOf="parent"
|
|
||||||
app:layout_constraintEnd_toEndOf="parent"
|
|
||||||
app:layout_constraintBottom_toBottomOf="parent"
|
|
||||||
app:layout_constrainedHeight="true" />
|
|
||||||
|
|
||||||
</androidx.constraintlayout.widget.ConstraintLayout>
|
|
|
@ -1,33 +0,0 @@
|
||||||
<?xml version="1.0" encoding="utf-8"?>
|
|
||||||
<androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android"
|
|
||||||
android:layout_width="match_parent"
|
|
||||||
android:layout_height="match_parent"
|
|
||||||
xmlns:app="http://schemas.android.com/apk/res-auto">
|
|
||||||
|
|
||||||
<TextView
|
|
||||||
android:id="@+id/download_progress_title"
|
|
||||||
android:text="@string/download_wizard_progress"
|
|
||||||
android:layout_width="wrap_content"
|
|
||||||
android:layout_height="wrap_content"
|
|
||||||
android:gravity="center_horizontal"
|
|
||||||
android:textSize="20sp"
|
|
||||||
android:layout_marginTop="20dp"
|
|
||||||
android:layout_marginBottom="20dp"
|
|
||||||
android:layout_marginStart="60dp"
|
|
||||||
android:layout_marginEnd="60dp"
|
|
||||||
app:layout_constraintStart_toStartOf="parent"
|
|
||||||
app:layout_constraintEnd_toEndOf="parent"
|
|
||||||
app:layout_constrainedWidth="true"
|
|
||||||
app:layout_constraintTop_toTopOf="parent" />
|
|
||||||
|
|
||||||
<androidx.recyclerview.widget.RecyclerView
|
|
||||||
android:id="@+id/download_progress_list"
|
|
||||||
android:layout_width="0dp"
|
|
||||||
android:layout_height="wrap_content"
|
|
||||||
app:layout_constraintTop_toBottomOf="@id/download_progress_title"
|
|
||||||
app:layout_constraintStart_toStartOf="parent"
|
|
||||||
app:layout_constraintEnd_toEndOf="parent"
|
|
||||||
app:layout_constraintBottom_toBottomOf="parent"
|
|
||||||
app:layout_constrainedHeight="true" />
|
|
||||||
|
|
||||||
</androidx.constraintlayout.widget.ConstraintLayout>
|
|
|
@ -1,33 +0,0 @@
|
||||||
<?xml version="1.0" encoding="utf-8"?>
|
|
||||||
<androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android"
|
|
||||||
android:layout_width="match_parent"
|
|
||||||
android:layout_height="match_parent"
|
|
||||||
xmlns:app="http://schemas.android.com/apk/res-auto">
|
|
||||||
|
|
||||||
<TextView
|
|
||||||
android:id="@+id/download_slot_select_title"
|
|
||||||
android:text="@string/download_wizard_slot_select"
|
|
||||||
android:layout_width="wrap_content"
|
|
||||||
android:layout_height="wrap_content"
|
|
||||||
android:gravity="center_horizontal"
|
|
||||||
android:textSize="20sp"
|
|
||||||
android:layout_marginTop="20dp"
|
|
||||||
android:layout_marginBottom="20dp"
|
|
||||||
android:layout_marginStart="60dp"
|
|
||||||
android:layout_marginEnd="60dp"
|
|
||||||
app:layout_constraintStart_toStartOf="parent"
|
|
||||||
app:layout_constraintEnd_toEndOf="parent"
|
|
||||||
app:layout_constrainedWidth="true"
|
|
||||||
app:layout_constraintTop_toTopOf="parent" />
|
|
||||||
|
|
||||||
<androidx.recyclerview.widget.RecyclerView
|
|
||||||
android:id="@+id/download_slot_list"
|
|
||||||
android:layout_width="0dp"
|
|
||||||
android:layout_height="wrap_content"
|
|
||||||
app:layout_constraintTop_toBottomOf="@id/download_slot_select_title"
|
|
||||||
app:layout_constraintStart_toStartOf="parent"
|
|
||||||
app:layout_constraintEnd_toEndOf="parent"
|
|
||||||
app:layout_constraintBottom_toBottomOf="parent"
|
|
||||||
app:layout_constrainedHeight="true" />
|
|
||||||
|
|
||||||
</androidx.constraintlayout.widget.ConstraintLayout>
|
|
Some files were not shown because too many files have changed in this diff Show more
Loading…
Add table
Add a link
Reference in a new issue