forked from PeterCxy/OpenEUICC
Compare commits
397 commits
Author | SHA1 | Date | |
---|---|---|---|
21c04ed179 | |||
db4645b17f | |||
149a19ca1c | |||
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 | |||
b55df25650 | |||
c84b114de0 | |||
47c6bbb864 | |||
27627fdf15 | |||
7b605fd44a | |||
323c6a5bc0 | |||
e1877f5d3a | |||
154ac77203 | |||
5300178a0e | |||
26abb5cc43 | |||
67927cf7ef | |||
829f019aa2 | |||
f84baa02f0 | |||
eac33575ce | |||
0ecf4e9776 | |||
b16a72f43a | |||
293266bb6e | |||
b2fb3d23cb | |||
388f15a531 | |||
3ffd847af7 | |||
d78985bd72 | |||
9c7cf2c797 | |||
960fa6743a | |||
09a8db330b | |||
3f0e789997 | |||
c7afeea845 | |||
083ee03f8e | |||
1a1364f788 | |||
8210f06098 | |||
102902386c | |||
576ede455d | |||
fc9f962061 | |||
41f16e03cf | |||
ec5e5d0416 | |||
2bd5f007d9 | |||
17aebc2c73 | |||
9f6df17a1d | |||
c32ae6946d | |||
bc238cc680 | |||
d1d85161cc | |||
0a971b68b2 | |||
b67791412a | |||
3960a2d9d8 | |||
70f20f9de8 | |||
ccf21675d6 | |||
87fc1cd2f8 | |||
3667f578d7 | |||
803b88f74e | |||
6396f17012 | |||
4790f87b65 | |||
a29b068035 | |||
f3391bb8ee | |||
4914a35c3d | |||
113cc1b4cf | |||
c8d2269efb | |||
790a5cf778 | |||
261ad6dbeb | |||
f73af48b59 | |||
df9cece94b | |||
0e86a922d1 | |||
2b3f042e39 | |||
ff427759d7 | |||
20cdb99a7b | |||
01e1b2b9a4 | |||
1536343b3f | |||
051bb9f1e3 | |||
5498186cf1 | |||
2d312f2216 | |||
3869374140 | |||
fc4e5739de | |||
8eb36c77a8 | |||
1a22854d05 | |||
5785fe2e7c | |||
043dff0f0a | |||
59f3597874 | |||
0f655f1f1f | |||
1f6bad4222 | |||
03e6380570 | |||
bf121e07a4 | |||
8b38a5a58d | |||
6e590cfd48 | |||
35e543ff70 | |||
f046d40f2c | |||
80adac68c8 | |||
1ed5a4de38 | |||
4b842c4afe | |||
2517fc817e | |||
e48f9aa828 | |||
1ac683f9ab | |||
7834e0348a | |||
92d8f9079f | |||
d9d0cf2e75 | |||
999462c294 | |||
2061e6fea3 | |||
6977a32e80 | |||
3a0d805eb2 | |||
348395c48d | |||
124d1690ab | |||
8ee3c53492 |
215 changed files with 10132 additions and 2260 deletions
|
@ -1,7 +1,7 @@
|
|||
on:
|
||||
push:
|
||||
branches:
|
||||
- 'master'
|
||||
- '*'
|
||||
|
||||
jobs:
|
||||
build-debug:
|
||||
|
@ -14,6 +14,7 @@ jobs:
|
|||
uses: https://gitea.angry.im/actions/checkout@v3
|
||||
with:
|
||||
submodules: recursive
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Decode Secret Signing Configuration
|
||||
uses: https://gitea.angry.im/actions/base64-to-file@v1
|
||||
|
@ -34,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
|
||||
|
|
|
@ -2,10 +2,6 @@ on:
|
|||
push:
|
||||
tags: '*'
|
||||
|
||||
env:
|
||||
# Enable reproducibility-related build system workarounds
|
||||
REPRODUCIBLE_BUILD: true
|
||||
|
||||
jobs:
|
||||
release:
|
||||
runs-on: [docker, android-app-certs]
|
||||
|
@ -17,6 +13,7 @@ jobs:
|
|||
uses: https://gitea.angry.im/actions/checkout@v3
|
||||
with:
|
||||
submodules: recursive
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Decode Secret Signing Configuration
|
||||
uses: https://gitea.angry.im/actions/base64-to-file@v1
|
||||
|
@ -37,6 +34,9 @@ jobs:
|
|||
- name: Build Release APK (Unprivileged / EasyEUICC only)
|
||||
run: ./gradlew --no-daemon :app-unpriv:assembleRelease
|
||||
|
||||
- name: Copy Debug Symbols to Release Path
|
||||
run: cp app-unpriv/build/outputs/native-debug-symbols/release/native-debug-symbols.zip app-unpriv/build/outputs/apk/release/
|
||||
|
||||
- name: Create Release
|
||||
uses: https://gitea.angry.im/actions/forgejo-release@v1
|
||||
with:
|
||||
|
|
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>
|
833
LICENSE
833
LICENSE
|
@ -1,281 +1,622 @@
|
|||
GNU GENERAL PUBLIC LICENSE
|
||||
Version 2, June 1991
|
||||
Version 3, 29 June 2007
|
||||
|
||||
Copyright (C) 1989, 1991 Free Software Foundation, Inc.,
|
||||
51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA
|
||||
Copyright (C) 2007 Free Software Foundation, Inc. <https://fsf.org/>
|
||||
Everyone is permitted to copy and distribute verbatim copies
|
||||
of this license document, but changing it is not allowed.
|
||||
|
||||
Preamble
|
||||
|
||||
The licenses for most software are designed to take away your
|
||||
freedom to share and change it. By contrast, the GNU General Public
|
||||
License is intended to guarantee your freedom to share and change free
|
||||
software--to make sure the software is free for all its users. This
|
||||
General Public License applies to most of the Free Software
|
||||
Foundation's software and to any other program whose authors commit to
|
||||
using it. (Some other Free Software Foundation software is covered by
|
||||
the GNU Lesser General Public License instead.) You can apply it to
|
||||
The GNU General Public License is a free, copyleft license for
|
||||
software and other kinds of works.
|
||||
|
||||
The licenses for most software and other practical works are designed
|
||||
to take away your freedom to share and change the works. By contrast,
|
||||
the GNU General Public License is intended to guarantee your freedom to
|
||||
share and change all versions of a program--to make sure it remains free
|
||||
software for all its users. We, the Free Software Foundation, use the
|
||||
GNU General Public License for most of our software; it applies also to
|
||||
any other work released this way by its authors. You can apply it to
|
||||
your programs, too.
|
||||
|
||||
When we speak of free software, we are referring to freedom, not
|
||||
price. Our General Public Licenses are designed to make sure that you
|
||||
have the freedom to distribute copies of free software (and charge for
|
||||
this service if you wish), that you receive source code or can get it
|
||||
if you want it, that you can change the software or use pieces of it
|
||||
in new free programs; and that you know you can do these things.
|
||||
them if you wish), that you receive source code or can get it if you
|
||||
want it, that you can change the software or use pieces of it in new
|
||||
free programs, and that you know you can do these things.
|
||||
|
||||
To protect your rights, we need to make restrictions that forbid
|
||||
anyone to deny you these rights or to ask you to surrender the rights.
|
||||
These restrictions translate to certain responsibilities for you if you
|
||||
distribute copies of the software, or if you modify it.
|
||||
To protect your rights, we need to prevent others from denying you
|
||||
these rights or asking you to surrender the rights. Therefore, you have
|
||||
certain responsibilities if you distribute copies of the software, or if
|
||||
you modify it: responsibilities to respect the freedom of others.
|
||||
|
||||
For example, if you distribute copies of such a program, whether
|
||||
gratis or for a fee, you must give the recipients all the rights that
|
||||
you have. You must make sure that they, too, receive or can get the
|
||||
source code. And you must show them these terms so they know their
|
||||
rights.
|
||||
gratis or for a fee, you must pass on to the recipients the same
|
||||
freedoms that you received. You must make sure that they, too, receive
|
||||
or can get the source code. And you must show them these terms so they
|
||||
know their rights.
|
||||
|
||||
We protect your rights with two steps: (1) copyright the software, and
|
||||
(2) offer you this license which gives you legal permission to copy,
|
||||
distribute and/or modify the software.
|
||||
Developers that use the GNU GPL protect your rights with two steps:
|
||||
(1) assert copyright on the software, and (2) offer you this License
|
||||
giving you legal permission to copy, distribute and/or modify it.
|
||||
|
||||
Also, for each author's protection and ours, we want to make certain
|
||||
that everyone understands that there is no warranty for this free
|
||||
software. If the software is modified by someone else and passed on, we
|
||||
want its recipients to know that what they have is not the original, so
|
||||
that any problems introduced by others will not reflect on the original
|
||||
authors' reputations.
|
||||
For the developers' and authors' protection, the GPL clearly explains
|
||||
that there is no warranty for this free software. For both users' and
|
||||
authors' sake, the GPL requires that modified versions be marked as
|
||||
changed, so that their problems will not be attributed erroneously to
|
||||
authors of previous versions.
|
||||
|
||||
Finally, any free program is threatened constantly by software
|
||||
patents. We wish to avoid the danger that redistributors of a free
|
||||
program will individually obtain patent licenses, in effect making the
|
||||
program proprietary. To prevent this, we have made it clear that any
|
||||
patent must be licensed for everyone's free use or not licensed at all.
|
||||
Some devices are designed to deny users access to install or run
|
||||
modified versions of the software inside them, although the manufacturer
|
||||
can do so. This is fundamentally incompatible with the aim of
|
||||
protecting users' freedom to change the software. The systematic
|
||||
pattern of such abuse occurs in the area of products for individuals to
|
||||
use, which is precisely where it is most unacceptable. Therefore, we
|
||||
have designed this version of the GPL to prohibit the practice for those
|
||||
products. If such problems arise substantially in other domains, we
|
||||
stand ready to extend this provision to those domains in future versions
|
||||
of the GPL, as needed to protect the freedom of users.
|
||||
|
||||
Finally, every program is threatened constantly by software patents.
|
||||
States should not allow patents to restrict development and use of
|
||||
software on general-purpose computers, but in those that do, we wish to
|
||||
avoid the special danger that patents applied to a free program could
|
||||
make it effectively proprietary. To prevent this, the GPL assures that
|
||||
patents cannot be used to render the program non-free.
|
||||
|
||||
The precise terms and conditions for copying, distribution and
|
||||
modification follow.
|
||||
|
||||
GNU GENERAL PUBLIC LICENSE
|
||||
TERMS AND CONDITIONS FOR COPYING, DISTRIBUTION AND MODIFICATION
|
||||
TERMS AND CONDITIONS
|
||||
|
||||
0. This License applies to any program or other work which contains
|
||||
a notice placed by the copyright holder saying it may be distributed
|
||||
under the terms of this General Public License. The "Program", below,
|
||||
refers to any such program or work, and a "work based on the Program"
|
||||
means either the Program or any derivative work under copyright law:
|
||||
that is to say, a work containing the Program or a portion of it,
|
||||
either verbatim or with modifications and/or translated into another
|
||||
language. (Hereinafter, translation is included without limitation in
|
||||
the term "modification".) Each licensee is addressed as "you".
|
||||
0. Definitions.
|
||||
|
||||
Activities other than copying, distribution and modification are not
|
||||
covered by this License; they are outside its scope. The act of
|
||||
running the Program is not restricted, and the output from the Program
|
||||
is covered only if its contents constitute a work based on the
|
||||
Program (independent of having been made by running the Program).
|
||||
Whether that is true depends on what the Program does.
|
||||
"This License" refers to version 3 of the GNU General Public License.
|
||||
|
||||
1. You may copy and distribute verbatim copies of the Program's
|
||||
source code as you receive it, in any medium, provided that you
|
||||
conspicuously and appropriately publish on each copy an appropriate
|
||||
copyright notice and disclaimer of warranty; keep intact all the
|
||||
notices that refer to this License and to the absence of any warranty;
|
||||
and give any other recipients of the Program a copy of this License
|
||||
along with the Program.
|
||||
"Copyright" also means copyright-like laws that apply to other kinds of
|
||||
works, such as semiconductor masks.
|
||||
|
||||
You may charge a fee for the physical act of transferring a copy, and
|
||||
you may at your option offer warranty protection in exchange for a fee.
|
||||
"The Program" refers to any copyrightable work licensed under this
|
||||
License. Each licensee is addressed as "you". "Licensees" and
|
||||
"recipients" may be individuals or organizations.
|
||||
|
||||
2. You may modify your copy or copies of the Program or any portion
|
||||
of it, thus forming a work based on the Program, and copy and
|
||||
distribute such modifications or work under the terms of Section 1
|
||||
above, provided that you also meet all of these conditions:
|
||||
To "modify" a work means to copy from or adapt all or part of the work
|
||||
in a fashion requiring copyright permission, other than the making of an
|
||||
exact copy. The resulting work is called a "modified version" of the
|
||||
earlier work or a work "based on" the earlier work.
|
||||
|
||||
a) You must cause the modified files to carry prominent notices
|
||||
stating that you changed the files and the date of any change.
|
||||
A "covered work" means either the unmodified Program or a work based
|
||||
on the Program.
|
||||
|
||||
b) You must cause any work that you distribute or publish, that in
|
||||
whole or in part contains or is derived from the Program or any
|
||||
part thereof, to be licensed as a whole at no charge to all third
|
||||
parties under the terms of this License.
|
||||
To "propagate" a work means to do anything with it that, without
|
||||
permission, would make you directly or secondarily liable for
|
||||
infringement under applicable copyright law, except executing it on a
|
||||
computer or modifying a private copy. Propagation includes copying,
|
||||
distribution (with or without modification), making available to the
|
||||
public, and in some countries other activities as well.
|
||||
|
||||
c) If the modified program normally reads commands interactively
|
||||
when run, you must cause it, when started running for such
|
||||
interactive use in the most ordinary way, to print or display an
|
||||
announcement including an appropriate copyright notice and a
|
||||
notice that there is no warranty (or else, saying that you provide
|
||||
a warranty) and that users may redistribute the program under
|
||||
these conditions, and telling the user how to view a copy of this
|
||||
License. (Exception: if the Program itself is interactive but
|
||||
does not normally print such an announcement, your work based on
|
||||
the Program is not required to print an announcement.)
|
||||
To "convey" a work means any kind of propagation that enables other
|
||||
parties to make or receive copies. Mere interaction with a user through
|
||||
a computer network, with no transfer of a copy, is not conveying.
|
||||
|
||||
These requirements apply to the modified work as a whole. If
|
||||
identifiable sections of that work are not derived from the Program,
|
||||
and can be reasonably considered independent and separate works in
|
||||
themselves, then this License, and its terms, do not apply to those
|
||||
sections when you distribute them as separate works. But when you
|
||||
distribute the same sections as part of a whole which is a work based
|
||||
on the Program, the distribution of the whole must be on the terms of
|
||||
this License, whose permissions for other licensees extend to the
|
||||
entire whole, and thus to each and every part regardless of who wrote it.
|
||||
An interactive user interface displays "Appropriate Legal Notices"
|
||||
to the extent that it includes a convenient and prominently visible
|
||||
feature that (1) displays an appropriate copyright notice, and (2)
|
||||
tells the user that there is no warranty for the work (except to the
|
||||
extent that warranties are provided), that licensees may convey the
|
||||
work under this License, and how to view a copy of this License. If
|
||||
the interface presents a list of user commands or options, such as a
|
||||
menu, a prominent item in the list meets this criterion.
|
||||
|
||||
Thus, it is not the intent of this section to claim rights or contest
|
||||
your rights to work written entirely by you; rather, the intent is to
|
||||
exercise the right to control the distribution of derivative or
|
||||
collective works based on the Program.
|
||||
1. Source Code.
|
||||
|
||||
In addition, mere aggregation of another work not based on the Program
|
||||
with the Program (or with a work based on the Program) on a volume of
|
||||
a storage or distribution medium does not bring the other work under
|
||||
the scope of this License.
|
||||
The "source code" for a work means the preferred form of the work
|
||||
for making modifications to it. "Object code" means any non-source
|
||||
form of a work.
|
||||
|
||||
3. You may copy and distribute the Program (or a work based on it,
|
||||
under Section 2) in object code or executable form under the terms of
|
||||
Sections 1 and 2 above provided that you also do one of the following:
|
||||
A "Standard Interface" means an interface that either is an official
|
||||
standard defined by a recognized standards body, or, in the case of
|
||||
interfaces specified for a particular programming language, one that
|
||||
is widely used among developers working in that language.
|
||||
|
||||
a) Accompany it with the complete corresponding machine-readable
|
||||
source code, which must be distributed under the terms of Sections
|
||||
1 and 2 above on a medium customarily used for software interchange; or,
|
||||
The "System Libraries" of an executable work include anything, other
|
||||
than the work as a whole, that (a) is included in the normal form of
|
||||
packaging a Major Component, but which is not part of that Major
|
||||
Component, and (b) serves only to enable use of the work with that
|
||||
Major Component, or to implement a Standard Interface for which an
|
||||
implementation is available to the public in source code form. A
|
||||
"Major Component", in this context, means a major essential component
|
||||
(kernel, window system, and so on) of the specific operating system
|
||||
(if any) on which the executable work runs, or a compiler used to
|
||||
produce the work, or an object code interpreter used to run it.
|
||||
|
||||
b) Accompany it with a written offer, valid for at least three
|
||||
years, to give any third party, for a charge no more than your
|
||||
cost of physically performing source distribution, a complete
|
||||
machine-readable copy of the corresponding source code, to be
|
||||
distributed under the terms of Sections 1 and 2 above on a medium
|
||||
customarily used for software interchange; or,
|
||||
The "Corresponding Source" for a work in object code form means all
|
||||
the source code needed to generate, install, and (for an executable
|
||||
work) run the object code and to modify the work, including scripts to
|
||||
control those activities. However, it does not include the work's
|
||||
System Libraries, or general-purpose tools or generally available free
|
||||
programs which are used unmodified in performing those activities but
|
||||
which are not part of the work. For example, Corresponding Source
|
||||
includes interface definition files associated with source files for
|
||||
the work, and the source code for shared libraries and dynamically
|
||||
linked subprograms that the work is specifically designed to require,
|
||||
such as by intimate data communication or control flow between those
|
||||
subprograms and other parts of the work.
|
||||
|
||||
c) Accompany it with the information you received as to the offer
|
||||
to distribute corresponding source code. (This alternative is
|
||||
allowed only for noncommercial distribution and only if you
|
||||
received the program in object code or executable form with such
|
||||
an offer, in accord with Subsection b above.)
|
||||
The Corresponding Source need not include anything that users
|
||||
can regenerate automatically from other parts of the Corresponding
|
||||
Source.
|
||||
|
||||
The source code for a work means the preferred form of the work for
|
||||
making modifications to it. For an executable work, complete source
|
||||
code means all the source code for all modules it contains, plus any
|
||||
associated interface definition files, plus the scripts used to
|
||||
control compilation and installation of the executable. However, as a
|
||||
special exception, the source code distributed need not include
|
||||
anything that is normally distributed (in either source or binary
|
||||
form) with the major components (compiler, kernel, and so on) of the
|
||||
operating system on which the executable runs, unless that component
|
||||
itself accompanies the executable.
|
||||
The Corresponding Source for a work in source code form is that
|
||||
same work.
|
||||
|
||||
If distribution of executable or object code is made by offering
|
||||
access to copy from a designated place, then offering equivalent
|
||||
access to copy the source code from the same place counts as
|
||||
distribution of the source code, even though third parties are not
|
||||
compelled to copy the source along with the object code.
|
||||
2. Basic Permissions.
|
||||
|
||||
4. You may not copy, modify, sublicense, or distribute the Program
|
||||
except as expressly provided under this License. Any attempt
|
||||
otherwise to copy, modify, sublicense or distribute the Program is
|
||||
void, and will automatically terminate your rights under this License.
|
||||
However, parties who have received copies, or rights, from you under
|
||||
this License will not have their licenses terminated so long as such
|
||||
parties remain in full compliance.
|
||||
All rights granted under this License are granted for the term of
|
||||
copyright on the Program, and are irrevocable provided the stated
|
||||
conditions are met. This License explicitly affirms your unlimited
|
||||
permission to run the unmodified Program. The output from running a
|
||||
covered work is covered by this License only if the output, given its
|
||||
content, constitutes a covered work. This License acknowledges your
|
||||
rights of fair use or other equivalent, as provided by copyright law.
|
||||
|
||||
5. You are not required to accept this License, since you have not
|
||||
signed it. However, nothing else grants you permission to modify or
|
||||
distribute the Program or its derivative works. These actions are
|
||||
prohibited by law if you do not accept this License. Therefore, by
|
||||
modifying or distributing the Program (or any work based on the
|
||||
Program), you indicate your acceptance of this License to do so, and
|
||||
all its terms and conditions for copying, distributing or modifying
|
||||
the Program or works based on it.
|
||||
You may make, run and propagate covered works that you do not
|
||||
convey, without conditions so long as your license otherwise remains
|
||||
in force. You may convey covered works to others for the sole purpose
|
||||
of having them make modifications exclusively for you, or provide you
|
||||
with facilities for running those works, provided that you comply with
|
||||
the terms of this License in conveying all material for which you do
|
||||
not control copyright. Those thus making or running the covered works
|
||||
for you must do so exclusively on your behalf, under your direction
|
||||
and control, on terms that prohibit them from making any copies of
|
||||
your copyrighted material outside their relationship with you.
|
||||
|
||||
6. Each time you redistribute the Program (or any work based on the
|
||||
Program), the recipient automatically receives a license from the
|
||||
original licensor to copy, distribute or modify the Program subject to
|
||||
these terms and conditions. You may not impose any further
|
||||
restrictions on the recipients' exercise of the rights granted herein.
|
||||
You are not responsible for enforcing compliance by third parties to
|
||||
Conveying under any other circumstances is permitted solely under
|
||||
the conditions stated below. Sublicensing is not allowed; section 10
|
||||
makes it unnecessary.
|
||||
|
||||
3. Protecting Users' Legal Rights From Anti-Circumvention Law.
|
||||
|
||||
No covered work shall be deemed part of an effective technological
|
||||
measure under any applicable law fulfilling obligations under article
|
||||
11 of the WIPO copyright treaty adopted on 20 December 1996, or
|
||||
similar laws prohibiting or restricting circumvention of such
|
||||
measures.
|
||||
|
||||
When you convey a covered work, you waive any legal power to forbid
|
||||
circumvention of technological measures to the extent such circumvention
|
||||
is effected by exercising rights under this License with respect to
|
||||
the covered work, and you disclaim any intention to limit operation or
|
||||
modification of the work as a means of enforcing, against the work's
|
||||
users, your or third parties' legal rights to forbid circumvention of
|
||||
technological measures.
|
||||
|
||||
4. Conveying Verbatim Copies.
|
||||
|
||||
You may convey verbatim copies of the Program's source code as you
|
||||
receive it, in any medium, provided that you conspicuously and
|
||||
appropriately publish on each copy an appropriate copyright notice;
|
||||
keep intact all notices stating that this License and any
|
||||
non-permissive terms added in accord with section 7 apply to the code;
|
||||
keep intact all notices of the absence of any warranty; and give all
|
||||
recipients a copy of this License along with the Program.
|
||||
|
||||
You may charge any price or no price for each copy that you convey,
|
||||
and you may offer support or warranty protection for a fee.
|
||||
|
||||
5. Conveying Modified Source Versions.
|
||||
|
||||
You may convey a work based on the Program, or the modifications to
|
||||
produce it from the Program, in the form of source code under the
|
||||
terms of section 4, provided that you also meet all of these conditions:
|
||||
|
||||
a) The work must carry prominent notices stating that you modified
|
||||
it, and giving a relevant date.
|
||||
|
||||
b) The work must carry prominent notices stating that it is
|
||||
released under this License and any conditions added under section
|
||||
7. This requirement modifies the requirement in section 4 to
|
||||
"keep intact all notices".
|
||||
|
||||
c) You must license the entire work, as a whole, under this
|
||||
License to anyone who comes into possession of a copy. This
|
||||
License will therefore apply, along with any applicable section 7
|
||||
additional terms, to the whole of the work, and all its parts,
|
||||
regardless of how they are packaged. This License gives no
|
||||
permission to license the work in any other way, but it does not
|
||||
invalidate such permission if you have separately received it.
|
||||
|
||||
d) If the work has interactive user interfaces, each must display
|
||||
Appropriate Legal Notices; however, if the Program has interactive
|
||||
interfaces that do not display Appropriate Legal Notices, your
|
||||
work need not make them do so.
|
||||
|
||||
A compilation of a covered work with other separate and independent
|
||||
works, which are not by their nature extensions of the covered work,
|
||||
and which are not combined with it such as to form a larger program,
|
||||
in or on a volume of a storage or distribution medium, is called an
|
||||
"aggregate" if the compilation and its resulting copyright are not
|
||||
used to limit the access or legal rights of the compilation's users
|
||||
beyond what the individual works permit. Inclusion of a covered work
|
||||
in an aggregate does not cause this License to apply to the other
|
||||
parts of the aggregate.
|
||||
|
||||
6. Conveying Non-Source Forms.
|
||||
|
||||
You may convey a covered work in object code form under the terms
|
||||
of sections 4 and 5, provided that you also convey the
|
||||
machine-readable Corresponding Source under the terms of this License,
|
||||
in one of these ways:
|
||||
|
||||
a) Convey the object code in, or embodied in, a physical product
|
||||
(including a physical distribution medium), accompanied by the
|
||||
Corresponding Source fixed on a durable physical medium
|
||||
customarily used for software interchange.
|
||||
|
||||
b) Convey the object code in, or embodied in, a physical product
|
||||
(including a physical distribution medium), accompanied by a
|
||||
written offer, valid for at least three years and valid for as
|
||||
long as you offer spare parts or customer support for that product
|
||||
model, to give anyone who possesses the object code either (1) a
|
||||
copy of the Corresponding Source for all the software in the
|
||||
product that is covered by this License, on a durable physical
|
||||
medium customarily used for software interchange, for a price no
|
||||
more than your reasonable cost of physically performing this
|
||||
conveying of source, or (2) access to copy the
|
||||
Corresponding Source from a network server at no charge.
|
||||
|
||||
c) Convey individual copies of the object code with a copy of the
|
||||
written offer to provide the Corresponding Source. This
|
||||
alternative is allowed only occasionally and noncommercially, and
|
||||
only if you received the object code with such an offer, in accord
|
||||
with subsection 6b.
|
||||
|
||||
d) Convey the object code by offering access from a designated
|
||||
place (gratis or for a charge), and offer equivalent access to the
|
||||
Corresponding Source in the same way through the same place at no
|
||||
further charge. You need not require recipients to copy the
|
||||
Corresponding Source along with the object code. If the place to
|
||||
copy the object code is a network server, the Corresponding Source
|
||||
may be on a different server (operated by you or a third party)
|
||||
that supports equivalent copying facilities, provided you maintain
|
||||
clear directions next to the object code saying where to find the
|
||||
Corresponding Source. Regardless of what server hosts the
|
||||
Corresponding Source, you remain obligated to ensure that it is
|
||||
available for as long as needed to satisfy these requirements.
|
||||
|
||||
e) Convey the object code using peer-to-peer transmission, provided
|
||||
you inform other peers where the object code and Corresponding
|
||||
Source of the work are being offered to the general public at no
|
||||
charge under subsection 6d.
|
||||
|
||||
A separable portion of the object code, whose source code is excluded
|
||||
from the Corresponding Source as a System Library, need not be
|
||||
included in conveying the object code work.
|
||||
|
||||
A "User Product" is either (1) a "consumer product", which means any
|
||||
tangible personal property which is normally used for personal, family,
|
||||
or household purposes, or (2) anything designed or sold for incorporation
|
||||
into a dwelling. In determining whether a product is a consumer product,
|
||||
doubtful cases shall be resolved in favor of coverage. For a particular
|
||||
product received by a particular user, "normally used" refers to a
|
||||
typical or common use of that class of product, regardless of the status
|
||||
of the particular user or of the way in which the particular user
|
||||
actually uses, or expects or is expected to use, the product. A product
|
||||
is a consumer product regardless of whether the product has substantial
|
||||
commercial, industrial or non-consumer uses, unless such uses represent
|
||||
the only significant mode of use of the product.
|
||||
|
||||
"Installation Information" for a User Product means any methods,
|
||||
procedures, authorization keys, or other information required to install
|
||||
and execute modified versions of a covered work in that User Product from
|
||||
a modified version of its Corresponding Source. The information must
|
||||
suffice to ensure that the continued functioning of the modified object
|
||||
code is in no case prevented or interfered with solely because
|
||||
modification has been made.
|
||||
|
||||
If you convey an object code work under this section in, or with, or
|
||||
specifically for use in, a User Product, and the conveying occurs as
|
||||
part of a transaction in which the right of possession and use of the
|
||||
User Product is transferred to the recipient in perpetuity or for a
|
||||
fixed term (regardless of how the transaction is characterized), the
|
||||
Corresponding Source conveyed under this section must be accompanied
|
||||
by the Installation Information. But this requirement does not apply
|
||||
if neither you nor any third party retains the ability to install
|
||||
modified object code on the User Product (for example, the work has
|
||||
been installed in ROM).
|
||||
|
||||
The requirement to provide Installation Information does not include a
|
||||
requirement to continue to provide support service, warranty, or updates
|
||||
for a work that has been modified or installed by the recipient, or for
|
||||
the User Product in which it has been modified or installed. Access to a
|
||||
network may be denied when the modification itself materially and
|
||||
adversely affects the operation of the network or violates the rules and
|
||||
protocols for communication across the network.
|
||||
|
||||
Corresponding Source conveyed, and Installation Information provided,
|
||||
in accord with this section must be in a format that is publicly
|
||||
documented (and with an implementation available to the public in
|
||||
source code form), and must require no special password or key for
|
||||
unpacking, reading or copying.
|
||||
|
||||
7. Additional Terms.
|
||||
|
||||
"Additional permissions" are terms that supplement the terms of this
|
||||
License by making exceptions from one or more of its conditions.
|
||||
Additional permissions that are applicable to the entire Program shall
|
||||
be treated as though they were included in this License, to the extent
|
||||
that they are valid under applicable law. If additional permissions
|
||||
apply only to part of the Program, that part may be used separately
|
||||
under those permissions, but the entire Program remains governed by
|
||||
this License without regard to the additional permissions.
|
||||
|
||||
When you convey a copy of a covered work, you may at your option
|
||||
remove any additional permissions from that copy, or from any part of
|
||||
it. (Additional permissions may be written to require their own
|
||||
removal in certain cases when you modify the work.) You may place
|
||||
additional permissions on material, added by you to a covered work,
|
||||
for which you have or can give appropriate copyright permission.
|
||||
|
||||
Notwithstanding any other provision of this License, for material you
|
||||
add to a covered work, you may (if authorized by the copyright holders of
|
||||
that material) supplement the terms of this License with terms:
|
||||
|
||||
a) Disclaiming warranty or limiting liability differently from the
|
||||
terms of sections 15 and 16 of this License; or
|
||||
|
||||
b) Requiring preservation of specified reasonable legal notices or
|
||||
author attributions in that material or in the Appropriate Legal
|
||||
Notices displayed by works containing it; or
|
||||
|
||||
c) Prohibiting misrepresentation of the origin of that material, or
|
||||
requiring that modified versions of such material be marked in
|
||||
reasonable ways as different from the original version; or
|
||||
|
||||
d) Limiting the use for publicity purposes of names of licensors or
|
||||
authors of the material; or
|
||||
|
||||
e) Declining to grant rights under trademark law for use of some
|
||||
trade names, trademarks, or service marks; or
|
||||
|
||||
f) Requiring indemnification of licensors and authors of that
|
||||
material by anyone who conveys the material (or modified versions of
|
||||
it) with contractual assumptions of liability to the recipient, for
|
||||
any liability that these contractual assumptions directly impose on
|
||||
those licensors and authors.
|
||||
|
||||
All other non-permissive additional terms are considered "further
|
||||
restrictions" within the meaning of section 10. If the Program as you
|
||||
received it, or any part of it, contains a notice stating that it is
|
||||
governed by this License along with a term that is a further
|
||||
restriction, you may remove that term. If a license document contains
|
||||
a further restriction but permits relicensing or conveying under this
|
||||
License, you may add to a covered work material governed by the terms
|
||||
of that license document, provided that the further restriction does
|
||||
not survive such relicensing or conveying.
|
||||
|
||||
If you add terms to a covered work in accord with this section, you
|
||||
must place, in the relevant source files, a statement of the
|
||||
additional terms that apply to those files, or a notice indicating
|
||||
where to find the applicable terms.
|
||||
|
||||
Additional terms, permissive or non-permissive, may be stated in the
|
||||
form of a separately written license, or stated as exceptions;
|
||||
the above requirements apply either way.
|
||||
|
||||
8. Termination.
|
||||
|
||||
You may not propagate or modify a covered work except as expressly
|
||||
provided under this License. Any attempt otherwise to propagate or
|
||||
modify it is void, and will automatically terminate your rights under
|
||||
this License (including any patent licenses granted under the third
|
||||
paragraph of section 11).
|
||||
|
||||
However, if you cease all violation of this License, then your
|
||||
license from a particular copyright holder is reinstated (a)
|
||||
provisionally, unless and until the copyright holder explicitly and
|
||||
finally terminates your license, and (b) permanently, if the copyright
|
||||
holder fails to notify you of the violation by some reasonable means
|
||||
prior to 60 days after the cessation.
|
||||
|
||||
Moreover, your license from a particular copyright holder is
|
||||
reinstated permanently if the copyright holder notifies you of the
|
||||
violation by some reasonable means, this is the first time you have
|
||||
received notice of violation of this License (for any work) from that
|
||||
copyright holder, and you cure the violation prior to 30 days after
|
||||
your receipt of the notice.
|
||||
|
||||
Termination of your rights under this section does not terminate the
|
||||
licenses of parties who have received copies or rights from you under
|
||||
this License. If your rights have been terminated and not permanently
|
||||
reinstated, you do not qualify to receive new licenses for the same
|
||||
material under section 10.
|
||||
|
||||
9. Acceptance Not Required for Having Copies.
|
||||
|
||||
You are not required to accept this License in order to receive or
|
||||
run a copy of the Program. Ancillary propagation of a covered work
|
||||
occurring solely as a consequence of using peer-to-peer transmission
|
||||
to receive a copy likewise does not require acceptance. However,
|
||||
nothing other than this License grants you permission to propagate or
|
||||
modify any covered work. These actions infringe copyright if you do
|
||||
not accept this License. Therefore, by modifying or propagating a
|
||||
covered work, you indicate your acceptance of this License to do so.
|
||||
|
||||
10. Automatic Licensing of Downstream Recipients.
|
||||
|
||||
Each time you convey a covered work, the recipient automatically
|
||||
receives a license from the original licensors, to run, modify and
|
||||
propagate that work, subject to this License. You are not responsible
|
||||
for enforcing compliance by third parties with this License.
|
||||
|
||||
An "entity transaction" is a transaction transferring control of an
|
||||
organization, or substantially all assets of one, or subdividing an
|
||||
organization, or merging organizations. If propagation of a covered
|
||||
work results from an entity transaction, each party to that
|
||||
transaction who receives a copy of the work also receives whatever
|
||||
licenses to the work the party's predecessor in interest had or could
|
||||
give under the previous paragraph, plus a right to possession of the
|
||||
Corresponding Source of the work from the predecessor in interest, if
|
||||
the predecessor has it or can get it with reasonable efforts.
|
||||
|
||||
You may not impose any further restrictions on the exercise of the
|
||||
rights granted or affirmed under this License. For example, you may
|
||||
not impose a license fee, royalty, or other charge for exercise of
|
||||
rights granted under this License, and you may not initiate litigation
|
||||
(including a cross-claim or counterclaim in a lawsuit) alleging that
|
||||
any patent claim is infringed by making, using, selling, offering for
|
||||
sale, or importing the Program or any portion of it.
|
||||
|
||||
11. Patents.
|
||||
|
||||
A "contributor" is a copyright holder who authorizes use under this
|
||||
License of the Program or a work on which the Program is based. The
|
||||
work thus licensed is called the contributor's "contributor version".
|
||||
|
||||
A contributor's "essential patent claims" are all patent claims
|
||||
owned or controlled by the contributor, whether already acquired or
|
||||
hereafter acquired, that would be infringed by some manner, permitted
|
||||
by this License, of making, using, or selling its contributor version,
|
||||
but do not include claims that would be infringed only as a
|
||||
consequence of further modification of the contributor version. For
|
||||
purposes of this definition, "control" includes the right to grant
|
||||
patent sublicenses in a manner consistent with the requirements of
|
||||
this License.
|
||||
|
||||
7. If, as a consequence of a court judgment or allegation of patent
|
||||
infringement or for any other reason (not limited to patent issues),
|
||||
conditions are imposed on you (whether by court order, agreement or
|
||||
Each contributor grants you a non-exclusive, worldwide, royalty-free
|
||||
patent license under the contributor's essential patent claims, to
|
||||
make, use, sell, offer for sale, import and otherwise run, modify and
|
||||
propagate the contents of its contributor version.
|
||||
|
||||
In the following three paragraphs, a "patent license" is any express
|
||||
agreement or commitment, however denominated, not to enforce a patent
|
||||
(such as an express permission to practice a patent or covenant not to
|
||||
sue for patent infringement). To "grant" such a patent license to a
|
||||
party means to make such an agreement or commitment not to enforce a
|
||||
patent against the party.
|
||||
|
||||
If you convey a covered work, knowingly relying on a patent license,
|
||||
and the Corresponding Source of the work is not available for anyone
|
||||
to copy, free of charge and under the terms of this License, through a
|
||||
publicly available network server or other readily accessible means,
|
||||
then you must either (1) cause the Corresponding Source to be so
|
||||
available, or (2) arrange to deprive yourself of the benefit of the
|
||||
patent license for this particular work, or (3) arrange, in a manner
|
||||
consistent with the requirements of this License, to extend the patent
|
||||
license to downstream recipients. "Knowingly relying" means you have
|
||||
actual knowledge that, but for the patent license, your conveying the
|
||||
covered work in a country, or your recipient's use of the covered work
|
||||
in a country, would infringe one or more identifiable patents in that
|
||||
country that you have reason to believe are valid.
|
||||
|
||||
If, pursuant to or in connection with a single transaction or
|
||||
arrangement, you convey, or propagate by procuring conveyance of, a
|
||||
covered work, and grant a patent license to some of the parties
|
||||
receiving the covered work authorizing them to use, propagate, modify
|
||||
or convey a specific copy of the covered work, then the patent license
|
||||
you grant is automatically extended to all recipients of the covered
|
||||
work and works based on it.
|
||||
|
||||
A patent license is "discriminatory" if it does not include within
|
||||
the scope of its coverage, prohibits the exercise of, or is
|
||||
conditioned on the non-exercise of one or more of the rights that are
|
||||
specifically granted under this License. You may not convey a covered
|
||||
work if you are a party to an arrangement with a third party that is
|
||||
in the business of distributing software, under which you make payment
|
||||
to the third party based on the extent of your activity of conveying
|
||||
the work, and under which the third party grants, to any of the
|
||||
parties who would receive the covered work from you, a discriminatory
|
||||
patent license (a) in connection with copies of the covered work
|
||||
conveyed by you (or copies made from those copies), or (b) primarily
|
||||
for and in connection with specific products or compilations that
|
||||
contain the covered work, unless you entered into that arrangement,
|
||||
or that patent license was granted, prior to 28 March 2007.
|
||||
|
||||
Nothing in this License shall be construed as excluding or limiting
|
||||
any implied license or other defenses to infringement that may
|
||||
otherwise be available to you under applicable patent law.
|
||||
|
||||
12. No Surrender of Others' Freedom.
|
||||
|
||||
If conditions are imposed on you (whether by court order, agreement or
|
||||
otherwise) that contradict the conditions of this License, they do not
|
||||
excuse you from the conditions of this License. If you cannot
|
||||
distribute so as to satisfy simultaneously your obligations under this
|
||||
License and any other pertinent obligations, then as a consequence you
|
||||
may not distribute the Program at all. For example, if a patent
|
||||
license would not permit royalty-free redistribution of the Program by
|
||||
all those who receive copies directly or indirectly through you, then
|
||||
the only way you could satisfy both it and this License would be to
|
||||
refrain entirely from distribution of the Program.
|
||||
excuse you from the conditions of this License. If you cannot convey a
|
||||
covered work so as to satisfy simultaneously your obligations under this
|
||||
License and any other pertinent obligations, then as a consequence you may
|
||||
not convey it at all. For example, if you agree to terms that obligate you
|
||||
to collect a royalty for further conveying from those to whom you convey
|
||||
the Program, the only way you could satisfy both those terms and this
|
||||
License would be to refrain entirely from conveying the Program.
|
||||
|
||||
If any portion of this section is held invalid or unenforceable under
|
||||
any particular circumstance, the balance of the section is intended to
|
||||
apply and the section as a whole is intended to apply in other
|
||||
circumstances.
|
||||
13. Use with the GNU Affero General Public License.
|
||||
|
||||
It is not the purpose of this section to induce you to infringe any
|
||||
patents or other property right claims or to contest validity of any
|
||||
such claims; this section has the sole purpose of protecting the
|
||||
integrity of the free software distribution system, which is
|
||||
implemented by public license practices. Many people have made
|
||||
generous contributions to the wide range of software distributed
|
||||
through that system in reliance on consistent application of that
|
||||
system; it is up to the author/donor to decide if he or she is willing
|
||||
to distribute software through any other system and a licensee cannot
|
||||
impose that choice.
|
||||
Notwithstanding any other provision of this License, you have
|
||||
permission to link or combine any covered work with a work licensed
|
||||
under version 3 of the GNU Affero General Public License into a single
|
||||
combined work, and to convey the resulting work. The terms of this
|
||||
License will continue to apply to the part which is the covered work,
|
||||
but the special requirements of the GNU Affero General Public License,
|
||||
section 13, concerning interaction through a network will apply to the
|
||||
combination as such.
|
||||
|
||||
This section is intended to make thoroughly clear what is believed to
|
||||
be a consequence of the rest of this License.
|
||||
14. Revised Versions of this License.
|
||||
|
||||
8. If the distribution and/or use of the Program is restricted in
|
||||
certain countries either by patents or by copyrighted interfaces, the
|
||||
original copyright holder who places the Program under this License
|
||||
may add an explicit geographical distribution limitation excluding
|
||||
those countries, so that distribution is permitted only in or among
|
||||
countries not thus excluded. In such case, this License incorporates
|
||||
the limitation as if written in the body of this License.
|
||||
|
||||
9. The Free Software Foundation may publish revised and/or new versions
|
||||
of the General Public License from time to time. Such new versions will
|
||||
The Free Software Foundation may publish revised and/or new versions of
|
||||
the GNU General Public License from time to time. Such new versions will
|
||||
be similar in spirit to the present version, but may differ in detail to
|
||||
address new problems or concerns.
|
||||
|
||||
Each version is given a distinguishing version number. If the Program
|
||||
specifies a version number of this License which applies to it and "any
|
||||
later version", you have the option of following the terms and conditions
|
||||
either of that version or of any later version published by the Free
|
||||
Software Foundation. If the Program does not specify a version number of
|
||||
this License, you may choose any version ever published by the Free Software
|
||||
Foundation.
|
||||
Each version is given a distinguishing version number. If the
|
||||
Program specifies that a certain numbered version of the GNU General
|
||||
Public License "or any later version" applies to it, you have the
|
||||
option of following the terms and conditions either of that numbered
|
||||
version or of any later version published by the Free Software
|
||||
Foundation. If the Program does not specify a version number of the
|
||||
GNU General Public License, you may choose any version ever published
|
||||
by the Free Software Foundation.
|
||||
|
||||
10. If you wish to incorporate parts of the Program into other free
|
||||
programs whose distribution conditions are different, write to the author
|
||||
to ask for permission. For software which is copyrighted by the Free
|
||||
Software Foundation, write to the Free Software Foundation; we sometimes
|
||||
make exceptions for this. Our decision will be guided by the two goals
|
||||
of preserving the free status of all derivatives of our free software and
|
||||
of promoting the sharing and reuse of software generally.
|
||||
If the Program specifies that a proxy can decide which future
|
||||
versions of the GNU General Public License can be used, that proxy's
|
||||
public statement of acceptance of a version permanently authorizes you
|
||||
to choose that version for the Program.
|
||||
|
||||
NO WARRANTY
|
||||
Later license versions may give you additional or different
|
||||
permissions. However, no additional obligations are imposed on any
|
||||
author or copyright holder as a result of your choosing to follow a
|
||||
later version.
|
||||
|
||||
11. BECAUSE THE PROGRAM IS LICENSED FREE OF CHARGE, THERE IS NO WARRANTY
|
||||
FOR THE PROGRAM, TO THE EXTENT PERMITTED BY APPLICABLE LAW. EXCEPT WHEN
|
||||
OTHERWISE STATED IN WRITING THE COPYRIGHT HOLDERS AND/OR OTHER PARTIES
|
||||
PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY OF ANY KIND, EITHER EXPRESSED
|
||||
OR IMPLIED, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF
|
||||
MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE. THE ENTIRE RISK AS
|
||||
TO THE QUALITY AND PERFORMANCE OF THE PROGRAM IS WITH YOU. SHOULD THE
|
||||
PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF ALL NECESSARY SERVICING,
|
||||
REPAIR OR CORRECTION.
|
||||
15. Disclaimer of Warranty.
|
||||
|
||||
12. IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING
|
||||
WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MAY MODIFY AND/OR
|
||||
REDISTRIBUTE THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES,
|
||||
INCLUDING ANY GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING
|
||||
OUT OF THE USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED
|
||||
TO LOSS OF DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY
|
||||
YOU OR THIRD PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER
|
||||
PROGRAMS), EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE
|
||||
POSSIBILITY OF SUCH DAMAGES.
|
||||
THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY
|
||||
APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT
|
||||
HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY
|
||||
OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO,
|
||||
THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR
|
||||
PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM
|
||||
IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF
|
||||
ALL NECESSARY SERVICING, REPAIR OR CORRECTION.
|
||||
|
||||
16. Limitation of Liability.
|
||||
|
||||
IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING
|
||||
WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS
|
||||
THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY
|
||||
GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE
|
||||
USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF
|
||||
DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD
|
||||
PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS),
|
||||
EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF
|
||||
SUCH DAMAGES.
|
||||
|
||||
17. Interpretation of Sections 15 and 16.
|
||||
|
||||
If the disclaimer of warranty and limitation of liability provided
|
||||
above cannot be given local legal effect according to their terms,
|
||||
reviewing courts shall apply local law that most closely approximates
|
||||
an absolute waiver of all civil liability in connection with the
|
||||
Program, unless a warranty or assumption of liability accompanies a
|
||||
copy of the Program in return for a fee.
|
||||
|
||||
END OF TERMS AND CONDITIONS
|
||||
|
||||
|
@ -287,15 +628,15 @@ free software which everyone can redistribute and change under these terms.
|
|||
|
||||
To do so, attach the following notices to the program. It is safest
|
||||
to attach them to the start of each source file to most effectively
|
||||
convey the exclusion of warranty; and each file should have at least
|
||||
state the exclusion of warranty; and each file should have at least
|
||||
the "copyright" line and a pointer to where the full notice is found.
|
||||
|
||||
<one line to give the program's name and a brief idea of what it does.>
|
||||
Copyright (C) <year> <name of author>
|
||||
|
||||
This program is free software; you can redistribute it and/or modify
|
||||
This program is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU General Public License as published by
|
||||
the Free Software Foundation; either version 2 of the License, or
|
||||
the Free Software Foundation, either version 3 of the License, or
|
||||
(at your option) any later version.
|
||||
|
||||
This program is distributed in the hope that it will be useful,
|
||||
|
@ -303,37 +644,31 @@ the "copyright" line and a pointer to where the full notice is found.
|
|||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
GNU General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU General Public License along
|
||||
with this program; if not, write to the Free Software Foundation, Inc.,
|
||||
51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA.
|
||||
You should have received a copy of the GNU General Public License
|
||||
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
Also add information on how to contact you by electronic and paper mail.
|
||||
|
||||
If the program is interactive, make it output a short notice like this
|
||||
when it starts in an interactive mode:
|
||||
If the program does terminal interaction, make it output a short
|
||||
notice like this when it starts in an interactive mode:
|
||||
|
||||
Gnomovision version 69, Copyright (C) year name of author
|
||||
Gnomovision comes with ABSOLUTELY NO WARRANTY; for details type `show w'.
|
||||
<program> Copyright (C) <year> <name of author>
|
||||
This program comes with ABSOLUTELY NO WARRANTY; for details type `show w'.
|
||||
This is free software, and you are welcome to redistribute it
|
||||
under certain conditions; type `show c' for details.
|
||||
|
||||
The hypothetical commands `show w' and `show c' should show the appropriate
|
||||
parts of the General Public License. Of course, the commands you use may
|
||||
be called something other than `show w' and `show c'; they could even be
|
||||
mouse-clicks or menu items--whatever suits your program.
|
||||
parts of the General Public License. Of course, your program's commands
|
||||
might be different; for a GUI interface, you would use an "about box".
|
||||
|
||||
You should also get your employer (if you work as a programmer) or your
|
||||
school, if any, to sign a "copyright disclaimer" for the program, if
|
||||
necessary. Here is a sample; alter the names:
|
||||
You should also get your employer (if you work as a programmer) or school,
|
||||
if any, to sign a "copyright disclaimer" for the program, if necessary.
|
||||
For more information on this, and how to apply and follow the GNU GPL, see
|
||||
<https://www.gnu.org/licenses/>.
|
||||
|
||||
Yoyodyne, Inc., hereby disclaims all copyright interest in the program
|
||||
`Gnomovision' (which makes passes at compilers) written by James Hacker.
|
||||
|
||||
<signature of Ty Coon>, 1 April 1989
|
||||
Ty Coon, President of Vice
|
||||
|
||||
This General Public License does not permit incorporating your program into
|
||||
proprietary programs. If your program is a subroutine library, you may
|
||||
consider it more useful to permit linking proprietary applications with the
|
||||
library. If this is what you want to do, use the GNU Lesser General
|
||||
Public License instead of this License.
|
||||
The GNU General Public License does not permit incorporating your program
|
||||
into proprietary programs. If your program is a subroutine library, you
|
||||
may consider it more useful to permit linking proprietary applications with
|
||||
the library. If this is what you want to do, use the GNU Lesser General
|
||||
Public License instead of this License. But first, please read
|
||||
<https://www.gnu.org/licenses/why-not-lgpl.html>.
|
||||
|
|
36
README.md
36
README.md
|
@ -1,17 +1,27 @@
|
|||
{Open,Easy}EUICC
|
||||
---
|
||||
<img src="https://gitea.angry.im/PeterCxy/OpenEUICC/media/branch/master/art/OpenEUICCBG.svg" width="512" height="300">
|
||||
|
||||
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.
|
||||
- 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.
|
||||
- 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, 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)
|
||||
===
|
||||
|
@ -78,14 +88,14 @@ FAQs
|
|||
Copyright
|
||||
===
|
||||
|
||||
Everything except `libs/lpac-jni`:
|
||||
Everything except `libs/lpac-jni` and `art/`:
|
||||
|
||||
```
|
||||
Copyright 2022-2024 OpenEUICC contributors
|
||||
|
||||
This program is free software; you can redistribute it and/or
|
||||
modify it under the terms of the GNU General Public License
|
||||
as published by the Free Software Foundation, version 2.
|
||||
as published by the Free Software Foundation, version 3.
|
||||
|
||||
This program is distributed in the hope that it will be useful,
|
||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
|
@ -115,3 +125,5 @@ You should have received a copy of the GNU Lesser General Public
|
|||
License along with this library; if not, write to the Free Software
|
||||
Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA
|
||||
```
|
||||
|
||||
`art/`: Courtesy of [Aikoyori](https://github.com/Aikoyori), CC NC-SA 4.0.
|
|
@ -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,17 +21,48 @@
|
|||
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"
|
||||
tools:replace="screenOrientation" />
|
||||
|
||||
<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?
|
||||
}
|
|
@ -3,6 +3,9 @@ package im.angry.openeuicc.core
|
|||
import android.content.Context
|
||||
import android.se.omapi.SEService
|
||||
import android.util.Log
|
||||
import im.angry.openeuicc.common.R
|
||||
import im.angry.openeuicc.core.usb.UsbApduInterface
|
||||
import im.angry.openeuicc.core.usb.UsbCcidContext
|
||||
import im.angry.openeuicc.util.*
|
||||
import java.lang.IllegalArgumentException
|
||||
|
||||
|
@ -10,32 +13,82 @@ open class DefaultEuiccChannelFactory(protected val context: Context) : EuiccCha
|
|||
private var seService: SEService? = null
|
||||
|
||||
private suspend fun ensureSEService() {
|
||||
if (seService == null) {
|
||||
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 OmapiChannel(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(
|
||||
ccidCtx: UsbCcidContext,
|
||||
isdrAid: ByteArray
|
||||
): EuiccChannel? {
|
||||
try {
|
||||
return EuiccChannelImpl(
|
||||
context.getString(R.string.usb),
|
||||
FakeUiccPortInfoCompat(FakeUiccCardInfoCompat(EuiccChannelManager.USB_CHANNEL_ID)),
|
||||
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() {
|
||||
seService?.shutdown()
|
||||
seService = null
|
||||
|
|
|
@ -1,15 +1,26 @@
|
|||
package im.angry.openeuicc.core
|
||||
|
||||
import android.content.Context
|
||||
import android.hardware.usb.UsbDevice
|
||||
import android.hardware.usb.UsbManager
|
||||
import android.telephony.SubscriptionManager
|
||||
import android.util.Log
|
||||
import im.angry.openeuicc.core.usb.UsbCcidContext
|
||||
import im.angry.openeuicc.core.usb.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.runBlocking
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.first
|
||||
import kotlinx.coroutines.flow.flow
|
||||
import kotlinx.coroutines.flow.flowOn
|
||||
import kotlinx.coroutines.flow.merge
|
||||
import kotlinx.coroutines.sync.Mutex
|
||||
import kotlinx.coroutines.sync.withLock
|
||||
import kotlinx.coroutines.withContext
|
||||
import kotlinx.coroutines.withTimeout
|
||||
|
||||
open class DefaultEuiccChannelManager(
|
||||
protected val appContainer: AppContainer,
|
||||
|
@ -19,7 +30,9 @@ open class DefaultEuiccChannelManager(
|
|||
const val TAG = "EuiccChannelManager"
|
||||
}
|
||||
|
||||
private val channels = mutableListOf<EuiccChannel>()
|
||||
private val channelCache = mutableListOf<EuiccChannel>()
|
||||
|
||||
private var usbChannel: EuiccChannel? = null
|
||||
|
||||
private val lock = Mutex()
|
||||
|
||||
|
@ -27,6 +40,10 @@ open class DefaultEuiccChannelManager(
|
|||
appContainer.telephonyManager
|
||||
}
|
||||
|
||||
private val usbManager by lazy {
|
||||
context.getSystemService(Context.USB_SERVICE) as UsbManager
|
||||
}
|
||||
|
||||
private val euiccChannelFactory by lazy {
|
||||
appContainer.euiccChannelFactory
|
||||
}
|
||||
|
@ -34,16 +51,43 @@ 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) {
|
||||
return if (usbChannel != null && usbChannel!!.valid) {
|
||||
usbChannel
|
||||
} else {
|
||||
usbChannel = null
|
||||
null
|
||||
}
|
||||
}
|
||||
|
||||
val existing =
|
||||
channels.find { it.slotId == port.card.physicalSlotIndex && it.portId == port.portIndex }
|
||||
channelCache.find { it.slotId == port.card.physicalSlotIndex && it.portId == port.portIndex }
|
||||
if (existing != null) {
|
||||
if (existing.valid && port.logicalSlotIndex == existing.logicalSlotId) {
|
||||
return existing
|
||||
} else {
|
||||
existing.close()
|
||||
channels.remove(existing)
|
||||
channelCache.remove(existing)
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -52,84 +96,222 @@ open class DefaultEuiccChannelManager(
|
|||
return null
|
||||
}
|
||||
|
||||
return euiccChannelFactory.tryOpenEuiccChannel(port)?.also {
|
||||
channels.add(it)
|
||||
val channel =
|
||||
tryOpenChannelFirstValidAid { euiccChannelFactory.tryOpenEuiccChannel(port, it) }
|
||||
|
||||
if (channel != null) {
|
||||
channelCache.add(channel)
|
||||
return channel
|
||||
} else {
|
||||
Log.i(
|
||||
TAG,
|
||||
"Was able to open channel for logical slot ${port.logicalSlotIndex}, but the channel is invalid (cannot get eID or profiles without errors). This slot might be broken, aborting."
|
||||
)
|
||||
return null
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun findEuiccChannelBySlotBlocking(logicalSlotId: Int): EuiccChannel? =
|
||||
runBlocking {
|
||||
withContext(Dispatchers.IO) {
|
||||
for (card in uiccCards) {
|
||||
for (port in card.ports) {
|
||||
if (port.logicalSlotIndex == logicalSlotId) {
|
||||
return@withContext tryOpenEuiccChannel(port)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
null
|
||||
}
|
||||
}
|
||||
|
||||
override fun findEuiccChannelByPhysicalSlotBlocking(physicalSlotId: Int): EuiccChannel? =
|
||||
runBlocking {
|
||||
withContext(Dispatchers.IO) {
|
||||
for (card in uiccCards) {
|
||||
if (card.physicalSlotIndex != physicalSlotId) continue
|
||||
for (port in card.ports) {
|
||||
tryOpenEuiccChannel(port)?.let { return@withContext it }
|
||||
}
|
||||
}
|
||||
|
||||
null
|
||||
}
|
||||
}
|
||||
|
||||
override fun findAllEuiccChannelsByPhysicalSlotBlocking(physicalSlotId: Int): List<EuiccChannel>? =
|
||||
runBlocking {
|
||||
for (card in uiccCards) {
|
||||
if (card.physicalSlotIndex != physicalSlotId) continue
|
||||
return@runBlocking card.ports.mapNotNull { tryOpenEuiccChannel(it) }
|
||||
.ifEmpty { null }
|
||||
}
|
||||
return@runBlocking null
|
||||
}
|
||||
|
||||
override fun findEuiccChannelByPortBlocking(physicalSlotId: Int, portId: Int): EuiccChannel? =
|
||||
runBlocking {
|
||||
withContext(Dispatchers.IO) {
|
||||
uiccCards.find { it.physicalSlotIndex == physicalSlotId }?.let { card ->
|
||||
card.ports.find { it.portIndex == portId }?.let { tryOpenEuiccChannel(it) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override suspend fun enumerateEuiccChannels() {
|
||||
protected suspend fun findEuiccChannelByLogicalSlot(logicalSlotId: Int): EuiccChannel? =
|
||||
withContext(Dispatchers.IO) {
|
||||
for (uiccInfo in uiccCards) {
|
||||
for (port in uiccInfo.ports) {
|
||||
if (tryOpenEuiccChannel(port) != null) {
|
||||
Log.d(
|
||||
TAG,
|
||||
"Found eUICC on slot ${uiccInfo.physicalSlotIndex} port ${port.portIndex}"
|
||||
)
|
||||
if (logicalSlotId == EuiccChannelManager.USB_CHANNEL_ID) {
|
||||
return@withContext usbChannel
|
||||
}
|
||||
|
||||
for (card in uiccCards) {
|
||||
for (port in card.ports) {
|
||||
if (port.logicalSlotIndex == logicalSlotId) {
|
||||
return@withContext tryOpenEuiccChannel(port)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
null
|
||||
}
|
||||
|
||||
private suspend fun findAllEuiccChannelsByPhysicalSlot(physicalSlotId: Int): List<EuiccChannel>? {
|
||||
if (physicalSlotId == EuiccChannelManager.USB_CHANNEL_ID) {
|
||||
return usbChannel?.let { listOf(it) }
|
||||
}
|
||||
|
||||
for (card in uiccCards) {
|
||||
if (card.physicalSlotIndex != physicalSlotId) continue
|
||||
return card.ports.mapNotNull { tryOpenEuiccChannel(it) }
|
||||
.ifEmpty { null }
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
private suspend fun findEuiccChannelByPort(physicalSlotId: Int, portId: Int): EuiccChannel? =
|
||||
withContext(Dispatchers.IO) {
|
||||
if (physicalSlotId == EuiccChannelManager.USB_CHANNEL_ID) {
|
||||
return@withContext usbChannel
|
||||
}
|
||||
|
||||
uiccCards.find { it.physicalSlotIndex == physicalSlotId }?.let { card ->
|
||||
card.ports.find { it.portIndex == portId }?.let { tryOpenEuiccChannel(it) }
|
||||
}
|
||||
}
|
||||
|
||||
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) {
|
||||
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
|
||||
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"
|
||||
)
|
||||
}
|
||||
delay(1000)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override val knownChannels: List<EuiccChannel>
|
||||
get() = channels.toList()
|
||||
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}"
|
||||
)
|
||||
|
||||
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.interfaces.smartCard ?: return@forEach
|
||||
// If we don't have permission, tell UI code that we found a candidate device, but we
|
||||
// need permission to be able to do anything with it
|
||||
if (!usbManager.hasPermission(device)) return@withContext Pair(device, false)
|
||||
Log.i(
|
||||
TAG,
|
||||
"Found CCID interface on ${device.deviceId}:${device.vendorId}, and has permission; trying to open channel"
|
||||
)
|
||||
|
||||
val ccidCtx = UsbCcidContext.createFromUsbDevice(context, device, iface) ?: return@forEach
|
||||
|
||||
try {
|
||||
val channel = tryOpenChannelFirstValidAid {
|
||||
euiccChannelFactory.tryOpenUsbEuiccChannel(ccidCtx, it)
|
||||
}
|
||||
if (channel != null && channel.lpa.valid) {
|
||||
ccidCtx.allowDisconnect = true
|
||||
usbChannel = channel
|
||||
return@withContext Pair(device, true)
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
// Ignored -- skip forward
|
||||
e.printStackTrace()
|
||||
}
|
||||
|
||||
ccidCtx.allowDisconnect = true
|
||||
ccidCtx.disconnect()
|
||||
|
||||
Log.i(
|
||||
TAG,
|
||||
"No valid eUICC channel found on USB device ${device.deviceId}:${device.vendorId}"
|
||||
)
|
||||
}
|
||||
return@withContext Pair(null, false)
|
||||
}
|
||||
|
||||
override fun invalidate() {
|
||||
for (channel in channels) {
|
||||
for (channel in channelCache) {
|
||||
channel.close()
|
||||
}
|
||||
|
||||
channels.clear()
|
||||
usbChannel?.close()
|
||||
usbChannel = null
|
||||
channelCache.clear()
|
||||
euiccChannelFactory.cleanup()
|
||||
}
|
||||
}
|
|
@ -0,0 +1,10 @@
|
|||
package im.angry.openeuicc.core
|
||||
|
||||
import android.app.Service
|
||||
import im.angry.openeuicc.di.AppContainer
|
||||
|
||||
class DefaultEuiccChannelManagerFactory(private val appContainer: AppContainer) :
|
||||
EuiccChannelManagerFactory {
|
||||
override fun createEuiccChannelManager(serviceContext: Service) =
|
||||
DefaultEuiccChannelManager(appContainer, serviceContext)
|
||||
}
|
|
@ -1,18 +1,43 @@
|
|||
package im.angry.openeuicc.core
|
||||
|
||||
import im.angry.openeuicc.util.*
|
||||
import net.typeblog.lpac_jni.ApduInterface
|
||||
import net.typeblog.lpac_jni.LocalProfileAssistant
|
||||
|
||||
abstract class EuiccChannel(
|
||||
interface EuiccChannel {
|
||||
val type: String
|
||||
|
||||
val port: UiccPortInfoCompat
|
||||
) {
|
||||
val slotId = port.card.physicalSlotIndex // PHYSICAL slot
|
||||
val logicalSlotId = port.logicalSlotIndex
|
||||
val portId = port.portIndex
|
||||
|
||||
abstract val lpa: LocalProfileAssistant
|
||||
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,11 +1,17 @@
|
|||
package im.angry.openeuicc.core
|
||||
|
||||
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(
|
||||
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,38 +1,99 @@
|
|||
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
|
||||
* APDU channels to SIM cards. The find* methods will create channels when needed, and
|
||||
* all opened channels will be held in an internal cache until invalidate() is called
|
||||
* or when this instance is destroyed.
|
||||
*
|
||||
* To precisely control the lifecycle of this object itself (and thus its cached channels),
|
||||
* all other compoents must access EuiccChannelManager objects through EuiccChannelManagerService.
|
||||
* Holding references independent of EuiccChannelManagerService is unsupported.
|
||||
*/
|
||||
interface EuiccChannelManager {
|
||||
val knownChannels: List<EuiccChannel>
|
||||
companion object {
|
||||
const val USB_CHANNEL_ID = 99
|
||||
}
|
||||
|
||||
/**
|
||||
* Scan all possible sources for EuiccChannels and have them cached for future use
|
||||
* 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()
|
||||
fun flowInternalEuiccPorts(): Flow<Pair<Int, Int>>
|
||||
|
||||
/**
|
||||
* Returns the EuiccChannel corresponding to a **logical** slot
|
||||
* 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 findEuiccChannelBySlotBlocking(logicalSlotId: Int): EuiccChannel?
|
||||
fun flowAllOpenEuiccPorts(): Flow<Pair<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.
|
||||
* 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 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.
|
||||
*/
|
||||
fun findEuiccChannelByPhysicalSlotBlocking(physicalSlotId: Int): EuiccChannel?
|
||||
suspend fun tryOpenUsbEuiccChannel(): Pair<UsbDevice?, Boolean>
|
||||
|
||||
/**
|
||||
* Returns all EuiccChannels corresponding to a **physical** slot
|
||||
* Multiple channels are possible in the case of MEP
|
||||
* Wait for a slot + port to reconnect (i.e. become valid again)
|
||||
* If the port is currently valid, this function will return immediately.
|
||||
* On timeout, the caller can decide to either try again later, or alert the user with an error
|
||||
*/
|
||||
fun findAllEuiccChannelsByPhysicalSlotBlocking(physicalSlotId: Int): List<EuiccChannel>?
|
||||
suspend fun waitForReconnect(physicalSlotId: Int, portId: Int, timeoutMillis: Long = 1000)
|
||||
|
||||
/**
|
||||
* Returns the EuiccChannel corresponding to a **physical** slot and a port ID
|
||||
* Returns the first mapped & available port ID for a physical slot, or -1 if
|
||||
* not found.
|
||||
*/
|
||||
fun findEuiccChannelByPortBlocking(physicalSlotId: Int, portId: Int): EuiccChannel?
|
||||
suspend fun findFirstAvailablePort(physicalSlotId: Int): Int
|
||||
|
||||
/**
|
||||
* Invalidate all EuiccChannels previously known by this Manager
|
||||
* Returns all mapped & available port IDs for a physical slot.
|
||||
*/
|
||||
suspend fun findAvailablePorts(physicalSlotId: Int): List<Int>
|
||||
|
||||
class EuiccChannelNotFoundException: Exception("EuiccChannel not found")
|
||||
|
||||
/**
|
||||
* 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 <R> withEuiccChannel(
|
||||
physicalSlotId: Int,
|
||||
portId: Int,
|
||||
fn: suspend (EuiccChannel) -> R
|
||||
): R
|
||||
|
||||
/**
|
||||
* Same as withEuiccChannel(Int, Int, (EuiccChannel) -> R) but instead uses logical slot ID
|
||||
*/
|
||||
suspend fun <R> withEuiccChannel(
|
||||
logicalSlotId: Int,
|
||||
fn: suspend (EuiccChannel) -> R
|
||||
): R
|
||||
|
||||
/**
|
||||
* Invalidate all EuiccChannels previously cached by this Manager
|
||||
*/
|
||||
fun invalidate()
|
||||
|
||||
|
@ -41,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,7 @@
|
|||
package im.angry.openeuicc.core
|
||||
|
||||
import android.app.Service
|
||||
|
||||
interface EuiccChannelManagerFactory {
|
||||
fun createEuiccChannelManager(serviceContext: Service): EuiccChannelManager
|
||||
}
|
|
@ -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,18 +3,32 @@ 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 net.typeblog.lpac_jni.LocalProfileAssistant
|
||||
import net.typeblog.lpac_jni.impl.HttpInterfaceImpl
|
||||
import net.typeblog.lpac_jni.impl.LocalProfileAssistantImpl
|
||||
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,35 +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
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
class OmapiChannel(
|
||||
service: SEService,
|
||||
port: UiccPortInfoCompat,
|
||||
) : EuiccChannel(port) {
|
||||
override val lpa: LocalProfileAssistant = LocalProfileAssistantImpl(
|
||||
OmapiApduInterface(service, port),
|
||||
HttpInterfaceImpl())
|
||||
}
|
|
@ -0,0 +1,157 @@
|
|||
package im.angry.openeuicc.core.usb
|
||||
|
||||
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 ccidCtx: UsbCcidContext
|
||||
) : ApduInterface, ApduInterfaceAtrProvider {
|
||||
companion object {
|
||||
private const val TAG = "UsbApduInterface"
|
||||
}
|
||||
|
||||
override val atr: ByteArray?
|
||||
get() = ccidCtx.atr
|
||||
|
||||
override val valid: Boolean
|
||||
get() = channels.isNotEmpty()
|
||||
|
||||
private var channels = mutableSetOf<Int>()
|
||||
|
||||
override fun connect() {
|
||||
ccidCtx.connect()
|
||||
|
||||
// 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)
|
||||
}
|
||||
|
||||
override fun disconnect() = ccidCtx.disconnect()
|
||||
|
||||
override fun logicalChannelOpen(aid: ByteArray): Int {
|
||||
// OPEN LOGICAL CHANNEL
|
||||
val req = manageChannelCmd(true, 0)
|
||||
|
||||
val resp = try {
|
||||
transmitApduByChannel(req, 0)
|
||||
} catch (e: Exception) {
|
||||
e.printStackTrace()
|
||||
return -1
|
||||
}
|
||||
|
||||
if (!isSuccessResponse(resp)) {
|
||||
Log.d(TAG, "OPEN LOGICAL CHANNEL failed: ${resp.encodeHex()}")
|
||||
return -1
|
||||
}
|
||||
|
||||
val channelId = resp[0].toInt()
|
||||
Log.d(TAG, "channelId = $channelId")
|
||||
|
||||
// Then, select AID
|
||||
val selectAid = selectByDfCmd(aid, channelId.toByte())
|
||||
val selectAidResp = transmitApduByChannel(selectAid, channelId.toByte())
|
||||
|
||||
if (!isSuccessResponse(selectAidResp)) {
|
||||
Log.d(TAG, "Select DF failed : ${selectAidResp.encodeHex()}")
|
||||
return -1
|
||||
}
|
||||
|
||||
channels.add(channelId)
|
||||
|
||||
return channelId
|
||||
}
|
||||
|
||||
override fun logicalChannelClose(handle: Int) {
|
||||
check(channels.contains(handle)) {
|
||||
"Invalid logical channel handle $handle"
|
||||
}
|
||||
// CLOSE LOGICAL CHANNEL
|
||||
val req = manageChannelCmd(false, handle.toByte())
|
||||
val resp = transmitApduByChannel(req, handle.toByte())
|
||||
|
||||
if (!isSuccessResponse(resp)) {
|
||||
Log.d(TAG, "CLOSE LOGICAL CHANNEL failed: ${resp.encodeHex()}")
|
||||
}
|
||||
channels.remove(handle)
|
||||
}
|
||||
|
||||
override fun transmit(handle: Int, tx: ByteArray): ByteArray {
|
||||
check(channels.contains(handle)) {
|
||||
"Invalid logical channel handle $handle"
|
||||
}
|
||||
return transmitApduByChannel(tx, handle.toByte())
|
||||
}
|
||||
|
||||
private fun isSuccessResponse(resp: ByteArray): Boolean =
|
||||
resp.size >= 2 && resp[resp.size - 2] == 0x90.toByte() && resp[resp.size - 1] == 0x00.toByte()
|
||||
|
||||
private fun buildCmd(cla: Byte, ins: Byte, p1: Byte, p2: Byte, data: ByteArray?, le: Byte?) =
|
||||
byteArrayOf(cla, ins, p1, p2).let {
|
||||
if (data != null) {
|
||||
it + data.size.toByte() + data
|
||||
} else {
|
||||
it
|
||||
}
|
||||
}.let {
|
||||
if (le != null) {
|
||||
it + byteArrayOf(le)
|
||||
} else {
|
||||
it
|
||||
}
|
||||
}
|
||||
|
||||
private fun manageChannelCmd(open: Boolean, channel: Byte) =
|
||||
if (open) {
|
||||
buildCmd(0x00, 0x70, 0x00, 0x00, null, 0x01)
|
||||
} else {
|
||||
buildCmd(channel, 0x70, 0x80.toByte(), channel, null, null)
|
||||
}
|
||||
|
||||
private fun selectByDfCmd(aid: ByteArray, channel: Byte) =
|
||||
buildCmd(channel, 0xA4.toByte(), 0x04, 0x00, aid, null)
|
||||
|
||||
private fun transmitApduByChannel(tx: ByteArray, channel: Byte): ByteArray {
|
||||
val realTx = tx.copyOf()
|
||||
// OR the channel mask into the CLA byte
|
||||
realTx[0] = ((realTx[0].toInt() and 0xFC) or channel.toInt()).toByte()
|
||||
|
||||
var resp = ccidCtx.transceiver.sendXfrBlock(realTx).data!!
|
||||
|
||||
if (resp.size < 2) throw RuntimeException("APDU response smaller than 2 (sw1 + sw2)!")
|
||||
|
||||
var sw1 = resp[resp.size - 2].toInt() and 0xFF
|
||||
var sw2 = resp[resp.size - 1].toInt() and 0xFF
|
||||
|
||||
if (sw1 == 0x6C) {
|
||||
// 0x6C = wrong le
|
||||
// so we fix the le field here
|
||||
realTx[realTx.size - 1] = resp[resp.size - 1]
|
||||
resp = ccidCtx.transceiver.sendXfrBlock(realTx).data!!
|
||||
} else if (sw1 == 0x61) {
|
||||
// 0x61 = X bytes available
|
||||
// continue reading by GET RESPONSE
|
||||
do {
|
||||
// GET RESPONSE
|
||||
val getResponseCmd = byteArrayOf(
|
||||
realTx[0], 0xC0.toByte(), 0x00, 0x00, sw2.toByte()
|
||||
)
|
||||
|
||||
val tmp = ccidCtx.transceiver.sendXfrBlock(getResponseCmd).data!!
|
||||
|
||||
resp = resp.sliceArray(0 until (resp.size - 2)) + tmp
|
||||
|
||||
sw1 = resp[resp.size - 2].toInt() and 0xFF
|
||||
sw2 = resp[resp.size - 1].toInt() and 0xFF
|
||||
} while (sw1 == 0x61)
|
||||
}
|
||||
|
||||
return resp
|
||||
}
|
||||
}
|
|
@ -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
|
||||
}
|
||||
}
|
||||
}
|
|
@ -0,0 +1,98 @@
|
|||
package im.angry.openeuicc.core.usb
|
||||
|
||||
import java.nio.ByteBuffer
|
||||
import java.nio.ByteOrder
|
||||
|
||||
@Suppress("unused")
|
||||
data class UsbCcidDescription(
|
||||
private val bMaxSlotIndex: Byte,
|
||||
private val bVoltageSupport: Byte,
|
||||
private val dwProtocols: Int,
|
||||
private val dwFeatures: Int
|
||||
) {
|
||||
companion object {
|
||||
private const val DESCRIPTOR_LENGTH: Byte = 0x36
|
||||
private const val DESCRIPTOR_TYPE: Byte = 0x21
|
||||
|
||||
// dwFeatures Masks
|
||||
private const val FEATURE_AUTOMATIC_VOLTAGE = 0x00008
|
||||
private const val FEATURE_AUTOMATIC_PPS = 0x00080
|
||||
|
||||
private const val FEATURE_EXCHANGE_LEVEL_TPDU = 0x10000
|
||||
private const val FEATURE_EXCHANGE_LEVEL_SHORT_APDU = 0x20000
|
||||
private const val FEATURE_EXCHANGE_LEVEL_EXTENDED_APDU = 0x40000
|
||||
|
||||
// bVoltageSupport Masks
|
||||
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
|
||||
private const val MASK_T0_PROTO = 1
|
||||
private const val MASK_T1_PROTO = 2
|
||||
|
||||
fun fromRawDescriptors(desc: ByteArray): UsbCcidDescription? {
|
||||
var dwProtocols = 0
|
||||
var dwFeatures = 0
|
||||
var bMaxSlotIndex: Byte = 0
|
||||
var bVoltageSupport: Byte = 0
|
||||
|
||||
var hasCcidDescriptor = false
|
||||
|
||||
val byteBuffer = ByteBuffer.wrap(desc).order(ByteOrder.LITTLE_ENDIAN)
|
||||
|
||||
while (byteBuffer.hasRemaining()) {
|
||||
byteBuffer.mark()
|
||||
val len = byteBuffer.get()
|
||||
val type = byteBuffer.get()
|
||||
if (type == DESCRIPTOR_TYPE && len == DESCRIPTOR_LENGTH) {
|
||||
byteBuffer.reset()
|
||||
byteBuffer.position(byteBuffer.position() + SLOT_OFFSET)
|
||||
bMaxSlotIndex = byteBuffer.get()
|
||||
bVoltageSupport = byteBuffer.get()
|
||||
dwProtocols = byteBuffer.int
|
||||
byteBuffer.reset()
|
||||
byteBuffer.position(byteBuffer.position() + FEATURES_OFFSET)
|
||||
dwFeatures = byteBuffer.int
|
||||
hasCcidDescriptor = true
|
||||
break
|
||||
} else {
|
||||
byteBuffer.position(byteBuffer.position() + len - 2)
|
||||
}
|
||||
}
|
||||
|
||||
return if (hasCcidDescriptor) {
|
||||
UsbCcidDescription(bMaxSlotIndex, bVoltageSupport, dwProtocols, dwFeatures)
|
||||
} else {
|
||||
null
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
enum class Voltage(powerOnValue: Int, mask: Int) {
|
||||
// @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) = (dwFeatures and feature) != 0
|
||||
|
||||
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
|
||||
get() = hasFeature(FEATURE_AUTOMATIC_PPS)
|
||||
|
||||
val hasT0Protocol: Boolean
|
||||
get() = (dwProtocols and MASK_T0_PROTO) != 0
|
||||
}
|
|
@ -0,0 +1,345 @@
|
|||
package im.angry.openeuicc.core.usb
|
||||
|
||||
import android.hardware.usb.UsbDeviceConnection
|
||||
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
|
||||
|
||||
|
||||
/**
|
||||
* Provides raw, APDU-agnostic transmission to the CCID reader
|
||||
* Adapted from <https://github.com/open-keychain/open-keychain/blob/master/OpenKeychain/src/main/java/org/sufficientlysecure/keychain/securitytoken/usb/CcidTransceiver.java>
|
||||
*/
|
||||
@Suppress("unused")
|
||||
class UsbCcidTransceiver(
|
||||
private val usbConnection: UsbDeviceConnection,
|
||||
private val usbBulkIn: UsbEndpoint,
|
||||
private val usbBulkOut: UsbEndpoint,
|
||||
private val usbCcidDescription: UsbCcidDescription,
|
||||
private val verboseLoggingFlow: Flow<Boolean>
|
||||
) {
|
||||
companion object {
|
||||
private const val TAG = "UsbCcidTransceiver"
|
||||
|
||||
private const val CCID_HEADER_LENGTH = 10
|
||||
|
||||
private const val MESSAGE_TYPE_RDR_TO_PC_DATA_BLOCK = 0x80
|
||||
private const val MESSAGE_TYPE_PC_TO_RDR_ICC_POWER_ON = 0x62
|
||||
private const val MESSAGE_TYPE_PC_TO_RDR_ICC_POWER_OFF = 0x63
|
||||
private const val MESSAGE_TYPE_PC_TO_RDR_XFR_BLOCK = 0x6f
|
||||
|
||||
private const val COMMAND_STATUS_SUCCESS: Byte = 0
|
||||
private const val COMMAND_STATUS_TIME_EXTENSION_RQUESTED: Byte = 2
|
||||
|
||||
/**
|
||||
* Level Parameter: APDU is a single command.
|
||||
*
|
||||
* "the command APDU begins and ends with this command"
|
||||
* -- DWG Smart-Card USB Integrated Circuit(s) Card Devices rev 1.0
|
||||
* § 6.1.1.3
|
||||
*/
|
||||
const val LEVEL_PARAM_START_SINGLE_CMD_APDU: Short = 0x0000
|
||||
|
||||
/**
|
||||
* Level Parameter: First APDU in a multi-command APDU.
|
||||
*
|
||||
* "the command APDU begins with this command, and continue in the
|
||||
* next PC_to_RDR_XfrBlock"
|
||||
* -- DWG Smart-Card USB Integrated Circuit(s) Card Devices rev 1.0
|
||||
* § 6.1.1.3
|
||||
*/
|
||||
const val LEVEL_PARAM_START_MULTI_CMD_APDU: Short = 0x0001
|
||||
|
||||
/**
|
||||
* Level Parameter: Final APDU in a multi-command APDU.
|
||||
*
|
||||
* "this abData field continues a command APDU and ends the command APDU"
|
||||
* -- DWG Smart-Card USB Integrated Circuit(s) Card Devices rev 1.0
|
||||
* § 6.1.1.3
|
||||
*/
|
||||
const val LEVEL_PARAM_END_MULTI_CMD_APDU: Short = 0x0002
|
||||
|
||||
/**
|
||||
* Level Parameter: Next command in a multi-command APDU.
|
||||
*
|
||||
* "the abData field continues a command APDU and another block is to follow"
|
||||
* -- DWG Smart-Card USB Integrated Circuit(s) Card Devices rev 1.0
|
||||
* § 6.1.1.3
|
||||
*/
|
||||
const val LEVEL_PARAM_CONTINUE_MULTI_CMD_APDU: Short = 0x0003
|
||||
|
||||
/**
|
||||
* Level Parameter: Request the device continue sending APDU.
|
||||
*
|
||||
* "empty abData field, continuation of response APDU is expected in the next
|
||||
* RDR_to_PC_DataBlock"
|
||||
* -- DWG Smart-Card USB Integrated Circuit(s) Card Devices rev 1.0
|
||||
* § 6.1.1.3
|
||||
*/
|
||||
const val LEVEL_PARAM_CONTINUE_RESPONSE: Short = 0x0010
|
||||
|
||||
private const val SLOT_NUMBER = 0x00
|
||||
|
||||
private const val ICC_STATUS_SUCCESS: Byte = 0
|
||||
|
||||
private const val DEVICE_COMMUNICATE_TIMEOUT_MILLIS = 5000
|
||||
private const val DEVICE_SKIP_TIMEOUT_MILLIS = 100
|
||||
}
|
||||
|
||||
data class UsbCcidErrorException(val msg: String, val errorResponse: CcidDataBlock) :
|
||||
Exception(msg)
|
||||
|
||||
@Suppress("ArrayInDataClass")
|
||||
data class CcidDataBlock(
|
||||
val dwLength: Int,
|
||||
val bSlot: Byte,
|
||||
val bSeq: Byte,
|
||||
val bStatus: Byte,
|
||||
val bError: Byte,
|
||||
val bChainParameter: Byte,
|
||||
val data: ByteArray?
|
||||
) {
|
||||
companion object {
|
||||
fun parseHeaderFromBytes(headerBytes: ByteArray): CcidDataBlock {
|
||||
val buf = ByteBuffer.wrap(headerBytes)
|
||||
buf.order(ByteOrder.LITTLE_ENDIAN)
|
||||
|
||||
val type = buf.get()
|
||||
require(type == MESSAGE_TYPE_RDR_TO_PC_DATA_BLOCK.toByte()) { "Header has incorrect type value!" }
|
||||
val dwLength = buf.int
|
||||
val bSlot = buf.get()
|
||||
val bSeq = buf.get()
|
||||
val bStatus = buf.get()
|
||||
val bError = buf.get()
|
||||
val bChainParameter = buf.get()
|
||||
|
||||
return CcidDataBlock(dwLength, bSlot, bSeq, bStatus, bError, bChainParameter, null)
|
||||
}
|
||||
}
|
||||
|
||||
fun withData(d: ByteArray): CcidDataBlock {
|
||||
require(data == null) { "Cannot add data twice" }
|
||||
return CcidDataBlock(dwLength, bSlot, bSeq, bStatus, bError, bChainParameter, d)
|
||||
}
|
||||
|
||||
val iccStatus: Byte
|
||||
get() = (bStatus.toInt() and 0x03).toByte()
|
||||
|
||||
val commandStatus: Byte
|
||||
get() = ((bStatus.toInt() shr 6) and 0x03).toByte()
|
||||
|
||||
val isStatusTimeoutExtensionRequest: Boolean
|
||||
get() = commandStatus == COMMAND_STATUS_TIME_EXTENSION_RQUESTED
|
||||
|
||||
val isStatusSuccess: Boolean
|
||||
get() = iccStatus == ICC_STATUS_SUCCESS && commandStatus == COMMAND_STATUS_SUCCESS
|
||||
}
|
||||
|
||||
val hasAutomaticPps = usbCcidDescription.hasAutomaticPps
|
||||
|
||||
private val inputBuffer = ByteArray(usbBulkIn.maxPacketSize)
|
||||
|
||||
private var currentSequenceNumber: Byte = 0
|
||||
|
||||
private fun sendRaw(data: ByteArray, offset: Int, length: Int) {
|
||||
val tr1 = usbConnection.bulkTransfer(
|
||||
usbBulkOut, data, offset, length, DEVICE_COMMUNICATE_TIMEOUT_MILLIS
|
||||
)
|
||||
if (tr1 != length) {
|
||||
throw UsbTransportException(
|
||||
"USB error - failed to transmit data ($tr1/$length)"
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private fun receiveDataBlock(expectedSequenceNumber: Byte): CcidDataBlock {
|
||||
var response: CcidDataBlock?
|
||||
do {
|
||||
response = receiveDataBlockImmediate(expectedSequenceNumber)
|
||||
} while (response!!.isStatusTimeoutExtensionRequest)
|
||||
if (!response.isStatusSuccess) {
|
||||
throw UsbCcidErrorException("USB-CCID error!", response)
|
||||
}
|
||||
return response
|
||||
}
|
||||
|
||||
private fun receiveDataBlockImmediate(expectedSequenceNumber: Byte): CcidDataBlock {
|
||||
/*
|
||||
* Some USB CCID devices (notably NitroKey 3) may time-out and need a subsequent poke to
|
||||
* carry on communications. No particular reason why the number 3 was chosen. If we get a
|
||||
* zero-sized reply (or a time-out), we try again. Clamped retries prevent an infinite loop
|
||||
* if things really turn sour.
|
||||
*/
|
||||
var attempts = 3
|
||||
Log.d(TAG, "Receive data block immediate seq=$expectedSequenceNumber")
|
||||
var readBytes: Int
|
||||
do {
|
||||
readBytes = usbConnection.bulkTransfer(
|
||||
usbBulkIn, inputBuffer, inputBuffer.size, DEVICE_COMMUNICATE_TIMEOUT_MILLIS
|
||||
)
|
||||
if (runBlocking { verboseLoggingFlow.first() }) {
|
||||
Log.d(TAG, "Received $readBytes bytes: ${inputBuffer.encodeHex()}")
|
||||
}
|
||||
} while (readBytes <= 0 && attempts-- > 0)
|
||||
if (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]) {
|
||||
append(", sequence number ")
|
||||
append("%d (expected %d)".format(inputBuffer[6], expectedSequenceNumber))
|
||||
}
|
||||
})
|
||||
}
|
||||
var result = CcidDataBlock.parseHeaderFromBytes(inputBuffer)
|
||||
if (expectedSequenceNumber != result.bSeq) {
|
||||
throw UsbTransportException("USB-CCID error - expected sequence number $expectedSequenceNumber, got $result")
|
||||
}
|
||||
|
||||
val dataBuffer = ByteArray(result.dwLength)
|
||||
var bufferedBytes = readBytes - CCID_HEADER_LENGTH
|
||||
System.arraycopy(inputBuffer, CCID_HEADER_LENGTH, dataBuffer, 0, bufferedBytes)
|
||||
while (bufferedBytes < dataBuffer.size) {
|
||||
readBytes = usbConnection.bulkTransfer(
|
||||
usbBulkIn, inputBuffer, inputBuffer.size, DEVICE_COMMUNICATE_TIMEOUT_MILLIS
|
||||
)
|
||||
if (readBytes < 0) {
|
||||
throw UsbTransportException("USB error - failed reading response data! Header: $result")
|
||||
}
|
||||
System.arraycopy(inputBuffer, 0, dataBuffer, bufferedBytes, readBytes)
|
||||
bufferedBytes += readBytes
|
||||
}
|
||||
result = result.withData(dataBuffer)
|
||||
return result
|
||||
}
|
||||
|
||||
|
||||
private fun skipAvailableInput() {
|
||||
var ignoredBytes: Int
|
||||
do {
|
||||
ignoredBytes = usbConnection.bulkTransfer(
|
||||
usbBulkIn, inputBuffer, inputBuffer.size, DEVICE_SKIP_TIMEOUT_MILLIS
|
||||
)
|
||||
if (ignoredBytes > 0) {
|
||||
Log.e(TAG, "Skipped $ignoredBytes bytes")
|
||||
}
|
||||
} while (ignoredBytes > 0)
|
||||
}
|
||||
|
||||
/**
|
||||
* Receives a continued XfrBlock. Should be called when a multiblock response is indicated
|
||||
* 6.1.4 PC_to_RDR_XfrBlock
|
||||
*/
|
||||
fun receiveContinuedResponse(): CcidDataBlock {
|
||||
return sendXfrBlock(ByteArray(0), LEVEL_PARAM_CONTINUE_RESPONSE)
|
||||
}
|
||||
|
||||
/**
|
||||
* Transmits XfrBlock
|
||||
* 6.1.4 PC_to_RDR_XfrBlock
|
||||
*
|
||||
* @param payload payload to transmit
|
||||
* @param levelParam Level parameter
|
||||
*/
|
||||
fun sendXfrBlock(
|
||||
payload: ByteArray,
|
||||
levelParam: Short = LEVEL_PARAM_START_SINGLE_CMD_APDU
|
||||
): CcidDataBlock {
|
||||
val startTime = SystemClock.elapsedRealtime()
|
||||
val l = payload.size
|
||||
val sequenceNumber: Byte = currentSequenceNumber++
|
||||
val headerData = byteArrayOf(
|
||||
MESSAGE_TYPE_PC_TO_RDR_XFR_BLOCK.toByte(),
|
||||
l.toByte(),
|
||||
(l shr 8).toByte(),
|
||||
(l shr 16).toByte(),
|
||||
(l shr 24).toByte(),
|
||||
SLOT_NUMBER.toByte(),
|
||||
sequenceNumber,
|
||||
0x00.toByte(),
|
||||
(levelParam.toInt() and 0x00ff).toByte(),
|
||||
(levelParam.toInt() shr 8).toByte()
|
||||
)
|
||||
val data: ByteArray = headerData + payload
|
||||
var sentBytes = 0
|
||||
while (sentBytes < data.size) {
|
||||
val bytesToSend = usbBulkOut.maxPacketSize.coerceAtMost(data.size - sentBytes)
|
||||
sendRaw(data, sentBytes, bytesToSend)
|
||||
sentBytes += bytesToSend
|
||||
}
|
||||
val ccidDataBlock = receiveDataBlock(sequenceNumber)
|
||||
val elapsedTime = SystemClock.elapsedRealtime() - startTime
|
||||
Log.d(TAG, "USB XferBlock call took ${elapsedTime}ms")
|
||||
return ccidDataBlock
|
||||
}
|
||||
|
||||
fun iccPowerOn(): CcidDataBlock {
|
||||
val startTime = SystemClock.elapsedRealtime()
|
||||
skipAvailableInput()
|
||||
var response: CcidDataBlock? = null
|
||||
for (voltage in usbCcidDescription.voltages) {
|
||||
Log.v(TAG, "CCID: attempting to power on with voltage $voltage")
|
||||
response = try {
|
||||
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 $voltage")
|
||||
iccPowerOff()
|
||||
Log.v(TAG, "CCID: powered off")
|
||||
continue
|
||||
}
|
||||
throw e
|
||||
}
|
||||
break
|
||||
}
|
||||
if (response == null) {
|
||||
throw UsbTransportException("Couldn't power up ICC2")
|
||||
}
|
||||
val elapsedTime = SystemClock.elapsedRealtime() - startTime
|
||||
Log.d(
|
||||
TAG,
|
||||
buildString {
|
||||
append("Usb transport connected")
|
||||
append(", took ", elapsedTime, "ms")
|
||||
append(", ATR=", response.data?.encodeHex())
|
||||
}
|
||||
)
|
||||
return response
|
||||
}
|
||||
|
||||
private fun iccPowerOnVoltage(voltage: Byte): CcidDataBlock {
|
||||
val sequenceNumber = currentSequenceNumber++
|
||||
val iccPowerCommand = byteArrayOf(
|
||||
MESSAGE_TYPE_PC_TO_RDR_ICC_POWER_ON.toByte(),
|
||||
0x00, 0x00, 0x00, 0x00,
|
||||
SLOT_NUMBER.toByte(),
|
||||
sequenceNumber,
|
||||
voltage,
|
||||
0x00, 0x00 // reserved for future use
|
||||
)
|
||||
sendRaw(iccPowerCommand, 0, iccPowerCommand.size)
|
||||
return receiveDataBlock(sequenceNumber)
|
||||
}
|
||||
|
||||
private fun iccPowerOff() {
|
||||
val sequenceNumber = currentSequenceNumber++
|
||||
val iccPowerCommand = byteArrayOf(
|
||||
MESSAGE_TYPE_PC_TO_RDR_ICC_POWER_OFF.toByte(),
|
||||
0x00, 0x00, 0x00, 0x00,
|
||||
0x00,
|
||||
sequenceNumber,
|
||||
0x00
|
||||
)
|
||||
sendRaw(iccPowerCommand, 0, iccPowerCommand.size)
|
||||
}
|
||||
}
|
|
@ -0,0 +1,27 @@
|
|||
// Adapted from <https://github.com/open-keychain/open-keychain/blob/master/OpenKeychain/src/main/java/org/sufficientlysecure/keychain/securitytoken/usb>
|
||||
package im.angry.openeuicc.core.usb
|
||||
|
||||
import android.hardware.usb.UsbConstants
|
||||
import android.hardware.usb.UsbDevice
|
||||
import android.hardware.usb.UsbEndpoint
|
||||
import android.hardware.usb.UsbInterface
|
||||
|
||||
class UsbTransportException(message: String) : Exception(message)
|
||||
|
||||
val UsbDevice.interfaces: Iterable<UsbInterface>
|
||||
get() = (0 until interfaceCount).map(::getInterface)
|
||||
|
||||
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 },
|
||||
)
|
||||
}
|
|
@ -4,13 +4,16 @@ import android.telephony.SubscriptionManager
|
|||
import android.telephony.TelephonyManager
|
||||
import im.angry.openeuicc.core.EuiccChannelFactory
|
||||
import im.angry.openeuicc.core.EuiccChannelManager
|
||||
import im.angry.openeuicc.core.EuiccChannelManagerFactory
|
||||
import im.angry.openeuicc.util.*
|
||||
|
||||
interface AppContainer {
|
||||
val telephonyManager: TelephonyManager
|
||||
val euiccChannelManager: EuiccChannelManager
|
||||
val euiccChannelManagerFactory: EuiccChannelManagerFactory
|
||||
val subscriptionManager: SubscriptionManager
|
||||
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
|
||||
}
|
|
@ -5,7 +5,9 @@ import android.telephony.SubscriptionManager
|
|||
import android.telephony.TelephonyManager
|
||||
import im.angry.openeuicc.core.DefaultEuiccChannelFactory
|
||||
import im.angry.openeuicc.core.DefaultEuiccChannelManager
|
||||
import im.angry.openeuicc.core.DefaultEuiccChannelManagerFactory
|
||||
import im.angry.openeuicc.core.EuiccChannelManager
|
||||
import im.angry.openeuicc.core.EuiccChannelManagerFactory
|
||||
import im.angry.openeuicc.util.*
|
||||
|
||||
open class DefaultAppContainer(context: Context) : AppContainer {
|
||||
|
@ -17,6 +19,10 @@ open class DefaultAppContainer(context: Context) : AppContainer {
|
|||
DefaultEuiccChannelManager(this, context)
|
||||
}
|
||||
|
||||
override val euiccChannelManagerFactory: EuiccChannelManagerFactory by lazy {
|
||||
DefaultEuiccChannelManagerFactory(this)
|
||||
}
|
||||
|
||||
override val subscriptionManager by lazy {
|
||||
context.getSystemService(SubscriptionManager::class.java)!!
|
||||
}
|
||||
|
@ -32,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,9 +1,16 @@
|
|||
package im.angry.openeuicc.di
|
||||
|
||||
import im.angry.openeuicc.core.EuiccChannel
|
||||
import androidx.fragment.app.Fragment
|
||||
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,8 +1,11 @@
|
|||
package im.angry.openeuicc.di
|
||||
|
||||
import im.angry.openeuicc.core.EuiccChannel
|
||||
import androidx.fragment.app.Fragment
|
||||
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
|
||||
}
|
|
@ -0,0 +1,527 @@
|
|||
package im.angry.openeuicc.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.
|
||||
* The purpose of this wrapper is mainly lifecycle-wise: having a Service allows the manager
|
||||
* instance to have its own independent lifecycle. This way it can be created as requested and
|
||||
* destroyed when no other components are bound to this service anymore.
|
||||
* This behavior allows us to avoid keeping the APDU channels open at all times. For example,
|
||||
* the EuiccService implementation should *only* bind to this service when it requires an
|
||||
* 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 : 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
|
||||
}
|
||||
|
||||
private val euiccChannelManagerDelegate = lazy {
|
||||
appContainer.euiccChannelManagerFactory.createEuiccChannelManager(this)
|
||||
}
|
||||
val euiccChannelManager: EuiccChannelManager by euiccChannelManagerDelegate
|
||||
|
||||
private val wakeLock: PowerManager.WakeLock by lazy {
|
||||
(getSystemService(POWER_SERVICE) as PowerManager).run {
|
||||
newWakeLock(PowerManager.PARTIAL_WAKE_LOCK, this::class.simpleName)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The state of a "foreground" task (named so due to the need to startForeground())
|
||||
*/
|
||||
sealed interface ForegroundTaskState {
|
||||
data object Idle : ForegroundTaskState
|
||||
data class InProgress(val progress: Int) : 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()
|
||||
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()
|
||||
}
|
||||
}
|
||||
}
|
|
@ -0,0 +1,52 @@
|
|||
package im.angry.openeuicc.ui
|
||||
|
||||
import android.content.ComponentName
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.content.ServiceConnection
|
||||
import android.os.Bundle
|
||||
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?) {
|
||||
euiccChannelManagerService = (service!! as EuiccChannelManagerService.LocalBinder).service
|
||||
euiccChannelManager = euiccChannelManagerService.euiccChannelManager
|
||||
euiccChannelManagerLoaded.complete(Unit)
|
||||
onInit()
|
||||
}
|
||||
|
||||
override fun onServiceDisconnected(name: ComponentName?) {
|
||||
// These activities should never lose the EuiccChannelManagerService connection
|
||||
finish()
|
||||
}
|
||||
}
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
|
||||
bindService(
|
||||
Intent(this, EuiccChannelManagerService::class.java),
|
||||
euiccChannelManagerServiceConnection,
|
||||
Context.BIND_AUTO_CREATE
|
||||
)
|
||||
}
|
||||
|
||||
override fun onDestroy() {
|
||||
super.onDestroy()
|
||||
unbindService(euiccChannelManagerServiceConnection)
|
||||
}
|
||||
|
||||
/**
|
||||
* When called, euiccChannelManager is guaranteed to have been initialized
|
||||
*/
|
||||
abstract fun onInit()
|
||||
}
|
|
@ -1,43 +0,0 @@
|
|||
package im.angry.openeuicc.ui
|
||||
|
||||
import android.os.Bundle
|
||||
import androidx.appcompat.app.AppCompatActivity
|
||||
import androidx.lifecycle.lifecycleScope
|
||||
import im.angry.openeuicc.util.*
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
|
||||
class DirectProfileDownloadActivity : AppCompatActivity(), SlotSelectFragment.SlotSelectedListener, OpenEuiccContextMarker {
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
lifecycleScope.launch {
|
||||
withContext(Dispatchers.IO) {
|
||||
euiccChannelManager.enumerateEuiccChannels()
|
||||
}
|
||||
|
||||
when {
|
||||
euiccChannelManager.knownChannels.isEmpty() -> {
|
||||
finish()
|
||||
}
|
||||
euiccChannelManager.knownChannels.hasMultipleChips -> {
|
||||
SlotSelectFragment.newInstance()
|
||||
.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(euiccChannelManager.knownChannels[0].slotId,
|
||||
euiccChannelManager.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,198 @@
|
|||
package im.angry.openeuicc.ui
|
||||
|
||||
import android.annotation.SuppressLint
|
||||
import android.content.ClipData
|
||||
import android.content.ClipboardManager
|
||||
import android.os.Build
|
||||
import android.os.Bundle
|
||||
import android.view.LayoutInflater
|
||||
import android.view.MenuItem
|
||||
import android.view.View
|
||||
import android.view.ViewGroup
|
||||
import android.widget.TextView
|
||||
import android.widget.Toast
|
||||
import androidx.activity.enableEdgeToEdge
|
||||
import androidx.annotation.StringRes
|
||||
import androidx.lifecycle.lifecycleScope
|
||||
import androidx.recyclerview.widget.DividerItemDecoration
|
||||
import androidx.recyclerview.widget.LinearLayoutManager
|
||||
import androidx.recyclerview.widget.RecyclerView
|
||||
import androidx.recyclerview.widget.RecyclerView.ViewHolder
|
||||
import androidx.swiperefreshlayout.widget.SwipeRefreshLayout
|
||||
import im.angry.openeuicc.common.R
|
||||
import im.angry.openeuicc.core.EuiccChannel
|
||||
import im.angry.openeuicc.core.EuiccChannelManager
|
||||
import im.angry.openeuicc.util.*
|
||||
import kotlinx.coroutines.launch
|
||||
import net.typeblog.lpac_jni.impl.PKID_GSMA_LIVE_CI
|
||||
import net.typeblog.lpac_jni.impl.PKID_GSMA_TEST_CI
|
||||
|
||||
// https://euicc-manual.osmocom.org/docs/pki/eum/accredited.json
|
||||
// ref: <https://regex101.com/r/5FFz8u>
|
||||
private val RE_SAS = Regex(
|
||||
"""^[A-Z]{2}-[A-Z]{2}(?:-UP)?-\d{4}T?(?:-\d+)?T?$""",
|
||||
setOf(RegexOption.IGNORE_CASE),
|
||||
)
|
||||
|
||||
class EuiccInfoActivity : BaseEuiccAccessActivity(), OpenEuiccContextMarker {
|
||||
companion object {
|
||||
private val YES_NO = Pair(R.string.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()))
|
||||
info.sasAccreditationNumber.trim().takeIf(RE_SAS::matches)
|
||||
?.let { add(Item(R.string.euicc_info_sas_accreditation_number, it.uppercase())) }
|
||||
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))
|
||||
}
|
||||
|
||||
@Suppress("SameParameterValue")
|
||||
private fun formatByBoolean(b: Boolean, res: Pair<Int, Int>): String =
|
||||
getString(if (b) res.first else res.second)
|
||||
|
||||
inner class EuiccInfoViewHolder(root: View) : ViewHolder(root) {
|
||||
private val title: TextView = root.requireViewById(R.id.euicc_info_title)
|
||||
private val content: TextView = root.requireViewById(R.id.euicc_info_content)
|
||||
private var copiedToastResId: Int? = null
|
||||
|
||||
init {
|
||||
root.setOnClickListener {
|
||||
if (copiedToastResId != null) {
|
||||
val label = title.text.toString()
|
||||
getSystemService(ClipboardManager::class.java)!!
|
||||
.setPrimaryClip(ClipData.newPlainText(label, content.text))
|
||||
if (Build.VERSION.SDK_INT <= Build.VERSION_CODES.S_V2) {
|
||||
Toast.makeText(
|
||||
this@EuiccInfoActivity,
|
||||
copiedToastResId!!,
|
||||
Toast.LENGTH_SHORT
|
||||
).show()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun bind(item: Item) {
|
||||
copiedToastResId = item.copiedToastResId
|
||||
title.setText(item.titleResId)
|
||||
content.text = item.content ?: getString(R.string.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])
|
||||
}
|
||||
}
|
||||
}
|
|
@ -1,10 +1,12 @@
|
|||
package im.angry.openeuicc.ui
|
||||
|
||||
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
|
||||
|
@ -16,6 +18,11 @@ import android.widget.ImageButton
|
|||
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
|
||||
|
@ -24,12 +31,18 @@ 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.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
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.first
|
||||
import kotlinx.coroutines.flow.stateIn
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.runBlocking
|
||||
import kotlinx.coroutines.withContext
|
||||
import java.lang.Exception
|
||||
|
||||
open class EuiccManagementFragment : Fragment(), EuiccProfilesChangedListener,
|
||||
EuiccChannelFragmentMarker {
|
||||
|
@ -43,9 +56,21 @@ 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()
|
||||
|
||||
// Marker for when this fragment might enter an invalid state
|
||||
// e.g. after a failed enable / disable operation
|
||||
private var invalid = false
|
||||
|
||||
// Subscribe to settings we care about outside of coroutine contexts while initializing
|
||||
// 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)
|
||||
|
@ -58,9 +83,24 @@ open class EuiccManagementFragment : Fragment(), EuiccProfilesChangedListener,
|
|||
): View {
|
||||
val view = inflater.inflate(R.layout.fragment_euicc, container, false)
|
||||
|
||||
swipeRefresh = view.findViewById(R.id.swipe_refresh)
|
||||
fab = view.findViewById(R.id.fab)
|
||||
profileList = view.findViewById(R.id.profile_list)
|
||||
swipeRefresh = view.requireViewById(R.id.swipe_refresh)
|
||||
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
|
||||
}
|
||||
|
@ -73,8 +113,10 @@ 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -92,78 +134,178 @@ open class EuiccManagementFragment : Fragment(), EuiccProfilesChangedListener,
|
|||
inflater.inflate(R.menu.fragment_euicc, menu)
|
||||
}
|
||||
|
||||
override fun onOptionsItemSelected(item: MenuItem): Boolean =
|
||||
when (item.itemId) {
|
||||
R.id.show_notifications -> {
|
||||
Intent(requireContext(), NotificationsActivity::class.java).apply {
|
||||
putExtra("logicalSlotId", channel.logicalSlotId)
|
||||
startActivity(this)
|
||||
}
|
||||
true
|
||||
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", logicalSlotId)
|
||||
startActivity(this)
|
||||
}
|
||||
else -> super.onOptionsItemSelected(item)
|
||||
true
|
||||
}
|
||||
|
||||
protected open suspend fun onCreateFooterViews(parent: ViewGroup): List<View> = listOf()
|
||||
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)
|
||||
}
|
||||
|
||||
protected open suspend fun onCreateFooterViews(
|
||||
parent: ViewGroup,
|
||||
profiles: List<LocalProfileInfo>
|
||||
): List<View> =
|
||||
if (profiles.isEmpty()) {
|
||||
val view = layoutInflater.inflate(R.layout.footer_no_profile, parent, false)
|
||||
listOf(view)
|
||||
} else {
|
||||
listOf()
|
||||
}
|
||||
|
||||
@SuppressLint("NotifyDataSetChanged")
|
||||
private fun refresh() {
|
||||
if (invalid) return
|
||||
swipeRefresh.isRefreshing = true
|
||||
|
||||
lifecycleScope.launch {
|
||||
val profiles = withContext(Dispatchers.IO) {
|
||||
euiccChannelManager.notifyEuiccProfilesChanged(channel.logicalSlotId)
|
||||
channel.lpa.profiles
|
||||
}
|
||||
|
||||
withContext(Dispatchers.Main) {
|
||||
adapter.profiles = profiles.operational
|
||||
adapter.footerViews = onCreateFooterViews(profileList)
|
||||
adapter.notifyDataSetChanged()
|
||||
swipeRefresh.isRefreshing = false
|
||||
}
|
||||
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 = withEuiccChannel { channel ->
|
||||
logicalSlotId = channel.logicalSlotId
|
||||
eid = channel.lpa.eID
|
||||
euiccChannelManager.notifyEuiccProfilesChanged(channel.logicalSlotId)
|
||||
if (unfilteredProfileListFlow.value)
|
||||
channel.lpa.profiles
|
||||
else
|
||||
channel.lpa.profiles.operational
|
||||
}
|
||||
|
||||
withContext(Dispatchers.Main) {
|
||||
adapter.profiles = profiles
|
||||
adapter.footerViews = onCreateFooterViews(profileList, profiles)
|
||||
adapter.notifyDataSetChanged()
|
||||
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) {
|
||||
swipeRefresh.isRefreshing = true
|
||||
fab.isEnabled = false
|
||||
|
||||
lifecycleScope.launch {
|
||||
try {
|
||||
if (enable) {
|
||||
doEnableProfile(iccid)
|
||||
} else {
|
||||
doDisableProfile(iccid)
|
||||
ensureEuiccChannelManager()
|
||||
euiccChannelManagerService.waitForForegroundTask()
|
||||
|
||||
val err = euiccChannelManagerService.launchProfileSwitchTask(
|
||||
slotId,
|
||||
portId,
|
||||
iccid,
|
||||
enable,
|
||||
reconnectTimeoutMillis = 30 * 1000
|
||||
).waitDone()
|
||||
|
||||
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)
|
||||
setPositiveButton(android.R.string.ok) { dialog, _ ->
|
||||
dialog.dismiss()
|
||||
requireActivity().finish()
|
||||
}
|
||||
setOnDismissListener { _ ->
|
||||
requireActivity().finish()
|
||||
}
|
||||
show()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
refresh()
|
||||
fab.isEnabled = true
|
||||
} catch (e: Exception) {
|
||||
Log.d(TAG, "Failed to enable / disable profile $iccid")
|
||||
Log.d(TAG, Log.getStackTraceString(e))
|
||||
fab.isEnabled = true
|
||||
Toast.makeText(context, R.string.toast_profile_enable_failed, Toast.LENGTH_LONG).show()
|
||||
|
||||
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(appContainer.customizableTextProvider.profileSwitchingTimeoutMessage)
|
||||
setPositiveButton(android.R.string.ok) { dialog, _ ->
|
||||
dialog.dismiss()
|
||||
requireActivity().finish()
|
||||
}
|
||||
setOnDismissListener { _ ->
|
||||
requireActivity().finish()
|
||||
}
|
||||
show()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
else -> showSwitchFailureText()
|
||||
}
|
||||
|
||||
refresh()
|
||||
fab.isEnabled = true
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun doEnableProfile(iccid: String) =
|
||||
channel.lpa.beginOperation {
|
||||
channel.lpa.enableProfile(iccid, reconnectTimeout = 15 * 1000) &&
|
||||
preferenceRepository.notificationEnableFlow.first()
|
||||
}
|
||||
|
||||
private suspend fun doDisableProfile(iccid: String) =
|
||||
channel.lpa.beginOperation {
|
||||
channel.lpa.disableProfile(iccid, reconnectTimeout = 15 * 1000) &&
|
||||
preferenceRepository.notificationDisableFlow.first()
|
||||
}
|
||||
|
||||
protected open fun populatePopupWithProfileActions(popup: PopupMenu, profile: LocalProfileInfo) {
|
||||
popup.inflate(R.menu.profile_options)
|
||||
if (profile.isEnabled) {
|
||||
popup.menu.findItem(R.id.enable).isVisible = false
|
||||
popup.menu.findItem(R.id.delete).isVisible = false
|
||||
|
||||
// We hide the disable option by default to avoid "bricking" some cards that won't get
|
||||
// recognized again by the phone's modem. However we don't have that worry if we are
|
||||
// accessing it through a USB card reader, or when the user explicitly opted in
|
||||
if (isUsb || disableSafeguardFlow.value) {
|
||||
popup.menu.findItem(R.id.disable).isVisible = true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -174,12 +316,19 @@ open class EuiccManagementFragment : Fragment(), EuiccProfilesChangedListener,
|
|||
|
||||
companion object {
|
||||
fun fromInt(value: Int) =
|
||||
Type.values().first { it.value == value }
|
||||
entries.first { it.value == value }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
inner class FooterViewHolder: ViewHolder(FrameLayout(requireContext())) {
|
||||
init {
|
||||
itemView.layoutParams = ViewGroup.LayoutParams(
|
||||
ViewGroup.LayoutParams.MATCH_PARENT,
|
||||
ViewGroup.LayoutParams.WRAP_CONTENT
|
||||
)
|
||||
}
|
||||
|
||||
fun attach(view: View) {
|
||||
view.parent?.let { (it as ViewGroup).removeView(view) }
|
||||
(itemView as FrameLayout).addView(view)
|
||||
|
@ -191,11 +340,13 @@ open class EuiccManagementFragment : Fragment(), EuiccProfilesChangedListener,
|
|||
}
|
||||
|
||||
inner class ProfileViewHolder(private val root: View) : ViewHolder(root) {
|
||||
private val iccid: TextView = root.findViewById(R.id.iccid)
|
||||
private val name: TextView = root.findViewById(R.id.name)
|
||||
private val state: TextView = root.findViewById(R.id.state)
|
||||
private val provider: TextView = root.findViewById(R.id.provider)
|
||||
private val profileMenu: ImageButton = root.findViewById(R.id.profile_menu)
|
||||
private val iccid: TextView = root.requireViewById(R.id.iccid)
|
||||
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 {
|
||||
iccid.setOnClickListener {
|
||||
|
@ -206,6 +357,15 @@ open class EuiccManagementFragment : Fragment(), EuiccProfilesChangedListener,
|
|||
}
|
||||
}
|
||||
|
||||
iccid.setOnLongClickListener {
|
||||
requireContext().getSystemService(ClipboardManager::class.java)!!
|
||||
.setPrimaryClip(ClipData.newPlainText("iccid", iccid.text))
|
||||
if (Build.VERSION.SDK_INT <= Build.VERSION_CODES.S_V2) Toast
|
||||
.makeText(requireContext(), R.string.toast_iccid_copied, Toast.LENGTH_SHORT)
|
||||
.show()
|
||||
true
|
||||
}
|
||||
|
||||
profileMenu.setOnClickListener { showOptionsMenu() }
|
||||
}
|
||||
|
||||
|
@ -223,11 +383,23 @@ 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()
|
||||
}
|
||||
|
||||
private fun showOptionsMenu() {
|
||||
// Prevent users from doing multiple things at once
|
||||
if (invalid || swipeRefresh.isRefreshing) return
|
||||
|
||||
PopupMenu(root.context, profileMenu).apply {
|
||||
setOnMenuItemClickListener(::onMenuItemClicked)
|
||||
populatePopupWithProfileActions(this, profile)
|
||||
|
|
|
@ -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,25 +26,39 @@ 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(findViewById(R.id.toolbar))
|
||||
setSupportActionBar(requireViewById(R.id.toolbar))
|
||||
setupToolbarInsets()
|
||||
supportActionBar!!.setDisplayHomeAsUpEnabled(true)
|
||||
|
||||
swipeRefresh = findViewById(R.id.swipe_refresh)
|
||||
scrollView = findViewById(R.id.scroll_view)
|
||||
logText = findViewById(R.id.log_text)
|
||||
swipeRefresh = requireViewById(R.id.swipe_refresh)
|
||||
scrollView = requireViewById(R.id.scroll_view)
|
||||
logText = requireViewById(R.id.log_text)
|
||||
|
||||
setupRootViewInsets(scrollView)
|
||||
|
||||
swipeRefresh.setOnRefreshListener {
|
||||
lifecycleScope.launch {
|
||||
|
@ -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)
|
||||
|
|
|
@ -1,116 +1,234 @@
|
|||
package im.angry.openeuicc.ui
|
||||
|
||||
import android.annotation.SuppressLint
|
||||
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
|
||||
import android.view.Menu
|
||||
import android.view.MenuItem
|
||||
import android.view.View
|
||||
import android.widget.AdapterView
|
||||
import android.widget.ArrayAdapter
|
||||
import android.widget.Spinner
|
||||
import androidx.appcompat.app.AppCompatActivity
|
||||
import android.widget.ProgressBar
|
||||
import androidx.activity.enableEdgeToEdge
|
||||
import androidx.fragment.app.Fragment
|
||||
import androidx.lifecycle.lifecycleScope
|
||||
import androidx.viewpager2.adapter.FragmentStateAdapter
|
||||
import androidx.viewpager2.widget.ViewPager2
|
||||
import com.google.android.material.tabs.TabLayout
|
||||
import com.google.android.material.tabs.TabLayoutMediator
|
||||
import im.angry.openeuicc.common.R
|
||||
import im.angry.openeuicc.core.EuiccChannel
|
||||
import im.angry.openeuicc.core.EuiccChannelManager
|
||||
import im.angry.openeuicc.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
|
||||
|
||||
open class MainActivity : AppCompatActivity(), OpenEuiccContextMarker {
|
||||
@SuppressLint("NotifyDataSetChanged")
|
||||
open class MainActivity : BaseEuiccAccessActivity(), OpenEuiccContextMarker {
|
||||
companion object {
|
||||
const val TAG = "MainActivity"
|
||||
|
||||
const val PERMISSION_REQUEST_CODE = 1000
|
||||
}
|
||||
|
||||
private lateinit var spinnerAdapter: ArrayAdapter<String>
|
||||
private lateinit var spinner: Spinner
|
||||
private lateinit var loadingProgress: ProgressBar
|
||||
private lateinit var tabs: TabLayout
|
||||
private lateinit var viewPager: ViewPager2
|
||||
|
||||
private val fragments = arrayListOf<EuiccManagementFragment>()
|
||||
private var refreshing = false
|
||||
|
||||
private lateinit var noEuiccPlaceholder: View
|
||||
private data class Page(
|
||||
val logicalSlotId: Int,
|
||||
val title: String,
|
||||
val createFragment: () -> Fragment
|
||||
)
|
||||
|
||||
private val pages: MutableList<Page> = mutableListOf()
|
||||
|
||||
private val pagerAdapter by lazy {
|
||||
object : FragmentStateAdapter(this) {
|
||||
override fun getItemCount() = pages.size
|
||||
|
||||
override fun createFragment(position: Int): Fragment = pages[position].createFragment()
|
||||
}
|
||||
}
|
||||
|
||||
protected lateinit var tm: TelephonyManager
|
||||
|
||||
private val usbReceiver = object : BroadcastReceiver() {
|
||||
override fun onReceive(context: Context?, intent: Intent?) {
|
||||
if (intent?.action == UsbManager.ACTION_USB_DEVICE_ATTACHED || intent?.action == UsbManager.ACTION_USB_DEVICE_DETACHED) {
|
||||
refresh(true)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@SuppressLint("WrongConstant", "UnspecifiedRegisterReceiverFlag")
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
enableEdgeToEdge()
|
||||
super.onCreate(savedInstanceState)
|
||||
setContentView(R.layout.activity_main)
|
||||
setSupportActionBar(findViewById(R.id.toolbar))
|
||||
setSupportActionBar(requireViewById(R.id.toolbar))
|
||||
setupToolbarInsets()
|
||||
loadingProgress = requireViewById(R.id.loading)
|
||||
tabs = requireViewById(R.id.main_tabs)
|
||||
viewPager = requireViewById(R.id.view_pager)
|
||||
|
||||
noEuiccPlaceholder = findViewById(R.id.no_euicc_placeholder)
|
||||
viewPager.adapter = pagerAdapter
|
||||
TabLayoutMediator(tabs, viewPager) { tab, pos ->
|
||||
tab.text = pages[pos].title
|
||||
}.attach()
|
||||
|
||||
tm = telephonyManager
|
||||
|
||||
spinnerAdapter = ArrayAdapter<String>(this, R.layout.spinner_item)
|
||||
registerReceiver(usbReceiver, IntentFilter().apply {
|
||||
addAction(UsbManager.ACTION_USB_DEVICE_ATTACHED)
|
||||
addAction(UsbManager.ACTION_USB_DEVICE_DETACHED)
|
||||
})
|
||||
}
|
||||
|
||||
lifecycleScope.launch {
|
||||
init()
|
||||
}
|
||||
override fun onDestroy() {
|
||||
super.onDestroy()
|
||||
unregisterReceiver(usbReceiver)
|
||||
}
|
||||
|
||||
override fun onCreateOptionsMenu(menu: Menu): Boolean {
|
||||
menuInflater.inflate(R.menu.activity_main, menu)
|
||||
|
||||
if (!this::spinner.isInitialized) {
|
||||
spinner = menu.findItem(R.id.spinner).actionView as Spinner
|
||||
spinner.adapter = spinnerAdapter
|
||||
spinner.onItemSelectedListener = object : AdapterView.OnItemSelectedListener {
|
||||
override fun onItemSelected(
|
||||
parent: AdapterView<*>?,
|
||||
view: View?,
|
||||
position: Int,
|
||||
id: Long
|
||||
) {
|
||||
supportFragmentManager.beginTransaction()
|
||||
.replace(R.id.fragment_root, fragments[position]).commit()
|
||||
}
|
||||
|
||||
override fun onNothingSelected(parent: AdapterView<*>?) {
|
||||
}
|
||||
|
||||
}
|
||||
} else {
|
||||
// Fragments may cause this menu to be inflated multiple times.
|
||||
// Simply reuse the action view in that case
|
||||
menu.findItem(R.id.spinner).actionView = spinner
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
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 -> {
|
||||
refresh()
|
||||
true
|
||||
}
|
||||
else -> super.onOptionsItemSelected(item)
|
||||
}
|
||||
|
||||
private suspend fun init() {
|
||||
withContext(Dispatchers.IO) {
|
||||
euiccChannelManager.enumerateEuiccChannels()
|
||||
euiccChannelManager.knownChannels.forEach {
|
||||
Log.d(TAG, "slot ${it.slotId} port ${it.portId}")
|
||||
Log.d(TAG, it.lpa.eID)
|
||||
override fun onInit() {
|
||||
lifecycleScope.launch {
|
||||
init()
|
||||
}
|
||||
}
|
||||
|
||||
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 (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 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 {
|
||||
val productName = it.productName ?: getString(R.string.usb)
|
||||
newPages.add(Page(EuiccChannelManager.USB_CHANNEL_ID, productName) {
|
||||
UsbCcidReaderFragment()
|
||||
})
|
||||
}
|
||||
viewPager.visibility = View.VISIBLE
|
||||
|
||||
if (newPages.size > 1) {
|
||||
tabs.visibility = View.VISIBLE
|
||||
} else if (newPages.isEmpty()) {
|
||||
newPages.add(Page(-1, "") {
|
||||
appContainer.uiComponentFactory.createNoEuiccPlaceholderFragment()
|
||||
})
|
||||
}
|
||||
|
||||
withContext(Dispatchers.Main) {
|
||||
euiccChannelManager.knownChannels.sortedBy { it.logicalSlotId }.forEach { channel ->
|
||||
spinnerAdapter.add(getString(R.string.channel_name_format, channel.logicalSlotId))
|
||||
fragments.add(appContainer.uiComponentFactory.createEuiccManagementFragment(channel))
|
||||
}
|
||||
newPages.sortBy { it.logicalSlotId }
|
||||
|
||||
if (fragments.isNotEmpty()) {
|
||||
noEuiccPlaceholder.visibility = View.GONE
|
||||
supportFragmentManager.beginTransaction().replace(R.id.fragment_root, fragments.first()).commit()
|
||||
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.
|
||||
viewPager.adapter = pagerAdapter
|
||||
|
||||
if (fromUsbEvent && usbDevice != null) {
|
||||
// If this refresh was triggered by a USB insertion while active, scroll to that page
|
||||
viewPager.post {
|
||||
viewPager.setCurrentItem(pages.size - 1, true)
|
||||
}
|
||||
} else {
|
||||
viewPager.currentItem = 0
|
||||
}
|
||||
|
||||
if (pages.size > 0) {
|
||||
ensureNotificationPermissions()
|
||||
}
|
||||
|
||||
refreshing = false
|
||||
}
|
||||
|
||||
private fun refresh(fromUsbEvent: Boolean = false) {
|
||||
if (refreshing) return
|
||||
lifecycleScope.launch {
|
||||
refreshing = true
|
||||
loadingProgress.visibility = View.VISIBLE
|
||||
viewPager.visibility = View.GONE
|
||||
tabs.visibility = View.GONE
|
||||
|
||||
pages.clear()
|
||||
pagerAdapter.notifyDataSetChanged()
|
||||
viewPager.adapter = pagerAdapter
|
||||
|
||||
init(fromUsbEvent) // will set refreshing = false
|
||||
}
|
||||
}
|
||||
}
|
|
@ -0,0 +1,23 @@
|
|||
package im.angry.openeuicc.ui
|
||||
|
||||
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(), OpenEuiccContextMarker {
|
||||
override fun onCreateView(
|
||||
inflater: LayoutInflater,
|
||||
container: ViewGroup?,
|
||||
savedInstanceState: Bundle?
|
||||
): View? {
|
||||
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,8 +11,8 @@ 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.appcompat.app.AppCompatActivity
|
||||
import androidx.core.view.forEach
|
||||
import androidx.lifecycle.lifecycleScope
|
||||
import androidx.recyclerview.widget.DividerItemDecoration
|
||||
|
@ -20,38 +20,53 @@ 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
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
import net.typeblog.lpac_jni.LocalProfileNotification
|
||||
|
||||
class NotificationsActivity: AppCompatActivity(), OpenEuiccContextMarker {
|
||||
class NotificationsActivity: BaseEuiccAccessActivity(), OpenEuiccContextMarker {
|
||||
private lateinit var swipeRefresh: SwipeRefreshLayout
|
||||
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(findViewById(R.id.toolbar))
|
||||
setSupportActionBar(requireViewById(R.id.toolbar))
|
||||
setupToolbarInsets()
|
||||
supportActionBar!!.setDisplayHomeAsUpEnabled(true)
|
||||
|
||||
euiccChannel = euiccChannelManager
|
||||
.findEuiccChannelBySlotBlocking(intent.getIntExtra("logicalSlotId", 0))!!
|
||||
swipeRefresh = requireViewById(R.id.swipe_refresh)
|
||||
notificationList = requireViewById(R.id.recycler_view)
|
||||
|
||||
swipeRefresh = findViewById(R.id.swipe_refresh)
|
||||
notificationList = findViewById(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 (logicalSlotId == EuiccChannelManager.USB_CHANNEL_ID) {
|
||||
getString(R.string.usb)
|
||||
} else {
|
||||
appContainer.customizableTextProvider.formatInternalChannelName(logicalSlotId)
|
||||
}
|
||||
|
||||
title = getString(R.string.profile_notifications_detailed_format, channelTitle)
|
||||
|
||||
swipeRefresh.setOnRefreshListener {
|
||||
refresh()
|
||||
}
|
||||
|
@ -88,6 +103,10 @@ class NotificationsActivity: AppCompatActivity(), OpenEuiccContextMarker {
|
|||
swipeRefresh.isRefreshing = true
|
||||
|
||||
lifecycleScope.launch {
|
||||
withContext(Dispatchers.IO) {
|
||||
euiccChannelManagerLoaded.await()
|
||||
}
|
||||
|
||||
task()
|
||||
|
||||
swipeRefresh.isRefreshing = false
|
||||
|
@ -96,15 +115,16 @@ class NotificationsActivity: AppCompatActivity(), OpenEuiccContextMarker {
|
|||
|
||||
private fun refresh() {
|
||||
launchTask {
|
||||
val profiles = withContext(Dispatchers.IO) {
|
||||
euiccChannel.lpa.profiles
|
||||
}
|
||||
|
||||
notificationAdapter.notifications =
|
||||
withContext(Dispatchers.IO) {
|
||||
euiccChannel.lpa.notifications.map {
|
||||
val profile = profiles.find { p -> p.iccid == it.iccid }
|
||||
LocalProfileNotificationWrapper(it, profile?.displayName ?: "???")
|
||||
euiccChannelManager.withEuiccChannel(logicalSlotId) { channel ->
|
||||
val nameMap = buildMap {
|
||||
for (profile in channel.lpa.profiles) {
|
||||
put(profile.iccid, profile.displayName)
|
||||
}
|
||||
}
|
||||
|
||||
channel.lpa.notifications.map {
|
||||
LocalProfileNotificationWrapper(it, nameMap[it.iccid] ?: "???")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -118,8 +138,10 @@ class NotificationsActivity: AppCompatActivity(), OpenEuiccContextMarker {
|
|||
@SuppressLint("ClickableViewAccessibility")
|
||||
inner class NotificationViewHolder(private val root: View):
|
||||
RecyclerView.ViewHolder(root), View.OnCreateContextMenuListener, OnMenuItemClickListener {
|
||||
private val address: TextView = root.findViewById(R.id.notification_address)
|
||||
private val profileName: TextView = root.findViewById(R.id.notification_profile_name)
|
||||
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
|
||||
|
||||
|
@ -140,6 +162,7 @@ class NotificationsActivity: AppCompatActivity(), OpenEuiccContextMarker {
|
|||
}
|
||||
}
|
||||
|
||||
|
||||
private fun operationToLocalizedText(operation: LocalProfileNotification.Operation) =
|
||||
root.context.getText(
|
||||
when (operation) {
|
||||
|
@ -153,6 +176,10 @@ class NotificationsActivity: AppCompatActivity(), 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),
|
||||
|
@ -177,19 +204,25 @@ class NotificationsActivity: AppCompatActivity(), 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()
|
||||
}
|
||||
refresh()
|
||||
true
|
||||
}
|
||||
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()
|
||||
}
|
||||
refresh()
|
||||
true
|
||||
}
|
||||
else -> false
|
||||
|
|
|
@ -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)
|
||||
}
|
||||
return instance
|
||||
fun newInstance(slotId: Int, portId: Int, iccid: String, name: String) =
|
||||
newInstanceEuicc(ProfileDeleteFragment::class.java, slotId, portId) {
|
||||
putString(FIELD_ICCID, iccid)
|
||||
putString(FIELD_NAME, name)
|
||||
}
|
||||
}
|
||||
|
||||
private val iccid by lazy {
|
||||
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() = channel.lpa.beginOperation {
|
||||
channel.lpa.deleteProfile(requireArguments().getString("iccid")!!)
|
||||
preferenceRepository.notificationDeleteFlow.first()
|
||||
}
|
||||
}
|
|
@ -1,220 +0,0 @@
|
|||
package im.angry.openeuicc.ui
|
||||
|
||||
import android.annotation.SuppressLint
|
||||
import android.app.Dialog
|
||||
import android.content.DialogInterface
|
||||
import android.os.Bundle
|
||||
import android.text.Editable
|
||||
import android.text.format.Formatter
|
||||
import android.util.Log
|
||||
import android.view.*
|
||||
import android.widget.ProgressBar
|
||||
import android.widget.TextView
|
||||
import android.widget.Toast
|
||||
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)
|
||||
val components = content.split("$")
|
||||
if (components.size < 3 || components[0] != "LPA:1") return@registerForActivityResult
|
||||
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.findViewById(R.id.toolbar)
|
||||
profileDownloadServer = view.findViewById(R.id.profile_download_server)
|
||||
profileDownloadCode = view.findViewById(R.id.profile_download_code)
|
||||
profileDownloadConfirmationCode = view.findViewById(R.id.profile_download_confirmation_code)
|
||||
profileDownloadIMEI = view.findViewById(R.id.profile_download_imei)
|
||||
profileDownloadFreeSpace = view.findViewById(R.id.profile_download_free_space)
|
||||
progress = view.findViewById(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.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 { Formatter.formatShortFileSize(requireContext(), it.toLong()) }
|
||||
|
||||
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?) = channel.lpa.beginOperation {
|
||||
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 we get here, we are successful
|
||||
// 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,35 +2,33 @@ 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)
|
||||
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)
|
||||
}
|
||||
return instance
|
||||
}
|
||||
}
|
||||
|
||||
private lateinit var toolbar: Toolbar
|
||||
|
@ -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?,
|
||||
|
@ -46,9 +52,9 @@ class ProfileRenameFragment : BaseMaterialDialogFragment(), EuiccChannelFragment
|
|||
): View {
|
||||
val view = inflater.inflate(R.layout.fragment_profile_rename, container, false)
|
||||
|
||||
toolbar = view.findViewById(R.id.toolbar)
|
||||
profileRenameNewName = view.findViewById(R.id.profile_rename_new_name)
|
||||
progress = view.findViewById(R.id.progress)
|
||||
toolbar = view.requireViewById(R.id.toolbar)
|
||||
profileRenameNewName = view.requireViewById(R.id.profile_rename_new_name)
|
||||
progress = view.requireViewById(R.id.progress)
|
||||
|
||||
toolbar.inflateMenu(R.menu.fragment_profile_rename)
|
||||
|
||||
|
@ -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()
|
||||
ensureEuiccChannelManager()
|
||||
euiccChannelManagerService.waitForForegroundTask()
|
||||
val response = euiccChannelManagerService
|
||||
.launchProfileRenameTask(slotId, portId, iccid, newName).waitDone()
|
||||
|
||||
when (response) {
|
||||
is LocalProfileAssistant.ProfileNameTooLongException -> {
|
||||
showErrorAndCancel(R.string.profile_rename_too_long)
|
||||
}
|
||||
|
||||
is LocalProfileAssistant.ProfileNameIsInvalidUTF8Exception -> {
|
||||
showErrorAndCancel(R.string.profile_rename_encoding_error)
|
||||
}
|
||||
|
||||
is Throwable -> {
|
||||
showErrorAndCancel(R.string.profile_rename_failure)
|
||||
}
|
||||
|
||||
else -> {
|
||||
parentFragment?.notifyEuiccProfilesChanged()
|
||||
|
||||
runCatching(::dismiss)
|
||||
}
|
||||
dismiss()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun doRename(name: String) = withContext(Dispatchers.IO) {
|
||||
if (!channel.lpa.setNickname(requireArguments().getString("iccid")!!, name)) {
|
||||
throw RuntimeException("Profile nickname not changed")
|
||||
}
|
||||
}
|
||||
}
|
|
@ -2,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(findViewById(R.id.toolbar))
|
||||
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()
|
||||
}
|
||||
|
||||
requirePreference<Preference>("pref_info_app_version").apply {
|
||||
summary = requireContext().selfAppVersion
|
||||
|
||||
// Enable developer options when this is clicked for 7 times
|
||||
setOnPreferenceClickListener(::onAppVersionClicked)
|
||||
}
|
||||
|
||||
requirePreference<Preference>("pref_advanced_language").apply {
|
||||
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.TIRAMISU) return@apply
|
||||
isVisible = true
|
||||
intent = Intent(Settings.ACTION_APP_LOCALE_SETTINGS).apply {
|
||||
data = Uri.fromParts("package", requireContext().packageName, null)
|
||||
}
|
||||
}
|
||||
|
||||
findPreference<Preference>("pref_advanced_logs")
|
||||
?.setOnPreferenceClickListener {
|
||||
startActivity(Intent(requireContext(), LogsActivity::class.java))
|
||||
true
|
||||
}
|
||||
requirePreference<Preference>("pref_advanced_logs").apply {
|
||||
intent = Intent(requireContext(), LogsActivity::class.java)
|
||||
}
|
||||
|
||||
findPreference<CheckBoxPreference>("pref_notifications_download")
|
||||
?.bindBooleanFlow(preferenceRepository.notificationDownloadFlow, PreferenceKeys.NOTIFICATION_DOWNLOAD)
|
||||
requirePreference<CheckBoxPreference>("pref_notifications_download")
|
||||
.bindBooleanFlow(preferenceRepository.notificationDownloadFlow)
|
||||
|
||||
findPreference<CheckBoxPreference>("pref_notifications_delete")
|
||||
?.bindBooleanFlow(preferenceRepository.notificationDeleteFlow, PreferenceKeys.NOTIFICATION_DELETE)
|
||||
requirePreference<CheckBoxPreference>("pref_notifications_delete")
|
||||
.bindBooleanFlow(preferenceRepository.notificationDeleteFlow)
|
||||
|
||||
findPreference<CheckBoxPreference>("pref_notifications_enable")
|
||||
?.bindBooleanFlow(preferenceRepository.notificationEnableFlow, PreferenceKeys.NOTIFICATION_ENABLE)
|
||||
requirePreference<CheckBoxPreference>("pref_notifications_switch")
|
||||
.bindBooleanFlow(preferenceRepository.notificationSwitchFlow)
|
||||
|
||||
findPreference<CheckBoxPreference>("pref_notifications_disable")
|
||||
?.bindBooleanFlow(preferenceRepository.notificationDisableFlow, PreferenceKeys.NOTIFICATION_DISABLE)
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
private fun CheckBoxPreference.bindBooleanFlow(flow: Flow<Boolean>, key: Preferences.Key<Boolean>) {
|
||||
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,77 +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(): SlotSelectFragment {
|
||||
return SlotSelectFragment()
|
||||
}
|
||||
}
|
||||
|
||||
interface SlotSelectedListener {
|
||||
fun onSlotSelected(slotId: Int, portId: Int)
|
||||
fun onSlotSelectCancelled()
|
||||
}
|
||||
|
||||
private lateinit var toolbar: Toolbar
|
||||
private lateinit var spinner: Spinner
|
||||
private val channels: List<EuiccChannel> by lazy {
|
||||
euiccChannelManager.knownChannels.sortedBy { it.logicalSlotId }
|
||||
}
|
||||
|
||||
override fun onCreateView(
|
||||
inflater: LayoutInflater,
|
||||
container: ViewGroup?,
|
||||
savedInstanceState: Bundle?
|
||||
): View? {
|
||||
val view = inflater.inflate(R.layout.fragment_slot_select, container, false)
|
||||
|
||||
toolbar = view.findViewById(R.id.toolbar)
|
||||
toolbar.setTitle(R.string.slot_select)
|
||||
toolbar.inflateMenu(R.menu.fragment_slot_select)
|
||||
|
||||
val adapter = ArrayAdapter<String>(inflater.context, R.layout.spinner_item)
|
||||
|
||||
spinner = view.findViewById(R.id.spinner)
|
||||
spinner.adapter = adapter
|
||||
|
||||
channels.forEach { channel ->
|
||||
adapter.add(getString(R.string.channel_name_format, channel.logicalSlotId))
|
||||
}
|
||||
|
||||
toolbar.setNavigationOnClickListener {
|
||||
(requireActivity() as SlotSelectedListener).onSlotSelectCancelled()
|
||||
}
|
||||
toolbar.setOnMenuItemClickListener {
|
||||
val channel = channels[spinner.selectedItemPosition]
|
||||
(requireActivity() as SlotSelectedListener).onSlotSelected(channel.slotId, channel.portId)
|
||||
dismiss()
|
||||
true
|
||||
}
|
||||
|
||||
return view
|
||||
}
|
||||
|
||||
override fun onResume() {
|
||||
super.onResume()
|
||||
setWidthPercent(75)
|
||||
}
|
||||
|
||||
override fun onCancel(dialog: DialogInterface) {
|
||||
super.onCancel(dialog)
|
||||
(requireActivity() as SlotSelectedListener).onSlotSelectCancelled()
|
||||
}
|
||||
}
|
|
@ -0,0 +1,169 @@
|
|||
package im.angry.openeuicc.ui
|
||||
|
||||
import android.annotation.SuppressLint
|
||||
import android.app.PendingIntent
|
||||
import android.content.BroadcastReceiver
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.content.IntentFilter
|
||||
import android.hardware.usb.UsbDevice
|
||||
import android.hardware.usb.UsbManager
|
||||
import android.os.Build
|
||||
import android.os.Bundle
|
||||
import android.view.LayoutInflater
|
||||
import android.view.View
|
||||
import android.view.ViewGroup
|
||||
import android.widget.Button
|
||||
import android.widget.ProgressBar
|
||||
import android.widget.TextView
|
||||
import androidx.fragment.app.Fragment
|
||||
import androidx.fragment.app.commit
|
||||
import androidx.lifecycle.lifecycleScope
|
||||
import im.angry.openeuicc.common.R
|
||||
import im.angry.openeuicc.core.EuiccChannelManager
|
||||
import im.angry.openeuicc.util.*
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
|
||||
/**
|
||||
* A wrapper fragment over EuiccManagementFragment where we handle
|
||||
* logic specific to USB devices. This is mainly USB permission
|
||||
* requests, and the fact that USB devices may or may not be
|
||||
* available by the time the user selects it from MainActivity.
|
||||
*
|
||||
* Having this fragment allows MainActivity to be (mostly) agnostic
|
||||
* of the underlying implementation of different types of channels.
|
||||
* When permission is granted, this fragment will simply load
|
||||
* EuiccManagementFragment using its own childFragmentManager.
|
||||
*
|
||||
* Note that for now we assume there will only be one USB card reader
|
||||
* device. This is also an implicit assumption in EuiccChannelManager.
|
||||
*/
|
||||
class UsbCcidReaderFragment : Fragment(), OpenEuiccContextMarker {
|
||||
companion object {
|
||||
const val ACTION_USB_PERMISSION = "im.angry.openeuicc.USB_PERMISSION"
|
||||
}
|
||||
|
||||
private val euiccChannelManager: EuiccChannelManager by lazy {
|
||||
(requireActivity() as MainActivity).euiccChannelManager
|
||||
}
|
||||
|
||||
private val usbManager: UsbManager by lazy {
|
||||
requireContext().getSystemService(Context.USB_SERVICE) as UsbManager
|
||||
}
|
||||
|
||||
private val usbPermissionReceiver = object : BroadcastReceiver() {
|
||||
override fun onReceive(context: Context?, intent: Intent?) {
|
||||
if (intent?.action == ACTION_USB_PERMISSION) {
|
||||
if (usbDevice != null && usbManager.hasPermission(usbDevice)) {
|
||||
lifecycleScope.launch(Dispatchers.Main) {
|
||||
tryLoadUsbChannel()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private lateinit var usbPendingIntent: PendingIntent
|
||||
|
||||
private lateinit var text: TextView
|
||||
private lateinit var permissionButton: Button
|
||||
private lateinit var loadingProgress: ProgressBar
|
||||
|
||||
private var usbDevice: UsbDevice? = null
|
||||
|
||||
override fun onCreateView(
|
||||
inflater: LayoutInflater,
|
||||
container: ViewGroup?,
|
||||
savedInstanceState: Bundle?
|
||||
): View? {
|
||||
val view = inflater.inflate(R.layout.fragment_usb_ccid_reader, container, false)
|
||||
|
||||
text = view.requireViewById(R.id.usb_reader_text)
|
||||
permissionButton = view.requireViewById(R.id.usb_grant_permission)
|
||||
loadingProgress = view.requireViewById(R.id.loading)
|
||||
|
||||
permissionButton.setOnClickListener {
|
||||
usbManager.requestPermission(usbDevice, usbPendingIntent)
|
||||
}
|
||||
|
||||
return view
|
||||
}
|
||||
|
||||
@SuppressLint("UnspecifiedRegisterReceiverFlag", "WrongConstant")
|
||||
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
|
||||
super.onViewCreated(view, savedInstanceState)
|
||||
|
||||
usbPendingIntent = PendingIntent.getBroadcast(
|
||||
requireContext(), 0,
|
||||
Intent(ACTION_USB_PERMISSION), PendingIntent.FLAG_IMMUTABLE
|
||||
)
|
||||
val filter = IntentFilter(ACTION_USB_PERMISSION)
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
|
||||
requireContext().registerReceiver(
|
||||
usbPermissionReceiver,
|
||||
filter,
|
||||
Context.RECEIVER_EXPORTED
|
||||
)
|
||||
} else {
|
||||
requireContext().registerReceiver(usbPermissionReceiver, filter)
|
||||
}
|
||||
|
||||
lifecycleScope.launch(Dispatchers.Main) {
|
||||
tryLoadUsbChannel()
|
||||
}
|
||||
}
|
||||
|
||||
override fun onDetach() {
|
||||
super.onDetach()
|
||||
try {
|
||||
requireContext().unregisterReceiver(usbPermissionReceiver)
|
||||
} catch (_: Exception) {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
|
||||
override fun onDestroy() {
|
||||
super.onDestroy()
|
||||
try {
|
||||
requireContext().unregisterReceiver(usbPermissionReceiver)
|
||||
} catch (_: Exception) {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun tryLoadUsbChannel() {
|
||||
text.visibility = View.GONE
|
||||
permissionButton.visibility = View.GONE
|
||||
loadingProgress.visibility = View.VISIBLE
|
||||
|
||||
val (device, canOpen) = withContext(Dispatchers.IO) {
|
||||
euiccChannelManager.tryOpenUsbEuiccChannel()
|
||||
}
|
||||
|
||||
loadingProgress.visibility = View.GONE
|
||||
|
||||
usbDevice = 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 && canOpen) {
|
||||
childFragmentManager.commit {
|
||||
replace(
|
||||
R.id.child_container,
|
||||
appContainer.uiComponentFactory.createEuiccManagementFragment(
|
||||
slotId = EuiccChannelManager.USB_CHANNEL_ID,
|
||||
portId = 0
|
||||
)
|
||||
)
|
||||
}
|
||||
} else {
|
||||
text.text = getString(R.string.usb_failed)
|
||||
text.visibility = View.VISIBLE
|
||||
permissionButton.visibility = View.GONE
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
|
@ -3,30 +3,69 @@ package im.angry.openeuicc.util
|
|||
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
|
||||
|
||||
interface EuiccChannelFragmentMarker: OpenEuiccContextMarker
|
||||
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()
|
||||
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()
|
||||
}
|
||||
return instance
|
||||
|
||||
// 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(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
|
||||
|
||||
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)
|
||||
}
|
||||
|
||||
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")
|
||||
suspend fun <T> T.ensureEuiccChannelManager() where T : Fragment, T : OpenEuiccContextMarker =
|
||||
requireEuiccActivity().euiccChannelManagerLoaded.await()
|
||||
|
||||
val <T> T.channel: EuiccChannel where T: Fragment, T: EuiccChannelFragmentMarker
|
||||
get() =
|
||||
euiccChannelManager.findEuiccChannelByPortBlocking(slotId, portId)!!
|
||||
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()
|
||||
|
|
|
@ -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('$')
|
||||
}
|
||||
}
|
|
@ -1,18 +1,106 @@
|
|||
package im.angry.openeuicc.util
|
||||
|
||||
import android.util.Log
|
||||
import im.angry.openeuicc.core.EuiccChannel
|
||||
import im.angry.openeuicc.core.EuiccChannelManager
|
||||
import net.typeblog.lpac_jni.LocalProfileAssistant
|
||||
import net.typeblog.lpac_jni.LocalProfileInfo
|
||||
|
||||
const val TAG = "LPAUtils"
|
||||
|
||||
val LocalProfileInfo.displayName: String
|
||||
get() = nickName.ifEmpty { name }
|
||||
|
||||
|
||||
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
|
||||
|
||||
fun LocalProfileAssistant.switchProfile(
|
||||
iccid: String,
|
||||
enable: Boolean = false,
|
||||
refresh: Boolean = false
|
||||
): Boolean =
|
||||
if (enable) {
|
||||
enableProfile(iccid, refresh)
|
||||
} else {
|
||||
disableProfile(iccid, refresh)
|
||||
}
|
||||
|
||||
fun LocalProfileAssistant.disableActiveProfileWithUndo(): () -> Unit =
|
||||
profiles.find { it.state == LocalProfileInfo.State.Enabled }?.let {
|
||||
disableProfile(it.iccid)
|
||||
return { enableProfile(it.iccid) }
|
||||
} ?: { }
|
||||
/**
|
||||
* Disable the current active profile if any. If refresh is true, also cause a refresh command.
|
||||
* See EuiccManager.waitForReconnect()
|
||||
*/
|
||||
fun LocalProfileAssistant.disableActiveProfile(refresh: Boolean): Boolean =
|
||||
profiles.enabled?.let {
|
||||
Log.i(TAG, "Disabling active profile ${it.iccid}")
|
||||
disableProfile(it.iccid, refresh)
|
||||
} ?: true
|
||||
|
||||
/**
|
||||
* 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.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
|
||||
* Automatically handle any newly generated notification during the operation
|
||||
* if the function "op" returns true.
|
||||
*
|
||||
* This requires the EuiccChannelManager object and a slotId / portId instead of
|
||||
* just an LPA object, because a LPA might become invalid during an operation
|
||||
* that generates notifications. As such, we will end up having to reconnect
|
||||
* when this happens.
|
||||
*
|
||||
* Note that however, if reconnect is required and will not be instant, waiting
|
||||
* 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.
|
||||
*/
|
||||
suspend inline fun EuiccChannelManager.beginTrackedOperation(
|
||||
slotId: Int,
|
||||
portId: Int,
|
||||
op: () -> Boolean
|
||||
) {
|
||||
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;
|
||||
// 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")
|
||||
channel.lpa.handleNotification(it.seqNumber)
|
||||
}
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
// Ignore any error during notification handling
|
||||
e.printStackTrace()
|
||||
}
|
||||
}
|
||||
Log.d(TAG, "Operation complete")
|
||||
}
|
|
@ -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_ENABLE = booleanPreferencesKey("notification_enable")
|
||||
val NOTIFICATION_DISABLE = booleanPreferencesKey("notification_disable")
|
||||
val NOTIFICATION_SWITCH = booleanPreferencesKey("notification_switch")
|
||||
|
||||
// ---- Advanced ----
|
||||
val DISABLE_SAFEGUARD_REMOVABLE_ESIM = booleanPreferencesKey("disable_safeguard_removable_esim")
|
||||
val VERBOSE_LOGGING = booleanPreferencesKey("verbose_logging")
|
||||
|
||||
// ---- Developer Options ----
|
||||
val DEVELOPER_OPTIONS_ENABLED = booleanPreferencesKey("developer_options_enabled")
|
||||
val REFRESH_AFTER_SWITCH = booleanPreferencesKey("refresh_after_switch")
|
||||
val UNFILTERED_PROFILE_LIST = booleanPreferencesKey("unfiltered_profile_list")
|
||||
val IGNORE_TLS_CERTIFICATE = booleanPreferencesKey("ignore_tls_certificate")
|
||||
val EUICC_MEMORY_RESET = booleanPreferencesKey("euicc_memory_reset")
|
||||
val ISDR_AID_LIST = stringPreferencesKey("isdr_aid_list")
|
||||
}
|
||||
|
||||
class PreferenceRepository(context: Context) {
|
||||
private val dataStore = context.dataStore
|
||||
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: Flow<Boolean> =
|
||||
dataStore.data.map { it[PreferenceKeys.NOTIFICATION_DOWNLOAD] ?: true }
|
||||
val notificationDownloadFlow = bindFlow(PreferenceKeys.NOTIFICATION_DOWNLOAD, true)
|
||||
val notificationDeleteFlow = bindFlow(PreferenceKeys.NOTIFICATION_DELETE, true)
|
||||
val notificationSwitchFlow = bindFlow(PreferenceKeys.NOTIFICATION_SWITCH, false)
|
||||
|
||||
val notificationDeleteFlow: Flow<Boolean> =
|
||||
dataStore.data.map { it[PreferenceKeys.NOTIFICATION_DELETE] ?: true }
|
||||
// ---- Advanced ----
|
||||
val disableSafeguardFlow = bindFlow(PreferenceKeys.DISABLE_SAFEGUARD_REMOVABLE_ESIM, false)
|
||||
val verboseLoggingFlow = bindFlow(PreferenceKeys.VERBOSE_LOGGING, false)
|
||||
|
||||
// Enabling / disabling notifications are not sent by default
|
||||
val notificationEnableFlow: Flow<Boolean> =
|
||||
dataStore.data.map { it[PreferenceKeys.NOTIFICATION_ENABLE] ?: 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() })
|
||||
|
||||
val notificationDisableFlow: Flow<Boolean> =
|
||||
dataStore.data.map { it[PreferenceKeys.NOTIFICATION_DISABLE] ?: false }
|
||||
protected fun <T> bindFlow(
|
||||
key: Preferences.Key<T>,
|
||||
defaultValue: T,
|
||||
encoder: (T) -> T = { it },
|
||||
decoder: (T) -> T = { it }
|
||||
): PreferenceFlowWrapper<T> =
|
||||
PreferenceFlowWrapper(context, key, defaultValue, encoder, decoder)
|
||||
}
|
||||
|
||||
suspend fun <T> updatePreference(key: Preferences.Key<T>, value: T) {
|
||||
dataStore.edit {
|
||||
it[key] = value
|
||||
}
|
||||
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)
|
||||
|
@ -20,3 +20,94 @@ fun ByteArray.encodeHex(): String {
|
|||
}
|
||||
return sb.toString()
|
||||
}
|
||||
|
||||
fun formatFreeSpace(size: Int): String =
|
||||
// SIM cards probably won't have much more space anytime soon.
|
||||
if (size >= 1024) {
|
||||
"%.2f KiB".format(size.toDouble() / 1024)
|
||||
} 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)
|
||||
}
|
||||
}
|
|
@ -2,20 +2,22 @@ package im.angry.openeuicc.util
|
|||
|
||||
import android.content.Context
|
||||
import android.content.pm.PackageManager
|
||||
import android.graphics.Bitmap
|
||||
import android.se.omapi.SEService
|
||||
import android.telephony.TelephonyManager
|
||||
import androidx.fragment.app.Fragment
|
||||
import com.google.zxing.BinaryBitmap
|
||||
import com.google.zxing.RGBLuminanceSource
|
||||
import com.google.zxing.common.HybridBinarizer
|
||||
import com.google.zxing.qrcode.QRCodeReader
|
||||
import im.angry.openeuicc.OpenEuiccApplication
|
||||
import im.angry.openeuicc.core.EuiccChannel
|
||||
import im.angry.openeuicc.core.EuiccChannelManager
|
||||
import im.angry.openeuicc.di.AppContainer
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.runBlocking
|
||||
import kotlinx.coroutines.sync.Mutex
|
||||
import kotlinx.coroutines.sync.withLock
|
||||
import kotlinx.coroutines.withContext
|
||||
import net.typeblog.lpac_jni.LocalProfileInfo
|
||||
import java.lang.RuntimeException
|
||||
import kotlin.RuntimeException
|
||||
import kotlin.coroutines.resume
|
||||
import kotlin.coroutines.resumeWithException
|
||||
import kotlin.coroutines.suspendCoroutine
|
||||
|
@ -24,7 +26,7 @@ val Context.selfAppVersion: String
|
|||
get() =
|
||||
try {
|
||||
val pInfo = packageManager.getPackageInfo(packageName, 0)
|
||||
pInfo.versionName
|
||||
pInfo.versionName!!
|
||||
} catch (e: PackageManager.NameNotFoundException) {
|
||||
throw RuntimeException(e)
|
||||
}
|
||||
|
@ -52,19 +54,13 @@ interface OpenEuiccContextMarker {
|
|||
val appContainer: AppContainer
|
||||
get() = openEuiccApplication.appContainer
|
||||
|
||||
val euiccChannelManager: EuiccChannelManager
|
||||
get() = appContainer.euiccChannelManager
|
||||
val preferenceRepository: PreferenceRepository
|
||||
get() = appContainer.preferenceRepository
|
||||
|
||||
val telephonyManager: TelephonyManager
|
||||
get() = appContainer.telephonyManager
|
||||
}
|
||||
|
||||
val LocalProfileInfo.isEnabled: Boolean
|
||||
get() = state == LocalProfileInfo.State.Enabled
|
||||
|
||||
val List<EuiccChannel>.hasMultipleChips: Boolean
|
||||
get() = distinctBy { it.slotId }.size > 1
|
||||
|
||||
// Create an instance of OMAPI SEService in a manner that "makes sense" without unpredictable callbacks
|
||||
suspend fun connectSEService(context: Context): SEService = suspendCoroutine { cont ->
|
||||
// Use a Mutex to make sure the continuation is run *after* the "service" variable is assigned
|
||||
|
@ -92,3 +88,21 @@ 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)
|
||||
bmp.getPixels(pixels, 0, bmp.width, 0, 0, bmp.width, bmp.height)
|
||||
|
||||
val luminanceSource = RGBLuminanceSource(bmp.width, bmp.height, pixels)
|
||||
val binaryBmp = BinaryBitmap(HybridBinarizer(luminanceSource))
|
||||
|
||||
QRCodeReader().decode(binaryBmp).text
|
||||
}.getOrNull()
|
||||
|
|
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>
|
5
app-common/src/main/res/drawable/ic_gallery_black.xml
Normal file
5
app-common/src/main/res/drawable/ic_gallery_black.xml
Normal file
|
@ -0,0 +1,5 @@
|
|||
<vector android:height="24dp" android:tint="?attr/colorControlNormal"
|
||||
android:viewportHeight="24" android:viewportWidth="24"
|
||||
android:width="24dp" xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<path android:fillColor="@android:color/white" android:pathData="M22,16L22,4c0,-1.1 -0.9,-2 -2,-2L8,2c-1.1,0 -2,0.9 -2,2v12c0,1.1 0.9,2 2,2h12c1.1,0 2,-0.9 2,-2zM11,12l2.03,2.71L16,11l4,5L8,16l3,-4zM2,6v14c0,1.1 0.9,2 2,2h14v-2L4,20L4,6L2,6z"/>
|
||||
</vector>
|
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_refresh_black.xml
Normal file
5
app-common/src/main/res/drawable/ic_refresh_black.xml
Normal file
|
@ -0,0 +1,5 @@
|
|||
<vector android:height="24dp" android:tint="?attr/colorControlNormal"
|
||||
android:viewportHeight="24" android:viewportWidth="24"
|
||||
android:width="24dp" xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<path android:fillColor="@android:color/white" android:pathData="M17.65,6.35C16.2,4.9 14.21,4 12,4c-4.42,0 -7.99,3.58 -7.99,8s3.57,8 7.99,8c3.73,0 6.84,-2.55 7.73,-6h-2.08c-0.82,2.33 -3.04,4 -5.65,4 -3.31,0 -6,-2.69 -6,-6s2.69,-6 6,-6c1.66,0 3.14,0.69 4.22,1.78L13,11h7V4l-2.35,2.35z"/>
|
||||
</vector>
|
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"
|
||||
|
|
|
@ -3,33 +3,39 @@
|
|||
xmlns:app="http://schemas.android.com/apk/res-auto"
|
||||
xmlns:tools="http://schemas.android.com/tools"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="match_parent"
|
||||
tools:context=".ui.MainActivity">
|
||||
android:layout_height="match_parent">
|
||||
|
||||
<com.google.android.material.appbar.MaterialToolbar
|
||||
android:id="@+id/toolbar"
|
||||
android:layout_width="0dp"
|
||||
<include layout="@layout/toolbar_activity" />
|
||||
|
||||
<com.google.android.material.tabs.TabLayout
|
||||
android:id="@+id/main_tabs"
|
||||
android:background="?attr/colorSurfaceVariant"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
app:layout_constraintTop_toTopOf="parent"
|
||||
app:layout_constraintLeft_toLeftOf="parent"
|
||||
app:layout_constraintWidth_percent="1" />
|
||||
android:visibility="gone"
|
||||
app:tabTextColor="?attr/colorOnSurfaceVariant"
|
||||
app:tabSelectedTextColor="?attr/colorOnSurfaceVariant"
|
||||
app:layout_constraintTop_toBottomOf="@id/toolbar"
|
||||
app:layout_constraintStart_toStartOf="parent" />
|
||||
|
||||
<FrameLayout
|
||||
android:id="@+id/fragment_root"
|
||||
<ProgressBar
|
||||
android:id="@+id/loading"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:indeterminate="true"
|
||||
app:layout_constraintStart_toStartOf="parent"
|
||||
app:layout_constraintEnd_toEndOf="parent"
|
||||
app:layout_constraintTop_toBottomOf="@id/main_tabs"
|
||||
app:layout_constraintBottom_toBottomOf="parent" />
|
||||
|
||||
<androidx.viewpager2.widget.ViewPager2
|
||||
android:id="@+id/view_pager"
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="0dp"
|
||||
android:visibility="gone"
|
||||
app:layout_constraintBottom_toBottomOf="parent"
|
||||
app:layout_constraintLeft_toLeftOf="parent"
|
||||
app:layout_constraintRight_toRightOf="parent"
|
||||
app:layout_constraintTop_toBottomOf="@id/toolbar">
|
||||
<TextView
|
||||
android:id="@+id/no_euicc_placeholder"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="match_parent"
|
||||
android:layout_marginStart="40dp"
|
||||
android:layout_marginEnd="40dp"
|
||||
android:gravity="center"
|
||||
android:text="@string/no_euicc" />
|
||||
</FrameLayout>
|
||||
app:layout_constraintTop_toBottomOf="@id/main_tabs"/>
|
||||
|
||||
</androidx.constraintlayout.widget.ConstraintLayout>
|
|
@ -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>
|
Some files were not shown because too many files have changed in this diff Show more
Loading…
Add table
Reference in a new issue