Compare commits
405 commits
unpriv-v1.
...
master
| Author | SHA1 | Date | |
|---|---|---|---|
| 6210c81201 | |||
| 0a353a3df6 | |||
| 713ffec26d | |||
| c676c27338 | |||
| 81810b22fb | |||
| fdde41329b | |||
| 2cf2d9490a | |||
| b9863e2e54 | |||
| 56fbd34616 | |||
| 419db21b12 | |||
| 9496370135 | |||
| 84f57a4ef7 | |||
| a75478dd31 | |||
| f17e171372 | |||
| e60f98fdef | |||
| 31991c909d | |||
| 392a2927cb | |||
| f3dbba0229 | |||
| 0c305ff6ff | |||
| e5568e2164 | |||
| 639c1d0ea6 | |||
| d7fb53ce5c | |||
| d40b839911 | |||
| 76ef63efdf | |||
| b7fc164a3f | |||
| 19b127df3f | |||
| aca541593c | |||
| fd79236591 | |||
| f498c22ad8 | |||
| c23ad21421 | |||
| 65a4c887f1 | |||
| 2bb10c72f6 | |||
| 7ca832f68e | |||
| 7e1c4907bb | |||
| 22a7a5145c | |||
| 6e5bbc4085 | |||
| 709a962314 | |||
| 755499e878 | |||
| 6b0e058d5c | |||
| 1938a4ff5a | |||
| f2c7e79bee | |||
| 552aba87c0 | |||
| cd34597362 | |||
| c396223a02 | |||
| 69fe2764c9 | |||
| 8ec0757e28 | |||
| 55e7731197 | |||
| 938e298ca3 | |||
| 8f51abe1af | |||
| 5e7dfd36ab | |||
| 4d43ab4824 | |||
| 4e873f4e88 | |||
| ebdffc5032 | |||
| cc9f99a2a9 | |||
| 3821cb7de0 | |||
| 1933cffd82 | |||
| 987eb4f71b | |||
| 418010ba69 | |||
| f3f97cc942 | |||
| af6270add2 | |||
| d0bd5e7dfb | |||
| 3b0e25b8ab | |||
| c74c1f309c | |||
| b245a9c893 | |||
| 351567a972 | |||
| 0096b9005f | |||
| 525212c1b8 | |||
| bf30f1478c | |||
| 2cda633fd0 | |||
| 7db1c1ea9d | |||
| d46461bd2d | |||
| 472f9d05ac | |||
| 0c98121c2a | |||
| acfeda8dc9 | |||
| 7bae82daf9 | |||
| cce247e747 | |||
| 915a05634b | |||
| 9e40232ed0 | |||
| 27b7e50b97 | |||
| 7e7f5c2b05 | |||
| 4d8b8e8fb5 | |||
| a8b7482afb | |||
| 3fed4b2bd6 | |||
| deb0a372b1 | |||
| 72ec20f824 | |||
| 7c2157daa4 | |||
| e604f39e0d | |||
| 797f3dc722 | |||
| 1be6c25cb6 | |||
| c0cd7a25cf | |||
| dfdd8948c5 | |||
| 1fd13634fc | |||
| 29b2cba673 | |||
| 8c982ae890 | |||
| eb006b8f19 | |||
| 0506475a85 | |||
| 0b4d065d4b | |||
| 52cf68d43d | |||
| 4c221f74ff | |||
| 4991f2580e | |||
| 65775c1d06 | |||
| cbe3d1fd50 | |||
| 677b69cedf | |||
| 6d43a9207c | |||
| f056e2d313 | |||
| 4ac0820bbf | |||
|
c0dc8ac19d |
|||
| 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 |
280 changed files with 10847 additions and 3700 deletions
22
.editorconfig
Normal file
22
.editorconfig
Normal file
|
|
@ -0,0 +1,22 @@
|
|||
root = true
|
||||
|
||||
[*]
|
||||
charset = utf-8
|
||||
indent_size = 2
|
||||
indent_style = space
|
||||
end_of_line = lf
|
||||
insert_final_newline = true
|
||||
trim_trailing_whitespace = true
|
||||
max_line_length = 120
|
||||
|
||||
[*.{kt,java}]
|
||||
indent_size = 4
|
||||
|
||||
[*.gradle.kts]
|
||||
indent_size = 4
|
||||
|
||||
[*.xml]
|
||||
indent_size = 4
|
||||
|
||||
[.idea/**/*.xml]
|
||||
indent_size = 2
|
||||
|
|
@ -1,11 +1,15 @@
|
|||
name: Build Debug APKs
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- 'master'
|
||||
- '*'
|
||||
tags:
|
||||
- '*'
|
||||
|
||||
jobs:
|
||||
build-debug:
|
||||
runs-on: [docker, android-app-certs]
|
||||
runs-on: [ docker, android-app-certs ]
|
||||
container:
|
||||
volumes:
|
||||
- android-app-keystore:/keystore
|
||||
|
|
@ -33,13 +37,23 @@ jobs:
|
|||
uses: https://gitea.angry.im/actions/setup-android@v3
|
||||
|
||||
- name: Build Debug APKs
|
||||
run: ./gradlew --no-daemon assembleDebug
|
||||
run: ./gradlew --no-daemon assembleDebug :app:assembleDebugMagiskModuleDir
|
||||
|
||||
- name: Upload Artifacts
|
||||
- name: Copy Artifacts
|
||||
run: |
|
||||
find . -name 'app*-debug.apk' -exec cp {} . \;
|
||||
cp -r app/build/magisk/debug ./magisk-debug
|
||||
|
||||
- name: Upload APK Artifacts
|
||||
uses: https://gitea.angry.im/actions/upload-artifact@v3
|
||||
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
|
||||
|
||||
- name: Upload Magisk Artifacts
|
||||
uses: https://gitea.angry.im/actions/upload-artifact@v3
|
||||
with:
|
||||
name: magisk-debug
|
||||
compression-level: 0
|
||||
path: magisk-debug
|
||||
|
|
|
|||
|
|
@ -1,10 +1,12 @@
|
|||
name: Build Release
|
||||
|
||||
on:
|
||||
push:
|
||||
tags: '*'
|
||||
|
||||
jobs:
|
||||
release:
|
||||
runs-on: [docker, android-app-certs]
|
||||
runs-on: [ docker, android-app-certs ]
|
||||
container:
|
||||
volumes:
|
||||
- android-app-keystore:/keystore
|
||||
|
|
|
|||
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
|
||||
|
|
|
|||
2
.gitmodules
vendored
2
.gitmodules
vendored
|
|
@ -1,3 +1,3 @@
|
|||
[submodule "libs/lpac-jni/src/main/jni/lpac"]
|
||||
path = libs/lpac-jni/src/main/jni/lpac
|
||||
url = https://github.com/estkme/lpac
|
||||
url = https://github.com/estkme-group/lpac.git
|
||||
|
|
|
|||
10
.idea/.gitignore
generated
vendored
10
.idea/.gitignore
generated
vendored
|
|
@ -1,3 +1,7 @@
|
|||
# Default ignored files
|
||||
/shelf/
|
||||
/workspace.xml
|
||||
*
|
||||
!/codeStyles/Project.xml
|
||||
!/codeStyles/codeStyleConfig.xml
|
||||
!/vcs.xml
|
||||
!/kotlinc.xml
|
||||
!/compiler.xml
|
||||
!/migrations.xml
|
||||
|
|
|
|||
45
.idea/codeStyles/Project.xml
generated
45
.idea/codeStyles/Project.xml
generated
|
|
@ -1,5 +1,47 @@
|
|||
<component name="ProjectCodeStyleConfiguration">
|
||||
<code_scheme name="Project" version="173">
|
||||
<JavaCodeStyleSettings>
|
||||
<option name="IMPORT_LAYOUT_TABLE">
|
||||
<value>
|
||||
<package name="android" withSubpackages="true" static="true" />
|
||||
<package name="androidx" withSubpackages="true" static="true" />
|
||||
<package name="com" withSubpackages="true" static="true" />
|
||||
<package name="junit" withSubpackages="true" static="true" />
|
||||
<package name="net" withSubpackages="true" static="true" />
|
||||
<package name="org" withSubpackages="true" static="true" />
|
||||
<package name="java" withSubpackages="true" static="true" />
|
||||
<package name="javax" withSubpackages="true" static="true" />
|
||||
<package name="" withSubpackages="true" static="true" />
|
||||
<emptyLine />
|
||||
<package name="android" withSubpackages="true" static="false" />
|
||||
<emptyLine />
|
||||
<package name="androidx" withSubpackages="true" static="false" />
|
||||
<emptyLine />
|
||||
<package name="com" withSubpackages="true" static="false" />
|
||||
<emptyLine />
|
||||
<package name="junit" withSubpackages="true" static="false" />
|
||||
<emptyLine />
|
||||
<package name="net" withSubpackages="true" static="false" />
|
||||
<emptyLine />
|
||||
<package name="org" withSubpackages="true" static="false" />
|
||||
<emptyLine />
|
||||
<package name="java" withSubpackages="true" static="false" />
|
||||
<emptyLine />
|
||||
<package name="javax" withSubpackages="true" static="false" />
|
||||
<emptyLine />
|
||||
<package name="" withSubpackages="true" static="false" />
|
||||
<emptyLine />
|
||||
</value>
|
||||
</option>
|
||||
</JavaCodeStyleSettings>
|
||||
<JetCodeStyleSettings>
|
||||
<option name="PACKAGES_TO_USE_STAR_IMPORTS">
|
||||
<value>
|
||||
<package name="im.angry.openeuicc.util" alias="false" withSubpackages="true" />
|
||||
</value>
|
||||
</option>
|
||||
<option name="CODE_STYLE_DEFAULTS" value="KOTLIN_OFFICIAL" />
|
||||
</JetCodeStyleSettings>
|
||||
<codeStyleSettings language="XML">
|
||||
<option name="FORCE_REARRANGE_MODE" value="1" />
|
||||
<indentOptions>
|
||||
|
|
@ -113,5 +155,8 @@
|
|||
</rules>
|
||||
</arrangement>
|
||||
</codeStyleSettings>
|
||||
<codeStyleSettings language="kotlin">
|
||||
<option name="CODE_STYLE_DEFAULTS" value="KOTLIN_OFFICIAL" />
|
||||
</codeStyleSettings>
|
||||
</code_scheme>
|
||||
</component>
|
||||
2
.idea/codeStyles/codeStyleConfig.xml
generated
2
.idea/codeStyles/codeStyleConfig.xml
generated
|
|
@ -1,5 +1,5 @@
|
|||
<component name="ProjectCodeStyleConfiguration">
|
||||
<state>
|
||||
<option name="PREFERRED_PROJECT_CODE_STYLE" value="Default" />
|
||||
<option name="USE_PER_PROJECT_SETTINGS" value="true" />
|
||||
</state>
|
||||
</component>
|
||||
14
.idea/compiler.xml
generated
14
.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>
|
||||
</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>
|
||||
4
.idea/kotlinc.xml
generated
4
.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>
|
||||
</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>
|
||||
2
.idea/vcs.xml
generated
2
.idea/vcs.xml
generated
|
|
@ -4,4 +4,4 @@
|
|||
<mapping directory="$PROJECT_DIR$" vcs="Git" />
|
||||
<mapping directory="$PROJECT_DIR$/libs/lpac-jni/src/main/jni/lpac" vcs="Git" />
|
||||
</component>
|
||||
</project>
|
||||
</project>
|
||||
|
|
|
|||
114
README.md
114
README.md
|
|
@ -2,20 +2,53 @@
|
|||
|
||||
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](https://easyeuicc.org):
|
||||
|
||||
- OpenEUICC: The full-fledged privileged variant.
|
||||
- Due to its privilege requirement, OpenEUICC must be placed inside `/system/priv-app` and be signed with the platform certificate.
|
||||
- The preferred way to including OpenEUICC in a system image is to [build it along with AOSP](#building-aosp).
|
||||
- EasyEUICC: Unprivileged version that can run as a user app.
|
||||
- This version supports two modes of operation:
|
||||
1. Inserted, removable eSIMs: Due to obvious security requirements, EasyEUICC is only able to access eSIM chips whose [ARF/ARA](https://source.android.com/docs/core/connect/uicc#arf) contains the hash of EasyEUICC's signing certificate.
|
||||
2. USB CCID Card Readers: Only `T=0` readers that use the standard [USB CCID protocol](https://en.wikipedia.org/wiki/CCID_(protocol)) are supported. In this mode, EasyEUICC can access any eSIM chip loaded in the card reader regardless of their ARF/ARA, as long as they implement the [SGP.22 standard](https://www.gsma.com/solutions-and-impact/technologies/esim/wp-content/uploads/2021/07/SGP.22-v2.3.pdf).
|
||||
- Prebuilt release-mode EasyEUICC apks can be downloaded [here](https://gitea.angry.im/PeterCxy/OpenEUICC/releases)
|
||||
- For removable eSIM chip vendors: to have your chip supported by official builds of EasyEUICC when inserted, include the ARA-M hash `2A2FA878BC7C3354C2CF82935A5945A3EDAE4AFA`
|
||||
| | OpenEUICC | EasyEUICC |
|
||||
|:------------------------------|:-------------------------------:|:-------------------:|
|
||||
| Privileged | Must be installed as system app | No |
|
||||
| Internal eSIM | Supported | Unsupported |
|
||||
| External eSIM [^1] | Supported | Supported |
|
||||
| USB Readers | Supported | Supported |
|
||||
| Requires allowlisting by eSIM | No | Yes -- except USB |
|
||||
| System Integration | Partial [^2] | No |
|
||||
| Minimum Android Version | Android 11 or higher | Android 9 or higher |
|
||||
|
||||
Building (Gradle)
|
||||
===
|
||||
[^1]: Also known as "Removable eSIM"
|
||||
[^2]: Carrier Partner API unimplemented yet
|
||||
|
||||
Some side notes:
|
||||
|
||||
1. When privileged, OpenEUICC supports any eUICC chip that implements the [SGP.22] standard, internal or external.
|
||||
However, there is **no guarantee** that external (removable) eSIMs actually follow the standard.
|
||||
Please **DO NOT** submit bug reports for non-functioning removable eSIMs.
|
||||
They are **NOT** officially supported unless they also support / are supported by EasyEUICC, the unprivileged
|
||||
variant.
|
||||
2. Both variants support accessing eUICC chips through USB CCID readers,
|
||||
regardless of whether the chip contains the correct ARA-M hash to allow for unprivileged access.
|
||||
However, only `T=0` readers that use the standard [USB CCID protocol][usb-ccid] are supported.
|
||||
3. Prebuilt release-mode EasyEUICC apks can be downloaded [here][releases].
|
||||
For OpenEUICC, no official release is currently provided and only debug mode APKs and Magisk modules can be found in
|
||||
the [CI page][actions].
|
||||
4. For removable eSIM chip vendors: to have your chip supported by official builds of EasyEUICC when inserted,
|
||||
include the ARA-M hash `2A2FA878BC7C3354C2CF82935A5945A3EDAE4AFA`.
|
||||
|
||||
[sgp.22]: https://www.gsma.com/solutions-and-impact/technologies/esim/gsma_resources/sgp-22-v2-2-2/ "SGP.22 v2.2.2"
|
||||
|
||||
[usb-ccid]: https://en.wikipedia.org/wiki/CCID_%28protocol%29 "USB CCID Protocol"
|
||||
|
||||
[releases]: https://gitea.angry.im/PeterCxy/OpenEUICC/releases "EasyEUICC Releases"
|
||||
|
||||
[actions]: https://gitea.angry.im/PeterCxy/OpenEUICC/actions "OpenEUICC Actions"
|
||||
|
||||
**This project is Free Software licensed under GNU GPL v3, WITHOUT the "or later" clause.**
|
||||
Any modification and derivative work **MUST** be released under the SAME license, which means, at the very least, that
|
||||
the source code **MUST** be available upon request.
|
||||
|
||||
**If you are releasing a modification of this app, you are kindly asked to make changes to at least the app name and
|
||||
package name.**
|
||||
|
||||
# Building (Gradle)
|
||||
|
||||
Make sure you have all submodules cloned and updated by running
|
||||
|
||||
|
|
@ -48,41 +81,50 @@ For EasyEUICC:
|
|||
./gradlew :app-unpriv:assembleRelease
|
||||
```
|
||||
|
||||
Building (AOSP)
|
||||
===
|
||||
# Building (AOSP)
|
||||
|
||||
There are two ways to include OpenEUICC in your AOSP-based system image:
|
||||
|
||||
1. Include this project and its [dependencies](https://gitea.angry.im/PeterCxy/android_prebuilts_openeuicc-deps) inside the AOSP tree.
|
||||
- If inclusion in `manifest.xml` is required, remember to set the `sync-s` option to clone submodules.
|
||||
- The module name is `OpenEUICC`. You can include it in `PRODUCT_PACKAGES`, or simply build it standalone using `mm`.
|
||||
- Compilation of this project is **only** tested against the latest AOSP release version. The app itself should be compatible with older AOSP versions, but the source may not compile against an older AOSP source tree.
|
||||
2. If compilation against AOSP source tree is not possible, consider [building with gradle](#building-gradle) and import the apk as a prebuilt.
|
||||
- No official `Android.bp` is provided for this case but it should be straightforward to write.
|
||||
- You might want to include `privapp_whitelist_im.angry.openeuicc.xml` as well.
|
||||
1. Include this project and its [dependencies](https://gitea.angry.im/PeterCxy/android_prebuilts_openeuicc-deps) inside
|
||||
the AOSP tree.
|
||||
|
||||
FAQs
|
||||
===
|
||||
- If inclusion in `manifest.xml` is required, remember to set the `sync-s` option to clone submodules.
|
||||
- The module name is `OpenEUICC`. You can include it in `PRODUCT_PACKAGES`, or simply build it standalone using `mm`.
|
||||
- Compilation of this project is **only** tested against the latest AOSP release version. The app itself should be
|
||||
compatible with older AOSP versions, but the source may not compile against an older AOSP source tree.
|
||||
|
||||
- Q: Do you provide prebuilt binaries for OpenEUICC?
|
||||
- A: Debug-mode APKs are available continuously as an artifact of the [Actions](https://gitea.angry.im/PeterCxy/OpenEUICC/actions) CI used by this project. However, these debug-mode APKs are **not** intended for inclusion inside system images, nor are they supported by the developer in any sense. If you are a custom ROM developer, either include the entire OpenEUICC repository in your AOSP source tree, or generate an APK using `gradle` and import that as a prebuilt system app. Note that you might want `privapp_whitelist_im.angry.openeuicc.xml` as well.
|
||||
2. If compilation against AOSP source tree is not possible, consider [building with gradle](#building-gradle) and import
|
||||
the apk as a prebuilt.
|
||||
|
||||
- Q: AOSP's Settings app seems to be confused by OpenEUICC (for example, disabling / enabling profiles from the Networks page do not work properly)
|
||||
- A: When your device has internal eSIM chip(s) __and__ you have inserted a removable eSIM chip, the Settings app can misbehave since it was never designed for this scenario. __Please prefer using OpenEUICC's own management interface whenever possible.__ In the future, there might be an option to exclude removable SIMs from being reported to the Android system.
|
||||
- No official `Android.bp` is provided for this case but it should be straightforward to write.
|
||||
- You might want to include [`privapp_whitelist_im.angry.openeuicc.xml`] as well.
|
||||
|
||||
- Q: Can EasyEUICC manage my phone's internal eSIM?
|
||||
- A: No. For EasyEUICC to work, the eSIM chip MUST proactively grant access via its ARA-M field.
|
||||
[`privapp_whitelist_im.angry.openeuicc.xml`]: privapp_whitelist_im.angry.openeuicc.xml "OpenEUICC Privapp Whitelist"
|
||||
|
||||
- Q: Removable eSIMs? Are they a joke?
|
||||
- A: No, even though the name "removable embedded SIM" can sound like an oxymoron. In fact, there can be many advantages to these chips compared to fully embedded ones. For example, the ability to transfer eSIM profiles without carrier support or approval, or the ability to use eSIM on devices that do not and may never get the support, such as Wi-Fi hotspots.
|
||||
# FAQs
|
||||
|
||||
Copyright
|
||||
===
|
||||
- Q: Do you provide prebuilt binaries for OpenEUICC? \
|
||||
A: Debug-mode APKs and Magisk modules are available continuously as an artifact of the [Actions] CI used by this
|
||||
project. However, these debug-mode APKs are **not** intended for inclusion inside system images, nor are they
|
||||
supported by the developer in any sense. If you are a custom ROM developer, either include the entire OpenEUICC
|
||||
repository in your AOSP source tree, or generate an APK using `gradle` and import that as a prebuilt system app. Note
|
||||
that you might want [`privapp_whitelist_im.angry.openeuicc.xml`] as well.
|
||||
|
||||
- Q: Can EasyEUICC manage my phone's internal eSIM? \
|
||||
A: No. For EasyEUICC to work, the eSIM chip MUST proactively grant access via its ARA-M field.
|
||||
|
||||
- Q: Removable eSIMs? Are they a joke? \
|
||||
A: No, even though the name "removable embedded SIM" can sound like an oxymoron. In fact, there can be many advantages
|
||||
to these chips compared to fully embedded ones. For example, the ability to transfer eSIM profiles without carrier
|
||||
support or approval, or the ability to use eSIM on devices that do not and may never get the support, such as Wi-Fi
|
||||
hotspots.
|
||||
|
||||
# Copyright
|
||||
|
||||
Everything except `libs/lpac-jni` and `art/`:
|
||||
|
||||
```
|
||||
Copyright 2022-2024 OpenEUICC contributors
|
||||
Copyright 2022-2026 OpenEUICC contributors
|
||||
|
||||
This program is free software; you can redistribute it and/or
|
||||
modify it under the terms of the GNU General Public License
|
||||
|
|
@ -101,7 +143,7 @@ Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
|
|||
`libs/lpac-jni`:
|
||||
|
||||
```
|
||||
Copyright (C) 2022-2024 OpenEUICC contributiors
|
||||
Copyright (C) 2022-2026 OpenEUICC contributiors
|
||||
|
||||
This library is free software; you can redistribute it and/or
|
||||
modify it under the terms of the GNU Lesser General Public
|
||||
|
|
@ -117,4 +159,4 @@ License along with this library; if not, write to the Free Software
|
|||
Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA
|
||||
```
|
||||
|
||||
`art/`: Courtesy of [Aikoyori](https://github.com/Aikoyori), CC NC-SA 4.0.
|
||||
`art/`: Courtesy of [Aikoyori](https://github.com/Aikoyori), CC NC-SA 4.0.
|
||||
|
|
|
|||
|
|
@ -5,7 +5,7 @@ plugins {
|
|||
|
||||
android {
|
||||
namespace = "im.angry.openeuicc.common"
|
||||
compileSdk = 34
|
||||
compileSdk = 35
|
||||
|
||||
defaultConfig {
|
||||
minSdk = 28
|
||||
|
|
|
|||
|
|
@ -1,13 +1,11 @@
|
|||
package im.angry.openeuicc.common
|
||||
|
||||
import androidx.test.platform.app.InstrumentationRegistry
|
||||
import androidx.test.ext.junit.runners.AndroidJUnit4
|
||||
|
||||
import androidx.test.platform.app.InstrumentationRegistry
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Test
|
||||
import org.junit.runner.RunWith
|
||||
|
||||
import org.junit.Assert.*
|
||||
|
||||
/**
|
||||
* Instrumented test, which will execute on an Android device.
|
||||
*
|
||||
|
|
@ -21,4 +19,4 @@ class ExampleInstrumentedTest {
|
|||
val appContext = InstrumentationRegistry.getInstrumentation().targetContext
|
||||
assertEquals("im.angry.openeuicc.common.test", appContext.packageName)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,12 +1,17 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<manifest xmlns:tools="http://schemas.android.com/tools"
|
||||
xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
xmlns:tools="http://schemas.android.com/tools"
|
||||
package="im.angry.openeuicc.common">
|
||||
|
||||
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
|
||||
<uses-permission android:name="android.permission.READ_PHONE_STATE" />
|
||||
<uses-permission android:name="android.permission.INTERNET" />
|
||||
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
|
||||
<uses-permission android:name="android.permission.WAKE_LOCK" />
|
||||
|
||||
<application>
|
||||
<application
|
||||
android:enableOnBackInvokedCallback="true"
|
||||
tools:targetApi="tiramisu">
|
||||
<activity
|
||||
android:name="im.angry.openeuicc.ui.SettingsActivity"
|
||||
android:label="@string/pref_settings" />
|
||||
|
|
@ -16,14 +21,43 @@
|
|||
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:name="im.angry.openeuicc.ui.wizard.DownloadWizardActivity"
|
||||
android:exported="true"
|
||||
android:label="@string/download_wizard">
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.VIEW" />
|
||||
|
||||
<category android:name="android.intent.category.DEFAULT" />
|
||||
<category android:name="android.intent.category.BROWSABLE" />
|
||||
|
||||
<!-- Accepts URIs that begin with "lpa:" -->
|
||||
<!-- for example: "LPA:1$..." -->
|
||||
<!-- refs: https://www.iana.org/assignments/uri-schemes/prov/lpa -->
|
||||
<data android:scheme="lpa" />
|
||||
<data
|
||||
android:scheme="LPA"
|
||||
tools:ignore="AppLinkUrlError" />
|
||||
<data android:sspPrefix="1$" />
|
||||
</intent-filter>
|
||||
</activity>
|
||||
|
||||
<activity-alias
|
||||
android:name="im.angry.openeuicc.ui.DirectProfileDownloadActivity"
|
||||
android:exported="true"
|
||||
android:targetActivity="im.angry.openeuicc.ui.wizard.DownloadWizardActivity" />
|
||||
|
||||
<activity
|
||||
android:name="com.journeyapps.barcodescanner.CaptureActivity"
|
||||
android:screenOrientation="fullSensor"
|
||||
|
|
@ -31,6 +65,7 @@
|
|||
|
||||
<service
|
||||
android:name="im.angry.openeuicc.service.EuiccChannelManagerService"
|
||||
android:exported="false" />
|
||||
android:exported="false"
|
||||
android:foregroundServiceType="shortService" />
|
||||
</application>
|
||||
</manifest>
|
||||
|
|
|
|||
|
|
@ -0,0 +1,5 @@
|
|||
package im.angry.openeuicc.core
|
||||
|
||||
interface ApduInterfaceAtrProvider {
|
||||
val atr: ByteArray?
|
||||
}
|
||||
|
|
@ -1,63 +1,91 @@
|
|||
package im.angry.openeuicc.core
|
||||
|
||||
import android.content.Context
|
||||
import android.hardware.usb.UsbDevice
|
||||
import android.hardware.usb.UsbInterface
|
||||
import android.hardware.usb.UsbManager
|
||||
import android.se.omapi.SEService
|
||||
import android.util.Log
|
||||
import im.angry.openeuicc.common.R
|
||||
import im.angry.openeuicc.core.usb.UsbApduInterface
|
||||
import im.angry.openeuicc.core.usb.getIoEndpoints
|
||||
import im.angry.openeuicc.core.usb.UsbCcidContext
|
||||
import im.angry.openeuicc.util.*
|
||||
import java.lang.IllegalArgumentException
|
||||
|
||||
open class DefaultEuiccChannelFactory(protected val context: Context) : EuiccChannelFactory {
|
||||
private var seService: SEService? = null
|
||||
|
||||
private val usbManager by lazy {
|
||||
context.getSystemService(Context.USB_SERVICE) as UsbManager
|
||||
}
|
||||
|
||||
private suspend fun ensureSEService() {
|
||||
if (seService == null || !seService!!.isConnected) {
|
||||
seService = connectSEService(context)
|
||||
}
|
||||
}
|
||||
|
||||
override suspend fun tryOpenEuiccChannel(port: UiccPortInfoCompat): EuiccChannel? {
|
||||
override suspend fun tryOpenEuiccChannel(
|
||||
port: UiccPortInfoCompat,
|
||||
isdrAid: ByteArray,
|
||||
seId: EuiccChannel.SecureElementId,
|
||||
): EuiccChannel? = try {
|
||||
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}")
|
||||
try {
|
||||
return EuiccChannel(port, OmapiApduInterface(seService!!, port))
|
||||
} catch (e: IllegalArgumentException) {
|
||||
// Failed
|
||||
Log.w(
|
||||
DefaultEuiccChannelManager.TAG,
|
||||
"OMAPI APDU interface unavailable for physical slot ${port.card.physicalSlotIndex}."
|
||||
)
|
||||
}
|
||||
|
||||
return null
|
||||
Log.i(
|
||||
DefaultEuiccChannelManager.TAG,
|
||||
"Trying OMAPI for physical slot ${port.card.physicalSlotIndex}"
|
||||
)
|
||||
EuiccChannelImpl(
|
||||
context.getString(R.string.channel_type_omapi),
|
||||
port,
|
||||
OmapiApduInterface(
|
||||
seService!!,
|
||||
port,
|
||||
context.preferenceRepository.verboseLoggingFlow
|
||||
),
|
||||
isdrAid,
|
||||
seId,
|
||||
context.preferenceRepository.verboseLoggingFlow,
|
||||
context.preferenceRepository.ignoreTLSCertificateFlow,
|
||||
context.preferenceRepository.es10xMssFlow,
|
||||
)
|
||||
} catch (_: IllegalArgumentException) {
|
||||
// Failed
|
||||
Log.w(
|
||||
DefaultEuiccChannelManager.TAG,
|
||||
"OMAPI APDU interface unavailable for physical slot ${port.card.physicalSlotIndex} with ISD-R AID: ${isdrAid.encodeHex()}."
|
||||
)
|
||||
null
|
||||
}
|
||||
|
||||
override fun tryOpenUsbEuiccChannel(usbDevice: UsbDevice, usbInterface: UsbInterface): EuiccChannel? {
|
||||
val (bulkIn, bulkOut) = usbInterface.getIoEndpoints()
|
||||
if (bulkIn == null || bulkOut == null) return null
|
||||
val conn = usbManager.openDevice(usbDevice) ?: return null
|
||||
if (!conn.claimInterface(usbInterface, true)) return null
|
||||
return EuiccChannel(
|
||||
override fun tryOpenUsbEuiccChannel(
|
||||
ccidCtx: UsbCcidContext,
|
||||
isdrAid: ByteArray,
|
||||
seId: EuiccChannel.SecureElementId
|
||||
): EuiccChannel? = try {
|
||||
EuiccChannelImpl(
|
||||
context.getString(R.string.channel_type_usb),
|
||||
FakeUiccPortInfoCompat(FakeUiccCardInfoCompat(EuiccChannelManager.USB_CHANNEL_ID)),
|
||||
UsbApduInterface(conn, bulkIn, bulkOut)
|
||||
UsbApduInterface(
|
||||
ccidCtx
|
||||
),
|
||||
isdrAid,
|
||||
seId,
|
||||
context.preferenceRepository.verboseLoggingFlow,
|
||||
context.preferenceRepository.ignoreTLSCertificateFlow,
|
||||
context.preferenceRepository.es10xMssFlow,
|
||||
)
|
||||
} catch (_: IllegalArgumentException) {
|
||||
// Failed
|
||||
Log.w(
|
||||
DefaultEuiccChannelManager.TAG,
|
||||
"USB APDU interface unavailable for ISD-R AID: ${isdrAid.encodeHex()}."
|
||||
)
|
||||
null
|
||||
}
|
||||
|
||||
override fun cleanup() {
|
||||
seService?.shutdown()
|
||||
seService = null
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -5,12 +5,18 @@ import android.hardware.usb.UsbDevice
|
|||
import android.hardware.usb.UsbManager
|
||||
import android.telephony.SubscriptionManager
|
||||
import android.util.Log
|
||||
import im.angry.openeuicc.core.usb.getSmartCardInterface
|
||||
import im.angry.openeuicc.core.usb.UsbCcidContext
|
||||
import im.angry.openeuicc.core.usb.interfaces
|
||||
import im.angry.openeuicc.core.usb.smartCard
|
||||
import im.angry.openeuicc.di.AppContainer
|
||||
import im.angry.openeuicc.util.*
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.runBlocking
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.first
|
||||
import kotlinx.coroutines.flow.flow
|
||||
import kotlinx.coroutines.flow.flowOn
|
||||
import kotlinx.coroutines.flow.merge
|
||||
import kotlinx.coroutines.sync.Mutex
|
||||
import kotlinx.coroutines.sync.withLock
|
||||
import kotlinx.coroutines.withContext
|
||||
|
|
@ -26,7 +32,7 @@ open class DefaultEuiccChannelManager(
|
|||
|
||||
private val channelCache = mutableListOf<EuiccChannel>()
|
||||
|
||||
private var usbChannel: EuiccChannel? = null
|
||||
private var usbChannels = mutableListOf<EuiccChannel>()
|
||||
|
||||
private val lock = Mutex()
|
||||
|
||||
|
|
@ -45,25 +51,94 @@ open class DefaultEuiccChannelManager(
|
|||
protected open val uiccCards: Collection<UiccCardInfoCompat>
|
||||
get() = (0..<tm.activeModemCountCompat).map { FakeUiccCardInfoCompat(it) }
|
||||
|
||||
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
|
||||
private suspend inline fun tryOpenChannelWithKnownAids(
|
||||
openFn: (ByteArray, EuiccChannel.SecureElementId) -> EuiccChannel?
|
||||
): List<EuiccChannel> {
|
||||
var isdrAidList =
|
||||
parseIsdrAidList(appContainer.preferenceRepository.isdrAidListFlow.first())
|
||||
val ret = mutableListOf<EuiccChannel>()
|
||||
val openedAids = mutableListOf<ByteArray>()
|
||||
var hasReset = false
|
||||
var vendorDecider: VendorAidDecider? = null
|
||||
var seId = 0
|
||||
|
||||
outer@ while (true) {
|
||||
for (aid in isdrAidList) {
|
||||
if (vendorDecider != null && !vendorDecider.shouldOpenMore(openedAids, aid)) {
|
||||
break@outer
|
||||
}
|
||||
|
||||
val channel =
|
||||
openFn(aid, EuiccChannel.SecureElementId.createFromInt(seId))?.let { channel ->
|
||||
if (channel.valid) {
|
||||
seId += 1
|
||||
channel
|
||||
} else {
|
||||
channel.close()
|
||||
null
|
||||
}
|
||||
}
|
||||
|
||||
if (!hasReset) {
|
||||
val res = channel?.queryVendorAidListTransformation(isdrAidList)
|
||||
if (res != null) {
|
||||
// Reset the for loop since we needed to replace the AID list due to vendor-specific code
|
||||
Log.i(TAG, "AID list replaced, resetting open attempt")
|
||||
isdrAidList = res.first
|
||||
vendorDecider = res.second
|
||||
seId = 0
|
||||
ret.clear()
|
||||
openedAids.clear()
|
||||
channel.close()
|
||||
hasReset = true // Don't let anything reset again
|
||||
continue@outer
|
||||
}
|
||||
}
|
||||
|
||||
if (channel != null) {
|
||||
ret.add(channel)
|
||||
openedAids.add(aid)
|
||||
|
||||
// Don't try opening more than 1 channel unless there is a vendor
|
||||
// implementation for deciding when we should stop opening more channels
|
||||
if (vendorDecider == null) {
|
||||
break@outer
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// If we get here we should exit, since the inner loop completed without resetting
|
||||
break
|
||||
}
|
||||
|
||||
// Set the hasMultipleSE field now since we only get to know that after we have iterated all AIDs
|
||||
// This also flips a flag in EuiccChannelImpl and prevents the field from being set again
|
||||
ret.forEach { it.hasMultipleSE = (seId > 1) }
|
||||
|
||||
return ret
|
||||
}
|
||||
|
||||
private suspend fun tryOpenEuiccChannel(
|
||||
port: UiccPortInfoCompat,
|
||||
): List<EuiccChannel>? {
|
||||
lock.withLock {
|
||||
if (port.card.physicalSlotIndex == EuiccChannelManager.USB_CHANNEL_ID) {
|
||||
return usbChannels
|
||||
}
|
||||
|
||||
// First get all channels for the requested port
|
||||
val existing =
|
||||
channelCache.find { it.slotId == port.card.physicalSlotIndex && it.portId == port.portIndex }
|
||||
if (existing != null) {
|
||||
if (existing.valid && port.logicalSlotIndex == existing.logicalSlotId) {
|
||||
channelCache.filter { it.slotId == port.card.physicalSlotIndex && it.portId == port.portIndex }
|
||||
if (existing.isNotEmpty()) {
|
||||
if (existing.all { it.valid && it.logicalSlotId == port.logicalSlotIndex }) {
|
||||
return existing
|
||||
} else {
|
||||
existing.close()
|
||||
channelCache.remove(existing)
|
||||
// If any channel shouldn't be considered valid anymore, close all existing for the same slot / port
|
||||
// and reopen
|
||||
existing.forEach {
|
||||
it.close()
|
||||
channelCache.remove(it)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -72,81 +147,78 @@ open class DefaultEuiccChannelManager(
|
|||
return null
|
||||
}
|
||||
|
||||
val channel = euiccChannelFactory.tryOpenEuiccChannel(port) ?: return null
|
||||
// This function is not responsible for managing USB channels (see the initial check)
|
||||
val channels =
|
||||
tryOpenChannelWithKnownAids { isdrAid, seId ->
|
||||
euiccChannelFactory.tryOpenEuiccChannel(
|
||||
port,
|
||||
isdrAid,
|
||||
seId
|
||||
)
|
||||
}
|
||||
|
||||
if (channel.valid) {
|
||||
channelCache.add(channel)
|
||||
return channel
|
||||
if (channels.isNotEmpty()) {
|
||||
channelCache.addAll(channels)
|
||||
return channels
|
||||
} else {
|
||||
Log.i(
|
||||
TAG,
|
||||
"Was able to open channel for logical slot ${port.logicalSlotIndex}, but the channel is invalid (cannot get eID or profiles without errors). This slot might be broken, aborting."
|
||||
)
|
||||
channel.close()
|
||||
return null
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun findEuiccChannelBySlotBlocking(logicalSlotId: Int): EuiccChannel? =
|
||||
runBlocking {
|
||||
withContext(Dispatchers.IO) {
|
||||
if (logicalSlotId == EuiccChannelManager.USB_CHANNEL_ID) {
|
||||
return@withContext usbChannel
|
||||
}
|
||||
protected suspend fun findEuiccChannelByLogicalSlot(
|
||||
logicalSlotId: Int,
|
||||
seId: EuiccChannel.SecureElementId
|
||||
): EuiccChannel? =
|
||||
withContext(Dispatchers.IO) {
|
||||
if (logicalSlotId == EuiccChannelManager.USB_CHANNEL_ID) {
|
||||
return@withContext usbChannels.find { it.seId == seId }
|
||||
}
|
||||
|
||||
for (card in uiccCards) {
|
||||
for (port in card.ports) {
|
||||
if (port.logicalSlotIndex == logicalSlotId) {
|
||||
return@withContext tryOpenEuiccChannel(port)
|
||||
}
|
||||
for (card in uiccCards) {
|
||||
for (port in card.ports) {
|
||||
if (port.logicalSlotIndex == logicalSlotId) {
|
||||
return@withContext tryOpenEuiccChannel(port)?.find { it.seId == seId }
|
||||
}
|
||||
}
|
||||
|
||||
null
|
||||
}
|
||||
|
||||
null
|
||||
}
|
||||
|
||||
override fun findEuiccChannelByPhysicalSlotBlocking(physicalSlotId: Int): EuiccChannel? =
|
||||
runBlocking {
|
||||
withContext(Dispatchers.IO) {
|
||||
if (physicalSlotId == EuiccChannelManager.USB_CHANNEL_ID) {
|
||||
return@withContext usbChannel
|
||||
}
|
||||
|
||||
for (card in uiccCards) {
|
||||
if (card.physicalSlotIndex != physicalSlotId) continue
|
||||
for (port in card.ports) {
|
||||
tryOpenEuiccChannel(port)?.let { return@withContext it }
|
||||
}
|
||||
}
|
||||
|
||||
null
|
||||
}
|
||||
}
|
||||
|
||||
override suspend fun findAllEuiccChannelsByPhysicalSlot(physicalSlotId: Int): List<EuiccChannel>? {
|
||||
/**
|
||||
* Find all EuiccChannels associated with a _physical_ slot, including all secure elements
|
||||
* on cards with multiple of them.
|
||||
*/
|
||||
private suspend fun findAllEuiccChannelsByPhysicalSlot(physicalSlotId: Int): List<EuiccChannel>? {
|
||||
if (physicalSlotId == EuiccChannelManager.USB_CHANNEL_ID) {
|
||||
return usbChannel?.let { listOf(it) }
|
||||
return usbChannels.ifEmpty { null }
|
||||
}
|
||||
|
||||
for (card in uiccCards) {
|
||||
if (card.physicalSlotIndex != physicalSlotId) continue
|
||||
return card.ports.mapNotNull { tryOpenEuiccChannel(it) }
|
||||
.flatten()
|
||||
.ifEmpty { null }
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
override fun findAllEuiccChannelsByPhysicalSlotBlocking(physicalSlotId: Int): List<EuiccChannel>? =
|
||||
runBlocking {
|
||||
findAllEuiccChannelsByPhysicalSlot(physicalSlotId)
|
||||
}
|
||||
|
||||
override suspend fun findEuiccChannelByPort(physicalSlotId: Int, portId: Int): EuiccChannel? =
|
||||
/**
|
||||
* Finds all EuiccChannels associated with a physical slot + port. Note that this
|
||||
* may return multiple in case there are multiple SEs.
|
||||
*/
|
||||
private suspend fun findEuiccChannelsByPort(
|
||||
physicalSlotId: Int,
|
||||
portId: Int,
|
||||
): List<EuiccChannel>? =
|
||||
withContext(Dispatchers.IO) {
|
||||
if (physicalSlotId == EuiccChannelManager.USB_CHANNEL_ID) {
|
||||
return@withContext usbChannel
|
||||
return@withContext usbChannels.ifEmpty { null }
|
||||
}
|
||||
|
||||
uiccCards.find { it.physicalSlotIndex == physicalSlotId }?.let { card ->
|
||||
|
|
@ -154,72 +226,180 @@ open class DefaultEuiccChannelManager(
|
|||
}
|
||||
}
|
||||
|
||||
override fun findEuiccChannelByPortBlocking(physicalSlotId: Int, portId: Int): EuiccChannel? =
|
||||
runBlocking {
|
||||
findEuiccChannelByPort(physicalSlotId, portId)
|
||||
override suspend fun findFirstAvailablePort(physicalSlotId: Int): Int =
|
||||
withContext(Dispatchers.IO) {
|
||||
if (physicalSlotId == EuiccChannelManager.USB_CHANNEL_ID) {
|
||||
return@withContext 0
|
||||
}
|
||||
|
||||
findAllEuiccChannelsByPhysicalSlot(physicalSlotId)?.getOrNull(0)?.portId ?: -1
|
||||
}
|
||||
|
||||
override suspend fun findAvailablePorts(physicalSlotId: Int): List<Int> =
|
||||
withContext(Dispatchers.IO) {
|
||||
if (physicalSlotId == EuiccChannelManager.USB_CHANNEL_ID) {
|
||||
return@withContext listOf(0)
|
||||
}
|
||||
|
||||
findAllEuiccChannelsByPhysicalSlot(physicalSlotId)?.map { it.portId }?.toSet()?.toList()
|
||||
?: listOf()
|
||||
}
|
||||
|
||||
override suspend fun <R> withEuiccChannel(
|
||||
physicalSlotId: Int,
|
||||
portId: Int,
|
||||
seId: EuiccChannel.SecureElementId,
|
||||
fn: suspend (EuiccChannel) -> R
|
||||
): R {
|
||||
val channel = findEuiccChannelsByPort(physicalSlotId, portId)?.find { it.seId == seId }
|
||||
?: throw EuiccChannelManager.EuiccChannelNotFoundException()
|
||||
val wrapper = EuiccChannelWrapper(channel)
|
||||
try {
|
||||
return withContext(Dispatchers.IO) {
|
||||
fn(wrapper)
|
||||
}
|
||||
} finally {
|
||||
wrapper.invalidateWrapper()
|
||||
}
|
||||
}
|
||||
|
||||
override suspend fun <R> withEuiccChannel(
|
||||
logicalSlotId: Int,
|
||||
seId: EuiccChannel.SecureElementId,
|
||||
fn: suspend (EuiccChannel) -> R
|
||||
): R {
|
||||
val channel = findEuiccChannelByLogicalSlot(logicalSlotId, seId)
|
||||
?: throw EuiccChannelManager.EuiccChannelNotFoundException()
|
||||
val wrapper = EuiccChannelWrapper(channel)
|
||||
try {
|
||||
return withContext(Dispatchers.IO) {
|
||||
fn(wrapper)
|
||||
}
|
||||
} finally {
|
||||
wrapper.invalidateWrapper()
|
||||
}
|
||||
}
|
||||
|
||||
override suspend fun waitForReconnect(physicalSlotId: Int, portId: Int, timeoutMillis: Long) {
|
||||
if (physicalSlotId == EuiccChannelManager.USB_CHANNEL_ID) return
|
||||
|
||||
// If 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()
|
||||
val numChannelsBefore = if (physicalSlotId == EuiccChannelManager.USB_CHANNEL_ID) {
|
||||
usbChannels.size
|
||||
} else {
|
||||
// Don't use find* methods since they reopen channels if not found
|
||||
channelCache.filter { it.slotId == physicalSlotId && it.portId == portId }.size
|
||||
}
|
||||
|
||||
val resetChannels = {
|
||||
if (physicalSlotId == EuiccChannelManager.USB_CHANNEL_ID) {
|
||||
usbChannels.forEach { it.close() }
|
||||
usbChannels.clear()
|
||||
} else {
|
||||
// If there is already a valid channel, we close it proactively
|
||||
channelCache.filter { it.slotId == physicalSlotId && it.portId == portId }.forEach { it.close() }
|
||||
}
|
||||
}
|
||||
|
||||
resetChannels()
|
||||
|
||||
withTimeout(timeoutMillis) {
|
||||
while (true) {
|
||||
try {
|
||||
// tryOpenEuiccChannel() will automatically dispose of invalid channels
|
||||
// and recreate when needed
|
||||
val channel = findEuiccChannelByPortBlocking(physicalSlotId, portId)!!
|
||||
check(channel.valid) { "Invalid channel" }
|
||||
val channels = if (physicalSlotId == EuiccChannelManager.USB_CHANNEL_ID) {
|
||||
// tryOpenUsbEuiccChannel() will always try to reopen the channel, even if
|
||||
// a USB channel already exists
|
||||
tryOpenUsbEuiccChannel()
|
||||
usbChannels
|
||||
} else {
|
||||
// tryOpenEuiccChannel() will automatically dispose of invalid channels
|
||||
// and recreate when needed
|
||||
findEuiccChannelsByPort(physicalSlotId, portId)!!
|
||||
}
|
||||
check(channels.isNotEmpty()) { "No channel" }
|
||||
check(channels.all { it.valid }) { "Invalid channel" }
|
||||
check(numChannelsBefore > 0 && channels.size >= numChannelsBefore) { "Less channels than before" }
|
||||
break
|
||||
} catch (e: Exception) {
|
||||
Log.d(TAG, "Slot $physicalSlotId port $portId reconnect failure, retrying in 1000 ms")
|
||||
Log.d(
|
||||
TAG,
|
||||
"Slot $physicalSlotId port $portId reconnect failure, retrying in 1000 ms"
|
||||
)
|
||||
resetChannels()
|
||||
}
|
||||
delay(1000)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override suspend fun enumerateEuiccChannels(): List<EuiccChannel> =
|
||||
withContext(Dispatchers.IO) {
|
||||
uiccCards.flatMap { info ->
|
||||
info.ports.mapNotNull { port ->
|
||||
tryOpenEuiccChannel(port)?.also {
|
||||
Log.d(
|
||||
TAG,
|
||||
"Found eUICC on slot ${info.physicalSlotIndex} port ${port.portIndex}"
|
||||
)
|
||||
}
|
||||
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 suspend fun enumerateUsbEuiccChannel(): Pair<UsbDevice?, EuiccChannel?> =
|
||||
override fun flowAllOpenEuiccPorts(): Flow<Pair<Int, Int>> =
|
||||
merge(flowInternalEuiccPorts(), flow {
|
||||
if (tryOpenUsbEuiccChannel().second) {
|
||||
emit(Pair(EuiccChannelManager.USB_CHANNEL_ID, 0))
|
||||
}
|
||||
})
|
||||
|
||||
override fun flowEuiccSecureElements(
|
||||
slotId: Int,
|
||||
portId: Int
|
||||
): Flow<EuiccChannel.SecureElementId> = flow {
|
||||
findEuiccChannelsByPort(slotId, portId)?.forEach {
|
||||
emit(it.seId)
|
||||
}
|
||||
}
|
||||
|
||||
override suspend fun tryOpenUsbEuiccChannel(): Pair<UsbDevice?, Boolean> =
|
||||
withContext(Dispatchers.IO) {
|
||||
usbManager.deviceList.values.forEach { device ->
|
||||
Log.i(TAG, "Scanning USB device ${device.deviceId}:${device.vendorId}")
|
||||
val iface = device.getSmartCardInterface() ?: return@forEach
|
||||
val iface = device.interfaces.smartCard ?: return@forEach
|
||||
// If we don't have permission, tell UI code that we found a candidate device, but we
|
||||
// need permission to be able to do anything with it
|
||||
if (!usbManager.hasPermission(device)) return@withContext Pair(device, null)
|
||||
Log.i(TAG, "Found CCID interface on ${device.deviceId}:${device.vendorId}, and has permission; trying to open channel")
|
||||
if (!usbManager.hasPermission(device)) return@withContext Pair(device, false)
|
||||
Log.i(
|
||||
TAG,
|
||||
"Found CCID interface on ${device.deviceId}:${device.vendorId}, and has permission; trying to open channel"
|
||||
)
|
||||
|
||||
val ccidCtx =
|
||||
UsbCcidContext.createFromUsbDevice(context, device, iface) ?: return@forEach
|
||||
|
||||
try {
|
||||
val channel = euiccChannelFactory.tryOpenUsbEuiccChannel(device, iface)
|
||||
if (channel != null && channel.lpa.valid) {
|
||||
usbChannel = channel
|
||||
return@withContext Pair(device, channel)
|
||||
val channels = tryOpenChannelWithKnownAids { isdrAid, seId ->
|
||||
euiccChannelFactory.tryOpenUsbEuiccChannel(ccidCtx, isdrAid, seId)
|
||||
}
|
||||
if (channels.isNotEmpty() && channels[0].valid) {
|
||||
ccidCtx.allowDisconnect = true
|
||||
usbChannels.clear()
|
||||
usbChannels.addAll(channels)
|
||||
return@withContext Pair(device, true)
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
// Ignored -- skip forward
|
||||
e.printStackTrace()
|
||||
}
|
||||
Log.i(TAG, "No valid eUICC channel found on USB device ${device.deviceId}:${device.vendorId}")
|
||||
|
||||
ccidCtx.allowDisconnect = true
|
||||
ccidCtx.disconnect()
|
||||
|
||||
Log.i(
|
||||
TAG,
|
||||
"No valid eUICC channel found on USB device ${device.deviceId}:${device.vendorId}"
|
||||
)
|
||||
}
|
||||
return@withContext Pair(null, null)
|
||||
return@withContext Pair(null, false)
|
||||
}
|
||||
|
||||
override fun invalidate() {
|
||||
|
|
@ -227,9 +407,9 @@ open class DefaultEuiccChannelManager(
|
|||
channel.close()
|
||||
}
|
||||
|
||||
usbChannel?.close()
|
||||
usbChannel = null
|
||||
usbChannels.forEach { it.close() }
|
||||
usbChannels.clear()
|
||||
channelCache.clear()
|
||||
euiccChannelFactory.cleanup()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,23 +1,99 @@
|
|||
package im.angry.openeuicc.core
|
||||
|
||||
import android.os.Parcel
|
||||
import android.os.Parcelable
|
||||
import im.angry.openeuicc.util.*
|
||||
import net.typeblog.lpac_jni.ApduInterface
|
||||
import net.typeblog.lpac_jni.LocalProfileAssistant
|
||||
import net.typeblog.lpac_jni.impl.HttpInterfaceImpl
|
||||
import net.typeblog.lpac_jni.impl.LocalProfileAssistantImpl
|
||||
|
||||
class EuiccChannel(
|
||||
val port: UiccPortInfoCompat,
|
||||
apduInterface: ApduInterface,
|
||||
) {
|
||||
val slotId = port.card.physicalSlotIndex // PHYSICAL slot
|
||||
val logicalSlotId = port.logicalSlotIndex
|
||||
val portId = port.portIndex
|
||||
interface EuiccChannel {
|
||||
val type: String
|
||||
|
||||
val lpa: LocalProfileAssistant = LocalProfileAssistantImpl(apduInterface, HttpInterfaceImpl())
|
||||
val port: UiccPortInfoCompat
|
||||
|
||||
val slotId: Int // PHYSICAL slot
|
||||
val logicalSlotId: Int
|
||||
val portId: Int
|
||||
|
||||
/**
|
||||
* A semi-obscure wrapper over the integer ID of a secure element on a card.
|
||||
*
|
||||
* Because the ID is arbitrary, this is intended to discourage the use of the
|
||||
* integer value directly. Additionally, it prevents accidentally calling the
|
||||
* wrong function in EuiccChannelManager with a ton of integer parameters.
|
||||
*/
|
||||
class SecureElementId private constructor(val id: Int) : Parcelable {
|
||||
companion object {
|
||||
val DEFAULT = SecureElementId(0)
|
||||
|
||||
/**
|
||||
* Create a SecureElementId from an integer ID. You should not call this directly
|
||||
* unless you know what you're doing.
|
||||
*
|
||||
* This is currently only ever used in the download flow.
|
||||
*/
|
||||
fun createFromInt(id: Int): SecureElementId =
|
||||
SecureElementId(id)
|
||||
|
||||
@Suppress("unused")
|
||||
@JvmField
|
||||
val CREATOR = object : Parcelable.Creator<SecureElementId> {
|
||||
override fun createFromParcel(parcel: Parcel): SecureElementId =
|
||||
createFromInt(parcel.readInt())
|
||||
|
||||
override fun newArray(size: Int): Array<SecureElementId?> = arrayOfNulls(size)
|
||||
}
|
||||
}
|
||||
|
||||
override fun hashCode(): Int =
|
||||
id.hashCode()
|
||||
|
||||
override fun equals(other: Any?): Boolean =
|
||||
if (other is SecureElementId) {
|
||||
this.id == other.id
|
||||
} else {
|
||||
super.equals(other)
|
||||
}
|
||||
|
||||
override fun describeContents(): Int = id
|
||||
|
||||
override fun writeToParcel(parcel: Parcel, flags: Int) {
|
||||
parcel.writeInt(id)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Some chips support multiple SEs on one chip. The seId here is intended
|
||||
* to distinguish channels opened from these different SEs.
|
||||
*/
|
||||
val seId: SecureElementId
|
||||
|
||||
/**
|
||||
* Does this channel belong to a chip that supports multiple SEs?
|
||||
* Note that this is only made `var` to make initialization a bit less annoying --
|
||||
* this should never be set again after the channel is originally opened.
|
||||
* Attempting to do so will yield an exception.
|
||||
*/
|
||||
var hasMultipleSE: Boolean
|
||||
|
||||
val lpa: LocalProfileAssistant
|
||||
|
||||
val valid: Boolean
|
||||
get() = lpa.valid
|
||||
|
||||
fun close() = lpa.close()
|
||||
/**
|
||||
* Answer to Reset (ATR) value of the underlying interface, if any
|
||||
*/
|
||||
val atr: ByteArray?
|
||||
|
||||
/**
|
||||
* The underlying APDU interface for this channel
|
||||
*/
|
||||
val apduInterface: ApduInterface
|
||||
|
||||
/**
|
||||
* The AID of the ISD-R channel currently in use
|
||||
*/
|
||||
val isdrAid: ByteArray
|
||||
|
||||
fun close()
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,15 +1,22 @@
|
|||
package im.angry.openeuicc.core
|
||||
|
||||
import android.hardware.usb.UsbDevice
|
||||
import android.hardware.usb.UsbInterface
|
||||
import im.angry.openeuicc.core.usb.UsbCcidContext
|
||||
import im.angry.openeuicc.util.*
|
||||
|
||||
// This class is here instead of inside DI because it contains a bit more logic than just
|
||||
// "dumb" dependency injection.
|
||||
interface EuiccChannelFactory {
|
||||
suspend fun tryOpenEuiccChannel(port: UiccPortInfoCompat): EuiccChannel?
|
||||
suspend fun tryOpenEuiccChannel(
|
||||
port: UiccPortInfoCompat,
|
||||
isdrAid: ByteArray,
|
||||
seId: EuiccChannel.SecureElementId
|
||||
): EuiccChannel?
|
||||
|
||||
fun tryOpenUsbEuiccChannel(usbDevice: UsbDevice, usbInterface: UsbInterface): EuiccChannel?
|
||||
fun tryOpenUsbEuiccChannel(
|
||||
ccidCtx: UsbCcidContext,
|
||||
isdrAid: ByteArray,
|
||||
seId: EuiccChannel.SecureElementId
|
||||
): EuiccChannel?
|
||||
|
||||
/**
|
||||
* Release all resources used by this EuiccChannelFactory
|
||||
|
|
@ -17,4 +24,4 @@ interface EuiccChannelFactory {
|
|||
* re-acquired when this happens
|
||||
*/
|
||||
fun cleanup()
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,53 @@
|
|||
package im.angry.openeuicc.core
|
||||
|
||||
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
|
||||
|
||||
class EuiccChannelImpl(
|
||||
override val type: String,
|
||||
override val port: UiccPortInfoCompat,
|
||||
override val apduInterface: ApduInterface,
|
||||
override val isdrAid: ByteArray,
|
||||
override val seId: EuiccChannel.SecureElementId,
|
||||
verboseLoggingFlow: Flow<Boolean>,
|
||||
ignoreTLSCertificateFlow: Flow<Boolean>,
|
||||
es10xMssFlow: Flow<Int>,
|
||||
) : EuiccChannel {
|
||||
override val slotId = port.card.physicalSlotIndex
|
||||
override val logicalSlotId = port.logicalSlotIndex
|
||||
override val portId = port.portIndex
|
||||
|
||||
override val lpa: LocalProfileAssistant =
|
||||
LocalProfileAssistantImpl(
|
||||
isdrAid,
|
||||
apduInterface,
|
||||
HttpInterfaceImpl(verboseLoggingFlow, ignoreTLSCertificateFlow),
|
||||
).also {
|
||||
it.setEs10xMss(runBlocking { es10xMssFlow.first().toByte() })
|
||||
}
|
||||
|
||||
override val atr: ByteArray?
|
||||
get() = (apduInterface as? ApduInterfaceAtrProvider)?.atr
|
||||
|
||||
override val valid: Boolean
|
||||
get() = lpa.valid
|
||||
|
||||
private var hasMultipleSEInitialized = false
|
||||
|
||||
override var hasMultipleSE: Boolean = false
|
||||
set(value) {
|
||||
if (hasMultipleSEInitialized) {
|
||||
throw IllegalStateException("already initialized")
|
||||
}
|
||||
|
||||
field = value
|
||||
}
|
||||
|
||||
override fun close() = lpa.close()
|
||||
}
|
||||
|
|
@ -1,6 +1,7 @@
|
|||
package im.angry.openeuicc.core
|
||||
|
||||
import android.hardware.usb.UsbDevice
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
|
||||
/**
|
||||
* EuiccChannelManager holds references to, and manages the lifecycles of, individual
|
||||
|
|
@ -9,7 +10,7 @@ import android.hardware.usb.UsbDevice
|
|||
* 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.
|
||||
* all other components must access EuiccChannelManager objects through EuiccChannelManagerService.
|
||||
* Holding references independent of EuiccChannelManagerService is unsupported.
|
||||
*/
|
||||
interface EuiccChannelManager {
|
||||
|
|
@ -18,19 +19,43 @@ interface EuiccChannelManager {
|
|||
}
|
||||
|
||||
/**
|
||||
* Scan all possible _device internal_ sources for EuiccChannels, return them and have all
|
||||
* scanned channels cached; these channels will remain open for the entire lifetime of
|
||||
* this EuiccChannelManager object, unless disconnected externally or invalidate()'d
|
||||
* Scan all possible _device internal_ sources for EuiccChannels, as a flow, return their physical
|
||||
* (slotId, portId) and have all scanned channels cached; these channels will remain open
|
||||
* for the entire lifetime of this EuiccChannelManager object, unless disconnected externally
|
||||
* or invalidate()'d.
|
||||
*
|
||||
* To obtain a temporary reference to a EuiccChannel, use `withEuiccChannel()`.
|
||||
*/
|
||||
suspend fun enumerateEuiccChannels(): List<EuiccChannel>
|
||||
fun flowInternalEuiccPorts(): Flow<Pair<Int, Int>>
|
||||
|
||||
/**
|
||||
* Same as flowInternalEuiccPorts(), except that this includes non-device internal eUICC chips
|
||||
* as well. Namely, this includes the USB reader.
|
||||
*
|
||||
* Non-internal readers will only be included if they have been opened properly, i.e. with permissions
|
||||
* granted by the user.
|
||||
*/
|
||||
fun flowAllOpenEuiccPorts(): Flow<Pair<Int, Int>>
|
||||
|
||||
/**
|
||||
* Iterate over all the Secure Elements available on one eUICC.
|
||||
*
|
||||
* This is going to almost always return only 1 result, except in the case where
|
||||
* a card has multiple SEs.
|
||||
*/
|
||||
fun flowEuiccSecureElements(slotId: Int, portId: Int): Flow<EuiccChannel.SecureElementId>
|
||||
|
||||
/**
|
||||
* Scan all possible USB devices for CCID readers that may contain eUICC cards.
|
||||
* If found, try to open it for access, and add it to the internal EuiccChannel cache
|
||||
* as a "port" with id 99. When user interaction is required to obtain permission
|
||||
* to interact with the device, the second return value (EuiccChannel) will be null.
|
||||
* to interact with the device, the second return value will be false.
|
||||
*
|
||||
* Returns (usbDevice, canOpen). canOpen is false if either (1) no usb reader is found;
|
||||
* or (2) usb reader is found, but user interaction is required for access;
|
||||
* or (3) usb reader is found, but we are unable to open ISD-R.
|
||||
*/
|
||||
suspend fun enumerateUsbEuiccChannel(): Pair<UsbDevice?, EuiccChannel?>
|
||||
suspend fun tryOpenUsbEuiccChannel(): Pair<UsbDevice?, Boolean>
|
||||
|
||||
/**
|
||||
* Wait for a slot + port to reconnect (i.e. become valid again)
|
||||
|
|
@ -40,29 +65,33 @@ interface EuiccChannelManager {
|
|||
suspend fun waitForReconnect(physicalSlotId: Int, portId: Int, timeoutMillis: Long = 1000)
|
||||
|
||||
/**
|
||||
* Returns the EuiccChannel corresponding to a **logical** slot
|
||||
* Returns the first mapped & available port ID for a physical slot, or -1 if
|
||||
* not found.
|
||||
*/
|
||||
fun findEuiccChannelBySlotBlocking(logicalSlotId: Int): EuiccChannel?
|
||||
suspend fun findFirstAvailablePort(physicalSlotId: Int): Int
|
||||
|
||||
/**
|
||||
* Returns the first EuiccChannel corresponding to a **physical** slot
|
||||
* If the physical slot supports MEP and has multiple ports, it is undefined
|
||||
* which of the two channels will be returned.
|
||||
* Returns all mapped & available port IDs for a physical slot.
|
||||
*/
|
||||
fun findEuiccChannelByPhysicalSlotBlocking(physicalSlotId: Int): EuiccChannel?
|
||||
suspend fun findAvailablePorts(physicalSlotId: Int): List<Int>
|
||||
|
||||
class EuiccChannelNotFoundException : Exception("EuiccChannel not found")
|
||||
|
||||
/**
|
||||
* Returns all EuiccChannels corresponding to a **physical** slot
|
||||
* Multiple channels are possible in the case of MEP
|
||||
* Find a EuiccChannel by its slot and port, then run a callback with a reference to it.
|
||||
* The reference is not supposed to be held outside of the callback. This is enforced via
|
||||
* a wrapper object.
|
||||
*
|
||||
* The callback is run on Dispatchers.IO by default.
|
||||
*
|
||||
* If a channel for that slot / port is not found, EuiccChannelNotFoundException is thrown
|
||||
*/
|
||||
suspend fun findAllEuiccChannelsByPhysicalSlot(physicalSlotId: Int): List<EuiccChannel>?
|
||||
fun findAllEuiccChannelsByPhysicalSlotBlocking(physicalSlotId: Int): List<EuiccChannel>?
|
||||
suspend fun <R> withEuiccChannel(physicalSlotId: Int, portId: Int, seId: EuiccChannel.SecureElementId, fn: suspend (EuiccChannel) -> R): R
|
||||
|
||||
/**
|
||||
* Returns the EuiccChannel corresponding to a **physical** slot and a port ID
|
||||
* Same as withEuiccChannel(Int, Int, SecureElementId, (EuiccChannel) -> R) but instead uses logical slot ID
|
||||
*/
|
||||
suspend fun findEuiccChannelByPort(physicalSlotId: Int, portId: Int): EuiccChannel?
|
||||
fun findEuiccChannelByPortBlocking(physicalSlotId: Int, portId: Int): EuiccChannel?
|
||||
suspend fun <R> withEuiccChannel(logicalSlotId: Int, seId: EuiccChannel.SecureElementId, fn: suspend (EuiccChannel) -> R): R
|
||||
|
||||
/**
|
||||
* Invalidate all EuiccChannels previously cached by this Manager
|
||||
|
|
@ -74,7 +103,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,58 @@
|
|||
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
|
||||
override val seId: EuiccChannel.SecureElementId
|
||||
get() = channel.seId
|
||||
private val lpaDelegate = lazy {
|
||||
LocalProfileAssistantWrapper(channel.lpa)
|
||||
}
|
||||
override val lpa: LocalProfileAssistant by lpaDelegate
|
||||
override val valid: Boolean
|
||||
get() = channel.valid
|
||||
override val apduInterface: ApduInterface
|
||||
get() = channel.apduInterface
|
||||
override val atr: ByteArray?
|
||||
get() = channel.atr
|
||||
override val isdrAid: ByteArray
|
||||
get() = channel.isdrAid
|
||||
override var hasMultipleSE: Boolean
|
||||
get() = channel.hasMultipleSE
|
||||
set(value) {
|
||||
channel.hasMultipleSE = value
|
||||
}
|
||||
|
||||
override fun close() = channel.close()
|
||||
|
||||
fun invalidateWrapper() {
|
||||
_inner = null
|
||||
|
||||
if (lpaDelegate.isInitialized()) {
|
||||
(lpa as LocalProfileAssistantWrapper).invalidateWrapper()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,62 @@
|
|||
package im.angry.openeuicc.core
|
||||
|
||||
import net.typeblog.lpac_jni.ProfileDownloadInput
|
||||
import net.typeblog.lpac_jni.EuiccInfo2
|
||||
import net.typeblog.lpac_jni.LocalProfileAssistant
|
||||
import net.typeblog.lpac_jni.LocalProfileInfo
|
||||
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(input: ProfileDownloadInput, callback: ProfileDownloadCallback) =
|
||||
lpa.downloadProfile(input, callback)
|
||||
|
||||
override fun deleteNotification(seqNumber: Long): Boolean = lpa.deleteNotification(seqNumber)
|
||||
|
||||
override fun handleNotification(seqNumber: Long): Boolean = lpa.handleNotification(seqNumber)
|
||||
|
||||
override fun euiccMemoryReset() = lpa.euiccMemoryReset()
|
||||
|
||||
override fun setNickname(iccid: String, nickname: String) {
|
||||
lpa.setNickname(iccid, nickname)
|
||||
}
|
||||
|
||||
override fun close() = lpa.close()
|
||||
|
||||
fun invalidateWrapper() {
|
||||
_inner = null
|
||||
}
|
||||
}
|
||||
|
|
@ -3,19 +3,33 @@ package im.angry.openeuicc.core
|
|||
import android.se.omapi.Channel
|
||||
import android.se.omapi.SEService
|
||||
import android.se.omapi.Session
|
||||
import android.util.Log
|
||||
import im.angry.openeuicc.util.*
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.first
|
||||
import kotlinx.coroutines.runBlocking
|
||||
import net.typeblog.lpac_jni.ApduInterface
|
||||
import java.util.concurrent.atomic.AtomicInteger
|
||||
|
||||
class OmapiApduInterface(
|
||||
private val service: SEService,
|
||||
private val port: UiccPortInfoCompat
|
||||
): ApduInterface {
|
||||
private val port: UiccPortInfoCompat,
|
||||
private val verboseLoggingFlow: Flow<Boolean>
|
||||
) : ApduInterface, ApduInterfaceAtrProvider {
|
||||
companion object {
|
||||
const val TAG = "OmapiApduInterface"
|
||||
}
|
||||
|
||||
private lateinit var session: Session
|
||||
private lateinit var lastChannel: Channel
|
||||
private val index = AtomicInteger(0)
|
||||
private val channels = mutableMapOf<Int, Channel>()
|
||||
|
||||
override val valid: Boolean
|
||||
get() = service.isConnected && (this::session.isInitialized && !session.isClosed)
|
||||
|
||||
override val atr: ByteArray?
|
||||
get() = session.atr
|
||||
|
||||
override fun connect() {
|
||||
session = service.getUiccReaderCompat(port.logicalSlotIndex + 1).openSession()
|
||||
}
|
||||
|
|
@ -25,26 +39,48 @@ class OmapiApduInterface(
|
|||
}
|
||||
|
||||
override fun logicalChannelOpen(aid: ByteArray): Int {
|
||||
check(!this::lastChannel.isInitialized) {
|
||||
"Can only open one channel"
|
||||
}
|
||||
lastChannel = session.openLogicalChannel(aid)!!;
|
||||
return 1;
|
||||
val channel = session.openLogicalChannel(aid)
|
||||
check(channel != null) { "Failed to open logical channel (${aid.encodeHex()})" }
|
||||
val handle = index.incrementAndGet()
|
||||
synchronized(channels) { channels[handle] = channel }
|
||||
return handle
|
||||
}
|
||||
|
||||
override fun logicalChannelClose(handle: Int) {
|
||||
check(handle == 1 && !this::lastChannel.isInitialized) {
|
||||
"Unknown channel"
|
||||
}
|
||||
lastChannel.close()
|
||||
val channel = channels[handle]
|
||||
check(channel != null) { "Invalid logical channel handle $handle" }
|
||||
if (channel.isOpen) channel.close()
|
||||
synchronized(channels) { channels.remove(handle) }
|
||||
}
|
||||
|
||||
override fun transmit(tx: ByteArray): ByteArray {
|
||||
check(this::lastChannel.isInitialized) {
|
||||
"Unknown channel"
|
||||
override fun transmit(handle: Int, tx: ByteArray): ByteArray {
|
||||
val channel = channels[handle]
|
||||
check(channel != null) { "Invalid logical channel handle $handle" }
|
||||
|
||||
if (runBlocking { verboseLoggingFlow.first() }) {
|
||||
Log.d(TAG, "OMAPI APDU: ${tx.encodeHex()}")
|
||||
}
|
||||
|
||||
return lastChannel.transmit(tx)
|
||||
}
|
||||
try {
|
||||
for (i in 0..10) {
|
||||
val res = channel.transmit(tx)
|
||||
if (runBlocking { verboseLoggingFlow.first() }) {
|
||||
Log.d(TAG, "OMAPI APDU response: ${res.encodeHex()}")
|
||||
}
|
||||
|
||||
}
|
||||
if (res.size == 2 && res[0] == 0x66.toByte() && res[1] == 0x01.toByte()) {
|
||||
Log.d(TAG, "Received checksum error 0x6601, retrying (count = $i)")
|
||||
continue
|
||||
}
|
||||
|
||||
return res
|
||||
}
|
||||
|
||||
throw RuntimeException("Retransmit attempts exhausted; this was likely caused by checksum errors")
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "OMAPI APDU exception")
|
||||
e.printStackTrace()
|
||||
throw e
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,49 +1,103 @@
|
|||
package im.angry.openeuicc.core.usb
|
||||
|
||||
import android.hardware.usb.UsbDeviceConnection
|
||||
import android.hardware.usb.UsbEndpoint
|
||||
import android.util.Log
|
||||
import im.angry.openeuicc.util.*
|
||||
import im.angry.openeuicc.core.ApduInterfaceAtrProvider
|
||||
import im.angry.openeuicc.util.decodeHex
|
||||
import im.angry.openeuicc.util.encodeHex
|
||||
import net.typeblog.lpac_jni.ApduInterface
|
||||
|
||||
class UsbApduInterface(
|
||||
private val conn: UsbDeviceConnection,
|
||||
private val bulkIn: UsbEndpoint,
|
||||
private val bulkOut: UsbEndpoint
|
||||
): ApduInterface {
|
||||
private val ccidCtx: UsbCcidContext
|
||||
) : ApduInterface, ApduInterfaceAtrProvider {
|
||||
companion object {
|
||||
private const val TAG = "UsbApduInterface"
|
||||
}
|
||||
|
||||
private lateinit var ccidDescription: UsbCcidDescription
|
||||
private lateinit var transceiver: UsbCcidTransceiver
|
||||
override val atr: ByteArray?
|
||||
get() = ccidCtx.atr
|
||||
|
||||
private var channelId = -1
|
||||
override val valid: Boolean
|
||||
get() = channels.isNotEmpty()
|
||||
|
||||
private var channels = mutableSetOf<Int>()
|
||||
|
||||
// ATR parser
|
||||
// Specs: ISO/IEC 7816-3:2006 8.2 Answer-to-Reset
|
||||
// See also: https://en.wikipedia.org/wiki/Answer_to_reset
|
||||
class ParsedAtr private constructor(val ts: Byte?, val t0: Byte?, val ta1: Byte?, val tb1: Byte?, val tc1: Byte?, val td1: Byte?, val ta2: Byte?, val tb2: Byte?, val tc2: Byte?, val td2: Byte?) {
|
||||
companion object {
|
||||
fun parse(atr: ByteArray): ParsedAtr {
|
||||
val ts = atr[0]
|
||||
val t0 = atr[1]
|
||||
val tx1 = arrayOf<Byte?>(null, null, null, null)
|
||||
val tx2 = arrayOf<Byte?>(null, null, null, null)
|
||||
var pointer = 2
|
||||
|
||||
for (i in 0..3) {
|
||||
if (t0.toInt() and (0x10 shl i) != 0) {
|
||||
tx1[i] = atr[pointer]
|
||||
pointer++
|
||||
}
|
||||
}
|
||||
|
||||
val td1 = tx1[3] ?: 0
|
||||
|
||||
for (i in 0..3) {
|
||||
if (td1.toInt() and (0x10 shl i) != 0) {
|
||||
tx2[i] = atr[pointer]
|
||||
pointer++
|
||||
}
|
||||
}
|
||||
|
||||
return ParsedAtr(ts=ts, t0=t0, ta1=tx1[0], tb1=tx1[1], tc1=tx1[2], td1=tx1[3],
|
||||
ta2=tx2[0], tb2=tx2[1], tc2=tx2[2], td2=tx2[3],
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun connect() {
|
||||
ccidDescription = UsbCcidDescription.fromRawDescriptors(conn.rawDescriptors)!!
|
||||
ccidCtx.connect()
|
||||
|
||||
if (!ccidDescription.hasT0Protocol) {
|
||||
throw IllegalArgumentException("Unsupported card reader; T=0 support is required")
|
||||
if (ccidCtx.transceiver.isTpdu) {
|
||||
// Send parameter selection
|
||||
// Specs: USB-CCID 3.2.1 TPDU level of exchange
|
||||
val parsedAtr = ParsedAtr.parse(atr!!)
|
||||
val ta1 = parsedAtr.ta1 ?: 0x11.toByte()
|
||||
val pts1 = ta1 // TODO: Check that reader supports baud rate proposed by the card
|
||||
val pps = byteArrayOf(0xff.toByte(), 0x10.toByte(), pts1, 0x00.toByte())
|
||||
Log.d(TAG, "PTS1=${pts1} PPS: ${pps.encodeHex()}")
|
||||
ccidCtx.transceiver.sendXfrBlock(pps)
|
||||
|
||||
// Send Set Parameters
|
||||
// Specs: USB-CCID 6.1.7 PC_to_RDR_SetParameters
|
||||
|
||||
val param = byteArrayOf(
|
||||
pts1,
|
||||
(if (parsedAtr.ts == 0x3F.toByte()) 0x02 else 0x00),
|
||||
parsedAtr.tc1 ?: 0,
|
||||
parsedAtr.tc2 ?: 0x0A,
|
||||
0x00
|
||||
)
|
||||
|
||||
Log.d(TAG, "Param: ${param.encodeHex()}")
|
||||
|
||||
ccidCtx.transceiver.sendParamBlock(param)
|
||||
}
|
||||
|
||||
transceiver = UsbCcidTransceiver(conn, bulkIn, bulkOut, ccidDescription)
|
||||
|
||||
try {
|
||||
transceiver.iccPowerOn()
|
||||
} catch (e: Exception) {
|
||||
e.printStackTrace()
|
||||
throw e
|
||||
}
|
||||
// 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() {
|
||||
conn.close()
|
||||
}
|
||||
override fun disconnect() = ccidCtx.disconnect()
|
||||
|
||||
override fun logicalChannelOpen(aid: ByteArray): Int {
|
||||
check(channelId == -1) { "Logical channel already opened" }
|
||||
|
||||
// OPEN LOGICAL CHANNEL
|
||||
val req = manageChannelCmd(true, 0)
|
||||
|
||||
|
|
@ -59,8 +113,9 @@ class UsbApduInterface(
|
|||
return -1
|
||||
}
|
||||
|
||||
channelId = resp[0].toInt()
|
||||
val channelId = resp[0].toInt()
|
||||
Log.d(TAG, "channelId = $channelId")
|
||||
channels.add(channelId)
|
||||
|
||||
// Then, select AID
|
||||
val selectAid = selectByDfCmd(aid, channelId.toByte())
|
||||
|
|
@ -68,6 +123,8 @@ class UsbApduInterface(
|
|||
|
||||
if (!isSuccessResponse(selectAidResp)) {
|
||||
Log.d(TAG, "Select DF failed : ${selectAidResp.encodeHex()}")
|
||||
logicalChannelClose(channelId)
|
||||
Log.d(TAG, "Closed logical channel $channelId due to select DF failure")
|
||||
return -1
|
||||
}
|
||||
|
||||
|
|
@ -75,28 +132,26 @@ class UsbApduInterface(
|
|||
}
|
||||
|
||||
override fun logicalChannelClose(handle: Int) {
|
||||
check(handle == channelId) { "Logical channel ID mismatch" }
|
||||
check(channelId != -1) { "Logical channel is not opened" }
|
||||
|
||||
check(channels.contains(handle)) {
|
||||
"Invalid logical channel handle $handle"
|
||||
}
|
||||
// CLOSE LOGICAL CHANNEL
|
||||
val req = manageChannelCmd(false, channelId.toByte())
|
||||
val resp = transmitApduByChannel(req, channelId.toByte())
|
||||
val req = manageChannelCmd(false, handle.toByte())
|
||||
val resp = transmitApduByChannel(req, handle.toByte())
|
||||
|
||||
if (!isSuccessResponse(resp)) {
|
||||
Log.d(TAG, "CLOSE LOGICAL CHANNEL failed: ${resp.encodeHex()}")
|
||||
}
|
||||
|
||||
channelId = -1
|
||||
channels.remove(handle)
|
||||
}
|
||||
|
||||
override fun transmit(tx: ByteArray): ByteArray {
|
||||
check(channelId != -1) { "Logical channel is not opened" }
|
||||
return transmitApduByChannel(tx, channelId.toByte())
|
||||
override fun transmit(handle: Int, tx: ByteArray): ByteArray {
|
||||
check(channels.contains(handle)) {
|
||||
"Invalid logical channel handle $handle"
|
||||
}
|
||||
return transmitApduByChannel(tx, handle.toByte())
|
||||
}
|
||||
|
||||
override val valid: Boolean
|
||||
get() = channelId != -1
|
||||
|
||||
private fun isSuccessResponse(resp: ByteArray): Boolean =
|
||||
resp.size >= 2 && resp[resp.size - 2] == 0x90.toByte() && resp[resp.size - 1] == 0x00.toByte()
|
||||
|
||||
|
|
@ -130,7 +185,7 @@ class UsbApduInterface(
|
|||
// OR the channel mask into the CLA byte
|
||||
realTx[0] = ((realTx[0].toInt() and 0xFC) or channel.toInt()).toByte()
|
||||
|
||||
var resp = transceiver.sendXfrBlock(realTx).data!!
|
||||
var resp = ccidCtx.transceiver.sendXfrBlock(realTx).data!!
|
||||
|
||||
if (resp.size < 2) throw RuntimeException("APDU response smaller than 2 (sw1 + sw2)!")
|
||||
|
||||
|
|
@ -141,7 +196,7 @@ class UsbApduInterface(
|
|||
// 0x6C = wrong le
|
||||
// so we fix the le field here
|
||||
realTx[realTx.size - 1] = resp[resp.size - 1]
|
||||
resp = transceiver.sendXfrBlock(realTx).data!!
|
||||
resp = ccidCtx.transceiver.sendXfrBlock(realTx).data!!
|
||||
} else if (sw1 == 0x61) {
|
||||
// 0x61 = X bytes available
|
||||
// continue reading by GET RESPONSE
|
||||
|
|
@ -151,7 +206,7 @@ class UsbApduInterface(
|
|||
realTx[0], 0xC0.toByte(), 0x00, 0x00, sw2.toByte()
|
||||
)
|
||||
|
||||
val tmp = transceiver.sendXfrBlock(getResponseCmd).data!!
|
||||
val tmp = ccidCtx.transceiver.sendXfrBlock(getResponseCmd).data!!
|
||||
|
||||
resp = resp.sliceArray(0 until (resp.size - 2)) + tmp
|
||||
|
||||
|
|
@ -162,4 +217,4 @@ class UsbApduInterface(
|
|||
|
||||
return resp
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,85 @@
|
|||
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 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,
|
||||
context.preferenceRepository.verboseLoggingFlow
|
||||
)
|
||||
}.getOrNull()
|
||||
}
|
||||
|
||||
/**
|
||||
* When set to false (the default), the disconnect() method does nothing.
|
||||
* This allows the separation of device disconnection from lpac-jni's APDU interface.
|
||||
*/
|
||||
var allowDisconnect = false
|
||||
private var initialized = false
|
||||
lateinit var transceiver: UsbCcidTransceiver
|
||||
var atr: ByteArray? = null
|
||||
|
||||
fun connect() {
|
||||
if (initialized) {
|
||||
return
|
||||
}
|
||||
|
||||
val ccidDescription = UsbCcidDescription.fromRawDescriptors(conn.rawDescriptors)!!
|
||||
|
||||
if (!ccidDescription.hasT0Protocol) {
|
||||
throw IllegalArgumentException("Unsupported card reader; T=0 support is required")
|
||||
}
|
||||
|
||||
transceiver = UsbCcidTransceiver(conn, bulkIn, bulkOut, ccidDescription, verboseLoggingFlow)
|
||||
|
||||
try {
|
||||
// 6.1.1.1 PC_to_RDR_IccPowerOn (Page 20 of 40)
|
||||
// https://www.usb.org/sites/default/files/DWG_Smart-Card_USB-ICC_ICCD_rev10.pdf
|
||||
atr = transceiver.iccPowerOn().data
|
||||
} catch (e: Exception) {
|
||||
e.printStackTrace()
|
||||
throw e
|
||||
}
|
||||
|
||||
initialized = true
|
||||
}
|
||||
|
||||
fun disconnect() {
|
||||
if (initialized && allowDisconnect) {
|
||||
conn.close()
|
||||
atr = null
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -20,12 +20,12 @@ data class UsbCcidDescription(
|
|||
|
||||
private const val FEATURE_EXCHANGE_LEVEL_TPDU = 0x10000
|
||||
private const val FEATURE_EXCHANGE_LEVEL_SHORT_APDU = 0x20000
|
||||
private const val FEATURE_EXCHAGE_LEVEL_EXTENDED_APDU = 0x40000
|
||||
private const val FEATURE_EXCHANGE_LEVEL_EXTENDED_APDU = 0x40000
|
||||
|
||||
// bVoltageSupport Masks
|
||||
private const val VOLTAGE_5V: Byte = 1
|
||||
private const val VOLTAGE_3V: Byte = 2
|
||||
private const val VOLTAGE_1_8V: Byte = 4
|
||||
private const val VOLTAGE_5V0: Byte = 1
|
||||
private const val VOLTAGE_3V0: Byte = 2
|
||||
private const val VOLTAGE_1V8: Byte = 4
|
||||
|
||||
private const val SLOT_OFFSET = 4
|
||||
private const val FEATURES_OFFSET = 40
|
||||
|
|
@ -71,35 +71,30 @@ data class UsbCcidDescription(
|
|||
}
|
||||
|
||||
enum class Voltage(powerOnValue: Int, mask: Int) {
|
||||
AUTO(0, 0), _5V(1, VOLTAGE_5V.toInt()), _3V(2, VOLTAGE_3V.toInt()), _1_8V(
|
||||
3,
|
||||
VOLTAGE_1_8V.toInt()
|
||||
);
|
||||
// @formatter:off
|
||||
AUTO(0, 0),
|
||||
V50(1, VOLTAGE_5V0.toInt()),
|
||||
V30(2, VOLTAGE_3V0.toInt()),
|
||||
V18(3, VOLTAGE_1V8.toInt());
|
||||
// @formatter:on
|
||||
|
||||
val mask = powerOnValue.toByte()
|
||||
val powerOnValue = mask.toByte()
|
||||
}
|
||||
|
||||
private fun hasFeature(feature: Int): Boolean =
|
||||
(dwFeatures and feature) != 0
|
||||
private fun hasFeature(feature: Int) = (dwFeatures and feature) != 0
|
||||
|
||||
val voltages: Array<Voltage>
|
||||
get() =
|
||||
if (hasFeature(FEATURE_AUTOMATIC_VOLTAGE)) {
|
||||
arrayOf(Voltage.AUTO)
|
||||
} else {
|
||||
Voltage.values().mapNotNull {
|
||||
if ((it.mask.toInt() and bVoltageSupport.toInt()) != 0) {
|
||||
it
|
||||
} else {
|
||||
null
|
||||
}
|
||||
}.toTypedArray()
|
||||
}
|
||||
val isTpdu = hasFeature(0x10000)
|
||||
|
||||
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
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -4,7 +4,10 @@ import android.hardware.usb.UsbDeviceConnection
|
|||
import android.hardware.usb.UsbEndpoint
|
||||
import android.os.SystemClock
|
||||
import android.util.Log
|
||||
import im.angry.openeuicc.util.*
|
||||
import im.angry.openeuicc.util.encodeHex
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.first
|
||||
import kotlinx.coroutines.runBlocking
|
||||
import java.nio.ByteBuffer
|
||||
import java.nio.ByteOrder
|
||||
|
||||
|
|
@ -18,7 +21,8 @@ class UsbCcidTransceiver(
|
|||
private val usbConnection: UsbDeviceConnection,
|
||||
private val usbBulkIn: UsbEndpoint,
|
||||
private val usbBulkOut: UsbEndpoint,
|
||||
private val usbCcidDescription: UsbCcidDescription
|
||||
private val usbCcidDescription: UsbCcidDescription,
|
||||
private val verboseLoggingFlow: Flow<Boolean>
|
||||
) {
|
||||
companion object {
|
||||
private const val TAG = "UsbCcidTransceiver"
|
||||
|
|
@ -91,6 +95,7 @@ class UsbCcidTransceiver(
|
|||
data class UsbCcidErrorException(val msg: String, val errorResponse: CcidDataBlock) :
|
||||
Exception(msg)
|
||||
|
||||
@Suppress("ArrayInDataClass")
|
||||
data class CcidDataBlock(
|
||||
val dwLength: Int,
|
||||
val bSlot: Byte,
|
||||
|
|
@ -138,6 +143,8 @@ class UsbCcidTransceiver(
|
|||
|
||||
val hasAutomaticPps = usbCcidDescription.hasAutomaticPps
|
||||
|
||||
val isTpdu = usbCcidDescription.isTpdu
|
||||
|
||||
private val inputBuffer = ByteArray(usbBulkIn.maxPacketSize)
|
||||
|
||||
private var currentSequenceNumber: Byte = 0
|
||||
|
|
@ -153,6 +160,46 @@ class UsbCcidTransceiver(
|
|||
}
|
||||
}
|
||||
|
||||
private fun receiveParamBlock(expectedSequenceNumber: Byte): ByteArray {
|
||||
var response: ByteArray?
|
||||
do {
|
||||
response = receiveParamBlockImmediate(expectedSequenceNumber)
|
||||
} while (response!![7] == 0x80.toByte())
|
||||
return response
|
||||
}
|
||||
|
||||
private fun receiveParamBlockImmediate(expectedSequenceNumber: Byte): ByteArray {
|
||||
/*
|
||||
* Some USB CCID devices (notably NitroKey 3) may time-out and need a subsequent poke to
|
||||
* carry on communications. No particular reason why the number 3 was chosen. If we get a
|
||||
* zero-sized reply (or a time-out), we try again. Clamped retries prevent an infinite loop
|
||||
* if things really turn sour.
|
||||
*/
|
||||
var attempts = 3
|
||||
Log.d(TAG, "Receive data block immediate seq=$expectedSequenceNumber")
|
||||
var readBytes: Int
|
||||
do {
|
||||
readBytes = usbConnection.bulkTransfer(
|
||||
usbBulkIn, inputBuffer, inputBuffer.size, DEVICE_COMMUNICATE_TIMEOUT_MILLIS
|
||||
)
|
||||
if (runBlocking { verboseLoggingFlow.first() }) {
|
||||
Log.d(TAG, "Received $readBytes bytes: ${inputBuffer.encodeHex()}")
|
||||
}
|
||||
} while (readBytes <= 0 && attempts-- > 0)
|
||||
if (inputBuffer[0] != 0x82.toByte()) {
|
||||
throw UsbTransportException(buildString {
|
||||
append("USB-CCID error - bad CCID header")
|
||||
append(", type ")
|
||||
append("%d (expected %d)".format(inputBuffer[0], MESSAGE_TYPE_RDR_TO_PC_DATA_BLOCK))
|
||||
if (expectedSequenceNumber != inputBuffer[6]) {
|
||||
append(", sequence number ")
|
||||
append("%d (expected %d)".format(inputBuffer[6], expectedSequenceNumber))
|
||||
}
|
||||
})
|
||||
}
|
||||
return inputBuffer
|
||||
}
|
||||
|
||||
private fun receiveDataBlock(expectedSequenceNumber: Byte): CcidDataBlock {
|
||||
var response: CcidDataBlock?
|
||||
do {
|
||||
|
|
@ -178,30 +225,27 @@ class UsbCcidTransceiver(
|
|||
readBytes = usbConnection.bulkTransfer(
|
||||
usbBulkIn, inputBuffer, inputBuffer.size, DEVICE_COMMUNICATE_TIMEOUT_MILLIS
|
||||
)
|
||||
Log.d(TAG, "Received " + readBytes + " bytes: " + inputBuffer.encodeHex())
|
||||
if (runBlocking { verboseLoggingFlow.first() }) {
|
||||
Log.d(TAG, "Received $readBytes bytes: ${inputBuffer.encodeHex()}")
|
||||
}
|
||||
} while (readBytes <= 0 && attempts-- > 0)
|
||||
if (readBytes < CCID_HEADER_LENGTH) {
|
||||
throw UsbTransportException("USB-CCID error - failed to receive CCID header")
|
||||
}
|
||||
if (inputBuffer[0] != MESSAGE_TYPE_RDR_TO_PC_DATA_BLOCK.toByte()) {
|
||||
if (expectedSequenceNumber != inputBuffer[6]) {
|
||||
throw UsbTransportException(
|
||||
((("USB-CCID error - bad CCID header, type " + inputBuffer[0]) + " (expected " +
|
||||
MESSAGE_TYPE_RDR_TO_PC_DATA_BLOCK) + "), sequence number " + inputBuffer[6]
|
||||
) + " (expected " +
|
||||
expectedSequenceNumber + ")"
|
||||
)
|
||||
}
|
||||
throw UsbTransportException(
|
||||
"USB-CCID error - bad CCID header type " + inputBuffer[0]
|
||||
)
|
||||
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)
|
||||
)
|
||||
throw UsbTransportException("USB-CCID error - expected sequence number $expectedSequenceNumber, got $result")
|
||||
}
|
||||
|
||||
val dataBuffer = ByteArray(result.dwLength)
|
||||
|
|
@ -212,9 +256,7 @@ class UsbCcidTransceiver(
|
|||
usbBulkIn, inputBuffer, inputBuffer.size, DEVICE_COMMUNICATE_TIMEOUT_MILLIS
|
||||
)
|
||||
if (readBytes < 0) {
|
||||
throw UsbTransportException(
|
||||
"USB error - failed reading response data! Header: $result"
|
||||
)
|
||||
throw UsbTransportException("USB error - failed reading response data! Header: $result")
|
||||
}
|
||||
System.arraycopy(inputBuffer, 0, dataBuffer, bufferedBytes, readBytes)
|
||||
bufferedBytes += readBytes
|
||||
|
|
@ -279,7 +321,39 @@ class UsbCcidTransceiver(
|
|||
}
|
||||
val ccidDataBlock = receiveDataBlock(sequenceNumber)
|
||||
val elapsedTime = SystemClock.elapsedRealtime() - startTime
|
||||
Log.d(TAG, "USB XferBlock call took " + elapsedTime + "ms")
|
||||
Log.d(TAG, "USB XferBlock call took ${elapsedTime}ms")
|
||||
return ccidDataBlock
|
||||
}
|
||||
|
||||
fun sendParamBlock(
|
||||
payload: ByteArray
|
||||
): ByteArray {
|
||||
val startTime = SystemClock.elapsedRealtime()
|
||||
val l = payload.size
|
||||
val sequenceNumber: Byte = currentSequenceNumber++
|
||||
val headerData = byteArrayOf(
|
||||
0x61.toByte(),
|
||||
l.toByte(),
|
||||
(l shr 8).toByte(),
|
||||
(l shr 16).toByte(),
|
||||
(l shr 24).toByte(),
|
||||
SLOT_NUMBER.toByte(),
|
||||
sequenceNumber,
|
||||
0x00.toByte(),
|
||||
0x00.toByte(),
|
||||
0x00.toByte()
|
||||
)
|
||||
val data: ByteArray = headerData + payload
|
||||
Log.d(TAG, "USB ParamBlock: ${data.encodeHex()}")
|
||||
var sentBytes = 0
|
||||
while (sentBytes < data.size) {
|
||||
val bytesToSend = usbBulkOut.maxPacketSize.coerceAtMost(data.size - sentBytes)
|
||||
sendRaw(data, sentBytes, bytesToSend)
|
||||
sentBytes += bytesToSend
|
||||
}
|
||||
val ccidDataBlock = receiveParamBlock(sequenceNumber)
|
||||
val elapsedTime = SystemClock.elapsedRealtime() - startTime
|
||||
Log.d(TAG, "USB ParamBlock call took ${elapsedTime}ms")
|
||||
return ccidDataBlock
|
||||
}
|
||||
|
||||
|
|
@ -287,13 +361,13 @@ class UsbCcidTransceiver(
|
|||
val startTime = SystemClock.elapsedRealtime()
|
||||
skipAvailableInput()
|
||||
var response: CcidDataBlock? = null
|
||||
for (v in usbCcidDescription.voltages) {
|
||||
Log.v(TAG, "CCID: attempting to power on with voltage $v")
|
||||
for (voltage in usbCcidDescription.voltages) {
|
||||
Log.v(TAG, "CCID: attempting to power on with voltage $voltage")
|
||||
response = try {
|
||||
iccPowerOnVoltage(v.powerOnValue)
|
||||
iccPowerOnVoltage(voltage.powerOnValue)
|
||||
} catch (e: UsbCcidErrorException) {
|
||||
if (e.errorResponse.bError.toInt() == 7) { // Power select error
|
||||
Log.v(TAG, "CCID: failed to power on with voltage $v")
|
||||
Log.v(TAG, "CCID: failed to power on with voltage $voltage")
|
||||
iccPowerOff()
|
||||
Log.v(TAG, "CCID: powered off")
|
||||
continue
|
||||
|
|
@ -308,8 +382,11 @@ class UsbCcidTransceiver(
|
|||
val elapsedTime = SystemClock.elapsedRealtime() - startTime
|
||||
Log.d(
|
||||
TAG,
|
||||
"Usb transport connected, took " + elapsedTime + "ms, ATR=" +
|
||||
response.data?.encodeHex()
|
||||
buildString {
|
||||
append("Usb transport connected")
|
||||
append(", took ", elapsedTime, "ms")
|
||||
append(", ATR=", response.data?.encodeHex())
|
||||
}
|
||||
)
|
||||
return response
|
||||
}
|
||||
|
|
@ -339,4 +416,4 @@ class UsbCcidTransceiver(
|
|||
)
|
||||
sendRaw(iccPowerCommand, 0, iccPowerCommand.size)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -6,31 +6,22 @@ import android.hardware.usb.UsbDevice
|
|||
import android.hardware.usb.UsbEndpoint
|
||||
import android.hardware.usb.UsbInterface
|
||||
|
||||
class UsbTransportException(msg: String) : Exception(msg)
|
||||
class UsbTransportException(message: String) : Exception(message)
|
||||
|
||||
fun UsbInterface.getIoEndpoints(): Pair<UsbEndpoint?, UsbEndpoint?> {
|
||||
var bulkIn: UsbEndpoint? = null
|
||||
var bulkOut: UsbEndpoint? = null
|
||||
for (i in 0 until endpointCount) {
|
||||
val endpoint = getEndpoint(i)
|
||||
if (endpoint.type != UsbConstants.USB_ENDPOINT_XFER_BULK) {
|
||||
continue
|
||||
}
|
||||
if (endpoint.direction == UsbConstants.USB_DIR_IN) {
|
||||
bulkIn = endpoint
|
||||
} else if (endpoint.direction == UsbConstants.USB_DIR_OUT) {
|
||||
bulkOut = endpoint
|
||||
}
|
||||
}
|
||||
return Pair(bulkIn, bulkOut)
|
||||
}
|
||||
val UsbDevice.interfaces: Iterable<UsbInterface>
|
||||
get() = (0 until interfaceCount).map(::getInterface)
|
||||
|
||||
fun UsbDevice.getSmartCardInterface(): UsbInterface? {
|
||||
for (i in 0 until interfaceCount) {
|
||||
val anInterface = getInterface(i)
|
||||
if (anInterface.interfaceClass == UsbConstants.USB_CLASS_CSCID) {
|
||||
return anInterface
|
||||
}
|
||||
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 },
|
||||
)
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
|
@ -15,4 +15,5 @@ interface AppContainer {
|
|||
val preferenceRepository: PreferenceRepository
|
||||
val uiComponentFactory: UiComponentFactory
|
||||
val euiccChannelFactory: EuiccChannelFactory
|
||||
}
|
||||
val customizableTextProvider: CustomizableTextProvider
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,33 @@
|
|||
package im.angry.openeuicc.di
|
||||
|
||||
import android.net.Uri
|
||||
import im.angry.openeuicc.core.EuiccChannel
|
||||
|
||||
interface CustomizableTextProvider {
|
||||
/**
|
||||
* Explanation string for when no eUICC is found on the device.
|
||||
* 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
|
||||
|
||||
/**
|
||||
* Display the website link in settings; null if not available.
|
||||
*/
|
||||
val websiteUri: Uri?
|
||||
|
||||
/**
|
||||
* Format the name of a logical slot -- not for USB channels
|
||||
*/
|
||||
fun formatNonUsbChannelName(logicalSlotId: Int): String
|
||||
|
||||
/**
|
||||
* Format the name of a logical slot with a SE ID, in case of multi-SE chips; currently
|
||||
* this is used in the download flow to distinguish between them on the same chip.
|
||||
*/
|
||||
fun formatNonUsbChannelNameWithSeId(logicalSlotId: Int, seId: EuiccChannel.SecureElementId): String
|
||||
}
|
||||
|
|
@ -38,4 +38,8 @@ open class DefaultAppContainer(context: Context) : AppContainer {
|
|||
override val euiccChannelFactory by lazy {
|
||||
DefaultEuiccChannelFactory(context)
|
||||
}
|
||||
}
|
||||
|
||||
override val customizableTextProvider by lazy {
|
||||
DefaultCustomizableTextProvider(context)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,23 @@
|
|||
package im.angry.openeuicc.di
|
||||
|
||||
import android.content.Context
|
||||
import android.net.Uri
|
||||
import im.angry.openeuicc.common.R
|
||||
import im.angry.openeuicc.core.EuiccChannel
|
||||
|
||||
open class DefaultCustomizableTextProvider(private val context: Context) : CustomizableTextProvider {
|
||||
override val noEuiccExplanation: String
|
||||
get() = context.getString(R.string.no_euicc)
|
||||
|
||||
override val profileSwitchingTimeoutMessage: String
|
||||
get() = context.getString(R.string.profile_switch_timeout)
|
||||
|
||||
override val websiteUri: Uri?
|
||||
get() = null
|
||||
|
||||
override fun formatNonUsbChannelName(logicalSlotId: Int): String =
|
||||
context.getString(R.string.channel_name_format, logicalSlotId)
|
||||
|
||||
override fun formatNonUsbChannelNameWithSeId(logicalSlotId: Int, seId: EuiccChannel.SecureElementId): String =
|
||||
context.getString(R.string.channel_name_format_se, logicalSlotId, seId.id)
|
||||
}
|
||||
|
|
@ -4,10 +4,17 @@ import androidx.fragment.app.Fragment
|
|||
import im.angry.openeuicc.core.EuiccChannel
|
||||
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,
|
||||
seId: EuiccChannel.SecureElementId
|
||||
): EuiccManagementFragment =
|
||||
EuiccManagementFragment.newInstance(slotId, portId, seId)
|
||||
|
||||
override fun createNoEuiccPlaceholderFragment(): Fragment = NoEuiccPlaceholderFragment()
|
||||
}
|
||||
|
||||
override fun createSettingsFragment(): Fragment = SettingsFragment()
|
||||
}
|
||||
|
|
|
|||
|
|
@ -5,6 +5,12 @@ import im.angry.openeuicc.core.EuiccChannel
|
|||
import im.angry.openeuicc.ui.EuiccManagementFragment
|
||||
|
||||
interface UiComponentFactory {
|
||||
fun createEuiccManagementFragment(channel: EuiccChannel): EuiccManagementFragment
|
||||
fun createEuiccManagementFragment(
|
||||
slotId: Int,
|
||||
portId: Int,
|
||||
seId: EuiccChannel.SecureElementId
|
||||
): EuiccManagementFragment
|
||||
|
||||
fun createNoEuiccPlaceholderFragment(): Fragment
|
||||
}
|
||||
fun createSettingsFragment(): Fragment
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,11 +1,43 @@
|
|||
package im.angry.openeuicc.service
|
||||
|
||||
import android.app.Service
|
||||
import android.content.Intent
|
||||
import android.content.pm.PackageManager
|
||||
import android.os.Binder
|
||||
import android.os.IBinder
|
||||
import android.os.PowerManager
|
||||
import android.util.Log
|
||||
import androidx.core.app.NotificationChannelCompat
|
||||
import androidx.core.app.NotificationCompat
|
||||
import androidx.core.app.NotificationManagerCompat
|
||||
import androidx.lifecycle.LifecycleService
|
||||
import androidx.lifecycle.lifecycleScope
|
||||
import im.angry.openeuicc.common.R
|
||||
import im.angry.openeuicc.core.EuiccChannel
|
||||
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.ProfileDownloadInput
|
||||
|
||||
/**
|
||||
* An Android Service wrapper for EuiccChannelManager.
|
||||
|
|
@ -17,8 +49,41 @@ import im.angry.openeuicc.util.*
|
|||
* instance of EuiccChannelManager. UI components can keep being bound to this service for
|
||||
* their entire lifecycles, since the whole purpose of them is to expose the current state
|
||||
* to the user.
|
||||
*
|
||||
* Additionally, this service is also responsible for long-running "foreground" tasks that
|
||||
* are not suitable to be managed by UI components. This includes profile downloading, etc.
|
||||
* When a UI component needs to run one of these tasks, they have to bind to this service
|
||||
* and call one of the `launch*` methods, which will run the task inside this service's
|
||||
* lifecycle context and return a Flow instance for the UI component to subscribe to its
|
||||
* progress.
|
||||
*/
|
||||
class EuiccChannelManagerService : Service(), OpenEuiccContextMarker {
|
||||
class EuiccChannelManagerService : LifecycleService(), OpenEuiccContextMarker {
|
||||
companion object {
|
||||
private const val TAG = "EuiccChannelManagerService"
|
||||
private const val CHANNEL_ID = "tasks"
|
||||
private const val FOREGROUND_ID = 1000
|
||||
private const val TASK_FAILURE_ID = 1000
|
||||
|
||||
/**
|
||||
* Utility function to wait for a foreground task to be done, return its
|
||||
* error if any, or null on success.
|
||||
*/
|
||||
suspend fun Flow<ForegroundTaskState>.waitDone(): Throwable? =
|
||||
(this.last() as ForegroundTaskState.Done).error
|
||||
|
||||
/**
|
||||
* Apply transform to a ForegroundTaskState flow so that it completes when a Done is seen.
|
||||
*
|
||||
* This must be applied each time a flow is returned for subscription purposes. If applied
|
||||
* beforehand, we lose the ability to subscribe multiple times.
|
||||
*/
|
||||
private fun Flow<ForegroundTaskState>.applyCompletionTransform() =
|
||||
transformWhile {
|
||||
emit(it)
|
||||
it !is ForegroundTaskState.Done
|
||||
}
|
||||
}
|
||||
|
||||
inner class LocalBinder : Binder() {
|
||||
val service = this@EuiccChannelManagerService
|
||||
}
|
||||
|
|
@ -28,14 +93,431 @@ class EuiccChannelManagerService : Service(), OpenEuiccContextMarker {
|
|||
}
|
||||
val euiccChannelManager: EuiccChannelManager by euiccChannelManagerDelegate
|
||||
|
||||
override fun onBind(intent: Intent?): IBinder = LocalBinder()
|
||||
private val wakeLock: PowerManager.WakeLock by lazy {
|
||||
(getSystemService(POWER_SERVICE) as PowerManager).run {
|
||||
newWakeLock(PowerManager.PARTIAL_WAKE_LOCK, this::class.simpleName)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The state of a "foreground" task (named so due to the need to startForeground())
|
||||
*/
|
||||
sealed interface ForegroundTaskState {
|
||||
data object Idle : ForegroundTaskState
|
||||
data class InProgress(val progress: Int) : ForegroundTaskState
|
||||
data class Done(val error: Throwable?) : ForegroundTaskState
|
||||
}
|
||||
|
||||
/**
|
||||
* This flow emits whenever the service has had a start command, from startService()
|
||||
* The service self-starts when foreground is required, because other components
|
||||
* only bind to this service and do not start it per-se.
|
||||
*/
|
||||
private val foregroundStarted: MutableSharedFlow<Unit> = MutableSharedFlow()
|
||||
|
||||
/**
|
||||
* This flow is used to emit progress updates when a foreground task is running.
|
||||
*/
|
||||
private val foregroundTaskState: MutableStateFlow<ForegroundTaskState> =
|
||||
MutableStateFlow(ForegroundTaskState.Idle)
|
||||
|
||||
/**
|
||||
* A simple wrapper over a flow with taskId added.
|
||||
*
|
||||
* taskID is the exact millisecond-precision timestamp when the task is launched.
|
||||
*/
|
||||
class ForegroundTaskSubscriberFlow(val taskId: Long, inner: Flow<ForegroundTaskState>) :
|
||||
Flow<ForegroundTaskState> by inner
|
||||
|
||||
/**
|
||||
* A cache of subscribers to 5 recently-launched foreground tasks, identified by ID
|
||||
*
|
||||
* Only one can be run at the same time, but those that are done will be kept in this
|
||||
* map for a little while -- because UI components may be stopped and recreated while
|
||||
* tasks are running. Having this buffer allows the components to re-subscribe even if
|
||||
* the task completes while they are being recreated.
|
||||
*/
|
||||
private val foregroundTaskSubscribers: MutableMap<Long, SharedFlow<ForegroundTaskState>> =
|
||||
mutableMapOf()
|
||||
|
||||
override fun onBind(intent: Intent): IBinder {
|
||||
super.onBind(intent)
|
||||
return LocalBinder()
|
||||
}
|
||||
|
||||
override fun onDestroy() {
|
||||
super.onDestroy()
|
||||
// This is the whole reason of the existence of this service:
|
||||
// we can clean up opened channels when no one is using them
|
||||
if (euiccChannelManagerDelegate.isInitialized()) {
|
||||
euiccChannelManager.invalidate()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
|
||||
return super.onStartCommand(intent, flags, startId).also {
|
||||
lifecycleScope.launch {
|
||||
foregroundStarted.emit(Unit)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun ensureForegroundTaskNotificationChannel() {
|
||||
val nm = NotificationManagerCompat.from(this)
|
||||
if (nm.getNotificationChannelCompat(CHANNEL_ID) == null) {
|
||||
val channel =
|
||||
NotificationChannelCompat.Builder(
|
||||
CHANNEL_ID,
|
||||
NotificationManagerCompat.IMPORTANCE_LOW
|
||||
)
|
||||
.setName(getString(R.string.task_notification))
|
||||
.setVibrationEnabled(false)
|
||||
.build()
|
||||
nm.createNotificationChannel(channel)
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun updateForegroundNotification(title: String, iconRes: Int) {
|
||||
ensureForegroundTaskNotificationChannel()
|
||||
|
||||
val nm = NotificationManagerCompat.from(this)
|
||||
val state = foregroundTaskState.value
|
||||
|
||||
if (state is ForegroundTaskState.InProgress) {
|
||||
val notification = NotificationCompat.Builder(this, CHANNEL_ID)
|
||||
.setContentTitle(title)
|
||||
.setProgress(100, state.progress, state.progress == 0)
|
||||
.setSmallIcon(iconRes)
|
||||
.setPriority(NotificationCompat.PRIORITY_LOW)
|
||||
.setOngoing(true)
|
||||
.setOnlyAlertOnce(true)
|
||||
.build()
|
||||
|
||||
if (state.progress == 0) {
|
||||
startForeground(FOREGROUND_ID, notification)
|
||||
} else if (checkSelfPermission(android.Manifest.permission.POST_NOTIFICATIONS) == PackageManager.PERMISSION_GRANTED) {
|
||||
nm.notify(FOREGROUND_ID, notification)
|
||||
}
|
||||
|
||||
// Yield out so that the main looper can handle the notification event
|
||||
// Without this yield, the notification sent above will not be shown in time.
|
||||
yield()
|
||||
} else {
|
||||
stopForeground(STOP_FOREGROUND_REMOVE)
|
||||
}
|
||||
}
|
||||
|
||||
private fun postForegroundTaskFailureNotification(title: String) {
|
||||
if (checkSelfPermission(android.Manifest.permission.POST_NOTIFICATIONS) != PackageManager.PERMISSION_GRANTED) {
|
||||
return
|
||||
}
|
||||
|
||||
val notification = NotificationCompat.Builder(this, CHANNEL_ID)
|
||||
.setContentTitle(title)
|
||||
.setSmallIcon(R.drawable.ic_x_black)
|
||||
.build()
|
||||
NotificationManagerCompat.from(this).notify(TASK_FAILURE_ID, notification)
|
||||
}
|
||||
|
||||
/**
|
||||
* Recover the subscriber to a foreground task that is recently launched.
|
||||
*
|
||||
* null if the task doesn't exist, or was launched too long ago.
|
||||
*/
|
||||
fun recoverForegroundTaskSubscriber(taskId: Long): ForegroundTaskSubscriberFlow? =
|
||||
foregroundTaskSubscribers[taskId]?.let {
|
||||
ForegroundTaskSubscriberFlow(taskId, it.applyCompletionTransform())
|
||||
}
|
||||
|
||||
/**
|
||||
* Launch a potentially blocking foreground task in this service's lifecycle context.
|
||||
* This function does not block, but returns a Flow that emits ForegroundTaskState
|
||||
* updates associated with this task. The last update the returned flow will emit is
|
||||
* always ForegroundTaskState.Done.
|
||||
*
|
||||
* The returned flow can only be subscribed to once even though the underlying implementation
|
||||
* is a SharedFlow. This is due to the need to apply transformations so that the stream
|
||||
* actually completes. In order to subscribe multiple times, use `recoverForegroundTaskSubscriber`
|
||||
* to acquire another instance.
|
||||
*
|
||||
* The task closure is expected to update foregroundTaskState whenever appropriate.
|
||||
* If a foreground task is already running, this function returns null.
|
||||
*
|
||||
* To wait for foreground tasks to be available, use waitForForegroundTask().
|
||||
*
|
||||
* The function will set the state back to Idle once it sees ForegroundTaskState.Done.
|
||||
*/
|
||||
private fun launchForegroundTask(
|
||||
title: String,
|
||||
failureTitle: String,
|
||||
iconRes: Int,
|
||||
task: suspend EuiccChannelManagerService.() -> Unit
|
||||
): ForegroundTaskSubscriberFlow {
|
||||
val taskID = System.currentTimeMillis()
|
||||
|
||||
// Atomically set the state to InProgress. If this returns true, we are
|
||||
// the only task currently in progress.
|
||||
if (!foregroundTaskState.compareAndSet(
|
||||
ForegroundTaskState.Idle,
|
||||
ForegroundTaskState.InProgress(0)
|
||||
)
|
||||
) {
|
||||
return ForegroundTaskSubscriberFlow(
|
||||
taskID,
|
||||
flow { emit(ForegroundTaskState.Done(IllegalStateException("There are tasks currently running"))) })
|
||||
}
|
||||
|
||||
lifecycleScope.launch(Dispatchers.Main) {
|
||||
// Wait until our self-start command has succeeded.
|
||||
// We can only call startForeground() after that
|
||||
val res = withTimeoutOrNull(30 * 1000) {
|
||||
foregroundStarted.first()
|
||||
}
|
||||
|
||||
if (res == null) {
|
||||
// The only case where the wait above could time out is if the subscriber
|
||||
// to the flow is stuck. Or we failed to start foreground.
|
||||
// In that case, we should just set our state back to Idle -- setting it
|
||||
// to Done wouldn't help much because nothing is going to then set it Idle.
|
||||
foregroundTaskState.value = ForegroundTaskState.Idle
|
||||
return@launch
|
||||
}
|
||||
|
||||
updateForegroundNotification(title, iconRes)
|
||||
|
||||
wakeLock.acquire(10 * 60 * 1000L /*10 minutes*/)
|
||||
|
||||
try {
|
||||
withContext(Dispatchers.IO + NonCancellable) { // Any LPA-related task must always complete
|
||||
this@EuiccChannelManagerService.task()
|
||||
}
|
||||
// This update will be sent by the subscriber (as shown below)
|
||||
foregroundTaskState.value = ForegroundTaskState.Done(null)
|
||||
} catch (t: Throwable) {
|
||||
Log.e(TAG, "Foreground task encountered an error")
|
||||
Log.e(TAG, Log.getStackTraceString(t))
|
||||
foregroundTaskState.value = ForegroundTaskState.Done(t)
|
||||
|
||||
if (isActive) {
|
||||
postForegroundTaskFailureNotification(failureTitle)
|
||||
}
|
||||
} finally {
|
||||
wakeLock.release()
|
||||
if (isActive) {
|
||||
stopSelf()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// This is the flow we are going to return. We allow multiple subscribers by
|
||||
// re-emitting state updates into this flow from another coroutine.
|
||||
// replay = 2 ensures that we at least have 1 previous state whenever subscribed to.
|
||||
// This is helpful when the task completed and is then re-subscribed to due to a
|
||||
// UI recreation event -- this way, the UI will know at least one last progress event
|
||||
// before completion / failure
|
||||
val subscriberFlow = MutableSharedFlow<ForegroundTaskState>(
|
||||
replay = 2,
|
||||
onBufferOverflow = BufferOverflow.DROP_OLDEST
|
||||
)
|
||||
|
||||
// We should be the only task running, so we can subscribe to foregroundTaskState
|
||||
// until we encounter ForegroundTaskState.Done.
|
||||
// Then, we complete the returned flow, but we also set the state back to Idle.
|
||||
// The state update back to Idle won't show up in the returned stream, because
|
||||
// it has been completed by that point.
|
||||
lifecycleScope.launch(Dispatchers.Main) {
|
||||
foregroundTaskState
|
||||
.applyCompletionTransform()
|
||||
.onEach {
|
||||
// Also update our notification when we see an update
|
||||
// But ignore the first progress = 0 update -- that is the current value.
|
||||
// we need that to be handled by the main coroutine after it finishes.
|
||||
if (it !is ForegroundTaskState.InProgress || it.progress != 0) {
|
||||
updateForegroundNotification(title, iconRes)
|
||||
}
|
||||
|
||||
subscriberFlow.emit(it)
|
||||
}
|
||||
.onCompletion {
|
||||
// Reset state back to Idle when we are done.
|
||||
// We do it here because otherwise Idle and Done might become conflated
|
||||
// when emitted by the main coroutine in quick succession.
|
||||
// Doing it here ensures we've seen Done. This Idle event won't be
|
||||
// emitted to the consumer because the subscription has completed here.
|
||||
foregroundTaskState.value = ForegroundTaskState.Idle
|
||||
}
|
||||
.collect()
|
||||
}
|
||||
|
||||
foregroundTaskSubscribers[taskID] = subscriberFlow.asSharedFlow()
|
||||
|
||||
if (foregroundTaskSubscribers.size > 5) {
|
||||
// Remove enough elements so that the size is kept at 5
|
||||
for (key in foregroundTaskSubscribers.keys.sorted()
|
||||
.take(foregroundTaskSubscribers.size - 5)) {
|
||||
foregroundTaskSubscribers.remove(key)
|
||||
}
|
||||
}
|
||||
|
||||
// Before we return, and after we have set everything up,
|
||||
// self-start with foreground permission.
|
||||
// This is going to unblock the main coroutine handling the task.
|
||||
startForegroundService(
|
||||
Intent(
|
||||
this@EuiccChannelManagerService,
|
||||
this@EuiccChannelManagerService::class.java
|
||||
)
|
||||
)
|
||||
|
||||
return ForegroundTaskSubscriberFlow(
|
||||
taskID,
|
||||
subscriberFlow.asSharedFlow().applyCompletionTransform()
|
||||
)
|
||||
}
|
||||
|
||||
suspend fun waitForForegroundTask() {
|
||||
foregroundTaskState.takeWhile { it != ForegroundTaskState.Idle }
|
||||
.collect()
|
||||
}
|
||||
|
||||
fun launchProfileDownloadTask(
|
||||
slotId: Int, portId: Int, seId: EuiccChannel.SecureElementId,
|
||||
input: ProfileDownloadInput
|
||||
): 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, seId) {
|
||||
euiccChannelManager.withEuiccChannel(slotId, portId, seId) { channel ->
|
||||
channel.lpa.downloadProfile(input) { state ->
|
||||
if (state.progress == 0) return@downloadProfile
|
||||
foregroundTaskState.value = ForegroundTaskState.InProgress(state.progress)
|
||||
}
|
||||
}
|
||||
|
||||
preferenceRepository.notificationDownloadFlow.first()
|
||||
}
|
||||
}
|
||||
|
||||
fun launchProfileRenameTask(
|
||||
slotId: Int,
|
||||
portId: Int,
|
||||
seId: EuiccChannel.SecureElementId,
|
||||
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, seId) { channel ->
|
||||
channel.lpa.setNickname(
|
||||
iccid,
|
||||
name
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
fun launchProfileDeleteTask(
|
||||
slotId: Int,
|
||||
portId: Int,
|
||||
seId: EuiccChannel.SecureElementId,
|
||||
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, seId) {
|
||||
euiccChannelManager.withEuiccChannel(slotId, portId, seId) { channel ->
|
||||
channel.lpa.deleteProfile(iccid)
|
||||
}
|
||||
|
||||
preferenceRepository.notificationDeleteFlow.first()
|
||||
}
|
||||
}
|
||||
|
||||
class SwitchingProfilesRefreshException : Exception()
|
||||
|
||||
fun launchProfileSwitchTask(
|
||||
slotId: Int,
|
||||
portId: Int,
|
||||
seId: EuiccChannel.SecureElementId,
|
||||
iccid: String,
|
||||
enable: Boolean, // Enable or disable the profile indicated in iccid
|
||||
reconnectTimeoutMillis: Long = 0 // 0 = do not wait for reconnect
|
||||
) =
|
||||
launchForegroundTask(
|
||||
getString(R.string.task_profile_switch),
|
||||
getString(R.string.task_profile_switch_failure),
|
||||
R.drawable.ic_task_switch
|
||||
) {
|
||||
euiccChannelManager.beginTrackedOperation(slotId, portId, seId) {
|
||||
val (response, refreshed) =
|
||||
euiccChannelManager.withEuiccChannel(slotId, portId, seId) { channel ->
|
||||
val refresh = preferenceRepository.refreshAfterSwitchFlow.first()
|
||||
val response = channel.lpa.switchProfile(iccid, enable, refresh)
|
||||
if (response || !refresh) {
|
||||
Pair(response, refresh)
|
||||
} else {
|
||||
// refresh failed, but refresh was requested
|
||||
// Sometimes, we *can* enable or disable the profile, but we cannot
|
||||
// send the refresh command to the modem because the profile somehow
|
||||
// makes the modem "busy". In this case, we can still switch by setting
|
||||
// refresh to false, but then the switch cannot take effect until the
|
||||
// user resets the modem manually by toggling airplane mode or rebooting.
|
||||
Pair(
|
||||
channel.lpa.switchProfile(iccid, enable, refresh = false),
|
||||
false
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
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,
|
||||
seId: EuiccChannel.SecureElementId
|
||||
): ForegroundTaskSubscriberFlow =
|
||||
launchForegroundTask(
|
||||
getString(R.string.task_euicc_memory_reset),
|
||||
getString(R.string.task_euicc_memory_reset_failure),
|
||||
R.drawable.ic_euicc_memory_reset
|
||||
) {
|
||||
euiccChannelManager.beginTrackedOperation(slotId, portId, seId) {
|
||||
euiccChannelManager.withEuiccChannel(slotId, portId, seId) { channel ->
|
||||
channel.lpa.euiccMemoryReset()
|
||||
}
|
||||
|
||||
preferenceRepository.notificationDeleteFlow.first()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,7 +1,6 @@
|
|||
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
|
||||
|
|
@ -9,14 +8,18 @@ import android.os.IBinder
|
|||
import androidx.appcompat.app.AppCompatActivity
|
||||
import im.angry.openeuicc.core.EuiccChannelManager
|
||||
import im.angry.openeuicc.service.EuiccChannelManagerService
|
||||
import kotlinx.coroutines.CompletableDeferred
|
||||
|
||||
abstract class BaseEuiccAccessActivity : AppCompatActivity() {
|
||||
val euiccChannelManagerLoaded = CompletableDeferred<Unit>()
|
||||
lateinit var euiccChannelManager: EuiccChannelManager
|
||||
lateinit var euiccChannelManagerService: EuiccChannelManagerService
|
||||
|
||||
private val euiccChannelManagerServiceConnection = object : ServiceConnection {
|
||||
override fun onServiceConnected(name: ComponentName?, service: IBinder?) {
|
||||
euiccChannelManager =
|
||||
(service!! as EuiccChannelManagerService.LocalBinder).service.euiccChannelManager
|
||||
euiccChannelManagerService = (service!! as EuiccChannelManagerService.LocalBinder).service
|
||||
euiccChannelManager = euiccChannelManagerService.euiccChannelManager
|
||||
euiccChannelManagerLoaded.complete(Unit)
|
||||
onInit()
|
||||
}
|
||||
|
||||
|
|
@ -32,7 +35,7 @@ abstract class BaseEuiccAccessActivity : AppCompatActivity() {
|
|||
bindService(
|
||||
Intent(this, EuiccChannelManagerService::class.java),
|
||||
euiccChannelManagerServiceConnection,
|
||||
Context.BIND_AUTO_CREATE
|
||||
BIND_AUTO_CREATE
|
||||
)
|
||||
}
|
||||
|
||||
|
|
@ -45,4 +48,4 @@ abstract class BaseEuiccAccessActivity : AppCompatActivity() {
|
|||
* When called, euiccChannelManager is guaranteed to have been initialized
|
||||
*/
|
||||
abstract fun onInit()
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -9,7 +9,7 @@ import androidx.fragment.app.DialogFragment
|
|||
import com.google.android.material.color.DynamicColors
|
||||
import im.angry.openeuicc.common.R
|
||||
|
||||
abstract class BaseMaterialDialogFragment: DialogFragment() {
|
||||
abstract class BaseMaterialDialogFragment : DialogFragment() {
|
||||
override fun onGetLayoutInflater(savedInstanceState: Bundle?): LayoutInflater {
|
||||
val inflater = super.onGetLayoutInflater(savedInstanceState)
|
||||
val wrappedContext = ContextThemeWrapper(requireContext(), R.style.Theme_OpenEUICC)
|
||||
|
|
@ -23,4 +23,4 @@ abstract class BaseMaterialDialogFragment: DialogFragment() {
|
|||
it.window?.setBackgroundDrawableResource(R.drawable.dialog_background)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,40 +0,0 @@
|
|||
package im.angry.openeuicc.ui
|
||||
|
||||
import androidx.lifecycle.lifecycleScope
|
||||
import im.angry.openeuicc.util.*
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
|
||||
class DirectProfileDownloadActivity : BaseEuiccAccessActivity(), SlotSelectFragment.SlotSelectedListener, OpenEuiccContextMarker {
|
||||
override fun onInit() {
|
||||
lifecycleScope.launch {
|
||||
val knownChannels = withContext(Dispatchers.IO) {
|
||||
euiccChannelManager.enumerateEuiccChannels()
|
||||
}
|
||||
|
||||
when {
|
||||
knownChannels.isEmpty() -> {
|
||||
finish()
|
||||
}
|
||||
knownChannels.hasMultipleChips -> {
|
||||
SlotSelectFragment.newInstance(knownChannels.sortedBy { it.logicalSlotId })
|
||||
.show(supportFragmentManager, SlotSelectFragment.TAG)
|
||||
}
|
||||
else -> {
|
||||
// If the device has only one eSIM "chip" (but may be mapped to multiple slots),
|
||||
// we can skip the slot selection dialog since there is only one chip to save to.
|
||||
onSlotSelected(knownChannels[0].slotId,
|
||||
knownChannels[0].portId)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun onSlotSelected(slotId: Int, portId: Int) {
|
||||
ProfileDownloadFragment.newInstance(slotId, portId, finishWhenDone = true)
|
||||
.show(supportFragmentManager, ProfileDownloadFragment.TAG)
|
||||
}
|
||||
|
||||
override fun onSlotSelectCancelled() = finish()
|
||||
}
|
||||
|
|
@ -0,0 +1,236 @@
|
|||
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.Dispatchers
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
import net.typeblog.lpac_jni.impl.PKID_GSMA_LIVE_CI
|
||||
import net.typeblog.lpac_jni.impl.PKID_GSMA_TEST_CI
|
||||
|
||||
// https://euicc-manual.osmocom.org/docs/pki/eum/accredited.json
|
||||
// ref: <https://regex101.com/r/5FFz8u>
|
||||
private val RE_SAS = Regex(
|
||||
"""^[A-Z]{2}-[A-Z]{2}(?:-UP)?-\d{4}T?(?:-\d+)?T?$""",
|
||||
setOf(RegexOption.IGNORE_CASE),
|
||||
)
|
||||
|
||||
class EuiccInfoActivity : BaseEuiccAccessActivity(), OpenEuiccContextMarker {
|
||||
companion object {
|
||||
private val YES_NO = Pair(R.string.euicc_info_yes, R.string.euicc_info_no)
|
||||
}
|
||||
|
||||
private lateinit var swipeRefresh: SwipeRefreshLayout
|
||||
private lateinit var infoList: RecyclerView
|
||||
|
||||
private var logicalSlotId: Int = -1
|
||||
private var seId: EuiccChannel.SecureElementId = EuiccChannel.SecureElementId.DEFAULT
|
||||
|
||||
data class Item(
|
||||
@get:StringRes
|
||||
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))
|
||||
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)
|
||||
seId = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
|
||||
intent.getParcelableExtra("seId", EuiccChannel.SecureElementId::class.java)
|
||||
} else {
|
||||
@Suppress("DEPRECATION")
|
||||
intent.getParcelableExtra("seId")
|
||||
} ?: EuiccChannel.SecureElementId.DEFAULT
|
||||
|
||||
setChannelTitle(
|
||||
if (logicalSlotId == EuiccChannelManager.USB_CHANNEL_ID)
|
||||
getString(R.string.channel_name_format_usb) else
|
||||
appContainer.customizableTextProvider.formatNonUsbChannelName(logicalSlotId)
|
||||
)
|
||||
|
||||
swipeRefresh.setOnRefreshListener { refresh() }
|
||||
|
||||
setupRootViewSystemBarInsets(
|
||||
window.decorView.rootView, arrayOf(
|
||||
this::activityToolbarInsetHandler,
|
||||
mainViewPaddingInsetHandler(infoList)
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
override fun onOptionsItemSelected(item: MenuItem): Boolean = when (item.itemId) {
|
||||
android.R.id.home -> {
|
||||
finish()
|
||||
true
|
||||
}
|
||||
|
||||
else -> super.onOptionsItemSelected(item)
|
||||
}
|
||||
|
||||
private fun setChannelTitle(title: CharSequence) {
|
||||
super.setTitle(getString(R.string.euicc_info_activity_title, title))
|
||||
}
|
||||
|
||||
override fun onInit() {
|
||||
refresh()
|
||||
}
|
||||
|
||||
private fun refresh() {
|
||||
swipeRefresh.isRefreshing = true
|
||||
|
||||
lifecycleScope.launch {
|
||||
euiccChannelManager.withEuiccChannel(logicalSlotId, seId) { channel ->
|
||||
if (channel.hasMultipleSE) {
|
||||
withContext(Dispatchers.Main) {
|
||||
val title = if (logicalSlotId == EuiccChannelManager.USB_CHANNEL_ID) {
|
||||
getString(R.string.channel_name_format_usb_se, seId.id)
|
||||
} else {
|
||||
appContainer.customizableTextProvider.formatNonUsbChannelNameWithSeId(logicalSlotId, seId)
|
||||
}
|
||||
setChannelTitle(title)
|
||||
}
|
||||
}
|
||||
|
||||
val items = buildEuiccInfoItems(channel)
|
||||
|
||||
withContext(Dispatchers.Main) {
|
||||
(infoList.adapter!! as EuiccInfoAdapter).euiccInfoItems = items
|
||||
}
|
||||
}
|
||||
|
||||
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))
|
||||
if (!channel.isdrAid.contentEquals(EUICC_DEFAULT_ISDR_AID.decodeHex())) {
|
||||
// Only show if it's not the default ISD-R AID
|
||||
add(Item(R.string.euicc_info_isdr_aid, channel.isdrAid.encodeHex()))
|
||||
}
|
||||
channel.tryParseEuiccVendorInfo()?.let { vendorInfo ->
|
||||
// @formatter:off
|
||||
vendorInfo.skuName?.let { add(Item(R.string.euicc_info_sku, it)) }
|
||||
vendorInfo.serialNumber?.let { add(Item(R.string.euicc_info_sn, it, copiedToastResId = R.string.toast_sn_copied)) }
|
||||
vendorInfo.firmwareVersion?.let { add(Item(R.string.euicc_info_fw_ver, it)) }
|
||||
// @formatter:on
|
||||
}
|
||||
channel.lpa.euiccInfo2?.let { info ->
|
||||
add(Item(R.string.euicc_info_sgp22_version, info.sgp22Version.toString()))
|
||||
info.sasAccreditationNumber.trim().takeIf(RE_SAS::matches)
|
||||
?.let { add(Item(R.string.euicc_info_sas_accreditation_number, it.uppercase())) }
|
||||
|
||||
val nvramText = buildString {
|
||||
append(formatFreeSpace(info.freeNvram))
|
||||
append(' ')
|
||||
append(getString(R.string.euicc_info_free_nvram_hint))
|
||||
}
|
||||
add(Item(R.string.euicc_info_free_nvram, nvramText))
|
||||
}
|
||||
channel.lpa.euiccInfo2?.euiccCiPKIdListForSigning.orEmpty().let { signers ->
|
||||
// SGP.28 v1.0, eSIM CI Registration Criteria (Page 5 of 9, 2019-10-24)
|
||||
// https://www.gsma.com/newsroom/wp-content/uploads/SGP.28-v1.0.pdf#page=5
|
||||
// FS.27 v2.0, Security Guidelines for UICC Profiles (Page 25 of 27, 2024-01-30)
|
||||
// https://www.gsma.com/solutions-and-impact/technologies/security/wp-content/uploads/2024/01/FS.27-Security-Guidelines-for-UICC-Credentials-v2.0-FINAL-23-July.pdf#page=25
|
||||
val resId = when {
|
||||
signers.isEmpty() -> R.string.euicc_info_unknown // the case is not mp, but it is not common
|
||||
PKID_GSMA_LIVE_CI.any(signers::contains) -> R.string.euicc_info_ci_gsma_live
|
||||
PKID_GSMA_TEST_CI.any(signers::contains) -> R.string.euicc_info_ci_gsma_test
|
||||
else -> R.string.euicc_info_ci_unknown
|
||||
}
|
||||
add(Item(R.string.euicc_info_ci_type, getString(resId)))
|
||||
}
|
||||
val atr = channel.atr?.encodeHex() ?: getString(R.string.euicc_info_unavailable)
|
||||
add(Item(R.string.euicc_info_atr, atr, copiedToastResId = R.string.toast_atr_copied))
|
||||
}
|
||||
|
||||
@Suppress("SameParameterValue")
|
||||
private fun formatByBoolean(b: Boolean, res: Pair<Int, Int>): String =
|
||||
getString(if (b) res.first else res.second)
|
||||
|
||||
inner class EuiccInfoViewHolder(root: View) : ViewHolder(root) {
|
||||
private val title: TextView = root.requireViewById(R.id.euicc_info_title)
|
||||
private val content: TextView = root.requireViewById(R.id.euicc_info_content)
|
||||
private var copiedToastResId: Int? = null
|
||||
|
||||
init {
|
||||
root.setOnClickListener {
|
||||
if (copiedToastResId != null) {
|
||||
val label = title.text.toString()
|
||||
getSystemService(ClipboardManager::class.java)!!
|
||||
.setPrimaryClip(ClipData.newPlainText(label, content.text))
|
||||
if (Build.VERSION.SDK_INT <= Build.VERSION_CODES.S_V2) {
|
||||
Toast.makeText(
|
||||
this@EuiccInfoActivity,
|
||||
copiedToastResId!!,
|
||||
Toast.LENGTH_SHORT
|
||||
).show()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun bind(item: Item) {
|
||||
copiedToastResId = item.copiedToastResId
|
||||
title.setText(item.titleResId)
|
||||
content.text = item.content ?: getString(R.string.euicc_info_unknown)
|
||||
}
|
||||
}
|
||||
|
||||
inner class EuiccInfoAdapter : RecyclerView.Adapter<EuiccInfoViewHolder>() {
|
||||
var euiccInfoItems: List<Item> = listOf()
|
||||
@SuppressLint("NotifyDataSetChanged")
|
||||
set(newVal) {
|
||||
field = newVal
|
||||
notifyDataSetChanged()
|
||||
}
|
||||
|
||||
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): EuiccInfoViewHolder {
|
||||
val root = LayoutInflater.from(parent.context)
|
||||
.inflate(R.layout.euicc_info_item, parent, false)
|
||||
return EuiccInfoViewHolder(root)
|
||||
}
|
||||
|
||||
override fun getItemCount(): Int = euiccInfoItems.size
|
||||
|
||||
override fun onBindViewHolder(holder: EuiccInfoViewHolder, position: Int) {
|
||||
holder.bind(euiccInfoItems[position])
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -4,9 +4,9 @@ import android.annotation.SuppressLint
|
|||
import android.content.ClipData
|
||||
import android.content.ClipboardManager
|
||||
import android.content.Intent
|
||||
import android.os.Build
|
||||
import android.os.Bundle
|
||||
import android.text.method.PasswordTransformationMethod
|
||||
import android.util.Log
|
||||
import android.view.LayoutInflater
|
||||
import android.view.Menu
|
||||
import android.view.MenuInflater
|
||||
|
|
@ -19,36 +19,47 @@ import android.widget.PopupMenu
|
|||
import android.widget.TextView
|
||||
import android.widget.Toast
|
||||
import androidx.appcompat.app.AlertDialog
|
||||
import androidx.core.view.isVisible
|
||||
import androidx.core.view.updateLayoutParams
|
||||
import androidx.fragment.app.Fragment
|
||||
import androidx.lifecycle.lifecycleScope
|
||||
import androidx.recyclerview.widget.LinearLayoutManager
|
||||
import androidx.recyclerview.widget.RecyclerView
|
||||
import androidx.swiperefreshlayout.widget.SwipeRefreshLayout
|
||||
import com.google.android.material.floatingactionbutton.FloatingActionButton
|
||||
import net.typeblog.lpac_jni.LocalProfileInfo
|
||||
import im.angry.openeuicc.common.R
|
||||
import im.angry.openeuicc.core.EuiccChannelManager
|
||||
import im.angry.openeuicc.core.EuiccChannel
|
||||
import im.angry.openeuicc.service.EuiccChannelManagerService
|
||||
import im.angry.openeuicc.service.EuiccChannelManagerService.Companion.waitDone
|
||||
import im.angry.openeuicc.ui.wizard.DownloadWizardActivity
|
||||
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.withContext
|
||||
import net.typeblog.lpac_jni.LocalProfileInfo
|
||||
|
||||
open class EuiccManagementFragment : Fragment(), EuiccProfilesChangedListener,
|
||||
EuiccChannelFragmentMarker {
|
||||
companion object {
|
||||
const val TAG = "EuiccManagementFragment"
|
||||
|
||||
fun newInstance(slotId: Int, portId: Int): EuiccManagementFragment =
|
||||
newInstanceEuicc(EuiccManagementFragment::class.java, slotId, portId)
|
||||
fun newInstance(
|
||||
slotId: Int,
|
||||
portId: Int,
|
||||
seId: EuiccChannel.SecureElementId
|
||||
): EuiccManagementFragment =
|
||||
newInstanceEuicc(EuiccManagementFragment::class.java, slotId, portId, seId)
|
||||
}
|
||||
|
||||
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 var enabledProfile: LocalProfileInfo? = null
|
||||
|
||||
private val adapter = EuiccProfileAdapter()
|
||||
|
||||
|
|
@ -60,6 +71,8 @@ open class EuiccManagementFragment : Fragment(), EuiccProfilesChangedListener,
|
|||
// This gives us access to the "latest" state without having to launch coroutines
|
||||
private lateinit var disableSafeguardFlow: StateFlow<Boolean>
|
||||
|
||||
private lateinit var unfilteredProfileListFlow: StateFlow<Boolean>
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
setHasOptionsMenu(true)
|
||||
|
|
@ -76,6 +89,25 @@ open class EuiccManagementFragment : Fragment(), EuiccProfilesChangedListener,
|
|||
fab = view.requireViewById(R.id.fab)
|
||||
profileList = view.requireViewById(R.id.profile_list)
|
||||
|
||||
val origFabMarginRight = (fab.layoutParams as ViewGroup.MarginLayoutParams).rightMargin
|
||||
val origFabMarginBottom = (fab.layoutParams as ViewGroup.MarginLayoutParams).bottomMargin
|
||||
|
||||
setupRootViewSystemBarInsets(
|
||||
view, arrayOf(
|
||||
mainViewPaddingInsetHandler(profileList),
|
||||
{ insets ->
|
||||
fab.updateLayoutParams<ViewGroup.MarginLayoutParams> {
|
||||
rightMargin = origFabMarginRight + insets.right
|
||||
bottomMargin = origFabMarginBottom + insets.bottom
|
||||
}
|
||||
}
|
||||
))
|
||||
|
||||
profileList.addOnScrollListener(object : RecyclerView.OnScrollListener() {
|
||||
override fun onScrollStateChanged(view: RecyclerView, newState: Int) =
|
||||
if (newState == RecyclerView.SCROLL_STATE_IDLE) fab.show() else fab.hide()
|
||||
})
|
||||
|
||||
return view
|
||||
}
|
||||
|
||||
|
|
@ -87,10 +119,13 @@ open class EuiccManagementFragment : Fragment(), EuiccProfilesChangedListener,
|
|||
LinearLayoutManager(view.context, LinearLayoutManager.VERTICAL, false)
|
||||
|
||||
fab.setOnClickListener {
|
||||
ProfileDownloadFragment.newInstance(slotId, portId)
|
||||
.show(childFragmentManager, ProfileDownloadFragment.TAG)
|
||||
val intent = DownloadWizardActivity.newIntent(requireContext(), slotId, seId)
|
||||
startActivity(intent)
|
||||
}
|
||||
}
|
||||
|
||||
override fun onStart() {
|
||||
super.onStart()
|
||||
refresh()
|
||||
}
|
||||
|
||||
|
|
@ -103,19 +138,44 @@ 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 =
|
||||
enabledProfile == null
|
||||
}
|
||||
|
||||
else -> super.onOptionsItemSelected(item)
|
||||
override fun onOptionsItemSelected(item: MenuItem) = when (item.itemId) {
|
||||
R.id.show_notifications -> {
|
||||
Intent(requireContext(), NotificationsActivity::class.java).apply {
|
||||
putExtra("logicalSlotId", logicalSlotId)
|
||||
putExtra("seId", seId)
|
||||
startActivity(this)
|
||||
}
|
||||
true
|
||||
}
|
||||
|
||||
R.id.euicc_info -> {
|
||||
Intent(requireContext(), EuiccInfoActivity::class.java).apply {
|
||||
putExtra("logicalSlotId", logicalSlotId)
|
||||
putExtra("seId", seId)
|
||||
startActivity(this)
|
||||
}
|
||||
true
|
||||
}
|
||||
|
||||
R.id.euicc_memory_reset -> {
|
||||
EuiccMemoryResetFragment.newInstance(slotId, portId, seId, eid)
|
||||
.show(childFragmentManager, EuiccMemoryResetFragment.TAG)
|
||||
true
|
||||
}
|
||||
|
||||
else -> super.onOptionsItemSelected(item)
|
||||
}
|
||||
|
||||
protected open suspend fun onCreateFooterViews(
|
||||
parent: ViewGroup,
|
||||
profiles: List<LocalProfileInfo>
|
||||
|
|
@ -127,92 +187,76 @@ open class EuiccManagementFragment : Fragment(), EuiccProfilesChangedListener,
|
|||
listOf()
|
||||
}
|
||||
|
||||
@SuppressLint("NotifyDataSetChanged")
|
||||
private fun refresh() {
|
||||
if (invalid) return
|
||||
swipeRefresh.isRefreshing = true
|
||||
|
||||
lifecycleScope.launch {
|
||||
if (!this@EuiccManagementFragment::disableSafeguardFlow.isInitialized) {
|
||||
disableSafeguardFlow =
|
||||
preferenceRepository.disableSafeguardFlow.stateIn(lifecycleScope)
|
||||
}
|
||||
|
||||
val profiles = withContext(Dispatchers.IO) {
|
||||
euiccChannelManager.notifyEuiccProfilesChanged(channel.logicalSlotId)
|
||||
channel.lpa.profiles.operational
|
||||
}
|
||||
|
||||
withContext(Dispatchers.Main) {
|
||||
adapter.profiles = profiles
|
||||
adapter.footerViews = onCreateFooterViews(profileList, profiles)
|
||||
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
|
||||
enabledProfile = channel.lpa.profiles.enabled
|
||||
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) {
|
||||
val resId = R.string.toast_profile_enable_failed
|
||||
Toast.makeText(context, resId, Toast.LENGTH_LONG).show()
|
||||
}
|
||||
|
||||
private fun enableOrDisableProfile(iccid: String, enable: Boolean) {
|
||||
swipeRefresh.isRefreshing = true
|
||||
fab.isEnabled = false
|
||||
|
||||
lifecycleScope.launch {
|
||||
beginTrackedOperation {
|
||||
val (res, refreshed) =
|
||||
if (!channel.lpa.switchProfile(iccid, enable, refresh = true)) {
|
||||
// Sometimes, we *can* enable or disable the profile, but we cannot
|
||||
// send the refresh command to the modem because the profile somehow
|
||||
// makes the modem "busy". In this case, we can still switch by setting
|
||||
// refresh to false, but then the switch cannot take effect until the
|
||||
// user resets the modem manually by toggling airplane mode or rebooting.
|
||||
Pair(channel.lpa.switchProfile(iccid, enable, refresh = false), false)
|
||||
} else {
|
||||
Pair(true, true)
|
||||
}
|
||||
ensureEuiccChannelManager()
|
||||
euiccChannelManagerService.waitForForegroundTask()
|
||||
|
||||
if (!res) {
|
||||
Log.d(TAG, "Failed to enable / disable profile $iccid")
|
||||
withContext(Dispatchers.Main) {
|
||||
Toast.makeText(
|
||||
context,
|
||||
R.string.toast_profile_enable_failed,
|
||||
Toast.LENGTH_LONG
|
||||
).show()
|
||||
}
|
||||
return@beginTrackedOperation false
|
||||
}
|
||||
val err = euiccChannelManagerService
|
||||
.launchProfileSwitchTask(
|
||||
slotId, portId, seId, iccid, enable,
|
||||
reconnectTimeoutMillis = 30 * 1000
|
||||
)
|
||||
.waitDone()
|
||||
|
||||
if (!refreshed && !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()
|
||||
}
|
||||
}
|
||||
return@beginTrackedOperation true
|
||||
}
|
||||
|
||||
if (!isUsb) {
|
||||
try {
|
||||
euiccChannelManager.waitForReconnect(
|
||||
slotId,
|
||||
portId,
|
||||
timeoutMillis = 30 * 1000
|
||||
)
|
||||
} catch (e: TimeoutCancellationException) {
|
||||
when (err) {
|
||||
null -> {}
|
||||
is EuiccChannelManagerService.SwitchingProfilesRefreshException -> {
|
||||
// This is only really fatal for internal eSIMs
|
||||
if (!isUsb) {
|
||||
withContext(Dispatchers.Main) {
|
||||
// Prevent this Fragment from being used again
|
||||
invalid = true
|
||||
// Timed out waiting for SIM to come back online, we can no longer assume that the LPA is still valid
|
||||
AlertDialog.Builder(requireContext()).apply {
|
||||
setMessage(R.string.enable_disable_timeout)
|
||||
setMessage(R.string.profile_switch_did_not_refresh)
|
||||
setPositiveButton(android.R.string.ok) { dialog, _ ->
|
||||
dialog.dismiss()
|
||||
requireActivity().finish()
|
||||
|
|
@ -223,30 +267,50 @@ open class EuiccManagementFragment : Fragment(), EuiccProfilesChangedListener,
|
|||
show()
|
||||
}
|
||||
}
|
||||
return@beginTrackedOperation false
|
||||
}
|
||||
}
|
||||
|
||||
preferenceRepository.notificationSwitchFlow.first()
|
||||
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
|
||||
}
|
||||
}
|
||||
|
||||
protected open fun populatePopupWithProfileActions(popup: PopupMenu, profile: LocalProfileInfo) {
|
||||
protected open fun populatePopupWithProfileActions(
|
||||
popup: PopupMenu,
|
||||
profile: LocalProfileInfo
|
||||
) {
|
||||
popup.inflate(R.menu.profile_options)
|
||||
if (profile.isEnabled) {
|
||||
popup.menu.findItem(R.id.enable).isVisible = false
|
||||
popup.menu.findItem(R.id.delete).isVisible = false
|
||||
if (!profile.isEnabled) return
|
||||
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
|
||||
}
|
||||
}
|
||||
// We hide the disable option by default to avoid "bricking" some cards that won't get
|
||||
// recognized again by the phone's modem. However, we don't have that worry if we are
|
||||
// accessing it through a USB card reader, or when the user explicitly opted in
|
||||
if (!isUsb && !disableSafeguardFlow.value) return
|
||||
popup.menu.findItem(R.id.disable).isVisible = true
|
||||
}
|
||||
|
||||
sealed class ViewHolder(root: View) : RecyclerView.ViewHolder(root) {
|
||||
|
|
@ -256,12 +320,12 @@ 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())) {
|
||||
inner class FooterViewHolder : ViewHolder(FrameLayout(requireContext())) {
|
||||
init {
|
||||
itemView.layoutParams = ViewGroup.LayoutParams(
|
||||
ViewGroup.LayoutParams.MATCH_PARENT,
|
||||
|
|
@ -284,7 +348,10 @@ open class EuiccManagementFragment : Fragment(), EuiccProfilesChangedListener,
|
|||
private val name: TextView = root.requireViewById(R.id.name)
|
||||
private val state: TextView = root.requireViewById(R.id.state)
|
||||
private val provider: TextView = root.requireViewById(R.id.provider)
|
||||
private val profileClassLabel: TextView = root.requireViewById(R.id.profile_class_label)
|
||||
private val profileClass: TextView = root.requireViewById(R.id.profile_class)
|
||||
private val profileMenu: ImageButton = root.requireViewById(R.id.profile_menu)
|
||||
private val profileSeqNumber: TextView = root.requireViewById(R.id.profile_sequence_number)
|
||||
|
||||
init {
|
||||
iccid.setOnClickListener {
|
||||
|
|
@ -296,17 +363,21 @@ open class EuiccManagementFragment : Fragment(), EuiccProfilesChangedListener,
|
|||
}
|
||||
|
||||
iccid.setOnLongClickListener {
|
||||
requireContext().getSystemService(ClipboardManager::class.java)
|
||||
requireContext().getSystemService(ClipboardManager::class.java)!!
|
||||
.setPrimaryClip(ClipData.newPlainText("iccid", iccid.text))
|
||||
Toast.makeText(requireContext(), R.string.toast_iccid_copied, Toast.LENGTH_SHORT)
|
||||
if (Build.VERSION.SDK_INT <= Build.VERSION_CODES.S_V2) Toast
|
||||
.makeText(requireContext(), R.string.toast_iccid_copied, Toast.LENGTH_SHORT)
|
||||
.show()
|
||||
true
|
||||
}
|
||||
|
||||
profileMenu.setOnClickListener { showOptionsMenu() }
|
||||
profileMenu.setOnClickListener {
|
||||
showOptionsMenu()
|
||||
}
|
||||
}
|
||||
|
||||
private lateinit var profile: LocalProfileInfo
|
||||
private var canEnable: Boolean = false
|
||||
|
||||
fun setProfile(profile: LocalProfileInfo) {
|
||||
this.profile = profile
|
||||
|
|
@ -314,16 +385,39 @@ open class EuiccManagementFragment : Fragment(), EuiccProfilesChangedListener,
|
|||
|
||||
state.setText(
|
||||
if (profile.isEnabled) {
|
||||
R.string.enabled
|
||||
R.string.profile_state_enabled
|
||||
} else {
|
||||
R.string.disabled
|
||||
R.string.profile_state_disabled
|
||||
}
|
||||
)
|
||||
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()
|
||||
}
|
||||
|
||||
fun setProfileSequenceNumber(index: Int) {
|
||||
profileSeqNumber.text = root.context.getString(
|
||||
R.string.profile_sequence_number_format,
|
||||
index,
|
||||
)
|
||||
}
|
||||
|
||||
fun setEnabledProfile(enabledProfile: LocalProfileInfo?) {
|
||||
// cannot cross profile class enable profile
|
||||
// e.g: testing -> operational or operational -> testing
|
||||
canEnable = enabledProfile == null ||
|
||||
enabledProfile.profileClass == profile.profileClass
|
||||
}
|
||||
|
||||
private fun showOptionsMenu() {
|
||||
// Prevent users from doing multiple things at once
|
||||
if (invalid || swipeRefresh.isRefreshing) return
|
||||
|
|
@ -338,23 +432,45 @@ open class EuiccManagementFragment : Fragment(), EuiccProfilesChangedListener,
|
|||
private fun onMenuItemClicked(item: MenuItem): Boolean =
|
||||
when (item.itemId) {
|
||||
R.id.enable -> {
|
||||
enableOrDisableProfile(profile.iccid, true)
|
||||
if (canEnable) {
|
||||
enableOrDisableProfile(profile.iccid, true)
|
||||
} else {
|
||||
val resId = R.string.toast_profile_enable_cross_class
|
||||
Toast.makeText(requireContext(), resId, Toast.LENGTH_LONG)
|
||||
.show()
|
||||
}
|
||||
true
|
||||
}
|
||||
|
||||
R.id.disable -> {
|
||||
enableOrDisableProfile(profile.iccid, false)
|
||||
true
|
||||
}
|
||||
|
||||
R.id.rename -> {
|
||||
ProfileRenameFragment.newInstance(slotId, portId, profile.iccid, profile.displayName)
|
||||
ProfileRenameFragment.newInstance(
|
||||
slotId,
|
||||
portId,
|
||||
seId,
|
||||
profile.iccid,
|
||||
profile.displayName
|
||||
)
|
||||
.show(childFragmentManager, ProfileRenameFragment.TAG)
|
||||
true
|
||||
}
|
||||
|
||||
R.id.delete -> {
|
||||
ProfileDeleteFragment.newInstance(slotId, portId, profile.iccid, profile.displayName)
|
||||
ProfileDeleteFragment.newInstance(
|
||||
slotId,
|
||||
portId,
|
||||
seId,
|
||||
profile.iccid,
|
||||
profile.displayName
|
||||
)
|
||||
.show(childFragmentManager, ProfileDeleteFragment.TAG)
|
||||
true
|
||||
}
|
||||
|
||||
else -> false
|
||||
}
|
||||
}
|
||||
|
|
@ -366,9 +482,11 @@ open class EuiccManagementFragment : Fragment(), EuiccProfilesChangedListener,
|
|||
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): ViewHolder =
|
||||
when (ViewHolder.Type.fromInt(viewType)) {
|
||||
ViewHolder.Type.PROFILE -> {
|
||||
val view = LayoutInflater.from(parent.context).inflate(R.layout.euicc_profile, parent, false)
|
||||
val view = LayoutInflater.from(parent.context)
|
||||
.inflate(R.layout.euicc_profile, parent, false)
|
||||
ProfileViewHolder(view)
|
||||
}
|
||||
|
||||
ViewHolder.Type.FOOTER -> {
|
||||
FooterViewHolder()
|
||||
}
|
||||
|
|
@ -379,9 +497,11 @@ open class EuiccManagementFragment : Fragment(), EuiccProfilesChangedListener,
|
|||
position < profiles.size -> {
|
||||
ViewHolder.Type.PROFILE.value
|
||||
}
|
||||
|
||||
position >= profiles.size && position < profiles.size + footerViews.size -> {
|
||||
ViewHolder.Type.FOOTER.value
|
||||
}
|
||||
|
||||
else -> -1
|
||||
}
|
||||
|
||||
|
|
@ -389,7 +509,10 @@ open class EuiccManagementFragment : Fragment(), EuiccProfilesChangedListener,
|
|||
when (holder) {
|
||||
is ProfileViewHolder -> {
|
||||
holder.setProfile(profiles[position])
|
||||
holder.setEnabledProfile(profiles.enabled)
|
||||
holder.setProfileSequenceNumber(position + 1)
|
||||
}
|
||||
|
||||
is FooterViewHolder -> {
|
||||
holder.attach(footerViews[position - profiles.size])
|
||||
}
|
||||
|
|
@ -404,4 +527,4 @@ open class EuiccManagementFragment : Fragment(), EuiccProfilesChangedListener,
|
|||
|
||||
override fun getItemCount(): Int = profiles.size + footerViews.size
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,119 @@
|
|||
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.lifecycle.lifecycleScope
|
||||
import im.angry.openeuicc.common.R
|
||||
import im.angry.openeuicc.core.EuiccChannel
|
||||
import im.angry.openeuicc.service.EuiccChannelManagerService.Companion.waitDone
|
||||
import im.angry.openeuicc.util.*
|
||||
import kotlinx.coroutines.flow.onStart
|
||||
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, seId: EuiccChannel.SecureElementId, eid: String) =
|
||||
newInstanceEuicc(EuiccMemoryResetFragment::class.java, slotId, portId, seId) {
|
||||
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, seId)
|
||||
.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,77 @@
|
|||
package im.angry.openeuicc.ui
|
||||
|
||||
import android.os.Bundle
|
||||
import android.text.Editable
|
||||
import android.view.Menu
|
||||
import android.view.MenuItem
|
||||
import android.widget.EditText
|
||||
import android.widget.Toast
|
||||
import androidx.activity.enableEdgeToEdge
|
||||
import androidx.appcompat.app.AppCompatActivity
|
||||
import androidx.lifecycle.lifecycleScope
|
||||
import im.angry.openeuicc.common.R
|
||||
import im.angry.openeuicc.util.*
|
||||
import kotlinx.coroutines.flow.collect
|
||||
import kotlinx.coroutines.flow.onEach
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
class IsdrAidListActivity : AppCompatActivity() {
|
||||
private lateinit var isdrAidListEditor: EditText
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
enableEdgeToEdge()
|
||||
super.onCreate(savedInstanceState)
|
||||
setContentView(R.layout.activity_isdr_aid_list)
|
||||
setSupportActionBar(requireViewById(R.id.toolbar))
|
||||
supportActionBar!!.setDisplayHomeAsUpEnabled(true)
|
||||
|
||||
isdrAidListEditor = requireViewById(R.id.isdr_aid_list_editor)
|
||||
|
||||
setupRootViewSystemBarInsets(
|
||||
window.decorView.rootView, arrayOf(
|
||||
this::activityToolbarInsetHandler,
|
||||
mainViewPaddingInsetHandler(isdrAidListEditor)
|
||||
)
|
||||
)
|
||||
|
||||
lifecycleScope.launch {
|
||||
preferenceRepository.isdrAidListFlow.onEach {
|
||||
isdrAidListEditor.text = Editable.Factory.getInstance().newEditable(it)
|
||||
}.collect()
|
||||
}
|
||||
}
|
||||
|
||||
override fun onCreateOptionsMenu(menu: Menu?): Boolean {
|
||||
menuInflater.inflate(R.menu.activity_isdr_aid_list, menu)
|
||||
return true
|
||||
}
|
||||
|
||||
override fun onOptionsItemSelected(item: MenuItem): Boolean =
|
||||
when (item.itemId) {
|
||||
R.id.save -> {
|
||||
lifecycleScope.launch {
|
||||
preferenceRepository.isdrAidListFlow.updatePreference(isdrAidListEditor.text.toString())
|
||||
Toast.makeText(
|
||||
this@IsdrAidListActivity,
|
||||
R.string.isdr_aid_list_saved,
|
||||
Toast.LENGTH_SHORT
|
||||
).show()
|
||||
}
|
||||
true
|
||||
}
|
||||
|
||||
R.id.reset -> {
|
||||
lifecycleScope.launch {
|
||||
preferenceRepository.isdrAidListFlow.removePreference()
|
||||
}
|
||||
true
|
||||
}
|
||||
|
||||
android.R.id.home -> {
|
||||
finish()
|
||||
true
|
||||
}
|
||||
|
||||
else -> super.onOptionsItemSelected(item)
|
||||
}
|
||||
}
|
||||
|
|
@ -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,17 +26,28 @@ class LogsActivity : AppCompatActivity() {
|
|||
private lateinit var logStr: String
|
||||
|
||||
private val saveLogs =
|
||||
registerForActivityResult(ActivityResultContracts.CreateDocument("text/plain")) { uri ->
|
||||
if (uri == null) return@registerForActivityResult
|
||||
if (!this::logStr.isInitialized) return@registerForActivityResult
|
||||
contentResolver.openFileDescriptor(uri, "w")?.use {
|
||||
FileOutputStream(it.fileDescriptor).use { os ->
|
||||
os.write(logStr.encodeToByteArray())
|
||||
}
|
||||
}
|
||||
}
|
||||
setupLogSaving(
|
||||
getLogFileName = {
|
||||
getString(
|
||||
R.string.logs_filename_template,
|
||||
SimpleDateFormat.getDateTimeInstance().format(Date())
|
||||
)
|
||||
},
|
||||
getLogText = ::buildLogText
|
||||
)
|
||||
|
||||
private fun buildLogText() = buildString {
|
||||
appendLine("Manufacturer: ${Build.MANUFACTURER}")
|
||||
appendLine("Brand: ${Build.BRAND}")
|
||||
appendLine("Model: ${Build.MODEL}")
|
||||
appendLine("SDK Version: ${Build.VERSION.SDK_INT}")
|
||||
appendLine("App Version: $selfAppVersion")
|
||||
appendLine("-".repeat(10))
|
||||
appendLine(logStr)
|
||||
}
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
enableEdgeToEdge()
|
||||
super.onCreate(savedInstanceState)
|
||||
setContentView(R.layout.activity_logs)
|
||||
setSupportActionBar(requireViewById(R.id.toolbar))
|
||||
|
|
@ -46,6 +57,13 @@ class LogsActivity : AppCompatActivity() {
|
|||
scrollView = requireViewById(R.id.scroll_view)
|
||||
logText = requireViewById(R.id.log_text)
|
||||
|
||||
setupRootViewSystemBarInsets(
|
||||
window.decorView.rootView, arrayOf(
|
||||
this::activityToolbarInsetHandler,
|
||||
mainViewPaddingInsetHandler(scrollView)
|
||||
)
|
||||
)
|
||||
|
||||
swipeRefresh.setOnRefreshListener {
|
||||
lifecycleScope.launch {
|
||||
reload()
|
||||
|
|
@ -66,12 +84,16 @@ class LogsActivity : AppCompatActivity() {
|
|||
}
|
||||
|
||||
override fun onOptionsItemSelected(item: MenuItem): Boolean = when (item.itemId) {
|
||||
R.id.save -> {
|
||||
saveLogs.launch(getString(R.string.logs_filename_template,
|
||||
SimpleDateFormat.getDateTimeInstance().format(Date())
|
||||
))
|
||||
android.R.id.home -> {
|
||||
finish()
|
||||
true
|
||||
}
|
||||
|
||||
R.id.save -> {
|
||||
saveLogs()
|
||||
true
|
||||
}
|
||||
|
||||
else -> super.onOptionsItemSelected(item)
|
||||
}
|
||||
|
||||
|
|
@ -91,4 +113,4 @@ class LogsActivity : AppCompatActivity() {
|
|||
scrollView.fullScroll(View.FOCUS_DOWN)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -5,7 +5,9 @@ import android.content.BroadcastReceiver
|
|||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.content.IntentFilter
|
||||
import android.content.pm.PackageManager
|
||||
import android.hardware.usb.UsbManager
|
||||
import android.os.Build
|
||||
import android.os.Bundle
|
||||
import android.telephony.TelephonyManager
|
||||
import android.util.Log
|
||||
|
|
@ -13,6 +15,10 @@ import android.view.Menu
|
|||
import android.view.MenuItem
|
||||
import android.view.View
|
||||
import android.widget.ProgressBar
|
||||
import androidx.activity.enableEdgeToEdge
|
||||
import androidx.core.content.pm.ShortcutInfoCompat
|
||||
import androidx.core.content.pm.ShortcutManagerCompat
|
||||
import androidx.core.graphics.drawable.IconCompat
|
||||
import androidx.fragment.app.Fragment
|
||||
import androidx.lifecycle.lifecycleScope
|
||||
import androidx.viewpager2.adapter.FragmentStateAdapter
|
||||
|
|
@ -20,8 +26,14 @@ import androidx.viewpager2.widget.ViewPager2
|
|||
import com.google.android.material.tabs.TabLayout
|
||||
import com.google.android.material.tabs.TabLayoutMediator
|
||||
import im.angry.openeuicc.common.R
|
||||
import im.angry.openeuicc.core.EuiccChannel
|
||||
import im.angry.openeuicc.core.EuiccChannelManager
|
||||
import im.angry.openeuicc.ui.wizard.DownloadWizardActivity
|
||||
import im.angry.openeuicc.util.*
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.flow.collect
|
||||
import kotlinx.coroutines.flow.first
|
||||
import kotlinx.coroutines.flow.onEach
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
|
||||
|
|
@ -29,6 +41,8 @@ import kotlinx.coroutines.withContext
|
|||
open class MainActivity : BaseEuiccAccessActivity(), OpenEuiccContextMarker {
|
||||
companion object {
|
||||
const val TAG = "MainActivity"
|
||||
|
||||
const val PERMISSION_REQUEST_CODE = 1000
|
||||
}
|
||||
|
||||
private lateinit var loadingProgress: ProgressBar
|
||||
|
|
@ -38,17 +52,30 @@ open class MainActivity : BaseEuiccAccessActivity(), OpenEuiccContextMarker {
|
|||
private var refreshing = false
|
||||
|
||||
private data class Page(
|
||||
val id: Long,
|
||||
val logicalSlotId: Int,
|
||||
val title: String,
|
||||
val createFragment: () -> Fragment
|
||||
)
|
||||
|
||||
private val pages: MutableList<Page> = mutableListOf()
|
||||
private var nextPageId = 0L
|
||||
|
||||
private fun newPage(
|
||||
logicalSlotId: Int,
|
||||
title: String,
|
||||
createFragment: () -> Fragment
|
||||
): Page = Page(nextPageId++, logicalSlotId, title, createFragment)
|
||||
|
||||
private val pagerAdapter by lazy {
|
||||
object : FragmentStateAdapter(this) {
|
||||
override fun getItemCount() = pages.size
|
||||
|
||||
override fun createFragment(position: Int): Fragment = pages[position].createFragment()
|
||||
|
||||
override fun getItemId(position: Int): Long = pages[position].id
|
||||
|
||||
override fun containsItem(itemId: Long): Boolean = pages.any { it.id == itemId }
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -64,6 +91,7 @@ open class MainActivity : BaseEuiccAccessActivity(), OpenEuiccContextMarker {
|
|||
|
||||
@SuppressLint("WrongConstant", "UnspecifiedRegisterReceiverFlag")
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
enableEdgeToEdge()
|
||||
super.onCreate(savedInstanceState)
|
||||
setContentView(R.layout.activity_main)
|
||||
setSupportActionBar(requireViewById(R.id.toolbar))
|
||||
|
|
@ -82,6 +110,12 @@ open class MainActivity : BaseEuiccAccessActivity(), OpenEuiccContextMarker {
|
|||
addAction(UsbManager.ACTION_USB_DEVICE_ATTACHED)
|
||||
addAction(UsbManager.ACTION_USB_DEVICE_DETACHED)
|
||||
})
|
||||
|
||||
setupRootViewSystemBarInsets(
|
||||
window.decorView.rootView, arrayOf(
|
||||
this::activityToolbarInsetHandler
|
||||
), consume = false
|
||||
)
|
||||
}
|
||||
|
||||
override fun onDestroy() {
|
||||
|
|
@ -97,13 +131,15 @@ open class MainActivity : BaseEuiccAccessActivity(), OpenEuiccContextMarker {
|
|||
override fun onOptionsItemSelected(item: MenuItem): Boolean =
|
||||
when (item.itemId) {
|
||||
R.id.settings -> {
|
||||
startActivity(Intent(this, SettingsActivity::class.java));
|
||||
startActivity(Intent(this, SettingsActivity::class.java))
|
||||
true
|
||||
}
|
||||
|
||||
R.id.reload -> {
|
||||
refresh()
|
||||
true
|
||||
}
|
||||
|
||||
else -> super.onOptionsItemSelected(item)
|
||||
}
|
||||
|
||||
|
|
@ -113,65 +149,103 @@ open class MainActivity : BaseEuiccAccessActivity(), OpenEuiccContextMarker {
|
|||
}
|
||||
}
|
||||
|
||||
private fun ensureNotificationPermissions() {
|
||||
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.TIRAMISU) return
|
||||
val permissions = arrayOf(android.Manifest.permission.POST_NOTIFICATIONS)
|
||||
if (permissions.all { checkSelfPermission(it) == PackageManager.PERMISSION_GRANTED }) return
|
||||
requestPermissions(permissions, PERMISSION_REQUEST_CODE)
|
||||
}
|
||||
|
||||
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
|
||||
|
||||
val knownChannels = withContext(Dispatchers.IO) {
|
||||
euiccChannelManager.enumerateEuiccChannels().onEach {
|
||||
Log.d(TAG, "slot ${it.slotId} port ${it.portId}")
|
||||
Log.d(TAG, it.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)
|
||||
}
|
||||
}
|
||||
// Prevent concurrent access with any running foreground task
|
||||
euiccChannelManagerService.waitForForegroundTask()
|
||||
|
||||
val (usbDevice, _) = withContext(Dispatchers.IO) {
|
||||
euiccChannelManager.enumerateUsbEuiccChannel()
|
||||
euiccChannelManager.tryOpenUsbEuiccChannel()
|
||||
}
|
||||
|
||||
withContext(Dispatchers.Main) {
|
||||
loadingProgress.visibility = View.GONE
|
||||
val newPages: MutableList<Page> = mutableListOf()
|
||||
|
||||
knownChannels.sortedBy { it.logicalSlotId }.forEach { channel ->
|
||||
pages.add(Page(
|
||||
getString(R.string.channel_name_format, channel.logicalSlotId)
|
||||
) { appContainer.uiComponentFactory.createEuiccManagementFragment(channel) })
|
||||
}
|
||||
euiccChannelManager.flowInternalEuiccPorts().onEach { (slotId, portId) ->
|
||||
Log.d(TAG, "slot $slotId port $portId")
|
||||
|
||||
// If USB readers exist, add them at the very last
|
||||
// We use a wrapper fragment to handle logic specific to USB readers
|
||||
usbDevice?.let {
|
||||
pages.add(Page(it.productName ?: getString(R.string.usb)) { UsbCcidReaderFragment() })
|
||||
}
|
||||
viewPager.visibility = View.VISIBLE
|
||||
euiccChannelManager.flowEuiccSecureElements(slotId, portId).onEach { seId ->
|
||||
euiccChannelManager.withEuiccChannel(slotId, portId, seId) { channel ->
|
||||
if (preferenceRepository.verboseLoggingFlow.first()) {
|
||||
Log.d(TAG, channel.lpa.eID)
|
||||
}
|
||||
// Request the system to refresh the list of profiles every time we start
|
||||
// Note that this is currently supposed to be no-op when unprivileged,
|
||||
// but it could change in the future
|
||||
euiccChannelManager.notifyEuiccProfilesChanged(channel.logicalSlotId)
|
||||
|
||||
if (pages.size > 1) {
|
||||
tabs.visibility = View.VISIBLE
|
||||
} else if (pages.isEmpty()) {
|
||||
pages.add(Page("") { appContainer.uiComponentFactory.createNoEuiccPlaceholderFragment() })
|
||||
}
|
||||
|
||||
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)
|
||||
val channelName = if (channel.hasMultipleSE) {
|
||||
appContainer.customizableTextProvider.formatNonUsbChannelNameWithSeId(
|
||||
channel.logicalSlotId,
|
||||
channel.seId
|
||||
)
|
||||
} else {
|
||||
appContainer.customizableTextProvider.formatNonUsbChannelName(channel.logicalSlotId)
|
||||
}
|
||||
newPages.add(newPage(channel.logicalSlotId, channelName) {
|
||||
appContainer.uiComponentFactory.createEuiccManagementFragment(
|
||||
slotId,
|
||||
portId,
|
||||
seId
|
||||
)
|
||||
})
|
||||
}
|
||||
} else {
|
||||
viewPager.currentItem = 0
|
||||
}
|
||||
}.collect()
|
||||
}.collect()
|
||||
|
||||
refreshing = false
|
||||
// If USB readers exist, add them at the very last
|
||||
// We use a wrapper fragment to handle logic specific to USB readers
|
||||
usbDevice?.let {
|
||||
newPages.add(newPage(EuiccChannelManager.USB_CHANNEL_ID, getString(R.string.channel_name_format_usb)) {
|
||||
UsbCcidReaderPermissionFragment()
|
||||
})
|
||||
}
|
||||
viewPager.visibility = View.VISIBLE
|
||||
|
||||
if (newPages.size > 1) {
|
||||
tabs.visibility = View.VISIBLE
|
||||
} else if (newPages.isEmpty()) {
|
||||
newPages.add(newPage(-1, "") {
|
||||
appContainer.uiComponentFactory.createNoEuiccPlaceholderFragment()
|
||||
})
|
||||
}
|
||||
|
||||
newPages.sortBy { it.logicalSlotId }
|
||||
|
||||
pages.clear()
|
||||
pages.addAll(newPages)
|
||||
|
||||
loadingProgress.visibility = View.GONE
|
||||
pagerAdapter.notifyDataSetChanged()
|
||||
// Reset the adapter so that the current view actually gets cleared
|
||||
// notifyDataSetChanged() doesn't cause the current view to be removed.
|
||||
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.isNotEmpty()) {
|
||||
ensureNotificationPermissions()
|
||||
}
|
||||
|
||||
ShortcutManagerCompat.setDynamicShortcuts(this, buildShortcuts().take(4))
|
||||
|
||||
refreshing = false
|
||||
}
|
||||
|
||||
private fun refresh(fromUsbEvent: Boolean = false) {
|
||||
|
|
@ -189,4 +263,44 @@ open class MainActivity : BaseEuiccAccessActivity(), OpenEuiccContextMarker {
|
|||
init(fromUsbEvent) // will set refreshing = false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
protected open fun buildShortcuts(): List<ShortcutInfoCompat> {
|
||||
val downloadShortcut = ShortcutInfoCompat.Builder(this, "download")
|
||||
.setShortLabel(getString(R.string.profile_download))
|
||||
.setIcon(IconCompat.createWithResource(this, R.drawable.ic_task_sim_card_download))
|
||||
.setIntent(DownloadWizardActivity.newIntent(this).apply { action = Intent.ACTION_VIEW })
|
||||
.build()
|
||||
return listOf(downloadShortcut)
|
||||
}
|
||||
|
||||
fun instantiateUsbTabs(seIds: List<EuiccChannel.SecureElementId>) {
|
||||
val existingUsbPageIndex = pages.indexOfFirst { it.logicalSlotId == EuiccChannelManager.USB_CHANNEL_ID }
|
||||
if (existingUsbPageIndex == -1) return
|
||||
|
||||
val usbPages =
|
||||
seIds.map { seId ->
|
||||
val name = if (seIds.size == 1) {
|
||||
getString(R.string.channel_name_format_usb)
|
||||
} else {
|
||||
getString(R.string.channel_name_format_usb_se, seId.id)
|
||||
}
|
||||
newPage(EuiccChannelManager.USB_CHANNEL_ID, name) {
|
||||
appContainer.uiComponentFactory.createEuiccManagementFragment(
|
||||
EuiccChannelManager.USB_CHANNEL_ID,
|
||||
0,
|
||||
seId
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// Add before removing to avoid out-of-bounds problems
|
||||
pages.addAll(existingUsbPageIndex, usbPages)
|
||||
// Remove the old USB reader page
|
||||
pages.removeAt(existingUsbPageIndex + usbPages.size)
|
||||
|
||||
if (pages.size > 1) {
|
||||
tabs.visibility = View.VISIBLE
|
||||
}
|
||||
pagerAdapter.notifyDataSetChanged()
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -4,15 +4,20 @@ import android.os.Bundle
|
|||
import android.view.LayoutInflater
|
||||
import android.view.View
|
||||
import android.view.ViewGroup
|
||||
import android.widget.TextView
|
||||
import androidx.fragment.app.Fragment
|
||||
import im.angry.openeuicc.common.R
|
||||
import im.angry.openeuicc.util.OpenEuiccContextMarker
|
||||
|
||||
class NoEuiccPlaceholderFragment : Fragment() {
|
||||
class NoEuiccPlaceholderFragment : Fragment(), OpenEuiccContextMarker {
|
||||
override fun onCreateView(
|
||||
inflater: LayoutInflater,
|
||||
container: ViewGroup?,
|
||||
savedInstanceState: Bundle?
|
||||
): View? {
|
||||
return inflater.inflate(R.layout.fragment_no_euicc_placeholder, container, false)
|
||||
val view = inflater.inflate(R.layout.fragment_no_euicc_placeholder, container, false)
|
||||
val textView = view.requireViewById<TextView>(R.id.no_euicc_placeholder)
|
||||
textView.text = appContainer.customizableTextProvider.noEuiccExplanation
|
||||
return view
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
package im.angry.openeuicc.ui
|
||||
|
||||
import android.annotation.SuppressLint
|
||||
import android.os.Build
|
||||
import android.os.Bundle
|
||||
import android.text.Html
|
||||
import android.view.ContextMenu
|
||||
|
|
@ -11,6 +12,7 @@ import android.view.MenuItem.OnMenuItemClickListener
|
|||
import android.view.View
|
||||
import android.view.ViewGroup
|
||||
import android.widget.TextView
|
||||
import androidx.activity.enableEdgeToEdge
|
||||
import androidx.appcompat.app.AlertDialog
|
||||
import androidx.core.view.forEach
|
||||
import androidx.lifecycle.lifecycleScope
|
||||
|
|
@ -27,42 +29,54 @@ import kotlinx.coroutines.launch
|
|||
import kotlinx.coroutines.withContext
|
||||
import net.typeblog.lpac_jni.LocalProfileNotification
|
||||
|
||||
class NotificationsActivity: BaseEuiccAccessActivity(), OpenEuiccContextMarker {
|
||||
class NotificationsActivity : BaseEuiccAccessActivity(), OpenEuiccContextMarker {
|
||||
private lateinit var swipeRefresh: SwipeRefreshLayout
|
||||
private lateinit var notificationList: RecyclerView
|
||||
private val notificationAdapter = NotificationAdapter()
|
||||
|
||||
private lateinit var euiccChannel: EuiccChannel
|
||||
private var logicalSlotId = -1
|
||||
private var seId = EuiccChannel.SecureElementId.DEFAULT
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
enableEdgeToEdge()
|
||||
super.onCreate(savedInstanceState)
|
||||
setContentView(R.layout.activity_notifications)
|
||||
setSupportActionBar(requireViewById(R.id.toolbar))
|
||||
supportActionBar!!.setDisplayHomeAsUpEnabled(true)
|
||||
}
|
||||
|
||||
override fun onInit() {
|
||||
euiccChannel = euiccChannelManager
|
||||
.findEuiccChannelBySlotBlocking(intent.getIntExtra("logicalSlotId", 0))!!
|
||||
|
||||
swipeRefresh = requireViewById(R.id.swipe_refresh)
|
||||
notificationList = requireViewById(R.id.recycler_view)
|
||||
|
||||
notificationList.layoutManager =
|
||||
LinearLayoutManager(this, LinearLayoutManager.VERTICAL, false)
|
||||
notificationList.addItemDecoration(DividerItemDecoration(this, LinearLayoutManager.VERTICAL))
|
||||
notificationList.adapter = notificationAdapter
|
||||
registerForContextMenu(notificationList)
|
||||
setupRootViewSystemBarInsets(
|
||||
window.decorView.rootView, arrayOf(
|
||||
this::activityToolbarInsetHandler,
|
||||
mainViewPaddingInsetHandler(notificationList)
|
||||
)
|
||||
)
|
||||
}
|
||||