Compare commits
No commits in common. "master" and "lpac" have entirely different histories.
344 changed files with 3029 additions and 17954 deletions
|
|
@ -1,22 +0,0 @@
|
|||
root = true
|
||||
|
||||
[*]
|
||||
charset = utf-8
|
||||
indent_size = 2
|
||||
indent_style = space
|
||||
end_of_line = lf
|
||||
insert_final_newline = true
|
||||
trim_trailing_whitespace = true
|
||||
max_line_length = 120
|
||||
|
||||
[*.{kt,java}]
|
||||
indent_size = 4
|
||||
|
||||
[*.gradle.kts]
|
||||
indent_size = 4
|
||||
|
||||
[*.xml]
|
||||
indent_size = 4
|
||||
|
||||
[.idea/**/*.xml]
|
||||
indent_size = 2
|
||||
|
|
@ -1,59 +0,0 @@
|
|||
name: Build Debug APKs
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- '*'
|
||||
tags:
|
||||
- '*'
|
||||
|
||||
jobs:
|
||||
build-debug:
|
||||
runs-on: [ docker, android-app-certs ]
|
||||
container:
|
||||
volumes:
|
||||
- android-app-keystore:/keystore
|
||||
steps:
|
||||
- name: Repository Checkout
|
||||
uses: https://gitea.angry.im/actions/checkout@v3
|
||||
with:
|
||||
submodules: recursive
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Decode Secret Signing Configuration
|
||||
uses: https://gitea.angry.im/actions/base64-to-file@v1
|
||||
with:
|
||||
fileName: keystore.properties
|
||||
fileDir: ${{ env.GITHUB_WORKSPACE }}
|
||||
encodedString: ${{ secrets.OPENEUICC_SIGNING_CONFIG }}
|
||||
|
||||
- name: Set up JDK 17
|
||||
uses: https://gitea.angry.im/actions/setup-java@v3
|
||||
with:
|
||||
java-version: '17'
|
||||
distribution: 'temurin'
|
||||
|
||||
- name: Setup Android SDK
|
||||
uses: https://gitea.angry.im/actions/setup-android@v3
|
||||
|
||||
- name: Build Debug APKs
|
||||
run: ./gradlew --no-daemon assembleDebug :app:assembleDebugMagiskModuleDir
|
||||
|
||||
- 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*-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,51 +0,0 @@
|
|||
name: Build Release
|
||||
|
||||
on:
|
||||
push:
|
||||
tags: '*'
|
||||
|
||||
jobs:
|
||||
release:
|
||||
runs-on: [ docker, android-app-certs ]
|
||||
container:
|
||||
volumes:
|
||||
- android-app-keystore:/keystore
|
||||
steps:
|
||||
- name: Repository Checkout
|
||||
uses: https://gitea.angry.im/actions/checkout@v3
|
||||
with:
|
||||
submodules: recursive
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Decode Secret Signing Configuration
|
||||
uses: https://gitea.angry.im/actions/base64-to-file@v1
|
||||
with:
|
||||
fileName: keystore.properties
|
||||
fileDir: ${{ env.GITHUB_WORKSPACE }}
|
||||
encodedString: ${{ secrets.OPENEUICC_SIGNING_CONFIG }}
|
||||
|
||||
- name: Set up JDK 17
|
||||
uses: https://gitea.angry.im/actions/setup-java@v3
|
||||
with:
|
||||
java-version: '17'
|
||||
distribution: 'temurin'
|
||||
|
||||
- name: Setup Android SDK
|
||||
uses: https://gitea.angry.im/actions/setup-android@v3
|
||||
|
||||
- name: Build Release APK (Unprivileged / EasyEUICC only)
|
||||
run: ./gradlew --no-daemon :app-unpriv:assembleRelease
|
||||
|
||||
- name: Copy Debug Symbols to Release Path
|
||||
run: cp app-unpriv/build/outputs/native-debug-symbols/release/native-debug-symbols.zip app-unpriv/build/outputs/apk/release/
|
||||
|
||||
- name: Create Release
|
||||
uses: https://gitea.angry.im/actions/forgejo-release@v1
|
||||
with:
|
||||
direction: upload
|
||||
release-dir: app-unpriv/build/outputs/apk/release
|
||||
url: https://gitea.angry.im
|
||||
token: ${{ secrets.FORGEJO_TOKEN }}
|
||||
# Release details are expected to be edited manually
|
||||
release-notes: TBD
|
||||
prerelease: 'true'
|
||||
24
.gitignore
vendored
24
.gitignore
vendored
|
|
@ -1,11 +1,17 @@
|
|||
/.gradle
|
||||
/captures
|
||||
|
||||
# Configuration files
|
||||
|
||||
/keystore.properties
|
||||
*.iml
|
||||
.gradle
|
||||
/local.properties
|
||||
|
||||
# macOS
|
||||
|
||||
/keystore.properties
|
||||
/.idea/caches
|
||||
/.idea/libraries
|
||||
/.idea/modules.xml
|
||||
/.idea/workspace.xml
|
||||
/.idea/navEditor.xml
|
||||
/.idea/assetWizardSettings.xml
|
||||
.DS_Store
|
||||
/build
|
||||
/captures
|
||||
.externalNativeBuild
|
||||
.cxx
|
||||
local.properties
|
||||
/libs/**/build
|
||||
|
|
|
|||
5
.gitmodules
vendored
5
.gitmodules
vendored
|
|
@ -1,6 +1,3 @@
|
|||
[submodule "libs/lpac-jni/src/main/jni/lpac"]
|
||||
path = libs/lpac-jni/src/main/jni/lpac
|
||||
url = https://github.com/estkme-group/lpac.git
|
||||
[submodule "libs/lpac-jni/src/main/jni/cJSON"]
|
||||
path = libs/lpac-jni/src/main/jni/cjson/cjson
|
||||
url = https://github.com/DaveGamble/cJSON
|
||||
url = https://github.com/estkme/lpac
|
||||
|
|
|
|||
10
.idea/.gitignore
generated
vendored
10
.idea/.gitignore
generated
vendored
|
|
@ -1,7 +1,3 @@
|
|||
*
|
||||
!/codeStyles/Project.xml
|
||||
!/codeStyles/codeStyleConfig.xml
|
||||
!/vcs.xml
|
||||
!/kotlinc.xml
|
||||
!/compiler.xml
|
||||
!/migrations.xml
|
||||
# Default ignored files
|
||||
/shelf/
|
||||
/workspace.xml
|
||||
|
|
|
|||
162
.idea/codeStyles/Project.xml
generated
162
.idea/codeStyles/Project.xml
generated
|
|
@ -1,162 +0,0 @@
|
|||
<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>
|
||||
<option name="CONTINUATION_INDENT_SIZE" value="4" />
|
||||
</indentOptions>
|
||||
<arrangement>
|
||||
<rules>
|
||||
<section>
|
||||
<rule>
|
||||
<match>
|
||||
<AND>
|
||||
<NAME>xmlns:android</NAME>
|
||||
<XML_ATTRIBUTE />
|
||||
<XML_NAMESPACE>^$</XML_NAMESPACE>
|
||||
</AND>
|
||||
</match>
|
||||
</rule>
|
||||
</section>
|
||||
<section>
|
||||
<rule>
|
||||
<match>
|
||||
<AND>
|
||||
<NAME>xmlns:.*</NAME>
|
||||
<XML_ATTRIBUTE />
|
||||
<XML_NAMESPACE>^$</XML_NAMESPACE>
|
||||
</AND>
|
||||
</match>
|
||||
<order>BY_NAME</order>
|
||||
</rule>
|
||||
</section>
|
||||
<section>
|
||||
<rule>
|
||||
<match>
|
||||
<AND>
|
||||
<NAME>.*:id</NAME>
|
||||
<XML_ATTRIBUTE />
|
||||
<XML_NAMESPACE>http://schemas.android.com/apk/res/android</XML_NAMESPACE>
|
||||
</AND>
|
||||
</match>
|
||||
</rule>
|
||||
</section>
|
||||
<section>
|
||||
<rule>
|
||||
<match>
|
||||
<AND>
|
||||
<NAME>.*:name</NAME>
|
||||
<XML_ATTRIBUTE />
|
||||
<XML_NAMESPACE>http://schemas.android.com/apk/res/android</XML_NAMESPACE>
|
||||
</AND>
|
||||
</match>
|
||||
</rule>
|
||||
</section>
|
||||
<section>
|
||||
<rule>
|
||||
<match>
|
||||
<AND>
|
||||
<NAME>name</NAME>
|
||||
<XML_ATTRIBUTE />
|
||||
<XML_NAMESPACE>^$</XML_NAMESPACE>
|
||||
</AND>
|
||||
</match>
|
||||
</rule>
|
||||
</section>
|
||||
<section>
|
||||
<rule>
|
||||
<match>
|
||||
<AND>
|
||||
<NAME>style</NAME>
|
||||
<XML_ATTRIBUTE />
|
||||
<XML_NAMESPACE>^$</XML_NAMESPACE>
|
||||
</AND>
|
||||
</match>
|
||||
</rule>
|
||||
</section>
|
||||
<section>
|
||||
<rule>
|
||||
<match>
|
||||
<AND>
|
||||
<NAME>.*</NAME>
|
||||
<XML_ATTRIBUTE />
|
||||
<XML_NAMESPACE>^$</XML_NAMESPACE>
|
||||
</AND>
|
||||
</match>
|
||||
<order>BY_NAME</order>
|
||||
</rule>
|
||||
</section>
|
||||
<section>
|
||||
<rule>
|
||||
<match>
|
||||
<AND>
|
||||
<NAME>.*</NAME>
|
||||
<XML_ATTRIBUTE />
|
||||
<XML_NAMESPACE>http://schemas.android.com/apk/res/android</XML_NAMESPACE>
|
||||
</AND>
|
||||
</match>
|
||||
<order>ANDROID_ATTRIBUTE_ORDER</order>
|
||||
</rule>
|
||||
</section>
|
||||
<section>
|
||||
<rule>
|
||||
<match>
|
||||
<AND>
|
||||
<NAME>.*</NAME>
|
||||
<XML_ATTRIBUTE />
|
||||
<XML_NAMESPACE>.*</XML_NAMESPACE>
|
||||
</AND>
|
||||
</match>
|
||||
<order>BY_NAME</order>
|
||||
</rule>
|
||||
</section>
|
||||
</rules>
|
||||
</arrangement>
|
||||
</codeStyleSettings>
|
||||
<codeStyleSettings language="kotlin">
|
||||
<option name="CODE_STYLE_DEFAULTS" value="KOTLIN_OFFICIAL" />
|
||||
</codeStyleSettings>
|
||||
</code_scheme>
|
||||
</component>
|
||||
5
.idea/codeStyles/codeStyleConfig.xml
generated
5
.idea/codeStyles/codeStyleConfig.xml
generated
|
|
@ -1,5 +0,0 @@
|
|||
<component name="ProjectCodeStyleConfiguration">
|
||||
<state>
|
||||
<option name="USE_PER_PROJECT_SETTINGS" value="true" />
|
||||
</state>
|
||||
</component>
|
||||
11
.idea/compiler.xml
generated
11
.idea/compiler.xml
generated
|
|
@ -1,6 +1,13 @@
|
|||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<project version="4">
|
||||
<component name="CompilerConfiguration">
|
||||
<bytecodeTargetLevel target="1.7" />
|
||||
<bytecodeTargetLevel target="1.7">
|
||||
<module name="OpenEUICC.app" target="17" />
|
||||
<module name="OpenEUICC.libs.hidden-apis-shim" target="17" />
|
||||
<module name="OpenEUICC.libs.lpac-jni" target="17" />
|
||||
<module name="OpenEUICC.libs.lpad-sm-dp-plus-connector" target="17" />
|
||||
<module name="OpenEUICC.libs.lpad-sm-dp-plus-connector.main" target="17" />
|
||||
<module name="OpenEUICC.libs.lpad-sm-dp-plus-connector.test" target="17" />
|
||||
</bytecodeTargetLevel>
|
||||
</component>
|
||||
</project>
|
||||
</project>
|
||||
25
.idea/gradle.xml
generated
Normal file
25
.idea/gradle.xml
generated
Normal file
|
|
@ -0,0 +1,25 @@
|
|||
<?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$/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.24" />
|
||||
<option name="version" value="1.9.20" />
|
||||
</component>
|
||||
</project>
|
||||
</project>
|
||||
10
.idea/migrations.xml
generated
10
.idea/migrations.xml
generated
|
|
@ -1,10 +0,0 @@
|
|||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<project version="4">
|
||||
<component name="ProjectMigrations">
|
||||
<option name="MigrateToGradleLocalJavaHome">
|
||||
<set>
|
||||
<option value="$PROJECT_DIR$" />
|
||||
</set>
|
||||
</option>
|
||||
</component>
|
||||
</project>
|
||||
25
.idea/misc.xml
generated
Normal file
25
.idea/misc.xml
generated
Normal file
|
|
@ -0,0 +1,25 @@
|
|||
<project version="4">
|
||||
<component name="DesignSurface">
|
||||
<option name="filePathToZoomLevelMap">
|
||||
<map>
|
||||
<entry key="app/src/main/res/drawable/ic_add.xml" value="0.2015" />
|
||||
<entry key="app/src/main/res/layout/activity_main.xml" value="0.19375" />
|
||||
<entry key="app/src/main/res/layout/euicc_profile.xml" value="0.19375" />
|
||||
<entry key="app/src/main/res/layout/fragment_euicc.xml" value="0.19375" />
|
||||
<entry key="app/src/main/res/layout/fragment_profile_download.xml" value="0.19375" />
|
||||
<entry key="app/src/main/res/layout/fragment_profile_rename.xml" value="0.19375" />
|
||||
<entry key="app/src/main/res/menu/activity_main.xml" value="0.19375" />
|
||||
<entry key="app/src/main/res/menu/activity_main_slot_spinner.xml" value="0.19375" />
|
||||
<entry key="app/src/main/res/menu/fragment_profile_download.xml" value="0.19375" />
|
||||
<entry key="app/src/main/res/menu/fragment_profile_rename.xml" value="0.19375" />
|
||||
<entry key="app/src/main/res/menu/profile_options.xml" value="0.19375" />
|
||||
</map>
|
||||
</option>
|
||||
</component>
|
||||
<component name="ProjectRootManager" version="2" languageLevel="JDK_1_7" project-jdk-name="jbr-17" project-jdk-type="JavaSDK">
|
||||
<output url="file://$PROJECT_DIR$/build/classes" />
|
||||
</component>
|
||||
<component name="ProjectType">
|
||||
<option name="id" value="Android" />
|
||||
</component>
|
||||
</project>
|
||||
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>
|
||||
58
Android.bp
58
Android.bp
|
|
@ -1,52 +1,30 @@
|
|||
java_library {
|
||||
name: "net.typeblog.lpac_jni",
|
||||
srcs: [
|
||||
"libs/lpac-jni/src/main/**/*.kt",
|
||||
],
|
||||
optimize: {
|
||||
enabled: false,
|
||||
},
|
||||
static_libs: [
|
||||
"kotlinx_coroutines",
|
||||
],
|
||||
system_ext_specific: true,
|
||||
}
|
||||
|
||||
android_library {
|
||||
name: "OpenEUICC-common",
|
||||
defaults: [
|
||||
"OpenEUICC-deps-defaults",
|
||||
],
|
||||
static_libs: [
|
||||
"net.typeblog.lpac_jni",
|
||||
"kotlinx_coroutines",
|
||||
],
|
||||
srcs: [
|
||||
"app-common/src/main/**/*.kt",
|
||||
],
|
||||
optimize: {
|
||||
enabled: false,
|
||||
},
|
||||
resource_dirs: [
|
||||
"app-common/src/main/res",
|
||||
],
|
||||
kotlincflags: [
|
||||
"-opt-in=kotlin.ExperimentalStdlibApi",
|
||||
],
|
||||
manifest: "app-common/src/main/AndroidManifest.xml",
|
||||
system_ext_specific: true,
|
||||
}
|
||||
|
||||
android_app {
|
||||
name: "OpenEUICC",
|
||||
static_libs: [
|
||||
"OpenEUICC-common",
|
||||
// Dependencies that must be pulled from maven,
|
||||
"zxing-core-prebuilt-jar",
|
||||
"zxing-android-embedded-prebuilt-aar",
|
||||
|
||||
// Dependencies included with AOSP
|
||||
"androidx.appcompat_appcompat",
|
||||
"androidx.cardview_cardview",
|
||||
"androidx-constraintlayout_constraintlayout",
|
||||
"androidx.core_core-ktx",
|
||||
"androidx.lifecycle_lifecycle-runtime-ktx",
|
||||
"androidx.swiperefreshlayout_swiperefreshlayout",
|
||||
"com.google.android.material_material",
|
||||
"gson",
|
||||
"kotlinx_coroutines",
|
||||
],
|
||||
jni_libs: [
|
||||
"liblpac-jni",
|
||||
],
|
||||
srcs: [
|
||||
// Main app
|
||||
"app/src/main/**/*.kt",
|
||||
|
||||
// lpac-jni interface
|
||||
"libs/lpac-jni/src/main/**/*.kt",
|
||||
],
|
||||
optimize: {
|
||||
enabled: false,
|
||||
|
|
|
|||
7
COPYING
Normal file
7
COPYING
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
Copyright 2022 Peter Cai & Pierre-Hugues Husson
|
||||
|
||||
This program is free software: you can redistribute it and/or modify it under the terms of the GNU General Public License as published by the Free Software Foundation, version 2.
|
||||
|
||||
This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU General Public License along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
873
LICENSE
873
LICENSE
|
|
@ -1,622 +1,281 @@
|
|||
GNU GENERAL PUBLIC LICENSE
|
||||
Version 3, 29 June 2007
|
||||
Version 2, June 1991
|
||||
|
||||
Copyright (C) 2007 Free Software Foundation, Inc. <https://fsf.org/>
|
||||
Copyright (C) 1989, 1991 Free Software Foundation, Inc.,
|
||||
51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA
|
||||
Everyone is permitted to copy and distribute verbatim copies
|
||||
of this license document, but changing it is not allowed.
|
||||
|
||||
Preamble
|
||||
|
||||
The GNU General Public License is a free, copyleft license for
|
||||
software and other kinds of works.
|
||||
|
||||
The licenses for most software and other practical works are designed
|
||||
to take away your freedom to share and change the works. By contrast,
|
||||
the GNU General Public License is intended to guarantee your freedom to
|
||||
share and change all versions of a program--to make sure it remains free
|
||||
software for all its users. We, the Free Software Foundation, use the
|
||||
GNU General Public License for most of our software; it applies also to
|
||||
any other work released this way by its authors. You can apply it to
|
||||
The licenses for most software are designed to take away your
|
||||
freedom to share and change it. By contrast, the GNU General Public
|
||||
License is intended to guarantee your freedom to share and change free
|
||||
software--to make sure the software is free for all its users. This
|
||||
General Public License applies to most of the Free Software
|
||||
Foundation's software and to any other program whose authors commit to
|
||||
using it. (Some other Free Software Foundation software is covered by
|
||||
the GNU Lesser General Public License instead.) You can apply it to
|
||||
your programs, too.
|
||||
|
||||
When we speak of free software, we are referring to freedom, not
|
||||
price. Our General Public Licenses are designed to make sure that you
|
||||
have the freedom to distribute copies of free software (and charge for
|
||||
them if you wish), that you receive source code or can get it if you
|
||||
want it, that you can change the software or use pieces of it in new
|
||||
free programs, and that you know you can do these things.
|
||||
this service if you wish), that you receive source code or can get it
|
||||
if you want it, that you can change the software or use pieces of it
|
||||
in new free programs; and that you know you can do these things.
|
||||
|
||||
To protect your rights, we need to prevent others from denying you
|
||||
these rights or asking you to surrender the rights. Therefore, you have
|
||||
certain responsibilities if you distribute copies of the software, or if
|
||||
you modify it: responsibilities to respect the freedom of others.
|
||||
To protect your rights, we need to make restrictions that forbid
|
||||
anyone to deny you these rights or to ask you to surrender the rights.
|
||||
These restrictions translate to certain responsibilities for you if you
|
||||
distribute copies of the software, or if you modify it.
|
||||
|
||||
For example, if you distribute copies of such a program, whether
|
||||
gratis or for a fee, you must pass on to the recipients the same
|
||||
freedoms that you received. You must make sure that they, too, receive
|
||||
or can get the source code. And you must show them these terms so they
|
||||
know their rights.
|
||||
gratis or for a fee, you must give the recipients all the rights that
|
||||
you have. You must make sure that they, too, receive or can get the
|
||||
source code. And you must show them these terms so they know their
|
||||
rights.
|
||||
|
||||
Developers that use the GNU GPL protect your rights with two steps:
|
||||
(1) assert copyright on the software, and (2) offer you this License
|
||||
giving you legal permission to copy, distribute and/or modify it.
|
||||
We protect your rights with two steps: (1) copyright the software, and
|
||||
(2) offer you this license which gives you legal permission to copy,
|
||||
distribute and/or modify the software.
|
||||
|
||||
For the developers' and authors' protection, the GPL clearly explains
|
||||
that there is no warranty for this free software. For both users' and
|
||||
authors' sake, the GPL requires that modified versions be marked as
|
||||
changed, so that their problems will not be attributed erroneously to
|
||||
authors of previous versions.
|
||||
Also, for each author's protection and ours, we want to make certain
|
||||
that everyone understands that there is no warranty for this free
|
||||
software. If the software is modified by someone else and passed on, we
|
||||
want its recipients to know that what they have is not the original, so
|
||||
that any problems introduced by others will not reflect on the original
|
||||
authors' reputations.
|
||||
|
||||
Some devices are designed to deny users access to install or run
|
||||
modified versions of the software inside them, although the manufacturer
|
||||
can do so. This is fundamentally incompatible with the aim of
|
||||
protecting users' freedom to change the software. The systematic
|
||||
pattern of such abuse occurs in the area of products for individuals to
|
||||
use, which is precisely where it is most unacceptable. Therefore, we
|
||||
have designed this version of the GPL to prohibit the practice for those
|
||||
products. If such problems arise substantially in other domains, we
|
||||
stand ready to extend this provision to those domains in future versions
|
||||
of the GPL, as needed to protect the freedom of users.
|
||||
|
||||
Finally, every program is threatened constantly by software patents.
|
||||
States should not allow patents to restrict development and use of
|
||||
software on general-purpose computers, but in those that do, we wish to
|
||||
avoid the special danger that patents applied to a free program could
|
||||
make it effectively proprietary. To prevent this, the GPL assures that
|
||||
patents cannot be used to render the program non-free.
|
||||
Finally, any free program is threatened constantly by software
|
||||
patents. We wish to avoid the danger that redistributors of a free
|
||||
program will individually obtain patent licenses, in effect making the
|
||||
program proprietary. To prevent this, we have made it clear that any
|
||||
patent must be licensed for everyone's free use or not licensed at all.
|
||||
|
||||
The precise terms and conditions for copying, distribution and
|
||||
modification follow.
|
||||
|
||||
TERMS AND CONDITIONS
|
||||
|
||||
0. Definitions.
|
||||
|
||||
"This License" refers to version 3 of the GNU General Public License.
|
||||
|
||||
"Copyright" also means copyright-like laws that apply to other kinds of
|
||||
works, such as semiconductor masks.
|
||||
|
||||
"The Program" refers to any copyrightable work licensed under this
|
||||
License. Each licensee is addressed as "you". "Licensees" and
|
||||
"recipients" may be individuals or organizations.
|
||||
|
||||
To "modify" a work means to copy from or adapt all or part of the work
|
||||
in a fashion requiring copyright permission, other than the making of an
|
||||
exact copy. The resulting work is called a "modified version" of the
|
||||
earlier work or a work "based on" the earlier work.
|
||||
|
||||
A "covered work" means either the unmodified Program or a work based
|
||||
on the Program.
|
||||
|
||||
To "propagate" a work means to do anything with it that, without
|
||||
permission, would make you directly or secondarily liable for
|
||||
infringement under applicable copyright law, except executing it on a
|
||||
computer or modifying a private copy. Propagation includes copying,
|
||||
distribution (with or without modification), making available to the
|
||||
public, and in some countries other activities as well.
|
||||
|
||||
To "convey" a work means any kind of propagation that enables other
|
||||
parties to make or receive copies. Mere interaction with a user through
|
||||
a computer network, with no transfer of a copy, is not conveying.
|
||||
|
||||
An interactive user interface displays "Appropriate Legal Notices"
|
||||
to the extent that it includes a convenient and prominently visible
|
||||
feature that (1) displays an appropriate copyright notice, and (2)
|
||||
tells the user that there is no warranty for the work (except to the
|
||||
extent that warranties are provided), that licensees may convey the
|
||||
work under this License, and how to view a copy of this License. If
|
||||
the interface presents a list of user commands or options, such as a
|
||||
menu, a prominent item in the list meets this criterion.
|
||||
|
||||
1. Source Code.
|
||||
|
||||
The "source code" for a work means the preferred form of the work
|
||||
for making modifications to it. "Object code" means any non-source
|
||||
form of a work.
|
||||
|
||||
A "Standard Interface" means an interface that either is an official
|
||||
standard defined by a recognized standards body, or, in the case of
|
||||
interfaces specified for a particular programming language, one that
|
||||
is widely used among developers working in that language.
|
||||
|
||||
The "System Libraries" of an executable work include anything, other
|
||||
than the work as a whole, that (a) is included in the normal form of
|
||||
packaging a Major Component, but which is not part of that Major
|
||||
Component, and (b) serves only to enable use of the work with that
|
||||
Major Component, or to implement a Standard Interface for which an
|
||||
implementation is available to the public in source code form. A
|
||||
"Major Component", in this context, means a major essential component
|
||||
(kernel, window system, and so on) of the specific operating system
|
||||
(if any) on which the executable work runs, or a compiler used to
|
||||
produce the work, or an object code interpreter used to run it.
|
||||
|
||||
The "Corresponding Source" for a work in object code form means all
|
||||
the source code needed to generate, install, and (for an executable
|
||||
work) run the object code and to modify the work, including scripts to
|
||||
control those activities. However, it does not include the work's
|
||||
System Libraries, or general-purpose tools or generally available free
|
||||
programs which are used unmodified in performing those activities but
|
||||
which are not part of the work. For example, Corresponding Source
|
||||
includes interface definition files associated with source files for
|
||||
the work, and the source code for shared libraries and dynamically
|
||||
linked subprograms that the work is specifically designed to require,
|
||||
such as by intimate data communication or control flow between those
|
||||
subprograms and other parts of the work.
|
||||
|
||||
The Corresponding Source need not include anything that users
|
||||
can regenerate automatically from other parts of the Corresponding
|
||||
Source.
|
||||
|
||||
The Corresponding Source for a work in source code form is that
|
||||
same work.
|
||||
|
||||
2. Basic Permissions.
|
||||
|
||||
All rights granted under this License are granted for the term of
|
||||
copyright on the Program, and are irrevocable provided the stated
|
||||
conditions are met. This License explicitly affirms your unlimited
|
||||
permission to run the unmodified Program. The output from running a
|
||||
covered work is covered by this License only if the output, given its
|
||||
content, constitutes a covered work. This License acknowledges your
|
||||
rights of fair use or other equivalent, as provided by copyright law.
|
||||
|
||||
You may make, run and propagate covered works that you do not
|
||||
convey, without conditions so long as your license otherwise remains
|
||||
in force. You may convey covered works to others for the sole purpose
|
||||
of having them make modifications exclusively for you, or provide you
|
||||
with facilities for running those works, provided that you comply with
|
||||
the terms of this License in conveying all material for which you do
|
||||
not control copyright. Those thus making or running the covered works
|
||||
for you must do so exclusively on your behalf, under your direction
|
||||
and control, on terms that prohibit them from making any copies of
|
||||
your copyrighted material outside their relationship with you.
|
||||
|
||||
Conveying under any other circumstances is permitted solely under
|
||||
the conditions stated below. Sublicensing is not allowed; section 10
|
||||
makes it unnecessary.
|
||||
|
||||
3. Protecting Users' Legal Rights From Anti-Circumvention Law.
|
||||
|
||||
No covered work shall be deemed part of an effective technological
|
||||
measure under any applicable law fulfilling obligations under article
|
||||
11 of the WIPO copyright treaty adopted on 20 December 1996, or
|
||||
similar laws prohibiting or restricting circumvention of such
|
||||
measures.
|
||||
|
||||
When you convey a covered work, you waive any legal power to forbid
|
||||
circumvention of technological measures to the extent such circumvention
|
||||
is effected by exercising rights under this License with respect to
|
||||
the covered work, and you disclaim any intention to limit operation or
|
||||
modification of the work as a means of enforcing, against the work's
|
||||
users, your or third parties' legal rights to forbid circumvention of
|
||||
technological measures.
|
||||
|
||||
4. Conveying Verbatim Copies.
|
||||
|
||||
You may convey verbatim copies of the Program's source code as you
|
||||
receive it, in any medium, provided that you conspicuously and
|
||||
appropriately publish on each copy an appropriate copyright notice;
|
||||
keep intact all notices stating that this License and any
|
||||
non-permissive terms added in accord with section 7 apply to the code;
|
||||
keep intact all notices of the absence of any warranty; and give all
|
||||
recipients a copy of this License along with the Program.
|
||||
|
||||
You may charge any price or no price for each copy that you convey,
|
||||
and you may offer support or warranty protection for a fee.
|
||||
|
||||
5. Conveying Modified Source Versions.
|
||||
|
||||
You may convey a work based on the Program, or the modifications to
|
||||
produce it from the Program, in the form of source code under the
|
||||
terms of section 4, provided that you also meet all of these conditions:
|
||||
|
||||
a) The work must carry prominent notices stating that you modified
|
||||
it, and giving a relevant date.
|
||||
|
||||
b) The work must carry prominent notices stating that it is
|
||||
released under this License and any conditions added under section
|
||||
7. This requirement modifies the requirement in section 4 to
|
||||
"keep intact all notices".
|
||||
|
||||
c) You must license the entire work, as a whole, under this
|
||||
License to anyone who comes into possession of a copy. This
|
||||
License will therefore apply, along with any applicable section 7
|
||||
additional terms, to the whole of the work, and all its parts,
|
||||
regardless of how they are packaged. This License gives no
|
||||
permission to license the work in any other way, but it does not
|
||||
invalidate such permission if you have separately received it.
|
||||
|
||||
d) If the work has interactive user interfaces, each must display
|
||||
Appropriate Legal Notices; however, if the Program has interactive
|
||||
interfaces that do not display Appropriate Legal Notices, your
|
||||
work need not make them do so.
|
||||
|
||||
A compilation of a covered work with other separate and independent
|
||||
works, which are not by their nature extensions of the covered work,
|
||||
and which are not combined with it such as to form a larger program,
|
||||
in or on a volume of a storage or distribution medium, is called an
|
||||
"aggregate" if the compilation and its resulting copyright are not
|
||||
used to limit the access or legal rights of the compilation's users
|
||||
beyond what the individual works permit. Inclusion of a covered work
|
||||
in an aggregate does not cause this License to apply to the other
|
||||
parts of the aggregate.
|
||||
|
||||
6. Conveying Non-Source Forms.
|
||||
|
||||
You may convey a covered work in object code form under the terms
|
||||
of sections 4 and 5, provided that you also convey the
|
||||
machine-readable Corresponding Source under the terms of this License,
|
||||
in one of these ways:
|
||||
|
||||
a) Convey the object code in, or embodied in, a physical product
|
||||
(including a physical distribution medium), accompanied by the
|
||||
Corresponding Source fixed on a durable physical medium
|
||||
customarily used for software interchange.
|
||||
|
||||
b) Convey the object code in, or embodied in, a physical product
|
||||
(including a physical distribution medium), accompanied by a
|
||||
written offer, valid for at least three years and valid for as
|
||||
long as you offer spare parts or customer support for that product
|
||||
model, to give anyone who possesses the object code either (1) a
|
||||
copy of the Corresponding Source for all the software in the
|
||||
product that is covered by this License, on a durable physical
|
||||
medium customarily used for software interchange, for a price no
|
||||
more than your reasonable cost of physically performing this
|
||||
conveying of source, or (2) access to copy the
|
||||
Corresponding Source from a network server at no charge.
|
||||
|
||||
c) Convey individual copies of the object code with a copy of the
|
||||
written offer to provide the Corresponding Source. This
|
||||
alternative is allowed only occasionally and noncommercially, and
|
||||
only if you received the object code with such an offer, in accord
|
||||
with subsection 6b.
|
||||
|
||||
d) Convey the object code by offering access from a designated
|
||||
place (gratis or for a charge), and offer equivalent access to the
|
||||
Corresponding Source in the same way through the same place at no
|
||||
further charge. You need not require recipients to copy the
|
||||
Corresponding Source along with the object code. If the place to
|
||||
copy the object code is a network server, the Corresponding Source
|
||||
may be on a different server (operated by you or a third party)
|
||||
that supports equivalent copying facilities, provided you maintain
|
||||
clear directions next to the object code saying where to find the
|
||||
Corresponding Source. Regardless of what server hosts the
|
||||
Corresponding Source, you remain obligated to ensure that it is
|
||||
available for as long as needed to satisfy these requirements.
|
||||
|
||||
e) Convey the object code using peer-to-peer transmission, provided
|
||||
you inform other peers where the object code and Corresponding
|
||||
Source of the work are being offered to the general public at no
|
||||
charge under subsection 6d.
|
||||
|
||||
A separable portion of the object code, whose source code is excluded
|
||||
from the Corresponding Source as a System Library, need not be
|
||||
included in conveying the object code work.
|
||||
|
||||
A "User Product" is either (1) a "consumer product", which means any
|
||||
tangible personal property which is normally used for personal, family,
|
||||
or household purposes, or (2) anything designed or sold for incorporation
|
||||
into a dwelling. In determining whether a product is a consumer product,
|
||||
doubtful cases shall be resolved in favor of coverage. For a particular
|
||||
product received by a particular user, "normally used" refers to a
|
||||
typical or common use of that class of product, regardless of the status
|
||||
of the particular user or of the way in which the particular user
|
||||
actually uses, or expects or is expected to use, the product. A product
|
||||
is a consumer product regardless of whether the product has substantial
|
||||
commercial, industrial or non-consumer uses, unless such uses represent
|
||||
the only significant mode of use of the product.
|
||||
|
||||
"Installation Information" for a User Product means any methods,
|
||||
procedures, authorization keys, or other information required to install
|
||||
and execute modified versions of a covered work in that User Product from
|
||||
a modified version of its Corresponding Source. The information must
|
||||
suffice to ensure that the continued functioning of the modified object
|
||||
code is in no case prevented or interfered with solely because
|
||||
modification has been made.
|
||||
|
||||
If you convey an object code work under this section in, or with, or
|
||||
specifically for use in, a User Product, and the conveying occurs as
|
||||
part of a transaction in which the right of possession and use of the
|
||||
User Product is transferred to the recipient in perpetuity or for a
|
||||
fixed term (regardless of how the transaction is characterized), the
|
||||
Corresponding Source conveyed under this section must be accompanied
|
||||
by the Installation Information. But this requirement does not apply
|
||||
if neither you nor any third party retains the ability to install
|
||||
modified object code on the User Product (for example, the work has
|
||||
been installed in ROM).
|
||||
|
||||
The requirement to provide Installation Information does not include a
|
||||
requirement to continue to provide support service, warranty, or updates
|
||||
for a work that has been modified or installed by the recipient, or for
|
||||
the User Product in which it has been modified or installed. Access to a
|
||||
network may be denied when the modification itself materially and
|
||||
adversely affects the operation of the network or violates the rules and
|
||||
protocols for communication across the network.
|
||||
|
||||
Corresponding Source conveyed, and Installation Information provided,
|
||||
in accord with this section must be in a format that is publicly
|
||||
documented (and with an implementation available to the public in
|
||||
source code form), and must require no special password or key for
|
||||
unpacking, reading or copying.
|
||||
|
||||
7. Additional Terms.
|
||||
|
||||
"Additional permissions" are terms that supplement the terms of this
|
||||
License by making exceptions from one or more of its conditions.
|
||||
Additional permissions that are applicable to the entire Program shall
|
||||
be treated as though they were included in this License, to the extent
|
||||
that they are valid under applicable law. If additional permissions
|
||||
apply only to part of the Program, that part may be used separately
|
||||
under those permissions, but the entire Program remains governed by
|
||||
this License without regard to the additional permissions.
|
||||
|
||||
When you convey a copy of a covered work, you may at your option
|
||||
remove any additional permissions from that copy, or from any part of
|
||||
it. (Additional permissions may be written to require their own
|
||||
removal in certain cases when you modify the work.) You may place
|
||||
additional permissions on material, added by you to a covered work,
|
||||
for which you have or can give appropriate copyright permission.
|
||||
|
||||
Notwithstanding any other provision of this License, for material you
|
||||
add to a covered work, you may (if authorized by the copyright holders of
|
||||
that material) supplement the terms of this License with terms:
|
||||
|
||||
a) Disclaiming warranty or limiting liability differently from the
|
||||
terms of sections 15 and 16 of this License; or
|
||||
|
||||
b) Requiring preservation of specified reasonable legal notices or
|
||||
author attributions in that material or in the Appropriate Legal
|
||||
Notices displayed by works containing it; or
|
||||
|
||||
c) Prohibiting misrepresentation of the origin of that material, or
|
||||
requiring that modified versions of such material be marked in
|
||||
reasonable ways as different from the original version; or
|
||||
|
||||
d) Limiting the use for publicity purposes of names of licensors or
|
||||
authors of the material; or
|
||||
|
||||
e) Declining to grant rights under trademark law for use of some
|
||||
trade names, trademarks, or service marks; or
|
||||
|
||||
f) Requiring indemnification of licensors and authors of that
|
||||
material by anyone who conveys the material (or modified versions of
|
||||
it) with contractual assumptions of liability to the recipient, for
|
||||
any liability that these contractual assumptions directly impose on
|
||||
those licensors and authors.
|
||||
|
||||
All other non-permissive additional terms are considered "further
|
||||
restrictions" within the meaning of section 10. If the Program as you
|
||||
received it, or any part of it, contains a notice stating that it is
|
||||
governed by this License along with a term that is a further
|
||||
restriction, you may remove that term. If a license document contains
|
||||
a further restriction but permits relicensing or conveying under this
|
||||
License, you may add to a covered work material governed by the terms
|
||||
of that license document, provided that the further restriction does
|
||||
not survive such relicensing or conveying.
|
||||
|
||||
If you add terms to a covered work in accord with this section, you
|
||||
must place, in the relevant source files, a statement of the
|
||||
additional terms that apply to those files, or a notice indicating
|
||||
where to find the applicable terms.
|
||||
|
||||
Additional terms, permissive or non-permissive, may be stated in the
|
||||
form of a separately written license, or stated as exceptions;
|
||||
the above requirements apply either way.
|
||||
|
||||
8. Termination.
|
||||
|
||||
You may not propagate or modify a covered work except as expressly
|
||||
provided under this License. Any attempt otherwise to propagate or
|
||||
modify it is void, and will automatically terminate your rights under
|
||||
this License (including any patent licenses granted under the third
|
||||
paragraph of section 11).
|
||||
|
||||
However, if you cease all violation of this License, then your
|
||||
license from a particular copyright holder is reinstated (a)
|
||||
provisionally, unless and until the copyright holder explicitly and
|
||||
finally terminates your license, and (b) permanently, if the copyright
|
||||
holder fails to notify you of the violation by some reasonable means
|
||||
prior to 60 days after the cessation.
|
||||
|
||||
Moreover, your license from a particular copyright holder is
|
||||
reinstated permanently if the copyright holder notifies you of the
|
||||
violation by some reasonable means, this is the first time you have
|
||||
received notice of violation of this License (for any work) from that
|
||||
copyright holder, and you cure the violation prior to 30 days after
|
||||
your receipt of the notice.
|
||||
|
||||
Termination of your rights under this section does not terminate the
|
||||
licenses of parties who have received copies or rights from you under
|
||||
this License. If your rights have been terminated and not permanently
|
||||
reinstated, you do not qualify to receive new licenses for the same
|
||||
material under section 10.
|
||||
|
||||
9. Acceptance Not Required for Having Copies.
|
||||
|
||||
You are not required to accept this License in order to receive or
|
||||
run a copy of the Program. Ancillary propagation of a covered work
|
||||
occurring solely as a consequence of using peer-to-peer transmission
|
||||
to receive a copy likewise does not require acceptance. However,
|
||||
nothing other than this License grants you permission to propagate or
|
||||
modify any covered work. These actions infringe copyright if you do
|
||||
not accept this License. Therefore, by modifying or propagating a
|
||||
covered work, you indicate your acceptance of this License to do so.
|
||||
|
||||
10. Automatic Licensing of Downstream Recipients.
|
||||
|
||||
Each time you convey a covered work, the recipient automatically
|
||||
receives a license from the original licensors, to run, modify and
|
||||
propagate that work, subject to this License. You are not responsible
|
||||
for enforcing compliance by third parties with this License.
|
||||
|
||||
An "entity transaction" is a transaction transferring control of an
|
||||
organization, or substantially all assets of one, or subdividing an
|
||||
organization, or merging organizations. If propagation of a covered
|
||||
work results from an entity transaction, each party to that
|
||||
transaction who receives a copy of the work also receives whatever
|
||||
licenses to the work the party's predecessor in interest had or could
|
||||
give under the previous paragraph, plus a right to possession of the
|
||||
Corresponding Source of the work from the predecessor in interest, if
|
||||
the predecessor has it or can get it with reasonable efforts.
|
||||
|
||||
You may not impose any further restrictions on the exercise of the
|
||||
rights granted or affirmed under this License. For example, you may
|
||||
not impose a license fee, royalty, or other charge for exercise of
|
||||
rights granted under this License, and you may not initiate litigation
|
||||
(including a cross-claim or counterclaim in a lawsuit) alleging that
|
||||
any patent claim is infringed by making, using, selling, offering for
|
||||
sale, or importing the Program or any portion of it.
|
||||
|
||||
11. Patents.
|
||||
|
||||
A "contributor" is a copyright holder who authorizes use under this
|
||||
License of the Program or a work on which the Program is based. The
|
||||
work thus licensed is called the contributor's "contributor version".
|
||||
|
||||
A contributor's "essential patent claims" are all patent claims
|
||||
owned or controlled by the contributor, whether already acquired or
|
||||
hereafter acquired, that would be infringed by some manner, permitted
|
||||
by this License, of making, using, or selling its contributor version,
|
||||
but do not include claims that would be infringed only as a
|
||||
consequence of further modification of the contributor version. For
|
||||
purposes of this definition, "control" includes the right to grant
|
||||
patent sublicenses in a manner consistent with the requirements of
|
||||
GNU GENERAL PUBLIC LICENSE
|
||||
TERMS AND CONDITIONS FOR COPYING, DISTRIBUTION AND MODIFICATION
|
||||
|
||||
0. This License applies to any program or other work which contains
|
||||
a notice placed by the copyright holder saying it may be distributed
|
||||
under the terms of this General Public License. The "Program", below,
|
||||
refers to any such program or work, and a "work based on the Program"
|
||||
means either the Program or any derivative work under copyright law:
|
||||
that is to say, a work containing the Program or a portion of it,
|
||||
either verbatim or with modifications and/or translated into another
|
||||
language. (Hereinafter, translation is included without limitation in
|
||||
the term "modification".) Each licensee is addressed as "you".
|
||||
|
||||
Activities other than copying, distribution and modification are not
|
||||
covered by this License; they are outside its scope. The act of
|
||||
running the Program is not restricted, and the output from the Program
|
||||
is covered only if its contents constitute a work based on the
|
||||
Program (independent of having been made by running the Program).
|
||||
Whether that is true depends on what the Program does.
|
||||
|
||||
1. You may copy and distribute verbatim copies of the Program's
|
||||
source code as you receive it, in any medium, provided that you
|
||||
conspicuously and appropriately publish on each copy an appropriate
|
||||
copyright notice and disclaimer of warranty; keep intact all the
|
||||
notices that refer to this License and to the absence of any warranty;
|
||||
and give any other recipients of the Program a copy of this License
|
||||
along with the Program.
|
||||
|
||||
You may charge a fee for the physical act of transferring a copy, and
|
||||
you may at your option offer warranty protection in exchange for a fee.
|
||||
|
||||
2. You may modify your copy or copies of the Program or any portion
|
||||
of it, thus forming a work based on the Program, and copy and
|
||||
distribute such modifications or work under the terms of Section 1
|
||||
above, provided that you also meet all of these conditions:
|
||||
|
||||
a) You must cause the modified files to carry prominent notices
|
||||
stating that you changed the files and the date of any change.
|
||||
|
||||
b) You must cause any work that you distribute or publish, that in
|
||||
whole or in part contains or is derived from the Program or any
|
||||
part thereof, to be licensed as a whole at no charge to all third
|
||||
parties under the terms of this License.
|
||||
|
||||
c) If the modified program normally reads commands interactively
|
||||
when run, you must cause it, when started running for such
|
||||
interactive use in the most ordinary way, to print or display an
|
||||
announcement including an appropriate copyright notice and a
|
||||
notice that there is no warranty (or else, saying that you provide
|
||||
a warranty) and that users may redistribute the program under
|
||||
these conditions, and telling the user how to view a copy of this
|
||||
License. (Exception: if the Program itself is interactive but
|
||||
does not normally print such an announcement, your work based on
|
||||
the Program is not required to print an announcement.)
|
||||
|
||||
These requirements apply to the modified work as a whole. If
|
||||
identifiable sections of that work are not derived from the Program,
|
||||
and can be reasonably considered independent and separate works in
|
||||
themselves, then this License, and its terms, do not apply to those
|
||||
sections when you distribute them as separate works. But when you
|
||||
distribute the same sections as part of a whole which is a work based
|
||||
on the Program, the distribution of the whole must be on the terms of
|
||||
this License, whose permissions for other licensees extend to the
|
||||
entire whole, and thus to each and every part regardless of who wrote it.
|
||||
|
||||
Thus, it is not the intent of this section to claim rights or contest
|
||||
your rights to work written entirely by you; rather, the intent is to
|
||||
exercise the right to control the distribution of derivative or
|
||||
collective works based on the Program.
|
||||
|
||||
In addition, mere aggregation of another work not based on the Program
|
||||
with the Program (or with a work based on the Program) on a volume of
|
||||
a storage or distribution medium does not bring the other work under
|
||||
the scope of this License.
|
||||
|
||||
3. You may copy and distribute the Program (or a work based on it,
|
||||
under Section 2) in object code or executable form under the terms of
|
||||
Sections 1 and 2 above provided that you also do one of the following:
|
||||
|
||||
a) Accompany it with the complete corresponding machine-readable
|
||||
source code, which must be distributed under the terms of Sections
|
||||
1 and 2 above on a medium customarily used for software interchange; or,
|
||||
|
||||
b) Accompany it with a written offer, valid for at least three
|
||||
years, to give any third party, for a charge no more than your
|
||||
cost of physically performing source distribution, a complete
|
||||
machine-readable copy of the corresponding source code, to be
|
||||
distributed under the terms of Sections 1 and 2 above on a medium
|
||||
customarily used for software interchange; or,
|
||||
|
||||
c) Accompany it with the information you received as to the offer
|
||||
to distribute corresponding source code. (This alternative is
|
||||
allowed only for noncommercial distribution and only if you
|
||||
received the program in object code or executable form with such
|
||||
an offer, in accord with Subsection b above.)
|
||||
|
||||
The source code for a work means the preferred form of the work for
|
||||
making modifications to it. For an executable work, complete source
|
||||
code means all the source code for all modules it contains, plus any
|
||||
associated interface definition files, plus the scripts used to
|
||||
control compilation and installation of the executable. However, as a
|
||||
special exception, the source code distributed need not include
|
||||
anything that is normally distributed (in either source or binary
|
||||
form) with the major components (compiler, kernel, and so on) of the
|
||||
operating system on which the executable runs, unless that component
|
||||
itself accompanies the executable.
|
||||
|
||||
If distribution of executable or object code is made by offering
|
||||
access to copy from a designated place, then offering equivalent
|
||||
access to copy the source code from the same place counts as
|
||||
distribution of the source code, even though third parties are not
|
||||
compelled to copy the source along with the object code.
|
||||
|
||||
4. You may not copy, modify, sublicense, or distribute the Program
|
||||
except as expressly provided under this License. Any attempt
|
||||
otherwise to copy, modify, sublicense or distribute the Program is
|
||||
void, and will automatically terminate your rights under this License.
|
||||
However, parties who have received copies, or rights, from you under
|
||||
this License will not have their licenses terminated so long as such
|
||||
parties remain in full compliance.
|
||||
|
||||
5. You are not required to accept this License, since you have not
|
||||
signed it. However, nothing else grants you permission to modify or
|
||||
distribute the Program or its derivative works. These actions are
|
||||
prohibited by law if you do not accept this License. Therefore, by
|
||||
modifying or distributing the Program (or any work based on the
|
||||
Program), you indicate your acceptance of this License to do so, and
|
||||
all its terms and conditions for copying, distributing or modifying
|
||||
the Program or works based on it.
|
||||
|
||||
6. Each time you redistribute the Program (or any work based on the
|
||||
Program), the recipient automatically receives a license from the
|
||||
original licensor to copy, distribute or modify the Program subject to
|
||||
these terms and conditions. You may not impose any further
|
||||
restrictions on the recipients' exercise of the rights granted herein.
|
||||
You are not responsible for enforcing compliance by third parties to
|
||||
this License.
|
||||
|
||||
Each contributor grants you a non-exclusive, worldwide, royalty-free
|
||||
patent license under the contributor's essential patent claims, to
|
||||
make, use, sell, offer for sale, import and otherwise run, modify and
|
||||
propagate the contents of its contributor version.
|
||||
|
||||
In the following three paragraphs, a "patent license" is any express
|
||||
agreement or commitment, however denominated, not to enforce a patent
|
||||
(such as an express permission to practice a patent or covenant not to
|
||||
sue for patent infringement). To "grant" such a patent license to a
|
||||
party means to make such an agreement or commitment not to enforce a
|
||||
patent against the party.
|
||||
|
||||
If you convey a covered work, knowingly relying on a patent license,
|
||||
and the Corresponding Source of the work is not available for anyone
|
||||
to copy, free of charge and under the terms of this License, through a
|
||||
publicly available network server or other readily accessible means,
|
||||
then you must either (1) cause the Corresponding Source to be so
|
||||
available, or (2) arrange to deprive yourself of the benefit of the
|
||||
patent license for this particular work, or (3) arrange, in a manner
|
||||
consistent with the requirements of this License, to extend the patent
|
||||
license to downstream recipients. "Knowingly relying" means you have
|
||||
actual knowledge that, but for the patent license, your conveying the
|
||||
covered work in a country, or your recipient's use of the covered work
|
||||
in a country, would infringe one or more identifiable patents in that
|
||||
country that you have reason to believe are valid.
|
||||
|
||||
If, pursuant to or in connection with a single transaction or
|
||||
arrangement, you convey, or propagate by procuring conveyance of, a
|
||||
covered work, and grant a patent license to some of the parties
|
||||
receiving the covered work authorizing them to use, propagate, modify
|
||||
or convey a specific copy of the covered work, then the patent license
|
||||
you grant is automatically extended to all recipients of the covered
|
||||
work and works based on it.
|
||||
|
||||
A patent license is "discriminatory" if it does not include within
|
||||
the scope of its coverage, prohibits the exercise of, or is
|
||||
conditioned on the non-exercise of one or more of the rights that are
|
||||
specifically granted under this License. You may not convey a covered
|
||||
work if you are a party to an arrangement with a third party that is
|
||||
in the business of distributing software, under which you make payment
|
||||
to the third party based on the extent of your activity of conveying
|
||||
the work, and under which the third party grants, to any of the
|
||||
parties who would receive the covered work from you, a discriminatory
|
||||
patent license (a) in connection with copies of the covered work
|
||||
conveyed by you (or copies made from those copies), or (b) primarily
|
||||
for and in connection with specific products or compilations that
|
||||
contain the covered work, unless you entered into that arrangement,
|
||||
or that patent license was granted, prior to 28 March 2007.
|
||||
|
||||
Nothing in this License shall be construed as excluding or limiting
|
||||
any implied license or other defenses to infringement that may
|
||||
otherwise be available to you under applicable patent law.
|
||||
|
||||
12. No Surrender of Others' Freedom.
|
||||
|
||||
If conditions are imposed on you (whether by court order, agreement or
|
||||
7. If, as a consequence of a court judgment or allegation of patent
|
||||
infringement or for any other reason (not limited to patent issues),
|
||||
conditions are imposed on you (whether by court order, agreement or
|
||||
otherwise) that contradict the conditions of this License, they do not
|
||||
excuse you from the conditions of this License. If you cannot convey a
|
||||
covered work so as to satisfy simultaneously your obligations under this
|
||||
License and any other pertinent obligations, then as a consequence you may
|
||||
not convey it at all. For example, if you agree to terms that obligate you
|
||||
to collect a royalty for further conveying from those to whom you convey
|
||||
the Program, the only way you could satisfy both those terms and this
|
||||
License would be to refrain entirely from conveying the Program.
|
||||
excuse you from the conditions of this License. If you cannot
|
||||
distribute so as to satisfy simultaneously your obligations under this
|
||||
License and any other pertinent obligations, then as a consequence you
|
||||
may not distribute the Program at all. For example, if a patent
|
||||
license would not permit royalty-free redistribution of the Program by
|
||||
all those who receive copies directly or indirectly through you, then
|
||||
the only way you could satisfy both it and this License would be to
|
||||
refrain entirely from distribution of the Program.
|
||||
|
||||
13. Use with the GNU Affero General Public License.
|
||||
If any portion of this section is held invalid or unenforceable under
|
||||
any particular circumstance, the balance of the section is intended to
|
||||
apply and the section as a whole is intended to apply in other
|
||||
circumstances.
|
||||
|
||||
Notwithstanding any other provision of this License, you have
|
||||
permission to link or combine any covered work with a work licensed
|
||||
under version 3 of the GNU Affero General Public License into a single
|
||||
combined work, and to convey the resulting work. The terms of this
|
||||
License will continue to apply to the part which is the covered work,
|
||||
but the special requirements of the GNU Affero General Public License,
|
||||
section 13, concerning interaction through a network will apply to the
|
||||
combination as such.
|
||||
It is not the purpose of this section to induce you to infringe any
|
||||
patents or other property right claims or to contest validity of any
|
||||
such claims; this section has the sole purpose of protecting the
|
||||
integrity of the free software distribution system, which is
|
||||
implemented by public license practices. Many people have made
|
||||
generous contributions to the wide range of software distributed
|
||||
through that system in reliance on consistent application of that
|
||||
system; it is up to the author/donor to decide if he or she is willing
|
||||
to distribute software through any other system and a licensee cannot
|
||||
impose that choice.
|
||||
|
||||
14. Revised Versions of this License.
|
||||
This section is intended to make thoroughly clear what is believed to
|
||||
be a consequence of the rest of this License.
|
||||
|
||||
The Free Software Foundation may publish revised and/or new versions of
|
||||
the GNU General Public License from time to time. Such new versions will
|
||||
8. If the distribution and/or use of the Program is restricted in
|
||||
certain countries either by patents or by copyrighted interfaces, the
|
||||
original copyright holder who places the Program under this License
|
||||
may add an explicit geographical distribution limitation excluding
|
||||
those countries, so that distribution is permitted only in or among
|
||||
countries not thus excluded. In such case, this License incorporates
|
||||
the limitation as if written in the body of this License.
|
||||
|
||||
9. The Free Software Foundation may publish revised and/or new versions
|
||||
of the General Public License from time to time. Such new versions will
|
||||
be similar in spirit to the present version, but may differ in detail to
|
||||
address new problems or concerns.
|
||||
|
||||
Each version is given a distinguishing version number. If the
|
||||
Program specifies that a certain numbered version of the GNU General
|
||||
Public License "or any later version" applies to it, you have the
|
||||
option of following the terms and conditions either of that numbered
|
||||
version or of any later version published by the Free Software
|
||||
Foundation. If the Program does not specify a version number of the
|
||||
GNU General Public License, you may choose any version ever published
|
||||
by the Free Software Foundation.
|
||||
Each version is given a distinguishing version number. If the Program
|
||||
specifies a version number of this License which applies to it and "any
|
||||
later version", you have the option of following the terms and conditions
|
||||
either of that version or of any later version published by the Free
|
||||
Software Foundation. If the Program does not specify a version number of
|
||||
this License, you may choose any version ever published by the Free Software
|
||||
Foundation.
|
||||
|
||||
If the Program specifies that a proxy can decide which future
|
||||
versions of the GNU General Public License can be used, that proxy's
|
||||
public statement of acceptance of a version permanently authorizes you
|
||||
to choose that version for the Program.
|
||||
10. If you wish to incorporate parts of the Program into other free
|
||||
programs whose distribution conditions are different, write to the author
|
||||
to ask for permission. For software which is copyrighted by the Free
|
||||
Software Foundation, write to the Free Software Foundation; we sometimes
|
||||
make exceptions for this. Our decision will be guided by the two goals
|
||||
of preserving the free status of all derivatives of our free software and
|
||||
of promoting the sharing and reuse of software generally.
|
||||
|
||||
Later license versions may give you additional or different
|
||||
permissions. However, no additional obligations are imposed on any
|
||||
author or copyright holder as a result of your choosing to follow a
|
||||
later version.
|
||||
NO WARRANTY
|
||||
|
||||
15. Disclaimer of Warranty.
|
||||
11. BECAUSE THE PROGRAM IS LICENSED FREE OF CHARGE, THERE IS NO WARRANTY
|
||||
FOR THE PROGRAM, TO THE EXTENT PERMITTED BY APPLICABLE LAW. EXCEPT WHEN
|
||||
OTHERWISE STATED IN WRITING THE COPYRIGHT HOLDERS AND/OR OTHER PARTIES
|
||||
PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY OF ANY KIND, EITHER EXPRESSED
|
||||
OR IMPLIED, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF
|
||||
MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE. THE ENTIRE RISK AS
|
||||
TO THE QUALITY AND PERFORMANCE OF THE PROGRAM IS WITH YOU. SHOULD THE
|
||||
PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF ALL NECESSARY SERVICING,
|
||||
REPAIR OR CORRECTION.
|
||||
|
||||
THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY
|
||||
APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT
|
||||
HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY
|
||||
OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO,
|
||||
THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR
|
||||
PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM
|
||||
IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF
|
||||
ALL NECESSARY SERVICING, REPAIR OR CORRECTION.
|
||||
|
||||
16. Limitation of Liability.
|
||||
|
||||
IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING
|
||||
WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS
|
||||
THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY
|
||||
GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE
|
||||
USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF
|
||||
DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD
|
||||
PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS),
|
||||
EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF
|
||||
SUCH DAMAGES.
|
||||
|
||||
17. Interpretation of Sections 15 and 16.
|
||||
|
||||
If the disclaimer of warranty and limitation of liability provided
|
||||
above cannot be given local legal effect according to their terms,
|
||||
reviewing courts shall apply local law that most closely approximates
|
||||
an absolute waiver of all civil liability in connection with the
|
||||
Program, unless a warranty or assumption of liability accompanies a
|
||||
copy of the Program in return for a fee.
|
||||
12. IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING
|
||||
WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MAY MODIFY AND/OR
|
||||
REDISTRIBUTE THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES,
|
||||
INCLUDING ANY GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING
|
||||
OUT OF THE USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED
|
||||
TO LOSS OF DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY
|
||||
YOU OR THIRD PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER
|
||||
PROGRAMS), EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE
|
||||
POSSIBILITY OF SUCH DAMAGES.
|
||||
|
||||
END OF TERMS AND CONDITIONS
|
||||
|
||||
|
|
@ -628,15 +287,15 @@ free software which everyone can redistribute and change under these terms.
|
|||
|
||||
To do so, attach the following notices to the program. It is safest
|
||||
to attach them to the start of each source file to most effectively
|
||||
state the exclusion of warranty; and each file should have at least
|
||||
convey the exclusion of warranty; and each file should have at least
|
||||
the "copyright" line and a pointer to where the full notice is found.
|
||||
|
||||
<one line to give the program's name and a brief idea of what it does.>
|
||||
Copyright (C) <year> <name of author>
|
||||
|
||||
This program is free software: you can redistribute it and/or modify
|
||||
This program is free software; you can redistribute it and/or modify
|
||||
it under the terms of the GNU General Public License as published by
|
||||
the Free Software Foundation, either version 3 of the License, or
|
||||
the Free Software Foundation; either version 2 of the License, or
|
||||
(at your option) any later version.
|
||||
|
||||
This program is distributed in the hope that it will be useful,
|
||||
|
|
@ -644,31 +303,37 @@ the "copyright" line and a pointer to where the full notice is found.
|
|||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
GNU General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU General Public License
|
||||
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
You should have received a copy of the GNU General Public License along
|
||||
with this program; if not, write to the Free Software Foundation, Inc.,
|
||||
51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA.
|
||||
|
||||
Also add information on how to contact you by electronic and paper mail.
|
||||
|
||||
If the program does terminal interaction, make it output a short
|
||||
notice like this when it starts in an interactive mode:
|
||||
If the program is interactive, make it output a short notice like this
|
||||
when it starts in an interactive mode:
|
||||
|
||||
<program> Copyright (C) <year> <name of author>
|
||||
This program comes with ABSOLUTELY NO WARRANTY; for details type `show w'.
|
||||
Gnomovision version 69, Copyright (C) year name of author
|
||||
Gnomovision comes with ABSOLUTELY NO WARRANTY; for details type `show w'.
|
||||
This is free software, and you are welcome to redistribute it
|
||||
under certain conditions; type `show c' for details.
|
||||
|
||||
The hypothetical commands `show w' and `show c' should show the appropriate
|
||||
parts of the General Public License. Of course, your program's commands
|
||||
might be different; for a GUI interface, you would use an "about box".
|
||||
parts of the General Public License. Of course, the commands you use may
|
||||
be called something other than `show w' and `show c'; they could even be
|
||||
mouse-clicks or menu items--whatever suits your program.
|
||||
|
||||
You should also get your employer (if you work as a programmer) or school,
|
||||
if any, to sign a "copyright disclaimer" for the program, if necessary.
|
||||
For more information on this, and how to apply and follow the GNU GPL, see
|
||||
<https://www.gnu.org/licenses/>.
|
||||
You should also get your employer (if you work as a programmer) or your
|
||||
school, if any, to sign a "copyright disclaimer" for the program, if
|
||||
necessary. Here is a sample; alter the names:
|
||||
|
||||
The GNU General Public License does not permit incorporating your program
|
||||
into proprietary programs. If your program is a subroutine library, you
|
||||
may consider it more useful to permit linking proprietary applications with
|
||||
the library. If this is what you want to do, use the GNU Lesser General
|
||||
Public License instead of this License. But first, please read
|
||||
<https://www.gnu.org/licenses/why-not-lgpl.html>.
|
||||
Yoyodyne, Inc., hereby disclaims all copyright interest in the program
|
||||
`Gnomovision' (which makes passes at compilers) written by James Hacker.
|
||||
|
||||
<signature of Ty Coon>, 1 April 1989
|
||||
Ty Coon, President of Vice
|
||||
|
||||
This General Public License does not permit incorporating your program into
|
||||
proprietary programs. If your program is a subroutine library, you may
|
||||
consider it more useful to permit linking proprietary applications with the
|
||||
library. If this is what you want to do, use the GNU Lesser General
|
||||
Public License instead of this License.
|
||||
|
|
|
|||
162
README.md
162
README.md
|
|
@ -1,162 +0,0 @@
|
|||
<img src="https://gitea.angry.im/PeterCxy/OpenEUICC/media/branch/master/art/OpenEUICCBG.svg" width="512" height="300">
|
||||
|
||||
A fully free and open-source Local Profile Assistant implementation for Android devices.
|
||||
|
||||
There are two variants of this project, OpenEUICC and [EasyEUICC](https://easyeuicc.org):
|
||||
|
||||
| | 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 |
|
||||
|
||||
[^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
|
||||
|
||||
```shell
|
||||
git submodule update --init
|
||||
```
|
||||
|
||||
A file `keystore.properties` is required in the root directory. Template:
|
||||
|
||||
```ini
|
||||
storePassword=my-store-password
|
||||
keyPassword=my-password
|
||||
keyAlias=my-key
|
||||
unprivKeyPassword=my-unpriv-password
|
||||
unprivKeyAlias=my-unpriv-key
|
||||
storeFile=/path/to/android/keystore
|
||||
```
|
||||
|
||||
Note that you must have a Java-compatible keystore generated first.
|
||||
|
||||
To build the privileged OpenEUICC:
|
||||
|
||||
```shell
|
||||
./gradlew :app:assembleRelease
|
||||
```
|
||||
|
||||
For EasyEUICC:
|
||||
|
||||
```shell
|
||||
./gradlew :app-unpriv:assembleRelease
|
||||
```
|
||||
|
||||
# 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.
|
||||
|
||||
[`privapp_whitelist_im.angry.openeuicc.xml`]: privapp_whitelist_im.angry.openeuicc.xml "OpenEUICC Privapp Whitelist"
|
||||
|
||||
# FAQs
|
||||
|
||||
- Q: Do you provide prebuilt binaries for OpenEUICC? \
|
||||
A: Debug-mode APKs and Magisk modules are available continuously as an artifact of the [Actions] CI used by this
|
||||
project. However, these debug-mode APKs are **not** intended for inclusion inside system images, nor are they
|
||||
supported by the developer in any sense. If you are a custom ROM developer, either include the entire OpenEUICC
|
||||
repository in your AOSP source tree, or generate an APK using `gradle` and import that as a prebuilt system app. Note
|
||||
that you might want [`privapp_whitelist_im.angry.openeuicc.xml`] as well.
|
||||
|
||||
- Q: Can EasyEUICC manage my phone's internal eSIM? \
|
||||
A: No. For EasyEUICC to work, the eSIM chip MUST proactively grant access via its ARA-M field.
|
||||
|
||||
- Q: Removable eSIMs? Are they a joke? \
|
||||
A: No, even though the name "removable embedded SIM" can sound like an oxymoron. In fact, there can be many advantages
|
||||
to these chips compared to fully embedded ones. For example, the ability to transfer eSIM profiles without carrier
|
||||
support or approval, or the ability to use eSIM on devices that do not and may never get the support, such as Wi-Fi
|
||||
hotspots.
|
||||
|
||||
# Copyright
|
||||
|
||||
Everything except `libs/lpac-jni` and `art/`:
|
||||
|
||||
```
|
||||
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
|
||||
as published by the Free Software Foundation, version 3.
|
||||
|
||||
This program is distributed in the hope that it will be useful,
|
||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
GNU General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU General Public License
|
||||
along with this program; if not, write to the Free Software
|
||||
Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
|
||||
```
|
||||
|
||||
`libs/lpac-jni`:
|
||||
|
||||
```
|
||||
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
|
||||
License as published by the Free Software Foundation, version 2.1.
|
||||
|
||||
This library is distributed in the hope that it will be useful,
|
||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
|
||||
Lesser General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU Lesser General Public
|
||||
License along with this library; if not, write to the Free Software
|
||||
Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA
|
||||
```
|
||||
|
||||
`art/`: Courtesy of [Aikoyori](https://github.com/Aikoyori), CC NC-SA 4.0.
|
||||
1
app-common/.gitignore
vendored
1
app-common/.gitignore
vendored
|
|
@ -1 +0,0 @@
|
|||
/build
|
||||
|
|
@ -1,37 +0,0 @@
|
|||
plugins {
|
||||
id("com.android.library")
|
||||
id("org.jetbrains.kotlin.android")
|
||||
}
|
||||
|
||||
android {
|
||||
namespace = "im.angry.openeuicc.common"
|
||||
compileSdk = 35
|
||||
|
||||
defaultConfig {
|
||||
minSdk = 28
|
||||
|
||||
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
|
||||
}
|
||||
|
||||
buildTypes {
|
||||
release {
|
||||
isMinifyEnabled = false
|
||||
proguardFiles(getDefaultProguardFile("proguard-android-optimize.txt"), "proguard-rules.pro")
|
||||
}
|
||||
}
|
||||
compileOptions {
|
||||
sourceCompatibility = JavaVersion.VERSION_1_8
|
||||
targetCompatibility = JavaVersion.VERSION_1_8
|
||||
}
|
||||
kotlinOptions {
|
||||
jvmTarget = "1.8"
|
||||
}
|
||||
}
|
||||
|
||||
dependencies {
|
||||
api(project(":libs:lpac-jni"))
|
||||
api(project(":app-deps"))
|
||||
testImplementation("junit:junit:4.13.2")
|
||||
androidTestImplementation("androidx.test.ext:junit:1.1.5")
|
||||
androidTestImplementation("androidx.test.espresso:espresso-core:3.5.1")
|
||||
}
|
||||
21
app-common/proguard-rules.pro
vendored
21
app-common/proguard-rules.pro
vendored
|
|
@ -1,21 +0,0 @@
|
|||
# Add project specific ProGuard rules here.
|
||||
# You can control the set of applied configuration files using the
|
||||
# proguardFiles setting in build.gradle.
|
||||
#
|
||||
# For more details, see
|
||||
# http://developer.android.com/guide/developing/tools/proguard.html
|
||||
|
||||
# If your project uses WebView with JS, uncomment the following
|
||||
# and specify the fully qualified class name to the JavaScript interface
|
||||
# class:
|
||||
#-keepclassmembers class fqcn.of.javascript.interface.for.webview {
|
||||
# public *;
|
||||
#}
|
||||
|
||||
# Uncomment this to preserve the line number information for
|
||||
# debugging stack traces.
|
||||
#-keepattributes SourceFile,LineNumberTable
|
||||
|
||||
# If you keep the line number information, uncomment this to
|
||||
# hide the original source file name.
|
||||
#-renamesourcefileattribute SourceFile
|
||||
|
|
@ -1,22 +0,0 @@
|
|||
package im.angry.openeuicc.common
|
||||
|
||||
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
|
||||
|
||||
/**
|
||||
* Instrumented test, which will execute on an Android device.
|
||||
*
|
||||
* See [testing documentation](http://d.android.com/tools/testing).
|
||||
*/
|
||||
@RunWith(AndroidJUnit4::class)
|
||||
class ExampleInstrumentedTest {
|
||||
@Test
|
||||
fun useAppContext() {
|
||||
// Context of the app under test.
|
||||
val appContext = InstrumentationRegistry.getInstrumentation().targetContext
|
||||
assertEquals("im.angry.openeuicc.common.test", appContext.packageName)
|
||||
}
|
||||
}
|
||||
|
|
@ -1,71 +0,0 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<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
|
||||
android:enableOnBackInvokedCallback="true"
|
||||
tools:targetApi="tiramisu">
|
||||
<activity
|
||||
android:name="im.angry.openeuicc.ui.SettingsActivity"
|
||||
android:label="@string/pref_settings" />
|
||||
|
||||
<activity
|
||||
android:name="im.angry.openeuicc.ui.NotificationsActivity"
|
||||
android:label="@string/profile_notifications" />
|
||||
|
||||
<activity
|
||||
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"
|
||||
tools:replace="screenOrientation" />
|
||||
|
||||
<service
|
||||
android:name="im.angry.openeuicc.service.EuiccChannelManagerService"
|
||||
android:exported="false"
|
||||
android:foregroundServiceType="shortService" />
|
||||
</application>
|
||||
</manifest>
|
||||
|
|
@ -1,19 +0,0 @@
|
|||
package im.angry.openeuicc
|
||||
|
||||
import android.app.Application
|
||||
import com.google.android.material.color.DynamicColors
|
||||
import im.angry.openeuicc.di.AppContainer
|
||||
import im.angry.openeuicc.di.DefaultAppContainer
|
||||
|
||||
open class OpenEuiccApplication : Application() {
|
||||
open val appContainer: AppContainer by lazy {
|
||||
DefaultAppContainer(this)
|
||||
}
|
||||
|
||||
override fun onCreate() {
|
||||
super.onCreate()
|
||||
|
||||
// Observe dynamic colors changes
|
||||
DynamicColors.applyToActivitiesIfAvailable(this)
|
||||
}
|
||||
}
|
||||
|
|
@ -1,5 +0,0 @@
|
|||
package im.angry.openeuicc.core
|
||||
|
||||
interface ApduInterfaceAtrProvider {
|
||||
val atr: ByteArray?
|
||||
}
|
||||
|
|
@ -1,91 +0,0 @@
|
|||
package im.angry.openeuicc.core
|
||||
|
||||
import android.content.Context
|
||||
import android.se.omapi.SEService
|
||||
import android.util.Log
|
||||
import im.angry.openeuicc.common.R
|
||||
import im.angry.openeuicc.core.usb.UsbApduInterface
|
||||
import im.angry.openeuicc.core.usb.UsbCcidContext
|
||||
import im.angry.openeuicc.util.*
|
||||
|
||||
open class DefaultEuiccChannelFactory(protected val context: Context) : EuiccChannelFactory {
|
||||
private var seService: SEService? = null
|
||||
|
||||
private suspend fun ensureSEService() {
|
||||
if (seService == null || !seService!!.isConnected) {
|
||||
seService = connectSEService(context)
|
||||
}
|
||||
}
|
||||
|
||||
override suspend fun tryOpenEuiccChannel(
|
||||
port: UiccPortInfoCompat,
|
||||
isdrAid: ByteArray,
|
||||
seId: EuiccChannel.SecureElementId,
|
||||
): EuiccChannel? = try {
|
||||
if (port.portIndex != 0) {
|
||||
Log.w(
|
||||
DefaultEuiccChannelManager.TAG,
|
||||
"OMAPI channel attempted on non-zero portId, this may or may not work."
|
||||
)
|
||||
}
|
||||
|
||||
ensureSEService()
|
||||
|
||||
Log.i(
|
||||
DefaultEuiccChannelManager.TAG,
|
||||
"Trying OMAPI for physical slot ${port.card.physicalSlotIndex}"
|
||||
)
|
||||
EuiccChannelImpl(
|
||||
context.getString(R.string.channel_type_omapi),
|
||||
port,
|
||||
OmapiApduInterface(
|
||||
seService!!,
|
||||
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(
|
||||
ccidCtx: UsbCcidContext,
|
||||
isdrAid: ByteArray,
|
||||
seId: EuiccChannel.SecureElementId
|
||||
): EuiccChannel? = try {
|
||||
EuiccChannelImpl(
|
||||
context.getString(R.string.channel_type_usb),
|
||||
FakeUiccPortInfoCompat(FakeUiccCardInfoCompat(EuiccChannelManager.USB_CHANNEL_ID)),
|
||||
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
|
||||
}
|
||||
}
|
||||
|
|
@ -1,415 +0,0 @@
|
|||
package im.angry.openeuicc.core
|
||||
|
||||
import android.content.Context
|
||||
import android.hardware.usb.UsbDevice
|
||||
import android.hardware.usb.UsbManager
|
||||
import android.telephony.SubscriptionManager
|
||||
import android.util.Log
|
||||
import im.angry.openeuicc.core.usb.UsbCcidContext
|
||||
import im.angry.openeuicc.core.usb.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.flow.Flow
|
||||
import kotlinx.coroutines.flow.first
|
||||
import kotlinx.coroutines.flow.flow
|
||||
import kotlinx.coroutines.flow.flowOn
|
||||
import kotlinx.coroutines.flow.merge
|
||||
import kotlinx.coroutines.sync.Mutex
|
||||
import kotlinx.coroutines.sync.withLock
|
||||
import kotlinx.coroutines.withContext
|
||||
import kotlinx.coroutines.withTimeout
|
||||
|
||||
open class DefaultEuiccChannelManager(
|
||||
protected val appContainer: AppContainer,
|
||||
protected val context: Context
|
||||
) : EuiccChannelManager {
|
||||
companion object {
|
||||
const val TAG = "EuiccChannelManager"
|
||||
}
|
||||
|
||||
private val channelCache = mutableListOf<EuiccChannel>()
|
||||
|
||||
private var usbChannels = mutableListOf<EuiccChannel>()
|
||||
|
||||
private val lock = Mutex()
|
||||
|
||||
protected val tm by lazy {
|
||||
appContainer.telephonyManager
|
||||
}
|
||||
|
||||
private val usbManager by lazy {
|
||||
context.getSystemService(Context.USB_SERVICE) as UsbManager
|
||||
}
|
||||
|
||||
private val euiccChannelFactory by lazy {
|
||||
appContainer.euiccChannelFactory
|
||||
}
|
||||
|
||||
protected open val uiccCards: Collection<UiccCardInfoCompat>
|
||||
get() = (0..<tm.activeModemCountCompat).map { FakeUiccCardInfoCompat(it) }
|
||||
|
||||
private suspend inline fun tryOpenChannelWithKnownAids(
|
||||
openFn: (ByteArray, EuiccChannel.SecureElementId) -> EuiccChannel?
|
||||
): List<EuiccChannel> {
|
||||
var isdrAidList =
|
||||
parseIsdrAidList(appContainer.preferenceRepository.isdrAidListFlow.first())
|
||||
val ret = mutableListOf<EuiccChannel>()
|
||||
val openedAids = mutableListOf<ByteArray>()
|
||||
var hasReset = false
|
||||
var vendorDecider: VendorAidDecider? = null
|
||||
var seId = 0
|
||||
|
||||
outer@ while (true) {
|
||||
for (aid in isdrAidList) {
|
||||
if (vendorDecider != null && !vendorDecider.shouldOpenMore(openedAids, aid)) {
|
||||
break@outer
|
||||
}
|
||||
|
||||
val channel =
|
||||
openFn(aid, EuiccChannel.SecureElementId.createFromInt(seId))?.let { channel ->
|
||||
if (channel.valid) {
|
||||
seId += 1
|
||||
channel
|
||||
} else {
|
||||
channel.close()
|
||||
null
|
||||
}
|
||||
}
|
||||
|
||||
if (!hasReset) {
|
||||
val res = channel?.queryVendorAidListTransformation(isdrAidList)
|
||||
if (res != null) {
|
||||
// Reset the for loop since we needed to replace the AID list due to vendor-specific code
|
||||
Log.i(TAG, "AID list replaced, resetting open attempt")
|
||||
isdrAidList = res.first
|
||||
vendorDecider = res.second
|
||||
seId = 0
|
||||
ret.clear()
|
||||
openedAids.clear()
|
||||
channel.close()
|
||||
hasReset = true // Don't let anything reset again
|
||||
continue@outer
|
||||
}
|
||||
}
|
||||
|
||||
if (channel != null) {
|
||||
ret.add(channel)
|
||||
openedAids.add(aid)
|
||||
|
||||
// Don't try opening more than 1 channel unless there is a vendor
|
||||
// implementation for deciding when we should stop opening more channels
|
||||
if (vendorDecider == null) {
|
||||
break@outer
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// If we get here we should exit, since the inner loop completed without resetting
|
||||
break
|
||||
}
|
||||
|
||||
// Set the hasMultipleSE field now since we only get to know that after we have iterated all AIDs
|
||||
// This also flips a flag in EuiccChannelImpl and prevents the field from being set again
|
||||
ret.forEach { it.hasMultipleSE = (seId > 1) }
|
||||
|
||||
return ret
|
||||
}
|
||||
|
||||
private suspend fun tryOpenEuiccChannel(
|
||||
port: UiccPortInfoCompat,
|
||||
): List<EuiccChannel>? {
|
||||
lock.withLock {
|
||||
if (port.card.physicalSlotIndex == EuiccChannelManager.USB_CHANNEL_ID) {
|
||||
return usbChannels
|
||||
}
|
||||
|
||||
// First get all channels for the requested port
|
||||
val existing =
|
||||
channelCache.filter { it.slotId == port.card.physicalSlotIndex && it.portId == port.portIndex }
|
||||
if (existing.isNotEmpty()) {
|
||||
if (existing.all { it.valid && it.logicalSlotId == port.logicalSlotIndex }) {
|
||||
return existing
|
||||
} else {
|
||||
// If any channel shouldn't be considered valid anymore, close all existing for the same slot / port
|
||||
// and reopen
|
||||
existing.forEach {
|
||||
it.close()
|
||||
channelCache.remove(it)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (port.logicalSlotIndex == SubscriptionManager.INVALID_SIM_SLOT_INDEX) {
|
||||
// We can only open channels on ports that are actually enabled
|
||||
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 (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."
|
||||
)
|
||||
return null
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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)?.find { it.seId == seId }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
null
|
||||
}
|
||||
|
||||
/**
|
||||
* Find all EuiccChannels associated with a _physical_ slot, including all secure elements
|
||||
* on cards with multiple of them.
|
||||
*/
|
||||
private suspend fun findAllEuiccChannelsByPhysicalSlot(physicalSlotId: Int): List<EuiccChannel>? {
|
||||
if (physicalSlotId == EuiccChannelManager.USB_CHANNEL_ID) {
|
||||
return usbChannels.ifEmpty { null }
|
||||
}
|
||||
|
||||
for (card in uiccCards) {
|
||||
if (card.physicalSlotIndex != physicalSlotId) continue
|
||||
return card.ports.mapNotNull { tryOpenEuiccChannel(it) }
|
||||
.flatten()
|
||||
.ifEmpty { null }
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
/**
|
||||
* Finds all EuiccChannels associated with a physical slot + port. Note that this
|
||||
* may return multiple in case there are multiple SEs.
|
||||
*/
|
||||
private suspend fun findEuiccChannelsByPort(
|
||||
physicalSlotId: Int,
|
||||
portId: Int,
|
||||
): List<EuiccChannel>? =
|
||||
withContext(Dispatchers.IO) {
|
||||
if (physicalSlotId == EuiccChannelManager.USB_CHANNEL_ID) {
|
||||
return@withContext usbChannels.ifEmpty { null }
|
||||
}
|
||||
|
||||
uiccCards.find { it.physicalSlotIndex == physicalSlotId }?.let { card ->
|
||||
card.ports.find { it.portIndex == portId }?.let { tryOpenEuiccChannel(it) }
|
||||
}
|
||||
}
|
||||
|
||||
override suspend fun findFirstAvailablePort(physicalSlotId: Int): Int =
|
||||
withContext(Dispatchers.IO) {
|
||||
if (physicalSlotId == EuiccChannelManager.USB_CHANNEL_ID) {
|
||||
return@withContext 0
|
||||
}
|
||||
|
||||
findAllEuiccChannelsByPhysicalSlot(physicalSlotId)?.getOrNull(0)?.portId ?: -1
|
||||
}
|
||||
|
||||
override suspend fun findAvailablePorts(physicalSlotId: Int): List<Int> =
|
||||
withContext(Dispatchers.IO) {
|
||||
if (physicalSlotId == EuiccChannelManager.USB_CHANNEL_ID) {
|
||||
return@withContext listOf(0)
|
||||
}
|
||||
|
||||
findAllEuiccChannelsByPhysicalSlot(physicalSlotId)?.map { it.portId }?.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) {
|
||||
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 {
|
||||
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"
|
||||
)
|
||||
resetChannels()
|
||||
}
|
||||
delay(1000)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun flowInternalEuiccPorts(): Flow<Pair<Int, Int>> = flow {
|
||||
uiccCards.forEach { info ->
|
||||
info.ports.forEach { port ->
|
||||
tryOpenEuiccChannel(port)?.also {
|
||||
Log.d(
|
||||
TAG,
|
||||
"Found eUICC on slot ${info.physicalSlotIndex} port ${port.portIndex}"
|
||||
)
|
||||
|
||||
emit(Pair(info.physicalSlotIndex, port.portIndex))
|
||||
}
|
||||
}
|
||||
}
|
||||
}.flowOn(Dispatchers.IO)
|
||||
|
||||
override fun flowAllOpenEuiccPorts(): Flow<Pair<Int, Int>> =
|
||||
merge(flowInternalEuiccPorts(), flow {
|
||||
if (tryOpenUsbEuiccChannel().second) {
|
||||
emit(Pair(EuiccChannelManager.USB_CHANNEL_ID, 0))
|
||||
}
|
||||
})
|
||||
|
||||
override 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.interfaces.smartCard ?: return@forEach
|
||||
// If we don't have permission, tell UI code that we found a candidate device, but we
|
||||
// need permission to be able to do anything with it
|
||||
if (!usbManager.hasPermission(device)) return@withContext Pair(device, false)
|
||||
Log.i(
|
||||
TAG,
|
||||
"Found CCID interface on ${device.deviceId}:${device.vendorId}, and has permission; trying to open channel"
|
||||
)
|
||||
|
||||
val ccidCtx =
|
||||
UsbCcidContext.createFromUsbDevice(context, device, iface) ?: return@forEach
|
||||
|
||||
try {
|
||||
val 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()
|
||||
}
|
||||
|
||||
ccidCtx.allowDisconnect = true
|
||||
ccidCtx.disconnect()
|
||||
|
||||
Log.i(
|
||||
TAG,
|
||||
"No valid eUICC channel found on USB device ${device.deviceId}:${device.vendorId}"
|
||||
)
|
||||
}
|
||||
return@withContext Pair(null, false)
|
||||
}
|
||||
|
||||
override fun invalidate() {
|
||||
for (channel in channelCache) {
|
||||
channel.close()
|
||||
}
|
||||
|
||||
usbChannels.forEach { it.close() }
|
||||
usbChannels.clear()
|
||||
channelCache.clear()
|
||||
euiccChannelFactory.cleanup()
|
||||
}
|
||||
}
|
||||
|
|
@ -1,10 +0,0 @@
|
|||
package im.angry.openeuicc.core
|
||||
|
||||
import android.app.Service
|
||||
import im.angry.openeuicc.di.AppContainer
|
||||
|
||||
class DefaultEuiccChannelManagerFactory(private val appContainer: AppContainer) :
|
||||
EuiccChannelManagerFactory {
|
||||
override fun createEuiccChannelManager(serviceContext: Service) =
|
||||
DefaultEuiccChannelManager(appContainer, serviceContext)
|
||||
}
|
||||
|
|
@ -1,99 +0,0 @@
|
|||
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
|
||||
|
||||
interface EuiccChannel {
|
||||
val type: String
|
||||
|
||||
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
|
||||
|
||||
/**
|
||||
* 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,27 +0,0 @@
|
|||
package im.angry.openeuicc.core
|
||||
|
||||
import im.angry.openeuicc.core.usb.UsbCcidContext
|
||||
import im.angry.openeuicc.util.*
|
||||
|
||||
// This class is here instead of inside DI because it contains a bit more logic than just
|
||||
// "dumb" dependency injection.
|
||||
interface EuiccChannelFactory {
|
||||
suspend fun tryOpenEuiccChannel(
|
||||
port: UiccPortInfoCompat,
|
||||
isdrAid: ByteArray,
|
||||
seId: EuiccChannel.SecureElementId
|
||||
): EuiccChannel?
|
||||
|
||||
fun tryOpenUsbEuiccChannel(
|
||||
ccidCtx: UsbCcidContext,
|
||||
isdrAid: ByteArray,
|
||||
seId: EuiccChannel.SecureElementId
|
||||
): EuiccChannel?
|
||||
|
||||
/**
|
||||
* Release all resources used by this EuiccChannelFactory
|
||||
* Note that the same instance may be reused; any resources allocated must be automatically
|
||||
* re-acquired when this happens
|
||||
*/
|
||||
fun cleanup()
|
||||
}
|
||||
|
|
@ -1,53 +0,0 @@
|
|||
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,109 +0,0 @@
|
|||
package im.angry.openeuicc.core
|
||||
|
||||
import android.hardware.usb.UsbDevice
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
|
||||
/**
|
||||
* EuiccChannelManager holds references to, and manages the lifecycles of, individual
|
||||
* APDU channels to SIM cards. The find* methods will create channels when needed, and
|
||||
* all opened channels will be held in an internal cache until invalidate() is called
|
||||
* or when this instance is destroyed.
|
||||
*
|
||||
* To precisely control the lifecycle of this object itself (and thus its cached channels),
|
||||
* all other components must access EuiccChannelManager objects through EuiccChannelManagerService.
|
||||
* Holding references independent of EuiccChannelManagerService is unsupported.
|
||||
*/
|
||||
interface EuiccChannelManager {
|
||||
companion object {
|
||||
const val USB_CHANNEL_ID = 99
|
||||
}
|
||||
|
||||
/**
|
||||
* 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()`.
|
||||
*/
|
||||
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 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 tryOpenUsbEuiccChannel(): Pair<UsbDevice?, Boolean>
|
||||
|
||||
/**
|
||||
* Wait for a slot + port to reconnect (i.e. become valid again)
|
||||
* If the port is currently valid, this function will return immediately.
|
||||
* On timeout, the caller can decide to either try again later, or alert the user with an error
|
||||
*/
|
||||
suspend fun waitForReconnect(physicalSlotId: Int, portId: Int, timeoutMillis: Long = 1000)
|
||||
|
||||
/**
|
||||
* Returns the first mapped & available port ID for a physical slot, or -1 if
|
||||
* not found.
|
||||
*/
|
||||
suspend fun findFirstAvailablePort(physicalSlotId: Int): Int
|
||||
|
||||
/**
|
||||
* Returns all mapped & available port IDs for a physical slot.
|
||||
*/
|
||||
suspend fun findAvailablePorts(physicalSlotId: Int): List<Int>
|
||||
|
||||
class EuiccChannelNotFoundException : Exception("EuiccChannel not found")
|
||||
|
||||
/**
|
||||
* Find a EuiccChannel by its slot and port, then run a callback with a reference to it.
|
||||
* The reference is not supposed to be held outside of the callback. This is enforced via
|
||||
* a wrapper object.
|
||||
*
|
||||
* The callback is run on Dispatchers.IO by default.
|
||||
*
|
||||
* If a channel for that slot / port is not found, EuiccChannelNotFoundException is thrown
|
||||
*/
|
||||
suspend fun <R> withEuiccChannel(physicalSlotId: Int, portId: Int, seId: EuiccChannel.SecureElementId, fn: suspend (EuiccChannel) -> R): R
|
||||
|
||||
/**
|
||||
* Same as withEuiccChannel(Int, Int, SecureElementId, (EuiccChannel) -> R) but instead uses logical slot ID
|
||||
*/
|
||||
suspend fun <R> withEuiccChannel(logicalSlotId: Int, seId: EuiccChannel.SecureElementId, fn: suspend (EuiccChannel) -> R): R
|
||||
|
||||
/**
|
||||
* Invalidate all EuiccChannels previously cached by this Manager
|
||||
*/
|
||||
fun invalidate()
|
||||
|
||||
/**
|
||||
* If possible, trigger the system to update the cached list of profiles
|
||||
* This is only expected to be implemented when the application is privileged
|
||||
* TODO: Remove this from the common interface
|
||||
*/
|
||||
suspend fun notifyEuiccProfilesChanged(logicalSlotId: Int) {
|
||||
// no-op by default
|
||||
}
|
||||
}
|
||||
|
|
@ -1,7 +0,0 @@
|
|||
package im.angry.openeuicc.core
|
||||
|
||||
import android.app.Service
|
||||
|
||||
interface EuiccChannelManagerFactory {
|
||||
fun createEuiccChannelManager(serviceContext: Service): EuiccChannelManager
|
||||
}
|
||||
|
|
@ -1,58 +0,0 @@
|
|||
package im.angry.openeuicc.core
|
||||
|
||||
import im.angry.openeuicc.util.*
|
||||
import net.typeblog.lpac_jni.ApduInterface
|
||||
import net.typeblog.lpac_jni.LocalProfileAssistant
|
||||
|
||||
class EuiccChannelWrapper(orig: EuiccChannel) : EuiccChannel {
|
||||
private var _inner: EuiccChannel? = orig
|
||||
|
||||
private val channel: EuiccChannel
|
||||
get() {
|
||||
if (_inner == null) {
|
||||
throw IllegalStateException("This wrapper has been invalidated")
|
||||
}
|
||||
|
||||
return _inner!!
|
||||
}
|
||||
|
||||
override val type: String
|
||||
get() = channel.type
|
||||
override val port: UiccPortInfoCompat
|
||||
get() = channel.port
|
||||
override val slotId: Int
|
||||
get() = channel.slotId
|
||||
override val logicalSlotId: Int
|
||||
get() = channel.logicalSlotId
|
||||
override val portId: Int
|
||||
get() = channel.portId
|
||||
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()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -1,62 +0,0 @@
|
|||
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
|
||||
}
|
||||
}
|
||||
|
|
@ -1,86 +0,0 @@
|
|||
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,
|
||||
private val verboseLoggingFlow: Flow<Boolean>
|
||||
) : ApduInterface, ApduInterfaceAtrProvider {
|
||||
companion object {
|
||||
const val TAG = "OmapiApduInterface"
|
||||
}
|
||||
|
||||
private lateinit var session: Session
|
||||
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()
|
||||
}
|
||||
|
||||
override fun disconnect() {
|
||||
session.close()
|
||||
}
|
||||
|
||||
override fun logicalChannelOpen(aid: ByteArray): Int {
|
||||
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) {
|
||||
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(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()}")
|
||||
}
|
||||
|
||||
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,220 +0,0 @@
|
|||
package im.angry.openeuicc.core.usb
|
||||
|
||||
import android.util.Log
|
||||
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 ccidCtx: UsbCcidContext
|
||||
) : ApduInterface, ApduInterfaceAtrProvider {
|
||||
companion object {
|
||||
private const val TAG = "UsbApduInterface"
|
||||
}
|
||||
|
||||
override val atr: ByteArray?
|
||||
get() = ccidCtx.atr
|
||||
|
||||
override val valid: Boolean
|
||||
get() = channels.isNotEmpty()
|
||||
|
||||
private var channels = mutableSetOf<Int>()
|
||||
|
||||
// ATR parser
|
||||
// Specs: ISO/IEC 7816-3:2006 8.2 Answer-to-Reset
|
||||
// See also: https://en.wikipedia.org/wiki/Answer_to_reset
|
||||
class ParsedAtr private constructor(val ts: Byte?, val t0: Byte?, val ta1: Byte?, val tb1: Byte?, val tc1: Byte?, val td1: Byte?, val ta2: Byte?, val tb2: Byte?, val tc2: Byte?, val td2: Byte?) {
|
||||
companion object {
|
||||
fun parse(atr: ByteArray): ParsedAtr {
|
||||
val ts = atr[0]
|
||||
val t0 = atr[1]
|
||||
val tx1 = arrayOf<Byte?>(null, null, null, null)
|
||||
val tx2 = arrayOf<Byte?>(null, null, null, null)
|
||||
var pointer = 2
|
||||
|
||||
for (i in 0..3) {
|
||||
if (t0.toInt() and (0x10 shl i) != 0) {
|
||||
tx1[i] = atr[pointer]
|
||||
pointer++
|
||||
}
|
||||
}
|
||||
|
||||
val td1 = tx1[3] ?: 0
|
||||
|
||||
for (i in 0..3) {
|
||||
if (td1.toInt() and (0x10 shl i) != 0) {
|
||||
tx2[i] = atr[pointer]
|
||||
pointer++
|
||||
}
|
||||
}
|
||||
|
||||
return ParsedAtr(ts=ts, t0=t0, ta1=tx1[0], tb1=tx1[1], tc1=tx1[2], td1=tx1[3],
|
||||
ta2=tx2[0], tb2=tx2[1], tc2=tx2[2], td2=tx2[3],
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun connect() {
|
||||
ccidCtx.connect()
|
||||
|
||||
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)
|
||||
}
|
||||
|
||||
// Send Terminal Capabilities
|
||||
// Specs: ETSI TS 102 221 v15.0.0 - 11.1.19 TERMINAL CAPABILITY
|
||||
val terminalCapabilities = buildCmd(
|
||||
0x80.toByte(), 0xaa.toByte(), 0x00, 0x00,
|
||||
"A9088100820101830107".decodeHex(),
|
||||
le = null,
|
||||
)
|
||||
transmitApduByChannel(terminalCapabilities, 0)
|
||||
}
|
||||
|
||||
override fun disconnect() = ccidCtx.disconnect()
|
||||
|
||||
override fun logicalChannelOpen(aid: ByteArray): Int {
|
||||
// OPEN LOGICAL CHANNEL
|
||||
val req = manageChannelCmd(true, 0)
|
||||
|
||||
val resp = try {
|
||||
transmitApduByChannel(req, 0)
|
||||
} catch (e: Exception) {
|
||||
e.printStackTrace()
|
||||
return -1
|
||||
}
|
||||
|
||||
if (!isSuccessResponse(resp)) {
|
||||
Log.d(TAG, "OPEN LOGICAL CHANNEL failed: ${resp.encodeHex()}")
|
||||
return -1
|
||||
}
|
||||
|
||||
val channelId = resp[0].toInt()
|
||||
Log.d(TAG, "channelId = $channelId")
|
||||
channels.add(channelId)
|
||||
|
||||
// Then, select AID
|
||||
val selectAid = selectByDfCmd(aid, channelId.toByte())
|
||||
val selectAidResp = transmitApduByChannel(selectAid, channelId.toByte())
|
||||
|
||||
if (!isSuccessResponse(selectAidResp)) {
|
||||
Log.d(TAG, "Select DF failed : ${selectAidResp.encodeHex()}")
|
||||
logicalChannelClose(channelId)
|
||||
Log.d(TAG, "Closed logical channel $channelId due to select DF failure")
|
||||
return -1
|
||||
}
|
||||
|
||||
return channelId
|
||||
}
|
||||
|
||||
override fun logicalChannelClose(handle: Int) {
|
||||
check(channels.contains(handle)) {
|
||||
"Invalid logical channel handle $handle"
|
||||
}
|
||||
// CLOSE LOGICAL CHANNEL
|
||||
val req = manageChannelCmd(false, handle.toByte())
|
||||
val resp = transmitApduByChannel(req, handle.toByte())
|
||||
|
||||
if (!isSuccessResponse(resp)) {
|
||||
Log.d(TAG, "CLOSE LOGICAL CHANNEL failed: ${resp.encodeHex()}")
|
||||
}
|
||||
channels.remove(handle)
|
||||
}
|
||||
|
||||
override fun transmit(handle: Int, tx: ByteArray): ByteArray {
|
||||
check(channels.contains(handle)) {
|
||||
"Invalid logical channel handle $handle"
|
||||
}
|
||||
return transmitApduByChannel(tx, handle.toByte())
|
||||
}
|
||||
|
||||
private fun isSuccessResponse(resp: ByteArray): Boolean =
|
||||
resp.size >= 2 && resp[resp.size - 2] == 0x90.toByte() && resp[resp.size - 1] == 0x00.toByte()
|
||||
|
||||
private fun buildCmd(cla: Byte, ins: Byte, p1: Byte, p2: Byte, data: ByteArray?, le: Byte?) =
|
||||
byteArrayOf(cla, ins, p1, p2).let {
|
||||
if (data != null) {
|
||||
it + data.size.toByte() + data
|
||||
} else {
|
||||
it
|
||||
}
|
||||
}.let {
|
||||
if (le != null) {
|
||||
it + byteArrayOf(le)
|
||||
} else {
|
||||
it
|
||||
}
|
||||
}
|
||||
|
||||
private fun manageChannelCmd(open: Boolean, channel: Byte) =
|
||||
if (open) {
|
||||
buildCmd(0x00, 0x70, 0x00, 0x00, null, 0x01)
|
||||
} else {
|
||||
buildCmd(channel, 0x70, 0x80.toByte(), channel, null, null)
|
||||
}
|
||||
|
||||
private fun selectByDfCmd(aid: ByteArray, channel: Byte) =
|
||||
buildCmd(channel, 0xA4.toByte(), 0x04, 0x00, aid, null)
|
||||
|
||||
private fun transmitApduByChannel(tx: ByteArray, channel: Byte): ByteArray {
|
||||
val realTx = tx.copyOf()
|
||||
// OR the channel mask into the CLA byte
|
||||
realTx[0] = ((realTx[0].toInt() and 0xFC) or channel.toInt()).toByte()
|
||||
|
||||
var resp = ccidCtx.transceiver.sendXfrBlock(realTx).data!!
|
||||
|
||||
if (resp.size < 2) throw RuntimeException("APDU response smaller than 2 (sw1 + sw2)!")
|
||||
|
||||
var sw1 = resp[resp.size - 2].toInt() and 0xFF
|
||||
var sw2 = resp[resp.size - 1].toInt() and 0xFF
|
||||
|
||||
if (sw1 == 0x6C) {
|
||||
// 0x6C = wrong le
|
||||
// so we fix the le field here
|
||||
realTx[realTx.size - 1] = resp[resp.size - 1]
|
||||
resp = ccidCtx.transceiver.sendXfrBlock(realTx).data!!
|
||||
} else if (sw1 == 0x61) {
|
||||
// 0x61 = X bytes available
|
||||
// continue reading by GET RESPONSE
|
||||
do {
|
||||
// GET RESPONSE
|
||||
val getResponseCmd = byteArrayOf(
|
||||
realTx[0], 0xC0.toByte(), 0x00, 0x00, sw2.toByte()
|
||||
)
|
||||
|
||||
val tmp = ccidCtx.transceiver.sendXfrBlock(getResponseCmd).data!!
|
||||
|
||||
resp = resp.sliceArray(0 until (resp.size - 2)) + tmp
|
||||
|
||||
sw1 = resp[resp.size - 2].toInt() and 0xFF
|
||||
sw2 = resp[resp.size - 1].toInt() and 0xFF
|
||||
} while (sw1 == 0x61)
|
||||
}
|
||||
|
||||
return resp
|
||||
}
|
||||
}
|
||||
|
|
@ -1,85 +0,0 @@
|
|||
package im.angry.openeuicc.core.usb
|
||||
|
||||
import android.content.Context
|
||||
import android.hardware.usb.UsbDevice
|
||||
import android.hardware.usb.UsbDeviceConnection
|
||||
import android.hardware.usb.UsbEndpoint
|
||||
import android.hardware.usb.UsbInterface
|
||||
import android.hardware.usb.UsbManager
|
||||
import im.angry.openeuicc.util.preferenceRepository
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
|
||||
/**
|
||||
* A wrapper over an usb device + interface, manages the lifecycle independent
|
||||
* of the APDU interface exposed to lpac-jni.
|
||||
*
|
||||
* This allows us to try multiple AIDs on each interface without opening / closing
|
||||
* the USB connection numerous times.
|
||||
*/
|
||||
class UsbCcidContext private constructor(
|
||||
private val conn: UsbDeviceConnection,
|
||||
private val bulkIn: UsbEndpoint,
|
||||
private val bulkOut: UsbEndpoint,
|
||||
val 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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -1,100 +0,0 @@
|
|||
package im.angry.openeuicc.core.usb
|
||||
|
||||
import java.nio.ByteBuffer
|
||||
import java.nio.ByteOrder
|
||||
|
||||
@Suppress("unused")
|
||||
data class UsbCcidDescription(
|
||||
private val bMaxSlotIndex: Byte,
|
||||
private val bVoltageSupport: Byte,
|
||||
private val dwProtocols: Int,
|
||||
private val dwFeatures: Int
|
||||
) {
|
||||
companion object {
|
||||
private const val DESCRIPTOR_LENGTH: Byte = 0x36
|
||||
private const val DESCRIPTOR_TYPE: Byte = 0x21
|
||||
|
||||
// dwFeatures Masks
|
||||
private const val FEATURE_AUTOMATIC_VOLTAGE = 0x00008
|
||||
private const val FEATURE_AUTOMATIC_PPS = 0x00080
|
||||
|
||||
private const val FEATURE_EXCHANGE_LEVEL_TPDU = 0x10000
|
||||
private const val FEATURE_EXCHANGE_LEVEL_SHORT_APDU = 0x20000
|
||||
private const val FEATURE_EXCHANGE_LEVEL_EXTENDED_APDU = 0x40000
|
||||
|
||||
// bVoltageSupport Masks
|
||||
private const val VOLTAGE_5V0: Byte = 1
|
||||
private const val VOLTAGE_3V0: Byte = 2
|
||||
private const val VOLTAGE_1V8: Byte = 4
|
||||
|
||||
private const val SLOT_OFFSET = 4
|
||||
private const val FEATURES_OFFSET = 40
|
||||
private const val MASK_T0_PROTO = 1
|
||||
private const val MASK_T1_PROTO = 2
|
||||
|
||||
fun fromRawDescriptors(desc: ByteArray): UsbCcidDescription? {
|
||||
var dwProtocols = 0
|
||||
var dwFeatures = 0
|
||||
var bMaxSlotIndex: Byte = 0
|
||||
var bVoltageSupport: Byte = 0
|
||||
|
||||
var hasCcidDescriptor = false
|
||||
|
||||
val byteBuffer = ByteBuffer.wrap(desc).order(ByteOrder.LITTLE_ENDIAN)
|
||||
|
||||
while (byteBuffer.hasRemaining()) {
|
||||
byteBuffer.mark()
|
||||
val len = byteBuffer.get()
|
||||
val type = byteBuffer.get()
|
||||
if (type == DESCRIPTOR_TYPE && len == DESCRIPTOR_LENGTH) {
|
||||
byteBuffer.reset()
|
||||
byteBuffer.position(byteBuffer.position() + SLOT_OFFSET)
|
||||
bMaxSlotIndex = byteBuffer.get()
|
||||
bVoltageSupport = byteBuffer.get()
|
||||
dwProtocols = byteBuffer.int
|
||||
byteBuffer.reset()
|
||||
byteBuffer.position(byteBuffer.position() + FEATURES_OFFSET)
|
||||
dwFeatures = byteBuffer.int
|
||||
hasCcidDescriptor = true
|
||||
break
|
||||
} else {
|
||||
byteBuffer.position(byteBuffer.position() + len - 2)
|
||||
}
|
||||
}
|
||||
|
||||
return if (hasCcidDescriptor) {
|
||||
UsbCcidDescription(bMaxSlotIndex, bVoltageSupport, dwProtocols, dwFeatures)
|
||||
} else {
|
||||
null
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
enum class Voltage(powerOnValue: Int, mask: Int) {
|
||||
// @formatter:off
|
||||
AUTO(0, 0),
|
||||
V50(1, VOLTAGE_5V0.toInt()),
|
||||
V30(2, VOLTAGE_3V0.toInt()),
|
||||
V18(3, VOLTAGE_1V8.toInt());
|
||||
// @formatter:on
|
||||
|
||||
val mask = powerOnValue.toByte()
|
||||
val powerOnValue = mask.toByte()
|
||||
}
|
||||
|
||||
private fun hasFeature(feature: Int) = (dwFeatures and feature) != 0
|
||||
|
||||
val 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
|
||||
}
|
||||
|
|
@ -1,419 +0,0 @@
|
|||
package im.angry.openeuicc.core.usb
|
||||
|
||||
import android.hardware.usb.UsbDeviceConnection
|
||||
import android.hardware.usb.UsbEndpoint
|
||||
import android.os.SystemClock
|
||||
import android.util.Log
|
||||
import im.angry.openeuicc.util.encodeHex
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.first
|
||||
import kotlinx.coroutines.runBlocking
|
||||
import java.nio.ByteBuffer
|
||||
import java.nio.ByteOrder
|
||||
|
||||
|
||||
/**
|
||||
* Provides raw, APDU-agnostic transmission to the CCID reader
|
||||
* Adapted from <https://github.com/open-keychain/open-keychain/blob/master/OpenKeychain/src/main/java/org/sufficientlysecure/keychain/securitytoken/usb/CcidTransceiver.java>
|
||||
*/
|
||||
@Suppress("unused")
|
||||
class UsbCcidTransceiver(
|
||||
private val usbConnection: UsbDeviceConnection,
|
||||
private val usbBulkIn: UsbEndpoint,
|
||||
private val usbBulkOut: UsbEndpoint,
|
||||
private val usbCcidDescription: UsbCcidDescription,
|
||||
private val verboseLoggingFlow: Flow<Boolean>
|
||||
) {
|
||||
companion object {
|
||||
private const val TAG = "UsbCcidTransceiver"
|
||||
|
||||
private const val CCID_HEADER_LENGTH = 10
|
||||
|
||||
private const val MESSAGE_TYPE_RDR_TO_PC_DATA_BLOCK = 0x80
|
||||
private const val MESSAGE_TYPE_PC_TO_RDR_ICC_POWER_ON = 0x62
|
||||
private const val MESSAGE_TYPE_PC_TO_RDR_ICC_POWER_OFF = 0x63
|
||||
private const val MESSAGE_TYPE_PC_TO_RDR_XFR_BLOCK = 0x6f
|
||||
|
||||
private const val COMMAND_STATUS_SUCCESS: Byte = 0
|
||||
private const val COMMAND_STATUS_TIME_EXTENSION_RQUESTED: Byte = 2
|
||||
|
||||
/**
|
||||
* Level Parameter: APDU is a single command.
|
||||
*
|
||||
* "the command APDU begins and ends with this command"
|
||||
* -- DWG Smart-Card USB Integrated Circuit(s) Card Devices rev 1.0
|
||||
* § 6.1.1.3
|
||||
*/
|
||||
const val LEVEL_PARAM_START_SINGLE_CMD_APDU: Short = 0x0000
|
||||
|
||||
/**
|
||||
* Level Parameter: First APDU in a multi-command APDU.
|
||||
*
|
||||
* "the command APDU begins with this command, and continue in the
|
||||
* next PC_to_RDR_XfrBlock"
|
||||
* -- DWG Smart-Card USB Integrated Circuit(s) Card Devices rev 1.0
|
||||
* § 6.1.1.3
|
||||
*/
|
||||
const val LEVEL_PARAM_START_MULTI_CMD_APDU: Short = 0x0001
|
||||
|
||||
/**
|
||||
* Level Parameter: Final APDU in a multi-command APDU.
|
||||
*
|
||||
* "this abData field continues a command APDU and ends the command APDU"
|
||||
* -- DWG Smart-Card USB Integrated Circuit(s) Card Devices rev 1.0
|
||||
* § 6.1.1.3
|
||||
*/
|
||||
const val LEVEL_PARAM_END_MULTI_CMD_APDU: Short = 0x0002
|
||||
|
||||
/**
|
||||
* Level Parameter: Next command in a multi-command APDU.
|
||||
*
|
||||
* "the abData field continues a command APDU and another block is to follow"
|
||||
* -- DWG Smart-Card USB Integrated Circuit(s) Card Devices rev 1.0
|
||||
* § 6.1.1.3
|
||||
*/
|
||||
const val LEVEL_PARAM_CONTINUE_MULTI_CMD_APDU: Short = 0x0003
|
||||
|
||||
/**
|
||||
* Level Parameter: Request the device continue sending APDU.
|
||||
*
|
||||
* "empty abData field, continuation of response APDU is expected in the next
|
||||
* RDR_to_PC_DataBlock"
|
||||
* -- DWG Smart-Card USB Integrated Circuit(s) Card Devices rev 1.0
|
||||
* § 6.1.1.3
|
||||
*/
|
||||
const val LEVEL_PARAM_CONTINUE_RESPONSE: Short = 0x0010
|
||||
|
||||
private const val SLOT_NUMBER = 0x00
|
||||
|
||||
private const val ICC_STATUS_SUCCESS: Byte = 0
|
||||
|
||||
private const val DEVICE_COMMUNICATE_TIMEOUT_MILLIS = 5000
|
||||
private const val DEVICE_SKIP_TIMEOUT_MILLIS = 100
|
||||
}
|
||||
|
||||
data class UsbCcidErrorException(val msg: String, val errorResponse: CcidDataBlock) :
|
||||
Exception(msg)
|
||||
|
||||
@Suppress("ArrayInDataClass")
|
||||
data class CcidDataBlock(
|
||||
val dwLength: Int,
|
||||
val bSlot: Byte,
|
||||
val bSeq: Byte,
|
||||
val bStatus: Byte,
|
||||
val bError: Byte,
|
||||
val bChainParameter: Byte,
|
||||
val data: ByteArray?
|
||||
) {
|
||||
companion object {
|
||||
fun parseHeaderFromBytes(headerBytes: ByteArray): CcidDataBlock {
|
||||
val buf = ByteBuffer.wrap(headerBytes)
|
||||
buf.order(ByteOrder.LITTLE_ENDIAN)
|
||||
|
||||
val type = buf.get()
|
||||
require(type == MESSAGE_TYPE_RDR_TO_PC_DATA_BLOCK.toByte()) { "Header has incorrect type value!" }
|
||||
val dwLength = buf.int
|
||||
val bSlot = buf.get()
|
||||
val bSeq = buf.get()
|
||||
val bStatus = buf.get()
|
||||
val bError = buf.get()
|
||||
val bChainParameter = buf.get()
|
||||
|
||||
return CcidDataBlock(dwLength, bSlot, bSeq, bStatus, bError, bChainParameter, null)
|
||||
}
|
||||
}
|
||||
|
||||
fun withData(d: ByteArray): CcidDataBlock {
|
||||
require(data == null) { "Cannot add data twice" }
|
||||
return CcidDataBlock(dwLength, bSlot, bSeq, bStatus, bError, bChainParameter, d)
|
||||
}
|
||||
|
||||
val iccStatus: Byte
|
||||
get() = (bStatus.toInt() and 0x03).toByte()
|
||||
|
||||
val commandStatus: Byte
|
||||
get() = ((bStatus.toInt() shr 6) and 0x03).toByte()
|
||||
|
||||
val isStatusTimeoutExtensionRequest: Boolean
|
||||
get() = commandStatus == COMMAND_STATUS_TIME_EXTENSION_RQUESTED
|
||||
|
||||
val isStatusSuccess: Boolean
|
||||
get() = iccStatus == ICC_STATUS_SUCCESS && commandStatus == COMMAND_STATUS_SUCCESS
|
||||
}
|
||||
|
||||
val hasAutomaticPps = usbCcidDescription.hasAutomaticPps
|
||||
|
||||
val isTpdu = usbCcidDescription.isTpdu
|
||||
|
||||
private val inputBuffer = ByteArray(usbBulkIn.maxPacketSize)
|
||||
|
||||
private var currentSequenceNumber: Byte = 0
|
||||
|
||||
private fun sendRaw(data: ByteArray, offset: Int, length: Int) {
|
||||
val tr1 = usbConnection.bulkTransfer(
|
||||
usbBulkOut, data, offset, length, DEVICE_COMMUNICATE_TIMEOUT_MILLIS
|
||||
)
|
||||
if (tr1 != length) {
|
||||
throw UsbTransportException(
|
||||
"USB error - failed to transmit data ($tr1/$length)"
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private fun 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 {
|
||||
response = receiveDataBlockImmediate(expectedSequenceNumber)
|
||||
} while (response!!.isStatusTimeoutExtensionRequest)
|
||||
if (!response.isStatusSuccess) {
|
||||
throw UsbCcidErrorException("USB-CCID error!", response)
|
||||
}
|
||||
return response
|
||||
}
|
||||
|
||||
private fun receiveDataBlockImmediate(expectedSequenceNumber: Byte): CcidDataBlock {
|
||||
/*
|
||||
* Some USB CCID devices (notably NitroKey 3) may time-out and need a subsequent poke to
|
||||
* carry on communications. No particular reason why the number 3 was chosen. If we get a
|
||||
* zero-sized reply (or a time-out), we try again. Clamped retries prevent an infinite loop
|
||||
* if things really turn sour.
|
||||
*/
|
||||
var attempts = 3
|
||||
Log.d(TAG, "Receive data block immediate seq=$expectedSequenceNumber")
|
||||
var readBytes: Int
|
||||
do {
|
||||
readBytes = usbConnection.bulkTransfer(
|
||||
usbBulkIn, inputBuffer, inputBuffer.size, DEVICE_COMMUNICATE_TIMEOUT_MILLIS
|
||||
)
|
||||
if (runBlocking { verboseLoggingFlow.first() }) {
|
||||
Log.d(TAG, "Received $readBytes bytes: ${inputBuffer.encodeHex()}")
|
||||
}
|
||||
} while (readBytes <= 0 && attempts-- > 0)
|
||||
if (readBytes < CCID_HEADER_LENGTH) {
|
||||
throw UsbTransportException("USB-CCID error - failed to receive CCID header")
|
||||
}
|
||||
if (inputBuffer[0] != MESSAGE_TYPE_RDR_TO_PC_DATA_BLOCK.toByte()) {
|
||||
throw UsbTransportException(buildString {
|
||||
append("USB-CCID error - bad CCID header")
|
||||
append(", type ")
|
||||
append("%d (expected %d)".format(inputBuffer[0], MESSAGE_TYPE_RDR_TO_PC_DATA_BLOCK))
|
||||
if (expectedSequenceNumber != inputBuffer[6]) {
|
||||
append(", sequence number ")
|
||||
append("%d (expected %d)".format(inputBuffer[6], expectedSequenceNumber))
|
||||
}
|
||||
})
|
||||
}
|
||||
var result = CcidDataBlock.parseHeaderFromBytes(inputBuffer)
|
||||
if (expectedSequenceNumber != result.bSeq) {
|
||||
throw UsbTransportException("USB-CCID error - expected sequence number $expectedSequenceNumber, got $result")
|
||||
}
|
||||
|
||||
val dataBuffer = ByteArray(result.dwLength)
|
||||
var bufferedBytes = readBytes - CCID_HEADER_LENGTH
|
||||
System.arraycopy(inputBuffer, CCID_HEADER_LENGTH, dataBuffer, 0, bufferedBytes)
|
||||
while (bufferedBytes < dataBuffer.size) {
|
||||
readBytes = usbConnection.bulkTransfer(
|
||||
usbBulkIn, inputBuffer, inputBuffer.size, DEVICE_COMMUNICATE_TIMEOUT_MILLIS
|
||||
)
|
||||
if (readBytes < 0) {
|
||||
throw UsbTransportException("USB error - failed reading response data! Header: $result")
|
||||
}
|
||||
System.arraycopy(inputBuffer, 0, dataBuffer, bufferedBytes, readBytes)
|
||||
bufferedBytes += readBytes
|
||||
}
|
||||
result = result.withData(dataBuffer)
|
||||
return result
|
||||
}
|
||||
|
||||
|
||||
private fun skipAvailableInput() {
|
||||
var ignoredBytes: Int
|
||||
do {
|
||||
ignoredBytes = usbConnection.bulkTransfer(
|
||||
usbBulkIn, inputBuffer, inputBuffer.size, DEVICE_SKIP_TIMEOUT_MILLIS
|
||||
)
|
||||
if (ignoredBytes > 0) {
|
||||
Log.e(TAG, "Skipped $ignoredBytes bytes")
|
||||
}
|
||||
} while (ignoredBytes > 0)
|
||||
}
|
||||
|
||||
/**
|
||||
* Receives a continued XfrBlock. Should be called when a multiblock response is indicated
|
||||
* 6.1.4 PC_to_RDR_XfrBlock
|
||||
*/
|
||||
fun receiveContinuedResponse(): CcidDataBlock {
|
||||
return sendXfrBlock(ByteArray(0), LEVEL_PARAM_CONTINUE_RESPONSE)
|
||||
}
|
||||
|
||||
/**
|
||||
* Transmits XfrBlock
|
||||
* 6.1.4 PC_to_RDR_XfrBlock
|
||||
*
|
||||
* @param payload payload to transmit
|
||||
* @param levelParam Level parameter
|
||||
*/
|
||||
fun sendXfrBlock(
|
||||
payload: ByteArray,
|
||||
levelParam: Short = LEVEL_PARAM_START_SINGLE_CMD_APDU
|
||||
): CcidDataBlock {
|
||||
val startTime = SystemClock.elapsedRealtime()
|
||||
val l = payload.size
|
||||
val sequenceNumber: Byte = currentSequenceNumber++
|
||||
val headerData = byteArrayOf(
|
||||
MESSAGE_TYPE_PC_TO_RDR_XFR_BLOCK.toByte(),
|
||||
l.toByte(),
|
||||
(l shr 8).toByte(),
|
||||
(l shr 16).toByte(),
|
||||
(l shr 24).toByte(),
|
||||
SLOT_NUMBER.toByte(),
|
||||
sequenceNumber,
|
||||
0x00.toByte(),
|
||||
(levelParam.toInt() and 0x00ff).toByte(),
|
||||
(levelParam.toInt() shr 8).toByte()
|
||||
)
|
||||
val data: ByteArray = headerData + payload
|
||||
var sentBytes = 0
|
||||
while (sentBytes < data.size) {
|
||||
val bytesToSend = usbBulkOut.maxPacketSize.coerceAtMost(data.size - sentBytes)
|
||||
sendRaw(data, sentBytes, bytesToSend)
|
||||
sentBytes += bytesToSend
|
||||
}
|
||||
val ccidDataBlock = receiveDataBlock(sequenceNumber)
|
||||
val elapsedTime = SystemClock.elapsedRealtime() - startTime
|
||||
Log.d(TAG, "USB XferBlock call took ${elapsedTime}ms")
|
||||
return ccidDataBlock
|
||||
}
|
||||
|
||||
fun sendParamBlock(
|
||||
payload: ByteArray
|
||||
): ByteArray {
|
||||
val startTime = SystemClock.elapsedRealtime()
|
||||
val l = payload.size
|
||||
val sequenceNumber: Byte = currentSequenceNumber++
|
||||
val headerData = byteArrayOf(
|
||||
0x61.toByte(),
|
||||
l.toByte(),
|
||||
(l shr 8).toByte(),
|
||||
(l shr 16).toByte(),
|
||||
(l shr 24).toByte(),
|
||||
SLOT_NUMBER.toByte(),
|
||||
sequenceNumber,
|
||||
0x00.toByte(),
|
||||
0x00.toByte(),
|
||||
0x00.toByte()
|
||||
)
|
||||
val data: ByteArray = headerData + payload
|
||||
Log.d(TAG, "USB ParamBlock: ${data.encodeHex()}")
|
||||
var sentBytes = 0
|
||||
while (sentBytes < data.size) {
|
||||
val bytesToSend = usbBulkOut.maxPacketSize.coerceAtMost(data.size - sentBytes)
|
||||
sendRaw(data, sentBytes, bytesToSend)
|
||||
sentBytes += bytesToSend
|
||||
}
|
||||
val ccidDataBlock = receiveParamBlock(sequenceNumber)
|
||||
val elapsedTime = SystemClock.elapsedRealtime() - startTime
|
||||
Log.d(TAG, "USB ParamBlock call took ${elapsedTime}ms")
|
||||
return ccidDataBlock
|
||||
}
|
||||
|
||||
fun iccPowerOn(): CcidDataBlock {
|
||||
val startTime = SystemClock.elapsedRealtime()
|
||||
skipAvailableInput()
|
||||
var response: CcidDataBlock? = null
|
||||
for (voltage in usbCcidDescription.voltages) {
|
||||
Log.v(TAG, "CCID: attempting to power on with voltage $voltage")
|
||||
response = try {
|
||||
iccPowerOnVoltage(voltage.powerOnValue)
|
||||
} catch (e: UsbCcidErrorException) {
|
||||
if (e.errorResponse.bError.toInt() == 7) { // Power select error
|
||||
Log.v(TAG, "CCID: failed to power on with voltage $voltage")
|
||||
iccPowerOff()
|
||||
Log.v(TAG, "CCID: powered off")
|
||||
continue
|
||||
}
|
||||
throw e
|
||||
}
|
||||
break
|
||||
}
|
||||
if (response == null) {
|
||||
throw UsbTransportException("Couldn't power up ICC2")
|
||||
}
|
||||
val elapsedTime = SystemClock.elapsedRealtime() - startTime
|
||||
Log.d(
|
||||
TAG,
|
||||
buildString {
|
||||
append("Usb transport connected")
|
||||
append(", took ", elapsedTime, "ms")
|
||||
append(", ATR=", response.data?.encodeHex())
|
||||
}
|
||||
)
|
||||
return response
|
||||
}
|
||||
|
||||
private fun iccPowerOnVoltage(voltage: Byte): CcidDataBlock {
|
||||
val sequenceNumber = currentSequenceNumber++
|
||||
val iccPowerCommand = byteArrayOf(
|
||||
MESSAGE_TYPE_PC_TO_RDR_ICC_POWER_ON.toByte(),
|
||||
0x00, 0x00, 0x00, 0x00,
|
||||
SLOT_NUMBER.toByte(),
|
||||
sequenceNumber,
|
||||
voltage,
|
||||
0x00, 0x00 // reserved for future use
|
||||
)
|
||||
sendRaw(iccPowerCommand, 0, iccPowerCommand.size)
|
||||
return receiveDataBlock(sequenceNumber)
|
||||
}
|
||||
|
||||
private fun iccPowerOff() {
|
||||
val sequenceNumber = currentSequenceNumber++
|
||||
val iccPowerCommand = byteArrayOf(
|
||||
MESSAGE_TYPE_PC_TO_RDR_ICC_POWER_OFF.toByte(),
|
||||
0x00, 0x00, 0x00, 0x00,
|
||||
0x00,
|
||||
sequenceNumber,
|
||||
0x00
|
||||
)
|
||||
sendRaw(iccPowerCommand, 0, iccPowerCommand.size)
|
||||
}
|
||||
}
|
||||
|
|
@ -1,27 +0,0 @@
|
|||
// Adapted from <https://github.com/open-keychain/open-keychain/blob/master/OpenKeychain/src/main/java/org/sufficientlysecure/keychain/securitytoken/usb>
|
||||
package im.angry.openeuicc.core.usb
|
||||
|
||||
import android.hardware.usb.UsbConstants
|
||||
import android.hardware.usb.UsbDevice
|
||||
import android.hardware.usb.UsbEndpoint
|
||||
import android.hardware.usb.UsbInterface
|
||||
|
||||
class UsbTransportException(message: String) : Exception(message)
|
||||
|
||||
val UsbDevice.interfaces: Iterable<UsbInterface>
|
||||
get() = (0 until interfaceCount).map(::getInterface)
|
||||
|
||||
val Iterable<UsbInterface>.smartCard: UsbInterface?
|
||||
get() = find { it.interfaceClass == UsbConstants.USB_CLASS_CSCID }
|
||||
|
||||
val UsbInterface.endpoints: Iterable<UsbEndpoint>
|
||||
get() = (0 until endpointCount).map(::getEndpoint)
|
||||
|
||||
val Iterable<UsbEndpoint>.bulkPair: Pair<UsbEndpoint?, UsbEndpoint?>
|
||||
get() {
|
||||
val endpoints = filter { it.type == UsbConstants.USB_ENDPOINT_XFER_BULK }
|
||||
return Pair(
|
||||
endpoints.find { it.direction == UsbConstants.USB_DIR_IN },
|
||||
endpoints.find { it.direction == UsbConstants.USB_DIR_OUT },
|
||||
)
|
||||
}
|
||||
|
|
@ -1,19 +0,0 @@
|
|||
package im.angry.openeuicc.di
|
||||
|
||||
import android.telephony.SubscriptionManager
|
||||
import android.telephony.TelephonyManager
|
||||
import im.angry.openeuicc.core.EuiccChannelFactory
|
||||
import im.angry.openeuicc.core.EuiccChannelManager
|
||||
import im.angry.openeuicc.core.EuiccChannelManagerFactory
|
||||
import im.angry.openeuicc.util.*
|
||||
|
||||
interface AppContainer {
|
||||
val telephonyManager: TelephonyManager
|
||||
val euiccChannelManager: EuiccChannelManager
|
||||
val euiccChannelManagerFactory: EuiccChannelManagerFactory
|
||||
val subscriptionManager: SubscriptionManager
|
||||
val preferenceRepository: PreferenceRepository
|
||||
val uiComponentFactory: UiComponentFactory
|
||||
val euiccChannelFactory: EuiccChannelFactory
|
||||
val customizableTextProvider: CustomizableTextProvider
|
||||
}
|
||||
|
|
@ -1,33 +0,0 @@
|
|||
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
|
||||
}
|
||||
|
|
@ -1,45 +0,0 @@
|
|||
package im.angry.openeuicc.di
|
||||
|
||||
import android.content.Context
|
||||
import android.telephony.SubscriptionManager
|
||||
import android.telephony.TelephonyManager
|
||||
import im.angry.openeuicc.core.DefaultEuiccChannelFactory
|
||||
import im.angry.openeuicc.core.DefaultEuiccChannelManager
|
||||
import im.angry.openeuicc.core.DefaultEuiccChannelManagerFactory
|
||||
import im.angry.openeuicc.core.EuiccChannelManager
|
||||
import im.angry.openeuicc.core.EuiccChannelManagerFactory
|
||||
import im.angry.openeuicc.util.*
|
||||
|
||||
open class DefaultAppContainer(context: Context) : AppContainer {
|
||||
override val telephonyManager by lazy {
|
||||
context.getSystemService(TelephonyManager::class.java)!!
|
||||
}
|
||||
|
||||
override val euiccChannelManager: EuiccChannelManager by lazy {
|
||||
DefaultEuiccChannelManager(this, context)
|
||||
}
|
||||
|
||||
override val euiccChannelManagerFactory: EuiccChannelManagerFactory by lazy {
|
||||
DefaultEuiccChannelManagerFactory(this)
|
||||
}
|
||||
|
||||
override val subscriptionManager by lazy {
|
||||
context.getSystemService(SubscriptionManager::class.java)!!
|
||||
}
|
||||
|
||||
override val preferenceRepository by lazy {
|
||||
PreferenceRepository(context)
|
||||
}
|
||||
|
||||
override val uiComponentFactory by lazy {
|
||||
DefaultUiComponentFactory()
|
||||
}
|
||||
|
||||
override val euiccChannelFactory by lazy {
|
||||
DefaultEuiccChannelFactory(context)
|
||||
}
|
||||
|
||||
override val customizableTextProvider by lazy {
|
||||
DefaultCustomizableTextProvider(context)
|
||||
}
|
||||
}
|
||||
|
|
@ -1,23 +0,0 @@
|
|||
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)
|
||||
}
|
||||
|
|
@ -1,20 +0,0 @@
|
|||
package im.angry.openeuicc.di
|
||||
|
||||
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(
|
||||
slotId: Int,
|
||||
portId: Int,
|
||||
seId: EuiccChannel.SecureElementId
|
||||
): EuiccManagementFragment =
|
||||
EuiccManagementFragment.newInstance(slotId, portId, seId)
|
||||
|
||||
override fun createNoEuiccPlaceholderFragment(): Fragment = NoEuiccPlaceholderFragment()
|
||||
|
||||
override fun createSettingsFragment(): Fragment = SettingsFragment()
|
||||
}
|
||||
|
|
@ -1,16 +0,0 @@
|
|||
package im.angry.openeuicc.di
|
||||
|
||||
import androidx.fragment.app.Fragment
|
||||
import im.angry.openeuicc.core.EuiccChannel
|
||||
import im.angry.openeuicc.ui.EuiccManagementFragment
|
||||
|
||||
interface UiComponentFactory {
|
||||
fun createEuiccManagementFragment(
|
||||
slotId: Int,
|
||||
portId: Int,
|
||||
seId: EuiccChannel.SecureElementId
|
||||
): EuiccManagementFragment
|
||||
|
||||
fun createNoEuiccPlaceholderFragment(): Fragment
|
||||
fun createSettingsFragment(): Fragment
|
||||
}
|
||||
|
|
@ -1,539 +0,0 @@
|
|||
package im.angry.openeuicc.service
|
||||
|
||||
import android.content.Intent
|
||||
import android.content.pm.PackageManager
|
||||
import android.os.Binder
|
||||
import android.os.IBinder
|
||||
import android.os.PowerManager
|
||||
import android.util.Log
|
||||
import androidx.core.app.NotificationChannelCompat
|
||||
import androidx.core.app.NotificationCompat
|
||||
import androidx.core.app.NotificationManagerCompat
|
||||
import androidx.lifecycle.LifecycleService
|
||||
import androidx.lifecycle.lifecycleScope
|
||||
import im.angry.openeuicc.common.R
|
||||
import im.angry.openeuicc.core.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.ProfileDownloadCallback
|
||||
import net.typeblog.lpac_jni.ProfileDownloadInput
|
||||
import net.typeblog.lpac_jni.ProfileDownloadState
|
||||
import net.typeblog.lpac_jni.RemoteProfileInfo
|
||||
|
||||
/**
|
||||
* An Android Service wrapper for EuiccChannelManager.
|
||||
* The purpose of this wrapper is mainly lifecycle-wise: having a Service allows the manager
|
||||
* instance to have its own independent lifecycle. This way it can be created as requested and
|
||||
* destroyed when no other components are bound to this service anymore.
|
||||
* This behavior allows us to avoid keeping the APDU channels open at all times. For example,
|
||||
* the EuiccService implementation should *only* bind to this service when it requires an
|
||||
* instance of EuiccChannelManager. UI components can keep being bound to this service for
|
||||
* their entire lifecycles, since the whole purpose of them is to expose the current state
|
||||
* to the user.
|
||||
*
|
||||
* Additionally, this service is also responsible for long-running "foreground" tasks that
|
||||
* are not suitable to be managed by UI components. This includes profile downloading, etc.
|
||||
* When a UI component needs to run one of these tasks, they have to bind to this service
|
||||
* and call one of the `launch*` methods, which will run the task inside this service's
|
||||
* lifecycle context and return a Flow instance for the UI component to subscribe to its
|
||||
* progress.
|
||||
*/
|
||||
class EuiccChannelManagerService : LifecycleService(), OpenEuiccContextMarker {
|
||||
companion object {
|
||||
private const val TAG = "EuiccChannelManagerService"
|
||||
private const val CHANNEL_ID = "tasks"
|
||||
private const val FOREGROUND_ID = 1000
|
||||
private const val TASK_FAILURE_ID = 1000
|
||||
|
||||
/**
|
||||
* Utility function to wait for a foreground task to be done, return its
|
||||
* error if any, or null on success.
|
||||
*/
|
||||
suspend fun Flow<ForegroundTaskState>.waitDone(): Throwable? =
|
||||
(this.last() as ForegroundTaskState.Done).error
|
||||
|
||||
/**
|
||||
* Apply transform to a ForegroundTaskState flow so that it completes when a Done is seen.
|
||||
*
|
||||
* This must be applied each time a flow is returned for subscription purposes. If applied
|
||||
* beforehand, we lose the ability to subscribe multiple times.
|
||||
*/
|
||||
private fun Flow<ForegroundTaskState>.applyCompletionTransform() =
|
||||
transformWhile {
|
||||
emit(it)
|
||||
it !is ForegroundTaskState.Done
|
||||
}
|
||||
}
|
||||
|
||||
inner class LocalBinder : Binder() {
|
||||
val service = this@EuiccChannelManagerService
|
||||
}
|
||||
|
||||
private val euiccChannelManagerDelegate = lazy {
|
||||
appContainer.euiccChannelManagerFactory.createEuiccChannelManager(this)
|
||||
}
|
||||
val euiccChannelManager: EuiccChannelManager by euiccChannelManagerDelegate
|
||||
|
||||
private val wakeLock: PowerManager.WakeLock by lazy {
|
||||
(getSystemService(POWER_SERVICE) as PowerManager).run {
|
||||
newWakeLock(PowerManager.PARTIAL_WAKE_LOCK, this::class.simpleName)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The state of a "foreground" task (named so due to the need to startForeground())
|
||||
*/
|
||||
sealed interface ForegroundTaskState {
|
||||
data object Idle : ForegroundTaskState
|
||||
data class InProgress(val progress: Int) : ForegroundTaskState
|
||||
data class Done(val error: Throwable?) : ForegroundTaskState
|
||||
}
|
||||
|
||||
/**
|
||||
* This flow emits whenever the service has had a start command, from startService()
|
||||
* The service self-starts when foreground is required, because other components
|
||||
* only bind to this service and do not start it per-se.
|
||||
*/
|
||||
private val foregroundStarted: MutableSharedFlow<Unit> = MutableSharedFlow()
|
||||
|
||||
/**
|
||||
* This flow is used to emit progress updates when a foreground task is running.
|
||||
*/
|
||||
private val foregroundTaskState: MutableStateFlow<ForegroundTaskState> =
|
||||
MutableStateFlow(ForegroundTaskState.Idle)
|
||||
|
||||
/**
|
||||
* A simple wrapper over a flow with taskId added.
|
||||
*
|
||||
* taskID is the exact millisecond-precision timestamp when the task is launched.
|
||||
*/
|
||||
class ForegroundTaskSubscriberFlow(val taskId: Long, inner: Flow<ForegroundTaskState>) :
|
||||
Flow<ForegroundTaskState> by inner
|
||||
|
||||
/**
|
||||
* A cache of subscribers to 5 recently-launched foreground tasks, identified by ID
|
||||
*
|
||||
* Only one can be run at the same time, but those that are done will be kept in this
|
||||
* map for a little while -- because UI components may be stopped and recreated while
|
||||
* tasks are running. Having this buffer allows the components to re-subscribe even if
|
||||
* the task completes while they are being recreated.
|
||||
*/
|
||||
private val foregroundTaskSubscribers: MutableMap<Long, SharedFlow<ForegroundTaskState>> =
|
||||
mutableMapOf()
|
||||
|
||||
override fun onBind(intent: Intent): IBinder {
|
||||
super.onBind(intent)
|
||||
return LocalBinder()
|
||||
}
|
||||
|
||||
override fun onDestroy() {
|
||||
super.onDestroy()
|
||||
if (euiccChannelManagerDelegate.isInitialized()) {
|
||||
euiccChannelManager.invalidate()
|
||||
}
|
||||
}
|
||||
|
||||
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
|
||||
return super.onStartCommand(intent, flags, startId).also {
|
||||
lifecycleScope.launch {
|
||||
foregroundStarted.emit(Unit)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun ensureForegroundTaskNotificationChannel() {
|
||||
val nm = NotificationManagerCompat.from(this)
|
||||
if (nm.getNotificationChannelCompat(CHANNEL_ID) == null) {
|
||||
val channel =
|
||||
NotificationChannelCompat.Builder(
|
||||
CHANNEL_ID,
|
||||
NotificationManagerCompat.IMPORTANCE_LOW
|
||||
)
|
||||
.setName(getString(R.string.task_notification))
|
||||
.setVibrationEnabled(false)
|
||||
.build()
|
||||
nm.createNotificationChannel(channel)
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun updateForegroundNotification(title: String, iconRes: Int) {
|
||||
ensureForegroundTaskNotificationChannel()
|
||||
|
||||
val nm = NotificationManagerCompat.from(this)
|
||||
val state = foregroundTaskState.value
|
||||
|
||||
if (state is ForegroundTaskState.InProgress) {
|
||||
val notification = NotificationCompat.Builder(this, CHANNEL_ID)
|
||||
.setContentTitle(title)
|
||||
.setProgress(100, state.progress, state.progress == 0)
|
||||
.setSmallIcon(iconRes)
|
||||
.setPriority(NotificationCompat.PRIORITY_LOW)
|
||||
.setOngoing(true)
|
||||
.setOnlyAlertOnce(true)
|
||||
.build()
|
||||
|
||||
if (state.progress == 0) {
|
||||
startForeground(FOREGROUND_ID, notification)
|
||||
} else if (checkSelfPermission(android.Manifest.permission.POST_NOTIFICATIONS) == PackageManager.PERMISSION_GRANTED) {
|
||||
nm.notify(FOREGROUND_ID, notification)
|
||||
}
|
||||
|
||||
// Yield out so that the main looper can handle the notification event
|
||||
// Without this yield, the notification sent above will not be shown in time.
|
||||
yield()
|
||||
} else {
|
||||
stopForeground(STOP_FOREGROUND_REMOVE)
|
||||
}
|
||||
}
|
||||
|
||||
private fun postForegroundTaskFailureNotification(title: String) {
|
||||
if (checkSelfPermission(android.Manifest.permission.POST_NOTIFICATIONS) != PackageManager.PERMISSION_GRANTED) {
|
||||
return
|
||||
}
|
||||
|
||||
val notification = NotificationCompat.Builder(this, CHANNEL_ID)
|
||||
.setContentTitle(title)
|
||||
.setSmallIcon(R.drawable.ic_x_black)
|
||||
.build()
|
||||
NotificationManagerCompat.from(this).notify(TASK_FAILURE_ID, notification)
|
||||
}
|
||||
|
||||
/**
|
||||
* Recover the subscriber to a foreground task that is recently launched.
|
||||
*
|
||||
* null if the task doesn't exist, or was launched too long ago.
|
||||
*/
|
||||
fun recoverForegroundTaskSubscriber(taskId: Long): ForegroundTaskSubscriberFlow? =
|
||||
foregroundTaskSubscribers[taskId]?.let {
|
||||
ForegroundTaskSubscriberFlow(taskId, it.applyCompletionTransform())
|
||||
}
|
||||
|
||||
/**
|
||||
* Launch a potentially blocking foreground task in this service's lifecycle context.
|
||||
* This function does not block, but returns a Flow that emits ForegroundTaskState
|
||||
* updates associated with this task. The last update the returned flow will emit is
|
||||
* always ForegroundTaskState.Done.
|
||||
*
|
||||
* The returned flow can only be subscribed to once even though the underlying implementation
|
||||
* is a SharedFlow. This is due to the need to apply transformations so that the stream
|
||||
* actually completes. In order to subscribe multiple times, use `recoverForegroundTaskSubscriber`
|
||||
* to acquire another instance.
|
||||
*
|
||||
* The task closure is expected to update foregroundTaskState whenever appropriate.
|
||||
* If a foreground task is already running, this function returns null.
|
||||
*
|
||||
* To wait for foreground tasks to be available, use waitForForegroundTask().
|
||||
*
|
||||
* The function will set the state back to Idle once it sees ForegroundTaskState.Done.
|
||||
*/
|
||||
private fun launchForegroundTask(
|
||||
title: String,
|
||||
failureTitle: String,
|
||||
iconRes: Int,
|
||||
task: suspend EuiccChannelManagerService.() -> Unit
|
||||
): ForegroundTaskSubscriberFlow {
|
||||
val taskID = System.currentTimeMillis()
|
||||
|
||||
// Atomically set the state to InProgress. If this returns true, we are
|
||||
// the only task currently in progress.
|
||||
if (!foregroundTaskState.compareAndSet(
|
||||
ForegroundTaskState.Idle,
|
||||
ForegroundTaskState.InProgress(0)
|
||||
)
|
||||
) {
|
||||
return ForegroundTaskSubscriberFlow(
|
||||
taskID,
|
||||
flow { emit(ForegroundTaskState.Done(IllegalStateException("There are tasks currently running"))) })
|
||||
}
|
||||
|
||||
lifecycleScope.launch(Dispatchers.Main) {
|
||||
// Wait until our self-start command has succeeded.
|
||||
// We can only call startForeground() after that
|
||||
val res = withTimeoutOrNull(30 * 1000) {
|
||||
foregroundStarted.first()
|
||||
}
|
||||
|
||||
if (res == null) {
|
||||
// The only case where the wait above could time out is if the subscriber
|
||||
// to the flow is stuck. Or we failed to start foreground.
|
||||
// In that case, we should just set our state back to Idle -- setting it
|
||||
// to Done wouldn't help much because nothing is going to then set it Idle.
|
||||
foregroundTaskState.value = ForegroundTaskState.Idle
|
||||
return@launch
|
||||
}
|
||||
|
||||
updateForegroundNotification(title, iconRes)
|
||||
|
||||
wakeLock.acquire(10 * 60 * 1000L /*10 minutes*/)
|
||||
|
||||
try {
|
||||
withContext(Dispatchers.IO + NonCancellable) { // Any LPA-related task must always complete
|
||||
this@EuiccChannelManagerService.task()
|
||||
}
|
||||
// This update will be sent by the subscriber (as shown below)
|
||||
foregroundTaskState.value = ForegroundTaskState.Done(null)
|
||||
} catch (t: Throwable) {
|
||||
Log.e(TAG, "Foreground task encountered an error")
|
||||
Log.e(TAG, Log.getStackTraceString(t))
|
||||
foregroundTaskState.value = ForegroundTaskState.Done(t)
|
||||
|
||||
if (isActive) {
|
||||
postForegroundTaskFailureNotification(failureTitle)
|
||||
}
|
||||
} finally {
|
||||
wakeLock.release()
|
||||
if (isActive) {
|
||||
stopSelf()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// This is the flow we are going to return. We allow multiple subscribers by
|
||||
// re-emitting state updates into this flow from another coroutine.
|
||||
// replay = 2 ensures that we at least have 1 previous state whenever subscribed to.
|
||||
// This is helpful when the task completed and is then re-subscribed to due to a
|
||||
// UI recreation event -- this way, the UI will know at least one last progress event
|
||||
// before completion / failure
|
||||
val subscriberFlow = MutableSharedFlow<ForegroundTaskState>(
|
||||
replay = 2,
|
||||
onBufferOverflow = BufferOverflow.DROP_OLDEST
|
||||
)
|
||||
|
||||
// We should be the only task running, so we can subscribe to foregroundTaskState
|
||||
// until we encounter ForegroundTaskState.Done.
|
||||
// Then, we complete the returned flow, but we also set the state back to Idle.
|
||||
// The state update back to Idle won't show up in the returned stream, because
|
||||
// it has been completed by that point.
|
||||
lifecycleScope.launch(Dispatchers.Main) {
|
||||
foregroundTaskState
|
||||
.applyCompletionTransform()
|
||||
.onEach {
|
||||
// Also update our notification when we see an update
|
||||
// But ignore the first progress = 0 update -- that is the current value.
|
||||
// we need that to be handled by the main coroutine after it finishes.
|
||||
if (it !is ForegroundTaskState.InProgress || it.progress != 0) {
|
||||
updateForegroundNotification(title, iconRes)
|
||||
}
|
||||
|
||||
subscriberFlow.emit(it)
|
||||
}
|
||||
.onCompletion {
|
||||
// Reset state back to Idle when we are done.
|
||||
// We do it here because otherwise Idle and Done might become conflated
|
||||
// when emitted by the main coroutine in quick succession.
|
||||
// Doing it here ensures we've seen Done. This Idle event won't be
|
||||
// emitted to the consumer because the subscription has completed here.
|
||||
foregroundTaskState.value = ForegroundTaskState.Idle
|
||||
}
|
||||
.collect()
|
||||
}
|
||||
|
||||
foregroundTaskSubscribers[taskID] = subscriberFlow.asSharedFlow()
|
||||
|
||||
if (foregroundTaskSubscribers.size > 5) {
|
||||
// Remove enough elements so that the size is kept at 5
|
||||
for (key in foregroundTaskSubscribers.keys.sorted()
|
||||
.take(foregroundTaskSubscribers.size - 5)) {
|
||||
foregroundTaskSubscribers.remove(key)
|
||||
}
|
||||
}
|
||||
|
||||
// Before we return, and after we have set everything up,
|
||||
// self-start with foreground permission.
|
||||
// This is going to unblock the main coroutine handling the task.
|
||||
startForegroundService(
|
||||
Intent(
|
||||
this@EuiccChannelManagerService,
|
||||
this@EuiccChannelManagerService::class.java
|
||||
)
|
||||
)
|
||||
|
||||
return ForegroundTaskSubscriberFlow(
|
||||
taskID,
|
||||
subscriberFlow.asSharedFlow().applyCompletionTransform()
|
||||
)
|
||||
}
|
||||
|
||||
suspend fun waitForForegroundTask() {
|
||||
foregroundTaskState.takeWhile { it != ForegroundTaskState.Idle }
|
||||
.collect()
|
||||
}
|
||||
|
||||
fun launchProfileDownloadTask(
|
||||
slotId: Int, portId: Int, 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, object : ProfileDownloadCallback {
|
||||
override fun onStateUpdate(state: ProfileDownloadState) {
|
||||
if (state.progress == 0) return
|
||||
foregroundTaskState.value = ForegroundTaskState.InProgress(state.progress)
|
||||
}
|
||||
|
||||
override fun onConfirmMetadata(metadata: RemoteProfileInfo?): Boolean {
|
||||
// TODO: Actually do something here and not just logging?
|
||||
if (metadata != null) {
|
||||
Log.i(
|
||||
TAG,
|
||||
"Downloading profile provider=${metadata.providerName} name=${metadata.name}"
|
||||
)
|
||||
}
|
||||
return true
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
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,51 +0,0 @@
|
|||
package im.angry.openeuicc.ui
|
||||
|
||||
import android.content.ComponentName
|
||||
import android.content.Intent
|
||||
import android.content.ServiceConnection
|
||||
import android.os.Bundle
|
||||
import android.os.IBinder
|
||||
import androidx.appcompat.app.AppCompatActivity
|
||||
import im.angry.openeuicc.core.EuiccChannelManager
|
||||
import im.angry.openeuicc.service.EuiccChannelManagerService
|
||||
import kotlinx.coroutines.CompletableDeferred
|
||||
|
||||
abstract class BaseEuiccAccessActivity : AppCompatActivity() {
|
||||
val euiccChannelManagerLoaded = CompletableDeferred<Unit>()
|
||||
lateinit var euiccChannelManager: EuiccChannelManager
|
||||
lateinit var euiccChannelManagerService: EuiccChannelManagerService
|
||||
|
||||
private val euiccChannelManagerServiceConnection = object : ServiceConnection {
|
||||
override fun onServiceConnected(name: ComponentName?, service: IBinder?) {
|
||||
euiccChannelManagerService = (service!! as EuiccChannelManagerService.LocalBinder).service
|
||||
euiccChannelManager = euiccChannelManagerService.euiccChannelManager
|
||||
euiccChannelManagerLoaded.complete(Unit)
|
||||
onInit()
|
||||
}
|
||||
|
||||
override fun onServiceDisconnected(name: ComponentName?) {
|
||||
// These activities should never lose the EuiccChannelManagerService connection
|
||||
finish()
|
||||
}
|
||||
}
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
|
||||
bindService(
|
||||
Intent(this, EuiccChannelManagerService::class.java),
|
||||
euiccChannelManagerServiceConnection,
|
||||
BIND_AUTO_CREATE
|
||||
)
|
||||
}
|
||||
|
||||
override fun onDestroy() {
|
||||
super.onDestroy()
|
||||
unbindService(euiccChannelManagerServiceConnection)
|
||||
}
|
||||
|
||||
/**
|
||||
* When called, euiccChannelManager is guaranteed to have been initialized
|
||||
*/
|
||||
abstract fun onInit()
|
||||
}
|
||||
|
|
@ -1,26 +0,0 @@
|
|||
package im.angry.openeuicc.ui
|
||||
|
||||
import android.app.Dialog
|
||||
import android.os.Bundle
|
||||
import android.view.LayoutInflater
|
||||
import android.view.Window
|
||||
import androidx.appcompat.view.ContextThemeWrapper
|
||||
import androidx.fragment.app.DialogFragment
|
||||
import com.google.android.material.color.DynamicColors
|
||||
import im.angry.openeuicc.common.R
|
||||
|
||||
abstract class BaseMaterialDialogFragment : DialogFragment() {
|
||||
override fun onGetLayoutInflater(savedInstanceState: Bundle?): LayoutInflater {
|
||||
val inflater = super.onGetLayoutInflater(savedInstanceState)
|
||||
val wrappedContext = ContextThemeWrapper(requireContext(), R.style.Theme_OpenEUICC)
|
||||
val dynamicWrappedContext = DynamicColors.wrapContextIfAvailable(wrappedContext)
|
||||
return inflater.cloneInContext(dynamicWrappedContext)
|
||||
}
|
||||
|
||||
override fun onCreateDialog(savedInstanceState: Bundle?): Dialog {
|
||||
return super.onCreateDialog(savedInstanceState).also {
|
||||
it.window?.requestFeature(Window.FEATURE_NO_TITLE)
|
||||
it.window?.setBackgroundDrawableResource(R.drawable.dialog_background)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -1,236 +0,0 @@
|
|||
package im.angry.openeuicc.ui
|
||||
|
||||
import android.annotation.SuppressLint
|
||||
import android.content.ClipData
|
||||
import android.content.ClipboardManager
|
||||
import android.os.Build
|
||||
import android.os.Bundle
|
||||
import android.view.LayoutInflater
|
||||
import android.view.MenuItem
|
||||
import android.view.View
|
||||
import android.view.ViewGroup
|
||||
import android.widget.TextView
|
||||
import android.widget.Toast
|
||||
import androidx.activity.enableEdgeToEdge
|
||||
import androidx.annotation.StringRes
|
||||
import androidx.lifecycle.lifecycleScope
|
||||
import androidx.recyclerview.widget.DividerItemDecoration
|
||||
import androidx.recyclerview.widget.LinearLayoutManager
|
||||
import androidx.recyclerview.widget.RecyclerView
|
||||
import androidx.recyclerview.widget.RecyclerView.ViewHolder
|
||||
import androidx.swiperefreshlayout.widget.SwipeRefreshLayout
|
||||
import im.angry.openeuicc.common.R
|
||||
import im.angry.openeuicc.core.EuiccChannel
|
||||
import im.angry.openeuicc.core.EuiccChannelManager
|
||||
import im.angry.openeuicc.util.*
|
||||
import kotlinx.coroutines.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])
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -1,531 +0,0 @@
|
|||
package im.angry.openeuicc.ui
|
||||
|
||||
import android.annotation.SuppressLint
|
||||
import android.content.ClipData
|
||||
import android.content.ClipboardManager
|
||||
import android.content.Intent
|
||||
import android.os.Build
|
||||
import android.os.Bundle
|
||||
import android.text.method.PasswordTransformationMethod
|
||||
import android.view.LayoutInflater
|
||||
import android.view.Menu
|
||||
import android.view.MenuInflater
|
||||
import android.view.MenuItem
|
||||
import android.view.View
|
||||
import android.view.ViewGroup
|
||||
import android.widget.FrameLayout
|
||||
import android.widget.ImageButton
|
||||
import android.widget.PopupMenu
|
||||
import android.widget.TextView
|
||||
import android.widget.Toast
|
||||
import androidx.appcompat.app.AlertDialog
|
||||
import androidx.core.view.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 im.angry.openeuicc.common.R
|
||||
import im.angry.openeuicc.core.EuiccChannel
|
||||
import im.angry.openeuicc.service.EuiccChannelManagerService
|
||||
import im.angry.openeuicc.service.EuiccChannelManagerService.Companion.waitDone
|
||||
import im.angry.openeuicc.ui.wizard.DownloadWizardActivity
|
||||
import im.angry.openeuicc.util.*
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.TimeoutCancellationException
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.stateIn
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
import net.typeblog.lpac_jni.LocalProfileInfo
|
||||
import net.typeblog.lpac_jni.ProfileClass
|
||||
|
||||
open class EuiccManagementFragment : Fragment(), EuiccProfilesChangedListener,
|
||||
EuiccChannelFragmentMarker {
|
||||
companion object {
|
||||
const val TAG = "EuiccManagementFragment"
|
||||
|
||||
fun newInstance(
|
||||
slotId: Int,
|
||||
portId: Int,
|
||||
seId: EuiccChannel.SecureElementId
|
||||
): EuiccManagementFragment =
|
||||
newInstanceEuicc(EuiccManagementFragment::class.java, slotId, portId, seId)
|
||||
}
|
||||
|
||||
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()
|
||||
|
||||
// Marker for when this fragment might enter an invalid state
|
||||
// e.g. after a failed enable / disable operation
|
||||
private var invalid = false
|
||||
|
||||
// Subscribe to settings we care about outside of coroutine contexts while initializing
|
||||
// This gives us access to the "latest" state without having to launch coroutines
|
||||
private lateinit var disableSafeguardFlow: StateFlow<Boolean>
|
||||
|
||||
private lateinit var unfilteredProfileListFlow: StateFlow<Boolean>
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
setHasOptionsMenu(true)
|
||||
}
|
||||
|
||||
override fun onCreateView(
|
||||
inflater: LayoutInflater,
|
||||
container: ViewGroup?,
|
||||
savedInstanceState: Bundle?
|
||||
): View {
|
||||
val view = inflater.inflate(R.layout.fragment_euicc, container, false)
|
||||
|
||||
swipeRefresh = view.requireViewById(R.id.swipe_refresh)
|
||||
fab = view.requireViewById(R.id.fab)
|
||||
profileList = view.requireViewById(R.id.profile_list)
|
||||
|
||||
val origFabMarginRight = (fab.layoutParams as ViewGroup.MarginLayoutParams).rightMargin
|
||||
val origFabMarginBottom = (fab.layoutParams as ViewGroup.MarginLayoutParams).bottomMargin
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
|
||||
super.onViewCreated(view, savedInstanceState)
|
||||
swipeRefresh.setOnRefreshListener { refresh() }
|
||||
profileList.adapter = adapter
|
||||
profileList.layoutManager =
|
||||
LinearLayoutManager(view.context, LinearLayoutManager.VERTICAL, false)
|
||||
|
||||
fab.setOnClickListener {
|
||||
val intent = DownloadWizardActivity.newIntent(requireContext(), slotId, seId)
|
||||
startActivity(intent)
|
||||
}
|
||||
}
|
||||
|
||||
override fun onStart() {
|
||||
super.onStart()
|
||||
refresh()
|
||||
}
|
||||
|
||||
override fun onEuiccProfilesChanged() {
|
||||
refresh()
|
||||
}
|
||||
|
||||
override fun onCreateOptionsMenu(menu: Menu, inflater: MenuInflater) {
|
||||
super.onCreateOptionsMenu(menu, inflater)
|
||||
inflater.inflate(R.menu.fragment_euicc, menu)
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
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>
|
||||
): List<View> =
|
||||
if (profiles.isEmpty()) {
|
||||
val view = layoutInflater.inflate(R.layout.footer_no_profile, parent, false)
|
||||
listOf(view)
|
||||
} else {
|
||||
listOf()
|
||||
}
|
||||
|
||||
private fun refresh() {
|
||||
if (invalid) return
|
||||
swipeRefresh.isRefreshing = true
|
||||
|
||||
lifecycleScope.launch {
|
||||
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 {
|
||||
ensureEuiccChannelManager()
|
||||
euiccChannelManagerService.waitForForegroundTask()
|
||||
|
||||
val err = euiccChannelManagerService
|
||||
.launchProfileSwitchTask(
|
||||
slotId, portId, seId, iccid, enable,
|
||||
reconnectTimeoutMillis = 30 * 1000
|
||||
)
|
||||
.waitDone()
|
||||
|
||||
when (err) {
|
||||
null -> {}
|
||||
is EuiccChannelManagerService.SwitchingProfilesRefreshException -> {
|
||||
// This is only really fatal for internal eSIMs
|
||||
if (!isUsb) {
|
||||
withContext(Dispatchers.Main) {
|
||||
AlertDialog.Builder(requireContext()).apply {
|
||||
setMessage(R.string.profile_switch_did_not_refresh)
|
||||
setPositiveButton(android.R.string.ok) { dialog, _ ->
|
||||
dialog.dismiss()
|
||||
requireActivity().finish()
|
||||
}
|
||||
setOnDismissListener { _ ->
|
||||
requireActivity().finish()
|
||||
}
|
||||
show()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
is TimeoutCancellationException -> {
|
||||
withContext(Dispatchers.Main) {
|
||||
// Prevent this Fragment from being used again
|
||||
invalid = true
|
||||
// Timed out waiting for SIM to come back online, we can no longer assume that the LPA is still valid
|
||||
AlertDialog.Builder(requireContext()).apply {
|
||||
setMessage(appContainer.customizableTextProvider.profileSwitchingTimeoutMessage)
|
||||
setPositiveButton(android.R.string.ok) { dialog, _ ->
|
||||
dialog.dismiss()
|
||||
requireActivity().finish()
|
||||
}
|
||||
setOnDismissListener { _ ->
|
||||
requireActivity().finish()
|
||||
}
|
||||
show()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
else -> showSwitchFailureText()
|
||||
}
|
||||
|
||||
refresh()
|
||||
fab.isEnabled = true
|
||||
}
|
||||
}
|
||||
|
||||
protected open fun populatePopupWithProfileActions(
|
||||
popup: PopupMenu,
|
||||
profile: LocalProfileInfo
|
||||
) {
|
||||
popup.inflate(R.menu.profile_options)
|
||||
if (!profile.isEnabled) return
|
||||
popup.menu.findItem(R.id.enable).isVisible = false
|
||||
popup.menu.findItem(R.id.delete).isVisible = false
|
||||
|
||||
// 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) {
|
||||
enum class Type(val value: Int) {
|
||||
PROFILE(0),
|
||||
FOOTER(1);
|
||||
|
||||
companion object {
|
||||
fun fromInt(value: Int) =
|
||||
entries.first { it.value == value }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
inner class FooterViewHolder : ViewHolder(FrameLayout(requireContext())) {
|
||||
init {
|
||||
itemView.layoutParams = ViewGroup.LayoutParams(
|
||||
ViewGroup.LayoutParams.MATCH_PARENT,
|
||||
ViewGroup.LayoutParams.WRAP_CONTENT
|
||||
)
|
||||
}
|
||||
|
||||
fun attach(view: View) {
|
||||
view.parent?.let { (it as ViewGroup).removeView(view) }
|
||||
(itemView as FrameLayout).addView(view)
|
||||
}
|
||||
|
||||
fun detach() {
|
||||
(itemView as FrameLayout).removeAllViews()
|
||||
}
|
||||
}
|
||||
|
||||
inner class ProfileViewHolder(private val root: View) : ViewHolder(root) {
|
||||
private val iccid: TextView = root.requireViewById(R.id.iccid)
|
||||
private val name: TextView = root.requireViewById(R.id.name)
|
||||
private val state: TextView = root.requireViewById(R.id.state)
|
||||
private val provider: TextView = root.requireViewById(R.id.provider)
|
||||
private val profileClassLabel: TextView = root.requireViewById(R.id.profile_class_label)
|
||||
private val profileClass: TextView = root.requireViewById(R.id.profile_class)
|
||||
private val profileMenu: ImageButton = root.requireViewById(R.id.profile_menu)
|
||||
private val profileSeqNumber: TextView = root.requireViewById(R.id.profile_sequence_number)
|
||||
|
||||
init {
|
||||
iccid.setOnClickListener {
|
||||
if (iccid.transformationMethod == null) {
|
||||
iccid.transformationMethod = PasswordTransformationMethod.getInstance()
|
||||
} else {
|
||||
iccid.transformationMethod = null
|
||||
}
|
||||
}
|
||||
|
||||
iccid.setOnLongClickListener {
|
||||
requireContext().getSystemService(ClipboardManager::class.java)!!
|
||||
.setPrimaryClip(ClipData.newPlainText("iccid", iccid.text))
|
||||
if (Build.VERSION.SDK_INT <= Build.VERSION_CODES.S_V2) Toast
|
||||
.makeText(requireContext(), R.string.toast_iccid_copied, Toast.LENGTH_SHORT)
|
||||
.show()
|
||||
true
|
||||
}
|
||||
|
||||
profileMenu.setOnClickListener {
|
||||
showOptionsMenu()
|
||||
}
|
||||
}
|
||||
|
||||
private lateinit var profile: LocalProfileInfo
|
||||
private var canEnable: Boolean = false
|
||||
|
||||
fun setProfile(profile: LocalProfileInfo) {
|
||||
this.profile = profile
|
||||
name.text = profile.displayName
|
||||
|
||||
state.setText(
|
||||
if (profile.isEnabled) {
|
||||
R.string.profile_state_enabled
|
||||
} else {
|
||||
R.string.profile_state_disabled
|
||||
}
|
||||
)
|
||||
provider.text = profile.providerName
|
||||
profileClassLabel.isVisible = unfilteredProfileListFlow.value
|
||||
profileClass.isVisible = unfilteredProfileListFlow.value
|
||||
profileClass.setText(
|
||||
when (profile.profileClass) {
|
||||
ProfileClass.Testing -> R.string.profile_class_testing
|
||||
ProfileClass.Provisioning -> R.string.profile_class_provisioning
|
||||
ProfileClass.Operational -> R.string.profile_class_operational
|
||||
}
|
||||
)
|
||||
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
|
||||
|
||||
PopupMenu(root.context, profileMenu).apply {
|
||||
setOnMenuItemClickListener(::onMenuItemClicked)
|
||||
populatePopupWithProfileActions(this, profile)
|
||||
show()
|
||||
}
|
||||
}
|
||||
|
||||
private fun onMenuItemClicked(item: MenuItem): Boolean =
|
||||
when (item.itemId) {
|
||||
R.id.enable -> {
|
||||
if (canEnable) {
|
||||
enableOrDisableProfile(profile.iccid, true)
|
||||
} else {
|
||||
val resId = R.string.toast_profile_enable_cross_class
|
||||
Toast.makeText(requireContext(), resId, Toast.LENGTH_LONG)
|
||||
.show()
|
||||
}
|
||||
true
|
||||
}
|
||||
|
||||
R.id.disable -> {
|
||||
enableOrDisableProfile(profile.iccid, false)
|
||||
true
|
||||
}
|
||||
|
||||
R.id.rename -> {
|
||||
ProfileRenameFragment.newInstance(
|
||||
slotId,
|
||||
portId,
|
||||
seId,
|
||||
profile.iccid,
|
||||
profile.displayName
|
||||
)
|
||||
.show(childFragmentManager, ProfileRenameFragment.TAG)
|
||||
true
|
||||
}
|
||||
|
||||
R.id.delete -> {
|
||||
ProfileDeleteFragment.newInstance(
|
||||
slotId,
|
||||
portId,
|
||||
seId,
|
||||
profile.iccid,
|
||||
profile.displayName
|
||||
)
|
||||
.show(childFragmentManager, ProfileDeleteFragment.TAG)
|
||||
true
|
||||
}
|
||||
|
||||
else -> false
|
||||
}
|
||||
}
|
||||
|
||||
inner class EuiccProfileAdapter : RecyclerView.Adapter<ViewHolder>() {
|
||||
var profiles: List<LocalProfileInfo> = listOf()
|
||||
var footerViews: List<View> = listOf()
|
||||
|
||||
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)
|
||||
ProfileViewHolder(view)
|
||||
}
|
||||
|
||||
ViewHolder.Type.FOOTER -> {
|
||||
FooterViewHolder()
|
||||
}
|
||||
}
|
||||
|
||||
override fun getItemViewType(position: Int): Int =
|
||||
when {
|
||||
position < profiles.size -> {
|
||||
ViewHolder.Type.PROFILE.value
|
||||
}
|
||||
|
||||
position >= profiles.size && position < profiles.size + footerViews.size -> {
|
||||
ViewHolder.Type.FOOTER.value
|
||||
}
|
||||
|
||||
else -> -1
|
||||
}
|
||||
|
||||
override fun onBindViewHolder(holder: ViewHolder, position: Int) {
|
||||
when (holder) {
|
||||
is ProfileViewHolder -> {
|
||||
holder.setProfile(profiles[position])
|
||||
holder.setEnabledProfile(profiles.enabled)
|
||||
holder.setProfileSequenceNumber(position + 1)
|
||||
}
|
||||
|
||||
is FooterViewHolder -> {
|
||||
holder.attach(footerViews[position - profiles.size])
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun onViewRecycled(holder: ViewHolder) {
|
||||
if (holder is FooterViewHolder) {
|
||||
holder.detach()
|
||||
}
|
||||
}
|
||||
|
||||
override fun getItemCount(): Int = profiles.size + footerViews.size
|
||||
}
|
||||
}
|
||||
|
|
@ -1,119 +0,0 @@
|
|||
package im.angry.openeuicc.ui
|
||||
|
||||
import android.graphics.Typeface
|
||||
import android.os.Bundle
|
||||
import android.text.Editable
|
||||
import android.util.Log
|
||||
import android.widget.EditText
|
||||
import android.widget.Toast
|
||||
import androidx.appcompat.app.AlertDialog
|
||||
import androidx.fragment.app.DialogFragment
|
||||
import androidx.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
|
||||
}
|
||||
}
|
||||
|
|
@ -1,77 +0,0 @@
|
|||
package im.angry.openeuicc.ui
|
||||
|
||||
import android.os.Bundle
|
||||
import android.text.Editable
|
||||
import android.view.Menu
|
||||
import android.view.MenuItem
|
||||
import android.widget.EditText
|
||||
import android.widget.Toast
|
||||
import androidx.activity.enableEdgeToEdge
|
||||
import androidx.appcompat.app.AppCompatActivity
|
||||
import androidx.lifecycle.lifecycleScope
|
||||
import im.angry.openeuicc.common.R
|
||||
import im.angry.openeuicc.util.*
|
||||
import kotlinx.coroutines.flow.collect
|
||||
import kotlinx.coroutines.flow.onEach
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
class IsdrAidListActivity : AppCompatActivity() {
|
||||
private lateinit var isdrAidListEditor: EditText
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
enableEdgeToEdge()
|
||||
super.onCreate(savedInstanceState)
|
||||
setContentView(R.layout.activity_isdr_aid_list)
|
||||
setSupportActionBar(requireViewById(R.id.toolbar))
|
||||
supportActionBar!!.setDisplayHomeAsUpEnabled(true)
|
||||
|
||||
isdrAidListEditor = requireViewById(R.id.isdr_aid_list_editor)
|
||||
|
||||
setupRootViewSystemBarInsets(
|
||||
window.decorView.rootView, arrayOf(
|
||||
this::activityToolbarInsetHandler,
|
||||
mainViewPaddingInsetHandler(isdrAidListEditor)
|
||||
)
|
||||
)
|
||||
|
||||
lifecycleScope.launch {
|
||||
preferenceRepository.isdrAidListFlow.onEach {
|
||||
isdrAidListEditor.text = Editable.Factory.getInstance().newEditable(it)
|
||||
}.collect()
|
||||
}
|
||||
}
|
||||
|
||||
override fun onCreateOptionsMenu(menu: Menu?): Boolean {
|
||||
menuInflater.inflate(R.menu.activity_isdr_aid_list, menu)
|
||||
return true
|
||||
}
|
||||
|
||||
override fun onOptionsItemSelected(item: MenuItem): Boolean =
|
||||
when (item.itemId) {
|
||||
R.id.save -> {
|
||||
lifecycleScope.launch {
|
||||
preferenceRepository.isdrAidListFlow.updatePreference(isdrAidListEditor.text.toString())
|
||||
Toast.makeText(
|
||||
this@IsdrAidListActivity,
|
||||
R.string.isdr_aid_list_saved,
|
||||
Toast.LENGTH_SHORT
|
||||
).show()
|
||||
}
|
||||
true
|
||||
}
|
||||
|
||||
R.id.reset -> {
|
||||
lifecycleScope.launch {
|
||||
preferenceRepository.isdrAidListFlow.removePreference()
|
||||
}
|
||||
true
|
||||
}
|
||||
|
||||
android.R.id.home -> {
|
||||
finish()
|
||||
true
|
||||
}
|
||||
|
||||
else -> super.onOptionsItemSelected(item)
|
||||
}
|
||||
}
|
||||
|
|
@ -1,116 +0,0 @@
|
|||
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.enableEdgeToEdge
|
||||
import androidx.appcompat.app.AppCompatActivity
|
||||
import androidx.lifecycle.lifecycleScope
|
||||
import androidx.swiperefreshlayout.widget.SwipeRefreshLayout
|
||||
import im.angry.openeuicc.common.R
|
||||
import im.angry.openeuicc.util.*
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
import java.util.Date
|
||||
|
||||
class LogsActivity : AppCompatActivity() {
|
||||
private lateinit var swipeRefresh: SwipeRefreshLayout
|
||||
private lateinit var scrollView: ScrollView
|
||||
private lateinit var logText: TextView
|
||||
private lateinit var logStr: String
|
||||
|
||||
private val saveLogs =
|
||||
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))
|
||||
supportActionBar!!.setDisplayHomeAsUpEnabled(true)
|
||||
|
||||
swipeRefresh = requireViewById(R.id.swipe_refresh)
|
||||
scrollView = requireViewById(R.id.scroll_view)
|
||||
logText = requireViewById(R.id.log_text)
|
||||
|
||||
setupRootViewSystemBarInsets(
|
||||
window.decorView.rootView, arrayOf(
|
||||
this::activityToolbarInsetHandler,
|
||||
mainViewPaddingInsetHandler(scrollView)
|
||||
)
|
||||
)
|
||||
|
||||
swipeRefresh.setOnRefreshListener {
|
||||
lifecycleScope.launch {
|
||||
reload()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun onStart() {
|
||||
super.onStart()
|
||||
lifecycleScope.launch {
|
||||
reload()
|
||||
}
|
||||
}
|
||||
|
||||
override fun onCreateOptionsMenu(menu: Menu?): Boolean {
|
||||
menuInflater.inflate(R.menu.activity_logs, menu)
|
||||
return true
|
||||
}
|
||||
|
||||
override fun onOptionsItemSelected(item: MenuItem): Boolean = when (item.itemId) {
|
||||
android.R.id.home -> {
|
||||
finish()
|
||||
true
|
||||
}
|
||||
|
||||
R.id.save -> {
|
||||
saveLogs()
|
||||
true
|
||||
}
|
||||
|
||||
else -> super.onOptionsItemSelected(item)
|
||||
}
|
||||
|
||||
private suspend fun reload() = withContext(Dispatchers.Main) {
|
||||
swipeRefresh.isRefreshing = true
|
||||
|
||||
logStr = intent.extras?.getString("log") ?: readSelfLog()
|
||||
|
||||
logText.text = withContext(Dispatchers.IO) {
|
||||
// Limit the UI to display only 256 lines
|
||||
logStr.lines().takeLast(256).joinToString("\n")
|
||||
}
|
||||
|
||||
swipeRefresh.isRefreshing = false
|
||||
|
||||
scrollView.post {
|
||||
scrollView.fullScroll(View.FOCUS_DOWN)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -1,306 +0,0 @@
|
|||
package im.angry.openeuicc.ui
|
||||
|
||||
import android.annotation.SuppressLint
|
||||
import android.content.BroadcastReceiver
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.content.IntentFilter
|
||||
import android.content.pm.PackageManager
|
||||
import android.hardware.usb.UsbManager
|
||||
import android.os.Build
|
||||
import android.os.Bundle
|
||||
import android.telephony.TelephonyManager
|
||||
import android.util.Log
|
||||
import android.view.Menu
|
||||
import android.view.MenuItem
|
||||
import android.view.View
|
||||
import android.widget.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
|
||||
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
|
||||
|
||||
@SuppressLint("NotifyDataSetChanged")
|
||||
open class MainActivity : BaseEuiccAccessActivity(), OpenEuiccContextMarker {
|
||||
companion object {
|
||||
const val TAG = "MainActivity"
|
||||
|
||||
const val PERMISSION_REQUEST_CODE = 1000
|
||||
}
|
||||
|
||||
private lateinit var loadingProgress: ProgressBar
|
||||
private lateinit var tabs: TabLayout
|
||||
private lateinit var viewPager: ViewPager2
|
||||
|
||||
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 }
|
||||
}
|
||||
}
|
||||
|
||||
protected lateinit var tm: TelephonyManager
|
||||
|
||||
private val usbReceiver = object : BroadcastReceiver() {
|
||||
override fun onReceive(context: Context?, intent: Intent?) {
|
||||
if (intent?.action == UsbManager.ACTION_USB_DEVICE_ATTACHED || intent?.action == UsbManager.ACTION_USB_DEVICE_DETACHED) {
|
||||
refresh(true)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@SuppressLint("WrongConstant", "UnspecifiedRegisterReceiverFlag")
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
enableEdgeToEdge()
|
||||
super.onCreate(savedInstanceState)
|
||||
setContentView(R.layout.activity_main)
|
||||
setSupportActionBar(requireViewById(R.id.toolbar))
|
||||
loadingProgress = requireViewById(R.id.loading)
|
||||
tabs = requireViewById(R.id.main_tabs)
|
||||
viewPager = requireViewById(R.id.view_pager)
|
||||
|
||||
viewPager.adapter = pagerAdapter
|
||||
TabLayoutMediator(tabs, viewPager) { tab, pos ->
|
||||
tab.text = pages[pos].title
|
||||
}.attach()
|
||||
|
||||
tm = telephonyManager
|
||||
|
||||
registerReceiver(usbReceiver, IntentFilter().apply {
|
||||
addAction(UsbManager.ACTION_USB_DEVICE_ATTACHED)
|
||||
addAction(UsbManager.ACTION_USB_DEVICE_DETACHED)
|
||||
})
|
||||
|
||||
setupRootViewSystemBarInsets(
|
||||
window.decorView.rootView, arrayOf(
|
||||
this::activityToolbarInsetHandler
|
||||
), consume = false
|
||||
)
|
||||
}
|
||||
|
||||
override fun onDestroy() {
|
||||
super.onDestroy()
|
||||
unregisterReceiver(usbReceiver)
|
||||
}
|
||||
|
||||
override fun onCreateOptionsMenu(menu: Menu): Boolean {
|
||||
menuInflater.inflate(R.menu.activity_main, menu)
|
||||
return true
|
||||
}
|
||||
|
||||
override fun onOptionsItemSelected(item: MenuItem): Boolean =
|
||||
when (item.itemId) {
|
||||
R.id.settings -> {
|
||||
startActivity(Intent(this, SettingsActivity::class.java))
|
||||
true
|
||||
}
|
||||
|
||||
R.id.reload -> {
|
||||
refresh()
|
||||
true
|
||||
}
|
||||
|
||||
else -> super.onOptionsItemSelected(item)
|
||||
}
|
||||
|
||||
override fun onInit() {
|
||||
lifecycleScope.launch {
|
||||
init()
|
||||
}
|
||||
}
|
||||
|
||||
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
|
||||
// Prevent concurrent access with any running foreground task
|
||||
euiccChannelManagerService.waitForForegroundTask()
|
||||
|
||||
val (usbDevice, _) = withContext(Dispatchers.IO) {
|
||||
euiccChannelManager.tryOpenUsbEuiccChannel()
|
||||
}
|
||||
|
||||
val newPages: MutableList<Page> = mutableListOf()
|
||||
|
||||
euiccChannelManager.flowInternalEuiccPorts().onEach { (slotId, portId) ->
|
||||
Log.d(TAG, "slot $slotId port $portId")
|
||||
|
||||
euiccChannelManager.flowEuiccSecureElements(slotId, portId).onEach { seId ->
|
||||
euiccChannelManager.withEuiccChannel(slotId, portId, seId) { channel ->
|
||||
if (preferenceRepository.verboseLoggingFlow.first()) {
|
||||
Log.d(TAG, channel.lpa.eID)
|
||||
}
|
||||
// Request the system to refresh the list of profiles every time we start
|
||||
// Note that this is currently supposed to be no-op when unprivileged,
|
||||
// but it could change in the future
|
||||
euiccChannelManager.notifyEuiccProfilesChanged(channel.logicalSlotId)
|
||||
|
||||
val channelName = if (channel.hasMultipleSE) {
|
||||
appContainer.customizableTextProvider.formatNonUsbChannelNameWithSeId(
|
||||
channel.logicalSlotId,
|
||||
channel.seId
|
||||
)
|
||||
} else {
|
||||
appContainer.customizableTextProvider.formatNonUsbChannelName(channel.logicalSlotId)
|
||||
}
|
||||
newPages.add(newPage(channel.logicalSlotId, channelName) {
|
||||
appContainer.uiComponentFactory.createEuiccManagementFragment(
|
||||
slotId,
|
||||
portId,
|
||||
seId
|
||||
)
|
||||
})
|
||||
}
|
||||
}.collect()
|
||||
}.collect()
|
||||
|
||||
// If USB readers exist, add them at the very last
|
||||
// We use a wrapper fragment to handle logic specific to USB readers
|
||||
usbDevice?.let {
|
||||
newPages.add(newPage(EuiccChannelManager.USB_CHANNEL_ID, getString(R.string.channel_name_format_usb)) {
|
||||
UsbCcidReaderPermissionFragment()
|
||||
})
|
||||
}
|
||||
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) {
|
||||
if (refreshing) return
|
||||
lifecycleScope.launch {
|
||||
refreshing = true
|
||||
loadingProgress.visibility = View.VISIBLE
|
||||
viewPager.visibility = View.GONE
|
||||
tabs.visibility = View.GONE
|
||||
|
||||
pages.clear()
|
||||
pagerAdapter.notifyDataSetChanged()
|
||||
viewPager.adapter = pagerAdapter
|
||||
|
||||
init(fromUsbEvent) // will set refreshing = false
|
||||
}
|
||||
}
|
||||
|
||||
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()
|
||||
}
|
||||
}
|
||||
|
|
@ -1,23 +0,0 @@
|
|||
package im.angry.openeuicc.ui
|
||||
|
||||
import android.os.Bundle
|
||||
import android.view.LayoutInflater
|
||||
import android.view.View
|
||||
import android.view.ViewGroup
|
||||
import android.widget.TextView
|
||||
import androidx.fragment.app.Fragment
|
||||
import im.angry.openeuicc.common.R
|
||||
import im.angry.openeuicc.util.OpenEuiccContextMarker
|
||||
|
||||
class NoEuiccPlaceholderFragment : Fragment(), OpenEuiccContextMarker {
|
||||
override fun onCreateView(
|
||||
inflater: LayoutInflater,
|
||||
container: ViewGroup?,
|
||||
savedInstanceState: Bundle?
|
||||
): View? {
|
||||
val view = inflater.inflate(R.layout.fragment_no_euicc_placeholder, container, false)
|
||||
val textView = view.requireViewById<TextView>(R.id.no_euicc_placeholder)
|
||||
textView.text = appContainer.customizableTextProvider.noEuiccExplanation
|
||||
return view
|
||||
}
|
||||
}
|
||||
|
|
@ -1,285 +0,0 @@
|
|||
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
|
||||
import android.view.LayoutInflater
|
||||
import android.view.Menu
|
||||
import android.view.MenuItem
|
||||
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
|
||||
import androidx.recyclerview.widget.DividerItemDecoration
|
||||
import androidx.recyclerview.widget.LinearLayoutManager
|
||||
import androidx.recyclerview.widget.RecyclerView
|
||||
import androidx.swiperefreshlayout.widget.SwipeRefreshLayout
|
||||
import im.angry.openeuicc.common.R
|
||||
import im.angry.openeuicc.core.EuiccChannel
|
||||
import im.angry.openeuicc.core.EuiccChannelManager
|
||||
import im.angry.openeuicc.util.*
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
import net.typeblog.lpac_jni.LocalProfileNotification
|
||||
|
||||
class NotificationsActivity : BaseEuiccAccessActivity(), OpenEuiccContextMarker {
|
||||
private lateinit var swipeRefresh: SwipeRefreshLayout
|
||||
private lateinit var notificationList: RecyclerView
|
||||
private val notificationAdapter = NotificationAdapter()
|
||||
|
||||
private var logicalSlotId = -1
|
||||
private var seId = EuiccChannel.SecureElementId.DEFAULT
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
enableEdgeToEdge()
|
||||
super.onCreate(savedInstanceState)
|
||||
setContentView(R.layout.activity_notifications)
|
||||
setSupportActionBar(requireViewById(R.id.toolbar))
|
||||
supportActionBar!!.setDisplayHomeAsUpEnabled(true)
|
||||
|
||||
swipeRefresh = requireViewById(R.id.swipe_refresh)
|
||||
notificationList = requireViewById(R.id.recycler_view)
|
||||
|
||||
setupRootViewSystemBarInsets(
|
||||
window.decorView.rootView, arrayOf(
|
||||
this::activityToolbarInsetHandler,
|
||||
mainViewPaddingInsetHandler(notificationList)
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
override fun onInit() {
|
||||
notificationList.apply {
|
||||
val context = this@NotificationsActivity
|
||||
adapter = notificationAdapter
|
||||
layoutManager = LinearLayoutManager(context, RecyclerView.VERTICAL, false)
|
||||
addItemDecoration(DividerItemDecoration(context, LinearLayoutManager.VERTICAL))
|
||||
registerForContextMenu(this)
|
||||
}
|
||||
|
||||
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()
|
||||
}
|
||||
|
||||
refresh()
|
||||
}
|
||||
|
||||
override fun onCreateOptionsMenu(menu: Menu?): Boolean {
|
||||
super.onCreateOptionsMenu(menu)
|
||||
menuInflater.inflate(R.menu.activity_notifications, menu)
|
||||
return true
|
||||
}
|
||||
|
||||
override fun onOptionsItemSelected(item: MenuItem): Boolean =
|
||||
when (item.itemId) {
|
||||
android.R.id.home -> {
|
||||
finish()
|
||||
true
|
||||
}
|
||||
|
||||
R.id.help -> {
|
||||
AlertDialog.Builder(this, R.style.AlertDialogTheme).apply {
|
||||
setMessage(R.string.profile_notifications_help)
|
||||
setPositiveButton(android.R.string.ok) { dialog, _ ->
|
||||
dialog.dismiss()
|
||||
}
|
||||
show()
|
||||
}
|
||||
true
|
||||
}
|
||||
|
||||
else -> super.onOptionsItemSelected(item)
|
||||
}
|
||||
|
||||
private fun setChannelTitle(title: CharSequence) {
|
||||
super.setTitle(getString(R.string.profile_notifications_detailed_format, title))
|
||||
}
|
||||
|
||||
private fun launchTask(task: suspend () -> Unit) {
|
||||
swipeRefresh.isRefreshing = true
|
||||
|
||||
lifecycleScope.launch {
|
||||
withContext(Dispatchers.IO) {
|
||||
euiccChannelManagerLoaded.await()
|
||||
}
|
||||
|
||||
task()
|
||||
|
||||
swipeRefresh.isRefreshing = false
|
||||
}
|
||||
}
|
||||
|
||||
private fun refresh() {
|
||||
launchTask {
|
||||
notificationAdapter.notifications = withEuiccChannel { channel ->
|
||||
if (channel.hasMultipleSE) {
|
||||
withContext(Dispatchers.Main) {
|
||||
val channelTitle = if (logicalSlotId == EuiccChannelManager.USB_CHANNEL_ID) {
|
||||
getString(R.string.channel_name_format_usb_se, seId.id)
|
||||
} else {
|
||||
appContainer.customizableTextProvider.formatNonUsbChannelNameWithSeId(logicalSlotId, seId)
|
||||
}
|
||||
setChannelTitle(channelTitle)
|
||||
}
|
||||
}
|
||||
|
||||
val nameMap = channel.lpa.profiles
|
||||
.associate { Pair(it.iccid, it.displayName) }
|
||||
|
||||
channel.lpa.notifications.map {
|
||||
LocalProfileNotificationWrapper(it, nameMap[it.iccid] ?: "???")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun <R> withEuiccChannel(fn: suspend (EuiccChannel) -> R) =
|
||||
euiccChannelManager.withEuiccChannel(logicalSlotId, seId, fn)
|
||||
|
||||
data class LocalProfileNotificationWrapper(
|
||||
val inner: LocalProfileNotification,
|
||||
val profileName: String
|
||||
)
|
||||
|
||||
@SuppressLint("ClickableViewAccessibility")
|
||||
inner class NotificationViewHolder(private val root: View) :
|
||||
RecyclerView.ViewHolder(root), View.OnCreateContextMenuListener, OnMenuItemClickListener {
|
||||
private val address: TextView = root.requireViewById(R.id.notification_address)
|
||||
private val sequenceNumber: TextView =
|
||||
root.requireViewById(R.id.notification_sequence_number)
|
||||
private val profileName: TextView = root.requireViewById(R.id.notification_profile_name)
|
||||
|
||||
private lateinit var notification: LocalProfileNotificationWrapper
|
||||
|
||||
private var lastTouchX = 0f
|
||||
private var lastTouchY = 0f
|
||||
|
||||
init {
|
||||
root.isClickable = true
|
||||
root.setOnCreateContextMenuListener(this)
|
||||
root.setOnTouchListener { _, event ->
|
||||
lastTouchX = event.x
|
||||
lastTouchY = event.y
|
||||
false
|
||||
}
|
||||
root.setOnLongClickListener {
|
||||
root.showContextMenu(lastTouchX, lastTouchY)
|
||||
true
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
private fun operationToLocalizedText(operation: LocalProfileNotification.Operation) =
|
||||
root.context.getText(
|
||||
when (operation) {
|
||||
LocalProfileNotification.Operation.Install -> R.string.profile_notification_operation_download
|
||||
LocalProfileNotification.Operation.Delete -> R.string.profile_notification_operation_delete
|
||||
LocalProfileNotification.Operation.Enable -> R.string.profile_notification_operation_enable
|
||||
LocalProfileNotification.Operation.Disable -> R.string.profile_notification_operation_disable
|
||||
}
|
||||
)
|
||||
|
||||
fun updateNotification(value: LocalProfileNotificationWrapper) {
|
||||
notification = value
|
||||
|
||||
address.text = value.inner.notificationAddress
|
||||
sequenceNumber.text = root.context.getString(
|
||||
R.string.profile_notification_sequence_number_format,
|
||||
value.inner.seqNumber
|
||||
)
|
||||
profileName.text = Html.fromHtml(
|
||||
root.context.getString(
|
||||
R.string.profile_notification_name_format,
|
||||
operationToLocalizedText(value.inner.profileManagementOperation),
|
||||
value.profileName, value.inner.iccid
|
||||
),
|
||||
Html.FROM_HTML_MODE_COMPACT
|
||||
)
|
||||
}
|
||||
|
||||
override fun onCreateContextMenu(
|
||||
menu: ContextMenu?,
|
||||
v: View?,
|
||||
menuInfo: ContextMenu.ContextMenuInfo?
|
||||
) {
|
||||
menuInflater.inflate(R.menu.notification_options, menu)
|
||||
|
||||
menu!!.forEach {
|
||||
it.setOnMenuItemClickListener(this)
|
||||
}
|
||||
}
|
||||
|
||||
override fun onMenuItemClick(item: MenuItem): Boolean =
|
||||
when (item.itemId) {
|
||||
R.id.notification_process -> {
|
||||
launchTask {
|
||||
withContext(Dispatchers.IO) {
|
||||
withEuiccChannel { channel ->
|
||||
channel.lpa.handleNotification(notification.inner.seqNumber)
|
||||
}
|
||||
}
|
||||
|
||||
refresh()
|
||||
}
|
||||
true
|
||||
}
|
||||
|
||||
R.id.notification_delete -> {
|
||||
launchTask {
|
||||
withContext(Dispatchers.IO) {
|
||||
withEuiccChannel { channel ->
|
||||
channel.lpa.deleteNotification(notification.inner.seqNumber)
|
||||
}
|
||||
}
|
||||
|
||||
refresh()
|
||||
}
|
||||
true
|
||||
}
|
||||
|
||||
else -> false
|
||||
}
|
||||
}
|
||||
|
||||
inner class NotificationAdapter : RecyclerView.Adapter<NotificationViewHolder>() {
|
||||
var notifications: List<LocalProfileNotificationWrapper> = listOf()
|
||||
@SuppressLint("NotifyDataSetChanged")
|
||||
set(value) {
|
||||
field = value
|
||||
notifyDataSetChanged()
|
||||
}
|
||||
|
||||
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): NotificationViewHolder {
|
||||
val root = LayoutInflater.from(parent.context)
|
||||
.inflate(R.layout.notification_item, parent, false)
|
||||
return NotificationViewHolder(root)
|
||||
}
|
||||
|
||||
override fun getItemCount(): Int = notifications.size
|
||||
|
||||
override fun onBindViewHolder(holder: NotificationViewHolder, position: Int) =
|
||||
holder.updateNotification(notifications[position])
|
||||
|
||||
}
|
||||
}
|
||||
|
|
@ -1,100 +0,0 @@
|
|||
package im.angry.openeuicc.ui
|
||||
|
||||
import android.app.Dialog
|
||||
import android.os.Bundle
|
||||
import android.text.Editable
|
||||
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 ProfileDeleteFragment : DialogFragment(), EuiccChannelFragmentMarker {
|
||||
companion object {
|
||||
const val TAG = "ProfileDeleteFragment"
|
||||
private const val FIELD_ICCID = "iccid"
|
||||
private const val FIELD_NAME = "name"
|
||||
|
||||
fun newInstance(slotId: Int, portId: Int, seId: EuiccChannel.SecureElementId, iccid: String, name: String) =
|
||||
newInstanceEuicc(ProfileDeleteFragment::class.java, slotId, portId, seId) {
|
||||
putString(FIELD_ICCID, iccid)
|
||||
putString(FIELD_NAME, name)
|
||||
}
|
||||
}
|
||||
|
||||
private val iccid by lazy {
|
||||
requireArguments().getString(FIELD_ICCID)!!
|
||||
}
|
||||
|
||||
private val name by lazy {
|
||||
requireArguments().getString(FIELD_NAME)!!
|
||||
}
|
||||
|
||||
private val editText by lazy {
|
||||
EditText(requireContext()).apply {
|
||||
hint = Editable.Factory.getInstance()
|
||||
.newEditable(getString(R.string.profile_delete_confirm_input, name))
|
||||
}
|
||||
}
|
||||
|
||||
private val inputMatchesName: Boolean
|
||||
get() = editText.text.toString() == name
|
||||
|
||||
private var toast: Toast? = null
|
||||
|
||||
private var deleting = false
|
||||
|
||||
private val alertDialog: AlertDialog
|
||||
get() = requireDialog() as AlertDialog
|
||||
|
||||
override fun onCreateDialog(savedInstanceState: Bundle?): Dialog =
|
||||
AlertDialog.Builder(requireContext(), R.style.AlertDialogTheme).apply {
|
||||
setMessage(getString(R.string.profile_delete_confirm, name))
|
||||
setView(editText)
|
||||
setPositiveButton(android.R.string.ok, null) // Set listener to null to prevent auto closing
|
||||
setNegativeButton(android.R.string.cancel, null)
|
||||
}.create()
|
||||
|
||||
override fun onResume() {
|
||||
super.onResume()
|
||||
alertDialog.getButton(AlertDialog.BUTTON_POSITIVE).setOnClickListener {
|
||||
if (!deleting) delete()
|
||||
}
|
||||
alertDialog.getButton(AlertDialog.BUTTON_NEGATIVE).setOnClickListener {
|
||||
if (!deleting) dismiss()
|
||||
}
|
||||
}
|
||||
|
||||
private fun delete() {
|
||||
toast?.cancel()
|
||||
if (!inputMatchesName) {
|
||||
val resId = R.string.toast_profile_delete_confirm_text_mismatched
|
||||
toast = Toast.makeText(requireContext(), resId, Toast.LENGTH_LONG).also {
|
||||
it.show()
|
||||
}
|
||||
return
|
||||
}
|
||||
deleting = true
|
||||
alertDialog.setCanceledOnTouchOutside(false)
|
||||
alertDialog.setCancelable(false)
|
||||
alertDialog.getButton(AlertDialog.BUTTON_POSITIVE).isEnabled = false
|
||||
alertDialog.getButton(AlertDialog.BUTTON_NEGATIVE).isEnabled = false
|
||||
|
||||
requireParentFragment().lifecycleScope.launch {
|
||||
ensureEuiccChannelManager()
|
||||
euiccChannelManagerService.waitForForegroundTask()
|
||||
euiccChannelManagerService.launchProfileDeleteTask(slotId, portId, seId, iccid)
|
||||
.onStart {
|
||||
parentFragment?.notifyEuiccProfilesChanged()
|
||||
runCatching(::dismiss)
|
||||
}
|
||||
.waitDone()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -1,134 +0,0 @@
|
|||
package im.angry.openeuicc.ui
|
||||
|
||||
import android.app.Dialog
|
||||
import android.os.Bundle
|
||||
import android.view.LayoutInflater
|
||||
import android.view.View
|
||||
import android.view.ViewGroup
|
||||
import android.widget.ProgressBar
|
||||
import android.widget.Toast
|
||||
import androidx.annotation.StringRes
|
||||
import androidx.appcompat.widget.Toolbar
|
||||
import androidx.lifecycle.lifecycleScope
|
||||
import com.google.android.material.textfield.TextInputLayout
|
||||
import im.angry.openeuicc.common.R
|
||||
import im.angry.openeuicc.core.EuiccChannel
|
||||
import im.angry.openeuicc.service.EuiccChannelManagerService.Companion.waitDone
|
||||
import im.angry.openeuicc.util.*
|
||||
import kotlinx.coroutines.launch
|
||||
import net.typeblog.lpac_jni.LocalProfileAssistant
|
||||
|
||||
class ProfileRenameFragment : BaseMaterialDialogFragment(), EuiccChannelFragmentMarker {
|
||||
companion object {
|
||||
private const val FIELD_ICCID = "iccid"
|
||||
private const val FIELD_CURRENT_NAME = "currentName"
|
||||
|
||||
const val TAG = "ProfileRenameFragment"
|
||||
|
||||
fun newInstance(
|
||||
slotId: Int, portId: Int, seId: EuiccChannel.SecureElementId,
|
||||
iccid: String, currentName: String
|
||||
) = newInstanceEuicc(ProfileRenameFragment::class.java, slotId, portId, seId) {
|
||||
putString(FIELD_ICCID, iccid)
|
||||
putString(FIELD_CURRENT_NAME, currentName)
|
||||
}
|
||||
}
|
||||
|
||||
private lateinit var toolbar: Toolbar
|
||||
private lateinit var profileRenameNewName: TextInputLayout
|
||||
private lateinit var progress: ProgressBar
|
||||
|
||||
private var renaming = false
|
||||
|
||||
private val iccid: String by lazy {
|
||||
requireArguments().getString(FIELD_ICCID)!!
|
||||
}
|
||||
|
||||
private val currentName: String by lazy {
|
||||
requireArguments().getString(FIELD_CURRENT_NAME)!!
|
||||
}
|
||||
|
||||
override fun onCreateView(
|
||||
inflater: LayoutInflater,
|
||||
container: ViewGroup?,
|
||||
savedInstanceState: Bundle?
|
||||
): View {
|
||||
val view = inflater.inflate(R.layout.fragment_profile_rename, container, false)
|
||||
|
||||
toolbar = view.requireViewById(R.id.toolbar)
|
||||
profileRenameNewName = view.requireViewById(R.id.profile_rename_new_name)
|
||||
progress = view.requireViewById(R.id.progress)
|
||||
|
||||
toolbar.inflateMenu(R.menu.fragment_profile_rename)
|
||||
|
||||
return view
|
||||
}
|
||||
|
||||
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
|
||||
super.onViewCreated(view, savedInstanceState)
|
||||
profileRenameNewName.editText!!.setText(currentName)
|
||||
toolbar.apply {
|
||||
setTitle(R.string.profile_rename)
|
||||
setNavigationOnClickListener {
|
||||
if (!renaming) dismiss()
|
||||
}
|
||||
setOnMenuItemClickListener {
|
||||
if (!renaming) rename()
|
||||
true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun onResume() {
|
||||
super.onResume()
|
||||
setWidthPercent(95)
|
||||
}
|
||||
|
||||
override fun onCreateDialog(savedInstanceState: Bundle?): Dialog {
|
||||
return super.onCreateDialog(savedInstanceState).also {
|
||||
it.setCanceledOnTouchOutside(false)
|
||||
}
|
||||
}
|
||||
|
||||
private fun showErrorAndCancel(@StringRes resId: Int) {
|
||||
Toast.makeText(requireContext(), resId, Toast.LENGTH_LONG).show()
|
||||
|
||||
renaming = false
|
||||
progress.visibility = View.GONE
|
||||
}
|
||||
|
||||
private fun rename() {
|
||||
renaming = true
|
||||
progress.isIndeterminate = true
|
||||
progress.visibility = View.VISIBLE
|
||||
|
||||
val newName = profileRenameNewName.editText!!.text.toString().trim()
|
||||
|
||||
lifecycleScope.launch {
|
||||
ensureEuiccChannelManager()
|
||||
euiccChannelManagerService.waitForForegroundTask()
|
||||
val response = euiccChannelManagerService
|
||||
.launchProfileRenameTask(slotId, portId, seId, iccid, newName).waitDone()
|
||||
|
||||
when (response) {
|
||||
is LocalProfileAssistant.ProfileNameTooLongException -> {
|
||||
showErrorAndCancel(R.string.profile_rename_too_long)
|
||||
}
|
||||
|
||||
is LocalProfileAssistant.ProfileNameIsInvalidUTF8Exception -> {
|
||||
showErrorAndCancel(R.string.profile_rename_encoding_error)
|
||||
}
|
||||
|
||||
is Throwable -> {
|
||||
showErrorAndCancel(R.string.profile_rename_failure)
|
||||
}
|
||||
|
||||
else -> {
|
||||
parentFragment?.notifyEuiccProfilesChanged()
|
||||
|
||||
runCatching(::dismiss)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -1,43 +0,0 @@
|
|||
package im.angry.openeuicc.ui
|
||||
|
||||
import android.os.Bundle
|
||||
import android.view.MenuItem
|
||||
import androidx.activity.enableEdgeToEdge
|
||||
import androidx.appcompat.app.AppCompatActivity
|
||||
import im.angry.openeuicc.OpenEuiccApplication
|
||||
import im.angry.openeuicc.common.R
|
||||
import im.angry.openeuicc.util.*
|
||||
|
||||
class SettingsActivity : AppCompatActivity() {
|
||||
private val appContainer
|
||||
get() = (application as OpenEuiccApplication).appContainer
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
enableEdgeToEdge()
|
||||
super.onCreate(savedInstanceState)
|
||||
setContentView(R.layout.activity_settings)
|
||||
setSupportActionBar(requireViewById(R.id.toolbar))
|
||||
supportActionBar!!.setDisplayHomeAsUpEnabled(true)
|
||||
|
||||
setupRootViewSystemBarInsets(
|
||||
window.decorView.rootView, arrayOf(
|
||||
this::activityToolbarInsetHandler
|
||||
), consume = false
|
||||
)
|
||||
|
||||
val settingsFragment = appContainer.uiComponentFactory.createSettingsFragment()
|
||||
supportFragmentManager.beginTransaction()
|
||||
.replace(R.id.settings_container, settingsFragment)
|
||||
.commit()
|
||||
}
|
||||
|
||||
override fun onOptionsItemSelected(item: MenuItem): Boolean =
|
||||
when (item.itemId) {
|
||||
android.R.id.home -> {
|
||||
finish()
|
||||
true
|
||||
}
|
||||
|
||||
else -> super.onOptionsItemSelected(item)
|
||||
}
|
||||
}
|
||||
|
|
@ -1,175 +0,0 @@
|
|||
package im.angry.openeuicc.ui
|
||||
|
||||
import android.content.Intent
|
||||
import android.net.Uri
|
||||
import android.os.Build
|
||||
import android.os.Bundle
|
||||
import android.provider.Settings
|
||||
import android.widget.Toast
|
||||
import androidx.lifecycle.lifecycleScope
|
||||
import androidx.preference.CheckBoxPreference
|
||||
import androidx.preference.ListPreference
|
||||
import androidx.preference.Preference
|
||||
import androidx.preference.PreferenceCategory
|
||||
import androidx.preference.PreferenceFragmentCompat
|
||||
import im.angry.openeuicc.common.R
|
||||
import im.angry.openeuicc.util.*
|
||||
import kotlinx.coroutines.flow.collect
|
||||
import kotlinx.coroutines.flow.onEach
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
open class SettingsFragment : PreferenceFragmentCompat(), OpenEuiccContextMarker {
|
||||
private lateinit var developerPref: PreferenceCategory
|
||||
|
||||
// Hidden developer options switch
|
||||
private var numClicks = 0
|
||||
private var lastClickTimestamp = -1L
|
||||
private var lastToast: Toast? = null
|
||||
|
||||
override fun onCreatePreferences(savedInstanceState: Bundle?, rootKey: String?) {
|
||||
setPreferencesFromResource(R.xml.pref_settings, rootKey)
|
||||
|
||||
developerPref = requirePreference("pref_developer")
|
||||
|
||||
// Show / hide developer preference based on whether it is enabled
|
||||
lifecycleScope.launch {
|
||||
preferenceRepository.developerOptionsEnabledFlow
|
||||
.onEach(developerPref::setVisible)
|
||||
.collect()
|
||||
}
|
||||
|
||||
requirePreference<Preference>("pref_info_app_version").apply {
|
||||
summary = requireContext().selfAppVersion
|
||||
|
||||
// Enable developer options when this is clicked for 7 times
|
||||
setOnPreferenceClickListener(::onAppVersionClicked)
|
||||
}
|
||||
|
||||
requirePreference<Preference>("pref_advanced_language").apply {
|
||||
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.TIRAMISU) return@apply
|
||||
val uri = Uri.fromParts("package", requireContext().packageName, null)
|
||||
intent = Intent(Settings.ACTION_APP_LOCALE_SETTINGS, uri)
|
||||
isVisible = intent!!.resolveActivity(requireContext().packageManager) != null
|
||||
}
|
||||
|
||||
requirePreference<Preference>("pref_advanced_logs").apply {
|
||||
intent = Intent(requireContext(), LogsActivity::class.java)
|
||||
}
|
||||
|
||||
requirePreference<CheckBoxPreference>("pref_notifications_download")
|
||||
.bindBooleanFlow(preferenceRepository.notificationDownloadFlow)
|
||||
|
||||
requirePreference<CheckBoxPreference>("pref_notifications_delete")
|
||||
.bindBooleanFlow(preferenceRepository.notificationDeleteFlow)
|
||||
|
||||
requirePreference<CheckBoxPreference>("pref_notifications_switch")
|
||||
.bindBooleanFlow(preferenceRepository.notificationSwitchFlow)
|
||||
|
||||
requirePreference<CheckBoxPreference>("pref_advanced_disable_safeguard_removable_esim")
|
||||
.bindBooleanFlow(preferenceRepository.disableSafeguardFlow)
|
||||
|
||||
requirePreference<CheckBoxPreference>("pref_advanced_verbose_logging")
|
||||
.bindBooleanFlow(preferenceRepository.verboseLoggingFlow)
|
||||
|
||||
requirePreference<CheckBoxPreference>("pref_developer_unfiltered_profile_list")
|
||||
.bindBooleanFlow(preferenceRepository.unfilteredProfileListFlow)
|
||||
|
||||
requirePreference<CheckBoxPreference>("pref_developer_ignore_tls_certificate")
|
||||
.bindBooleanFlow(preferenceRepository.ignoreTLSCertificateFlow)
|
||||
|
||||
requirePreference<CheckBoxPreference>("pref_developer_refresh_after_switch")
|
||||
.bindBooleanFlow(preferenceRepository.refreshAfterSwitchFlow)
|
||||
|
||||
requirePreference<ListPreference>("pref_developer_es10x_mss")
|
||||
.bindIntFlow(preferenceRepository.es10xMssFlow, 63)
|
||||
|
||||
requirePreference<Preference>("pref_developer_isdr_aid_list").apply {
|
||||
intent = Intent(requireContext(), IsdrAidListActivity::class.java)
|
||||
}
|
||||
|
||||
requirePreference<Preference>("pref_info_website").apply {
|
||||
val uri = appContainer.customizableTextProvider.websiteUri ?: return@apply
|
||||
isVisible = true
|
||||
summary = uri.buildUpon().clearQuery().build().toString()
|
||||
intent = Intent(/* action = */ Intent.ACTION_VIEW, uri)
|
||||
}
|
||||
}
|
||||
|
||||
protected fun <T : Preference> requirePreference(key: CharSequence) =
|
||||
findPreference<T>(key)!!
|
||||
|
||||
override fun onStart() {
|
||||
super.onStart()
|
||||
setupRootViewSystemBarInsets(requireView(), arrayOf(
|
||||
mainViewPaddingInsetHandler(requireView().requireViewById(R.id.recycler_view))
|
||||
))
|
||||
}
|
||||
|
||||
@Suppress("UNUSED_PARAMETER")
|
||||
private fun onAppVersionClicked(pref: Preference): Boolean {
|
||||
if (developerPref.isVisible) return false
|
||||
|
||||
val now = System.currentTimeMillis()
|
||||
numClicks = if (now - lastClickTimestamp >= 1000) 1 else numClicks + 1
|
||||
lastClickTimestamp = now
|
||||
|
||||
lifecycleScope.launch {
|
||||
preferenceRepository.developerOptionsEnabledFlow.updatePreference(numClicks >= 7)
|
||||
}
|
||||
|
||||
val toastText = when {
|
||||
numClicks == 7 -> getString(R.string.developer_options_enabled)
|
||||
numClicks > 1 -> getString(R.string.developer_options_steps, 7 - numClicks)
|
||||
else -> return true
|
||||
}
|
||||
|
||||
lastToast?.cancel()
|
||||
lastToast = Toast.makeText(requireContext(), toastText, Toast.LENGTH_SHORT)
|
||||
lastToast!!.show()
|
||||
return true
|
||||
}
|
||||
|
||||
protected fun CheckBoxPreference.bindBooleanFlow(flow: PreferenceFlowWrapper<Boolean>) {
|
||||
lifecycleScope.launch {
|
||||
flow.collect(::setChecked)
|
||||
}
|
||||
|
||||
setOnPreferenceChangeListener { _, newValue ->
|
||||
lifecycleScope.launch {
|
||||
flow.updatePreference(newValue as Boolean)
|
||||
}
|
||||
true
|
||||
}
|
||||
}
|
||||
|
||||
private fun ListPreference.bindIntFlow(flow: PreferenceFlowWrapper<Int>, defaultValue: Int) {
|
||||
lifecycleScope.launch {
|
||||
flow.collect { value = it.toString() }
|
||||
}
|
||||
|
||||
setOnPreferenceChangeListener { _, newValue ->
|
||||
lifecycleScope.launch {
|
||||
flow.updatePreference((newValue as String).toIntOrNull() ?: defaultValue)
|
||||
}
|
||||
true
|
||||
}
|
||||
}
|
||||
|
||||
protected fun mergePreferenceOverlay(overlayKey: String, targetKey: String) {
|
||||
val overlayCat = requirePreference<PreferenceCategory>(overlayKey)
|
||||
val targetCat = requirePreference<PreferenceCategory>(targetKey)
|
||||
|
||||
val prefs = buildList {
|
||||
for (i in 0..<overlayCat.preferenceCount) {
|
||||
add(overlayCat.getPreference(i))
|
||||
}
|
||||
}
|
||||
|
||||
prefs.forEach {
|
||||
overlayCat.removePreference(it)
|
||||
targetCat.addPreference(it)
|
||||
}
|
||||
|
||||
overlayCat.parent?.removePreference(overlayCat)
|
||||
}
|
||||
}
|
||||
|
|
@ -1,163 +0,0 @@
|
|||
package im.angry.openeuicc.ui
|
||||
|
||||
import android.annotation.SuppressLint
|
||||
import android.app.PendingIntent
|
||||
import android.content.BroadcastReceiver
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.content.IntentFilter
|
||||
import android.hardware.usb.UsbDevice
|
||||
import android.hardware.usb.UsbManager
|
||||
import android.os.Build
|
||||
import android.os.Bundle
|
||||
import android.view.LayoutInflater
|
||||
import android.view.View
|
||||
import android.view.ViewGroup
|
||||
import android.widget.Button
|
||||
import android.widget.TextView
|
||||
import androidx.fragment.app.Fragment
|
||||
import androidx.lifecycle.lifecycleScope
|
||||
import im.angry.openeuicc.common.R
|
||||
import im.angry.openeuicc.core.EuiccChannelManager
|
||||
import im.angry.openeuicc.util.*
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.flow.toList
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
|
||||
/**
|
||||
* A fragment to handle USB reader-specific permission flow. If/after
|
||||
* permission is granted, this fragment simply calls back to MainActivity
|
||||
* to instantiate the corresponding EuiccManagementFragment(s) for the USB
|
||||
* reader.
|
||||
*
|
||||
* Having this fragment allows MainActivity to be (mostly) agnostic
|
||||
* of the underlying implementation of different types of channels.
|
||||
* When permission is granted, this fragment will simply load
|
||||
* EuiccManagementFragment using its own childFragmentManager.
|
||||
*
|
||||
* Note that for now we assume there will only be one USB card reader
|
||||
* device. This is also an implicit assumption in EuiccChannelManager.
|
||||
*/
|
||||
class UsbCcidReaderPermissionFragment : Fragment(), OpenEuiccContextMarker {
|
||||
companion object {
|
||||
const val ACTION_USB_PERMISSION = "im.angry.openeuicc.USB_PERMISSION"
|
||||
}
|
||||
|
||||
private val euiccChannelManager: EuiccChannelManager by lazy {
|
||||
(requireActivity() as MainActivity).euiccChannelManager
|
||||
}
|
||||
|
||||
private val usbManager: UsbManager by lazy {
|
||||
requireContext().getSystemService(Context.USB_SERVICE) as UsbManager
|
||||
}
|
||||
|
||||
private val usbPermissionReceiver = object : BroadcastReceiver() {
|
||||
override fun onReceive(context: Context?, intent: Intent?) {
|
||||
if (intent?.action == ACTION_USB_PERMISSION) {
|
||||
if (usbDevice != null && usbManager.hasPermission(usbDevice)) {
|
||||
lifecycleScope.launch(Dispatchers.Main) {
|
||||
tryLoadUsbChannel()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private lateinit var usbPendingIntent: PendingIntent
|
||||
|
||||
private lateinit var text: TextView
|
||||
private lateinit var permissionButton: Button
|
||||
private lateinit var loadingProgress: View
|
||||
|
||||
private var usbDevice: UsbDevice? = null
|
||||
|
||||
override fun onCreateView(
|
||||
inflater: LayoutInflater,
|
||||
container: ViewGroup?,
|
||||
savedInstanceState: Bundle?
|
||||
): View? {
|
||||
val view = inflater.inflate(R.layout.fragment_usb_ccid_reader, container, false)
|
||||
|
||||
text = view.requireViewById(R.id.usb_reader_text)
|
||||
permissionButton = view.requireViewById(R.id.usb_grant_permission)
|
||||
loadingProgress = view.requireViewById(R.id.loading)
|
||||
|
||||
permissionButton.setOnClickListener {
|
||||
usbManager.requestPermission(usbDevice, usbPendingIntent)
|
||||
}
|
||||
|
||||
return view
|
||||
}
|
||||
|
||||
@SuppressLint("UnspecifiedRegisterReceiverFlag", "WrongConstant")
|
||||
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
|
||||
super.onViewCreated(view, savedInstanceState)
|
||||
|
||||
usbPendingIntent = PendingIntent.getBroadcast(
|
||||
requireContext(), 0,
|
||||
Intent(ACTION_USB_PERMISSION), PendingIntent.FLAG_IMMUTABLE
|
||||
)
|
||||
val filter = IntentFilter(ACTION_USB_PERMISSION)
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
|
||||
requireContext().registerReceiver(
|
||||
usbPermissionReceiver,
|
||||
filter,
|
||||
Context.RECEIVER_EXPORTED
|
||||
)
|
||||
} else {
|
||||
requireContext().registerReceiver(usbPermissionReceiver, filter)
|
||||
}
|
||||
|
||||
lifecycleScope.launch(Dispatchers.Main) {
|
||||
tryLoadUsbChannel()
|
||||
}
|
||||
}
|
||||
|
||||
override fun onDetach() {
|
||||
super.onDetach()
|
||||
try {
|
||||
requireContext().unregisterReceiver(usbPermissionReceiver)
|
||||
} catch (_: Exception) {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
|
||||
override fun onDestroy() {
|
||||
super.onDestroy()
|
||||
try {
|
||||
requireContext().unregisterReceiver(usbPermissionReceiver)
|
||||
} catch (_: Exception) {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun tryLoadUsbChannel() {
|
||||
text.visibility = View.GONE
|
||||
permissionButton.visibility = View.GONE
|
||||
loadingProgress.visibility = View.VISIBLE
|
||||
|
||||
val (device, canOpen) = withContext(Dispatchers.IO) {
|
||||
euiccChannelManager.tryOpenUsbEuiccChannel()
|
||||
}
|
||||
|
||||
usbDevice = device
|
||||
|
||||
if (device != null && !canOpen && !usbManager.hasPermission(device)) {
|
||||
loadingProgress.visibility = View.GONE
|
||||
text.text = getString(R.string.usb_permission_needed)
|
||||
text.visibility = View.VISIBLE
|
||||
permissionButton.visibility = View.VISIBLE
|
||||
} else if (device != null && canOpen) {
|
||||
val seIds = withContext(Dispatchers.IO) {
|
||||
euiccChannelManager.flowEuiccSecureElements(EuiccChannelManager.USB_CHANNEL_ID, 0).toList()
|
||||
}
|
||||
(requireActivity() as MainActivity).instantiateUsbTabs(seIds)
|
||||
} else {
|
||||
loadingProgress.visibility = View.GONE
|
||||
text.text = getString(R.string.usb_failed)
|
||||
text.visibility = View.VISIBLE
|
||||
permissionButton.visibility = View.GONE
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -1,21 +0,0 @@
|
|||
package im.angry.openeuicc.ui.preference
|
||||
|
||||
import android.content.Context
|
||||
import android.util.AttributeSet
|
||||
import android.widget.TextView
|
||||
import androidx.preference.PreferenceCategory
|
||||
import androidx.preference.PreferenceViewHolder
|
||||
|
||||
@Suppress("unused")
|
||||
class LongSummaryPreferenceCategory : PreferenceCategory {
|
||||
constructor(ctx: Context) : super(ctx)
|
||||
constructor(ctx: Context, attrs: AttributeSet) : super(ctx, attrs)
|
||||
constructor(ctx: Context, attrs: AttributeSet, defStyle: Int) : super(ctx, attrs, defStyle)
|
||||
|
||||
override fun onBindViewHolder(holder: PreferenceViewHolder) {
|
||||
super.onBindViewHolder(holder)
|
||||
val summaryText = holder.findViewById(android.R.id.summary) as TextView
|
||||
summaryText.isSingleLine = false
|
||||
summaryText.maxLines = 10
|
||||
}
|
||||
}
|
||||
|
|
@ -1,51 +0,0 @@
|
|||
package im.angry.openeuicc.ui.widget
|
||||
|
||||
import android.content.Context
|
||||
import android.util.AttributeSet
|
||||
import android.view.ViewGroup
|
||||
import com.google.android.material.R
|
||||
import com.google.android.material.tabs.TabLayout
|
||||
|
||||
/**
|
||||
* A TabLayout that automatically switches to MODE_SCROLLABLE when
|
||||
* child tabs overflow the full width of the layout.
|
||||
*/
|
||||
class DynamicModeTabLayout @JvmOverloads constructor(
|
||||
context: Context,
|
||||
attrs: AttributeSet? = null,
|
||||
defStyleAttr: Int = R.attr.tabStyle
|
||||
) : TabLayout(context, attrs, defStyleAttr) {
|
||||
init {
|
||||
addOnLayoutChangeListener { _, _, _, _, _, _, _, _, _ ->
|
||||
updateModeIfNecessary()
|
||||
}
|
||||
}
|
||||
|
||||
private fun updateModeIfNecessary() {
|
||||
if (width <= 0 || tabCount == 0) return
|
||||
|
||||
val tabStrip = getChildAt(0) as? ViewGroup ?: return
|
||||
val totalTabWidth = (0 until tabStrip.childCount).sumOf { index ->
|
||||
val tabView = tabStrip.getChildAt(index)
|
||||
val layoutParams = tabView.layoutParams as? MarginLayoutParams
|
||||
tabView.measure(
|
||||
MeasureSpec.makeMeasureSpec(0, MeasureSpec.UNSPECIFIED),
|
||||
MeasureSpec.makeMeasureSpec(0, MeasureSpec.UNSPECIFIED)
|
||||
)
|
||||
tabView.measuredWidth + (layoutParams?.leftMargin ?: 0) + (layoutParams?.rightMargin ?: 0)
|
||||
}
|
||||
|
||||
val availableWidth = width - paddingLeft - paddingRight
|
||||
val shouldScroll = totalTabWidth > availableWidth
|
||||
val targetMode = if (shouldScroll) MODE_SCROLLABLE else MODE_FIXED
|
||||
val targetGravity = if (shouldScroll) GRAVITY_START else GRAVITY_FILL
|
||||
|
||||
if (tabMode != targetMode) {
|
||||
tabMode = targetMode
|
||||
}
|
||||
|
||||
if (tabGravity != targetGravity) {
|
||||
tabGravity = targetGravity
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -1,349 +0,0 @@
|
|||
package im.angry.openeuicc.ui.wizard
|
||||
|
||||
import android.app.assist.AssistContent
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.os.Bundle
|
||||
import android.view.View
|
||||
import android.view.WindowManager
|
||||
import android.view.inputmethod.InputMethodManager
|
||||
import android.widget.Button
|
||||
import android.widget.ProgressBar
|
||||
import android.widget.Toast
|
||||
import androidx.activity.OnBackPressedCallback
|
||||
import androidx.activity.enableEdgeToEdge
|
||||
import androidx.core.net.toUri
|
||||
import androidx.core.view.ViewCompat
|
||||
import androidx.core.view.WindowInsetsCompat
|
||||
import androidx.core.view.updatePadding
|
||||
import androidx.fragment.app.Fragment
|
||||
import androidx.lifecycle.lifecycleScope
|
||||
import im.angry.openeuicc.common.R
|
||||
import im.angry.openeuicc.core.EuiccChannel
|
||||
import im.angry.openeuicc.core.EuiccChannelManager
|
||||
import im.angry.openeuicc.ui.BaseEuiccAccessActivity
|
||||
import im.angry.openeuicc.util.*
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.launch
|
||||
import net.typeblog.lpac_jni.LocalProfileAssistant
|
||||
|
||||
class DownloadWizardActivity : BaseEuiccAccessActivity() {
|
||||
companion object {
|
||||
const val TAG = "DownloadWizardActivity"
|
||||
|
||||
private const val FIELD_LOGICAL_SLOT_ID = "selectedLogicalSlot"
|
||||
|
||||
fun newIntent(
|
||||
context: Context,
|
||||
logicalSlotId: Int = 0,
|
||||
seId: EuiccChannel.SecureElementId = EuiccChannel.SecureElementId.DEFAULT
|
||||
) = Intent(context, DownloadWizardActivity::class.java).apply {
|
||||
val selectedSyntheticSlotId = DownloadWizardSlotSelectFragment
|
||||
.encodeSyntheticSlotId(logicalSlotId, seId)
|
||||
putExtra(FIELD_LOGICAL_SLOT_ID, selectedSyntheticSlotId)
|
||||
}
|
||||
}
|
||||
|
||||
data class DownloadWizardState(
|
||||
var currentStepFragmentClassName: String?,
|
||||
var selectedSyntheticSlotId: Int,
|
||||
var smdp: String,
|
||||
var matchingId: String?,
|
||||
var confirmationCode: String?,
|
||||
var imei: String?,
|
||||
var downloadStarted: Boolean,
|
||||
var downloadTaskID: Long,
|
||||
var downloadError: LocalProfileAssistant.ProfileDownloadException?,
|
||||
var skipMethodSelect: Boolean,
|
||||
var confirmationCodeRequired: Boolean,
|
||||
)
|
||||
|
||||
private lateinit var state: DownloadWizardState
|
||||
|
||||
private lateinit var progressBar: ProgressBar
|
||||
private lateinit var nextButton: Button
|
||||
private lateinit var prevButton: Button
|
||||
|
||||
private var currentFragment: DownloadWizardStepFragment? = null
|
||||
set(value) {
|
||||
if (this::state.isInitialized) {
|
||||
state.currentStepFragmentClassName = value?.javaClass?.name
|
||||
}
|
||||
field = value
|
||||
}
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
enableEdgeToEdge()
|
||||
super.onCreate(savedInstanceState)
|
||||
setContentView(R.layout.activity_download_wizard)
|
||||
onBackPressedDispatcher.addCallback(object : OnBackPressedCallback(true) {
|
||||
override fun handleOnBackPressed() {
|
||||
// Make back == prev
|
||||
onPrevPressed()
|
||||
}
|
||||
})
|
||||
|
||||
state = DownloadWizardState(
|
||||
currentStepFragmentClassName = null,
|
||||
selectedSyntheticSlotId = intent.getIntExtra(FIELD_LOGICAL_SLOT_ID, 0),
|
||||
smdp = "",
|
||||
matchingId = null,
|
||||
confirmationCode = null,
|
||||
imei = null,
|
||||
downloadStarted = false,
|
||||
downloadTaskID = -1,
|
||||
downloadError = null,
|
||||
skipMethodSelect = false,
|
||||
confirmationCodeRequired = false,
|
||||
)
|
||||
|
||||
handleDeepLink()
|
||||
|
||||
progressBar = requireViewById(R.id.progress)
|
||||
nextButton = requireViewById(R.id.download_wizard_next)
|
||||
prevButton = requireViewById(R.id.download_wizard_back)
|
||||
|
||||
nextButton.setOnClickListener {
|
||||
onNextPressed()
|
||||
}
|
||||
|
||||
prevButton.setOnClickListener {
|
||||
onPrevPressed()
|
||||
}
|
||||
|
||||
val navigation = requireViewById<View>(R.id.download_wizard_navigation)
|
||||
val origHeight = navigation.layoutParams.height
|
||||
val fragmentRoot = requireViewById<View>(R.id.step_fragment_container)
|
||||
|
||||
ViewCompat.setOnApplyWindowInsetsListener(window.decorView.rootView) { _, insets ->
|
||||
val bars = insets.getInsets(
|
||||
WindowInsetsCompat.Type.systemBars()
|
||||
or WindowInsetsCompat.Type.displayCutout()
|
||||
or WindowInsetsCompat.Type.ime()
|
||||
)
|
||||
|
||||
fragmentRoot.updatePadding(bars.left, bars.top, bars.right, 0)
|
||||
navigation.updatePadding(bars.left, 0, bars.right, bars.bottom)
|
||||
val newNavParams = navigation.layoutParams
|
||||
newNavParams.height = origHeight + bars.bottom
|
||||
navigation.layoutParams = newNavParams
|
||||
WindowInsetsCompat.CONSUMED
|
||||
}
|
||||
}
|
||||
|
||||
private fun handleDeepLink() {
|
||||
// If we get an LPA string from deep-link intents, extract from there.
|
||||
// Note that `onRestoreInstanceState` could override this with user input,
|
||||
// but that _is_ the desired behavior.
|
||||
val uri = intent.data ?: return
|
||||
if (uri.scheme.contentEquals("lpa", ignoreCase = true)) {
|
||||
val parsed = LPAString.parse(uri.schemeSpecificPart)
|
||||
state.smdp = parsed.address
|
||||
state.matchingId = parsed.matchingId
|
||||
state.confirmationCodeRequired = parsed.confirmationCodeRequired
|
||||
state.skipMethodSelect = true
|
||||
}
|
||||
}
|
||||
|
||||
override fun onProvideAssistContent(outContent: AssistContent?) {
|
||||
super.onProvideAssistContent(outContent)
|
||||
outContent?.webUri = try {
|
||||
val activationCode = LPAString(
|
||||
state.smdp,
|
||||
state.matchingId,
|
||||
null,
|
||||
state.confirmationCode != null,
|
||||
)
|
||||
"LPA:$activationCode".toUri()
|
||||
} catch (_: Exception) {
|
||||
null
|
||||
}
|
||||
}
|
||||
|
||||
override fun onSaveInstanceState(outState: Bundle) {
|
||||
super.onSaveInstanceState(outState)
|
||||
outState.putString("currentStepFragmentClassName", state.currentStepFragmentClassName)
|
||||
outState.putInt("selectedLogicalSlot", state.selectedSyntheticSlotId)
|
||||
outState.putString("smdp", state.smdp)
|
||||
outState.putString("matchingId", state.matchingId)
|
||||
outState.putString("confirmationCode", state.confirmationCode)
|
||||
outState.putString("imei", state.imei)
|
||||
outState.putBoolean("downloadStarted", state.downloadStarted)
|
||||
outState.putLong("downloadTaskID", state.downloadTaskID)
|
||||
outState.putBoolean("confirmationCodeRequired", state.confirmationCodeRequired)
|
||||
}
|
||||
|
||||
override fun onRestoreInstanceState(savedInstanceState: Bundle) {
|
||||
super.onRestoreInstanceState(savedInstanceState)
|
||||
state.currentStepFragmentClassName = savedInstanceState.getString(
|
||||
"currentStepFragmentClassName",
|
||||
state.currentStepFragmentClassName
|
||||
)
|
||||
state.selectedSyntheticSlotId =
|
||||
savedInstanceState.getInt("selectedSyntheticSlotId", state.selectedSyntheticSlotId)
|
||||
state.smdp = savedInstanceState.getString("smdp", state.smdp)
|
||||
state.matchingId = savedInstanceState.getString("matchingId", state.matchingId)
|
||||
state.imei = savedInstanceState.getString("imei", state.imei)
|
||||
state.downloadStarted =
|
||||
savedInstanceState.getBoolean("downloadStarted", state.downloadStarted)
|
||||
state.downloadTaskID = savedInstanceState.getLong("downloadTaskID", state.downloadTaskID)
|
||||
state.confirmationCode =
|
||||
savedInstanceState.getString("confirmationCode", state.confirmationCode)
|
||||
state.confirmationCodeRequired = savedInstanceState.getBoolean(
|
||||
"confirmationCodeRequired",
|
||||
state.confirmationCodeRequired
|
||||
)
|
||||
}
|
||||
|
||||
private fun onPrevPressed() {
|
||||
hideIme()
|
||||
|
||||
if (currentFragment?.hasPrev == true) {
|
||||
val prevFrag = currentFragment?.createPrevFragment()
|
||||
if (prevFrag == null) {
|
||||
finish()
|
||||
} else {
|
||||
showFragment(prevFrag, R.anim.slide_in_left, R.anim.slide_out_right)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun onNextPressed() {
|
||||
hideIme()
|
||||
|
||||
nextButton.isEnabled = false
|
||||
progressBar.visibility = View.VISIBLE
|
||||
progressBar.isIndeterminate = true
|
||||
|
||||
lifecycleScope.launch(Dispatchers.Main) {
|
||||
if (state.selectedSyntheticSlotId >= 0) {
|
||||
try {
|
||||
val (slotId, seId) = DownloadWizardSlotSelectFragment.decodeSyntheticSlotId(
|
||||
state.selectedSyntheticSlotId
|
||||
)
|
||||
// This is run on IO by default
|
||||
euiccChannelManager.withEuiccChannel(slotId, seId) { channel ->
|
||||
// Be _very_ sure that the channel we got is valid
|
||||
if (!channel.valid) throw EuiccChannelManager.EuiccChannelNotFoundException()
|
||||
}
|
||||
} catch (e: EuiccChannelManager.EuiccChannelNotFoundException) {
|
||||
Toast.makeText(
|
||||
this@DownloadWizardActivity,
|
||||
R.string.download_wizard_slot_removed,
|
||||
Toast.LENGTH_LONG
|
||||
).show()
|
||||
finish()
|
||||
}
|
||||
}
|
||||
|
||||
progressBar.visibility = View.GONE
|
||||
nextButton.isEnabled = true
|
||||
|
||||
if (currentFragment?.hasNext == true) {
|
||||
currentFragment?.beforeNext()
|
||||
val nextFrag = currentFragment?.createNextFragment()
|
||||
if (nextFrag == null) {
|
||||
finish()
|
||||
} else {
|
||||
showFragment(nextFrag, R.anim.slide_in_right, R.anim.slide_out_left)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun onInit() {
|
||||
progressBar.visibility = View.GONE
|
||||
|
||||
if (state.currentStepFragmentClassName != null) {
|
||||
val clazz = Class.forName(state.currentStepFragmentClassName!!)
|
||||
showFragment(clazz.getDeclaredConstructor().newInstance() as DownloadWizardStepFragment)
|
||||
} else {
|
||||
showFragment(DownloadWizardSlotSelectFragment())
|
||||
}
|
||||
}
|
||||
|
||||
private fun showFragment(
|
||||
nextFrag: DownloadWizardStepFragment,
|
||||
enterAnim: Int = 0,
|
||||
exitAnim: Int = 0
|
||||
) {
|
||||
currentFragment = nextFrag
|
||||
supportFragmentManager.beginTransaction().setCustomAnimations(enterAnim, exitAnim)
|
||||
.replace(R.id.step_fragment_container, nextFrag)
|
||||
.commit()
|
||||
|
||||
// Sync screen on state
|
||||
if (nextFrag.keepScreenOn) {
|
||||
window.addFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON)
|
||||
} else {
|
||||
window.clearFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON)
|
||||
}
|
||||
|
||||
refreshButtons()
|
||||
}
|
||||
|
||||
private fun refreshButtons() {
|
||||
currentFragment?.let {
|
||||
nextButton.visibility = if (it.hasNext) {
|
||||
View.VISIBLE
|
||||
} else {
|
||||
View.GONE
|
||||
}
|
||||
prevButton.visibility = if (it.hasPrev) {
|
||||
View.VISIBLE
|
||||
} else {
|
||||
View.GONE
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun hideIme() {
|
||||
currentFocus?.let {
|
||||
val imm = getSystemService(InputMethodManager::class.java)
|
||||
imm.hideSoftInputFromWindow(it.windowToken, 0)
|
||||
}
|
||||
}
|
||||
|
||||
abstract class DownloadWizardStepFragment : Fragment(), OpenEuiccContextMarker {
|
||||
protected val state: DownloadWizardState
|
||||
get() = (requireActivity() as DownloadWizardActivity).state
|
||||
|
||||
open val keepScreenOn = false
|
||||
|
||||
abstract val hasNext: Boolean
|
||||
abstract val hasPrev: Boolean
|
||||
abstract fun createNextFragment(): DownloadWizardStepFragment?
|
||||
abstract fun createPrevFragment(): DownloadWizardStepFragment?
|
||||
|
||||
protected fun gotoNextFragment(next: DownloadWizardStepFragment? = null) {
|
||||
val realNext = next ?: createNextFragment()
|
||||
(requireActivity() as DownloadWizardActivity).showFragment(
|
||||
realNext!!,
|
||||
R.anim.slide_in_right,
|
||||
R.anim.slide_out_left
|
||||
)
|
||||
}
|
||||
|
||||
protected fun hideProgressBar() {
|
||||
(requireActivity() as DownloadWizardActivity).progressBar.visibility = View.GONE
|
||||
}
|
||||
|
||||
protected fun showProgressBar(progressValue: Int) {
|
||||
(requireActivity() as DownloadWizardActivity).progressBar.apply {
|
||||
visibility = View.VISIBLE
|
||||
if (progressValue >= 0) {
|
||||
isIndeterminate = false
|
||||
progress = progressValue
|
||||
} else {
|
||||
isIndeterminate = true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
protected fun refreshButtons() {
|
||||
(requireActivity() as DownloadWizardActivity).refreshButtons()
|
||||
}
|
||||
|
||||
open fun beforeNext() {}
|
||||
}
|
||||
}
|
||||
|
|
@ -1,126 +0,0 @@
|
|||
package im.angry.openeuicc.ui.wizard
|
||||
|
||||
import android.os.Bundle
|
||||
import android.view.LayoutInflater
|
||||
import android.view.View
|
||||
import android.view.ViewGroup
|
||||
import android.widget.EditText
|
||||
import androidx.core.widget.addTextChangedListener
|
||||
import com.google.android.material.textfield.TextInputLayout
|
||||
import im.angry.openeuicc.common.R
|
||||
import im.angry.openeuicc.util.*
|
||||
|
||||
class DownloadWizardDetailsFragment : DownloadWizardActivity.DownloadWizardStepFragment() {
|
||||
private var inputComplete = false
|
||||
|
||||
override val hasNext: Boolean
|
||||
get() = inputComplete
|
||||
override val hasPrev: Boolean
|
||||
get() = true
|
||||
|
||||
private val address: EditText by lazy {
|
||||
requireView().requireViewById<TextInputLayout>(R.id.profile_download_server).editText!!
|
||||
}
|
||||
private val matchingId: EditText by lazy {
|
||||
requireView().requireViewById<TextInputLayout>(R.id.profile_download_code).editText!!
|
||||
}
|
||||
private val confirmationCode: EditText by lazy {
|
||||
requireView().requireViewById<TextInputLayout>(R.id.profile_download_confirmation_code).editText!!
|
||||
}
|
||||
private val imei: EditText by lazy {
|
||||
requireView().requireViewById<TextInputLayout>(R.id.profile_download_imei).editText!!
|
||||
}
|
||||
|
||||
private fun saveState() {
|
||||
state.smdp = address.text.toString().trim()
|
||||
// Treat empty inputs as null -- this is important for the download step
|
||||
state.matchingId = matchingId.text.toString().trim().ifBlank { null }
|
||||
state.confirmationCode = confirmationCode.text.toString().trim().ifBlank { null }
|
||||
state.imei = imei.text.toString().ifBlank { null }
|
||||
}
|
||||
|
||||
override fun beforeNext() = saveState()
|
||||
|
||||
override fun createNextFragment(): DownloadWizardActivity.DownloadWizardStepFragment =
|
||||
DownloadWizardProgressFragment()
|
||||
|
||||
override fun createPrevFragment(): DownloadWizardActivity.DownloadWizardStepFragment =
|
||||
if (state.skipMethodSelect) {
|
||||
DownloadWizardSlotSelectFragment()
|
||||
} else {
|
||||
DownloadWizardMethodSelectFragment()
|
||||
}
|
||||
|
||||
override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View =
|
||||
inflater.inflate(R.layout.fragment_download_details, container, /* attachToRoot = */ false)
|
||||
|
||||
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
|
||||
super.onViewCreated(view, savedInstanceState)
|
||||
address.addTextChangedListener(onTextChanged = ::handlePasteLPAString)
|
||||
address.addTextChangedListener { updateInputCompleteness() }
|
||||
matchingId.addTextChangedListener(onTextChanged = ::handlePasteLPAString)
|
||||
confirmationCode.addTextChangedListener { updateInputCompleteness() }
|
||||
}
|
||||
|
||||
override fun onStart() {
|
||||
super.onStart()
|
||||
address.setText(state.smdp)
|
||||
matchingId.setText(state.matchingId)
|
||||
confirmationCode.setText(state.confirmationCode)
|
||||
imei.setText(state.imei)
|
||||
updateInputCompleteness()
|
||||
|
||||
if (state.confirmationCodeRequired) {
|
||||
confirmationCode.requestFocus()
|
||||
confirmationCode.setHint(R.string.profile_download_confirmation_code_required)
|
||||
} else {
|
||||
confirmationCode.setHint(R.string.profile_download_confirmation_code)
|
||||
}
|
||||
}
|
||||
|
||||
override fun onPause() {
|
||||
super.onPause()
|
||||
saveState()
|
||||
}
|
||||
|
||||
private fun handlePasteLPAString(text: CharSequence?, start: Int, before: Int, count: Int) {
|
||||
if (start > 0 || before > 0) return // only handle insertions at the beginning
|
||||
if (text == null || !text.startsWith("LPA:", ignoreCase = true)) return
|
||||
val parsed = LPAString.parse(text)
|
||||
address.setText(parsed.address)
|
||||
matchingId.setText(parsed.matchingId)
|
||||
if (parsed.confirmationCodeRequired) confirmationCode.requestFocus()
|
||||
}
|
||||
|
||||
private fun updateInputCompleteness() {
|
||||
inputComplete = isValidAddress(address.text)
|
||||
if (state.confirmationCodeRequired) {
|
||||
inputComplete = inputComplete && confirmationCode.text.isNotEmpty()
|
||||
}
|
||||
refreshButtons()
|
||||
}
|
||||
}
|
||||
|
||||
private fun isValidAddress(input: CharSequence): Boolean {
|
||||
if (!input.contains('.')) return false
|
||||
var fqdn = input
|
||||
var port = 443
|
||||
if (input.contains(':')) {
|
||||
val portIndex = input.lastIndexOf(':')
|
||||
fqdn = input.take(portIndex)
|
||||
port = input.substring(portIndex + 1, input.length).toIntOrNull(10) ?: 0
|
||||
}
|
||||
// see https://en.wikipedia.org/wiki/Port_(computer_networking)
|
||||
if (port !in 1..0xffff) return false
|
||||
// see https://en.wikipedia.org/wiki/Fully_qualified_domain_name
|
||||
if (fqdn.isEmpty() || fqdn.length > 255) return false
|
||||
for (part in fqdn.split('.')) {
|
||||
if (part.isEmpty() || part.length > 64) return false
|
||||
if (part.first() == '-' || part.last() == '-') return false
|
||||
for (c in part) {
|
||||
if (c.isLetterOrDigit() || c == '-') continue
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
|
@ -1,141 +0,0 @@
|
|||
package im.angry.openeuicc.ui.wizard
|
||||
|
||||
import android.icu.text.SimpleDateFormat
|
||||
import android.os.Bundle
|
||||
import android.view.LayoutInflater
|
||||
import android.view.View
|
||||
import android.view.ViewGroup
|
||||
import android.widget.TextView
|
||||
import im.angry.openeuicc.common.R
|
||||
import im.angry.openeuicc.util.*
|
||||
import org.json.JSONObject
|
||||
import java.util.Date
|
||||
|
||||
class DownloadWizardDiagnosticsFragment : DownloadWizardActivity.DownloadWizardStepFragment() {
|
||||
override val hasNext: Boolean
|
||||
get() = true
|
||||
override val hasPrev: Boolean
|
||||
get() = false
|
||||
|
||||
private lateinit var diagnosticTextView: TextView
|
||||
|
||||
private val saveDiagnostics =
|
||||
setupLogSaving(
|
||||
getLogFileName = {
|
||||
getString(
|
||||
R.string.download_wizard_diagnostics_file_template,
|
||||
SimpleDateFormat.getDateTimeInstance().format(Date())
|
||||
)
|
||||
},
|
||||
getLogText = { diagnosticTextView.text.toString() }
|
||||
)
|
||||
|
||||
override fun createNextFragment(): DownloadWizardActivity.DownloadWizardStepFragment? = null
|
||||
|
||||
override fun createPrevFragment(): DownloadWizardActivity.DownloadWizardStepFragment? = null
|
||||
|
||||
override fun onCreateView(
|
||||
inflater: LayoutInflater,
|
||||
container: ViewGroup?,
|
||||
savedInstanceState: Bundle?
|
||||
): View? {
|
||||
val view = inflater.inflate(R.layout.fragment_download_diagnostics, container, false)
|
||||
view.requireViewById<View>(R.id.download_wizard_diagnostics_save).setOnClickListener {
|
||||
saveDiagnostics()
|
||||
}
|
||||
diagnosticTextView = view.requireViewById(R.id.download_wizard_diagnostics_text)
|
||||
return view
|
||||
}
|
||||
|
||||
override fun onStart() {
|
||||
super.onStart()
|
||||
val str = buildDiagnosticsText()
|
||||
if (str == null) {
|
||||
requireActivity().finish()
|
||||
return
|
||||
}
|
||||
|
||||
diagnosticTextView.text = str
|
||||
}
|
||||
|
||||
private fun buildDiagnosticsText(): String? = state.downloadError?.let { err ->
|
||||
val ret = StringBuilder()
|
||||
|
||||
ret.appendLine(
|
||||
getString(
|
||||
R.string.download_wizard_diagnostics_error_code,
|
||||
err.lpaErrorReason
|
||||
)
|
||||
)
|
||||
ret.appendLine()
|
||||
|
||||
err.lastHttpResponse?.let { resp ->
|
||||
if (resp.rcode != 200) {
|
||||
// Only show the status if it's not 200
|
||||
// Because we can have errors even if the rcode is 200 due to SM-DP+ servers being dumb
|
||||
// and showing 200 might mislead users
|
||||
ret.appendLine(
|
||||
getString(
|
||||
R.string.download_wizard_diagnostics_last_http_status,
|
||||
resp.rcode
|
||||
)
|
||||
)
|
||||
ret.appendLine()
|
||||
}
|
||||
|
||||
ret.appendLine(getString(R.string.download_wizard_diagnostics_last_http_response))
|
||||
ret.appendLine()
|
||||
|
||||
val str = resp.data.decodeToString(throwOnInvalidSequence = false)
|
||||
|
||||
ret.appendLine(
|
||||
if (str.startsWith('{')) {
|
||||
JSONObject(str).toString(2)
|
||||
} else {
|
||||
str
|
||||
}
|
||||
)
|
||||
|
||||
ret.appendLine()
|
||||
}
|
||||
|
||||
err.lastHttpException?.let { e ->
|
||||
ret.appendLine(getString(R.string.download_wizard_diagnostics_last_http_exception))
|
||||
ret.appendLine()
|
||||
ret.appendLine("${e.javaClass.name}: ${e.message}")
|
||||
ret.appendLine(e.stackTrace.joinToString("\n"))
|
||||
ret.appendLine()
|
||||
}
|
||||
|
||||
err.lastApduResponse?.let { resp ->
|
||||
val isSuccess =
|
||||
resp.size >= 2 && resp[resp.size - 2] == 0x90.toByte() && resp[resp.size - 1] == 0x00.toByte()
|
||||
|
||||
if (isSuccess) {
|
||||
ret.appendLine(getString(R.string.download_wizard_diagnostics_last_apdu_response_success))
|
||||
} else {
|
||||
// Only show the full APDU response when it's a failure
|
||||
// Otherwise it's going to get very crammed
|
||||
ret.appendLine(
|
||||
getString(
|
||||
R.string.download_wizard_diagnostics_last_apdu_response,
|
||||
resp.encodeHex()
|
||||
)
|
||||
)
|
||||
ret.appendLine()
|
||||
|
||||
ret.appendLine(getString(R.string.download_wizard_diagnostics_last_apdu_response_fail))
|
||||
}
|
||||
}
|
||||
|
||||
err.lastApduException?.let { e ->
|
||||
ret.appendLine(getString(R.string.download_wizard_diagnostics_last_apdu_exception))
|
||||
ret.appendLine()
|
||||
ret.appendLine("${e.javaClass.name}: ${e.message}")
|
||||
ret.appendLine(e.stackTrace.joinToString("\n"))
|
||||
ret.appendLine()
|
||||
}
|
||||
|
||||
ret.toString()
|
||||
}
|
||||
}
|
||||
|
|
@ -1,173 +0,0 @@
|
|||
package im.angry.openeuicc.ui.wizard
|
||||
|
||||
import android.app.AlertDialog
|
||||
import android.content.ClipboardManager
|
||||
import android.graphics.BitmapFactory
|
||||
import android.os.Bundle
|
||||
import android.view.LayoutInflater
|
||||
import android.view.View
|
||||
import android.view.ViewGroup
|
||||
import android.widget.ImageView
|
||||
import android.widget.TextView
|
||||
import android.widget.Toast
|
||||
import androidx.activity.result.contract.ActivityResultContracts
|
||||
import androidx.lifecycle.lifecycleScope
|
||||
import androidx.recyclerview.widget.DividerItemDecoration
|
||||
import androidx.recyclerview.widget.LinearLayoutManager
|
||||
import androidx.recyclerview.widget.RecyclerView
|
||||
import androidx.recyclerview.widget.RecyclerView.ViewHolder
|
||||
import com.journeyapps.barcodescanner.ScanContract
|
||||
import com.journeyapps.barcodescanner.ScanOptions
|
||||
import im.angry.openeuicc.common.R
|
||||
import im.angry.openeuicc.util.*
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
|
||||
class DownloadWizardMethodSelectFragment : DownloadWizardActivity.DownloadWizardStepFragment() {
|
||||
data class DownloadMethod(
|
||||
val iconRes: Int,
|
||||
val titleRes: Int,
|
||||
val onClick: () -> Unit
|
||||
)
|
||||
|
||||
// TODO: Maybe we should find a better barcode scanner (or an external one?)
|
||||
private val barcodeScannerLauncher = registerForActivityResult(ScanContract()) { result ->
|
||||
result.contents?.let { content ->
|
||||
processLpaString(content)
|
||||
}
|
||||
}
|
||||
|
||||
private val gallerySelectorLauncher =
|
||||
registerForActivityResult(ActivityResultContracts.GetContent()) { result ->
|
||||
if (result == null) return@registerForActivityResult
|
||||
|
||||
lifecycleScope.launch {
|
||||
val decoded = withContext(Dispatchers.IO) {
|
||||
runCatching {
|
||||
requireContext().contentResolver.openInputStream(result)?.use { input ->
|
||||
BitmapFactory.decodeStream(input).use(::decodeQrFromBitmap)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
decoded.getOrNull()?.let { processLpaString(it) }
|
||||
}
|
||||
}
|
||||
|
||||
val downloadMethods = arrayOf(
|
||||
DownloadMethod(R.drawable.ic_scan_black, R.string.download_wizard_method_qr_code) {
|
||||
barcodeScannerLauncher.launch(ScanOptions().apply {
|
||||
setDesiredBarcodeFormats(ScanOptions.QR_CODE)
|
||||
setOrientationLocked(false)
|
||||
})
|
||||
},
|
||||
DownloadMethod(R.drawable.ic_gallery_black, R.string.download_wizard_method_gallery) {
|
||||
gallerySelectorLauncher.launch("image/*")
|
||||
},
|
||||
DownloadMethod(R.drawable.ic_paste_go, R.string.download_wizard_method_clipboard) {
|
||||
handleLoadFromClipboard()
|
||||
},
|
||||
DownloadMethod(R.drawable.ic_edit, R.string.download_wizard_method_manual) {
|
||||
gotoNextFragment(DownloadWizardDetailsFragment())
|
||||
}
|
||||
)
|
||||
|
||||
override val hasNext: Boolean
|
||||
get() = false
|
||||
override val hasPrev: Boolean
|
||||
get() = true
|
||||
|
||||
override fun createNextFragment(): DownloadWizardActivity.DownloadWizardStepFragment? =
|
||||
null
|
||||
|
||||
override fun createPrevFragment(): DownloadWizardActivity.DownloadWizardStepFragment =
|
||||
DownloadWizardSlotSelectFragment()
|
||||
|
||||
override fun onCreateView(
|
||||
inflater: LayoutInflater,
|
||||
container: ViewGroup?,
|
||||
savedInstanceState: Bundle?
|
||||
): View? {
|
||||
val view = inflater.inflate(R.layout.fragment_download_method_select, container, false)
|
||||
val recyclerView = view.requireViewById<RecyclerView>(R.id.download_method_list)
|
||||
recyclerView.adapter = DownloadMethodAdapter()
|
||||
recyclerView.layoutManager =
|
||||
LinearLayoutManager(view.context, LinearLayoutManager.VERTICAL, false)
|
||||