forked from PeterCxy/OpenEUICC
Compare commits
296 commits
unpriv-v1.
...
master
Author | SHA1 | Date | |
---|---|---|---|
82f4c6b7d9 | |||
eaef00b88a | |||
023f6ded28 | |||
a601ab7d72 | |||
756c621d5e | |||
68114fa863 | |||
1fda120459 | |||
994324acb6 | |||
6c774450ec | |||
00ddf09287 | |||
3662f93760 | |||
05abed117a | |||
92fbfc5229 | |||
d7bfd84de9 | |||
c6963feb17 | |||
dc6b3a4810 | |||
e08f8beb45 | |||
6b169c505d | |||
33d383a3ce | |||
291869207a | |||
a6286ed097 | |||
360760b78f | |||
b9849afe18 | |||
88eb1ce0e2 | |||
74cc08ce8e | |||
f6c50490b8 | |||
c2659ddb69 | |||
5dd9eed4fe | |||
17102be7cb | |||
ece231f17b | |||
db8063cd5f | |||
d3df70501a | |||
53f9459aed | |||
6557ce45a7 | |||
2b86d719dd | |||
7edde1ffa4 | |||
e5753ec2d9 | |||
c528962f29 | |||
889b08767c | |||
8243914588 | |||
2eabf719d0 | |||
d068261ff9 | |||
6a5d4b9288 | |||
1313bfd24e | |||
99d9200c28 | |||
65c7f8de83 | |||
6c9063a761 | |||
d5aefcaec7 | |||
ef295c9d12 | |||
1d67fa5cfa | |||
c8ecdee095 | |||
03bfdf373c | |||
9517f53712 | |||
f5074acae2 | |||
bcd1295a18 | |||
50ba81f131 | |||
d0b3d54c66 | |||
3a860601a3 | |||
6b4723daee | |||
3ef78a23db | |||
31d595a6b1 | |||
e7ef370e46 | |||
653a7b32ee | |||
0f8749ee04 | |||
c0a6917645 | |||
6e3176668a | |||
66bee041a0 | |||
43f247a71b | |||
960f8855ad | |||
de3ae19a10 | |||
75d3894462 | |||
895899d03a | |||
a87f154653 | |||
b88345057c | |||
9596b8632c | |||
087c760010 | |||
24076e8fb4 | |||
f135a0da60 | |||
3b7bd8b31e | |||
74e946cc8f | |||
3430406603 | |||
24f04f54e4 | |||
0fbda7dd78 | |||
905d0c897e | |||
f395cee2e0 | |||
456754db5d | |||
7d1c7663bc | |||
9d18253e44 | |||
6039679693 | |||
5a8d92c3df | |||
55c99831f3 | |||
343dfb43f8 | |||
815d4d4324 | |||
ec334d104a | |||
70f1e00eb4 | |||
bc238c45cd | |||
14ea84c36e | |||
aefa79b18b | |||
aed2479044 | |||
f294fb5e17 | |||
55d96c6732 | |||
06873545e2 | |||
6d962a12b5 | |||
8f9c7137f6 | |||
125f1da6af | |||
4a482b9c73 | |||
ca46b578f7 | |||
23022b14be | |||
2d66c1f334 | |||
09b98b37ab | |||
fdbf9b3252 | |||
84f47cb0f0 | |||
0229ef41df | |||
15d3b701a5 | |||
700578a369 | |||
eab60bf3d3 | |||
5b80afd5fe | |||
400c2ff9f9 | |||
b4f562f90b | |||
5a000278d3 | |||
38d38523f9 | |||
022ca1da9d | |||
1140ddb249 | |||
9be1ae7cd1 | |||
a7e97378fc | |||
790cbb5a58 | |||
2247749b37 | |||
249aea482b | |||
dc0489a693 | |||
d3e54ece58 | |||
4e5bb5b11e | |||
68cc6adc9b | |||
9e637f766d | |||
acce39fd3b | |||
fb8b6de350 | |||
75221fcf79 | |||
4ae19aea3b | |||
ff6bd45ac6 | |||
858b6d55d6 | |||
78bf3612ee | |||
afeb5c5282 | |||
f74145d0b7 | |||
c6de599db0 | |||
0a78daee8b | |||
e7f58bbaaf | |||
562e5922be | |||
50c77ea467 | |||
6bb1a16aee | |||
92daa56f1a | |||
90878438f9 | |||
96bc9865ff | |||
dcae65011e | |||
1c4263a47a | |||
d7214141e6 | |||
326b39ed05 | |||
26d037048d | |||
5476e335b1 | |||
426e5c0197 | |||
74d7da35dc | |||
07072667db | |||
895cbdd53d | |||
1a3fd621d9 | |||
74489a9ae0 | |||
d68a7172de | |||
5b079c95ac | |||
f2c233fe1c | |||
3507c17834 | |||
b2abe5ee84 | |||
67c9612627 | |||
39b40f9b0d | |||
f236b40cd4 | |||
e7a0482281 | |||
81f34f9b1c | |||
8c73615fbb | |||
9cf95ad47c | |||
723ec70730 | |||
dbdadd33b3 | |||
92b7b46598 | |||
0c519af376 | |||
aaca9e807a | |||
98e16ee5aa | |||
b9d5c1c5bb | |||
c4b513fc0a | |||
6458f54db2 | |||
87f36f4166 | |||
4fb59a4b01 | |||
16636988b0 | |||
93e7297caa | |||
1087a676d4 | |||
375d13b7c4 | |||
a3d59a0761 | |||
5f0dbe3098 | |||
efa9b8bfa4 | |||
47d5c3881c | |||
e9f4d3d1f9 | |||
506b0e530a | |||
e8db3d1206 | |||
071304349a | |||
6f8aef8ea8 | |||
8e806c3ae5 | |||
42c870192c | |||
9201ee416e | |||
7105c43ae4 | |||
d846f0cdc4 | |||
5dacb75717 | |||
f28867ef2e | |||
7215a2351b | |||
837c34ba70 | |||
fe6d4264e3 | |||
13085ec202 | |||
9d8e58a95d | |||
22ec3e3baf | |||
32f5e3f71a | |||
04debd62d5 | |||
0ef435956c | |||
573dce56a6 | |||
272ab953e0 | |||
6257a03058 | |||
5e5210ae2d | |||
87eb497f40 | |||
1dc5004681 | |||
2ece6af174 | |||
59b4b9e4ab | |||
826c120ca5 | |||
5cefbc24f5 | |||
f285eacd55 | |||
481b9ce196 | |||
ce7fb29c14 | |||
c2cc8ceb2a | |||
3d4704e77b | |||
6a2d4d66dd | |||
8ac46bd778 | |||
0961ef70f4 | |||
3b868e4f9a | |||
95b24e6151 | |||
ef62274057 | |||
76e8fbd56b | |||
d54fcf2589 | |||
7cb872a664 | |||
65c9a7dc39 | |||
d26a8ddc78 | |||
aac457f4b5 | |||
2337ad035d | |||
7197501cca | |||
4709b6994f | |||
349c8179b0 | |||
16b6aceedf | |||
eab96dde05 | |||
84dd16c169 | |||
d3a04b94a9 | |||
19dc215b3f | |||
ddc421dae7 | |||
69e63b0a8b | |||
290bdca75a | |||
5c8bbeb217 | |||
ff266a4a9b | |||
6b71a746a4 | |||
165f685abb | |||
42942c2816 | |||
54b4f61fd7 | |||
7661b4b84f | |||
479e0ff34a | |||
79f43e2fda | |||
8573834a03 | |||
2721f91277 | |||
653123939c | |||
48b5f8ce06 | |||
31c06470c6 | |||
cf5704be42 | |||
f71da0e4ff | |||
fe1319537a | |||
8de0d86895 | |||
64a350d271 | |||
9a77824f79 | |||
3add3ffa90 | |||
324dcdc563 | |||
b94eedac0a | |||
a6777d1d17 | |||
dc70f7ca46 | |||
77d95e4d02 | |||
4a32f53c06 | |||
97bc0a0827 | |||
68f1e370fc | |||
bf36188219 | |||
a43ceea39f | |||
c681e99e47 | |||
0afbece3b5 | |||
b101b01228 | |||
5ab07d6262 | |||
394cad2eac | |||
7f67000074 | |||
7c07db0aab | |||
f073261b60 | |||
87ea017b36 | |||
44b85ffdea | |||
01fc07fd78 |
190 changed files with 7401 additions and 2084 deletions
|
@ -35,11 +35,12 @@ jobs:
|
|||
- name: Build Debug APKs
|
||||
run: ./gradlew --no-daemon assembleDebug
|
||||
|
||||
- name: Copy Artifacts
|
||||
run: find . -name 'app*-debug.apk' -exec cp {} . \;
|
||||
|
||||
- name: Upload Artifacts
|
||||
uses: https://gitea.angry.im/actions/upload-artifact@v3
|
||||
with:
|
||||
name: Debug APKs
|
||||
compression-level: 0
|
||||
path: |
|
||||
app-unpriv/build/outputs/apk/debug/app-unpriv-debug.apk
|
||||
app/build/outputs/apk/debug/app-debug.apk
|
||||
path: app*-debug.apk
|
||||
|
|
29
.gitignore
vendored
29
.gitignore
vendored
|
@ -1,20 +1,11 @@
|
|||
*.iml
|
||||
.gradle
|
||||
/local.properties
|
||||
/keystore.properties
|
||||
/.idea/caches
|
||||
/.idea/libraries
|
||||
/.idea/modules.xml
|
||||
/.idea/workspace.xml
|
||||
/.idea/navEditor.xml
|
||||
/.idea/assetWizardSettings.xml
|
||||
/.idea/deploymentTargetDropDown.xml
|
||||
.DS_Store
|
||||
/build
|
||||
/.gradle
|
||||
/captures
|
||||
.externalNativeBuild
|
||||
.cxx
|
||||
local.properties
|
||||
/libs/**/build
|
||||
/buildSrc/build
|
||||
/app-deps/libs
|
||||
|
||||
# Configuration files
|
||||
|
||||
/keystore.properties
|
||||
/local.properties
|
||||
|
||||
# macOS
|
||||
|
||||
.DS_Store
|
||||
|
|
15
.idea/.gitignore
generated
vendored
15
.idea/.gitignore
generated
vendored
|
@ -1,3 +1,14 @@
|
|||
# Default ignored files
|
||||
/shelf/
|
||||
/shelf
|
||||
/caches
|
||||
/libraries
|
||||
/assetWizardSettings.xml
|
||||
/deploymentTargetDropDown.xml
|
||||
/gradle.xml
|
||||
/misc.xml
|
||||
/modules.xml
|
||||
/navEditor.xml
|
||||
/runConfigurations.xml
|
||||
/workspace.xml
|
||||
/AndroidProjectSystem.xml
|
||||
|
||||
**/*.iml
|
6
.idea/codeStyles/Project.xml
generated
6
.idea/codeStyles/Project.xml
generated
|
@ -1,5 +1,8 @@
|
|||
<component name="ProjectCodeStyleConfiguration">
|
||||
<code_scheme name="Project" version="173">
|
||||
<JetCodeStyleSettings>
|
||||
<option name="CODE_STYLE_DEFAULTS" value="KOTLIN_OFFICIAL" />
|
||||
</JetCodeStyleSettings>
|
||||
<codeStyleSettings language="XML">
|
||||
<option name="FORCE_REARRANGE_MODE" value="1" />
|
||||
<indentOptions>
|
||||
|
@ -113,5 +116,8 @@
|
|||
</rules>
|
||||
</arrangement>
|
||||
</codeStyleSettings>
|
||||
<codeStyleSettings language="kotlin">
|
||||
<option name="CODE_STYLE_DEFAULTS" value="KOTLIN_OFFICIAL" />
|
||||
</codeStyleSettings>
|
||||
</code_scheme>
|
||||
</component>
|
1
.idea/codeStyles/codeStyleConfig.xml
generated
1
.idea/codeStyles/codeStyleConfig.xml
generated
|
@ -1,5 +1,6 @@
|
|||
<component name="ProjectCodeStyleConfiguration">
|
||||
<state>
|
||||
<option name="USE_PER_PROJECT_SETTINGS" value="true" />
|
||||
<option name="PREFERRED_PROJECT_CODE_STYLE" value="Default" />
|
||||
</state>
|
||||
</component>
|
12
.idea/compiler.xml
generated
12
.idea/compiler.xml
generated
|
@ -1,16 +1,6 @@
|
|||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<project version="4">
|
||||
<component name="CompilerConfiguration">
|
||||
<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>
|
||||
<bytecodeTargetLevel target="1.7" />
|
||||
</component>
|
||||
</project>
|
37
.idea/deploymentTargetSelector.xml
generated
Normal file
37
.idea/deploymentTargetSelector.xml
generated
Normal file
|
@ -0,0 +1,37 @@
|
|||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<project version="4">
|
||||
<component name="deploymentTargetSelector">
|
||||
<selectionStates>
|
||||
<SelectionState runConfigName="app-unpriv">
|
||||
<option name="selectionMode" value="DROPDOWN" />
|
||||
</SelectionState>
|
||||
<SelectionState runConfigName="app">
|
||||
<option name="selectionMode" value="DROPDOWN" />
|
||||
</SelectionState>
|
||||
<SelectionState runConfigName="app-unpriv.androidTest">
|
||||
<option name="selectionMode" value="DROPDOWN" />
|
||||
</SelectionState>
|
||||
<SelectionState runConfigName="app-unpriv.main">
|
||||
<option name="selectionMode" value="DROPDOWN" />
|
||||
</SelectionState>
|
||||
<SelectionState runConfigName="app-unpriv.unitTest">
|
||||
<option name="selectionMode" value="DROPDOWN" />
|
||||
</SelectionState>
|
||||
<SelectionState runConfigName="app.unitTest">
|
||||
<option name="selectionMode" value="DROPDOWN" />
|
||||
</SelectionState>
|
||||
<SelectionState runConfigName="app.androidTest">
|
||||
<option name="selectionMode" value="DROPDOWN" />
|
||||
</SelectionState>
|
||||
<SelectionState runConfigName="app.main">
|
||||
<option name="selectionMode" value="DROPDOWN" />
|
||||
</SelectionState>
|
||||
<SelectionState runConfigName="workspace.OpenEUICC.app-unpriv">
|
||||
<option name="selectionMode" value="DROPDOWN" />
|
||||
</SelectionState>
|
||||
<SelectionState runConfigName="workspace.OpenEUICC.app">
|
||||
<option name="selectionMode" value="DROPDOWN" />
|
||||
</SelectionState>
|
||||
</selectionStates>
|
||||
</component>
|
||||
</project>
|
29
.idea/gradle.xml
generated
29
.idea/gradle.xml
generated
|
@ -1,29 +0,0 @@
|
|||
<?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"?>
|
||||
<project version="4">
|
||||
<component name="KotlinJpsPluginSettings">
|
||||
<option name="version" value="1.9.20" />
|
||||
<option name="version" value="1.9.24" />
|
||||
</component>
|
||||
</project>
|
10
.idea/migrations.xml
generated
Normal file
10
.idea/migrations.xml
generated
Normal file
|
@ -0,0 +1,10 @@
|
|||
<?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
25
.idea/misc.xml
generated
|
@ -1,25 +0,0 @@
|
|||
<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>
|
29
README.md
29
README.md
|
@ -2,17 +2,26 @@
|
|||
|
||||
A fully free and open-source Local Profile Assistant implementation for Android devices.
|
||||
|
||||
There are two variants of this project:
|
||||
There are two variants of this project, OpenEUICC and 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.
|
||||
- The preferred way to including OpenEUICC in a system image is to [build it along with AOSP](#building-aosp).
|
||||
- EasyEUICC: Unprivileged version that can run as a user app.
|
||||
- This version supports two modes of operation:
|
||||
1. Inserted, removable eSIMs: Due to obvious security requirements, EasyEUICC is only able to access eSIM chips whose [ARF/ARA](https://source.android.com/docs/core/connect/uicc#arf) contains the hash of EasyEUICC's signing certificate.
|
||||
2. USB CCID Card Readers: Only `T=0` readers that use the standard [USB CCID protocol](https://en.wikipedia.org/wiki/CCID_(protocol)) are supported. In this mode, EasyEUICC can access any eSIM chip loaded in the card reader regardless of their ARF/ARA, as long as they implement the [SGP.22 standard](https://www.gsma.com/solutions-and-impact/technologies/esim/wp-content/uploads/2021/07/SGP.22-v2.3.pdf).
|
||||
- Prebuilt release-mode EasyEUICC apks can be downloaded [here](https://gitea.angry.im/PeterCxy/OpenEUICC/releases)
|
||||
- For removable eSIM chip vendors: to have your chip supported by official builds of EasyEUICC when inserted, include the ARA-M hash `2A2FA878BC7C3354C2CF82935A5945A3EDAE4AFA`
|
||||
| | OpenEUICC | EasyEUICC |
|
||||
|:------------------------------|:-----------------------------------------------:|:-----------------:|
|
||||
| Privileged | Must be installed as system app | No |
|
||||
| Internal eSIM | Supported | Unsupported |
|
||||
| External (Removable) eSIM | Supported | Supported |
|
||||
| USB Readers | Supported | Supported |
|
||||
| Requires allowlisting by eSIM | No | Yes -- except USB |
|
||||
| System Integration | Partial (carrier partner API unimplemented yet) | No |
|
||||
|
||||
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](https://en.wikipedia.org/wiki/CCID_(protocol)) are supported.
|
||||
3. Prebuilt release-mode EasyEUICC apks can be downloaded [here](https://gitea.angry.im/PeterCxy/OpenEUICC/releases). For OpenEUICC, no official release is currently provided and only debug mode APKs can be found in the CI page.
|
||||
4. For removable eSIM chip vendors: to have your chip supported by official builds of EasyEUICC when inserted, include the ARA-M hash `2A2FA878BC7C3354C2CF82935A5945A3EDAE4AFA`.
|
||||
|
||||
__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)
|
||||
===
|
||||
|
|
|
@ -5,7 +5,7 @@ plugins {
|
|||
|
||||
android {
|
||||
namespace = "im.angry.openeuicc.common"
|
||||
compileSdk = 34
|
||||
compileSdk = 35
|
||||
|
||||
defaultConfig {
|
||||
minSdk = 28
|
||||
|
|
|
@ -3,10 +3,15 @@
|
|||
xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
package="im.angry.openeuicc.common">
|
||||
|
||||
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
|
||||
<uses-permission android:name="android.permission.READ_PHONE_STATE" />
|
||||
<uses-permission android:name="android.permission.INTERNET" />
|
||||
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
|
||||
<uses-permission android:name="android.permission.WAKE_LOCK" />
|
||||
|
||||
<application>
|
||||
<application
|
||||
android:enableOnBackInvokedCallback="true"
|
||||
tools:targetApi="tiramisu">
|
||||
<activity
|
||||
android:name="im.angry.openeuicc.ui.SettingsActivity"
|
||||
android:label="@string/pref_settings" />
|
||||
|
@ -16,14 +21,40 @@
|
|||
android:label="@string/profile_notifications" />
|
||||
|
||||
<activity
|
||||
android:name="im.angry.openeuicc.ui.DirectProfileDownloadActivity"
|
||||
android:label="@string/profile_download"
|
||||
android:theme="@style/Theme.AppCompat.Translucent" />
|
||||
android:name="im.angry.openeuicc.ui.EuiccInfoActivity"
|
||||
android:label="@string/euicc_info" />
|
||||
|
||||
<activity
|
||||
android:name="im.angry.openeuicc.ui.LogsActivity"
|
||||
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: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
|
||||
android:name="com.journeyapps.barcodescanner.CaptureActivity"
|
||||
android:screenOrientation="fullSensor"
|
||||
|
@ -31,6 +62,7 @@
|
|||
|
||||
<service
|
||||
android:name="im.angry.openeuicc.service.EuiccChannelManagerService"
|
||||
android:foregroundServiceType="shortService"
|
||||
android:exported="false" />
|
||||
</application>
|
||||
</manifest>
|
||||
|
|
|
@ -0,0 +1,5 @@
|
|||
package im.angry.openeuicc.core
|
||||
|
||||
interface ApduInterfaceAtrProvider {
|
||||
val atr: ByteArray?
|
||||
}
|
|
@ -1,59 +1,92 @@
|
|||
package im.angry.openeuicc.core
|
||||
|
||||
import android.content.Context
|
||||
import android.hardware.usb.UsbDevice
|
||||
import android.hardware.usb.UsbInterface
|
||||
import android.hardware.usb.UsbManager
|
||||
import android.se.omapi.SEService
|
||||
import android.util.Log
|
||||
import im.angry.openeuicc.common.R
|
||||
import im.angry.openeuicc.core.usb.UsbApduInterface
|
||||
import im.angry.openeuicc.core.usb.getIoEndpoints
|
||||
import im.angry.openeuicc.core.usb.UsbCcidContext
|
||||
import im.angry.openeuicc.util.*
|
||||
import java.lang.IllegalArgumentException
|
||||
|
||||
open class DefaultEuiccChannelFactory(protected val context: Context) : EuiccChannelFactory {
|
||||
private var seService: SEService? = null
|
||||
|
||||
private val usbManager by lazy {
|
||||
context.getSystemService(Context.USB_SERVICE) as UsbManager
|
||||
}
|
||||
|
||||
private suspend fun ensureSEService() {
|
||||
if (seService == null || !seService!!.isConnected) {
|
||||
seService = connectSEService(context)
|
||||
}
|
||||
}
|
||||
|
||||
override suspend fun tryOpenEuiccChannel(port: UiccPortInfoCompat): EuiccChannel? {
|
||||
override suspend fun tryOpenEuiccChannel(
|
||||
port: UiccPortInfoCompat,
|
||||
isdrAid: ByteArray
|
||||
): EuiccChannel? {
|
||||
if (port.portIndex != 0) {
|
||||
Log.w(DefaultEuiccChannelManager.TAG, "OMAPI channel attempted on non-zero portId, this may or may not work.")
|
||||
Log.w(
|
||||
DefaultEuiccChannelManager.TAG,
|
||||
"OMAPI channel attempted on non-zero portId, this may or may not work."
|
||||
)
|
||||
}
|
||||
|
||||
ensureSEService()
|
||||
|
||||
Log.i(DefaultEuiccChannelManager.TAG, "Trying OMAPI for physical slot ${port.card.physicalSlotIndex}")
|
||||
Log.i(
|
||||
DefaultEuiccChannelManager.TAG,
|
||||
"Trying OMAPI for physical slot ${port.card.physicalSlotIndex}"
|
||||
)
|
||||
try {
|
||||
return EuiccChannel(port, OmapiApduInterface(seService!!, port))
|
||||
} catch (e: IllegalArgumentException) {
|
||||
return EuiccChannelImpl(
|
||||
context.getString(R.string.omapi),
|
||||
port,
|
||||
intrinsicChannelName = null,
|
||||
OmapiApduInterface(
|
||||
seService!!,
|
||||
port,
|
||||
context.preferenceRepository.verboseLoggingFlow
|
||||
),
|
||||
isdrAid,
|
||||
context.preferenceRepository.verboseLoggingFlow,
|
||||
context.preferenceRepository.ignoreTLSCertificateFlow,
|
||||
).also {
|
||||
Log.i(DefaultEuiccChannelManager.TAG, "Is OMAPI channel, setting MSS to 60")
|
||||
it.lpa.setEs10xMss(60)
|
||||
}
|
||||
} catch (_: IllegalArgumentException) {
|
||||
// Failed
|
||||
Log.w(
|
||||
DefaultEuiccChannelManager.TAG,
|
||||
"OMAPI APDU interface unavailable for physical slot ${port.card.physicalSlotIndex}."
|
||||
"OMAPI APDU interface unavailable for physical slot ${port.card.physicalSlotIndex} with ISD-R AID: ${isdrAid.encodeHex()}."
|
||||
)
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
override fun tryOpenUsbEuiccChannel(usbDevice: UsbDevice, usbInterface: UsbInterface): EuiccChannel? {
|
||||
val (bulkIn, bulkOut) = usbInterface.getIoEndpoints()
|
||||
if (bulkIn == null || bulkOut == null) return null
|
||||
val conn = usbManager.openDevice(usbDevice) ?: return null
|
||||
if (!conn.claimInterface(usbInterface, true)) return null
|
||||
return EuiccChannel(
|
||||
override fun tryOpenUsbEuiccChannel(
|
||||
ccidCtx: UsbCcidContext,
|
||||
isdrAid: ByteArray
|
||||
): EuiccChannel? {
|
||||
try {
|
||||
return EuiccChannelImpl(
|
||||
context.getString(R.string.usb),
|
||||
FakeUiccPortInfoCompat(FakeUiccCardInfoCompat(EuiccChannelManager.USB_CHANNEL_ID)),
|
||||
UsbApduInterface(conn, bulkIn, bulkOut)
|
||||
intrinsicChannelName = ccidCtx.productName,
|
||||
UsbApduInterface(
|
||||
ccidCtx
|
||||
),
|
||||
isdrAid,
|
||||
context.preferenceRepository.verboseLoggingFlow,
|
||||
context.preferenceRepository.ignoreTLSCertificateFlow,
|
||||
)
|
||||
} catch (_: IllegalArgumentException) {
|
||||
// Failed
|
||||
Log.w(
|
||||
DefaultEuiccChannelManager.TAG,
|
||||
"USB APDU interface unavailable for ISD-R AID: ${isdrAid.encodeHex()}."
|
||||
)
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
override fun cleanup() {
|
||||
|
|
|
@ -5,12 +5,18 @@ import android.hardware.usb.UsbDevice
|
|||
import android.hardware.usb.UsbManager
|
||||
import android.telephony.SubscriptionManager
|
||||
import android.util.Log
|
||||
import im.angry.openeuicc.core.usb.getSmartCardInterface
|
||||
import im.angry.openeuicc.core.usb.UsbCcidContext
|
||||
import im.angry.openeuicc.core.usb.smartCard
|
||||
import im.angry.openeuicc.core.usb.interfaces
|
||||
import im.angry.openeuicc.di.AppContainer
|
||||
import im.angry.openeuicc.util.*
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.runBlocking
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.first
|
||||
import kotlinx.coroutines.flow.flow
|
||||
import kotlinx.coroutines.flow.flowOn
|
||||
import kotlinx.coroutines.flow.merge
|
||||
import kotlinx.coroutines.sync.Mutex
|
||||
import kotlinx.coroutines.sync.withLock
|
||||
import kotlinx.coroutines.withContext
|
||||
|
@ -45,6 +51,24 @@ open class DefaultEuiccChannelManager(
|
|||
protected open val uiccCards: Collection<UiccCardInfoCompat>
|
||||
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? {
|
||||
lock.withLock {
|
||||
if (port.card.physicalSlotIndex == EuiccChannelManager.USB_CHANNEL_ID) {
|
||||
|
@ -72,9 +96,10 @@ open class DefaultEuiccChannelManager(
|
|||
return null
|
||||
}
|
||||
|
||||
val channel = euiccChannelFactory.tryOpenEuiccChannel(port) ?: return null
|
||||
val channel =
|
||||
tryOpenChannelFirstValidAid { euiccChannelFactory.tryOpenEuiccChannel(port, it) }
|
||||
|
||||
if (channel.valid) {
|
||||
if (channel != null) {
|
||||
channelCache.add(channel)
|
||||
return channel
|
||||
} else {
|
||||
|
@ -82,14 +107,12 @@ open class DefaultEuiccChannelManager(
|
|||
TAG,
|
||||
"Was able to open channel for logical slot ${port.logicalSlotIndex}, but the channel is invalid (cannot get eID or profiles without errors). This slot might be broken, aborting."
|
||||
)
|
||||
channel.close()
|
||||
return null
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun findEuiccChannelBySlotBlocking(logicalSlotId: Int): EuiccChannel? =
|
||||
runBlocking {
|
||||
protected suspend fun findEuiccChannelByLogicalSlot(logicalSlotId: Int): EuiccChannel? =
|
||||
withContext(Dispatchers.IO) {
|
||||
if (logicalSlotId == EuiccChannelManager.USB_CHANNEL_ID) {
|
||||
return@withContext usbChannel
|
||||
|
@ -105,27 +128,8 @@ open class DefaultEuiccChannelManager(
|
|||
|
||||
null
|
||||
}
|
||||
}
|
||||
|
||||
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>? {
|
||||
private suspend fun findAllEuiccChannelsByPhysicalSlot(physicalSlotId: Int): List<EuiccChannel>? {
|
||||
if (physicalSlotId == EuiccChannelManager.USB_CHANNEL_ID) {
|
||||
return usbChannel?.let { listOf(it) }
|
||||
}
|
||||
|
@ -138,12 +142,7 @@ open class DefaultEuiccChannelManager(
|
|||
return null
|
||||
}
|
||||
|
||||
override fun findAllEuiccChannelsByPhysicalSlotBlocking(physicalSlotId: Int): List<EuiccChannel>? =
|
||||
runBlocking {
|
||||
findAllEuiccChannelsByPhysicalSlot(physicalSlotId)
|
||||
}
|
||||
|
||||
override suspend fun findEuiccChannelByPort(physicalSlotId: Int, portId: Int): EuiccChannel? =
|
||||
private suspend fun findEuiccChannelByPort(physicalSlotId: Int, portId: Int): EuiccChannel? =
|
||||
withContext(Dispatchers.IO) {
|
||||
if (physicalSlotId == EuiccChannelManager.USB_CHANNEL_ID) {
|
||||
return@withContext usbChannel
|
||||
|
@ -154,72 +153,155 @@ open class DefaultEuiccChannelManager(
|
|||
}
|
||||
}
|
||||
|
||||
override fun findEuiccChannelByPortBlocking(physicalSlotId: Int, portId: Int): EuiccChannel? =
|
||||
runBlocking {
|
||||
findEuiccChannelByPort(physicalSlotId, portId)
|
||||
override suspend fun findFirstAvailablePort(physicalSlotId: Int): Int =
|
||||
withContext(Dispatchers.IO) {
|
||||
if (physicalSlotId == EuiccChannelManager.USB_CHANNEL_ID) {
|
||||
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) {
|
||||
if (physicalSlotId == EuiccChannelManager.USB_CHANNEL_ID) return
|
||||
|
||||
if (physicalSlotId == EuiccChannelManager.USB_CHANNEL_ID) {
|
||||
usbChannel?.close()
|
||||
usbChannel = null
|
||||
} else {
|
||||
// If there is already a valid channel, we close it proactively
|
||||
// Sometimes the current channel can linger on for a bit even after it should have become invalid
|
||||
channelCache.find { it.slotId == physicalSlotId && it.portId == portId }?.apply {
|
||||
if (valid) close()
|
||||
}
|
||||
}
|
||||
|
||||
withTimeout(timeoutMillis) {
|
||||
while (true) {
|
||||
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
|
||||
// and recreate when needed
|
||||
val channel = findEuiccChannelByPortBlocking(physicalSlotId, portId)!!
|
||||
findEuiccChannelByPort(physicalSlotId, portId)!!
|
||||
}
|
||||
check(channel.valid) { "Invalid channel" }
|
||||
break
|
||||
} catch (e: Exception) {
|
||||
Log.d(TAG, "Slot $physicalSlotId port $portId reconnect failure, retrying in 1000 ms")
|
||||
Log.d(
|
||||
TAG,
|
||||
"Slot $physicalSlotId port $portId reconnect failure, retrying in 1000 ms"
|
||||
)
|
||||
}
|
||||
delay(1000)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override suspend fun enumerateEuiccChannels(): List<EuiccChannel> =
|
||||
withContext(Dispatchers.IO) {
|
||||
uiccCards.flatMap { info ->
|
||||
info.ports.mapNotNull { port ->
|
||||
override fun flowInternalEuiccPorts(): Flow<Pair<Int, Int>> = flow {
|
||||
uiccCards.forEach { info ->
|
||||
info.ports.forEach { port ->
|
||||
tryOpenEuiccChannel(port)?.also {
|
||||
Log.d(
|
||||
TAG,
|
||||
"Found eUICC on slot ${info.physicalSlotIndex} port ${port.portIndex}"
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override suspend fun enumerateUsbEuiccChannel(): Pair<UsbDevice?, EuiccChannel?> =
|
||||
emit(Pair(info.physicalSlotIndex, port.portIndex))
|
||||
}
|
||||
}
|
||||
}
|
||||
}.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) {
|
||||
usbManager.deviceList.values.forEach { device ->
|
||||
Log.i(TAG, "Scanning USB device ${device.deviceId}:${device.vendorId}")
|
||||
val iface = device.getSmartCardInterface() ?: return@forEach
|
||||
val iface = device.interfaces.smartCard ?: return@forEach
|
||||
// If we don't have permission, tell UI code that we found a candidate device, but we
|
||||
// need permission to be able to do anything with it
|
||||
if (!usbManager.hasPermission(device)) return@withContext Pair(device, null)
|
||||
Log.i(TAG, "Found CCID interface on ${device.deviceId}:${device.vendorId}, and has permission; trying to open channel")
|
||||
if (!usbManager.hasPermission(device)) return@withContext Pair(device, false)
|
||||
Log.i(
|
||||
TAG,
|
||||
"Found CCID interface on ${device.deviceId}:${device.vendorId}, and has permission; trying to open channel"
|
||||
)
|
||||
|
||||
val ccidCtx = UsbCcidContext.createFromUsbDevice(context, device, iface) ?: return@forEach
|
||||
|
||||
try {
|
||||
val channel = euiccChannelFactory.tryOpenUsbEuiccChannel(device, iface)
|
||||
val channel = tryOpenChannelFirstValidAid {
|
||||
euiccChannelFactory.tryOpenUsbEuiccChannel(ccidCtx, it)
|
||||
}
|
||||
if (channel != null && channel.lpa.valid) {
|
||||
ccidCtx.allowDisconnect = true
|
||||
usbChannel = channel
|
||||
return@withContext Pair(device, channel)
|
||||
return@withContext Pair(device, true)
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
// Ignored -- skip forward
|
||||
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, null)
|
||||
return@withContext Pair(null, false)
|
||||
}
|
||||
|
||||
override fun invalidate() {
|
||||
|
|
|
@ -3,21 +3,41 @@ package im.angry.openeuicc.core
|
|||
import im.angry.openeuicc.util.*
|
||||
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 EuiccChannel(
|
||||
val port: UiccPortInfoCompat,
|
||||
apduInterface: ApduInterface,
|
||||
) {
|
||||
val slotId = port.card.physicalSlotIndex // PHYSICAL slot
|
||||
val logicalSlotId = port.logicalSlotIndex
|
||||
val portId = port.portIndex
|
||||
interface EuiccChannel {
|
||||
val type: String
|
||||
|
||||
val lpa: LocalProfileAssistant = LocalProfileAssistantImpl(apduInterface, HttpInterfaceImpl())
|
||||
val port: UiccPortInfoCompat
|
||||
|
||||
val slotId: Int // PHYSICAL slot
|
||||
val logicalSlotId: Int
|
||||
val portId: Int
|
||||
|
||||
val lpa: LocalProfileAssistant
|
||||
|
||||
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,15 +1,17 @@
|
|||
package im.angry.openeuicc.core
|
||||
|
||||
import android.hardware.usb.UsbDevice
|
||||
import android.hardware.usb.UsbInterface
|
||||
import im.angry.openeuicc.core.usb.UsbCcidContext
|
||||
import im.angry.openeuicc.util.*
|
||||
|
||||
// This class is here instead of inside DI because it contains a bit more logic than just
|
||||
// "dumb" dependency injection.
|
||||
interface EuiccChannelFactory {
|
||||
suspend fun tryOpenEuiccChannel(port: UiccPortInfoCompat): EuiccChannel?
|
||||
suspend fun tryOpenEuiccChannel(port: UiccPortInfoCompat, isdrAid: ByteArray): EuiccChannel?
|
||||
|
||||
fun tryOpenUsbEuiccChannel(usbDevice: UsbDevice, usbInterface: UsbInterface): EuiccChannel?
|
||||
fun tryOpenUsbEuiccChannel(
|
||||
ccidCtx: UsbCcidContext,
|
||||
isdrAid: ByteArray
|
||||
): EuiccChannel?
|
||||
|
||||
/**
|
||||
* Release all resources used by this EuiccChannelFactory
|
||||
|
|
|
@ -0,0 +1,38 @@
|
|||
package im.angry.openeuicc.core
|
||||
|
||||
import im.angry.openeuicc.util.UiccPortInfoCompat
|
||||
import im.angry.openeuicc.util.decodeHex
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
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>
|
||||
) : 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)
|
||||
)
|
||||
|
||||
override val atr: ByteArray?
|
||||
get() = (apduInterface as? ApduInterfaceAtrProvider)?.atr
|
||||
|
||||
override val valid: Boolean
|
||||
get() = lpa.valid
|
||||
|
||||
override fun close() = lpa.close()
|
||||
}
|
|
@ -1,6 +1,7 @@
|
|||
package im.angry.openeuicc.core
|
||||
|
||||
import android.hardware.usb.UsbDevice
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
|
||||
/**
|
||||
* EuiccChannelManager holds references to, and manages the lifecycles of, individual
|
||||
|
@ -18,19 +19,35 @@ interface EuiccChannelManager {
|
|||
}
|
||||
|
||||
/**
|
||||
* Scan all possible _device internal_ sources for EuiccChannels, return them and have all
|
||||
* scanned channels cached; these channels will remain open for the entire lifetime of
|
||||
* this EuiccChannelManager object, unless disconnected externally or invalidate()'d
|
||||
* Scan all possible _device internal_ sources for EuiccChannels, as a flow, return their physical
|
||||
* (slotId, portId) and have all scanned channels cached; these channels will remain open
|
||||
* for the entire lifetime of this EuiccChannelManager object, unless disconnected externally
|
||||
* or invalidate()'d.
|
||||
*
|
||||
* To obtain a temporary reference to a EuiccChannel, use `withEuiccChannel()`.
|
||||
*/
|
||||
suspend fun enumerateEuiccChannels(): List<EuiccChannel>
|
||||
fun flowInternalEuiccPorts(): Flow<Pair<Int, Int>>
|
||||
|
||||
/**
|
||||
* 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.
|
||||
* 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
|
||||
* to interact with the device, the second return value (EuiccChannel) will be null.
|
||||
* to interact with the device, the second return value will be false.
|
||||
*
|
||||
* 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 enumerateUsbEuiccChannel(): Pair<UsbDevice?, EuiccChannel?>
|
||||
suspend fun tryOpenUsbEuiccChannel(): Pair<UsbDevice?, Boolean>
|
||||
|
||||
/**
|
||||
* Wait for a slot + port to reconnect (i.e. become valid again)
|
||||
|
@ -40,29 +57,40 @@ interface EuiccChannelManager {
|
|||
suspend fun waitForReconnect(physicalSlotId: Int, portId: Int, timeoutMillis: Long = 1000)
|
||||
|
||||
/**
|
||||
* Returns the EuiccChannel corresponding to a **logical** slot
|
||||
* Returns the first mapped & available port ID for a physical slot, or -1 if
|
||||
* not found.
|
||||
*/
|
||||
fun findEuiccChannelBySlotBlocking(logicalSlotId: Int): EuiccChannel?
|
||||
suspend fun findFirstAvailablePort(physicalSlotId: Int): Int
|
||||
|
||||
/**
|
||||
* 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.
|
||||
* Returns all mapped & available port IDs for a physical slot.
|
||||
*/
|
||||
fun findEuiccChannelByPhysicalSlotBlocking(physicalSlotId: Int): EuiccChannel?
|
||||
suspend fun findAvailablePorts(physicalSlotId: Int): List<Int>
|
||||
|
||||
class EuiccChannelNotFoundException: Exception("EuiccChannel not found")
|
||||
|
||||
/**
|
||||
* Returns all EuiccChannels corresponding to a **physical** slot
|
||||
* Multiple channels are possible in the case of MEP
|
||||
* Find a EuiccChannel by its slot and port, then run a callback with a reference to it.
|
||||
* The reference is not supposed to be held outside of the callback. This is enforced via
|
||||
* 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 findAllEuiccChannelsByPhysicalSlot(physicalSlotId: Int): List<EuiccChannel>?
|
||||
fun findAllEuiccChannelsByPhysicalSlotBlocking(physicalSlotId: Int): List<EuiccChannel>?
|
||||
suspend fun <R> withEuiccChannel(
|
||||
physicalSlotId: Int,
|
||||
portId: Int,
|
||||
fn: suspend (EuiccChannel) -> R
|
||||
): R
|
||||
|
||||
/**
|
||||
* Returns the EuiccChannel corresponding to a **physical** slot and a port ID
|
||||
* Same as withEuiccChannel(Int, Int, (EuiccChannel) -> R) but instead uses logical slot ID
|
||||
*/
|
||||
suspend fun findEuiccChannelByPort(physicalSlotId: Int, portId: Int): EuiccChannel?
|
||||
fun findEuiccChannelByPortBlocking(physicalSlotId: Int, portId: Int): EuiccChannel?
|
||||
suspend fun <R> withEuiccChannel(
|
||||
logicalSlotId: Int,
|
||||
fn: suspend (EuiccChannel) -> R
|
||||
): R
|
||||
|
||||
/**
|
||||
* Invalidate all EuiccChannels previously cached by this Manager
|
||||
|
@ -74,7 +102,7 @@ interface EuiccChannelManager {
|
|||
* This is only expected to be implemented when the application is privileged
|
||||
* TODO: Remove this from the common interface
|
||||
*/
|
||||
fun notifyEuiccProfilesChanged(logicalSlotId: Int) {
|
||||
suspend fun notifyEuiccProfilesChanged(logicalSlotId: Int) {
|
||||
// no-op by default
|
||||
}
|
||||
}
|
|
@ -0,0 +1,53 @@
|
|||
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()
|
||||
}
|
||||
}
|
||||
}
|
|
@ -0,0 +1,66 @@
|
|||
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,19 +3,33 @@ package im.angry.openeuicc.core
|
|||
import android.se.omapi.Channel
|
||||
import android.se.omapi.SEService
|
||||
import android.se.omapi.Session
|
||||
import android.util.Log
|
||||
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 java.util.concurrent.atomic.AtomicInteger
|
||||
|
||||
class OmapiApduInterface(
|
||||
private val service: SEService,
|
||||
private val port: UiccPortInfoCompat
|
||||
): ApduInterface {
|
||||
private val port: UiccPortInfoCompat,
|
||||
private val verboseLoggingFlow: Flow<Boolean>
|
||||
): ApduInterface, ApduInterfaceAtrProvider {
|
||||
companion object {
|
||||
const val TAG = "OmapiApduInterface"
|
||||
}
|
||||
|
||||
private lateinit var session: Session
|
||||
private lateinit var lastChannel: Channel
|
||||
private val index = AtomicInteger(0)
|
||||
private val channels = mutableMapOf<Int, Channel>()
|
||||
|
||||
override val valid: Boolean
|
||||
get() = service.isConnected && (this::session.isInitialized && !session.isClosed)
|
||||
|
||||
override val atr: ByteArray?
|
||||
get() = session.atr
|
||||
|
||||
override fun connect() {
|
||||
session = service.getUiccReaderCompat(port.logicalSlotIndex + 1).openSession()
|
||||
}
|
||||
|
@ -25,26 +39,48 @@ class OmapiApduInterface(
|
|||
}
|
||||
|
||||
override fun logicalChannelOpen(aid: ByteArray): Int {
|
||||
check(!this::lastChannel.isInitialized) {
|
||||
"Can only open one channel"
|
||||
}
|
||||
lastChannel = session.openLogicalChannel(aid)!!;
|
||||
return 1;
|
||||
val channel = session.openLogicalChannel(aid)
|
||||
check(channel != null) { "Failed to open logical channel (${aid.encodeHex()})" }
|
||||
val handle = index.incrementAndGet()
|
||||
synchronized(channels) { channels[handle] = channel }
|
||||
return handle
|
||||
}
|
||||
|
||||
override fun logicalChannelClose(handle: Int) {
|
||||
check(handle == 1 && !this::lastChannel.isInitialized) {
|
||||
"Unknown channel"
|
||||
}
|
||||
lastChannel.close()
|
||||
val channel = channels[handle]
|
||||
check(channel != null) { "Invalid logical channel handle $handle" }
|
||||
if (channel.isOpen) channel.close()
|
||||
synchronized(channels) { channels.remove(handle) }
|
||||
}
|
||||
|
||||
override fun transmit(tx: ByteArray): ByteArray {
|
||||
check(this::lastChannel.isInitialized) {
|
||||
"Unknown channel"
|
||||
override fun transmit(handle: Int, tx: ByteArray): ByteArray {
|
||||
val channel = channels[handle]
|
||||
check(channel != null) { "Invalid logical channel handle $handle" }
|
||||
|
||||
if (runBlocking { verboseLoggingFlow.first() }) {
|
||||
Log.d(TAG, "OMAPI APDU: ${tx.encodeHex()}")
|
||||
}
|
||||
|
||||
return lastChannel.transmit(tx)
|
||||
try {
|
||||
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,49 +1,41 @@
|
|||
package im.angry.openeuicc.core.usb
|
||||
|
||||
import android.hardware.usb.UsbDeviceConnection
|
||||
import android.hardware.usb.UsbEndpoint
|
||||
import android.util.Log
|
||||
import im.angry.openeuicc.core.ApduInterfaceAtrProvider
|
||||
import im.angry.openeuicc.util.*
|
||||
import net.typeblog.lpac_jni.ApduInterface
|
||||
|
||||
class UsbApduInterface(
|
||||
private val conn: UsbDeviceConnection,
|
||||
private val bulkIn: UsbEndpoint,
|
||||
private val bulkOut: UsbEndpoint
|
||||
): ApduInterface {
|
||||
private val ccidCtx: UsbCcidContext
|
||||
) : ApduInterface, ApduInterfaceAtrProvider {
|
||||
companion object {
|
||||
private const val TAG = "UsbApduInterface"
|
||||
}
|
||||
|
||||
private lateinit var ccidDescription: UsbCcidDescription
|
||||
private lateinit var transceiver: UsbCcidTransceiver
|
||||
override val atr: ByteArray?
|
||||
get() = ccidCtx.atr
|
||||
|
||||
private var channelId = -1
|
||||
override val valid: Boolean
|
||||
get() = channels.isNotEmpty()
|
||||
|
||||
private var channels = mutableSetOf<Int>()
|
||||
|
||||
override fun connect() {
|
||||
ccidDescription = UsbCcidDescription.fromRawDescriptors(conn.rawDescriptors)!!
|
||||
ccidCtx.connect()
|
||||
|
||||
if (!ccidDescription.hasT0Protocol) {
|
||||
throw IllegalArgumentException("Unsupported card reader; T=0 support is required")
|
||||
// Send Terminal Capabilities
|
||||
// Specs: ETSI TS 102 221 v15.0.0 - 11.1.19 TERMINAL CAPABILITY
|
||||
val terminalCapabilities = buildCmd(
|
||||
0x80.toByte(), 0xaa.toByte(), 0x00, 0x00,
|
||||
"A9088100820101830107".decodeHex(),
|
||||
le = null,
|
||||
)
|
||||
transmitApduByChannel(terminalCapabilities, 0)
|
||||
}
|
||||
|
||||
transceiver = UsbCcidTransceiver(conn, bulkIn, bulkOut, ccidDescription)
|
||||
|
||||
try {
|
||||
transceiver.iccPowerOn()
|
||||
} catch (e: Exception) {
|
||||
e.printStackTrace()
|
||||
throw e
|
||||
}
|
||||
}
|
||||
|
||||
override fun disconnect() {
|
||||
conn.close()
|
||||
}
|
||||
override fun disconnect() = ccidCtx.disconnect()
|
||||
|
||||
override fun logicalChannelOpen(aid: ByteArray): Int {
|
||||
check(channelId == -1) { "Logical channel already opened" }
|
||||
|
||||
// OPEN LOGICAL CHANNEL
|
||||
val req = manageChannelCmd(true, 0)
|
||||
|
||||
|
@ -59,7 +51,7 @@ class UsbApduInterface(
|
|||
return -1
|
||||
}
|
||||
|
||||
channelId = resp[0].toInt()
|
||||
val channelId = resp[0].toInt()
|
||||
Log.d(TAG, "channelId = $channelId")
|
||||
|
||||
// Then, select AID
|
||||
|
@ -71,31 +63,31 @@ class UsbApduInterface(
|
|||
return -1
|
||||
}
|
||||
|
||||
channels.add(channelId)
|
||||
|
||||
return channelId
|
||||
}
|
||||
|
||||
override fun logicalChannelClose(handle: Int) {
|
||||
check(handle == channelId) { "Logical channel ID mismatch" }
|
||||
check(channelId != -1) { "Logical channel is not opened" }
|
||||
|
||||
check(channels.contains(handle)) {
|
||||
"Invalid logical channel handle $handle"
|
||||
}
|
||||
// CLOSE LOGICAL CHANNEL
|
||||
val req = manageChannelCmd(false, channelId.toByte())
|
||||
val resp = transmitApduByChannel(req, channelId.toByte())
|
||||
val req = manageChannelCmd(false, handle.toByte())
|
||||
val resp = transmitApduByChannel(req, handle.toByte())
|
||||
|
||||
if (!isSuccessResponse(resp)) {
|
||||
Log.d(TAG, "CLOSE LOGICAL CHANNEL failed: ${resp.encodeHex()}")
|
||||
}
|
||||
|
||||
channelId = -1
|
||||
channels.remove(handle)
|
||||
}
|
||||
|
||||
override fun transmit(tx: ByteArray): ByteArray {
|
||||
check(channelId != -1) { "Logical channel is not opened" }
|
||||
return transmitApduByChannel(tx, channelId.toByte())
|
||||
override fun transmit(handle: Int, tx: ByteArray): ByteArray {
|
||||
check(channels.contains(handle)) {
|
||||
"Invalid logical channel handle $handle"
|
||||
}
|
||||
return transmitApduByChannel(tx, handle.toByte())
|
||||
}
|
||||
|
||||
override val valid: Boolean
|
||||
get() = channelId != -1
|
||||
|
||||
private fun isSuccessResponse(resp: ByteArray): Boolean =
|
||||
resp.size >= 2 && resp[resp.size - 2] == 0x90.toByte() && resp[resp.size - 1] == 0x00.toByte()
|
||||
|
@ -130,7 +122,7 @@ class UsbApduInterface(
|
|||
// OR the channel mask into the CLA byte
|
||||
realTx[0] = ((realTx[0].toInt() and 0xFC) or channel.toInt()).toByte()
|
||||
|
||||
var resp = transceiver.sendXfrBlock(realTx).data!!
|
||||
var resp = ccidCtx.transceiver.sendXfrBlock(realTx).data!!
|
||||
|
||||
if (resp.size < 2) throw RuntimeException("APDU response smaller than 2 (sw1 + sw2)!")
|
||||
|
||||
|
@ -141,7 +133,7 @@ class UsbApduInterface(
|
|||
// 0x6C = wrong le
|
||||
// so we fix the le field here
|
||||
realTx[realTx.size - 1] = resp[resp.size - 1]
|
||||
resp = transceiver.sendXfrBlock(realTx).data!!
|
||||
resp = ccidCtx.transceiver.sendXfrBlock(realTx).data!!
|
||||
} else if (sw1 == 0x61) {
|
||||
// 0x61 = X bytes available
|
||||
// continue reading by GET RESPONSE
|
||||
|
@ -151,7 +143,7 @@ class UsbApduInterface(
|
|||
realTx[0], 0xC0.toByte(), 0x00, 0x00, sw2.toByte()
|
||||
)
|
||||
|
||||
val tmp = transceiver.sendXfrBlock(getResponseCmd).data!!
|
||||
val tmp = ccidCtx.transceiver.sendXfrBlock(getResponseCmd).data!!
|
||||
|
||||
resp = resp.sliceArray(0 until (resp.size - 2)) + tmp
|
||||
|
||||
|
|
|
@ -0,0 +1,87 @@
|
|||
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_SHORT_APDU = 0x20000
|
||||
private const val FEATURE_EXCHAGE_LEVEL_EXTENDED_APDU = 0x40000
|
||||
private const val FEATURE_EXCHANGE_LEVEL_EXTENDED_APDU = 0x40000
|
||||
|
||||
// bVoltageSupport Masks
|
||||
private const val VOLTAGE_5V: Byte = 1
|
||||
private const val VOLTAGE_3V: Byte = 2
|
||||
private const val VOLTAGE_1_8V: Byte = 4
|
||||
private const val VOLTAGE_5V0: Byte = 1
|
||||
private const val VOLTAGE_3V0: Byte = 2
|
||||
private const val VOLTAGE_1V8: Byte = 4
|
||||
|
||||
private const val SLOT_OFFSET = 4
|
||||
private const val FEATURES_OFFSET = 40
|
||||
|
@ -71,30 +71,23 @@ data class UsbCcidDescription(
|
|||
}
|
||||
|
||||
enum class Voltage(powerOnValue: Int, mask: Int) {
|
||||
AUTO(0, 0), _5V(1, VOLTAGE_5V.toInt()), _3V(2, VOLTAGE_3V.toInt()), _1_8V(
|
||||
3,
|
||||
VOLTAGE_1_8V.toInt()
|
||||
);
|
||||
// @formatter:off
|
||||
AUTO(0, 0),
|
||||
V50(1, VOLTAGE_5V0.toInt()),
|
||||
V30(2, VOLTAGE_3V0.toInt()),
|
||||
V18(3, VOLTAGE_1V8.toInt());
|
||||
// @formatter:on
|
||||
|
||||
val mask = powerOnValue.toByte()
|
||||
val powerOnValue = mask.toByte()
|
||||
}
|
||||
|
||||
private fun hasFeature(feature: Int): Boolean =
|
||||
(dwFeatures and feature) != 0
|
||||
private fun hasFeature(feature: Int) = (dwFeatures and feature) != 0
|
||||
|
||||
val voltages: Array<Voltage>
|
||||
get() =
|
||||
if (hasFeature(FEATURE_AUTOMATIC_VOLTAGE)) {
|
||||
arrayOf(Voltage.AUTO)
|
||||
} else {
|
||||
Voltage.values().mapNotNull {
|
||||
if ((it.mask.toInt() and bVoltageSupport.toInt()) != 0) {
|
||||
it
|
||||
} else {
|
||||
null
|
||||
}
|
||||
}.toTypedArray()
|
||||
val voltages: List<Voltage>
|
||||
get() {
|
||||
if (hasFeature(FEATURE_AUTOMATIC_VOLTAGE)) return listOf(Voltage.AUTO)
|
||||
return Voltage.entries.filter { (it.mask.toInt() and bVoltageSupport.toInt()) != 0 }
|
||||
}
|
||||
|
||||
val hasAutomaticPps: Boolean
|
||||
|
|
|
@ -5,6 +5,9 @@ import android.hardware.usb.UsbEndpoint
|
|||
import android.os.SystemClock
|
||||
import android.util.Log
|
||||
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.ByteOrder
|
||||
|
||||
|
@ -18,7 +21,8 @@ class UsbCcidTransceiver(
|
|||
private val usbConnection: UsbDeviceConnection,
|
||||
private val usbBulkIn: UsbEndpoint,
|
||||
private val usbBulkOut: UsbEndpoint,
|
||||
private val usbCcidDescription: UsbCcidDescription
|
||||
private val usbCcidDescription: UsbCcidDescription,
|
||||
private val verboseLoggingFlow: Flow<Boolean>
|
||||
) {
|
||||
companion object {
|
||||
private const val TAG = "UsbCcidTransceiver"
|
||||
|
@ -91,6 +95,7 @@ class UsbCcidTransceiver(
|
|||
data class UsbCcidErrorException(val msg: String, val errorResponse: CcidDataBlock) :
|
||||
Exception(msg)
|
||||
|
||||
@Suppress("ArrayInDataClass")
|
||||
data class CcidDataBlock(
|
||||
val dwLength: Int,
|
||||
val bSlot: Byte,
|
||||
|
@ -178,30 +183,27 @@ class UsbCcidTransceiver(
|
|||
readBytes = usbConnection.bulkTransfer(
|
||||
usbBulkIn, inputBuffer, inputBuffer.size, DEVICE_COMMUNICATE_TIMEOUT_MILLIS
|
||||
)
|
||||
Log.d(TAG, "Received " + readBytes + " bytes: " + inputBuffer.encodeHex())
|
||||
if (runBlocking { verboseLoggingFlow.first() }) {
|
||||
Log.d(TAG, "Received $readBytes bytes: ${inputBuffer.encodeHex()}")
|
||||
}
|
||||
} while (readBytes <= 0 && attempts-- > 0)
|
||||
if (readBytes < CCID_HEADER_LENGTH) {
|
||||
throw UsbTransportException("USB-CCID error - failed to receive CCID header")
|
||||
}
|
||||
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]) {
|
||||
throw UsbTransportException(
|
||||
((("USB-CCID error - bad CCID header, type " + inputBuffer[0]) + " (expected " +
|
||||
MESSAGE_TYPE_RDR_TO_PC_DATA_BLOCK) + "), sequence number " + inputBuffer[6]
|
||||
) + " (expected " +
|
||||
expectedSequenceNumber + ")"
|
||||
)
|
||||
append(", sequence number ")
|
||||
append("%d (expected %d)".format(inputBuffer[6], expectedSequenceNumber))
|
||||
}
|
||||
throw UsbTransportException(
|
||||
"USB-CCID error - bad CCID header type " + inputBuffer[0]
|
||||
)
|
||||
})
|
||||
}
|
||||
var result = CcidDataBlock.parseHeaderFromBytes(inputBuffer)
|
||||
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)
|
||||
|
@ -212,9 +214,7 @@ class UsbCcidTransceiver(
|
|||
usbBulkIn, inputBuffer, inputBuffer.size, DEVICE_COMMUNICATE_TIMEOUT_MILLIS
|
||||
)
|
||||
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)
|
||||
bufferedBytes += readBytes
|
||||
|
@ -279,7 +279,7 @@ class UsbCcidTransceiver(
|
|||
}
|
||||
val ccidDataBlock = receiveDataBlock(sequenceNumber)
|
||||
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
|
||||
}
|
||||
|
||||
|
@ -287,13 +287,13 @@ class UsbCcidTransceiver(
|
|||
val startTime = SystemClock.elapsedRealtime()
|
||||
skipAvailableInput()
|
||||
var response: CcidDataBlock? = null
|
||||
for (v in usbCcidDescription.voltages) {
|
||||
Log.v(TAG, "CCID: attempting to power on with voltage $v")
|
||||
for (voltage in usbCcidDescription.voltages) {
|
||||
Log.v(TAG, "CCID: attempting to power on with voltage $voltage")
|
||||
response = try {
|
||||
iccPowerOnVoltage(v.powerOnValue)
|
||||
iccPowerOnVoltage(voltage.powerOnValue)
|
||||
} catch (e: UsbCcidErrorException) {
|
||||
if (e.errorResponse.bError.toInt() == 7) { // Power select error
|
||||
Log.v(TAG, "CCID: failed to power on with voltage $v")
|
||||
Log.v(TAG, "CCID: failed to power on with voltage $voltage")
|
||||
iccPowerOff()
|
||||
Log.v(TAG, "CCID: powered off")
|
||||
continue
|
||||
|
@ -308,8 +308,11 @@ class UsbCcidTransceiver(
|
|||
val elapsedTime = SystemClock.elapsedRealtime() - startTime
|
||||
Log.d(
|
||||
TAG,
|
||||
"Usb transport connected, took " + elapsedTime + "ms, ATR=" +
|
||||
response.data?.encodeHex()
|
||||
buildString {
|
||||
append("Usb transport connected")
|
||||
append(", took ", elapsedTime, "ms")
|
||||
append(", ATR=", response.data?.encodeHex())
|
||||
}
|
||||
)
|
||||
return response
|
||||
}
|
||||
|
|
|
@ -6,31 +6,22 @@ import android.hardware.usb.UsbDevice
|
|||
import android.hardware.usb.UsbEndpoint
|
||||
import android.hardware.usb.UsbInterface
|
||||
|
||||
class UsbTransportException(msg: String) : Exception(msg)
|
||||
class UsbTransportException(message: String) : Exception(message)
|
||||
|
||||
fun UsbInterface.getIoEndpoints(): Pair<UsbEndpoint?, UsbEndpoint?> {
|
||||
var bulkIn: UsbEndpoint? = null
|
||||
var bulkOut: UsbEndpoint? = null
|
||||
for (i in 0 until endpointCount) {
|
||||
val endpoint = getEndpoint(i)
|
||||
if (endpoint.type != UsbConstants.USB_ENDPOINT_XFER_BULK) {
|
||||
continue
|
||||
}
|
||||
if (endpoint.direction == UsbConstants.USB_DIR_IN) {
|
||||
bulkIn = endpoint
|
||||
} else if (endpoint.direction == UsbConstants.USB_DIR_OUT) {
|
||||
bulkOut = endpoint
|
||||
}
|
||||
}
|
||||
return Pair(bulkIn, bulkOut)
|
||||
}
|
||||
val UsbDevice.interfaces: Iterable<UsbInterface>
|
||||
get() = (0 until interfaceCount).map(::getInterface)
|
||||
|
||||
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
|
||||
val Iterable<UsbInterface>.smartCard: UsbInterface?
|
||||
get() = find { it.interfaceClass == UsbConstants.USB_CLASS_CSCID }
|
||||
|
||||
val UsbInterface.endpoints: Iterable<UsbEndpoint>
|
||||
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 },
|
||||
)
|
||||
}
|
|
@ -15,4 +15,5 @@ interface AppContainer {
|
|||
val preferenceRepository: PreferenceRepository
|
||||
val uiComponentFactory: UiComponentFactory
|
||||
val euiccChannelFactory: EuiccChannelFactory
|
||||
val customizableTextProvider: CustomizableTextProvider
|
||||
}
|
|
@ -0,0 +1,20 @@
|
|||
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,4 +38,8 @@ open class DefaultAppContainer(context: Context) : AppContainer {
|
|||
override val euiccChannelFactory by lazy {
|
||||
DefaultEuiccChannelFactory(context)
|
||||
}
|
||||
|
||||
override val customizableTextProvider by lazy {
|
||||
DefaultCustomizableTextProvider(context)
|
||||
}
|
||||
}
|
|
@ -0,0 +1,15 @@
|
|||
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.enable_disable_timeout)
|
||||
|
||||
override fun formatInternalChannelName(logicalSlotId: Int): String =
|
||||
context.getString(R.string.channel_name_format, logicalSlotId)
|
||||
}
|
|
@ -1,13 +1,16 @@
|
|||
package im.angry.openeuicc.di
|
||||
|
||||
import androidx.fragment.app.Fragment
|
||||
import im.angry.openeuicc.core.EuiccChannel
|
||||
import androidx.preference.PreferenceFragmentCompat
|
||||
import im.angry.openeuicc.ui.EuiccManagementFragment
|
||||
import im.angry.openeuicc.ui.NoEuiccPlaceholderFragment
|
||||
import im.angry.openeuicc.ui.SettingsFragment
|
||||
|
||||
open class DefaultUiComponentFactory : UiComponentFactory {
|
||||
override fun createEuiccManagementFragment(channel: EuiccChannel): EuiccManagementFragment =
|
||||
EuiccManagementFragment.newInstance(channel.slotId, channel.portId)
|
||||
override fun createEuiccManagementFragment(slotId: Int, portId: Int): EuiccManagementFragment =
|
||||
EuiccManagementFragment.newInstance(slotId, portId)
|
||||
|
||||
override fun createNoEuiccPlaceholderFragment(): Fragment = NoEuiccPlaceholderFragment()
|
||||
|
||||
override fun createSettingsFragment(): Fragment = SettingsFragment()
|
||||
}
|
|
@ -1,10 +1,11 @@
|
|||
package im.angry.openeuicc.di
|
||||
|
||||
import androidx.fragment.app.Fragment
|
||||
import im.angry.openeuicc.core.EuiccChannel
|
||||
import androidx.preference.PreferenceFragmentCompat
|
||||
import im.angry.openeuicc.ui.EuiccManagementFragment
|
||||
|
||||
interface UiComponentFactory {
|
||||
fun createEuiccManagementFragment(channel: EuiccChannel): EuiccManagementFragment
|
||||
fun createEuiccManagementFragment(slotId: Int, portId: Int): EuiccManagementFragment
|
||||
fun createNoEuiccPlaceholderFragment(): Fragment
|
||||
fun createSettingsFragment(): Fragment
|
||||
}
|
|
@ -1,11 +1,42 @@
|
|||
package im.angry.openeuicc.service
|
||||
|
||||
import android.app.Service
|
||||
import android.content.Intent
|
||||
import android.content.pm.PackageManager
|
||||
import android.os.Binder
|
||||
import android.os.IBinder
|
||||
import android.os.PowerManager
|
||||
import android.util.Log
|
||||
import androidx.core.app.NotificationChannelCompat
|
||||
import androidx.core.app.NotificationCompat
|
||||
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.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.
|
||||
|
@ -17,8 +48,41 @@ import im.angry.openeuicc.util.*
|
|||
* 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
|
||||
* 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 : Service(), OpenEuiccContextMarker {
|
||||
class EuiccChannelManagerService : LifecycleService(), 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() {
|
||||
val service = this@EuiccChannelManagerService
|
||||
}
|
||||
|
@ -28,14 +92,436 @@ class EuiccChannelManagerService : Service(), OpenEuiccContextMarker {
|
|||
}
|
||||
val euiccChannelManager: EuiccChannelManager by euiccChannelManagerDelegate
|
||||
|
||||
override fun onBind(intent: Intent?): IBinder = LocalBinder()
|
||||
private val wakeLock: PowerManager.WakeLock by lazy {
|
||||
(getSystemService(POWER_SERVICE) as PowerManager).run {
|
||||
newWakeLock(PowerManager.PARTIAL_WAKE_LOCK, this::class.simpleName)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The state of a "foreground" task (named so due to the need to startForeground())
|
||||
*/
|
||||
sealed interface ForegroundTaskState {
|
||||
data object Idle : ForegroundTaskState
|
||||
data class InProgress(val progress: Int) : 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() {
|
||||
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()) {
|
||||
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,14 +9,18 @@ import android.os.IBinder
|
|||
import androidx.appcompat.app.AppCompatActivity
|
||||
import im.angry.openeuicc.core.EuiccChannelManager
|
||||
import im.angry.openeuicc.service.EuiccChannelManagerService
|
||||
import kotlinx.coroutines.CompletableDeferred
|
||||
|
||||
abstract class BaseEuiccAccessActivity : AppCompatActivity() {
|
||||
val euiccChannelManagerLoaded = CompletableDeferred<Unit>()
|
||||
lateinit var euiccChannelManager: EuiccChannelManager
|
||||
lateinit var euiccChannelManagerService: EuiccChannelManagerService
|
||||
|
||||
private val euiccChannelManagerServiceConnection = object : ServiceConnection {
|
||||
override fun onServiceConnected(name: ComponentName?, service: IBinder?) {
|
||||
euiccChannelManager =
|
||||
(service!! as EuiccChannelManagerService.LocalBinder).service.euiccChannelManager
|
||||
euiccChannelManagerService = (service!! as EuiccChannelManagerService.LocalBinder).service
|
||||
euiccChannelManager = euiccChannelManagerService.euiccChannelManager
|
||||
euiccChannelManagerLoaded.complete(Unit)
|
||||
onInit()
|
||||
}
|
||||
|
||||
|
|
|
@ -1,40 +0,0 @@
|
|||
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()
|
||||
}
|
|
@ -0,0 +1,195 @@
|
|||
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
|
||||
|
||||
class EuiccInfoActivity : BaseEuiccAccessActivity(), OpenEuiccContextMarker {
|
||||
companion object {
|
||||
private val YES_NO = Pair(R.string.yes, R.string.no)
|
||||
}
|
||||
|
||||
private lateinit var swipeRefresh: SwipeRefreshLayout
|
||||
private lateinit var infoList: RecyclerView
|
||||
|
||||
private var logicalSlotId: Int = -1
|
||||
|
||||
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.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_globalplatform_version, info?.globalPlatformVersion.toString()))
|
||||
add(Item(R.string.euicc_info_pp_version, info?.ppVersion.toString()))
|
||||
add(Item(R.string.euicc_info_sas_accreditation_number, info?.sasAccreditationNumber))
|
||||
add(Item(R.string.euicc_info_free_nvram, info?.freeNvram?.let(::formatFreeSpace)))
|
||||
}
|
||||
channel.lpa.euiccInfo2?.euiccCiPKIdListForSigning.orEmpty().let { signers ->
|
||||
// SGP.28 v1.0, eSIM CI Registration Criteria (Page 5 of 9, 2019-10-24)
|
||||
// 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.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.information_unavailable)
|
||||
add(Item(R.string.euicc_info_atr, atr, copiedToastResId = R.string.toast_atr_copied))
|
||||
}
|
||||
|
||||
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.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.ClipboardManager
|
||||
import android.content.Intent
|
||||
import android.os.Build
|
||||
import android.os.Bundle
|
||||
import android.text.method.PasswordTransformationMethod
|
||||
import android.util.Log
|
||||
import android.view.LayoutInflater
|
||||
import android.view.Menu
|
||||
import android.view.MenuInflater
|
||||
|
@ -19,6 +19,10 @@ import android.widget.PopupMenu
|
|||
import android.widget.TextView
|
||||
import android.widget.Toast
|
||||
import androidx.appcompat.app.AlertDialog
|
||||
import androidx.core.view.ViewCompat
|
||||
import androidx.core.view.WindowInsetsCompat
|
||||
import androidx.core.view.isVisible
|
||||
import androidx.core.view.updateLayoutParams
|
||||
import androidx.fragment.app.Fragment
|
||||
import androidx.lifecycle.lifecycleScope
|
||||
import androidx.recyclerview.widget.LinearLayoutManager
|
||||
|
@ -27,7 +31,9 @@ import androidx.swiperefreshlayout.widget.SwipeRefreshLayout
|
|||
import com.google.android.material.floatingactionbutton.FloatingActionButton
|
||||
import net.typeblog.lpac_jni.LocalProfileInfo
|
||||
import im.angry.openeuicc.common.R
|
||||
import im.angry.openeuicc.core.EuiccChannelManager
|
||||
import im.angry.openeuicc.service.EuiccChannelManagerService
|
||||
import im.angry.openeuicc.service.EuiccChannelManagerService.Companion.waitDone
|
||||
import im.angry.openeuicc.ui.wizard.DownloadWizardActivity
|
||||
import im.angry.openeuicc.util.*
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.TimeoutCancellationException
|
||||
|
@ -35,6 +41,7 @@ import kotlinx.coroutines.flow.StateFlow
|
|||
import kotlinx.coroutines.flow.first
|
||||
import kotlinx.coroutines.flow.stateIn
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.runBlocking
|
||||
import kotlinx.coroutines.withContext
|
||||
|
||||
open class EuiccManagementFragment : Fragment(), EuiccProfilesChangedListener,
|
||||
|
@ -49,6 +56,8 @@ open class EuiccManagementFragment : Fragment(), EuiccProfilesChangedListener,
|
|||
private lateinit var swipeRefresh: SwipeRefreshLayout
|
||||
private lateinit var fab: FloatingActionButton
|
||||
private lateinit var profileList: RecyclerView
|
||||
private var logicalSlotId: Int = -1
|
||||
private lateinit var eid: String
|
||||
|
||||
private val adapter = EuiccProfileAdapter()
|
||||
|
||||
|
@ -60,6 +69,8 @@ open class EuiccManagementFragment : Fragment(), EuiccProfilesChangedListener,
|
|||
// This gives us access to the "latest" state without having to launch coroutines
|
||||
private lateinit var disableSafeguardFlow: StateFlow<Boolean>
|
||||
|
||||
private lateinit var unfilteredProfileListFlow: StateFlow<Boolean>
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
setHasOptionsMenu(true)
|
||||
|
@ -76,6 +87,21 @@ open class EuiccManagementFragment : Fragment(), EuiccProfilesChangedListener,
|
|||
fab = view.requireViewById(R.id.fab)
|
||||
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
|
||||
}
|
||||
|
||||
|
@ -87,10 +113,15 @@ open class EuiccManagementFragment : Fragment(), EuiccProfilesChangedListener,
|
|||
LinearLayoutManager(view.context, LinearLayoutManager.VERTICAL, false)
|
||||
|
||||
fab.setOnClickListener {
|
||||
ProfileDownloadFragment.newInstance(slotId, portId)
|
||||
.show(childFragmentManager, ProfileDownloadFragment.TAG)
|
||||
Intent(requireContext(), DownloadWizardActivity::class.java).apply {
|
||||
putExtra("selectedLogicalSlot", logicalSlotId)
|
||||
startActivity(this)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun onStart() {
|
||||
super.onStart()
|
||||
refresh()
|
||||
}
|
||||
|
||||
|
@ -103,16 +134,39 @@ open class EuiccManagementFragment : Fragment(), EuiccProfilesChangedListener,
|
|||
inflater.inflate(R.menu.fragment_euicc, menu)
|
||||
}
|
||||
|
||||
override fun onOptionsItemSelected(item: MenuItem): Boolean =
|
||||
when (item.itemId) {
|
||||
override fun onPrepareOptionsMenu(menu: Menu) {
|
||||
super.onPrepareOptionsMenu(menu)
|
||||
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 -> {
|
||||
Intent(requireContext(), NotificationsActivity::class.java).apply {
|
||||
putExtra("logicalSlotId", channel.logicalSlotId)
|
||||
putExtra("logicalSlotId", logicalSlotId)
|
||||
startActivity(this)
|
||||
}
|
||||
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)
|
||||
}
|
||||
|
||||
|
@ -127,19 +181,36 @@ open class EuiccManagementFragment : Fragment(), EuiccProfilesChangedListener,
|
|||
listOf()
|
||||
}
|
||||
|
||||
@SuppressLint("NotifyDataSetChanged")
|
||||
private fun refresh() {
|
||||
if (invalid) return
|
||||
swipeRefresh.isRefreshing = true
|
||||
|
||||
lifecycleScope.launch {
|
||||
if (!this@EuiccManagementFragment::disableSafeguardFlow.isInitialized) {
|
||||
doRefresh()
|
||||
}
|
||||
}
|
||||
|
||||
@SuppressLint("NotifyDataSetChanged")
|
||||
protected open suspend fun doRefresh() {
|
||||
ensureEuiccChannelManager()
|
||||
euiccChannelManagerService.waitForForegroundTask()
|
||||
|
||||
if (!::disableSafeguardFlow.isInitialized) {
|
||||
disableSafeguardFlow =
|
||||
preferenceRepository.disableSafeguardFlow.stateIn(lifecycleScope)
|
||||
}
|
||||
if (!::unfilteredProfileListFlow.isInitialized) {
|
||||
unfilteredProfileListFlow =
|
||||
preferenceRepository.unfilteredProfileListFlow.stateIn(lifecycleScope)
|
||||
}
|
||||
|
||||
val profiles = withContext(Dispatchers.IO) {
|
||||
val profiles = withEuiccChannel { channel ->
|
||||
logicalSlotId = channel.logicalSlotId
|
||||
eid = channel.lpa.eID
|
||||
euiccChannelManager.notifyEuiccProfilesChanged(channel.logicalSlotId)
|
||||
if (unfilteredProfileListFlow.value)
|
||||
channel.lpa.profiles
|
||||
else
|
||||
channel.lpa.profiles.operational
|
||||
}
|
||||
|
||||
|
@ -150,6 +221,13 @@ open class EuiccManagementFragment : Fragment(), EuiccProfilesChangedListener,
|
|||
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) {
|
||||
|
@ -157,32 +235,22 @@ open class EuiccManagementFragment : Fragment(), EuiccProfilesChangedListener,
|
|||
fab.isEnabled = false
|
||||
|
||||
lifecycleScope.launch {
|
||||
beginTrackedOperation {
|
||||
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)
|
||||
}
|
||||
ensureEuiccChannelManager()
|
||||
euiccChannelManagerService.waitForForegroundTask()
|
||||
|
||||
if (!res) {
|
||||
Log.d(TAG, "Failed to enable / disable profile $iccid")
|
||||
withContext(Dispatchers.Main) {
|
||||
Toast.makeText(
|
||||
context,
|
||||
R.string.toast_profile_enable_failed,
|
||||
Toast.LENGTH_LONG
|
||||
).show()
|
||||
}
|
||||
return@beginTrackedOperation false
|
||||
}
|
||||
val err = euiccChannelManagerService.launchProfileSwitchTask(
|
||||
slotId,
|
||||
portId,
|
||||
iccid,
|
||||
enable,
|
||||
reconnectTimeoutMillis = 30 * 1000
|
||||
).waitDone()
|
||||
|
||||
if (!refreshed && !isUsb) {
|
||||
when (err) {
|
||||
null -> {}
|
||||
is EuiccChannelManagerService.SwitchingProfilesRefreshException -> {
|
||||
// This is only really fatal for internal eSIMs
|
||||
if (!isUsb) {
|
||||
withContext(Dispatchers.Main) {
|
||||
AlertDialog.Builder(requireContext()).apply {
|
||||
setMessage(R.string.switch_did_not_refresh)
|
||||
|
@ -196,23 +264,16 @@ open class EuiccManagementFragment : Fragment(), EuiccProfilesChangedListener,
|
|||
show()
|
||||
}
|
||||
}
|
||||
return@beginTrackedOperation true
|
||||
}
|
||||
}
|
||||
|
||||
if (!isUsb) {
|
||||
try {
|
||||
euiccChannelManager.waitForReconnect(
|
||||
slotId,
|
||||
portId,
|
||||
timeoutMillis = 30 * 1000
|
||||
)
|
||||
} catch (e: TimeoutCancellationException) {
|
||||
is TimeoutCancellationException -> {
|
||||
withContext(Dispatchers.Main) {
|
||||
// Prevent this Fragment from being used again
|
||||
invalid = true
|
||||
// Timed out waiting for SIM to come back online, we can no longer assume that the LPA is still valid
|
||||
AlertDialog.Builder(requireContext()).apply {
|
||||
setMessage(R.string.enable_disable_timeout)
|
||||
setMessage(appContainer.customizableTextProvider.profileSwitchingTimeoutMessage)
|
||||
setPositiveButton(android.R.string.ok) { dialog, _ ->
|
||||
dialog.dismiss()
|
||||
requireActivity().finish()
|
||||
|
@ -223,12 +284,11 @@ open class EuiccManagementFragment : Fragment(), EuiccProfilesChangedListener,
|
|||
show()
|
||||
}
|
||||
}
|
||||
return@beginTrackedOperation false
|
||||
}
|
||||
}
|
||||
|
||||
preferenceRepository.notificationSwitchFlow.first()
|
||||
else -> showSwitchFailureText()
|
||||
}
|
||||
|
||||
refresh()
|
||||
fab.isEnabled = true
|
||||
}
|
||||
|
@ -256,7 +316,7 @@ open class EuiccManagementFragment : Fragment(), EuiccProfilesChangedListener,
|
|||
|
||||
companion object {
|
||||
fun fromInt(value: Int) =
|
||||
Type.values().first { it.value == value }
|
||||
entries.first { it.value == value }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -284,6 +344,8 @@ open class EuiccManagementFragment : Fragment(), EuiccProfilesChangedListener,
|
|||
private val name: TextView = root.requireViewById(R.id.name)
|
||||
private val state: TextView = root.requireViewById(R.id.state)
|
||||
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)
|
||||
|
||||
init {
|
||||
|
@ -296,9 +358,10 @@ open class EuiccManagementFragment : Fragment(), EuiccProfilesChangedListener,
|
|||
}
|
||||
|
||||
iccid.setOnLongClickListener {
|
||||
requireContext().getSystemService(ClipboardManager::class.java)
|
||||
requireContext().getSystemService(ClipboardManager::class.java)!!
|
||||
.setPrimaryClip(ClipData.newPlainText("iccid", iccid.text))
|
||||
Toast.makeText(requireContext(), R.string.toast_iccid_copied, Toast.LENGTH_SHORT)
|
||||
if (Build.VERSION.SDK_INT <= Build.VERSION_CODES.S_V2) Toast
|
||||
.makeText(requireContext(), R.string.toast_iccid_copied, Toast.LENGTH_SHORT)
|
||||
.show()
|
||||
true
|
||||
}
|
||||
|
@ -320,6 +383,15 @@ open class EuiccManagementFragment : Fragment(), EuiccProfilesChangedListener,
|
|||
}
|
||||
)
|
||||
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.transformationMethod = PasswordTransformationMethod.getInstance()
|
||||
}
|
||||
|
|
|
@ -0,0 +1,126 @@
|
|||
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
|
||||
}
|
||||
}
|
|
@ -0,0 +1,72 @@
|
|||
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,13 +1,14 @@
|
|||
package im.angry.openeuicc.ui
|
||||
|
||||
import android.icu.text.SimpleDateFormat
|
||||
import android.os.Build
|
||||
import android.os.Bundle
|
||||
import android.view.Menu
|
||||
import android.view.MenuItem
|
||||
import android.view.View
|
||||
import android.widget.ScrollView
|
||||
import android.widget.TextView
|
||||
import androidx.activity.result.contract.ActivityResultContracts
|
||||
import androidx.activity.enableEdgeToEdge
|
||||
import androidx.appcompat.app.AppCompatActivity
|
||||
import androidx.lifecycle.lifecycleScope
|
||||
import androidx.swiperefreshlayout.widget.SwipeRefreshLayout
|
||||
|
@ -16,7 +17,6 @@ import im.angry.openeuicc.util.*
|
|||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
import java.io.FileOutputStream
|
||||
import java.util.Date
|
||||
|
||||
class LogsActivity : AppCompatActivity() {
|
||||
|
@ -26,26 +26,40 @@ class LogsActivity : AppCompatActivity() {
|
|||
private lateinit var logStr: String
|
||||
|
||||
private val saveLogs =
|
||||
registerForActivityResult(ActivityResultContracts.CreateDocument("text/plain")) { uri ->
|
||||
if (uri == null) return@registerForActivityResult
|
||||
if (!this::logStr.isInitialized) return@registerForActivityResult
|
||||
contentResolver.openFileDescriptor(uri, "w")?.use {
|
||||
FileOutputStream(it.fileDescriptor).use { os ->
|
||||
os.write(logStr.encodeToByteArray())
|
||||
}
|
||||
}
|
||||
setupLogSaving(
|
||||
getLogFileName = {
|
||||
getString(
|
||||
R.string.logs_filename_template,
|
||||
SimpleDateFormat.getDateTimeInstance().format(Date())
|
||||
)
|
||||
},
|
||||
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?) {
|
||||
enableEdgeToEdge()
|
||||
super.onCreate(savedInstanceState)
|
||||
setContentView(R.layout.activity_logs)
|
||||
setSupportActionBar(requireViewById(R.id.toolbar))
|
||||
setupToolbarInsets()
|
||||
supportActionBar!!.setDisplayHomeAsUpEnabled(true)
|
||||
|
||||
swipeRefresh = requireViewById(R.id.swipe_refresh)
|
||||
scrollView = requireViewById(R.id.scroll_view)
|
||||
logText = requireViewById(R.id.log_text)
|
||||
|
||||
setupRootViewInsets(scrollView)
|
||||
|
||||
swipeRefresh.setOnRefreshListener {
|
||||
lifecycleScope.launch {
|
||||
reload()
|
||||
|
@ -66,10 +80,12 @@ class LogsActivity : AppCompatActivity() {
|
|||
}
|
||||
|
||||
override fun onOptionsItemSelected(item: MenuItem): Boolean = when (item.itemId) {
|
||||
android.R.id.home -> {
|
||||
finish()
|
||||
true
|
||||
}
|
||||
R.id.save -> {
|
||||
saveLogs.launch(getString(R.string.logs_filename_template,
|
||||
SimpleDateFormat.getDateTimeInstance().format(Date())
|
||||
))
|
||||
saveLogs()
|
||||
true
|
||||
}
|
||||
else -> super.onOptionsItemSelected(item)
|
||||
|
|
|
@ -5,7 +5,9 @@ import android.content.BroadcastReceiver
|
|||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.content.IntentFilter
|
||||
import android.content.pm.PackageManager
|
||||
import android.hardware.usb.UsbManager
|
||||
import android.os.Build
|
||||
import android.os.Bundle
|
||||
import android.telephony.TelephonyManager
|
||||
import android.util.Log
|
||||
|
@ -13,6 +15,7 @@ import android.view.Menu
|
|||
import android.view.MenuItem
|
||||
import android.view.View
|
||||
import android.widget.ProgressBar
|
||||
import androidx.activity.enableEdgeToEdge
|
||||
import androidx.fragment.app.Fragment
|
||||
import androidx.lifecycle.lifecycleScope
|
||||
import androidx.viewpager2.adapter.FragmentStateAdapter
|
||||
|
@ -20,8 +23,12 @@ import androidx.viewpager2.widget.ViewPager2
|
|||
import com.google.android.material.tabs.TabLayout
|
||||
import com.google.android.material.tabs.TabLayoutMediator
|
||||
import im.angry.openeuicc.common.R
|
||||
import im.angry.openeuicc.core.EuiccChannelManager
|
||||
import im.angry.openeuicc.util.*
|
||||
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.withContext
|
||||
|
||||
|
@ -29,6 +36,8 @@ import kotlinx.coroutines.withContext
|
|||
open class MainActivity : BaseEuiccAccessActivity(), OpenEuiccContextMarker {
|
||||
companion object {
|
||||
const val TAG = "MainActivity"
|
||||
|
||||
const val PERMISSION_REQUEST_CODE = 1000
|
||||
}
|
||||
|
||||
private lateinit var loadingProgress: ProgressBar
|
||||
|
@ -38,6 +47,7 @@ open class MainActivity : BaseEuiccAccessActivity(), OpenEuiccContextMarker {
|
|||
private var refreshing = false
|
||||
|
||||
private data class Page(
|
||||
val logicalSlotId: Int,
|
||||
val title: String,
|
||||
val createFragment: () -> Fragment
|
||||
)
|
||||
|
@ -64,9 +74,11 @@ open class MainActivity : BaseEuiccAccessActivity(), OpenEuiccContextMarker {
|
|||
|
||||
@SuppressLint("WrongConstant", "UnspecifiedRegisterReceiverFlag")
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
enableEdgeToEdge()
|
||||
super.onCreate(savedInstanceState)
|
||||
setContentView(R.layout.activity_main)
|
||||
setSupportActionBar(requireViewById(R.id.toolbar))
|
||||
setupToolbarInsets()
|
||||
loadingProgress = requireViewById(R.id.loading)
|
||||
tabs = requireViewById(R.id.main_tabs)
|
||||
viewPager = requireViewById(R.id.view_pager)
|
||||
|
@ -97,7 +109,7 @@ open class MainActivity : BaseEuiccAccessActivity(), OpenEuiccContextMarker {
|
|||
override fun onOptionsItemSelected(item: MenuItem): Boolean =
|
||||
when (item.itemId) {
|
||||
R.id.settings -> {
|
||||
startActivity(Intent(this, SettingsActivity::class.java));
|
||||
startActivity(Intent(this, SettingsActivity::class.java))
|
||||
true
|
||||
}
|
||||
R.id.reload -> {
|
||||
|
@ -113,49 +125,76 @@ 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) {
|
||||
refreshing = true // We don't check this here -- the check happens in refresh()
|
||||
loadingProgress.visibility = View.VISIBLE
|
||||
viewPager.visibility = View.GONE
|
||||
tabs.visibility = View.GONE
|
||||
// Prevent concurrent access with any running foreground task
|
||||
euiccChannelManagerService.waitForForegroundTask()
|
||||
|
||||
val knownChannels = withContext(Dispatchers.IO) {
|
||||
euiccChannelManager.enumerateEuiccChannels().onEach {
|
||||
Log.d(TAG, "slot ${it.slotId} port ${it.portId}")
|
||||
Log.d(TAG, it.lpa.eID)
|
||||
val (usbDevice, _) = withContext(Dispatchers.IO) {
|
||||
euiccChannelManager.tryOpenUsbEuiccChannel()
|
||||
}
|
||||
|
||||
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
|
||||
// Note that this is currently supposed to be no-op when unprivileged,
|
||||
// but it could change in the future
|
||||
euiccChannelManager.notifyEuiccProfilesChanged(it.logicalSlotId)
|
||||
}
|
||||
}
|
||||
euiccChannelManager.notifyEuiccProfilesChanged(channel.logicalSlotId)
|
||||
|
||||
val (usbDevice, _) = withContext(Dispatchers.IO) {
|
||||
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) })
|
||||
val channelName =
|
||||
appContainer.customizableTextProvider.formatInternalChannelName(channel.logicalSlotId)
|
||||
newPages.add(Page(channel.logicalSlotId, channelName) {
|
||||
appContainer.uiComponentFactory.createEuiccManagementFragment(slotId, portId)
|
||||
})
|
||||
}
|
||||
}.collect()
|
||||
|
||||
// If USB readers exist, add them at the very last
|
||||
// We use a wrapper fragment to handle logic specific to USB readers
|
||||
usbDevice?.let {
|
||||
pages.add(Page(it.productName ?: getString(R.string.usb)) { UsbCcidReaderFragment() })
|
||||
val productName = it.productName ?: getString(R.string.usb)
|
||||
newPages.add(Page(EuiccChannelManager.USB_CHANNEL_ID, productName) {
|
||||
UsbCcidReaderFragment()
|
||||
})
|
||||
}
|
||||
viewPager.visibility = View.VISIBLE
|
||||
|
||||
if (pages.size > 1) {
|
||||
if (newPages.size > 1) {
|
||||
tabs.visibility = View.VISIBLE
|
||||
} else if (pages.isEmpty()) {
|
||||
pages.add(Page("") { appContainer.uiComponentFactory.createNoEuiccPlaceholderFragment() })
|
||||
} else if (newPages.isEmpty()) {
|
||||
newPages.add(Page(-1, "") {
|
||||
appContainer.uiComponentFactory.createNoEuiccPlaceholderFragment()
|
||||
})
|
||||
}
|
||||
|
||||
newPages.sortBy { it.logicalSlotId }
|
||||
|
||||
pages.clear()
|
||||
pages.addAll(newPages)
|
||||
|
||||
loadingProgress.visibility = View.GONE
|
||||
pagerAdapter.notifyDataSetChanged()
|
||||
// Reset the adapter so that the current view actually gets cleared
|
||||
// notifyDataSetChanged() doesn't cause the current view to be removed.
|
||||
|
@ -170,8 +209,11 @@ open class MainActivity : BaseEuiccAccessActivity(), OpenEuiccContextMarker {
|
|||
viewPager.currentItem = 0
|
||||
}
|
||||
|
||||
refreshing = false
|
||||
if (pages.size > 0) {
|
||||
ensureNotificationPermissions()
|
||||
}
|
||||
|
||||
refreshing = false
|
||||
}
|
||||
|
||||
private fun refresh(fromUsbEvent: Boolean = false) {
|
||||
|
|
|
@ -4,15 +4,20 @@ import android.os.Bundle
|
|||
import android.view.LayoutInflater
|
||||
import android.view.View
|
||||
import android.view.ViewGroup
|
||||
import android.widget.TextView
|
||||
import androidx.fragment.app.Fragment
|
||||
import im.angry.openeuicc.common.R
|
||||
import im.angry.openeuicc.util.*
|
||||
|
||||
class NoEuiccPlaceholderFragment : Fragment() {
|
||||
class NoEuiccPlaceholderFragment : Fragment(), OpenEuiccContextMarker {
|
||||
override fun onCreateView(
|
||||
inflater: LayoutInflater,
|
||||
container: ViewGroup?,
|
||||
savedInstanceState: Bundle?
|
||||
): View? {
|
||||
return inflater.inflate(R.layout.fragment_no_euicc_placeholder, container, false)
|
||||
val view = 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,6 +11,7 @@ import android.view.MenuItem.OnMenuItemClickListener
|
|||
import android.view.View
|
||||
import android.view.ViewGroup
|
||||
import android.widget.TextView
|
||||
import androidx.activity.enableEdgeToEdge
|
||||
import androidx.appcompat.app.AlertDialog
|
||||
import androidx.core.view.forEach
|
||||
import androidx.lifecycle.lifecycleScope
|
||||
|
@ -19,7 +20,6 @@ import androidx.recyclerview.widget.LinearLayoutManager
|
|||
import androidx.recyclerview.widget.RecyclerView
|
||||
import androidx.swiperefreshlayout.widget.SwipeRefreshLayout
|
||||
import im.angry.openeuicc.common.R
|
||||
import im.angry.openeuicc.core.EuiccChannel
|
||||
import im.angry.openeuicc.core.EuiccChannelManager
|
||||
import im.angry.openeuicc.util.*
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
|
@ -32,34 +32,37 @@ class NotificationsActivity: BaseEuiccAccessActivity(), OpenEuiccContextMarker {
|
|||
private lateinit var notificationList: RecyclerView
|
||||
private val notificationAdapter = NotificationAdapter()
|
||||
|
||||
private lateinit var euiccChannel: EuiccChannel
|
||||
private var logicalSlotId = -1
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
enableEdgeToEdge()
|
||||
super.onCreate(savedInstanceState)
|
||||
setContentView(R.layout.activity_notifications)
|
||||
setSupportActionBar(requireViewById(R.id.toolbar))
|
||||
setupToolbarInsets()
|
||||
supportActionBar!!.setDisplayHomeAsUpEnabled(true)
|
||||
}
|
||||
|
||||
override fun onInit() {
|
||||
euiccChannel = euiccChannelManager
|
||||
.findEuiccChannelBySlotBlocking(intent.getIntExtra("logicalSlotId", 0))!!
|
||||
|
||||
swipeRefresh = requireViewById(R.id.swipe_refresh)
|
||||
notificationList = requireViewById(R.id.recycler_view)
|
||||
|
||||
setupRootViewInsets(notificationList)
|
||||
}
|
||||
|
||||
override fun onInit() {
|
||||
notificationList.layoutManager =
|
||||
LinearLayoutManager(this, LinearLayoutManager.VERTICAL, false)
|
||||
notificationList.addItemDecoration(DividerItemDecoration(this, LinearLayoutManager.VERTICAL))
|
||||
notificationList.adapter = notificationAdapter
|
||||
registerForContextMenu(notificationList)
|
||||
|
||||
logicalSlotId = intent.getIntExtra("logicalSlotId", 0)
|
||||
|
||||
// This is slightly different from the MainActivity logic
|
||||
// due to the length (we don't want to display the full USB product name)
|
||||
val channelTitle = if (euiccChannel.logicalSlotId == EuiccChannelManager.USB_CHANNEL_ID) {
|
||||
val channelTitle = if (logicalSlotId == EuiccChannelManager.USB_CHANNEL_ID) {
|
||||
getString(R.string.usb)
|
||||
} else {
|
||||
getString(R.string.channel_name_format, euiccChannel.logicalSlotId)
|
||||
appContainer.customizableTextProvider.formatInternalChannelName(logicalSlotId)
|
||||
}
|
||||
|
||||
title = getString(R.string.profile_notifications_detailed_format, channelTitle)
|
||||
|
@ -100,6 +103,10 @@ class NotificationsActivity: BaseEuiccAccessActivity(), OpenEuiccContextMarker {
|
|||
swipeRefresh.isRefreshing = true
|
||||
|
||||
lifecycleScope.launch {
|
||||
withContext(Dispatchers.IO) {
|
||||
euiccChannelManagerLoaded.await()
|
||||
}
|
||||
|
||||
task()
|
||||
|
||||
swipeRefresh.isRefreshing = false
|
||||
|
@ -108,15 +115,16 @@ class NotificationsActivity: BaseEuiccAccessActivity(), OpenEuiccContextMarker {
|
|||
|
||||
private fun refresh() {
|
||||
launchTask {
|
||||
val profiles = withContext(Dispatchers.IO) {
|
||||
euiccChannel.lpa.profiles
|
||||
notificationAdapter.notifications =
|
||||
euiccChannelManager.withEuiccChannel(logicalSlotId) { channel ->
|
||||
val nameMap = buildMap {
|
||||
for (profile in channel.lpa.profiles) {
|
||||
put(profile.iccid, profile.displayName)
|
||||
}
|
||||
}
|
||||
|
||||
notificationAdapter.notifications =
|
||||
withContext(Dispatchers.IO) {
|
||||
euiccChannel.lpa.notifications.map {
|
||||
val profile = profiles.find { p -> p.iccid == it.iccid }
|
||||
LocalProfileNotificationWrapper(it, profile?.displayName ?: "???")
|
||||
channel.lpa.notifications.map {
|
||||
LocalProfileNotificationWrapper(it, nameMap[it.iccid] ?: "???")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -131,6 +139,8 @@ class NotificationsActivity: BaseEuiccAccessActivity(), OpenEuiccContextMarker {
|
|||
inner class NotificationViewHolder(private val root: View):
|
||||
RecyclerView.ViewHolder(root), View.OnCreateContextMenuListener, OnMenuItemClickListener {
|
||||
private val address: TextView = root.requireViewById(R.id.notification_address)
|
||||
private val sequenceNumber: TextView =
|
||||
root.requireViewById(R.id.notification_sequence_number)
|
||||
private val profileName: TextView = root.requireViewById(R.id.notification_profile_name)
|
||||
|
||||
private lateinit var notification: LocalProfileNotificationWrapper
|
||||
|
@ -152,6 +162,7 @@ class NotificationsActivity: BaseEuiccAccessActivity(), OpenEuiccContextMarker {
|
|||
}
|
||||
}
|
||||
|
||||
|
||||
private fun operationToLocalizedText(operation: LocalProfileNotification.Operation) =
|
||||
root.context.getText(
|
||||
when (operation) {
|
||||
|
@ -165,6 +176,10 @@ class NotificationsActivity: BaseEuiccAccessActivity(), OpenEuiccContextMarker {
|
|||
notification = value
|
||||
|
||||
address.text = value.inner.notificationAddress
|
||||
sequenceNumber.text = root.context.getString(
|
||||
R.string.profile_notification_sequence_number_format,
|
||||
value.inner.seqNumber
|
||||
)
|
||||
profileName.text = Html.fromHtml(
|
||||
root.context.getString(R.string.profile_notification_name_format,
|
||||
operationToLocalizedText(value.inner.profileManagementOperation),
|
||||
|
@ -189,7 +204,9 @@ class NotificationsActivity: BaseEuiccAccessActivity(), OpenEuiccContextMarker {
|
|||
R.id.notification_process -> {
|
||||
launchTask {
|
||||
withContext(Dispatchers.IO) {
|
||||
euiccChannel.lpa.handleNotification(notification.inner.seqNumber)
|
||||
euiccChannelManager.withEuiccChannel(logicalSlotId) { channel ->
|
||||
channel.lpa.handleNotification(notification.inner.seqNumber)
|
||||
}
|
||||
}
|
||||
|
||||
refresh()
|
||||
|
@ -199,7 +216,9 @@ class NotificationsActivity: BaseEuiccAccessActivity(), OpenEuiccContextMarker {
|
|||
R.id.notification_delete -> {
|
||||
launchTask {
|
||||
withContext(Dispatchers.IO) {
|
||||
euiccChannel.lpa.deleteNotification(notification.inner.seqNumber)
|
||||
euiccChannelManager.withEuiccChannel(logicalSlotId) { channel ->
|
||||
channel.lpa.deleteNotification(notification.inner.seqNumber)
|
||||
}
|
||||
}
|
||||
|
||||
refresh()
|
||||
|
|
|
@ -3,56 +3,67 @@ package im.angry.openeuicc.ui
|
|||
import android.app.Dialog
|
||||
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.lifecycle.lifecycleScope
|
||||
import im.angry.openeuicc.common.R
|
||||
import im.angry.openeuicc.service.EuiccChannelManagerService.Companion.waitDone
|
||||
import im.angry.openeuicc.util.*
|
||||
import kotlinx.coroutines.flow.first
|
||||
import kotlinx.coroutines.flow.onStart
|
||||
import kotlinx.coroutines.launch
|
||||
import java.lang.Exception
|
||||
|
||||
class ProfileDeleteFragment : DialogFragment(), EuiccChannelFragmentMarker {
|
||||
companion object {
|
||||
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): ProfileDeleteFragment {
|
||||
val instance = newInstanceEuicc(ProfileDeleteFragment::class.java, slotId, portId)
|
||||
instance.requireArguments().apply {
|
||||
putString("iccid", iccid)
|
||||
putString("name", name)
|
||||
fun newInstance(slotId: Int, portId: Int, iccid: String, name: String) =
|
||||
newInstanceEuicc(ProfileDeleteFragment::class.java, slotId, portId) {
|
||||
putString(FIELD_ICCID, iccid)
|
||||
putString(FIELD_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 {
|
||||
EditText(requireContext()).apply {
|
||||
hint = Editable.Factory.getInstance().newEditable(
|
||||
getString(R.string.profile_delete_confirm_input, requireArguments().getString("name")!!)
|
||||
)
|
||||
hint = Editable.Factory.getInstance()
|
||||
.newEditable(getString(R.string.profile_delete_confirm_input, name))
|
||||
}
|
||||
}
|
||||
|
||||
private val inputMatchesName: Boolean
|
||||
get() = editText.text.toString() == requireArguments().getString("name")!!
|
||||
get() = editText.text.toString() == name
|
||||
|
||||
private var toast: Toast? = null
|
||||
|
||||
private var deleting = false
|
||||
|
||||
override fun onCreateDialog(savedInstanceState: Bundle?): Dialog {
|
||||
return AlertDialog.Builder(requireContext(), R.style.AlertDialogTheme).apply {
|
||||
setMessage(getString(R.string.profile_delete_confirm, requireArguments().getString("name")))
|
||||
private val alertDialog: AlertDialog
|
||||
get() = requireDialog() as AlertDialog
|
||||
|
||||
override fun onCreateDialog(savedInstanceState: Bundle?): Dialog =
|
||||
AlertDialog.Builder(requireContext(), R.style.AlertDialogTheme).apply {
|
||||
setMessage(getString(R.string.profile_delete_confirm, name))
|
||||
setView(editText)
|
||||
setPositiveButton(android.R.string.ok, null) // Set listener to null to prevent auto closing
|
||||
setNegativeButton(android.R.string.cancel, null)
|
||||
}.create()
|
||||
}
|
||||
|
||||
override fun onResume() {
|
||||
super.onResume()
|
||||
val alertDialog = dialog!! as AlertDialog
|
||||
alertDialog.getButton(AlertDialog.BUTTON_POSITIVE).setOnClickListener {
|
||||
if (!deleting && inputMatchesName) delete()
|
||||
if (!deleting) delete()
|
||||
}
|
||||
alertDialog.getButton(AlertDialog.BUTTON_NEGATIVE).setOnClickListener {
|
||||
if (!deleting) dismiss()
|
||||
|
@ -60,30 +71,29 @@ class ProfileDeleteFragment : DialogFragment(), EuiccChannelFragmentMarker {
|
|||
}
|
||||
|
||||
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
|
||||
val alertDialog = dialog!! as AlertDialog
|
||||
alertDialog.setCanceledOnTouchOutside(false)
|
||||
alertDialog.setCancelable(false)
|
||||
alertDialog.getButton(AlertDialog.BUTTON_POSITIVE).isEnabled = false
|
||||
alertDialog.getButton(AlertDialog.BUTTON_NEGATIVE).isEnabled = false
|
||||
|
||||
lifecycleScope.launch {
|
||||
try {
|
||||
doDelete()
|
||||
} catch (e: Exception) {
|
||||
Log.d(ProfileDownloadFragment.TAG, "Error deleting profile")
|
||||
Log.d(ProfileDownloadFragment.TAG, Log.getStackTraceString(e))
|
||||
} finally {
|
||||
if (parentFragment is EuiccProfilesChangedListener) {
|
||||
(parentFragment as EuiccProfilesChangedListener).onEuiccProfilesChanged()
|
||||
requireParentFragment().lifecycleScope.launch {
|
||||
ensureEuiccChannelManager()
|
||||
euiccChannelManagerService.waitForForegroundTask()
|
||||
euiccChannelManagerService.launchProfileDeleteTask(slotId, portId, iccid)
|
||||
.onStart {
|
||||
parentFragment?.notifyEuiccProfilesChanged()
|
||||
runCatching(::dismiss)
|
||||
}
|
||||
dismiss()
|
||||
.waitDone()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun doDelete() = beginTrackedOperation {
|
||||
channel.lpa.deleteProfile(requireArguments().getString("iccid")!!)
|
||||
preferenceRepository.notificationDeleteFlow.first()
|
||||
}
|
||||
}
|
|
@ -1,266 +0,0 @@
|
|||
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,34 +2,32 @@ package im.angry.openeuicc.ui
|
|||
|
||||
import android.app.Dialog
|
||||
import android.os.Bundle
|
||||
import android.util.Log
|
||||
import android.view.LayoutInflater
|
||||
import android.view.View
|
||||
import android.view.ViewGroup
|
||||
import android.widget.ProgressBar
|
||||
import android.widget.Toast
|
||||
import androidx.annotation.StringRes
|
||||
import androidx.appcompat.widget.Toolbar
|
||||
import androidx.lifecycle.lifecycleScope
|
||||
import com.google.android.material.textfield.TextInputLayout
|
||||
import im.angry.openeuicc.common.R
|
||||
import im.angry.openeuicc.service.EuiccChannelManagerService.Companion.waitDone
|
||||
import im.angry.openeuicc.util.*
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
import java.lang.Exception
|
||||
import java.lang.RuntimeException
|
||||
import net.typeblog.lpac_jni.LocalProfileAssistant
|
||||
|
||||
class ProfileRenameFragment : BaseMaterialDialogFragment(), EuiccChannelFragmentMarker {
|
||||
companion object {
|
||||
private const val FIELD_ICCID = "iccid"
|
||||
private const val FIELD_CURRENT_NAME = "currentName"
|
||||
|
||||
const val TAG = "ProfileRenameFragment"
|
||||
|
||||
fun newInstance(slotId: Int, portId: Int, iccid: String, currentName: String): ProfileRenameFragment {
|
||||
val instance = newInstanceEuicc(ProfileRenameFragment::class.java, slotId, portId)
|
||||
instance.requireArguments().apply {
|
||||
putString("iccid", iccid)
|
||||
putString("currentName", currentName)
|
||||
}
|
||||
return instance
|
||||
fun newInstance(slotId: Int, portId: Int, iccid: String, currentName: String) =
|
||||
newInstanceEuicc(ProfileRenameFragment::class.java, slotId, portId) {
|
||||
putString(FIELD_ICCID, iccid)
|
||||
putString(FIELD_CURRENT_NAME, currentName)
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -39,6 +37,14 @@ class ProfileRenameFragment : BaseMaterialDialogFragment(), EuiccChannelFragment
|
|||
|
||||
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(
|
||||
inflater: LayoutInflater,
|
||||
container: ViewGroup?,
|
||||
|
@ -57,6 +63,7 @@ class ProfileRenameFragment : BaseMaterialDialogFragment(), EuiccChannelFragment
|
|||
|
||||
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
|
||||
super.onViewCreated(view, savedInstanceState)
|
||||
profileRenameNewName.editText!!.setText(currentName)
|
||||
toolbar.apply {
|
||||
setTitle(R.string.rename)
|
||||
setNavigationOnClickListener {
|
||||
|
@ -69,11 +76,6 @@ class ProfileRenameFragment : BaseMaterialDialogFragment(), EuiccChannelFragment
|
|||
}
|
||||
}
|
||||
|
||||
override fun onStart() {
|
||||
super.onStart()
|
||||
profileRenameNewName.editText!!.setText(requireArguments().getString("currentName"))
|
||||
}
|
||||
|
||||
override fun onResume() {
|
||||
super.onResume()
|
||||
setWidthPercent(95)
|
||||
|
@ -85,35 +87,45 @@ class ProfileRenameFragment : BaseMaterialDialogFragment(), EuiccChannelFragment
|
|||
}
|
||||
}
|
||||
|
||||
private fun rename() {
|
||||
val name = profileRenameNewName.editText!!.text.toString().trim()
|
||||
if (name.length >= 64) {
|
||||
Toast.makeText(context, R.string.toast_profile_name_too_long, Toast.LENGTH_LONG).show()
|
||||
return
|
||||
private fun showErrorAndCancel(@StringRes resId: Int) {
|
||||
Toast.makeText(requireContext(), resId, Toast.LENGTH_LONG).show()
|
||||
|
||||
renaming = false
|
||||
progress.visibility = View.GONE
|
||||
}
|
||||
|
||||
private fun rename() {
|
||||
renaming = true
|
||||
progress.isIndeterminate = true
|
||||
progress.visibility = View.VISIBLE
|
||||
|
||||
val newName = profileRenameNewName.editText!!.text.toString().trim()
|
||||
|
||||
lifecycleScope.launch {
|
||||
try {
|
||||
doRename(name)
|
||||
} catch (e: Exception) {
|
||||
Log.d(TAG, "Failed to rename profile")
|
||||
Log.d(TAG, Log.getStackTraceString(e))
|
||||
} finally {
|
||||
if (parentFragment is EuiccProfilesChangedListener) {
|
||||
(parentFragment as EuiccProfilesChangedListener).onEuiccProfilesChanged()
|
||||
}
|
||||
dismiss()
|
||||
}
|
||||
}
|
||||
ensureEuiccChannelManager()
|
||||
euiccChannelManagerService.waitForForegroundTask()
|
||||
val response = euiccChannelManagerService
|
||||
.launchProfileRenameTask(slotId, portId, iccid, newName).waitDone()
|
||||
|
||||
when (response) {
|
||||
is LocalProfileAssistant.ProfileNameTooLongException -> {
|
||||
showErrorAndCancel(R.string.profile_rename_too_long)
|
||||
}
|
||||
|
||||
private suspend fun doRename(name: String) = withContext(Dispatchers.IO) {
|
||||
if (!channel.lpa.setNickname(requireArguments().getString("iccid")!!, name)) {
|
||||
throw RuntimeException("Profile nickname not changed")
|
||||
is LocalProfileAssistant.ProfileNameIsInvalidUTF8Exception -> {
|
||||
showErrorAndCancel(R.string.profile_rename_encoding_error)
|
||||
}
|
||||
|
||||
is Throwable -> {
|
||||
showErrorAndCancel(R.string.profile_rename_failure)
|
||||
}
|
||||
|
||||
else -> {
|
||||
parentFragment?.notifyEuiccProfilesChanged()
|
||||
|
||||
runCatching(::dismiss)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
|
@ -2,17 +2,26 @@ package im.angry.openeuicc.ui
|
|||
|
||||
import android.os.Bundle
|
||||
import android.view.MenuItem
|
||||
import androidx.activity.enableEdgeToEdge
|
||||
import androidx.appcompat.app.AppCompatActivity
|
||||
import im.angry.openeuicc.OpenEuiccApplication
|
||||
import im.angry.openeuicc.common.R
|
||||
import im.angry.openeuicc.util.*
|
||||
|
||||
class SettingsActivity: AppCompatActivity() {
|
||||
private val appContainer
|
||||
get() = (application as OpenEuiccApplication).appContainer
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
enableEdgeToEdge()
|
||||
super.onCreate(savedInstanceState)
|
||||
setContentView(R.layout.activity_settings)
|
||||
setSupportActionBar(requireViewById(R.id.toolbar))
|
||||
setupToolbarInsets()
|
||||
supportActionBar!!.setDisplayHomeAsUpEnabled(true)
|
||||
val settingsFragment = appContainer.uiComponentFactory.createSettingsFragment()
|
||||
supportFragmentManager.beginTransaction()
|
||||
.replace(R.id.settings_container, SettingsFragment())
|
||||
.replace(R.id.settings_container, settingsFragment)
|
||||
.commit()
|
||||
}
|
||||
|
||||
|
|
|
@ -2,60 +2,164 @@ package im.angry.openeuicc.ui
|
|||
|
||||
import android.content.Intent
|
||||
import android.net.Uri
|
||||
import android.os.Build
|
||||
import android.os.Bundle
|
||||
import androidx.datastore.preferences.core.Preferences
|
||||
import android.provider.Settings
|
||||
import android.widget.Toast
|
||||
import androidx.lifecycle.lifecycleScope
|
||||
import androidx.preference.CheckBoxPreference
|
||||
import androidx.preference.Preference
|
||||
import androidx.preference.PreferenceCategory
|
||||
import androidx.preference.PreferenceFragmentCompat
|
||||
import im.angry.openeuicc.common.R
|
||||
import im.angry.openeuicc.util.*
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.collect
|
||||
import kotlinx.coroutines.flow.onEach
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.runBlocking
|
||||
|
||||
class SettingsFragment: PreferenceFragmentCompat() {
|
||||
open 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?) {
|
||||
setPreferencesFromResource(R.xml.pref_settings, rootKey)
|
||||
|
||||
findPreference<Preference>("pref_info_app_version")
|
||||
?.summary = requireContext().selfAppVersion
|
||||
developerPref = requirePreference("pref_developer")
|
||||
|
||||
findPreference<Preference>("pref_info_source_code")
|
||||
?.setOnPreferenceClickListener {
|
||||
startActivity(Intent(Intent.ACTION_VIEW, Uri.parse(it.summary.toString())))
|
||||
true
|
||||
// Show / hide developer preference based on whether it is enabled
|
||||
lifecycleScope.launch {
|
||||
preferenceRepository.developerOptionsEnabledFlow
|
||||
.onEach { developerPref.isVisible = it }
|
||||
.collect()
|
||||
}
|
||||
|
||||
findPreference<Preference>("pref_advanced_logs")
|
||||
?.setOnPreferenceClickListener {
|
||||
startActivity(Intent(requireContext(), LogsActivity::class.java))
|
||||
true
|
||||
requirePreference<Preference>("pref_info_app_version").apply {
|
||||
summary = requireContext().selfAppVersion
|
||||
|
||||
// Enable developer options when this is clicked for 7 times
|
||||
setOnPreferenceClickListener(::onAppVersionClicked)
|
||||
}
|
||||
|
||||
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)
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
private fun CheckBoxPreference.bindBooleanFlow(flow: Flow<Boolean>, key: Preferences.Key<Boolean>) {
|
||||
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<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()
|
||||
if (now - lastClickTimestamp >= 1000) {
|
||||
numClicks = 1
|
||||
} else {
|
||||
numClicks++
|
||||
}
|
||||
lastClickTimestamp = now
|
||||
|
||||
if (numClicks == 7) {
|
||||
lifecycleScope.launch {
|
||||
preferenceRepository.developerOptionsEnabledFlow.updatePreference(true)
|
||||
|
||||
lastToast?.cancel()
|
||||
Toast.makeText(
|
||||
requireContext(),
|
||||
R.string.developer_options_enabled,
|
||||
Toast.LENGTH_SHORT
|
||||
).show()
|
||||
}
|
||||
} else if (numClicks > 1) {
|
||||
lastToast?.cancel()
|
||||
lastToast = Toast.makeText(
|
||||
requireContext(),
|
||||
getString(R.string.developer_options_steps, 7 - numClicks),
|
||||
Toast.LENGTH_SHORT
|
||||
)
|
||||
lastToast!!.show()
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
protected fun CheckBoxPreference.bindBooleanFlow(flow: PreferenceFlowWrapper<Boolean>) {
|
||||
lifecycleScope.launch {
|
||||
flow.collect { isChecked = it }
|
||||
}
|
||||
|
||||
setOnPreferenceChangeListener { _, newValue ->
|
||||
runBlocking {
|
||||
preferenceRepository.updatePreference(key, newValue as Boolean)
|
||||
flow.updatePreference(newValue as Boolean)
|
||||
}
|
||||
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)
|
||||
}
|
||||
}
|
|
@ -1,93 +0,0 @@
|
|||
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,7 +20,6 @@ import androidx.fragment.app.Fragment
|
|||
import androidx.fragment.app.commit
|
||||
import androidx.lifecycle.lifecycleScope
|
||||
import im.angry.openeuicc.common.R
|
||||
import im.angry.openeuicc.core.EuiccChannel
|
||||
import im.angry.openeuicc.core.EuiccChannelManager
|
||||
import im.angry.openeuicc.util.*
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
|
@ -73,7 +72,6 @@ class UsbCcidReaderFragment : Fragment(), OpenEuiccContextMarker {
|
|||
private lateinit var loadingProgress: ProgressBar
|
||||
|
||||
private var usbDevice: UsbDevice? = null
|
||||
private var usbChannel: EuiccChannel? = null
|
||||
|
||||
override fun onCreateView(
|
||||
inflater: LayoutInflater,
|
||||
|
@ -122,7 +120,7 @@ class UsbCcidReaderFragment : Fragment(), OpenEuiccContextMarker {
|
|||
try {
|
||||
requireContext().unregisterReceiver(usbPermissionReceiver)
|
||||
} catch (_: Exception) {
|
||||
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -131,7 +129,7 @@ class UsbCcidReaderFragment : Fragment(), OpenEuiccContextMarker {
|
|||
try {
|
||||
requireContext().unregisterReceiver(usbPermissionReceiver)
|
||||
} catch (_: Exception) {
|
||||
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -140,24 +138,26 @@ class UsbCcidReaderFragment : Fragment(), OpenEuiccContextMarker {
|
|||
permissionButton.visibility = View.GONE
|
||||
loadingProgress.visibility = View.VISIBLE
|
||||
|
||||
val (device, channel) = withContext(Dispatchers.IO) {
|
||||
euiccChannelManager.enumerateUsbEuiccChannel()
|
||||
val (device, canOpen) = withContext(Dispatchers.IO) {
|
||||
euiccChannelManager.tryOpenUsbEuiccChannel()
|
||||
}
|
||||
|
||||
loadingProgress.visibility = View.GONE
|
||||
|
||||
usbDevice = device
|
||||
usbChannel = channel
|
||||
|
||||
if (device != null && channel == null && !usbManager.hasPermission(device)) {
|
||||
if (device != null && !canOpen && !usbManager.hasPermission(device)) {
|
||||
text.text = getString(R.string.usb_permission_needed)
|
||||
text.visibility = View.VISIBLE
|
||||
permissionButton.visibility = View.VISIBLE
|
||||
} else if (device != null && channel != null) {
|
||||
} else if (device != null && canOpen) {
|
||||
childFragmentManager.commit {
|
||||
replace(
|
||||
R.id.child_container,
|
||||
appContainer.uiComponentFactory.createEuiccManagementFragment(channel)
|
||||
appContainer.uiComponentFactory.createEuiccManagementFragment(
|
||||
slotId = EuiccChannelManager.USB_CHANNEL_ID,
|
||||
portId = 0
|
||||
)
|
||||
)
|
||||
}
|
||||
} else {
|
||||
|
|
|
@ -0,0 +1,330 @@
|
|||
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
|
||||
if (uri?.scheme == "lpa") {
|
||||
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() {}
|
||||
}
|
||||
}
|
|
@ -0,0 +1,117 @@
|
|||
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
|
||||
}
|
|
@ -0,0 +1,139 @@
|
|||
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 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('{')) {
|
||||
str.prettyPrintJson()
|
||||
} 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()
|
||||
}
|
||||
}
|
|
@ -0,0 +1,173 @@
|
|||
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])
|
||||
}
|
||||
|
||||
}
|
||||
}
|
|
@ -0,0 +1,243 @@
|
|||
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.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(
|
||||
val titleRes: Int,
|
||||
var state: ProgressState
|
||||
)
|
||||
|
||||
private val progressItems = arrayOf(
|
||||
ProgressItem(R.string.download_wizard_progress_step_preparing, ProgressState.NotStarted),
|
||||
ProgressItem(R.string.download_wizard_progress_step_connecting, ProgressState.NotStarted),
|
||||
ProgressItem(
|
||||
R.string.download_wizard_progress_step_authenticating,
|
||||
ProgressState.NotStarted
|
||||
),
|
||||
ProgressItem(R.string.download_wizard_progress_step_downloading, ProgressState.NotStarted),
|
||||
ProgressItem(R.string.download_wizard_progress_step_finalizing, ProgressState.NotStarted)
|
||||
)
|
||||
|
||||
private val adapter = ProgressItemAdapter()
|
||||
|
||||
// We don't want to turn off the screen during a download
|
||||
override val keepScreenOn = true
|
||||
|
||||
private var isDone = false
|
||||
|
||||
override val hasNext: Boolean
|
||||
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) {
|
||||
progressItem.state =
|
||||
if (state.downloadError == null) ProgressState.Done else ProgressState.Error
|
||||
}
|
||||
|
||||
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)
|
||||
|
||||
fun bind(item: ProgressItem) {
|
||||
title.text = getString(item.titleRes)
|
||||
|
||||
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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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])
|
||||
}
|
||||
}
|
||||
}
|
|
@ -0,0 +1,219 @@
|
|||
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
|
||||
import net.typeblog.lpac_jni.LocalProfileInfo
|
||||
|
||||
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.usb)
|
||||
} else {
|
||||
appContainer.customizableTextProvider.formatInternalChannelName(item.logicalSlotId)
|
||||
}
|
||||
eID.text = item.eID
|
||||
activeProfile.text = item.enabledProfileName ?: root.context.getString(R.string.unknown)
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
|
@ -4,49 +4,69 @@ import android.os.Bundle
|
|||
import androidx.fragment.app.Fragment
|
||||
import im.angry.openeuicc.core.EuiccChannel
|
||||
import im.angry.openeuicc.core.EuiccChannelManager
|
||||
import im.angry.openeuicc.service.EuiccChannelManagerService
|
||||
import im.angry.openeuicc.ui.BaseEuiccAccessActivity
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.withContext
|
||||
|
||||
private const val FIELD_SLOT_ID = "slotId"
|
||||
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"
|
||||
// in the definition of an interface, so the only way is to limit where the extension functions
|
||||
// can be applied.
|
||||
fun <T> newInstanceEuicc(clazz: Class<T>, slotId: Int, portId: Int, addArguments: Bundle.() -> Unit = {}): T where T: Fragment, T: EuiccChannelFragmentMarker {
|
||||
val instance = clazz.newInstance()
|
||||
instance.arguments = Bundle().apply {
|
||||
putInt("slotId", slotId)
|
||||
putInt("portId", portId)
|
||||
addArguments()
|
||||
}
|
||||
return instance
|
||||
fun <T> newInstanceEuicc(clazz: Class<T>, slotId: Int, portId: Int, addArguments: BundleSetter = {}): T
|
||||
where T : Fragment, T : EuiccChannelFragmentMarker =
|
||||
clazz.getDeclaredConstructor().newInstance().apply {
|
||||
arguments = Bundle()
|
||||
arguments!!.putInt(FIELD_SLOT_ID, slotId)
|
||||
arguments!!.putInt(FIELD_PORT_ID, portId)
|
||||
arguments!!.addArguments()
|
||||
}
|
||||
|
||||
// Convenient methods to avoid using `channel` for these
|
||||
// `channel` requires that the channel actually exists in EuiccChannelManager, which is
|
||||
// not always the case during operations such as switching
|
||||
val <T> T.slotId: Int where T: Fragment, T: EuiccChannelFragmentMarker
|
||||
get() = requireArguments().getInt("slotId")
|
||||
val <T> T.portId: Int where T: Fragment, T: EuiccChannelFragmentMarker
|
||||
get() = requireArguments().getInt("portId")
|
||||
val <T> T.isUsb: Boolean where T: Fragment, T: EuiccChannelFragmentMarker
|
||||
get() = requireArguments().getInt("slotId") == EuiccChannelManager.USB_CHANNEL_ID
|
||||
val <T> T.slotId: Int
|
||||
where T : Fragment, T : EuiccChannelFragmentMarker
|
||||
get() = requireArguments().getInt(FIELD_SLOT_ID)
|
||||
val <T> T.portId: Int
|
||||
where T : Fragment, T : EuiccChannelFragmentMarker
|
||||
get() = requireArguments().getInt(FIELD_PORT_ID)
|
||||
val <T> T.isUsb: Boolean
|
||||
where T : Fragment, T : EuiccChannelFragmentMarker
|
||||
get() = slotId == EuiccChannelManager.USB_CHANNEL_ID
|
||||
|
||||
val <T> T.euiccChannelManager: EuiccChannelManager where T: Fragment, T: EuiccChannelFragmentMarker
|
||||
get() = (requireActivity() as BaseEuiccAccessActivity).euiccChannelManager
|
||||
val <T> T.channel: EuiccChannel where T: Fragment, T: EuiccChannelFragmentMarker
|
||||
get() =
|
||||
euiccChannelManager.findEuiccChannelByPortBlocking(slotId, portId)!!
|
||||
private fun <T> T.requireEuiccActivity(): BaseEuiccAccessActivity
|
||||
where T : Fragment, T : OpenEuiccContextMarker =
|
||||
requireActivity() as BaseEuiccAccessActivity
|
||||
|
||||
val <T> T.euiccChannelManager: EuiccChannelManager
|
||||
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 {
|
||||
fun onEuiccProfilesChanged()
|
||||
}
|
||||
|
||||
suspend fun <T> T.beginTrackedOperation(op: suspend () -> Boolean) where T: Fragment, T: EuiccChannelFragmentMarker {
|
||||
withContext(Dispatchers.IO) {
|
||||
euiccChannelManager.beginTrackedOperationBlocking(slotId, portId) {
|
||||
op()
|
||||
}
|
||||
}
|
||||
}
|
|
@ -0,0 +1,34 @@
|
|||
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,9 +3,6 @@ package im.angry.openeuicc.util
|
|||
import android.util.Log
|
||||
import im.angry.openeuicc.core.EuiccChannel
|
||||
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.LocalProfileInfo
|
||||
|
||||
|
@ -19,9 +16,10 @@ val LocalProfileInfo.isEnabled: Boolean
|
|||
get() = state == LocalProfileInfo.State.Enabled
|
||||
|
||||
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
|
||||
get() = distinctBy { it.slotId }.size > 1
|
||||
|
@ -42,22 +40,27 @@ fun LocalProfileAssistant.switchProfile(
|
|||
* See EuiccManager.waitForReconnect()
|
||||
*/
|
||||
fun LocalProfileAssistant.disableActiveProfile(refresh: Boolean): Boolean =
|
||||
profiles.find { it.isEnabled }?.let {
|
||||
profiles.enabled?.let {
|
||||
Log.i(TAG, "Disabling active profile ${it.iccid}")
|
||||
disableProfile(it.iccid, refresh)
|
||||
} ?: true
|
||||
|
||||
/**
|
||||
* 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.
|
||||
* Disable the current active profile if any. If refresh is true, also cause a refresh command.
|
||||
* See EuiccManager.waitForReconnect()
|
||||
*
|
||||
* Return the iccid of the profile being disabled, or null if no active profile found or failed to
|
||||
* disable.
|
||||
*/
|
||||
fun LocalProfileAssistant.disableActiveProfileWithUndo(refreshOnDisable: Boolean): () -> Unit =
|
||||
profiles.find { it.isEnabled }?.let {
|
||||
disableProfile(it.iccid, refreshOnDisable)
|
||||
return { enableProfile(it.iccid) }
|
||||
} ?: { }
|
||||
fun LocalProfileAssistant.disableActiveProfileKeepIccId(refresh: Boolean): String? =
|
||||
profiles.enabled?.let {
|
||||
Log.i(TAG, "Disabling active profile ${it.iccid}")
|
||||
if (disableProfile(it.iccid, refresh)) {
|
||||
it.iccid
|
||||
} else {
|
||||
null
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Begin a "tracked" operation where notifications may be generated by the eSIM
|
||||
|
@ -73,29 +76,26 @@ fun LocalProfileAssistant.disableActiveProfileWithUndo(refreshOnDisable: Boolean
|
|||
* 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.
|
||||
*/
|
||||
inline fun EuiccChannelManager.beginTrackedOperationBlocking(
|
||||
suspend inline fun EuiccChannelManager.beginTrackedOperation(
|
||||
slotId: Int,
|
||||
portId: Int,
|
||||
op: () -> Boolean
|
||||
) {
|
||||
val latestSeq =
|
||||
findEuiccChannelByPortBlocking(slotId, portId)!!.lpa.notifications.firstOrNull()?.seqNumber
|
||||
val latestSeq = withEuiccChannel(slotId, portId) { channel ->
|
||||
channel.lpa.notifications.firstOrNull()?.seqNumber
|
||||
?: 0
|
||||
}
|
||||
Log.d(TAG, "Latest notification is $latestSeq before operation")
|
||||
if (op()) {
|
||||
Log.d(TAG, "Operation has requested notification handling")
|
||||
try {
|
||||
// Note that the exact instance of "channel" might have changed here if reconnected;
|
||||
// so we MUST use the automatic getter for "channel"
|
||||
findEuiccChannelByPortBlocking(
|
||||
slotId,
|
||||
portId
|
||||
)?.lpa?.notifications?.filter { it.seqNumber > latestSeq }?.forEach {
|
||||
// this is why we need to use two distinct calls to withEuiccChannel()
|
||||
withEuiccChannel(slotId, portId) { channel ->
|
||||
channel.lpa.notifications.filter { it.seqNumber > latestSeq }.forEach {
|
||||
Log.d(TAG, "Handling notification $it")
|
||||
findEuiccChannelByPortBlocking(
|
||||
slotId,
|
||||
portId
|
||||
)?.lpa?.handleNotification(it.seqNumber)
|
||||
channel.lpa.handleNotification(it.seqNumber)
|
||||
}
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
// Ignore any error during notification handling
|
||||
|
|
|
@ -5,11 +5,13 @@ import androidx.datastore.core.DataStore
|
|||
import androidx.datastore.preferences.core.Preferences
|
||||
import androidx.datastore.preferences.core.booleanPreferencesKey
|
||||
import androidx.datastore.preferences.core.edit
|
||||
import androidx.datastore.preferences.core.stringPreferencesKey
|
||||
import androidx.datastore.preferences.preferencesDataStore
|
||||
import androidx.fragment.app.Fragment
|
||||
import im.angry.openeuicc.OpenEuiccApplication
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.map
|
||||
import java.util.Base64
|
||||
|
||||
private val Context.dataStore: DataStore<Preferences> by preferencesDataStore(name = "prefs")
|
||||
|
||||
|
@ -19,34 +21,105 @@ val Context.preferenceRepository: PreferenceRepository
|
|||
val Fragment.preferenceRepository: PreferenceRepository
|
||||
get() = requireContext().preferenceRepository
|
||||
|
||||
object PreferenceKeys {
|
||||
internal object PreferenceKeys {
|
||||
// ---- Profile Notifications ----
|
||||
val NOTIFICATION_DOWNLOAD = booleanPreferencesKey("notification_download")
|
||||
val NOTIFICATION_DELETE = booleanPreferencesKey("notification_delete")
|
||||
val NOTIFICATION_SWITCH = booleanPreferencesKey("notification_switch")
|
||||
val DISABLE_SAFEGUARD_REMOVABLE_ESIM = booleanPreferencesKey("disable_safeguard_removable_esim")
|
||||
}
|
||||
|
||||
class PreferenceRepository(context: Context) {
|
||||
private val dataStore = context.dataStore
|
||||
|
||||
// Expose flows so that we can also handle default values
|
||||
// ---- Profile Notifications ----
|
||||
val notificationDownloadFlow: Flow<Boolean> =
|
||||
dataStore.data.map { it[PreferenceKeys.NOTIFICATION_DOWNLOAD] ?: true }
|
||||
|
||||
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 ----
|
||||
val disableSafeguardFlow: Flow<Boolean> =
|
||||
dataStore.data.map { it[PreferenceKeys.DISABLE_SAFEGUARD_REMOVABLE_ESIM] ?: false }
|
||||
val DISABLE_SAFEGUARD_REMOVABLE_ESIM = booleanPreferencesKey("disable_safeguard_removable_esim")
|
||||
val VERBOSE_LOGGING = booleanPreferencesKey("verbose_logging")
|
||||
|
||||
suspend fun <T> updatePreference(key: Preferences.Key<T>, value: T) {
|
||||
dataStore.edit {
|
||||
it[key] = value
|
||||
}
|
||||
// ---- 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")
|
||||
}
|
||||
|
||||
const val EUICC_DEFAULT_ISDR_AID = "A0000005591010FFFFFFFF8900000100"
|
||||
|
||||
internal object PreferenceConstants {
|
||||
val DEFAULT_AID_LIST = """
|
||||
# One AID per line. Comment lines start with #.
|
||||
# Refs: <https://euicc-manual.osmocom.org/docs/lpa/applet-id-oem/>
|
||||
|
||||
# eUICC standard
|
||||
$EUICC_DEFAULT_ISDR_AID
|
||||
|
||||
# eSTK.me
|
||||
A06573746B6D65FFFFFFFF4953442D52
|
||||
|
||||
# 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
|
||||
// ---- Profile Notifications ----
|
||||
val notificationDownloadFlow = bindFlow(PreferenceKeys.NOTIFICATION_DOWNLOAD, true)
|
||||
val notificationDeleteFlow = bindFlow(PreferenceKeys.NOTIFICATION_DELETE, true)
|
||||
val notificationSwitchFlow = bindFlow(PreferenceKeys.NOTIFICATION_SWITCH, false)
|
||||
|
||||
// ---- Advanced ----
|
||||
val disableSafeguardFlow = bindFlow(PreferenceKeys.DISABLE_SAFEGUARD_REMOVABLE_ESIM, false)
|
||||
val verboseLoggingFlow = bindFlow(PreferenceKeys.VERBOSE_LOGGING, false)
|
||||
|
||||
// ---- Developer Options ----
|
||||
val refreshAfterSwitchFlow = bindFlow(PreferenceKeys.REFRESH_AFTER_SWITCH, true)
|
||||
val developerOptionsEnabledFlow = bindFlow(PreferenceKeys.DEVELOPER_OPTIONS_ENABLED, false)
|
||||
val unfilteredProfileListFlow = bindFlow(PreferenceKeys.UNFILTERED_PROFILE_LIST, false)
|
||||
val ignoreTLSCertificateFlow = bindFlow(PreferenceKeys.IGNORE_TLS_CERTIFICATE, false)
|
||||
val 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() })
|
||||
|
||||
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
|
||||
|
||||
fun String.decodeHex(): ByteArray {
|
||||
check(length % 2 == 0) { "Must have an even length" }
|
||||
require(length % 2 == 0) { "Must have an even length" }
|
||||
|
||||
val decodedLength = length / 2
|
||||
val out = ByteArray(decodedLength)
|
||||
|
@ -28,3 +28,86 @@ fun formatFreeSpace(size: Int): String =
|
|||
} else {
|
||||
"$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()) }
|
||||
|
||||
fun String.prettyPrintJson(): String {
|
||||
val ret = StringBuilder()
|
||||
var inQuotes = false
|
||||
var escaped = false
|
||||
val indentSymbolStack = ArrayDeque<Char>()
|
||||
|
||||
val addNewLine = {
|
||||
ret.append('\n')
|
||||
repeat(indentSymbolStack.size) {
|
||||
ret.append('\t')
|
||||
}
|
||||
}
|
||||
|
||||
var lastChar = ' '
|
||||
|
||||
for (c in this) {
|
||||
when {
|
||||
!inQuotes && (c == '{' || c == '[') -> {
|
||||
ret.append(c)
|
||||
indentSymbolStack.addLast(c)
|
||||
addNewLine()
|
||||
}
|
||||
|
||||
!inQuotes && (c == '}' || c == ']') -> {
|
||||
indentSymbolStack.removeLast()
|
||||
if (lastChar != ',') {
|
||||
addNewLine()
|
||||
}
|
||||
ret.append(c)
|
||||
}
|
||||
|
||||
!inQuotes && c == ',' -> {
|
||||
ret.append(c)
|
||||
addNewLine()
|
||||
}
|
||||
|
||||
!inQuotes && c == ':' -> {
|
||||
ret.append(c)
|
||||
ret.append(' ')
|
||||
}
|
||||
|
||||
inQuotes && c == '\\' -> {
|
||||
ret.append(c)
|
||||
escaped = true
|
||||
continue
|
||||
}
|
||||
|
||||
!escaped && c == '"' -> {
|
||||
ret.append(c)
|
||||
inQuotes = !inQuotes
|
||||
}
|
||||
|
||||
!inQuotes && c == ' ' -> {
|
||||
// Do nothing -- we ignore spaces outside of quotes by default
|
||||
// This is to ensure predictable formatting
|
||||
}
|
||||
|
||||
else -> ret.append(c)
|
||||
}
|
||||
|
||||
if (escaped) {
|
||||
escaped = false
|
||||
}
|
||||
|
||||
lastChar = c
|
||||
}
|
||||
|
||||
return ret.toString()
|
||||
}
|
|
@ -45,6 +45,8 @@ fun SEService.getUiccReaderCompat(slotNumber: Int): Reader {
|
|||
interface UiccCardInfoCompat {
|
||||
val physicalSlotIndex: Int
|
||||
val ports: Collection<UiccPortInfoCompat>
|
||||
val isRemovable: Boolean
|
||||
get() = true // This defaults to removable unless overridden
|
||||
}
|
||||
|
||||
interface UiccPortInfoCompat {
|
||||
|
|
|
@ -1,9 +1,24 @@
|
|||
package im.angry.openeuicc.util
|
||||
|
||||
import android.content.ClipData
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.content.res.Resources
|
||||
import android.graphics.Rect
|
||||
import android.view.View
|
||||
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.Fragment
|
||||
import im.angry.openeuicc.common.R
|
||||
import java.io.FileOutputStream
|
||||
|
||||
// Source: <https://stackoverflow.com/questions/12478520/how-to-set-dialogfragments-width-and-height>
|
||||
/**
|
||||
|
@ -26,3 +41,84 @@ fun DialogFragment.setWidthPercent(percentage: Int) {
|
|||
fun DialogFragment.setFullScreen() {
|
||||
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(R.string.no) { _, _ -> }
|
||||
setPositiveButton(R.string.yes) { _, _ ->
|
||||
val intent = Intent(Intent.ACTION_SEND).apply {
|
||||
type = "text/plain"
|
||||
clipData = ClipData.newUri(context.contentResolver, lastFileName, uri)
|
||||
putExtra(Intent.EXTRA_TITLE, lastFileName)
|
||||
putExtra(Intent.EXTRA_STREAM, uri)
|
||||
addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION)
|
||||
}
|
||||
|
||||
context.startActivity(Intent.createChooser(intent, null))
|
||||
}
|
||||
}.show()
|
||||
}
|
||||
|
||||
return {
|
||||
lastFileName = getLogFileName()
|
||||
launchSaveIntent.launch(lastFileName)
|
||||
}
|
||||
}
|
|
@ -54,6 +54,9 @@ interface OpenEuiccContextMarker {
|
|||
val appContainer: AppContainer
|
||||
get() = openEuiccApplication.appContainer
|
||||
|
||||
val preferenceRepository: PreferenceRepository
|
||||
get() = appContainer.preferenceRepository
|
||||
|
||||
val telephonyManager: TelephonyManager
|
||||
get() = appContainer.telephonyManager
|
||||
}
|
||||
|
@ -86,6 +89,13 @@ 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? =
|
||||
runCatching {
|
||||
val pixels = IntArray(bmp.width * bmp.height)
|
||||
|
|
112
app-common/src/main/java/im/angry/openeuicc/util/Vendors.kt
Normal file
112
app-common/src/main/java/im/angry/openeuicc/util/Vendors.kt
Normal file
|
@ -0,0 +1,112 @@
|
|||
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
|
||||
)
|
||||
}
|
||||
}
|
6
app-common/src/main/res/anim/slide_in_left.xml
Normal file
6
app-common/src/main/res/anim/slide_in_left.xml
Normal file
|
@ -0,0 +1,6 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<translate xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:duration="@android:integer/config_shortAnimTime"
|
||||
android:interpolator="@android:anim/decelerate_interpolator"
|
||||
android:fromXDelta="-100%"
|
||||
android:toXDelta="0%" />
|
6
app-common/src/main/res/anim/slide_in_right.xml
Normal file
6
app-common/src/main/res/anim/slide_in_right.xml
Normal file
|
@ -0,0 +1,6 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<translate xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:duration="@android:integer/config_shortAnimTime"
|
||||
android:interpolator="@android:anim/decelerate_interpolator"
|
||||
android:fromXDelta="100%"
|
||||
android:toXDelta="0%" />
|
6
app-common/src/main/res/anim/slide_out_left.xml
Normal file
6
app-common/src/main/res/anim/slide_out_left.xml
Normal file
|
@ -0,0 +1,6 @@
|
|||
<!-- 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%" />
|
6
app-common/src/main/res/anim/slide_out_right.xml
Normal file
6
app-common/src/main/res/anim/slide_out_right.xml
Normal file
|
@ -0,0 +1,6 @@
|
|||
<!-- 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%" />
|
5
app-common/src/main/res/drawable/ic_chevron_left.xml
Normal file
5
app-common/src/main/res/drawable/ic_chevron_left.xml
Normal file
|
@ -0,0 +1,5 @@
|
|||
<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>
|
5
app-common/src/main/res/drawable/ic_chevron_right.xml
Normal file
5
app-common/src/main/res/drawable/ic_chevron_right.xml
Normal file
|
@ -0,0 +1,5 @@
|
|||
<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>
|
5
app-common/src/main/res/drawable/ic_edit.xml
Normal file
5
app-common/src/main/res/drawable/ic_edit.xml
Normal file
|
@ -0,0 +1,5 @@
|
|||
<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>
|
18
app-common/src/main/res/drawable/ic_euicc_memory_reset.xml
Normal file
18
app-common/src/main/res/drawable/ic_euicc_memory_reset.xml
Normal file
|
@ -0,0 +1,18 @@
|
|||
<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>
|
7
app-common/src/main/res/drawable/ic_paste_go.xml
Normal file
7
app-common/src/main/res/drawable/ic_paste_go.xml
Normal file
|
@ -0,0 +1,7 @@
|
|||
<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>
|
5
app-common/src/main/res/drawable/ic_task_delete.xml
Normal file
5
app-common/src/main/res/drawable/ic_task_delete.xml
Normal file
|
@ -0,0 +1,5 @@
|
|||
<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>
|
5
app-common/src/main/res/drawable/ic_task_rename.xml
Normal file
5
app-common/src/main/res/drawable/ic_task_rename.xml
Normal file
|
@ -0,0 +1,5 @@
|
|||
<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>
|
|
@ -0,0 +1,5 @@
|
|||
<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>
|
5
app-common/src/main/res/drawable/ic_task_switch.xml
Normal file
5
app-common/src/main/res/drawable/ic_task_switch.xml
Normal file
|
@ -0,0 +1,5 @@
|
|||
<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>
|
5
app-common/src/main/res/drawable/ic_x_black.xml
Normal file
5
app-common/src/main/res/drawable/ic_x_black.xml
Normal file
|
@ -0,0 +1,5 @@
|
|||
<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>
|
74
app-common/src/main/res/layout/activity_download_wizard.xml
Normal file
74
app-common/src/main/res/layout/activity_download_wizard.xml
Normal file
|
@ -0,0 +1,74 @@
|
|||
<?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>
|
25
app-common/src/main/res/layout/activity_euicc_info.xml
Normal file
25
app-common/src/main/res/layout/activity_euicc_info.xml
Normal file
|
@ -0,0 +1,25 @@
|
|||
<?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>
|
24
app-common/src/main/res/layout/activity_isdr_aid_list.xml
Normal file
24
app-common/src/main/res/layout/activity_isdr_aid_list.xml
Normal file
|
@ -0,0 +1,24 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
xmlns:tools="http://schemas.android.com/tools"
|
||||
xmlns:app="http://schemas.android.com/apk/res-auto"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="match_parent">
|
||||
|
||||
<include layout="@layout/toolbar_activity" />
|
||||
|
||||
<EditText
|
||||
android:id="@+id/isdr_aid_list_editor"
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="0dp"
|
||||
android:fontFamily="monospace"
|
||||
android:importantForAutofill="no"
|
||||
android:inputType="textMultiLine"
|
||||
android:gravity="top|start"
|
||||
app:layout_constraintTop_toBottomOf="@id/toolbar"
|
||||
app:layout_constraintStart_toStartOf="parent"
|
||||
app:layout_constraintEnd_toEndOf="parent"
|
||||
app:layout_constraintBottom_toBottomOf="parent"
|
||||
tools:ignore="LabelFor" />
|
||||
|
||||
</androidx.constraintlayout.widget.ConstraintLayout>
|
|
@ -5,13 +5,7 @@
|
|||
android:layout_height="match_parent"
|
||||
xmlns:app="http://schemas.android.com/apk/res-auto">
|
||||
|
||||
<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" />
|
||||
<include layout="@layout/toolbar_activity" />
|
||||
|
||||
<androidx.swiperefreshlayout.widget.SwipeRefreshLayout
|
||||
android:id="@+id/swipe_refresh"
|
||||
|
|
|
@ -5,13 +5,7 @@
|
|||
android:layout_width="match_parent"
|
||||
android:layout_height="match_parent">
|
||||
|
||||
<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" />
|
||||
<include layout="@layout/toolbar_activity" />
|
||||
|
||||
<com.google.android.material.tabs.TabLayout
|
||||
android:id="@+id/main_tabs"
|
||||
|
|
|
@ -4,13 +4,7 @@
|
|||
android:layout_height="match_parent"
|
||||
xmlns:app="http://schemas.android.com/apk/res-auto">
|
||||
|
||||
<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" />
|
||||
<include layout="@layout/toolbar_activity" />
|
||||
|
||||
<androidx.swiperefreshlayout.widget.SwipeRefreshLayout
|
||||
android:id="@+id/swipe_refresh"
|
||||
|
|
|
@ -4,13 +4,7 @@
|
|||
android:layout_height="match_parent"
|
||||
xmlns:app="http://schemas.android.com/apk/res-auto">
|
||||
|
||||
<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" />
|
||||
<include layout="@layout/toolbar_activity" />
|
||||
|
||||
<FrameLayout
|
||||
android:id="@+id/settings_container"
|
||||
|
|
44
app-common/src/main/res/layout/download_method_item.xml
Normal file
44
app-common/src/main/res/layout/download_method_item.xml
Normal file
|
@ -0,0 +1,44 @@
|
|||
<?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>
|
45
app-common/src/main/res/layout/download_progress_item.xml
Normal file
45
app-common/src/main/res/layout/download_progress_item.xml
Normal file
|
@ -0,0 +1,45 @@
|
|||
<?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">
|
||||
|
||||
<TextView
|
||||
android:id="@+id/download_progress_item_title"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_margin="20dp"
|
||||
android:textSize="14sp"
|
||||
app:layout_constraintTop_toTopOf="parent"
|
||||
app:layout_constraintStart_toStartOf="parent"
|
||||
app:layout_constraintBottom_toBottomOf="parent"
|
||||
app:layout_constraintEnd_toStartOf="@id/download_progress_icon_container"
|
||||
app:layout_constrainedWidth="true"
|
||||
app:layout_constraintHorizontal_bias="0.0" />
|
||||
|
||||
<FrameLayout
|
||||
android:id="@+id/download_progress_icon_container"
|
||||
android:layout_margin="20dp"
|
||||
android:layout_width="30dp"
|
||||
android:layout_height="30dp"
|
||||
app:layout_constraintTop_toTopOf="parent"
|
||||
app:layout_constraintBottom_toBottomOf="parent"
|
||||
app:layout_constraintEnd_toEndOf="parent">
|
||||
|
||||
<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>
|
||||
|
||||
</androidx.constraintlayout.widget.ConstraintLayout>
|
108
app-common/src/main/res/layout/download_slot_item.xml
Normal file
108
app-common/src/main/res/layout/download_slot_item.xml
Normal file
|
@ -0,0 +1,108 @@
|
|||
<?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>
|
30
app-common/src/main/res/layout/euicc_info_item.xml
Normal file
30
app-common/src/main/res/layout/euicc_info_item.xml
Normal file
|
@ -0,0 +1,30 @@
|
|||
<?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,7 +28,8 @@
|
|||
app:layout_constraintRight_toLeftOf="@+id/profile_menu"
|
||||
app:layout_constraintTop_toTopOf="parent"
|
||||
app:layout_constraintBottom_toTopOf="@+id/state"
|
||||
app:layout_constraintHorizontal_bias="0" />
|
||||
app:layout_constraintHorizontal_bias="0"
|
||||
app:layout_constrainedWidth="true" />
|
||||
|
||||
<androidx.appcompat.widget.AppCompatImageButton
|
||||
android:id="@+id/profile_menu"
|
||||
|
@ -62,18 +63,45 @@
|
|||
android:singleLine="true"
|
||||
app:layout_constraintLeft_toLeftOf="parent"
|
||||
app:layout_constraintTop_toBottomOf="@id/state"
|
||||
app:layout_constraintBottom_toTopOf="@+id/iccid_label"/>
|
||||
app:layout_constraintBottom_toTopOf="@+id/profile_class_label"/>
|
||||
|
||||
<TextView
|
||||
android:id="@+id/provider"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginTop="6dp"
|
||||
android:layout_marginLeft="7dp"
|
||||
android:layout_marginStart="7dp"
|
||||
android:textSize="14sp"
|
||||
android:singleLine="true"
|
||||
app:layout_constraintLeft_toRightOf="@id/provider_label"
|
||||
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"/>
|
||||
|
||||
<TextView
|
||||
|
@ -86,7 +114,7 @@
|
|||
android:textStyle="bold"
|
||||
android:singleLine="true"
|
||||
app:layout_constraintLeft_toLeftOf="parent"
|
||||
app:layout_constraintTop_toBottomOf="@id/provider_label"
|
||||
app:layout_constraintTop_toBottomOf="@id/profile_class_label"
|
||||
app:layout_constraintBottom_toBottomOf="parent"/>
|
||||
|
||||
<TextView
|
||||
|
@ -94,11 +122,11 @@
|
|||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginTop="6dp"
|
||||
android:layout_marginLeft="7dp"
|
||||
android:layout_marginStart="7dp"
|
||||
android:textSize="14sp"
|
||||
android:singleLine="true"
|
||||
app:layout_constraintLeft_toRightOf="@id/iccid_label"
|
||||
app:layout_constraintTop_toBottomOf="@id/provider"
|
||||
app:layout_constraintTop_toBottomOf="@id/profile_class"
|
||||
app:layout_constraintBottom_toBottomOf="parent"/>
|
||||
|
||||
</androidx.constraintlayout.widget.ConstraintLayout>
|
||||
|
|
104
app-common/src/main/res/layout/fragment_download_details.xml
Normal file
104
app-common/src/main/res/layout/fragment_download_details.xml
Normal file
|
@ -0,0 +1,104 @@
|
|||
<?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>
|
|
@ -0,0 +1,59 @@
|
|||
<?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>
|
|
@ -0,0 +1,33 @@
|
|||
<?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>
|
Some files were not shown because too many files have changed in this diff Show more
Loading…
Add table
Reference in a new issue