Compare commits
No commits in common. "master" and "master" have entirely different histories.
215 changed files with 2268 additions and 10140 deletions
|
@ -1,7 +1,7 @@
|
||||||
on:
|
on:
|
||||||
push:
|
push:
|
||||||
branches:
|
branches:
|
||||||
- '*'
|
- 'master'
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
build-debug:
|
build-debug:
|
||||||
|
@ -14,7 +14,6 @@ jobs:
|
||||||
uses: https://gitea.angry.im/actions/checkout@v3
|
uses: https://gitea.angry.im/actions/checkout@v3
|
||||||
with:
|
with:
|
||||||
submodules: recursive
|
submodules: recursive
|
||||||
fetch-depth: 0
|
|
||||||
|
|
||||||
- name: Decode Secret Signing Configuration
|
- name: Decode Secret Signing Configuration
|
||||||
uses: https://gitea.angry.im/actions/base64-to-file@v1
|
uses: https://gitea.angry.im/actions/base64-to-file@v1
|
||||||
|
@ -35,12 +34,11 @@ jobs:
|
||||||
- name: Build Debug APKs
|
- name: Build Debug APKs
|
||||||
run: ./gradlew --no-daemon assembleDebug
|
run: ./gradlew --no-daemon assembleDebug
|
||||||
|
|
||||||
- name: Copy Artifacts
|
|
||||||
run: find . -name 'app*-debug.apk' -exec cp {} . \;
|
|
||||||
|
|
||||||
- name: Upload Artifacts
|
- name: Upload Artifacts
|
||||||
uses: https://gitea.angry.im/actions/upload-artifact@v3
|
uses: https://gitea.angry.im/actions/upload-artifact@v3
|
||||||
with:
|
with:
|
||||||
name: Debug APKs
|
name: Debug APKs
|
||||||
compression-level: 0
|
compression-level: 0
|
||||||
path: app*-debug.apk
|
path: |
|
||||||
|
app-unpriv/build/outputs/apk/debug/app-unpriv-debug.apk
|
||||||
|
app/build/outputs/apk/debug/app-debug.apk
|
||||||
|
|
|
@ -2,6 +2,10 @@ on:
|
||||||
push:
|
push:
|
||||||
tags: '*'
|
tags: '*'
|
||||||
|
|
||||||
|
env:
|
||||||
|
# Enable reproducibility-related build system workarounds
|
||||||
|
REPRODUCIBLE_BUILD: true
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
release:
|
release:
|
||||||
runs-on: [docker, android-app-certs]
|
runs-on: [docker, android-app-certs]
|
||||||
|
@ -13,7 +17,6 @@ jobs:
|
||||||
uses: https://gitea.angry.im/actions/checkout@v3
|
uses: https://gitea.angry.im/actions/checkout@v3
|
||||||
with:
|
with:
|
||||||
submodules: recursive
|
submodules: recursive
|
||||||
fetch-depth: 0
|
|
||||||
|
|
||||||
- name: Decode Secret Signing Configuration
|
- name: Decode Secret Signing Configuration
|
||||||
uses: https://gitea.angry.im/actions/base64-to-file@v1
|
uses: https://gitea.angry.im/actions/base64-to-file@v1
|
||||||
|
@ -34,9 +37,6 @@ jobs:
|
||||||
- name: Build Release APK (Unprivileged / EasyEUICC only)
|
- name: Build Release APK (Unprivileged / EasyEUICC only)
|
||||||
run: ./gradlew --no-daemon :app-unpriv:assembleRelease
|
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
|
- name: Create Release
|
||||||
uses: https://gitea.angry.im/actions/forgejo-release@v1
|
uses: https://gitea.angry.im/actions/forgejo-release@v1
|
||||||
with:
|
with:
|
||||||
|
|
27
.gitignore
vendored
27
.gitignore
vendored
|
@ -1,11 +1,20 @@
|
||||||
/.gradle
|
*.iml
|
||||||
/captures
|
.gradle
|
||||||
|
|
||||||
# Configuration files
|
|
||||||
|
|
||||||
/keystore.properties
|
|
||||||
/local.properties
|
/local.properties
|
||||||
|
/keystore.properties
|
||||||
# macOS
|
/.idea/caches
|
||||||
|
/.idea/libraries
|
||||||
|
/.idea/modules.xml
|
||||||
|
/.idea/workspace.xml
|
||||||
|
/.idea/navEditor.xml
|
||||||
|
/.idea/assetWizardSettings.xml
|
||||||
|
/.idea/deploymentTargetDropDown.xml
|
||||||
.DS_Store
|
.DS_Store
|
||||||
|
/build
|
||||||
|
/captures
|
||||||
|
.externalNativeBuild
|
||||||
|
.cxx
|
||||||
|
local.properties
|
||||||
|
/libs/**/build
|
||||||
|
/buildSrc/build
|
||||||
|
/app-deps/libs
|
15
.idea/.gitignore
generated
vendored
15
.idea/.gitignore
generated
vendored
|
@ -1,14 +1,3 @@
|
||||||
/shelf
|
# Default ignored files
|
||||||
/caches
|
/shelf/
|
||||||
/libraries
|
|
||||||
/assetWizardSettings.xml
|
|
||||||
/deploymentTargetDropDown.xml
|
|
||||||
/gradle.xml
|
|
||||||
/misc.xml
|
|
||||||
/modules.xml
|
|
||||||
/navEditor.xml
|
|
||||||
/runConfigurations.xml
|
|
||||||
/workspace.xml
|
/workspace.xml
|
||||||
/AndroidProjectSystem.xml
|
|
||||||
|
|
||||||
**/*.iml
|
|
6
.idea/codeStyles/Project.xml
generated
6
.idea/codeStyles/Project.xml
generated
|
@ -1,8 +1,5 @@
|
||||||
<component name="ProjectCodeStyleConfiguration">
|
<component name="ProjectCodeStyleConfiguration">
|
||||||
<code_scheme name="Project" version="173">
|
<code_scheme name="Project" version="173">
|
||||||
<JetCodeStyleSettings>
|
|
||||||
<option name="CODE_STYLE_DEFAULTS" value="KOTLIN_OFFICIAL" />
|
|
||||||
</JetCodeStyleSettings>
|
|
||||||
<codeStyleSettings language="XML">
|
<codeStyleSettings language="XML">
|
||||||
<option name="FORCE_REARRANGE_MODE" value="1" />
|
<option name="FORCE_REARRANGE_MODE" value="1" />
|
||||||
<indentOptions>
|
<indentOptions>
|
||||||
|
@ -116,8 +113,5 @@
|
||||||
</rules>
|
</rules>
|
||||||
</arrangement>
|
</arrangement>
|
||||||
</codeStyleSettings>
|
</codeStyleSettings>
|
||||||
<codeStyleSettings language="kotlin">
|
|
||||||
<option name="CODE_STYLE_DEFAULTS" value="KOTLIN_OFFICIAL" />
|
|
||||||
</codeStyleSettings>
|
|
||||||
</code_scheme>
|
</code_scheme>
|
||||||
</component>
|
</component>
|
1
.idea/codeStyles/codeStyleConfig.xml
generated
1
.idea/codeStyles/codeStyleConfig.xml
generated
|
@ -1,6 +1,5 @@
|
||||||
<component name="ProjectCodeStyleConfiguration">
|
<component name="ProjectCodeStyleConfiguration">
|
||||||
<state>
|
<state>
|
||||||
<option name="USE_PER_PROJECT_SETTINGS" value="true" />
|
|
||||||
<option name="PREFERRED_PROJECT_CODE_STYLE" value="Default" />
|
<option name="PREFERRED_PROJECT_CODE_STYLE" value="Default" />
|
||||||
</state>
|
</state>
|
||||||
</component>
|
</component>
|
12
.idea/compiler.xml
generated
12
.idea/compiler.xml
generated
|
@ -1,6 +1,16 @@
|
||||||
<?xml version="1.0" encoding="UTF-8"?>
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
<project version="4">
|
<project version="4">
|
||||||
<component name="CompilerConfiguration">
|
<component name="CompilerConfiguration">
|
||||||
<bytecodeTargetLevel target="1.7" />
|
<bytecodeTargetLevel target="1.7">
|
||||||
|
<module name="OpenEUICC.app" target="17" />
|
||||||
|
<module name="OpenEUICC.app-common" target="17" />
|
||||||
|
<module name="OpenEUICC.app-deps" target="17" />
|
||||||
|
<module name="OpenEUICC.app-unpriv" target="17" />
|
||||||
|
<module name="OpenEUICC.buildSrc" target="17" />
|
||||||
|
<module name="OpenEUICC.buildSrc.main" target="17" />
|
||||||
|
<module name="OpenEUICC.buildSrc.test" target="17" />
|
||||||
|
<module name="OpenEUICC.libs.hidden-apis-shim" target="17" />
|
||||||
|
<module name="OpenEUICC.libs.lpac-jni" target="17" />
|
||||||
|
</bytecodeTargetLevel>
|
||||||
</component>
|
</component>
|
||||||
</project>
|
</project>
|
37
.idea/deploymentTargetSelector.xml
generated
37
.idea/deploymentTargetSelector.xml
generated
|
@ -1,37 +0,0 @@
|
||||||
<?xml version="1.0" encoding="UTF-8"?>
|
|
||||||
<project version="4">
|
|
||||||
<component name="deploymentTargetSelector">
|
|
||||||
<selectionStates>
|
|
||||||
<SelectionState runConfigName="app-unpriv">
|
|
||||||
<option name="selectionMode" value="DROPDOWN" />
|
|
||||||
</SelectionState>
|
|
||||||
<SelectionState runConfigName="app">
|
|
||||||
<option name="selectionMode" value="DROPDOWN" />
|
|
||||||
</SelectionState>
|
|
||||||
<SelectionState runConfigName="app-unpriv.androidTest">
|
|
||||||
<option name="selectionMode" value="DROPDOWN" />
|
|
||||||
</SelectionState>
|
|
||||||
<SelectionState runConfigName="app-unpriv.main">
|
|
||||||
<option name="selectionMode" value="DROPDOWN" />
|
|
||||||
</SelectionState>
|
|
||||||
<SelectionState runConfigName="app-unpriv.unitTest">
|
|
||||||
<option name="selectionMode" value="DROPDOWN" />
|
|
||||||
</SelectionState>
|
|
||||||
<SelectionState runConfigName="app.unitTest">
|
|
||||||
<option name="selectionMode" value="DROPDOWN" />
|
|
||||||
</SelectionState>
|
|
||||||
<SelectionState runConfigName="app.androidTest">
|
|
||||||
<option name="selectionMode" value="DROPDOWN" />
|
|
||||||
</SelectionState>
|
|
||||||
<SelectionState runConfigName="app.main">
|
|
||||||
<option name="selectionMode" value="DROPDOWN" />
|
|
||||||
</SelectionState>
|
|
||||||
<SelectionState runConfigName="workspace.OpenEUICC.app-unpriv">
|
|
||||||
<option name="selectionMode" value="DROPDOWN" />
|
|
||||||
</SelectionState>
|
|
||||||
<SelectionState runConfigName="workspace.OpenEUICC.app">
|
|
||||||
<option name="selectionMode" value="DROPDOWN" />
|
|
||||||
</SelectionState>
|
|
||||||
</selectionStates>
|
|
||||||
</component>
|
|
||||||
</project>
|
|
29
.idea/gradle.xml
generated
Normal file
29
.idea/gradle.xml
generated
Normal file
|
@ -0,0 +1,29 @@
|
||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<project version="4">
|
||||||
|
<component name="GradleMigrationSettings" migrationVersion="1" />
|
||||||
|
<component name="GradleSettings">
|
||||||
|
<option name="linkedExternalProjectsSettings">
|
||||||
|
<GradleProjectSettings>
|
||||||
|
<option name="testRunner" value="GRADLE" />
|
||||||
|
<option name="distributionType" value="DEFAULT_WRAPPED" />
|
||||||
|
<option name="externalProjectPath" value="$PROJECT_DIR$" />
|
||||||
|
<option name="gradleHome" value="/usr/share/java/gradle" />
|
||||||
|
<option name="gradleJvm" value="jbr-17" />
|
||||||
|
<option name="modules">
|
||||||
|
<set>
|
||||||
|
<option value="$PROJECT_DIR$" />
|
||||||
|
<option value="$PROJECT_DIR$/app" />
|
||||||
|
<option value="$PROJECT_DIR$/app-common" />
|
||||||
|
<option value="$PROJECT_DIR$/app-deps" />
|
||||||
|
<option value="$PROJECT_DIR$/app-unpriv" />
|
||||||
|
<option value="$PROJECT_DIR$/buildSrc" />
|
||||||
|
<option value="$PROJECT_DIR$/libs" />
|
||||||
|
<option value="$PROJECT_DIR$/libs/hidden-apis-shim" />
|
||||||
|
<option value="$PROJECT_DIR$/libs/hidden-apis-stub" />
|
||||||
|
<option value="$PROJECT_DIR$/libs/lpac-jni" />
|
||||||
|
</set>
|
||||||
|
</option>
|
||||||
|
</GradleProjectSettings>
|
||||||
|
</option>
|
||||||
|
</component>
|
||||||
|
</project>
|
2
.idea/kotlinc.xml
generated
2
.idea/kotlinc.xml
generated
|
@ -1,6 +1,6 @@
|
||||||
<?xml version="1.0" encoding="UTF-8"?>
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
<project version="4">
|
<project version="4">
|
||||||
<component name="KotlinJpsPluginSettings">
|
<component name="KotlinJpsPluginSettings">
|
||||||
<option name="version" value="1.9.24" />
|
<option name="version" value="1.9.20" />
|
||||||
</component>
|
</component>
|
||||||
</project>
|
</project>
|
10
.idea/migrations.xml
generated
10
.idea/migrations.xml
generated
|
@ -1,10 +0,0 @@
|
||||||
<?xml version="1.0" encoding="UTF-8"?>
|
|
||||||
<project version="4">
|
|
||||||
<component name="ProjectMigrations">
|
|
||||||
<option name="MigrateToGradleLocalJavaHome">
|
|
||||||
<set>
|
|
||||||
<option value="$PROJECT_DIR$" />
|
|
||||||
</set>
|
|
||||||
</option>
|
|
||||||
</component>
|
|
||||||
</project>
|
|
25
.idea/misc.xml
generated
Normal file
25
.idea/misc.xml
generated
Normal file
|
@ -0,0 +1,25 @@
|
||||||
|
<project version="4">
|
||||||
|
<component name="DesignSurface">
|
||||||
|
<option name="filePathToZoomLevelMap">
|
||||||
|
<map>
|
||||||
|
<entry key="app/src/main/res/drawable/ic_add.xml" value="0.2015" />
|
||||||
|
<entry key="app/src/main/res/layout/activity_main.xml" value="0.19375" />
|
||||||
|
<entry key="app/src/main/res/layout/euicc_profile.xml" value="0.19375" />
|
||||||
|
<entry key="app/src/main/res/layout/fragment_euicc.xml" value="0.19375" />
|
||||||
|
<entry key="app/src/main/res/layout/fragment_profile_download.xml" value="0.19375" />
|
||||||
|
<entry key="app/src/main/res/layout/fragment_profile_rename.xml" value="0.19375" />
|
||||||
|
<entry key="app/src/main/res/menu/activity_main.xml" value="0.19375" />
|
||||||
|
<entry key="app/src/main/res/menu/activity_main_slot_spinner.xml" value="0.19375" />
|
||||||
|
<entry key="app/src/main/res/menu/fragment_profile_download.xml" value="0.19375" />
|
||||||
|
<entry key="app/src/main/res/menu/fragment_profile_rename.xml" value="0.19375" />
|
||||||
|
<entry key="app/src/main/res/menu/profile_options.xml" value="0.19375" />
|
||||||
|
</map>
|
||||||
|
</option>
|
||||||
|
</component>
|
||||||
|
<component name="ProjectRootManager" version="2" languageLevel="JDK_1_7" project-jdk-name="jbr-17" project-jdk-type="JavaSDK">
|
||||||
|
<output url="file://$PROJECT_DIR$/build/classes" />
|
||||||
|
</component>
|
||||||
|
<component name="ProjectType">
|
||||||
|
<option name="id" value="Android" />
|
||||||
|
</component>
|
||||||
|
</project>
|
0
Android.mk
Normal file
0
Android.mk
Normal file
873
LICENSE
873
LICENSE
|
@ -1,622 +1,281 @@
|
||||||
GNU GENERAL PUBLIC LICENSE
|
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
|
Everyone is permitted to copy and distribute verbatim copies
|
||||||
of this license document, but changing it is not allowed.
|
of this license document, but changing it is not allowed.
|
||||||
|
|
||||||
Preamble
|
Preamble
|
||||||
|
|
||||||
The GNU General Public License is a free, copyleft license for
|
The licenses for most software are designed to take away your
|
||||||
software and other kinds of works.
|
freedom to share and change it. By contrast, the GNU General Public
|
||||||
|
License is intended to guarantee your freedom to share and change free
|
||||||
The licenses for most software and other practical works are designed
|
software--to make sure the software is free for all its users. This
|
||||||
to take away your freedom to share and change the works. By contrast,
|
General Public License applies to most of the Free Software
|
||||||
the GNU General Public License is intended to guarantee your freedom to
|
Foundation's software and to any other program whose authors commit to
|
||||||
share and change all versions of a program--to make sure it remains free
|
using it. (Some other Free Software Foundation software is covered by
|
||||||
software for all its users. We, the Free Software Foundation, use the
|
the GNU Lesser General Public License instead.) You can apply it to
|
||||||
GNU General Public License for most of our software; it applies also to
|
|
||||||
any other work released this way by its authors. You can apply it to
|
|
||||||
your programs, too.
|
your programs, too.
|
||||||
|
|
||||||
When we speak of free software, we are referring to freedom, not
|
When we speak of free software, we are referring to freedom, not
|
||||||
price. Our General Public Licenses are designed to make sure that you
|
price. Our General Public Licenses are designed to make sure that you
|
||||||
have the freedom to distribute copies of free software (and charge for
|
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
|
this service if you wish), that you receive source code or can get it
|
||||||
want it, that you can change the software or use pieces of it in new
|
if you want it, that you can change the software or use pieces of it
|
||||||
free programs, and that you know you can do these things.
|
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
|
To protect your rights, we need to make restrictions that forbid
|
||||||
these rights or asking you to surrender the rights. Therefore, you have
|
anyone to deny you these rights or to ask you to surrender the rights.
|
||||||
certain responsibilities if you distribute copies of the software, or if
|
These restrictions translate to certain responsibilities for you if you
|
||||||
you modify it: responsibilities to respect the freedom of others.
|
distribute copies of the software, or if you modify it.
|
||||||
|
|
||||||
For example, if you distribute copies of such a program, whether
|
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
|
gratis or for a fee, you must give the recipients all the rights that
|
||||||
freedoms that you received. You must make sure that they, too, receive
|
you have. You must make sure that they, too, receive or can get the
|
||||||
or can get the source code. And you must show them these terms so they
|
source code. And you must show them these terms so they know their
|
||||||
know their rights.
|
rights.
|
||||||
|
|
||||||
Developers that use the GNU GPL protect your rights with two steps:
|
We protect your rights with two steps: (1) copyright the software, and
|
||||||
(1) assert copyright on the software, and (2) offer you this License
|
(2) offer you this license which gives you legal permission to copy,
|
||||||
giving you legal permission to copy, distribute and/or modify it.
|
distribute and/or modify the software.
|
||||||
|
|
||||||
For the developers' and authors' protection, the GPL clearly explains
|
Also, for each author's protection and ours, we want to make certain
|
||||||
that there is no warranty for this free software. For both users' and
|
that everyone understands that there is no warranty for this free
|
||||||
authors' sake, the GPL requires that modified versions be marked as
|
software. If the software is modified by someone else and passed on, we
|
||||||
changed, so that their problems will not be attributed erroneously to
|
want its recipients to know that what they have is not the original, so
|
||||||
authors of previous versions.
|
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
|
Finally, any free program is threatened constantly by software
|
||||||
modified versions of the software inside them, although the manufacturer
|
patents. We wish to avoid the danger that redistributors of a free
|
||||||
can do so. This is fundamentally incompatible with the aim of
|
program will individually obtain patent licenses, in effect making the
|
||||||
protecting users' freedom to change the software. The systematic
|
program proprietary. To prevent this, we have made it clear that any
|
||||||
pattern of such abuse occurs in the area of products for individuals to
|
patent must be licensed for everyone's free use or not licensed at all.
|
||||||
use, which is precisely where it is most unacceptable. Therefore, we
|
|
||||||
have designed this version of the GPL to prohibit the practice for those
|
|
||||||
products. If such problems arise substantially in other domains, we
|
|
||||||
stand ready to extend this provision to those domains in future versions
|
|
||||||
of the GPL, as needed to protect the freedom of users.
|
|
||||||
|
|
||||||
Finally, every program is threatened constantly by software patents.
|
|
||||||
States should not allow patents to restrict development and use of
|
|
||||||
software on general-purpose computers, but in those that do, we wish to
|
|
||||||
avoid the special danger that patents applied to a free program could
|
|
||||||
make it effectively proprietary. To prevent this, the GPL assures that
|
|
||||||
patents cannot be used to render the program non-free.
|
|
||||||
|
|
||||||
The precise terms and conditions for copying, distribution and
|
The precise terms and conditions for copying, distribution and
|
||||||
modification follow.
|
modification follow.
|
||||||
|
|
||||||
TERMS AND CONDITIONS
|
GNU GENERAL PUBLIC LICENSE
|
||||||
|
TERMS AND CONDITIONS FOR COPYING, DISTRIBUTION AND MODIFICATION
|
||||||
0. Definitions.
|
|
||||||
|
0. This License applies to any program or other work which contains
|
||||||
"This License" refers to version 3 of the GNU General Public License.
|
a notice placed by the copyright holder saying it may be distributed
|
||||||
|
under the terms of this General Public License. The "Program", below,
|
||||||
"Copyright" also means copyright-like laws that apply to other kinds of
|
refers to any such program or work, and a "work based on the Program"
|
||||||
works, such as semiconductor masks.
|
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,
|
||||||
"The Program" refers to any copyrightable work licensed under this
|
either verbatim or with modifications and/or translated into another
|
||||||
License. Each licensee is addressed as "you". "Licensees" and
|
language. (Hereinafter, translation is included without limitation in
|
||||||
"recipients" may be individuals or organizations.
|
the term "modification".) Each licensee is addressed as "you".
|
||||||
|
|
||||||
To "modify" a work means to copy from or adapt all or part of the work
|
Activities other than copying, distribution and modification are not
|
||||||
in a fashion requiring copyright permission, other than the making of an
|
covered by this License; they are outside its scope. The act of
|
||||||
exact copy. The resulting work is called a "modified version" of the
|
running the Program is not restricted, and the output from the Program
|
||||||
earlier work or a work "based on" the earlier work.
|
is covered only if its contents constitute a work based on the
|
||||||
|
Program (independent of having been made by running the Program).
|
||||||
A "covered work" means either the unmodified Program or a work based
|
Whether that is true depends on what the Program does.
|
||||||
on the Program.
|
|
||||||
|
1. You may copy and distribute verbatim copies of the Program's
|
||||||
To "propagate" a work means to do anything with it that, without
|
source code as you receive it, in any medium, provided that you
|
||||||
permission, would make you directly or secondarily liable for
|
conspicuously and appropriately publish on each copy an appropriate
|
||||||
infringement under applicable copyright law, except executing it on a
|
copyright notice and disclaimer of warranty; keep intact all the
|
||||||
computer or modifying a private copy. Propagation includes copying,
|
notices that refer to this License and to the absence of any warranty;
|
||||||
distribution (with or without modification), making available to the
|
and give any other recipients of the Program a copy of this License
|
||||||
public, and in some countries other activities as well.
|
along with the Program.
|
||||||
|
|
||||||
To "convey" a work means any kind of propagation that enables other
|
You may charge a fee for the physical act of transferring a copy, and
|
||||||
parties to make or receive copies. Mere interaction with a user through
|
you may at your option offer warranty protection in exchange for a fee.
|
||||||
a computer network, with no transfer of a copy, is not conveying.
|
|
||||||
|
2. You may modify your copy or copies of the Program or any portion
|
||||||
An interactive user interface displays "Appropriate Legal Notices"
|
of it, thus forming a work based on the Program, and copy and
|
||||||
to the extent that it includes a convenient and prominently visible
|
distribute such modifications or work under the terms of Section 1
|
||||||
feature that (1) displays an appropriate copyright notice, and (2)
|
above, provided that you also meet all of these conditions:
|
||||||
tells the user that there is no warranty for the work (except to the
|
|
||||||
extent that warranties are provided), that licensees may convey the
|
a) You must cause the modified files to carry prominent notices
|
||||||
work under this License, and how to view a copy of this License. If
|
stating that you changed the files and the date of any change.
|
||||||
the interface presents a list of user commands or options, such as a
|
|
||||||
menu, a prominent item in the list meets this criterion.
|
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
|
||||||
1. Source Code.
|
part thereof, to be licensed as a whole at no charge to all third
|
||||||
|
parties under the terms of this License.
|
||||||
The "source code" for a work means the preferred form of the work
|
|
||||||
for making modifications to it. "Object code" means any non-source
|
c) If the modified program normally reads commands interactively
|
||||||
form of a work.
|
when run, you must cause it, when started running for such
|
||||||
|
interactive use in the most ordinary way, to print or display an
|
||||||
A "Standard Interface" means an interface that either is an official
|
announcement including an appropriate copyright notice and a
|
||||||
standard defined by a recognized standards body, or, in the case of
|
notice that there is no warranty (or else, saying that you provide
|
||||||
interfaces specified for a particular programming language, one that
|
a warranty) and that users may redistribute the program under
|
||||||
is widely used among developers working in that language.
|
these conditions, and telling the user how to view a copy of this
|
||||||
|
License. (Exception: if the Program itself is interactive but
|
||||||
The "System Libraries" of an executable work include anything, other
|
does not normally print such an announcement, your work based on
|
||||||
than the work as a whole, that (a) is included in the normal form of
|
the Program is not required to print an announcement.)
|
||||||
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
|
These requirements apply to the modified work as a whole. If
|
||||||
Major Component, or to implement a Standard Interface for which an
|
identifiable sections of that work are not derived from the Program,
|
||||||
implementation is available to the public in source code form. A
|
and can be reasonably considered independent and separate works in
|
||||||
"Major Component", in this context, means a major essential component
|
themselves, then this License, and its terms, do not apply to those
|
||||||
(kernel, window system, and so on) of the specific operating system
|
sections when you distribute them as separate works. But when you
|
||||||
(if any) on which the executable work runs, or a compiler used to
|
distribute the same sections as part of a whole which is a work based
|
||||||
produce the work, or an object code interpreter used to run it.
|
on the Program, the distribution of the whole must be on the terms of
|
||||||
|
this License, whose permissions for other licensees extend to the
|
||||||
The "Corresponding Source" for a work in object code form means all
|
entire whole, and thus to each and every part regardless of who wrote it.
|
||||||
the source code needed to generate, install, and (for an executable
|
|
||||||
work) run the object code and to modify the work, including scripts to
|
Thus, it is not the intent of this section to claim rights or contest
|
||||||
control those activities. However, it does not include the work's
|
your rights to work written entirely by you; rather, the intent is to
|
||||||
System Libraries, or general-purpose tools or generally available free
|
exercise the right to control the distribution of derivative or
|
||||||
programs which are used unmodified in performing those activities but
|
collective works based on the Program.
|
||||||
which are not part of the work. For example, Corresponding Source
|
|
||||||
includes interface definition files associated with source files for
|
In addition, mere aggregation of another work not based on the Program
|
||||||
the work, and the source code for shared libraries and dynamically
|
with the Program (or with a work based on the Program) on a volume of
|
||||||
linked subprograms that the work is specifically designed to require,
|
a storage or distribution medium does not bring the other work under
|
||||||
such as by intimate data communication or control flow between those
|
the scope of this License.
|
||||||
subprograms and other parts of the work.
|
|
||||||
|
3. You may copy and distribute the Program (or a work based on it,
|
||||||
The Corresponding Source need not include anything that users
|
under Section 2) in object code or executable form under the terms of
|
||||||
can regenerate automatically from other parts of the Corresponding
|
Sections 1 and 2 above provided that you also do one of the following:
|
||||||
Source.
|
|
||||||
|
a) Accompany it with the complete corresponding machine-readable
|
||||||
The Corresponding Source for a work in source code form is that
|
source code, which must be distributed under the terms of Sections
|
||||||
same work.
|
1 and 2 above on a medium customarily used for software interchange; or,
|
||||||
|
|
||||||
2. Basic Permissions.
|
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
|
||||||
All rights granted under this License are granted for the term of
|
cost of physically performing source distribution, a complete
|
||||||
copyright on the Program, and are irrevocable provided the stated
|
machine-readable copy of the corresponding source code, to be
|
||||||
conditions are met. This License explicitly affirms your unlimited
|
distributed under the terms of Sections 1 and 2 above on a medium
|
||||||
permission to run the unmodified Program. The output from running a
|
customarily used for software interchange; or,
|
||||||
covered work is covered by this License only if the output, given its
|
|
||||||
content, constitutes a covered work. This License acknowledges your
|
c) Accompany it with the information you received as to the offer
|
||||||
rights of fair use or other equivalent, as provided by copyright law.
|
to distribute corresponding source code. (This alternative is
|
||||||
|
allowed only for noncommercial distribution and only if you
|
||||||
You may make, run and propagate covered works that you do not
|
received the program in object code or executable form with such
|
||||||
convey, without conditions so long as your license otherwise remains
|
an offer, in accord with Subsection b above.)
|
||||||
in force. You may convey covered works to others for the sole purpose
|
|
||||||
of having them make modifications exclusively for you, or provide you
|
The source code for a work means the preferred form of the work for
|
||||||
with facilities for running those works, provided that you comply with
|
making modifications to it. For an executable work, complete source
|
||||||
the terms of this License in conveying all material for which you do
|
code means all the source code for all modules it contains, plus any
|
||||||
not control copyright. Those thus making or running the covered works
|
associated interface definition files, plus the scripts used to
|
||||||
for you must do so exclusively on your behalf, under your direction
|
control compilation and installation of the executable. However, as a
|
||||||
and control, on terms that prohibit them from making any copies of
|
special exception, the source code distributed need not include
|
||||||
your copyrighted material outside their relationship with you.
|
anything that is normally distributed (in either source or binary
|
||||||
|
form) with the major components (compiler, kernel, and so on) of the
|
||||||
Conveying under any other circumstances is permitted solely under
|
operating system on which the executable runs, unless that component
|
||||||
the conditions stated below. Sublicensing is not allowed; section 10
|
itself accompanies the executable.
|
||||||
makes it unnecessary.
|
|
||||||
|
If distribution of executable or object code is made by offering
|
||||||
3. Protecting Users' Legal Rights From Anti-Circumvention Law.
|
access to copy from a designated place, then offering equivalent
|
||||||
|
access to copy the source code from the same place counts as
|
||||||
No covered work shall be deemed part of an effective technological
|
distribution of the source code, even though third parties are not
|
||||||
measure under any applicable law fulfilling obligations under article
|
compelled to copy the source along with the object code.
|
||||||
11 of the WIPO copyright treaty adopted on 20 December 1996, or
|
|
||||||
similar laws prohibiting or restricting circumvention of such
|
4. You may not copy, modify, sublicense, or distribute the Program
|
||||||
measures.
|
except as expressly provided under this License. Any attempt
|
||||||
|
otherwise to copy, modify, sublicense or distribute the Program is
|
||||||
When you convey a covered work, you waive any legal power to forbid
|
void, and will automatically terminate your rights under this License.
|
||||||
circumvention of technological measures to the extent such circumvention
|
However, parties who have received copies, or rights, from you under
|
||||||
is effected by exercising rights under this License with respect to
|
this License will not have their licenses terminated so long as such
|
||||||
the covered work, and you disclaim any intention to limit operation or
|
parties remain in full compliance.
|
||||||
modification of the work as a means of enforcing, against the work's
|
|
||||||
users, your or third parties' legal rights to forbid circumvention of
|
5. You are not required to accept this License, since you have not
|
||||||
technological measures.
|
signed it. However, nothing else grants you permission to modify or
|
||||||
|
distribute the Program or its derivative works. These actions are
|
||||||
4. Conveying Verbatim Copies.
|
prohibited by law if you do not accept this License. Therefore, by
|
||||||
|
modifying or distributing the Program (or any work based on the
|
||||||
You may convey verbatim copies of the Program's source code as you
|
Program), you indicate your acceptance of this License to do so, and
|
||||||
receive it, in any medium, provided that you conspicuously and
|
all its terms and conditions for copying, distributing or modifying
|
||||||
appropriately publish on each copy an appropriate copyright notice;
|
the Program or works based on it.
|
||||||
keep intact all notices stating that this License and any
|
|
||||||
non-permissive terms added in accord with section 7 apply to the code;
|
6. Each time you redistribute the Program (or any work based on the
|
||||||
keep intact all notices of the absence of any warranty; and give all
|
Program), the recipient automatically receives a license from the
|
||||||
recipients a copy of this License along with the Program.
|
original licensor to copy, distribute or modify the Program subject to
|
||||||
|
these terms and conditions. You may not impose any further
|
||||||
You may charge any price or no price for each copy that you convey,
|
restrictions on the recipients' exercise of the rights granted herein.
|
||||||
and you may offer support or warranty protection for a fee.
|
You are not responsible for enforcing compliance by third parties to
|
||||||
|
|
||||||
5. Conveying Modified Source Versions.
|
|
||||||
|
|
||||||
You may convey a work based on the Program, or the modifications to
|
|
||||||
produce it from the Program, in the form of source code under the
|
|
||||||
terms of section 4, provided that you also meet all of these conditions:
|
|
||||||
|
|
||||||
a) The work must carry prominent notices stating that you modified
|
|
||||||
it, and giving a relevant date.
|
|
||||||
|
|
||||||
b) The work must carry prominent notices stating that it is
|
|
||||||
released under this License and any conditions added under section
|
|
||||||
7. This requirement modifies the requirement in section 4 to
|
|
||||||
"keep intact all notices".
|
|
||||||
|
|
||||||
c) You must license the entire work, as a whole, under this
|
|
||||||
License to anyone who comes into possession of a copy. This
|
|
||||||
License will therefore apply, along with any applicable section 7
|
|
||||||
additional terms, to the whole of the work, and all its parts,
|
|
||||||
regardless of how they are packaged. This License gives no
|
|
||||||
permission to license the work in any other way, but it does not
|
|
||||||
invalidate such permission if you have separately received it.
|
|
||||||
|
|
||||||
d) If the work has interactive user interfaces, each must display
|
|
||||||
Appropriate Legal Notices; however, if the Program has interactive
|
|
||||||
interfaces that do not display Appropriate Legal Notices, your
|
|
||||||
work need not make them do so.
|
|
||||||
|
|
||||||
A compilation of a covered work with other separate and independent
|
|
||||||
works, which are not by their nature extensions of the covered work,
|
|
||||||
and which are not combined with it such as to form a larger program,
|
|
||||||
in or on a volume of a storage or distribution medium, is called an
|
|
||||||
"aggregate" if the compilation and its resulting copyright are not
|
|
||||||
used to limit the access or legal rights of the compilation's users
|
|
||||||
beyond what the individual works permit. Inclusion of a covered work
|
|
||||||
in an aggregate does not cause this License to apply to the other
|
|
||||||
parts of the aggregate.
|
|
||||||
|
|
||||||
6. Conveying Non-Source Forms.
|
|
||||||
|
|
||||||
You may convey a covered work in object code form under the terms
|
|
||||||
of sections 4 and 5, provided that you also convey the
|
|
||||||
machine-readable Corresponding Source under the terms of this License,
|
|
||||||
in one of these ways:
|
|
||||||
|
|
||||||
a) Convey the object code in, or embodied in, a physical product
|
|
||||||
(including a physical distribution medium), accompanied by the
|
|
||||||
Corresponding Source fixed on a durable physical medium
|
|
||||||
customarily used for software interchange.
|
|
||||||
|
|
||||||
b) Convey the object code in, or embodied in, a physical product
|
|
||||||
(including a physical distribution medium), accompanied by a
|
|
||||||
written offer, valid for at least three years and valid for as
|
|
||||||
long as you offer spare parts or customer support for that product
|
|
||||||
model, to give anyone who possesses the object code either (1) a
|
|
||||||
copy of the Corresponding Source for all the software in the
|
|
||||||
product that is covered by this License, on a durable physical
|
|
||||||
medium customarily used for software interchange, for a price no
|
|
||||||
more than your reasonable cost of physically performing this
|
|
||||||
conveying of source, or (2) access to copy the
|
|
||||||
Corresponding Source from a network server at no charge.
|
|
||||||
|
|
||||||
c) Convey individual copies of the object code with a copy of the
|
|
||||||
written offer to provide the Corresponding Source. This
|
|
||||||
alternative is allowed only occasionally and noncommercially, and
|
|
||||||
only if you received the object code with such an offer, in accord
|
|
||||||
with subsection 6b.
|
|
||||||
|
|
||||||
d) Convey the object code by offering access from a designated
|
|
||||||
place (gratis or for a charge), and offer equivalent access to the
|
|
||||||
Corresponding Source in the same way through the same place at no
|
|
||||||
further charge. You need not require recipients to copy the
|
|
||||||
Corresponding Source along with the object code. If the place to
|
|
||||||
copy the object code is a network server, the Corresponding Source
|
|
||||||
may be on a different server (operated by you or a third party)
|
|
||||||
that supports equivalent copying facilities, provided you maintain
|
|
||||||
clear directions next to the object code saying where to find the
|
|
||||||
Corresponding Source. Regardless of what server hosts the
|
|
||||||
Corresponding Source, you remain obligated to ensure that it is
|
|
||||||
available for as long as needed to satisfy these requirements.
|
|
||||||
|
|
||||||
e) Convey the object code using peer-to-peer transmission, provided
|
|
||||||
you inform other peers where the object code and Corresponding
|
|
||||||
Source of the work are being offered to the general public at no
|
|
||||||
charge under subsection 6d.
|
|
||||||
|
|
||||||
A separable portion of the object code, whose source code is excluded
|
|
||||||
from the Corresponding Source as a System Library, need not be
|
|
||||||
included in conveying the object code work.
|
|
||||||
|
|
||||||
A "User Product" is either (1) a "consumer product", which means any
|
|
||||||
tangible personal property which is normally used for personal, family,
|
|
||||||
or household purposes, or (2) anything designed or sold for incorporation
|
|
||||||
into a dwelling. In determining whether a product is a consumer product,
|
|
||||||
doubtful cases shall be resolved in favor of coverage. For a particular
|
|
||||||
product received by a particular user, "normally used" refers to a
|
|
||||||
typical or common use of that class of product, regardless of the status
|
|
||||||
of the particular user or of the way in which the particular user
|
|
||||||
actually uses, or expects or is expected to use, the product. A product
|
|
||||||
is a consumer product regardless of whether the product has substantial
|
|
||||||
commercial, industrial or non-consumer uses, unless such uses represent
|
|
||||||
the only significant mode of use of the product.
|
|
||||||
|
|
||||||
"Installation Information" for a User Product means any methods,
|
|
||||||
procedures, authorization keys, or other information required to install
|
|
||||||
and execute modified versions of a covered work in that User Product from
|
|
||||||
a modified version of its Corresponding Source. The information must
|
|
||||||
suffice to ensure that the continued functioning of the modified object
|
|
||||||
code is in no case prevented or interfered with solely because
|
|
||||||
modification has been made.
|
|
||||||
|
|
||||||
If you convey an object code work under this section in, or with, or
|
|
||||||
specifically for use in, a User Product, and the conveying occurs as
|
|
||||||
part of a transaction in which the right of possession and use of the
|
|
||||||
User Product is transferred to the recipient in perpetuity or for a
|
|
||||||
fixed term (regardless of how the transaction is characterized), the
|
|
||||||
Corresponding Source conveyed under this section must be accompanied
|
|
||||||
by the Installation Information. But this requirement does not apply
|
|
||||||
if neither you nor any third party retains the ability to install
|
|
||||||
modified object code on the User Product (for example, the work has
|
|
||||||
been installed in ROM).
|
|
||||||
|
|
||||||
The requirement to provide Installation Information does not include a
|
|
||||||
requirement to continue to provide support service, warranty, or updates
|
|
||||||
for a work that has been modified or installed by the recipient, or for
|
|
||||||
the User Product in which it has been modified or installed. Access to a
|
|
||||||
network may be denied when the modification itself materially and
|
|
||||||
adversely affects the operation of the network or violates the rules and
|
|
||||||
protocols for communication across the network.
|
|
||||||
|
|
||||||
Corresponding Source conveyed, and Installation Information provided,
|
|
||||||
in accord with this section must be in a format that is publicly
|
|
||||||
documented (and with an implementation available to the public in
|
|
||||||
source code form), and must require no special password or key for
|
|
||||||
unpacking, reading or copying.
|
|
||||||
|
|
||||||
7. Additional Terms.
|
|
||||||
|
|
||||||
"Additional permissions" are terms that supplement the terms of this
|
|
||||||
License by making exceptions from one or more of its conditions.
|
|
||||||
Additional permissions that are applicable to the entire Program shall
|
|
||||||
be treated as though they were included in this License, to the extent
|
|
||||||
that they are valid under applicable law. If additional permissions
|
|
||||||
apply only to part of the Program, that part may be used separately
|
|
||||||
under those permissions, but the entire Program remains governed by
|
|
||||||
this License without regard to the additional permissions.
|
|
||||||
|
|
||||||
When you convey a copy of a covered work, you may at your option
|
|
||||||
remove any additional permissions from that copy, or from any part of
|
|
||||||
it. (Additional permissions may be written to require their own
|
|
||||||
removal in certain cases when you modify the work.) You may place
|
|
||||||
additional permissions on material, added by you to a covered work,
|
|
||||||
for which you have or can give appropriate copyright permission.
|
|
||||||
|
|
||||||
Notwithstanding any other provision of this License, for material you
|
|
||||||
add to a covered work, you may (if authorized by the copyright holders of
|
|
||||||
that material) supplement the terms of this License with terms:
|
|
||||||
|
|
||||||
a) Disclaiming warranty or limiting liability differently from the
|
|
||||||
terms of sections 15 and 16 of this License; or
|
|
||||||
|
|
||||||
b) Requiring preservation of specified reasonable legal notices or
|
|
||||||
author attributions in that material or in the Appropriate Legal
|
|
||||||
Notices displayed by works containing it; or
|
|
||||||
|
|
||||||
c) Prohibiting misrepresentation of the origin of that material, or
|
|
||||||
requiring that modified versions of such material be marked in
|
|
||||||
reasonable ways as different from the original version; or
|
|
||||||
|
|
||||||
d) Limiting the use for publicity purposes of names of licensors or
|
|
||||||
authors of the material; or
|
|
||||||
|
|
||||||
e) Declining to grant rights under trademark law for use of some
|
|
||||||
trade names, trademarks, or service marks; or
|
|
||||||
|
|
||||||
f) Requiring indemnification of licensors and authors of that
|
|
||||||
material by anyone who conveys the material (or modified versions of
|
|
||||||
it) with contractual assumptions of liability to the recipient, for
|
|
||||||
any liability that these contractual assumptions directly impose on
|
|
||||||
those licensors and authors.
|
|
||||||
|
|
||||||
All other non-permissive additional terms are considered "further
|
|
||||||
restrictions" within the meaning of section 10. If the Program as you
|
|
||||||
received it, or any part of it, contains a notice stating that it is
|
|
||||||
governed by this License along with a term that is a further
|
|
||||||
restriction, you may remove that term. If a license document contains
|
|
||||||
a further restriction but permits relicensing or conveying under this
|
|
||||||
License, you may add to a covered work material governed by the terms
|
|
||||||
of that license document, provided that the further restriction does
|
|
||||||
not survive such relicensing or conveying.
|
|
||||||
|
|
||||||
If you add terms to a covered work in accord with this section, you
|
|
||||||
must place, in the relevant source files, a statement of the
|
|
||||||
additional terms that apply to those files, or a notice indicating
|
|
||||||
where to find the applicable terms.
|
|
||||||
|
|
||||||
Additional terms, permissive or non-permissive, may be stated in the
|
|
||||||
form of a separately written license, or stated as exceptions;
|
|
||||||
the above requirements apply either way.
|
|
||||||
|
|
||||||
8. Termination.
|
|
||||||
|
|
||||||
You may not propagate or modify a covered work except as expressly
|
|
||||||
provided under this License. Any attempt otherwise to propagate or
|
|
||||||
modify it is void, and will automatically terminate your rights under
|
|
||||||
this License (including any patent licenses granted under the third
|
|
||||||
paragraph of section 11).
|
|
||||||
|
|
||||||
However, if you cease all violation of this License, then your
|
|
||||||
license from a particular copyright holder is reinstated (a)
|
|
||||||
provisionally, unless and until the copyright holder explicitly and
|
|
||||||
finally terminates your license, and (b) permanently, if the copyright
|
|
||||||
holder fails to notify you of the violation by some reasonable means
|
|
||||||
prior to 60 days after the cessation.
|
|
||||||
|
|
||||||
Moreover, your license from a particular copyright holder is
|
|
||||||
reinstated permanently if the copyright holder notifies you of the
|
|
||||||
violation by some reasonable means, this is the first time you have
|
|
||||||
received notice of violation of this License (for any work) from that
|
|
||||||
copyright holder, and you cure the violation prior to 30 days after
|
|
||||||
your receipt of the notice.
|
|
||||||
|
|
||||||
Termination of your rights under this section does not terminate the
|
|
||||||
licenses of parties who have received copies or rights from you under
|
|
||||||
this License. If your rights have been terminated and not permanently
|
|
||||||
reinstated, you do not qualify to receive new licenses for the same
|
|
||||||
material under section 10.
|
|
||||||
|
|
||||||
9. Acceptance Not Required for Having Copies.
|
|
||||||
|
|
||||||
You are not required to accept this License in order to receive or
|
|
||||||
run a copy of the Program. Ancillary propagation of a covered work
|
|
||||||
occurring solely as a consequence of using peer-to-peer transmission
|
|
||||||
to receive a copy likewise does not require acceptance. However,
|
|
||||||
nothing other than this License grants you permission to propagate or
|
|
||||||
modify any covered work. These actions infringe copyright if you do
|
|
||||||
not accept this License. Therefore, by modifying or propagating a
|
|
||||||
covered work, you indicate your acceptance of this License to do so.
|
|
||||||
|
|
||||||
10. Automatic Licensing of Downstream Recipients.
|
|
||||||
|
|
||||||
Each time you convey a covered work, the recipient automatically
|
|
||||||
receives a license from the original licensors, to run, modify and
|
|
||||||
propagate that work, subject to this License. You are not responsible
|
|
||||||
for enforcing compliance by third parties with this License.
|
|
||||||
|
|
||||||
An "entity transaction" is a transaction transferring control of an
|
|
||||||
organization, or substantially all assets of one, or subdividing an
|
|
||||||
organization, or merging organizations. If propagation of a covered
|
|
||||||
work results from an entity transaction, each party to that
|
|
||||||
transaction who receives a copy of the work also receives whatever
|
|
||||||
licenses to the work the party's predecessor in interest had or could
|
|
||||||
give under the previous paragraph, plus a right to possession of the
|
|
||||||
Corresponding Source of the work from the predecessor in interest, if
|
|
||||||
the predecessor has it or can get it with reasonable efforts.
|
|
||||||
|
|
||||||
You may not impose any further restrictions on the exercise of the
|
|
||||||
rights granted or affirmed under this License. For example, you may
|
|
||||||
not impose a license fee, royalty, or other charge for exercise of
|
|
||||||
rights granted under this License, and you may not initiate litigation
|
|
||||||
(including a cross-claim or counterclaim in a lawsuit) alleging that
|
|
||||||
any patent claim is infringed by making, using, selling, offering for
|
|
||||||
sale, or importing the Program or any portion of it.
|
|
||||||
|
|
||||||
11. Patents.
|
|
||||||
|
|
||||||
A "contributor" is a copyright holder who authorizes use under this
|
|
||||||
License of the Program or a work on which the Program is based. The
|
|
||||||
work thus licensed is called the contributor's "contributor version".
|
|
||||||
|
|
||||||
A contributor's "essential patent claims" are all patent claims
|
|
||||||
owned or controlled by the contributor, whether already acquired or
|
|
||||||
hereafter acquired, that would be infringed by some manner, permitted
|
|
||||||
by this License, of making, using, or selling its contributor version,
|
|
||||||
but do not include claims that would be infringed only as a
|
|
||||||
consequence of further modification of the contributor version. For
|
|
||||||
purposes of this definition, "control" includes the right to grant
|
|
||||||
patent sublicenses in a manner consistent with the requirements of
|
|
||||||
this License.
|
this License.
|
||||||
|
|
||||||
Each contributor grants you a non-exclusive, worldwide, royalty-free
|
7. If, as a consequence of a court judgment or allegation of patent
|
||||||
patent license under the contributor's essential patent claims, to
|
infringement or for any other reason (not limited to patent issues),
|
||||||
make, use, sell, offer for sale, import and otherwise run, modify and
|
conditions are imposed on you (whether by court order, agreement or
|
||||||
propagate the contents of its contributor version.
|
|
||||||
|
|
||||||
In the following three paragraphs, a "patent license" is any express
|
|
||||||
agreement or commitment, however denominated, not to enforce a patent
|
|
||||||
(such as an express permission to practice a patent or covenant not to
|
|
||||||
sue for patent infringement). To "grant" such a patent license to a
|
|
||||||
party means to make such an agreement or commitment not to enforce a
|
|
||||||
patent against the party.
|
|
||||||
|
|
||||||
If you convey a covered work, knowingly relying on a patent license,
|
|
||||||
and the Corresponding Source of the work is not available for anyone
|
|
||||||
to copy, free of charge and under the terms of this License, through a
|
|
||||||
publicly available network server or other readily accessible means,
|
|
||||||
then you must either (1) cause the Corresponding Source to be so
|
|
||||||
available, or (2) arrange to deprive yourself of the benefit of the
|
|
||||||
patent license for this particular work, or (3) arrange, in a manner
|
|
||||||
consistent with the requirements of this License, to extend the patent
|
|
||||||
license to downstream recipients. "Knowingly relying" means you have
|
|
||||||
actual knowledge that, but for the patent license, your conveying the
|
|
||||||
covered work in a country, or your recipient's use of the covered work
|
|
||||||
in a country, would infringe one or more identifiable patents in that
|
|
||||||
country that you have reason to believe are valid.
|
|
||||||
|
|
||||||
If, pursuant to or in connection with a single transaction or
|
|
||||||
arrangement, you convey, or propagate by procuring conveyance of, a
|
|
||||||
covered work, and grant a patent license to some of the parties
|
|
||||||
receiving the covered work authorizing them to use, propagate, modify
|
|
||||||
or convey a specific copy of the covered work, then the patent license
|
|
||||||
you grant is automatically extended to all recipients of the covered
|
|
||||||
work and works based on it.
|
|
||||||
|
|
||||||
A patent license is "discriminatory" if it does not include within
|
|
||||||
the scope of its coverage, prohibits the exercise of, or is
|
|
||||||
conditioned on the non-exercise of one or more of the rights that are
|
|
||||||
specifically granted under this License. You may not convey a covered
|
|
||||||
work if you are a party to an arrangement with a third party that is
|
|
||||||
in the business of distributing software, under which you make payment
|
|
||||||
to the third party based on the extent of your activity of conveying
|
|
||||||
the work, and under which the third party grants, to any of the
|
|
||||||
parties who would receive the covered work from you, a discriminatory
|
|
||||||
patent license (a) in connection with copies of the covered work
|
|
||||||
conveyed by you (or copies made from those copies), or (b) primarily
|
|
||||||
for and in connection with specific products or compilations that
|
|
||||||
contain the covered work, unless you entered into that arrangement,
|
|
||||||
or that patent license was granted, prior to 28 March 2007.
|
|
||||||
|
|
||||||
Nothing in this License shall be construed as excluding or limiting
|
|
||||||
any implied license or other defenses to infringement that may
|
|
||||||
otherwise be available to you under applicable patent law.
|
|
||||||
|
|
||||||
12. No Surrender of Others' Freedom.
|
|
||||||
|
|
||||||
If conditions are imposed on you (whether by court order, agreement or
|
|
||||||
otherwise) that contradict the conditions of this License, they do not
|
otherwise) that contradict the conditions of this License, they do not
|
||||||
excuse you from the conditions of this License. If you cannot convey a
|
excuse you from the conditions of this License. If you cannot
|
||||||
covered work so as to satisfy simultaneously your obligations under this
|
distribute so as to satisfy simultaneously your obligations under this
|
||||||
License and any other pertinent obligations, then as a consequence you may
|
License and any other pertinent obligations, then as a consequence you
|
||||||
not convey it at all. For example, if you agree to terms that obligate you
|
may not distribute the Program at all. For example, if a patent
|
||||||
to collect a royalty for further conveying from those to whom you convey
|
license would not permit royalty-free redistribution of the Program by
|
||||||
the Program, the only way you could satisfy both those terms and this
|
all those who receive copies directly or indirectly through you, then
|
||||||
License would be to refrain entirely from conveying the Program.
|
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
|
It is not the purpose of this section to induce you to infringe any
|
||||||
permission to link or combine any covered work with a work licensed
|
patents or other property right claims or to contest validity of any
|
||||||
under version 3 of the GNU Affero General Public License into a single
|
such claims; this section has the sole purpose of protecting the
|
||||||
combined work, and to convey the resulting work. The terms of this
|
integrity of the free software distribution system, which is
|
||||||
License will continue to apply to the part which is the covered work,
|
implemented by public license practices. Many people have made
|
||||||
but the special requirements of the GNU Affero General Public License,
|
generous contributions to the wide range of software distributed
|
||||||
section 13, concerning interaction through a network will apply to the
|
through that system in reliance on consistent application of that
|
||||||
combination as such.
|
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
|
8. If the distribution and/or use of the Program is restricted in
|
||||||
the GNU General Public License from time to time. Such new versions will
|
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
|
be similar in spirit to the present version, but may differ in detail to
|
||||||
address new problems or concerns.
|
address new problems or concerns.
|
||||||
|
|
||||||
Each version is given a distinguishing version number. If the
|
Each version is given a distinguishing version number. If the Program
|
||||||
Program specifies that a certain numbered version of the GNU General
|
specifies a version number of this License which applies to it and "any
|
||||||
Public License "or any later version" applies to it, you have the
|
later version", you have the option of following the terms and conditions
|
||||||
option of following the terms and conditions either of that numbered
|
either of that version or of any later version published by the Free
|
||||||
version or of any later version published by the Free Software
|
Software Foundation. If the Program does not specify a version number of
|
||||||
Foundation. If the Program does not specify a version number of the
|
this License, you may choose any version ever published by the Free Software
|
||||||
GNU General Public License, you may choose any version ever published
|
Foundation.
|
||||||
by the Free Software Foundation.
|
|
||||||
|
|
||||||
If the Program specifies that a proxy can decide which future
|
10. If you wish to incorporate parts of the Program into other free
|
||||||
versions of the GNU General Public License can be used, that proxy's
|
programs whose distribution conditions are different, write to the author
|
||||||
public statement of acceptance of a version permanently authorizes you
|
to ask for permission. For software which is copyrighted by the Free
|
||||||
to choose that version for the Program.
|
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
|
NO WARRANTY
|
||||||
permissions. However, no additional obligations are imposed on any
|
|
||||||
author or copyright holder as a result of your choosing to follow a
|
|
||||||
later version.
|
|
||||||
|
|
||||||
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
|
12. IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING
|
||||||
APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT
|
WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MAY MODIFY AND/OR
|
||||||
HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY
|
REDISTRIBUTE THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES,
|
||||||
OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO,
|
INCLUDING ANY GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING
|
||||||
THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR
|
OUT OF THE USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED
|
||||||
PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM
|
TO LOSS OF DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY
|
||||||
IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF
|
YOU OR THIRD PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER
|
||||||
ALL NECESSARY SERVICING, REPAIR OR CORRECTION.
|
PROGRAMS), EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE
|
||||||
|
POSSIBILITY OF SUCH DAMAGES.
|
||||||
16. Limitation of Liability.
|
|
||||||
|
|
||||||
IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING
|
|
||||||
WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS
|
|
||||||
THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY
|
|
||||||
GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE
|
|
||||||
USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF
|
|
||||||
DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD
|
|
||||||
PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS),
|
|
||||||
EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF
|
|
||||||
SUCH DAMAGES.
|
|
||||||
|
|
||||||
17. Interpretation of Sections 15 and 16.
|
|
||||||
|
|
||||||
If the disclaimer of warranty and limitation of liability provided
|
|
||||||
above cannot be given local legal effect according to their terms,
|
|
||||||
reviewing courts shall apply local law that most closely approximates
|
|
||||||
an absolute waiver of all civil liability in connection with the
|
|
||||||
Program, unless a warranty or assumption of liability accompanies a
|
|
||||||
copy of the Program in return for a fee.
|
|
||||||
|
|
||||||
END OF TERMS AND CONDITIONS
|
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 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
|
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.
|
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.>
|
<one line to give the program's name and a brief idea of what it does.>
|
||||||
Copyright (C) <year> <name of author>
|
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
|
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.
|
(at your option) any later version.
|
||||||
|
|
||||||
This program is distributed in the hope that it will be useful,
|
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
|
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||||
GNU General Public License for more details.
|
GNU General Public License for more details.
|
||||||
|
|
||||||
You should have received a copy of the GNU General Public License
|
You should have received a copy of the GNU General Public License along
|
||||||
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
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.
|
Also add information on how to contact you by electronic and paper mail.
|
||||||
|
|
||||||
If the program does terminal interaction, make it output a short
|
If the program is interactive, make it output a short notice like this
|
||||||
notice like this when it starts in an interactive mode:
|
when it starts in an interactive mode:
|
||||||
|
|
||||||
<program> Copyright (C) <year> <name of author>
|
Gnomovision version 69, Copyright (C) year name of author
|
||||||
This program comes with ABSOLUTELY NO WARRANTY; for details type `show w'.
|
Gnomovision comes with ABSOLUTELY NO WARRANTY; for details type `show w'.
|
||||||
This is free software, and you are welcome to redistribute it
|
This is free software, and you are welcome to redistribute it
|
||||||
under certain conditions; type `show c' for details.
|
under certain conditions; type `show c' for details.
|
||||||
|
|
||||||
The hypothetical commands `show w' and `show c' should show the appropriate
|
The hypothetical commands `show w' and `show c' should show the appropriate
|
||||||
parts of the General Public License. Of course, your program's commands
|
parts of the General Public License. Of course, the commands you use may
|
||||||
might be different; for a GUI interface, you would use an "about box".
|
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,
|
You should also get your employer (if you work as a programmer) or your
|
||||||
if any, to sign a "copyright disclaimer" for the program, if necessary.
|
school, if any, to sign a "copyright disclaimer" for the program, if
|
||||||
For more information on this, and how to apply and follow the GNU GPL, see
|
necessary. Here is a sample; alter the names:
|
||||||
<https://www.gnu.org/licenses/>.
|
|
||||||
|
|
||||||
The GNU General Public License does not permit incorporating your program
|
Yoyodyne, Inc., hereby disclaims all copyright interest in the program
|
||||||
into proprietary programs. If your program is a subroutine library, you
|
`Gnomovision' (which makes passes at compilers) written by James Hacker.
|
||||||
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
|
<signature of Ty Coon>, 1 April 1989
|
||||||
Public License instead of this License. But first, please read
|
Ty Coon, President of Vice
|
||||||
<https://www.gnu.org/licenses/why-not-lgpl.html>.
|
|
||||||
|
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.
|
||||||
|
|
38
README.md
38
README.md
|
@ -1,27 +1,17 @@
|
||||||
<img src="https://gitea.angry.im/PeterCxy/OpenEUICC/media/branch/master/art/OpenEUICCBG.svg" width="512" height="300">
|
{Open,Easy}EUICC
|
||||||
|
---
|
||||||
|
|
||||||
A fully free and open-source Local Profile Assistant implementation for Android devices.
|
A fully free and open-source Local Profile Assistant implementation for Android devices.
|
||||||
|
|
||||||
There are two variants of this project, OpenEUICC and EasyEUICC:
|
There are two variants of this project:
|
||||||
|
|
||||||
| | OpenEUICC | EasyEUICC |
|
- OpenEUICC: The full-fledged privileged variant.
|
||||||
|:------------------------------|:-----------------------------------------------:|:-----------------:|
|
- Due to its privilege requirement, OpenEUICC must be placed inside `/system/priv-app` and be signed with the platform certificate.
|
||||||
| Privileged | Must be installed as system app | No |
|
- The preferred way to including OpenEUICC in a system image is to [build it along with AOSP](#building-aosp).
|
||||||
| Internal eSIM | Supported | Unsupported |
|
- EasyEUICC: Unprivileged version that can run as a user app.
|
||||||
| External (Removable) eSIM | Supported | Supported |
|
- Due to obvious security requirements, EasyEUICC is only able to access eSIM chips whose [ARF/ARA](https://source.android.com/docs/core/connect/uicc#arf) contains the hash of EasyEUICC's signing certificate.
|
||||||
| USB Readers | Supported | Supported |
|
- Prebuilt release-mode EasyEUICC apks can be downloaded [here](https://gitea.angry.im/PeterCxy/OpenEUICC/releases)
|
||||||
| Requires allowlisting by eSIM | No | Yes -- except USB |
|
- For removable eSIM chip vendors: to have your chip supported by official builds of EasyEUICC, include the ARA-M hash `2A2FA878BC7C3354C2CF82935A5945A3EDAE4AFA`
|
||||||
| System Integration | Partial (carrier partner API unimplemented yet) | No |
|
|
||||||
|
|
||||||
Some side notes:
|
|
||||||
1. When privileged, OpenEUICC supports any eUICC chip that implements the SGP.22 standard, internal or external. However, there is __no guarantee__ that external (removable) eSIMs actually follow the standard. Please __DO NOT__ submit bug reports for non-functioning removable eSIMs. They are __NOT__ officially supported unless they also support / are supported by EasyEUICC, the unprivileged variant.
|
|
||||||
2. Both variants support accessing eUICC chips through USB CCID readers, regardless of whether the chip contains the correct ARA-M hash to allow for unprivileged access. However, only `T=0` readers that use the standard [USB CCID protocol](https://en.wikipedia.org/wiki/CCID_(protocol)) are supported.
|
|
||||||
3. Prebuilt release-mode EasyEUICC apks can be downloaded [here](https://gitea.angry.im/PeterCxy/OpenEUICC/releases). For OpenEUICC, no official release is currently provided and only debug mode APKs can be found in the CI page.
|
|
||||||
4. For removable eSIM chip vendors: to have your chip supported by official builds of EasyEUICC when inserted, include the ARA-M hash `2A2FA878BC7C3354C2CF82935A5945A3EDAE4AFA`.
|
|
||||||
|
|
||||||
__This project is Free Software licensed under GNU GPL v3, WITHOUT the "or later" clause.__ Any modification and derivative work __MUST__ be released under the SAME license, which means, at the very least, that the source code __MUST__ be available upon request.
|
|
||||||
|
|
||||||
__If you are releasing a modification of this app, you are kindly asked to make changes to at least the app name and package name.__
|
|
||||||
|
|
||||||
Building (Gradle)
|
Building (Gradle)
|
||||||
===
|
===
|
||||||
|
@ -88,14 +78,14 @@ FAQs
|
||||||
Copyright
|
Copyright
|
||||||
===
|
===
|
||||||
|
|
||||||
Everything except `libs/lpac-jni` and `art/`:
|
Everything except `libs/lpac-jni`:
|
||||||
|
|
||||||
```
|
```
|
||||||
Copyright 2022-2024 OpenEUICC contributors
|
Copyright 2022-2024 OpenEUICC contributors
|
||||||
|
|
||||||
This program is free software; you can redistribute it and/or
|
This program is free software; you can redistribute it and/or
|
||||||
modify it under the terms of the GNU General Public License
|
modify it under the terms of the GNU General Public License
|
||||||
as published by the Free Software Foundation, version 3.
|
as published by the Free Software Foundation, version 2.
|
||||||
|
|
||||||
This program is distributed in the hope that it will be useful,
|
This program is distributed in the hope that it will be useful,
|
||||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||||
|
@ -124,6 +114,4 @@ Lesser General Public License for more details.
|
||||||
You should have received a copy of the GNU Lesser General Public
|
You should have received a copy of the GNU Lesser General Public
|
||||||
License along with this library; if not, write to the Free Software
|
License along with this library; if not, write to the Free Software
|
||||||
Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA
|
Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA
|
||||||
```
|
```
|
||||||
|
|
||||||
`art/`: Courtesy of [Aikoyori](https://github.com/Aikoyori), CC NC-SA 4.0.
|
|
|
@ -5,7 +5,7 @@ plugins {
|
||||||
|
|
||||||
android {
|
android {
|
||||||
namespace = "im.angry.openeuicc.common"
|
namespace = "im.angry.openeuicc.common"
|
||||||
compileSdk = 35
|
compileSdk = 34
|
||||||
|
|
||||||
defaultConfig {
|
defaultConfig {
|
||||||
minSdk = 28
|
minSdk = 28
|
||||||
|
|
|
@ -3,15 +3,10 @@
|
||||||
xmlns:android="http://schemas.android.com/apk/res/android"
|
xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
package="im.angry.openeuicc.common">
|
package="im.angry.openeuicc.common">
|
||||||
|
|
||||||
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
|
|
||||||
<uses-permission android:name="android.permission.READ_PHONE_STATE" />
|
<uses-permission android:name="android.permission.READ_PHONE_STATE" />
|
||||||
<uses-permission android:name="android.permission.INTERNET" />
|
<uses-permission android:name="android.permission.INTERNET" />
|
||||||
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
|
|
||||||
<uses-permission android:name="android.permission.WAKE_LOCK" />
|
|
||||||
|
|
||||||
<application
|
<application>
|
||||||
android:enableOnBackInvokedCallback="true"
|
|
||||||
tools:targetApi="tiramisu">
|
|
||||||
<activity
|
<activity
|
||||||
android:name="im.angry.openeuicc.ui.SettingsActivity"
|
android:name="im.angry.openeuicc.ui.SettingsActivity"
|
||||||
android:label="@string/pref_settings" />
|
android:label="@string/pref_settings" />
|
||||||
|
@ -21,48 +16,17 @@
|
||||||
android:label="@string/profile_notifications" />
|
android:label="@string/profile_notifications" />
|
||||||
|
|
||||||
<activity
|
<activity
|
||||||
android:name="im.angry.openeuicc.ui.EuiccInfoActivity"
|
android:name="im.angry.openeuicc.ui.DirectProfileDownloadActivity"
|
||||||
android:label="@string/euicc_info" />
|
android:label="@string/profile_download"
|
||||||
|
android:theme="@style/Theme.AppCompat.Translucent" />
|
||||||
|
|
||||||
<activity
|
<activity
|
||||||
android:name="im.angry.openeuicc.ui.LogsActivity"
|
android:name="im.angry.openeuicc.ui.LogsActivity"
|
||||||
android:label="@string/pref_advanced_logs" />
|
android:label="@string/pref_advanced_logs" />
|
||||||
|
|
||||||
<activity
|
|
||||||
android:name="im.angry.openeuicc.ui.IsdrAidListActivity"
|
|
||||||
android:label="@string/isdr_aid_list" />
|
|
||||||
|
|
||||||
<activity
|
|
||||||
android:exported="true"
|
|
||||||
android:name="im.angry.openeuicc.ui.wizard.DownloadWizardActivity"
|
|
||||||
android:label="@string/download_wizard">
|
|
||||||
<intent-filter>
|
|
||||||
<action android:name="android.intent.action.VIEW" />
|
|
||||||
|
|
||||||
<category android:name="android.intent.category.DEFAULT" />
|
|
||||||
<category android:name="android.intent.category.BROWSABLE" />
|
|
||||||
|
|
||||||
<!-- Accepts URIs that begin with "lpa:" -->
|
|
||||||
<!-- for example: "LPA:1$..." -->
|
|
||||||
<!-- refs: https://www.iana.org/assignments/uri-schemes/prov/lpa -->
|
|
||||||
<data android:scheme="lpa"/>
|
|
||||||
<data android:sspPrefix="1$"/>
|
|
||||||
</intent-filter>
|
|
||||||
</activity>
|
|
||||||
|
|
||||||
<activity-alias
|
|
||||||
android:exported="true"
|
|
||||||
android:name="im.angry.openeuicc.ui.DirectProfileDownloadActivity"
|
|
||||||
android:targetActivity="im.angry.openeuicc.ui.wizard.DownloadWizardActivity" />
|
|
||||||
|
|
||||||
<activity
|
<activity
|
||||||
android:name="com.journeyapps.barcodescanner.CaptureActivity"
|
android:name="com.journeyapps.barcodescanner.CaptureActivity"
|
||||||
android:screenOrientation="fullSensor"
|
android:screenOrientation="fullSensor"
|
||||||
tools:replace="screenOrientation" />
|
tools:replace="screenOrientation" />
|
||||||
|
|
||||||
<service
|
|
||||||
android:name="im.angry.openeuicc.service.EuiccChannelManagerService"
|
|
||||||
android:foregroundServiceType="shortService"
|
|
||||||
android:exported="false" />
|
|
||||||
</application>
|
</application>
|
||||||
</manifest>
|
</manifest>
|
||||||
|
|
|
@ -1,5 +0,0 @@
|
||||||
package im.angry.openeuicc.core
|
|
||||||
|
|
||||||
interface ApduInterfaceAtrProvider {
|
|
||||||
val atr: ByteArray?
|
|
||||||
}
|
|
|
@ -3,9 +3,6 @@ package im.angry.openeuicc.core
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
import android.se.omapi.SEService
|
import android.se.omapi.SEService
|
||||||
import android.util.Log
|
import android.util.Log
|
||||||
import im.angry.openeuicc.common.R
|
|
||||||
import im.angry.openeuicc.core.usb.UsbApduInterface
|
|
||||||
import im.angry.openeuicc.core.usb.UsbCcidContext
|
|
||||||
import im.angry.openeuicc.util.*
|
import im.angry.openeuicc.util.*
|
||||||
import java.lang.IllegalArgumentException
|
import java.lang.IllegalArgumentException
|
||||||
|
|
||||||
|
@ -13,82 +10,32 @@ open class DefaultEuiccChannelFactory(protected val context: Context) : EuiccCha
|
||||||
private var seService: SEService? = null
|
private var seService: SEService? = null
|
||||||
|
|
||||||
private suspend fun ensureSEService() {
|
private suspend fun ensureSEService() {
|
||||||
if (seService == null || !seService!!.isConnected) {
|
if (seService == null) {
|
||||||
seService = connectSEService(context)
|
seService = connectSEService(context)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
override suspend fun tryOpenEuiccChannel(
|
override suspend fun tryOpenEuiccChannel(port: UiccPortInfoCompat): EuiccChannel? {
|
||||||
port: UiccPortInfoCompat,
|
|
||||||
isdrAid: ByteArray
|
|
||||||
): EuiccChannel? {
|
|
||||||
if (port.portIndex != 0) {
|
if (port.portIndex != 0) {
|
||||||
Log.w(
|
Log.w(DefaultEuiccChannelManager.TAG, "OMAPI channel attempted on non-zero portId, this may or may not work.")
|
||||||
DefaultEuiccChannelManager.TAG,
|
|
||||||
"OMAPI channel attempted on non-zero portId, this may or may not work."
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
ensureSEService()
|
ensureSEService()
|
||||||
|
|
||||||
Log.i(
|
Log.i(DefaultEuiccChannelManager.TAG, "Trying OMAPI for physical slot ${port.card.physicalSlotIndex}")
|
||||||
DefaultEuiccChannelManager.TAG,
|
|
||||||
"Trying OMAPI for physical slot ${port.card.physicalSlotIndex}"
|
|
||||||
)
|
|
||||||
try {
|
try {
|
||||||
return EuiccChannelImpl(
|
return OmapiChannel(seService!!, port)
|
||||||
context.getString(R.string.omapi),
|
} catch (e: IllegalArgumentException) {
|
||||||
port,
|
|
||||||
intrinsicChannelName = null,
|
|
||||||
OmapiApduInterface(
|
|
||||||
seService!!,
|
|
||||||
port,
|
|
||||||
context.preferenceRepository.verboseLoggingFlow
|
|
||||||
),
|
|
||||||
isdrAid,
|
|
||||||
context.preferenceRepository.verboseLoggingFlow,
|
|
||||||
context.preferenceRepository.ignoreTLSCertificateFlow,
|
|
||||||
).also {
|
|
||||||
Log.i(DefaultEuiccChannelManager.TAG, "Is OMAPI channel, setting MSS to 60")
|
|
||||||
it.lpa.setEs10xMss(60)
|
|
||||||
}
|
|
||||||
} catch (_: IllegalArgumentException) {
|
|
||||||
// Failed
|
// Failed
|
||||||
Log.w(
|
Log.w(
|
||||||
DefaultEuiccChannelManager.TAG,
|
DefaultEuiccChannelManager.TAG,
|
||||||
"OMAPI APDU interface unavailable for physical slot ${port.card.physicalSlotIndex} with ISD-R AID: ${isdrAid.encodeHex()}."
|
"OMAPI APDU interface unavailable for physical slot ${port.card.physicalSlotIndex}."
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun tryOpenUsbEuiccChannel(
|
|
||||||
ccidCtx: UsbCcidContext,
|
|
||||||
isdrAid: ByteArray
|
|
||||||
): EuiccChannel? {
|
|
||||||
try {
|
|
||||||
return EuiccChannelImpl(
|
|
||||||
context.getString(R.string.usb),
|
|
||||||
FakeUiccPortInfoCompat(FakeUiccCardInfoCompat(EuiccChannelManager.USB_CHANNEL_ID)),
|
|
||||||
intrinsicChannelName = ccidCtx.productName,
|
|
||||||
UsbApduInterface(
|
|
||||||
ccidCtx
|
|
||||||
),
|
|
||||||
isdrAid,
|
|
||||||
context.preferenceRepository.verboseLoggingFlow,
|
|
||||||
context.preferenceRepository.ignoreTLSCertificateFlow,
|
|
||||||
)
|
|
||||||
} catch (_: IllegalArgumentException) {
|
|
||||||
// Failed
|
|
||||||
Log.w(
|
|
||||||
DefaultEuiccChannelManager.TAG,
|
|
||||||
"USB APDU interface unavailable for ISD-R AID: ${isdrAid.encodeHex()}."
|
|
||||||
)
|
|
||||||
}
|
|
||||||
return null
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun cleanup() {
|
override fun cleanup() {
|
||||||
seService?.shutdown()
|
seService?.shutdown()
|
||||||
seService = null
|
seService = null
|
||||||
|
|
|
@ -1,26 +1,15 @@
|
||||||
package im.angry.openeuicc.core
|
package im.angry.openeuicc.core
|
||||||
|
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
import android.hardware.usb.UsbDevice
|
|
||||||
import android.hardware.usb.UsbManager
|
|
||||||
import android.telephony.SubscriptionManager
|
import android.telephony.SubscriptionManager
|
||||||
import android.util.Log
|
import android.util.Log
|
||||||
import im.angry.openeuicc.core.usb.UsbCcidContext
|
|
||||||
import im.angry.openeuicc.core.usb.smartCard
|
|
||||||
import im.angry.openeuicc.core.usb.interfaces
|
|
||||||
import im.angry.openeuicc.di.AppContainer
|
import im.angry.openeuicc.di.AppContainer
|
||||||
import im.angry.openeuicc.util.*
|
import im.angry.openeuicc.util.*
|
||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
import kotlinx.coroutines.delay
|
import kotlinx.coroutines.runBlocking
|
||||||
import kotlinx.coroutines.flow.Flow
|
|
||||||
import kotlinx.coroutines.flow.first
|
|
||||||
import kotlinx.coroutines.flow.flow
|
|
||||||
import kotlinx.coroutines.flow.flowOn
|
|
||||||
import kotlinx.coroutines.flow.merge
|
|
||||||
import kotlinx.coroutines.sync.Mutex
|
import kotlinx.coroutines.sync.Mutex
|
||||||
import kotlinx.coroutines.sync.withLock
|
import kotlinx.coroutines.sync.withLock
|
||||||
import kotlinx.coroutines.withContext
|
import kotlinx.coroutines.withContext
|
||||||
import kotlinx.coroutines.withTimeout
|
|
||||||
|
|
||||||
open class DefaultEuiccChannelManager(
|
open class DefaultEuiccChannelManager(
|
||||||
protected val appContainer: AppContainer,
|
protected val appContainer: AppContainer,
|
||||||
|
@ -30,9 +19,7 @@ open class DefaultEuiccChannelManager(
|
||||||
const val TAG = "EuiccChannelManager"
|
const val TAG = "EuiccChannelManager"
|
||||||
}
|
}
|
||||||
|
|
||||||
private val channelCache = mutableListOf<EuiccChannel>()
|
private val channels = mutableListOf<EuiccChannel>()
|
||||||
|
|
||||||
private var usbChannel: EuiccChannel? = null
|
|
||||||
|
|
||||||
private val lock = Mutex()
|
private val lock = Mutex()
|
||||||
|
|
||||||
|
@ -40,10 +27,6 @@ open class DefaultEuiccChannelManager(
|
||||||
appContainer.telephonyManager
|
appContainer.telephonyManager
|
||||||
}
|
}
|
||||||
|
|
||||||
private val usbManager by lazy {
|
|
||||||
context.getSystemService(Context.USB_SERVICE) as UsbManager
|
|
||||||
}
|
|
||||||
|
|
||||||
private val euiccChannelFactory by lazy {
|
private val euiccChannelFactory by lazy {
|
||||||
appContainer.euiccChannelFactory
|
appContainer.euiccChannelFactory
|
||||||
}
|
}
|
||||||
|
@ -51,43 +34,16 @@ open class DefaultEuiccChannelManager(
|
||||||
protected open val uiccCards: Collection<UiccCardInfoCompat>
|
protected open val uiccCards: Collection<UiccCardInfoCompat>
|
||||||
get() = (0..<tm.activeModemCountCompat).map { FakeUiccCardInfoCompat(it) }
|
get() = (0..<tm.activeModemCountCompat).map { FakeUiccCardInfoCompat(it) }
|
||||||
|
|
||||||
private suspend inline fun tryOpenChannelFirstValidAid(openFn: (ByteArray) -> EuiccChannel?): EuiccChannel? {
|
|
||||||
val isdrAidList =
|
|
||||||
parseIsdrAidList(appContainer.preferenceRepository.isdrAidListFlow.first())
|
|
||||||
|
|
||||||
return isdrAidList.firstNotNullOfOrNull {
|
|
||||||
Log.i(TAG, "Opening channel, trying ISDR AID ${it.encodeHex()}")
|
|
||||||
|
|
||||||
openFn(it)?.let { channel ->
|
|
||||||
if (channel.valid) {
|
|
||||||
channel
|
|
||||||
} else {
|
|
||||||
channel.close()
|
|
||||||
null
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private suspend fun tryOpenEuiccChannel(port: UiccPortInfoCompat): EuiccChannel? {
|
private suspend fun tryOpenEuiccChannel(port: UiccPortInfoCompat): EuiccChannel? {
|
||||||
lock.withLock {
|
lock.withLock {
|
||||||
if (port.card.physicalSlotIndex == EuiccChannelManager.USB_CHANNEL_ID) {
|
|
||||||
return if (usbChannel != null && usbChannel!!.valid) {
|
|
||||||
usbChannel
|
|
||||||
} else {
|
|
||||||
usbChannel = null
|
|
||||||
null
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
val existing =
|
val existing =
|
||||||
channelCache.find { it.slotId == port.card.physicalSlotIndex && it.portId == port.portIndex }
|
channels.find { it.slotId == port.card.physicalSlotIndex && it.portId == port.portIndex }
|
||||||
if (existing != null) {
|
if (existing != null) {
|
||||||
if (existing.valid && port.logicalSlotIndex == existing.logicalSlotId) {
|
if (existing.valid && port.logicalSlotIndex == existing.logicalSlotId) {
|
||||||
return existing
|
return existing
|
||||||
} else {
|
} else {
|
||||||
existing.close()
|
existing.close()
|
||||||
channelCache.remove(existing)
|
channels.remove(existing)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -96,222 +52,84 @@ open class DefaultEuiccChannelManager(
|
||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
|
|
||||||
val channel =
|
return euiccChannelFactory.tryOpenEuiccChannel(port)?.also {
|
||||||
tryOpenChannelFirstValidAid { euiccChannelFactory.tryOpenEuiccChannel(port, it) }
|
channels.add(it)
|
||||||
|
|
||||||
if (channel != null) {
|
|
||||||
channelCache.add(channel)
|
|
||||||
return channel
|
|
||||||
} else {
|
|
||||||
Log.i(
|
|
||||||
TAG,
|
|
||||||
"Was able to open channel for logical slot ${port.logicalSlotIndex}, but the channel is invalid (cannot get eID or profiles without errors). This slot might be broken, aborting."
|
|
||||||
)
|
|
||||||
return null
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
protected suspend fun findEuiccChannelByLogicalSlot(logicalSlotId: Int): EuiccChannel? =
|
override fun findEuiccChannelBySlotBlocking(logicalSlotId: Int): EuiccChannel? =
|
||||||
withContext(Dispatchers.IO) {
|
runBlocking {
|
||||||
if (logicalSlotId == EuiccChannelManager.USB_CHANNEL_ID) {
|
withContext(Dispatchers.IO) {
|
||||||
return@withContext usbChannel
|
for (card in uiccCards) {
|
||||||
}
|
for (port in card.ports) {
|
||||||
|
if (port.logicalSlotIndex == logicalSlotId) {
|
||||||
|
return@withContext tryOpenEuiccChannel(port)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun findEuiccChannelByPhysicalSlotBlocking(physicalSlotId: Int): EuiccChannel? =
|
||||||
|
runBlocking {
|
||||||
|
withContext(Dispatchers.IO) {
|
||||||
|
for (card in uiccCards) {
|
||||||
|
if (card.physicalSlotIndex != physicalSlotId) continue
|
||||||
|
for (port in card.ports) {
|
||||||
|
tryOpenEuiccChannel(port)?.let { return@withContext it }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun findAllEuiccChannelsByPhysicalSlotBlocking(physicalSlotId: Int): List<EuiccChannel>? =
|
||||||
|
runBlocking {
|
||||||
for (card in uiccCards) {
|
for (card in uiccCards) {
|
||||||
for (port in card.ports) {
|
if (card.physicalSlotIndex != physicalSlotId) continue
|
||||||
if (port.logicalSlotIndex == logicalSlotId) {
|
return@runBlocking card.ports.mapNotNull { tryOpenEuiccChannel(it) }
|
||||||
return@withContext tryOpenEuiccChannel(port)
|
.ifEmpty { null }
|
||||||
}
|
}
|
||||||
|
return@runBlocking null
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun findEuiccChannelByPortBlocking(physicalSlotId: Int, portId: Int): EuiccChannel? =
|
||||||
|
runBlocking {
|
||||||
|
withContext(Dispatchers.IO) {
|
||||||
|
uiccCards.find { it.physicalSlotIndex == physicalSlotId }?.let { card ->
|
||||||
|
card.ports.find { it.portIndex == portId }?.let { tryOpenEuiccChannel(it) }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
null
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private suspend fun findAllEuiccChannelsByPhysicalSlot(physicalSlotId: Int): List<EuiccChannel>? {
|
override suspend fun enumerateEuiccChannels() {
|
||||||
if (physicalSlotId == EuiccChannelManager.USB_CHANNEL_ID) {
|
|
||||||
return usbChannel?.let { listOf(it) }
|
|
||||||
}
|
|
||||||
|
|
||||||
for (card in uiccCards) {
|
|
||||||
if (card.physicalSlotIndex != physicalSlotId) continue
|
|
||||||
return card.ports.mapNotNull { tryOpenEuiccChannel(it) }
|
|
||||||
.ifEmpty { null }
|
|
||||||
}
|
|
||||||
return null
|
|
||||||
}
|
|
||||||
|
|
||||||
private suspend fun findEuiccChannelByPort(physicalSlotId: Int, portId: Int): EuiccChannel? =
|
|
||||||
withContext(Dispatchers.IO) {
|
withContext(Dispatchers.IO) {
|
||||||
if (physicalSlotId == EuiccChannelManager.USB_CHANNEL_ID) {
|
for (uiccInfo in uiccCards) {
|
||||||
return@withContext usbChannel
|
for (port in uiccInfo.ports) {
|
||||||
}
|
if (tryOpenEuiccChannel(port) != null) {
|
||||||
|
Log.d(
|
||||||
uiccCards.find { it.physicalSlotIndex == physicalSlotId }?.let { card ->
|
TAG,
|
||||||
card.ports.find { it.portIndex == portId }?.let { tryOpenEuiccChannel(it) }
|
"Found eUICC on slot ${uiccInfo.physicalSlotIndex} port ${port.portIndex}"
|
||||||
}
|
)
|
||||||
}
|
|
||||||
|
|
||||||
override suspend fun findFirstAvailablePort(physicalSlotId: Int): Int =
|
|
||||||
withContext(Dispatchers.IO) {
|
|
||||||
if (physicalSlotId == EuiccChannelManager.USB_CHANNEL_ID) {
|
|
||||||
return@withContext 0
|
|
||||||
}
|
|
||||||
|
|
||||||
findAllEuiccChannelsByPhysicalSlot(physicalSlotId)?.getOrNull(0)?.portId ?: -1
|
|
||||||
}
|
|
||||||
|
|
||||||
override suspend fun findAvailablePorts(physicalSlotId: Int): List<Int> =
|
|
||||||
withContext(Dispatchers.IO) {
|
|
||||||
if (physicalSlotId == EuiccChannelManager.USB_CHANNEL_ID) {
|
|
||||||
return@withContext listOf(0)
|
|
||||||
}
|
|
||||||
|
|
||||||
findAllEuiccChannelsByPhysicalSlot(physicalSlotId)?.map { it.portId } ?: listOf()
|
|
||||||
}
|
|
||||||
|
|
||||||
override suspend fun <R> withEuiccChannel(
|
|
||||||
physicalSlotId: Int,
|
|
||||||
portId: Int,
|
|
||||||
fn: suspend (EuiccChannel) -> R
|
|
||||||
): R {
|
|
||||||
val channel = findEuiccChannelByPort(physicalSlotId, portId)
|
|
||||||
?: throw EuiccChannelManager.EuiccChannelNotFoundException()
|
|
||||||
val wrapper = EuiccChannelWrapper(channel)
|
|
||||||
try {
|
|
||||||
return withContext(Dispatchers.IO) {
|
|
||||||
fn(wrapper)
|
|
||||||
}
|
|
||||||
} finally {
|
|
||||||
wrapper.invalidateWrapper()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
override suspend fun <R> withEuiccChannel(
|
|
||||||
logicalSlotId: Int,
|
|
||||||
fn: suspend (EuiccChannel) -> R
|
|
||||||
): R {
|
|
||||||
val channel = findEuiccChannelByLogicalSlot(logicalSlotId)
|
|
||||||
?: throw EuiccChannelManager.EuiccChannelNotFoundException()
|
|
||||||
val wrapper = EuiccChannelWrapper(channel)
|
|
||||||
try {
|
|
||||||
return withContext(Dispatchers.IO) {
|
|
||||||
fn(wrapper)
|
|
||||||
}
|
|
||||||
} finally {
|
|
||||||
wrapper.invalidateWrapper()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
override suspend fun waitForReconnect(physicalSlotId: Int, portId: Int, timeoutMillis: Long) {
|
|
||||||
if (physicalSlotId == EuiccChannelManager.USB_CHANNEL_ID) {
|
|
||||||
usbChannel?.close()
|
|
||||||
usbChannel = null
|
|
||||||
} else {
|
|
||||||
// If there is already a valid channel, we close it proactively
|
|
||||||
// Sometimes the current channel can linger on for a bit even after it should have become invalid
|
|
||||||
channelCache.find { it.slotId == physicalSlotId && it.portId == portId }?.apply {
|
|
||||||
if (valid) close()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
withTimeout(timeoutMillis) {
|
|
||||||
while (true) {
|
|
||||||
try {
|
|
||||||
val channel = if (physicalSlotId == EuiccChannelManager.USB_CHANNEL_ID) {
|
|
||||||
// tryOpenUsbEuiccChannel() will always try to reopen the channel, even if
|
|
||||||
// a USB channel already exists
|
|
||||||
tryOpenUsbEuiccChannel()
|
|
||||||
usbChannel!!
|
|
||||||
} else {
|
|
||||||
// tryOpenEuiccChannel() will automatically dispose of invalid channels
|
|
||||||
// and recreate when needed
|
|
||||||
findEuiccChannelByPort(physicalSlotId, portId)!!
|
|
||||||
}
|
}
|
||||||
check(channel.valid) { "Invalid channel" }
|
|
||||||
break
|
|
||||||
} catch (e: Exception) {
|
|
||||||
Log.d(
|
|
||||||
TAG,
|
|
||||||
"Slot $physicalSlotId port $portId reconnect failure, retrying in 1000 ms"
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
delay(1000)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun flowInternalEuiccPorts(): Flow<Pair<Int, Int>> = flow {
|
override val knownChannels: List<EuiccChannel>
|
||||||
uiccCards.forEach { info ->
|
get() = channels.toList()
|
||||||
info.ports.forEach { port ->
|
|
||||||
tryOpenEuiccChannel(port)?.also {
|
|
||||||
Log.d(
|
|
||||||
TAG,
|
|
||||||
"Found eUICC on slot ${info.physicalSlotIndex} port ${port.portIndex}"
|
|
||||||
)
|
|
||||||
|
|
||||||
emit(Pair(info.physicalSlotIndex, port.portIndex))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}.flowOn(Dispatchers.IO)
|
|
||||||
|
|
||||||
override fun flowAllOpenEuiccPorts(): Flow<Pair<Int, Int>> =
|
|
||||||
merge(flowInternalEuiccPorts(), flow {
|
|
||||||
if (tryOpenUsbEuiccChannel().second) {
|
|
||||||
emit(Pair(EuiccChannelManager.USB_CHANNEL_ID, 0))
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
override suspend fun tryOpenUsbEuiccChannel(): Pair<UsbDevice?, Boolean> =
|
|
||||||
withContext(Dispatchers.IO) {
|
|
||||||
usbManager.deviceList.values.forEach { device ->
|
|
||||||
Log.i(TAG, "Scanning USB device ${device.deviceId}:${device.vendorId}")
|
|
||||||
val iface = device.interfaces.smartCard ?: return@forEach
|
|
||||||
// If we don't have permission, tell UI code that we found a candidate device, but we
|
|
||||||
// need permission to be able to do anything with it
|
|
||||||
if (!usbManager.hasPermission(device)) return@withContext Pair(device, false)
|
|
||||||
Log.i(
|
|
||||||
TAG,
|
|
||||||
"Found CCID interface on ${device.deviceId}:${device.vendorId}, and has permission; trying to open channel"
|
|
||||||
)
|
|
||||||
|
|
||||||
val ccidCtx = UsbCcidContext.createFromUsbDevice(context, device, iface) ?: return@forEach
|
|
||||||
|
|
||||||
try {
|
|
||||||
val channel = tryOpenChannelFirstValidAid {
|
|
||||||
euiccChannelFactory.tryOpenUsbEuiccChannel(ccidCtx, it)
|
|
||||||
}
|
|
||||||
if (channel != null && channel.lpa.valid) {
|
|
||||||
ccidCtx.allowDisconnect = true
|
|
||||||
usbChannel = channel
|
|
||||||
return@withContext Pair(device, true)
|
|
||||||
}
|
|
||||||
} catch (e: Exception) {
|
|
||||||
// Ignored -- skip forward
|
|
||||||
e.printStackTrace()
|
|
||||||
}
|
|
||||||
|
|
||||||
ccidCtx.allowDisconnect = true
|
|
||||||
ccidCtx.disconnect()
|
|
||||||
|
|
||||||
Log.i(
|
|
||||||
TAG,
|
|
||||||
"No valid eUICC channel found on USB device ${device.deviceId}:${device.vendorId}"
|
|
||||||
)
|
|
||||||
}
|
|
||||||
return@withContext Pair(null, false)
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun invalidate() {
|
override fun invalidate() {
|
||||||
for (channel in channelCache) {
|
for (channel in channels) {
|
||||||
channel.close()
|
channel.close()
|
||||||
}
|
}
|
||||||
|
|
||||||
usbChannel?.close()
|
channels.clear()
|
||||||
usbChannel = null
|
|
||||||
channelCache.clear()
|
|
||||||
euiccChannelFactory.cleanup()
|
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,43 +1,18 @@
|
||||||
package im.angry.openeuicc.core
|
package im.angry.openeuicc.core
|
||||||
|
|
||||||
import im.angry.openeuicc.util.*
|
import im.angry.openeuicc.util.*
|
||||||
import net.typeblog.lpac_jni.ApduInterface
|
|
||||||
import net.typeblog.lpac_jni.LocalProfileAssistant
|
import net.typeblog.lpac_jni.LocalProfileAssistant
|
||||||
|
|
||||||
interface EuiccChannel {
|
abstract class EuiccChannel(
|
||||||
val type: String
|
|
||||||
|
|
||||||
val port: UiccPortInfoCompat
|
val port: UiccPortInfoCompat
|
||||||
|
) {
|
||||||
|
val slotId = port.card.physicalSlotIndex // PHYSICAL slot
|
||||||
|
val logicalSlotId = port.logicalSlotIndex
|
||||||
|
val portId = port.portIndex
|
||||||
|
|
||||||
val slotId: Int // PHYSICAL slot
|
abstract val lpa: LocalProfileAssistant
|
||||||
val logicalSlotId: Int
|
|
||||||
val portId: Int
|
|
||||||
|
|
||||||
val lpa: LocalProfileAssistant
|
|
||||||
|
|
||||||
val valid: Boolean
|
val valid: Boolean
|
||||||
|
get() = lpa.valid
|
||||||
|
|
||||||
/**
|
fun close() = lpa.close()
|
||||||
* Answer to Reset (ATR) value of the underlying interface, if any
|
}
|
||||||
*/
|
|
||||||
val atr: ByteArray?
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Intrinsic name of this channel. For device-internal SIM slots,
|
|
||||||
* this should be null; for USB readers, this should be the name of
|
|
||||||
* the reader device.
|
|
||||||
*/
|
|
||||||
val intrinsicChannelName: String?
|
|
||||||
|
|
||||||
/**
|
|
||||||
* The underlying APDU interface for this channel
|
|
||||||
*/
|
|
||||||
val apduInterface: ApduInterface
|
|
||||||
|
|
||||||
/**
|
|
||||||
* The AID of the ISD-R channel currently in use
|
|
||||||
*/
|
|
||||||
val isdrAid: ByteArray
|
|
||||||
|
|
||||||
fun close()
|
|
||||||
}
|
|
||||||
|
|
|
@ -1,17 +1,11 @@
|
||||||
package im.angry.openeuicc.core
|
package im.angry.openeuicc.core
|
||||||
|
|
||||||
import im.angry.openeuicc.core.usb.UsbCcidContext
|
|
||||||
import im.angry.openeuicc.util.*
|
import im.angry.openeuicc.util.*
|
||||||
|
|
||||||
// This class is here instead of inside DI because it contains a bit more logic than just
|
// This class is here instead of inside DI because it contains a bit more logic than just
|
||||||
// "dumb" dependency injection.
|
// "dumb" dependency injection.
|
||||||
interface EuiccChannelFactory {
|
interface EuiccChannelFactory {
|
||||||
suspend fun tryOpenEuiccChannel(port: UiccPortInfoCompat, isdrAid: ByteArray): EuiccChannel?
|
suspend fun tryOpenEuiccChannel(port: UiccPortInfoCompat): EuiccChannel?
|
||||||
|
|
||||||
fun tryOpenUsbEuiccChannel(
|
|
||||||
ccidCtx: UsbCcidContext,
|
|
||||||
isdrAid: ByteArray
|
|
||||||
): EuiccChannel?
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Release all resources used by this EuiccChannelFactory
|
* Release all resources used by this EuiccChannelFactory
|
||||||
|
|
|
@ -1,38 +0,0 @@
|
||||||
package im.angry.openeuicc.core
|
|
||||||
|
|
||||||
import im.angry.openeuicc.util.UiccPortInfoCompat
|
|
||||||
import im.angry.openeuicc.util.decodeHex
|
|
||||||
import kotlinx.coroutines.flow.Flow
|
|
||||||
import net.typeblog.lpac_jni.ApduInterface
|
|
||||||
import net.typeblog.lpac_jni.LocalProfileAssistant
|
|
||||||
import net.typeblog.lpac_jni.impl.HttpInterfaceImpl
|
|
||||||
import net.typeblog.lpac_jni.impl.LocalProfileAssistantImpl
|
|
||||||
|
|
||||||
class EuiccChannelImpl(
|
|
||||||
override val type: String,
|
|
||||||
override val port: UiccPortInfoCompat,
|
|
||||||
override val intrinsicChannelName: String?,
|
|
||||||
override val apduInterface: ApduInterface,
|
|
||||||
override val isdrAid: ByteArray,
|
|
||||||
verboseLoggingFlow: Flow<Boolean>,
|
|
||||||
ignoreTLSCertificateFlow: Flow<Boolean>
|
|
||||||
) : EuiccChannel {
|
|
||||||
override val slotId = port.card.physicalSlotIndex
|
|
||||||
override val logicalSlotId = port.logicalSlotIndex
|
|
||||||
override val portId = port.portIndex
|
|
||||||
|
|
||||||
override val lpa: LocalProfileAssistant =
|
|
||||||
LocalProfileAssistantImpl(
|
|
||||||
isdrAid,
|
|
||||||
apduInterface,
|
|
||||||
HttpInterfaceImpl(verboseLoggingFlow, ignoreTLSCertificateFlow)
|
|
||||||
)
|
|
||||||
|
|
||||||
override val atr: ByteArray?
|
|
||||||
get() = (apduInterface as? ApduInterfaceAtrProvider)?.atr
|
|
||||||
|
|
||||||
override val valid: Boolean
|
|
||||||
get() = lpa.valid
|
|
||||||
|
|
||||||
override fun close() = lpa.close()
|
|
||||||
}
|
|
|
@ -1,99 +1,38 @@
|
||||||
package im.angry.openeuicc.core
|
package im.angry.openeuicc.core
|
||||||
|
|
||||||
import android.hardware.usb.UsbDevice
|
|
||||||
import kotlinx.coroutines.flow.Flow
|
|
||||||
|
|
||||||
/**
|
|
||||||
* EuiccChannelManager holds references to, and manages the lifecycles of, individual
|
|
||||||
* APDU channels to SIM cards. The find* methods will create channels when needed, and
|
|
||||||
* all opened channels will be held in an internal cache until invalidate() is called
|
|
||||||
* or when this instance is destroyed.
|
|
||||||
*
|
|
||||||
* To precisely control the lifecycle of this object itself (and thus its cached channels),
|
|
||||||
* all other compoents must access EuiccChannelManager objects through EuiccChannelManagerService.
|
|
||||||
* Holding references independent of EuiccChannelManagerService is unsupported.
|
|
||||||
*/
|
|
||||||
interface EuiccChannelManager {
|
interface EuiccChannelManager {
|
||||||
companion object {
|
val knownChannels: List<EuiccChannel>
|
||||||
const val USB_CHANNEL_ID = 99
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Scan all possible _device internal_ sources for EuiccChannels, as a flow, return their physical
|
* Scan all possible sources for EuiccChannels and have them cached for future use
|
||||||
* (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>>
|
suspend fun enumerateEuiccChannels()
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Same as flowInternalEuiccPorts(), except that this includes non-device internal eUICC chips
|
* Returns the EuiccChannel corresponding to a **logical** slot
|
||||||
* 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>>
|
fun findEuiccChannelBySlotBlocking(logicalSlotId: Int): EuiccChannel?
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Scan all possible USB devices for CCID readers that may contain eUICC cards.
|
* Returns the first EuiccChannel corresponding to a **physical** slot
|
||||||
* If found, try to open it for access, and add it to the internal EuiccChannel cache
|
* If the physical slot supports MEP and has multiple ports, it is undefined
|
||||||
* as a "port" with id 99. When user interaction is required to obtain permission
|
* which of the two channels will be returned.
|
||||||
* 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>
|
fun findEuiccChannelByPhysicalSlotBlocking(physicalSlotId: Int): EuiccChannel?
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Wait for a slot + port to reconnect (i.e. become valid again)
|
* Returns all EuiccChannels corresponding to a **physical** slot
|
||||||
* If the port is currently valid, this function will return immediately.
|
* Multiple channels are possible in the case of MEP
|
||||||
* 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)
|
fun findAllEuiccChannelsByPhysicalSlotBlocking(physicalSlotId: Int): List<EuiccChannel>?
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Returns the first mapped & available port ID for a physical slot, or -1 if
|
* Returns the EuiccChannel corresponding to a **physical** slot and a port ID
|
||||||
* not found.
|
|
||||||
*/
|
*/
|
||||||
suspend fun findFirstAvailablePort(physicalSlotId: Int): Int
|
fun findEuiccChannelByPortBlocking(physicalSlotId: Int, portId: Int): EuiccChannel?
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Returns all mapped & available port IDs for a physical slot.
|
* Invalidate all EuiccChannels previously known by this Manager
|
||||||
*/
|
|
||||||
suspend fun findAvailablePorts(physicalSlotId: Int): List<Int>
|
|
||||||
|
|
||||||
class EuiccChannelNotFoundException: Exception("EuiccChannel not found")
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Find a EuiccChannel by its slot and port, then run a callback with a reference to it.
|
|
||||||
* The reference is not supposed to be held outside of the callback. This is enforced via
|
|
||||||
* a wrapper object.
|
|
||||||
*
|
|
||||||
* The callback is run on Dispatchers.IO by default.
|
|
||||||
*
|
|
||||||
* If a channel for that slot / port is not found, EuiccChannelNotFoundException is thrown
|
|
||||||
*/
|
|
||||||
suspend fun <R> withEuiccChannel(
|
|
||||||
physicalSlotId: Int,
|
|
||||||
portId: Int,
|
|
||||||
fn: suspend (EuiccChannel) -> R
|
|
||||||
): R
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Same as withEuiccChannel(Int, Int, (EuiccChannel) -> R) but instead uses logical slot ID
|
|
||||||
*/
|
|
||||||
suspend fun <R> withEuiccChannel(
|
|
||||||
logicalSlotId: Int,
|
|
||||||
fn: suspend (EuiccChannel) -> R
|
|
||||||
): R
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Invalidate all EuiccChannels previously cached by this Manager
|
|
||||||
*/
|
*/
|
||||||
fun invalidate()
|
fun invalidate()
|
||||||
|
|
||||||
|
@ -102,7 +41,7 @@ interface EuiccChannelManager {
|
||||||
* This is only expected to be implemented when the application is privileged
|
* This is only expected to be implemented when the application is privileged
|
||||||
* TODO: Remove this from the common interface
|
* TODO: Remove this from the common interface
|
||||||
*/
|
*/
|
||||||
suspend fun notifyEuiccProfilesChanged(logicalSlotId: Int) {
|
fun notifyEuiccProfilesChanged(logicalSlotId: Int) {
|
||||||
// no-op by default
|
// no-op by default
|
||||||
}
|
}
|
||||||
}
|
}
|
|
@ -1,7 +0,0 @@
|
||||||
package im.angry.openeuicc.core
|
|
||||||
|
|
||||||
import android.app.Service
|
|
||||||
|
|
||||||
interface EuiccChannelManagerFactory {
|
|
||||||
fun createEuiccChannelManager(serviceContext: Service): EuiccChannelManager
|
|
||||||
}
|
|
|
@ -1,53 +0,0 @@
|
||||||
package im.angry.openeuicc.core
|
|
||||||
|
|
||||||
import im.angry.openeuicc.util.*
|
|
||||||
import net.typeblog.lpac_jni.ApduInterface
|
|
||||||
import net.typeblog.lpac_jni.LocalProfileAssistant
|
|
||||||
|
|
||||||
class EuiccChannelWrapper(orig: EuiccChannel) : EuiccChannel {
|
|
||||||
private var _inner: EuiccChannel? = orig
|
|
||||||
|
|
||||||
private val channel: EuiccChannel
|
|
||||||
get() {
|
|
||||||
if (_inner == null) {
|
|
||||||
throw IllegalStateException("This wrapper has been invalidated")
|
|
||||||
}
|
|
||||||
|
|
||||||
return _inner!!
|
|
||||||
}
|
|
||||||
|
|
||||||
override val type: String
|
|
||||||
get() = channel.type
|
|
||||||
override val port: UiccPortInfoCompat
|
|
||||||
get() = channel.port
|
|
||||||
override val slotId: Int
|
|
||||||
get() = channel.slotId
|
|
||||||
override val logicalSlotId: Int
|
|
||||||
get() = channel.logicalSlotId
|
|
||||||
override val portId: Int
|
|
||||||
get() = channel.portId
|
|
||||||
private val lpaDelegate = lazy {
|
|
||||||
LocalProfileAssistantWrapper(channel.lpa)
|
|
||||||
}
|
|
||||||
override val lpa: LocalProfileAssistant by lpaDelegate
|
|
||||||
override val valid: Boolean
|
|
||||||
get() = channel.valid
|
|
||||||
override val intrinsicChannelName: String?
|
|
||||||
get() = channel.intrinsicChannelName
|
|
||||||
override val apduInterface: ApduInterface
|
|
||||||
get() = channel.apduInterface
|
|
||||||
override val atr: ByteArray?
|
|
||||||
get() = channel.atr
|
|
||||||
override val isdrAid: ByteArray
|
|
||||||
get() = channel.isdrAid
|
|
||||||
|
|
||||||
override fun close() = channel.close()
|
|
||||||
|
|
||||||
fun invalidateWrapper() {
|
|
||||||
_inner = null
|
|
||||||
|
|
||||||
if (lpaDelegate.isInitialized()) {
|
|
||||||
(lpa as LocalProfileAssistantWrapper).invalidateWrapper()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -1,66 +0,0 @@
|
||||||
package im.angry.openeuicc.core
|
|
||||||
|
|
||||||
import net.typeblog.lpac_jni.EuiccInfo2
|
|
||||||
import net.typeblog.lpac_jni.LocalProfileAssistant
|
|
||||||
import net.typeblog.lpac_jni.LocalProfileInfo
|
|
||||||
import net.typeblog.lpac_jni.LocalProfileNotification
|
|
||||||
import net.typeblog.lpac_jni.ProfileDownloadCallback
|
|
||||||
|
|
||||||
class LocalProfileAssistantWrapper(orig: LocalProfileAssistant) :
|
|
||||||
LocalProfileAssistant {
|
|
||||||
private var _inner: LocalProfileAssistant? = orig
|
|
||||||
|
|
||||||
private val lpa: LocalProfileAssistant
|
|
||||||
get() {
|
|
||||||
if (_inner == null) {
|
|
||||||
throw IllegalStateException("This wrapper has been invalidated")
|
|
||||||
}
|
|
||||||
|
|
||||||
return _inner!!
|
|
||||||
}
|
|
||||||
|
|
||||||
override val valid: Boolean
|
|
||||||
get() = lpa.valid
|
|
||||||
override val profiles: List<LocalProfileInfo>
|
|
||||||
get() = lpa.profiles
|
|
||||||
override val notifications: List<LocalProfileNotification>
|
|
||||||
get() = lpa.notifications
|
|
||||||
override val eID: String
|
|
||||||
get() = lpa.eID
|
|
||||||
override val euiccInfo2: EuiccInfo2?
|
|
||||||
get() = lpa.euiccInfo2
|
|
||||||
|
|
||||||
override fun setEs10xMss(mss: Byte) = lpa.setEs10xMss(mss)
|
|
||||||
|
|
||||||
override fun enableProfile(iccid: String, refresh: Boolean): Boolean =
|
|
||||||
lpa.enableProfile(iccid, refresh)
|
|
||||||
|
|
||||||
override fun disableProfile(iccid: String, refresh: Boolean): Boolean =
|
|
||||||
lpa.disableProfile(iccid, refresh)
|
|
||||||
|
|
||||||
override fun deleteProfile(iccid: String): Boolean = lpa.deleteProfile(iccid)
|
|
||||||
|
|
||||||
override fun downloadProfile(
|
|
||||||
smdp: String,
|
|
||||||
matchingId: String?,
|
|
||||||
imei: String?,
|
|
||||||
confirmationCode: String?,
|
|
||||||
callback: ProfileDownloadCallback
|
|
||||||
) = lpa.downloadProfile(smdp, matchingId, imei, confirmationCode, callback)
|
|
||||||
|
|
||||||
override fun deleteNotification(seqNumber: Long): Boolean = lpa.deleteNotification(seqNumber)
|
|
||||||
|
|
||||||
override fun handleNotification(seqNumber: Long): Boolean = lpa.handleNotification(seqNumber)
|
|
||||||
|
|
||||||
override fun euiccMemoryReset() = lpa.euiccMemoryReset()
|
|
||||||
|
|
||||||
override fun setNickname(iccid: String, nickname: String) {
|
|
||||||
lpa.setNickname(iccid, nickname)
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun close() = lpa.close()
|
|
||||||
|
|
||||||
fun invalidateWrapper() {
|
|
||||||
_inner = null
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -3,32 +3,18 @@ package im.angry.openeuicc.core
|
||||||
import android.se.omapi.Channel
|
import android.se.omapi.Channel
|
||||||
import android.se.omapi.SEService
|
import android.se.omapi.SEService
|
||||||
import android.se.omapi.Session
|
import android.se.omapi.Session
|
||||||
import android.util.Log
|
|
||||||
import im.angry.openeuicc.util.*
|
import im.angry.openeuicc.util.*
|
||||||
import kotlinx.coroutines.flow.Flow
|
|
||||||
import kotlinx.coroutines.flow.first
|
|
||||||
import kotlinx.coroutines.runBlocking
|
|
||||||
import net.typeblog.lpac_jni.ApduInterface
|
import net.typeblog.lpac_jni.ApduInterface
|
||||||
import java.util.concurrent.atomic.AtomicInteger
|
import net.typeblog.lpac_jni.LocalProfileAssistant
|
||||||
|
import net.typeblog.lpac_jni.impl.HttpInterfaceImpl
|
||||||
|
import net.typeblog.lpac_jni.impl.LocalProfileAssistantImpl
|
||||||
|
|
||||||
class OmapiApduInterface(
|
class OmapiApduInterface(
|
||||||
private val service: SEService,
|
private val service: SEService,
|
||||||
private val port: UiccPortInfoCompat,
|
private val port: UiccPortInfoCompat
|
||||||
private val verboseLoggingFlow: Flow<Boolean>
|
): ApduInterface {
|
||||||
): ApduInterface, ApduInterfaceAtrProvider {
|
|
||||||
companion object {
|
|
||||||
const val TAG = "OmapiApduInterface"
|
|
||||||
}
|
|
||||||
|
|
||||||
private lateinit var session: Session
|
private lateinit var session: Session
|
||||||
private val index = AtomicInteger(0)
|
private lateinit var lastChannel: Channel
|
||||||
private val channels = mutableMapOf<Int, Channel>()
|
|
||||||
|
|
||||||
override val valid: Boolean
|
|
||||||
get() = service.isConnected && (this::session.isInitialized && !session.isClosed)
|
|
||||||
|
|
||||||
override val atr: ByteArray?
|
|
||||||
get() = session.atr
|
|
||||||
|
|
||||||
override fun connect() {
|
override fun connect() {
|
||||||
session = service.getUiccReaderCompat(port.logicalSlotIndex + 1).openSession()
|
session = service.getUiccReaderCompat(port.logicalSlotIndex + 1).openSession()
|
||||||
|
@ -39,48 +25,35 @@ class OmapiApduInterface(
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun logicalChannelOpen(aid: ByteArray): Int {
|
override fun logicalChannelOpen(aid: ByteArray): Int {
|
||||||
val channel = session.openLogicalChannel(aid)
|
check(!this::lastChannel.isInitialized) {
|
||||||
check(channel != null) { "Failed to open logical channel (${aid.encodeHex()})" }
|
"Can only open one channel"
|
||||||
val handle = index.incrementAndGet()
|
}
|
||||||
synchronized(channels) { channels[handle] = channel }
|
lastChannel = session.openLogicalChannel(aid)!!;
|
||||||
return handle
|
return 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun logicalChannelClose(handle: Int) {
|
override fun logicalChannelClose(handle: Int) {
|
||||||
val channel = channels[handle]
|
check(handle == 1 && !this::lastChannel.isInitialized) {
|
||||||
check(channel != null) { "Invalid logical channel handle $handle" }
|
"Unknown channel"
|
||||||
if (channel.isOpen) channel.close()
|
}
|
||||||
synchronized(channels) { channels.remove(handle) }
|
lastChannel.close()
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun transmit(handle: Int, tx: ByteArray): ByteArray {
|
override fun transmit(tx: ByteArray): ByteArray {
|
||||||
val channel = channels[handle]
|
check(this::lastChannel.isInitialized) {
|
||||||
check(channel != null) { "Invalid logical channel handle $handle" }
|
"Unknown channel"
|
||||||
|
|
||||||
if (runBlocking { verboseLoggingFlow.first() }) {
|
|
||||||
Log.d(TAG, "OMAPI APDU: ${tx.encodeHex()}")
|
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
return lastChannel.transmit(tx)
|
||||||
for (i in 0..10) {
|
|
||||||
val res = channel.transmit(tx)
|
|
||||||
if (runBlocking { verboseLoggingFlow.first() }) {
|
|
||||||
Log.d(TAG, "OMAPI APDU response: ${res.encodeHex()}")
|
|
||||||
}
|
|
||||||
|
|
||||||
if (res.size == 2 && res[0] == 0x66.toByte() && res[1] == 0x01.toByte()) {
|
|
||||||
Log.d(TAG, "Received checksum error 0x6601, retrying (count = $i)")
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
return res
|
|
||||||
}
|
|
||||||
|
|
||||||
throw RuntimeException("Retransmit attempts exhausted; this was likely caused by checksum errors")
|
|
||||||
} catch (e: Exception) {
|
|
||||||
Log.e(TAG, "OMAPI APDU exception")
|
|
||||||
e.printStackTrace()
|
|
||||||
throw e
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
}
|
||||||
|
|
||||||
|
class OmapiChannel(
|
||||||
|
service: SEService,
|
||||||
|
port: UiccPortInfoCompat,
|
||||||
|
) : EuiccChannel(port) {
|
||||||
|
override val lpa: LocalProfileAssistant = LocalProfileAssistantImpl(
|
||||||
|
OmapiApduInterface(service, port),
|
||||||
|
HttpInterfaceImpl())
|
||||||
|
}
|
||||||
|
|
|
@ -1,157 +0,0 @@
|
||||||
package im.angry.openeuicc.core.usb
|
|
||||||
|
|
||||||
import android.util.Log
|
|
||||||
import im.angry.openeuicc.core.ApduInterfaceAtrProvider
|
|
||||||
import im.angry.openeuicc.util.*
|
|
||||||
import net.typeblog.lpac_jni.ApduInterface
|
|
||||||
|
|
||||||
class UsbApduInterface(
|
|
||||||
private val ccidCtx: UsbCcidContext
|
|
||||||
) : ApduInterface, ApduInterfaceAtrProvider {
|
|
||||||
companion object {
|
|
||||||
private const val TAG = "UsbApduInterface"
|
|
||||||
}
|
|
||||||
|
|
||||||
override val atr: ByteArray?
|
|
||||||
get() = ccidCtx.atr
|
|
||||||
|
|
||||||
override val valid: Boolean
|
|
||||||
get() = channels.isNotEmpty()
|
|
||||||
|
|
||||||
private var channels = mutableSetOf<Int>()
|
|
||||||
|
|
||||||
override fun connect() {
|
|
||||||
ccidCtx.connect()
|
|
||||||
|
|
||||||
// Send Terminal Capabilities
|
|
||||||
// Specs: ETSI TS 102 221 v15.0.0 - 11.1.19 TERMINAL CAPABILITY
|
|
||||||
val terminalCapabilities = buildCmd(
|
|
||||||
0x80.toByte(), 0xaa.toByte(), 0x00, 0x00,
|
|
||||||
"A9088100820101830107".decodeHex(),
|
|
||||||
le = null,
|
|
||||||
)
|
|
||||||
transmitApduByChannel(terminalCapabilities, 0)
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun disconnect() = ccidCtx.disconnect()
|
|
||||||
|
|
||||||
override fun logicalChannelOpen(aid: ByteArray): Int {
|
|
||||||
// OPEN LOGICAL CHANNEL
|
|
||||||
val req = manageChannelCmd(true, 0)
|
|
||||||
|
|
||||||
val resp = try {
|
|
||||||
transmitApduByChannel(req, 0)
|
|
||||||
} catch (e: Exception) {
|
|
||||||
e.printStackTrace()
|
|
||||||
return -1
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!isSuccessResponse(resp)) {
|
|
||||||
Log.d(TAG, "OPEN LOGICAL CHANNEL failed: ${resp.encodeHex()}")
|
|
||||||
return -1
|
|
||||||
}
|
|
||||||
|
|
||||||
val channelId = resp[0].toInt()
|
|
||||||
Log.d(TAG, "channelId = $channelId")
|
|
||||||
|
|
||||||
// Then, select AID
|
|
||||||
val selectAid = selectByDfCmd(aid, channelId.toByte())
|
|
||||||
val selectAidResp = transmitApduByChannel(selectAid, channelId.toByte())
|
|
||||||
|
|
||||||
if (!isSuccessResponse(selectAidResp)) {
|
|
||||||
Log.d(TAG, "Select DF failed : ${selectAidResp.encodeHex()}")
|
|
||||||
return -1
|
|
||||||
}
|
|
||||||
|
|
||||||
channels.add(channelId)
|
|
||||||
|
|
||||||
return channelId
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun logicalChannelClose(handle: Int) {
|
|
||||||
check(channels.contains(handle)) {
|
|
||||||
"Invalid logical channel handle $handle"
|
|
||||||
}
|
|
||||||
// CLOSE LOGICAL CHANNEL
|
|
||||||
val req = manageChannelCmd(false, handle.toByte())
|
|
||||||
val resp = transmitApduByChannel(req, handle.toByte())
|
|
||||||
|
|
||||||
if (!isSuccessResponse(resp)) {
|
|
||||||
Log.d(TAG, "CLOSE LOGICAL CHANNEL failed: ${resp.encodeHex()}")
|
|
||||||
}
|
|
||||||
channels.remove(handle)
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun transmit(handle: Int, tx: ByteArray): ByteArray {
|
|
||||||
check(channels.contains(handle)) {
|
|
||||||
"Invalid logical channel handle $handle"
|
|
||||||
}
|
|
||||||
return transmitApduByChannel(tx, handle.toByte())
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun isSuccessResponse(resp: ByteArray): Boolean =
|
|
||||||
resp.size >= 2 && resp[resp.size - 2] == 0x90.toByte() && resp[resp.size - 1] == 0x00.toByte()
|
|
||||||
|
|
||||||
private fun buildCmd(cla: Byte, ins: Byte, p1: Byte, p2: Byte, data: ByteArray?, le: Byte?) =
|
|
||||||
byteArrayOf(cla, ins, p1, p2).let {
|
|
||||||
if (data != null) {
|
|
||||||
it + data.size.toByte() + data
|
|
||||||
} else {
|
|
||||||
it
|
|
||||||
}
|
|
||||||
}.let {
|
|
||||||
if (le != null) {
|
|
||||||
it + byteArrayOf(le)
|
|
||||||
} else {
|
|
||||||
it
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun manageChannelCmd(open: Boolean, channel: Byte) =
|
|
||||||
if (open) {
|
|
||||||
buildCmd(0x00, 0x70, 0x00, 0x00, null, 0x01)
|
|
||||||
} else {
|
|
||||||
buildCmd(channel, 0x70, 0x80.toByte(), channel, null, null)
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun selectByDfCmd(aid: ByteArray, channel: Byte) =
|
|
||||||
buildCmd(channel, 0xA4.toByte(), 0x04, 0x00, aid, null)
|
|
||||||
|
|
||||||
private fun transmitApduByChannel(tx: ByteArray, channel: Byte): ByteArray {
|
|
||||||
val realTx = tx.copyOf()
|
|
||||||
// OR the channel mask into the CLA byte
|
|
||||||
realTx[0] = ((realTx[0].toInt() and 0xFC) or channel.toInt()).toByte()
|
|
||||||
|
|
||||||
var resp = ccidCtx.transceiver.sendXfrBlock(realTx).data!!
|
|
||||||
|
|
||||||
if (resp.size < 2) throw RuntimeException("APDU response smaller than 2 (sw1 + sw2)!")
|
|
||||||
|
|
||||||
var sw1 = resp[resp.size - 2].toInt() and 0xFF
|
|
||||||
var sw2 = resp[resp.size - 1].toInt() and 0xFF
|
|
||||||
|
|
||||||
if (sw1 == 0x6C) {
|
|
||||||
// 0x6C = wrong le
|
|
||||||
// so we fix the le field here
|
|
||||||
realTx[realTx.size - 1] = resp[resp.size - 1]
|
|
||||||
resp = ccidCtx.transceiver.sendXfrBlock(realTx).data!!
|
|
||||||
} else if (sw1 == 0x61) {
|
|
||||||
// 0x61 = X bytes available
|
|
||||||
// continue reading by GET RESPONSE
|
|
||||||
do {
|
|
||||||
// GET RESPONSE
|
|
||||||
val getResponseCmd = byteArrayOf(
|
|
||||||
realTx[0], 0xC0.toByte(), 0x00, 0x00, sw2.toByte()
|
|
||||||
)
|
|
||||||
|
|
||||||
val tmp = ccidCtx.transceiver.sendXfrBlock(getResponseCmd).data!!
|
|
||||||
|
|
||||||
resp = resp.sliceArray(0 until (resp.size - 2)) + tmp
|
|
||||||
|
|
||||||
sw1 = resp[resp.size - 2].toInt() and 0xFF
|
|
||||||
sw2 = resp[resp.size - 1].toInt() and 0xFF
|
|
||||||
} while (sw1 == 0x61)
|
|
||||||
}
|
|
||||||
|
|
||||||
return resp
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -1,87 +0,0 @@
|
||||||
package im.angry.openeuicc.core.usb
|
|
||||||
|
|
||||||
import android.content.Context
|
|
||||||
import android.hardware.usb.UsbDevice
|
|
||||||
import android.hardware.usb.UsbDeviceConnection
|
|
||||||
import android.hardware.usb.UsbEndpoint
|
|
||||||
import android.hardware.usb.UsbInterface
|
|
||||||
import android.hardware.usb.UsbManager
|
|
||||||
import im.angry.openeuicc.util.preferenceRepository
|
|
||||||
import kotlinx.coroutines.flow.Flow
|
|
||||||
|
|
||||||
/**
|
|
||||||
* A wrapper over an usb device + interface, manages the lifecycle independent
|
|
||||||
* of the APDU interface exposed to lpac-jni.
|
|
||||||
*
|
|
||||||
* This allows us to try multiple AIDs on each interface without opening / closing
|
|
||||||
* the USB connection numerous times.
|
|
||||||
*/
|
|
||||||
class UsbCcidContext private constructor(
|
|
||||||
private val conn: UsbDeviceConnection,
|
|
||||||
private val bulkIn: UsbEndpoint,
|
|
||||||
private val bulkOut: UsbEndpoint,
|
|
||||||
val productName: String,
|
|
||||||
val verboseLoggingFlow: Flow<Boolean>
|
|
||||||
) {
|
|
||||||
companion object {
|
|
||||||
fun createFromUsbDevice(
|
|
||||||
context: Context,
|
|
||||||
usbDevice: UsbDevice,
|
|
||||||
usbInterface: UsbInterface
|
|
||||||
): UsbCcidContext? = runCatching {
|
|
||||||
val (bulkIn, bulkOut) = usbInterface.endpoints.bulkPair
|
|
||||||
if (bulkIn == null || bulkOut == null) return@runCatching null
|
|
||||||
val conn = context.getSystemService(UsbManager::class.java).openDevice(usbDevice)
|
|
||||||
?: return@runCatching null
|
|
||||||
if (!conn.claimInterface(usbInterface, true)) return@runCatching null
|
|
||||||
UsbCcidContext(
|
|
||||||
conn,
|
|
||||||
bulkIn,
|
|
||||||
bulkOut,
|
|
||||||
usbDevice.productName ?: "USB",
|
|
||||||
context.preferenceRepository.verboseLoggingFlow
|
|
||||||
)
|
|
||||||
}.getOrNull()
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* When set to false (the default), the disconnect() method does nothing.
|
|
||||||
* This allows the separation of device disconnection from lpac-jni's APDU interface.
|
|
||||||
*/
|
|
||||||
var allowDisconnect = false
|
|
||||||
private var initialized = false
|
|
||||||
lateinit var transceiver: UsbCcidTransceiver
|
|
||||||
var atr: ByteArray? = null
|
|
||||||
|
|
||||||
fun connect() {
|
|
||||||
if (initialized) {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
val ccidDescription = UsbCcidDescription.fromRawDescriptors(conn.rawDescriptors)!!
|
|
||||||
|
|
||||||
if (!ccidDescription.hasT0Protocol) {
|
|
||||||
throw IllegalArgumentException("Unsupported card reader; T=0 support is required")
|
|
||||||
}
|
|
||||||
|
|
||||||
transceiver = UsbCcidTransceiver(conn, bulkIn, bulkOut, ccidDescription, verboseLoggingFlow)
|
|
||||||
|
|
||||||
try {
|
|
||||||
// 6.1.1.1 PC_to_RDR_IccPowerOn (Page 20 of 40)
|
|
||||||
// https://www.usb.org/sites/default/files/DWG_Smart-Card_USB-ICC_ICCD_rev10.pdf
|
|
||||||
atr = transceiver.iccPowerOn().data
|
|
||||||
} catch (e: Exception) {
|
|
||||||
e.printStackTrace()
|
|
||||||
throw e
|
|
||||||
}
|
|
||||||
|
|
||||||
initialized = true
|
|
||||||
}
|
|
||||||
|
|
||||||
fun disconnect() {
|
|
||||||
if (initialized && allowDisconnect) {
|
|
||||||
conn.close()
|
|
||||||
atr = null
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -1,98 +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 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,345 +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.*
|
|
||||||
import kotlinx.coroutines.flow.Flow
|
|
||||||
import kotlinx.coroutines.flow.first
|
|
||||||
import kotlinx.coroutines.runBlocking
|
|
||||||
import java.nio.ByteBuffer
|
|
||||||
import java.nio.ByteOrder
|
|
||||||
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Provides raw, APDU-agnostic transmission to the CCID reader
|
|
||||||
* Adapted from <https://github.com/open-keychain/open-keychain/blob/master/OpenKeychain/src/main/java/org/sufficientlysecure/keychain/securitytoken/usb/CcidTransceiver.java>
|
|
||||||
*/
|
|
||||||
@Suppress("unused")
|
|
||||||
class UsbCcidTransceiver(
|
|
||||||
private val usbConnection: UsbDeviceConnection,
|
|
||||||
private val usbBulkIn: UsbEndpoint,
|
|
||||||
private val usbBulkOut: UsbEndpoint,
|
|
||||||
private val usbCcidDescription: UsbCcidDescription,
|
|
||||||
private val verboseLoggingFlow: Flow<Boolean>
|
|
||||||
) {
|
|
||||||
companion object {
|
|
||||||
private const val TAG = "UsbCcidTransceiver"
|
|
||||||
|
|
||||||
private const val CCID_HEADER_LENGTH = 10
|
|
||||||
|
|
||||||
private const val MESSAGE_TYPE_RDR_TO_PC_DATA_BLOCK = 0x80
|
|
||||||
private const val MESSAGE_TYPE_PC_TO_RDR_ICC_POWER_ON = 0x62
|
|
||||||
private const val MESSAGE_TYPE_PC_TO_RDR_ICC_POWER_OFF = 0x63
|
|
||||||
private const val MESSAGE_TYPE_PC_TO_RDR_XFR_BLOCK = 0x6f
|
|
||||||
|
|
||||||
private const val COMMAND_STATUS_SUCCESS: Byte = 0
|
|
||||||
private const val COMMAND_STATUS_TIME_EXTENSION_RQUESTED: Byte = 2
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Level Parameter: APDU is a single command.
|
|
||||||
*
|
|
||||||
* "the command APDU begins and ends with this command"
|
|
||||||
* -- DWG Smart-Card USB Integrated Circuit(s) Card Devices rev 1.0
|
|
||||||
* § 6.1.1.3
|
|
||||||
*/
|
|
||||||
const val LEVEL_PARAM_START_SINGLE_CMD_APDU: Short = 0x0000
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Level Parameter: First APDU in a multi-command APDU.
|
|
||||||
*
|
|
||||||
* "the command APDU begins with this command, and continue in the
|
|
||||||
* next PC_to_RDR_XfrBlock"
|
|
||||||
* -- DWG Smart-Card USB Integrated Circuit(s) Card Devices rev 1.0
|
|
||||||
* § 6.1.1.3
|
|
||||||
*/
|
|
||||||
const val LEVEL_PARAM_START_MULTI_CMD_APDU: Short = 0x0001
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Level Parameter: Final APDU in a multi-command APDU.
|
|
||||||
*
|
|
||||||
* "this abData field continues a command APDU and ends the command APDU"
|
|
||||||
* -- DWG Smart-Card USB Integrated Circuit(s) Card Devices rev 1.0
|
|
||||||
* § 6.1.1.3
|
|
||||||
*/
|
|
||||||
const val LEVEL_PARAM_END_MULTI_CMD_APDU: Short = 0x0002
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Level Parameter: Next command in a multi-command APDU.
|
|
||||||
*
|
|
||||||
* "the abData field continues a command APDU and another block is to follow"
|
|
||||||
* -- DWG Smart-Card USB Integrated Circuit(s) Card Devices rev 1.0
|
|
||||||
* § 6.1.1.3
|
|
||||||
*/
|
|
||||||
const val LEVEL_PARAM_CONTINUE_MULTI_CMD_APDU: Short = 0x0003
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Level Parameter: Request the device continue sending APDU.
|
|
||||||
*
|
|
||||||
* "empty abData field, continuation of response APDU is expected in the next
|
|
||||||
* RDR_to_PC_DataBlock"
|
|
||||||
* -- DWG Smart-Card USB Integrated Circuit(s) Card Devices rev 1.0
|
|
||||||
* § 6.1.1.3
|
|
||||||
*/
|
|
||||||
const val LEVEL_PARAM_CONTINUE_RESPONSE: Short = 0x0010
|
|
||||||
|
|
||||||
private const val SLOT_NUMBER = 0x00
|
|
||||||
|
|
||||||
private const val ICC_STATUS_SUCCESS: Byte = 0
|
|
||||||
|
|
||||||
private const val DEVICE_COMMUNICATE_TIMEOUT_MILLIS = 5000
|
|
||||||
private const val DEVICE_SKIP_TIMEOUT_MILLIS = 100
|
|
||||||
}
|
|
||||||
|
|
||||||
data class UsbCcidErrorException(val msg: String, val errorResponse: CcidDataBlock) :
|
|
||||||
Exception(msg)
|
|
||||||
|
|
||||||
@Suppress("ArrayInDataClass")
|
|
||||||
data class CcidDataBlock(
|
|
||||||
val dwLength: Int,
|
|
||||||
val bSlot: Byte,
|
|
||||||
val bSeq: Byte,
|
|
||||||
val bStatus: Byte,
|
|
||||||
val bError: Byte,
|
|
||||||
val bChainParameter: Byte,
|
|
||||||
val data: ByteArray?
|
|
||||||
) {
|
|
||||||
companion object {
|
|
||||||
fun parseHeaderFromBytes(headerBytes: ByteArray): CcidDataBlock {
|
|
||||||
val buf = ByteBuffer.wrap(headerBytes)
|
|
||||||
buf.order(ByteOrder.LITTLE_ENDIAN)
|
|
||||||
|
|
||||||
val type = buf.get()
|
|
||||||
require(type == MESSAGE_TYPE_RDR_TO_PC_DATA_BLOCK.toByte()) { "Header has incorrect type value!" }
|
|
||||||
val dwLength = buf.int
|
|
||||||
val bSlot = buf.get()
|
|
||||||
val bSeq = buf.get()
|
|
||||||
val bStatus = buf.get()
|
|
||||||
val bError = buf.get()
|
|
||||||
val bChainParameter = buf.get()
|
|
||||||
|
|
||||||
return CcidDataBlock(dwLength, bSlot, bSeq, bStatus, bError, bChainParameter, null)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fun withData(d: ByteArray): CcidDataBlock {
|
|
||||||
require(data == null) { "Cannot add data twice" }
|
|
||||||
return CcidDataBlock(dwLength, bSlot, bSeq, bStatus, bError, bChainParameter, d)
|
|
||||||
}
|
|
||||||
|
|
||||||
val iccStatus: Byte
|
|
||||||
get() = (bStatus.toInt() and 0x03).toByte()
|
|
||||||
|
|
||||||
val commandStatus: Byte
|
|
||||||
get() = ((bStatus.toInt() shr 6) and 0x03).toByte()
|
|
||||||
|
|
||||||
val isStatusTimeoutExtensionRequest: Boolean
|
|
||||||
get() = commandStatus == COMMAND_STATUS_TIME_EXTENSION_RQUESTED
|
|
||||||
|
|
||||||
val isStatusSuccess: Boolean
|
|
||||||
get() = iccStatus == ICC_STATUS_SUCCESS && commandStatus == COMMAND_STATUS_SUCCESS
|
|
||||||
}
|
|
||||||
|
|
||||||
val hasAutomaticPps = usbCcidDescription.hasAutomaticPps
|
|
||||||
|
|
||||||
private val inputBuffer = ByteArray(usbBulkIn.maxPacketSize)
|
|
||||||
|
|
||||||
private var currentSequenceNumber: Byte = 0
|
|
||||||
|
|
||||||
private fun sendRaw(data: ByteArray, offset: Int, length: Int) {
|
|
||||||
val tr1 = usbConnection.bulkTransfer(
|
|
||||||
usbBulkOut, data, offset, length, DEVICE_COMMUNICATE_TIMEOUT_MILLIS
|
|
||||||
)
|
|
||||||
if (tr1 != length) {
|
|
||||||
throw UsbTransportException(
|
|
||||||
"USB error - failed to transmit data ($tr1/$length)"
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun receiveDataBlock(expectedSequenceNumber: Byte): CcidDataBlock {
|
|
||||||
var response: CcidDataBlock?
|
|
||||||
do {
|
|
||||||
response = receiveDataBlockImmediate(expectedSequenceNumber)
|
|
||||||
} while (response!!.isStatusTimeoutExtensionRequest)
|
|
||||||
if (!response.isStatusSuccess) {
|
|
||||||
throw UsbCcidErrorException("USB-CCID error!", response)
|
|
||||||
}
|
|
||||||
return response
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun receiveDataBlockImmediate(expectedSequenceNumber: Byte): CcidDataBlock {
|
|
||||||
/*
|
|
||||||
* Some USB CCID devices (notably NitroKey 3) may time-out and need a subsequent poke to
|
|
||||||
* carry on communications. No particular reason why the number 3 was chosen. If we get a
|
|
||||||
* zero-sized reply (or a time-out), we try again. Clamped retries prevent an infinite loop
|
|
||||||
* if things really turn sour.
|
|
||||||
*/
|
|
||||||
var attempts = 3
|
|
||||||
Log.d(TAG, "Receive data block immediate seq=$expectedSequenceNumber")
|
|
||||||
var readBytes: Int
|
|
||||||
do {
|
|
||||||
readBytes = usbConnection.bulkTransfer(
|
|
||||||
usbBulkIn, inputBuffer, inputBuffer.size, DEVICE_COMMUNICATE_TIMEOUT_MILLIS
|
|
||||||
)
|
|
||||||
if (runBlocking { verboseLoggingFlow.first() }) {
|
|
||||||
Log.d(TAG, "Received $readBytes bytes: ${inputBuffer.encodeHex()}")
|
|
||||||
}
|
|
||||||
} while (readBytes <= 0 && attempts-- > 0)
|
|
||||||
if (readBytes < CCID_HEADER_LENGTH) {
|
|
||||||
throw UsbTransportException("USB-CCID error - failed to receive CCID header")
|
|
||||||
}
|
|
||||||
if (inputBuffer[0] != MESSAGE_TYPE_RDR_TO_PC_DATA_BLOCK.toByte()) {
|
|
||||||
throw UsbTransportException(buildString {
|
|
||||||
append("USB-CCID error - bad CCID header")
|
|
||||||
append(", type ")
|
|
||||||
append("%d (expected %d)".format(inputBuffer[0], MESSAGE_TYPE_RDR_TO_PC_DATA_BLOCK))
|
|
||||||
if (expectedSequenceNumber != inputBuffer[6]) {
|
|
||||||
append(", sequence number ")
|
|
||||||
append("%d (expected %d)".format(inputBuffer[6], expectedSequenceNumber))
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
var result = CcidDataBlock.parseHeaderFromBytes(inputBuffer)
|
|
||||||
if (expectedSequenceNumber != result.bSeq) {
|
|
||||||
throw UsbTransportException("USB-CCID error - expected sequence number $expectedSequenceNumber, got $result")
|
|
||||||
}
|
|
||||||
|
|
||||||
val dataBuffer = ByteArray(result.dwLength)
|
|
||||||
var bufferedBytes = readBytes - CCID_HEADER_LENGTH
|
|
||||||
System.arraycopy(inputBuffer, CCID_HEADER_LENGTH, dataBuffer, 0, bufferedBytes)
|
|
||||||
while (bufferedBytes < dataBuffer.size) {
|
|
||||||
readBytes = usbConnection.bulkTransfer(
|
|
||||||
usbBulkIn, inputBuffer, inputBuffer.size, DEVICE_COMMUNICATE_TIMEOUT_MILLIS
|
|
||||||
)
|
|
||||||
if (readBytes < 0) {
|
|
||||||
throw UsbTransportException("USB error - failed reading response data! Header: $result")
|
|
||||||
}
|
|
||||||
System.arraycopy(inputBuffer, 0, dataBuffer, bufferedBytes, readBytes)
|
|
||||||
bufferedBytes += readBytes
|
|
||||||
}
|
|
||||||
result = result.withData(dataBuffer)
|
|
||||||
return result
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
private fun skipAvailableInput() {
|
|
||||||
var ignoredBytes: Int
|
|
||||||
do {
|
|
||||||
ignoredBytes = usbConnection.bulkTransfer(
|
|
||||||
usbBulkIn, inputBuffer, inputBuffer.size, DEVICE_SKIP_TIMEOUT_MILLIS
|
|
||||||
)
|
|
||||||
if (ignoredBytes > 0) {
|
|
||||||
Log.e(TAG, "Skipped $ignoredBytes bytes")
|
|
||||||
}
|
|
||||||
} while (ignoredBytes > 0)
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Receives a continued XfrBlock. Should be called when a multiblock response is indicated
|
|
||||||
* 6.1.4 PC_to_RDR_XfrBlock
|
|
||||||
*/
|
|
||||||
fun receiveContinuedResponse(): CcidDataBlock {
|
|
||||||
return sendXfrBlock(ByteArray(0), LEVEL_PARAM_CONTINUE_RESPONSE)
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Transmits XfrBlock
|
|
||||||
* 6.1.4 PC_to_RDR_XfrBlock
|
|
||||||
*
|
|
||||||
* @param payload payload to transmit
|
|
||||||
* @param levelParam Level parameter
|
|
||||||
*/
|
|
||||||
fun sendXfrBlock(
|
|
||||||
payload: ByteArray,
|
|
||||||
levelParam: Short = LEVEL_PARAM_START_SINGLE_CMD_APDU
|
|
||||||
): CcidDataBlock {
|
|
||||||
val startTime = SystemClock.elapsedRealtime()
|
|
||||||
val l = payload.size
|
|
||||||
val sequenceNumber: Byte = currentSequenceNumber++
|
|
||||||
val headerData = byteArrayOf(
|
|
||||||
MESSAGE_TYPE_PC_TO_RDR_XFR_BLOCK.toByte(),
|
|
||||||
l.toByte(),
|
|
||||||
(l shr 8).toByte(),
|
|
||||||
(l shr 16).toByte(),
|
|
||||||
(l shr 24).toByte(),
|
|
||||||
SLOT_NUMBER.toByte(),
|
|
||||||
sequenceNumber,
|
|
||||||
0x00.toByte(),
|
|
||||||
(levelParam.toInt() and 0x00ff).toByte(),
|
|
||||||
(levelParam.toInt() shr 8).toByte()
|
|
||||||
)
|
|
||||||
val data: ByteArray = headerData + payload
|
|
||||||
var sentBytes = 0
|
|
||||||
while (sentBytes < data.size) {
|
|
||||||
val bytesToSend = usbBulkOut.maxPacketSize.coerceAtMost(data.size - sentBytes)
|
|
||||||
sendRaw(data, sentBytes, bytesToSend)
|
|
||||||
sentBytes += bytesToSend
|
|
||||||
}
|
|
||||||
val ccidDataBlock = receiveDataBlock(sequenceNumber)
|
|
||||||
val elapsedTime = SystemClock.elapsedRealtime() - startTime
|
|
||||||
Log.d(TAG, "USB XferBlock call took ${elapsedTime}ms")
|
|
||||||
return ccidDataBlock
|
|
||||||
}
|
|
||||||
|
|
||||||
fun iccPowerOn(): CcidDataBlock {
|
|
||||||
val startTime = SystemClock.elapsedRealtime()
|
|
||||||
skipAvailableInput()
|
|
||||||
var response: CcidDataBlock? = null
|
|
||||||
for (voltage in usbCcidDescription.voltages) {
|
|
||||||
Log.v(TAG, "CCID: attempting to power on with voltage $voltage")
|
|
||||||
response = try {
|
|
||||||
iccPowerOnVoltage(voltage.powerOnValue)
|
|
||||||
} catch (e: UsbCcidErrorException) {
|
|
||||||
if (e.errorResponse.bError.toInt() == 7) { // Power select error
|
|
||||||
Log.v(TAG, "CCID: failed to power on with voltage $voltage")
|
|
||||||
iccPowerOff()
|
|
||||||
Log.v(TAG, "CCID: powered off")
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
throw e
|
|
||||||
}
|
|
||||||
break
|
|
||||||
}
|
|
||||||
if (response == null) {
|
|
||||||
throw UsbTransportException("Couldn't power up ICC2")
|
|
||||||
}
|
|
||||||
val elapsedTime = SystemClock.elapsedRealtime() - startTime
|
|
||||||
Log.d(
|
|
||||||
TAG,
|
|
||||||
buildString {
|
|
||||||
append("Usb transport connected")
|
|
||||||
append(", took ", elapsedTime, "ms")
|
|
||||||
append(", ATR=", response.data?.encodeHex())
|
|
||||||
}
|
|
||||||
)
|
|
||||||
return response
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun iccPowerOnVoltage(voltage: Byte): CcidDataBlock {
|
|
||||||
val sequenceNumber = currentSequenceNumber++
|
|
||||||
val iccPowerCommand = byteArrayOf(
|
|
||||||
MESSAGE_TYPE_PC_TO_RDR_ICC_POWER_ON.toByte(),
|
|
||||||
0x00, 0x00, 0x00, 0x00,
|
|
||||||
SLOT_NUMBER.toByte(),
|
|
||||||
sequenceNumber,
|
|
||||||
voltage,
|
|
||||||
0x00, 0x00 // reserved for future use
|
|
||||||
)
|
|
||||||
sendRaw(iccPowerCommand, 0, iccPowerCommand.size)
|
|
||||||
return receiveDataBlock(sequenceNumber)
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun iccPowerOff() {
|
|
||||||
val sequenceNumber = currentSequenceNumber++
|
|
||||||
val iccPowerCommand = byteArrayOf(
|
|
||||||
MESSAGE_TYPE_PC_TO_RDR_ICC_POWER_OFF.toByte(),
|
|
||||||
0x00, 0x00, 0x00, 0x00,
|
|
||||||
0x00,
|
|
||||||
sequenceNumber,
|
|
||||||
0x00
|
|
||||||
)
|
|
||||||
sendRaw(iccPowerCommand, 0, iccPowerCommand.size)
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -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 },
|
|
||||||
)
|
|
||||||
}
|
|
|
@ -4,16 +4,13 @@ import android.telephony.SubscriptionManager
|
||||||
import android.telephony.TelephonyManager
|
import android.telephony.TelephonyManager
|
||||||
import im.angry.openeuicc.core.EuiccChannelFactory
|
import im.angry.openeuicc.core.EuiccChannelFactory
|
||||||
import im.angry.openeuicc.core.EuiccChannelManager
|
import im.angry.openeuicc.core.EuiccChannelManager
|
||||||
import im.angry.openeuicc.core.EuiccChannelManagerFactory
|
|
||||||
import im.angry.openeuicc.util.*
|
import im.angry.openeuicc.util.*
|
||||||
|
|
||||||
interface AppContainer {
|
interface AppContainer {
|
||||||
val telephonyManager: TelephonyManager
|
val telephonyManager: TelephonyManager
|
||||||
val euiccChannelManager: EuiccChannelManager
|
val euiccChannelManager: EuiccChannelManager
|
||||||
val euiccChannelManagerFactory: EuiccChannelManagerFactory
|
|
||||||
val subscriptionManager: SubscriptionManager
|
val subscriptionManager: SubscriptionManager
|
||||||
val preferenceRepository: PreferenceRepository
|
val preferenceRepository: PreferenceRepository
|
||||||
val uiComponentFactory: UiComponentFactory
|
val uiComponentFactory: UiComponentFactory
|
||||||
val euiccChannelFactory: EuiccChannelFactory
|
val euiccChannelFactory: EuiccChannelFactory
|
||||||
val customizableTextProvider: CustomizableTextProvider
|
|
||||||
}
|
}
|
|
@ -1,20 +0,0 @@
|
||||||
package im.angry.openeuicc.di
|
|
||||||
|
|
||||||
interface CustomizableTextProvider {
|
|
||||||
/**
|
|
||||||
* Explanation string for when no eUICC is found on the device.
|
|
||||||
* This could be different depending on whether the app is privileged or not.
|
|
||||||
*/
|
|
||||||
val noEuiccExplanation: String
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Shown when we timed out switching between profiles.
|
|
||||||
*/
|
|
||||||
val profileSwitchingTimeoutMessage: String
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Format the name of a logical slot; internal only -- not intended for
|
|
||||||
* other channels such as USB.
|
|
||||||
*/
|
|
||||||
fun formatInternalChannelName(logicalSlotId: Int): String
|
|
||||||
}
|
|
|
@ -5,9 +5,7 @@ import android.telephony.SubscriptionManager
|
||||||
import android.telephony.TelephonyManager
|
import android.telephony.TelephonyManager
|
||||||
import im.angry.openeuicc.core.DefaultEuiccChannelFactory
|
import im.angry.openeuicc.core.DefaultEuiccChannelFactory
|
||||||
import im.angry.openeuicc.core.DefaultEuiccChannelManager
|
import im.angry.openeuicc.core.DefaultEuiccChannelManager
|
||||||
import im.angry.openeuicc.core.DefaultEuiccChannelManagerFactory
|
|
||||||
import im.angry.openeuicc.core.EuiccChannelManager
|
import im.angry.openeuicc.core.EuiccChannelManager
|
||||||
import im.angry.openeuicc.core.EuiccChannelManagerFactory
|
|
||||||
import im.angry.openeuicc.util.*
|
import im.angry.openeuicc.util.*
|
||||||
|
|
||||||
open class DefaultAppContainer(context: Context) : AppContainer {
|
open class DefaultAppContainer(context: Context) : AppContainer {
|
||||||
|
@ -19,10 +17,6 @@ open class DefaultAppContainer(context: Context) : AppContainer {
|
||||||
DefaultEuiccChannelManager(this, context)
|
DefaultEuiccChannelManager(this, context)
|
||||||
}
|
}
|
||||||
|
|
||||||
override val euiccChannelManagerFactory: EuiccChannelManagerFactory by lazy {
|
|
||||||
DefaultEuiccChannelManagerFactory(this)
|
|
||||||
}
|
|
||||||
|
|
||||||
override val subscriptionManager by lazy {
|
override val subscriptionManager by lazy {
|
||||||
context.getSystemService(SubscriptionManager::class.java)!!
|
context.getSystemService(SubscriptionManager::class.java)!!
|
||||||
}
|
}
|
||||||
|
@ -38,8 +32,4 @@ open class DefaultAppContainer(context: Context) : AppContainer {
|
||||||
override val euiccChannelFactory by lazy {
|
override val euiccChannelFactory by lazy {
|
||||||
DefaultEuiccChannelFactory(context)
|
DefaultEuiccChannelFactory(context)
|
||||||
}
|
}
|
||||||
|
|
||||||
override val customizableTextProvider by lazy {
|
|
||||||
DefaultCustomizableTextProvider(context)
|
|
||||||
}
|
|
||||||
}
|
}
|
|
@ -1,15 +0,0 @@
|
||||||
package im.angry.openeuicc.di
|
|
||||||
|
|
||||||
import android.content.Context
|
|
||||||
import im.angry.openeuicc.common.R
|
|
||||||
|
|
||||||
open class DefaultCustomizableTextProvider(private val context: Context) : CustomizableTextProvider {
|
|
||||||
override val noEuiccExplanation: String
|
|
||||||
get() = context.getString(R.string.no_euicc)
|
|
||||||
|
|
||||||
override val profileSwitchingTimeoutMessage: String
|
|
||||||
get() = context.getString(R.string.enable_disable_timeout)
|
|
||||||
|
|
||||||
override fun formatInternalChannelName(logicalSlotId: Int): String =
|
|
||||||
context.getString(R.string.channel_name_format, logicalSlotId)
|
|
||||||
}
|
|
|
@ -1,16 +1,9 @@
|
||||||
package im.angry.openeuicc.di
|
package im.angry.openeuicc.di
|
||||||
|
|
||||||
import androidx.fragment.app.Fragment
|
import im.angry.openeuicc.core.EuiccChannel
|
||||||
import androidx.preference.PreferenceFragmentCompat
|
|
||||||
import im.angry.openeuicc.ui.EuiccManagementFragment
|
import im.angry.openeuicc.ui.EuiccManagementFragment
|
||||||
import im.angry.openeuicc.ui.NoEuiccPlaceholderFragment
|
|
||||||
import im.angry.openeuicc.ui.SettingsFragment
|
|
||||||
|
|
||||||
open class DefaultUiComponentFactory : UiComponentFactory {
|
open class DefaultUiComponentFactory : UiComponentFactory {
|
||||||
override fun createEuiccManagementFragment(slotId: Int, portId: Int): EuiccManagementFragment =
|
override fun createEuiccManagementFragment(channel: EuiccChannel): EuiccManagementFragment =
|
||||||
EuiccManagementFragment.newInstance(slotId, portId)
|
EuiccManagementFragment.newInstance(channel.slotId, channel.portId)
|
||||||
|
|
||||||
override fun createNoEuiccPlaceholderFragment(): Fragment = NoEuiccPlaceholderFragment()
|
|
||||||
|
|
||||||
override fun createSettingsFragment(): Fragment = SettingsFragment()
|
|
||||||
}
|
}
|
|
@ -1,11 +1,8 @@
|
||||||
package im.angry.openeuicc.di
|
package im.angry.openeuicc.di
|
||||||
|
|
||||||
import androidx.fragment.app.Fragment
|
import im.angry.openeuicc.core.EuiccChannel
|
||||||
import androidx.preference.PreferenceFragmentCompat
|
|
||||||
import im.angry.openeuicc.ui.EuiccManagementFragment
|
import im.angry.openeuicc.ui.EuiccManagementFragment
|
||||||
|
|
||||||
interface UiComponentFactory {
|
interface UiComponentFactory {
|
||||||
fun createEuiccManagementFragment(slotId: Int, portId: Int): EuiccManagementFragment
|
fun createEuiccManagementFragment(channel: EuiccChannel): EuiccManagementFragment
|
||||||
fun createNoEuiccPlaceholderFragment(): Fragment
|
|
||||||
fun createSettingsFragment(): Fragment
|
|
||||||
}
|
}
|
|
@ -1,527 +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.EuiccChannelManager
|
|
||||||
import im.angry.openeuicc.util.*
|
|
||||||
import kotlinx.coroutines.Dispatchers
|
|
||||||
import kotlinx.coroutines.NonCancellable
|
|
||||||
import kotlinx.coroutines.channels.BufferOverflow
|
|
||||||
import kotlinx.coroutines.delay
|
|
||||||
import kotlinx.coroutines.flow.Flow
|
|
||||||
import kotlinx.coroutines.flow.MutableSharedFlow
|
|
||||||
import kotlinx.coroutines.flow.MutableStateFlow
|
|
||||||
import kotlinx.coroutines.flow.SharedFlow
|
|
||||||
import kotlinx.coroutines.flow.asSharedFlow
|
|
||||||
import kotlinx.coroutines.flow.collect
|
|
||||||
import kotlinx.coroutines.flow.first
|
|
||||||
import kotlinx.coroutines.flow.flow
|
|
||||||
import kotlinx.coroutines.flow.last
|
|
||||||
import kotlinx.coroutines.flow.onCompletion
|
|
||||||
import kotlinx.coroutines.flow.onEach
|
|
||||||
import kotlinx.coroutines.flow.takeWhile
|
|
||||||
import kotlinx.coroutines.flow.transformWhile
|
|
||||||
import kotlinx.coroutines.isActive
|
|
||||||
import kotlinx.coroutines.launch
|
|
||||||
import kotlinx.coroutines.withContext
|
|
||||||
import kotlinx.coroutines.withTimeoutOrNull
|
|
||||||
import kotlinx.coroutines.yield
|
|
||||||
import net.typeblog.lpac_jni.ProfileDownloadCallback
|
|
||||||
|
|
||||||
/**
|
|
||||||
* An Android Service wrapper for EuiccChannelManager.
|
|
||||||
* The purpose of this wrapper is mainly lifecycle-wise: having a Service allows the manager
|
|
||||||
* instance to have its own independent lifecycle. This way it can be created as requested and
|
|
||||||
* destroyed when no other components are bound to this service anymore.
|
|
||||||
* This behavior allows us to avoid keeping the APDU channels open at all times. For example,
|
|
||||||
* the EuiccService implementation should *only* bind to this service when it requires an
|
|
||||||
* instance of EuiccChannelManager. UI components can keep being bound to this service for
|
|
||||||
* their entire lifecycles, since the whole purpose of them is to expose the current state
|
|
||||||
* to the user.
|
|
||||||
*
|
|
||||||
* Additionally, this service is also responsible for long-running "foreground" tasks that
|
|
||||||
* are not suitable to be managed by UI components. This includes profile downloading, etc.
|
|
||||||
* When a UI component needs to run one of these tasks, they have to bind to this service
|
|
||||||
* and call one of the `launch*` methods, which will run the task inside this service's
|
|
||||||
* lifecycle context and return a Flow instance for the UI component to subscribe to its
|
|
||||||
* progress.
|
|
||||||
*/
|
|
||||||
class EuiccChannelManagerService : LifecycleService(), OpenEuiccContextMarker {
|
|
||||||
companion object {
|
|
||||||
private const val TAG = "EuiccChannelManagerService"
|
|
||||||
private const val CHANNEL_ID = "tasks"
|
|
||||||
private const val FOREGROUND_ID = 1000
|
|
||||||
private const val TASK_FAILURE_ID = 1000
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Utility function to wait for a foreground task to be done, return its
|
|
||||||
* error if any, or null on success.
|
|
||||||
*/
|
|
||||||
suspend fun Flow<ForegroundTaskState>.waitDone(): Throwable? =
|
|
||||||
(this.last() as ForegroundTaskState.Done).error
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Apply transform to a ForegroundTaskState flow so that it completes when a Done is seen.
|
|
||||||
*
|
|
||||||
* This must be applied each time a flow is returned for subscription purposes. If applied
|
|
||||||
* beforehand, we lose the ability to subscribe multiple times.
|
|
||||||
*/
|
|
||||||
private fun Flow<ForegroundTaskState>.applyCompletionTransform() =
|
|
||||||
transformWhile {
|
|
||||||
emit(it)
|
|
||||||
it !is ForegroundTaskState.Done
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
inner class LocalBinder : Binder() {
|
|
||||||
val service = this@EuiccChannelManagerService
|
|
||||||
}
|
|
||||||
|
|
||||||
private val euiccChannelManagerDelegate = lazy {
|
|
||||||
appContainer.euiccChannelManagerFactory.createEuiccChannelManager(this)
|
|
||||||
}
|
|
||||||
val euiccChannelManager: EuiccChannelManager by euiccChannelManagerDelegate
|
|
||||||
|
|
||||||
private val wakeLock: PowerManager.WakeLock by lazy {
|
|
||||||
(getSystemService(POWER_SERVICE) as PowerManager).run {
|
|
||||||
newWakeLock(PowerManager.PARTIAL_WAKE_LOCK, this::class.simpleName)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* The state of a "foreground" task (named so due to the need to startForeground())
|
|
||||||
*/
|
|
||||||
sealed interface ForegroundTaskState {
|
|
||||||
data object Idle : ForegroundTaskState
|
|
||||||
data class InProgress(val progress: Int) : ForegroundTaskState
|
|
||||||
data class Done(val error: Throwable?) : ForegroundTaskState
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* This flow emits whenever the service has had a start command, from startService()
|
|
||||||
* The service self-starts when foreground is required, because other components
|
|
||||||
* only bind to this service and do not start it per-se.
|
|
||||||
*/
|
|
||||||
private val foregroundStarted: MutableSharedFlow<Unit> = MutableSharedFlow()
|
|
||||||
|
|
||||||
/**
|
|
||||||
* This flow is used to emit progress updates when a foreground task is running.
|
|
||||||
*/
|
|
||||||
private val foregroundTaskState: MutableStateFlow<ForegroundTaskState> =
|
|
||||||
MutableStateFlow(ForegroundTaskState.Idle)
|
|
||||||
|
|
||||||
/**
|
|
||||||
* A simple wrapper over a flow with taskId added.
|
|
||||||
*
|
|
||||||
* taskID is the exact millisecond-precision timestamp when the task is launched.
|
|
||||||
*/
|
|
||||||
class ForegroundTaskSubscriberFlow(val taskId: Long, inner: Flow<ForegroundTaskState>) :
|
|
||||||
Flow<ForegroundTaskState> by inner
|
|
||||||
|
|
||||||
/**
|
|
||||||
* A cache of subscribers to 5 recently-launched foreground tasks, identified by ID
|
|
||||||
*
|
|
||||||
* Only one can be run at the same time, but those that are done will be kept in this
|
|
||||||
* map for a little while -- because UI components may be stopped and recreated while
|
|
||||||
* tasks are running. Having this buffer allows the components to re-subscribe even if
|
|
||||||
* the task completes while they are being recreated.
|
|
||||||
*/
|
|
||||||
private val foregroundTaskSubscribers: MutableMap<Long, SharedFlow<ForegroundTaskState>> =
|
|
||||||
mutableMapOf()
|
|
||||||
|
|
||||||
override fun onBind(intent: Intent): IBinder {
|
|
||||||
super.onBind(intent)
|
|
||||||
return LocalBinder()
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun onDestroy() {
|
|
||||||
super.onDestroy()
|
|
||||||
if (euiccChannelManagerDelegate.isInitialized()) {
|
|
||||||
euiccChannelManager.invalidate()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
|
|
||||||
return super.onStartCommand(intent, flags, startId).also {
|
|
||||||
lifecycleScope.launch {
|
|
||||||
foregroundStarted.emit(Unit)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun ensureForegroundTaskNotificationChannel() {
|
|
||||||
val nm = NotificationManagerCompat.from(this)
|
|
||||||
if (nm.getNotificationChannelCompat(CHANNEL_ID) == null) {
|
|
||||||
val channel =
|
|
||||||
NotificationChannelCompat.Builder(
|
|
||||||
CHANNEL_ID,
|
|
||||||
NotificationManagerCompat.IMPORTANCE_LOW
|
|
||||||
)
|
|
||||||
.setName(getString(R.string.task_notification))
|
|
||||||
.setVibrationEnabled(false)
|
|
||||||
.build()
|
|
||||||
nm.createNotificationChannel(channel)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private suspend fun updateForegroundNotification(title: String, iconRes: Int) {
|
|
||||||
ensureForegroundTaskNotificationChannel()
|
|
||||||
|
|
||||||
val nm = NotificationManagerCompat.from(this)
|
|
||||||
val state = foregroundTaskState.value
|
|
||||||
|
|
||||||
if (state is ForegroundTaskState.InProgress) {
|
|
||||||
val notification = NotificationCompat.Builder(this, CHANNEL_ID)
|
|
||||||
.setContentTitle(title)
|
|
||||||
.setProgress(100, state.progress, state.progress == 0)
|
|
||||||
.setSmallIcon(iconRes)
|
|
||||||
.setPriority(NotificationCompat.PRIORITY_LOW)
|
|
||||||
.setOngoing(true)
|
|
||||||
.setOnlyAlertOnce(true)
|
|
||||||
.build()
|
|
||||||
|
|
||||||
if (state.progress == 0) {
|
|
||||||
startForeground(FOREGROUND_ID, notification)
|
|
||||||
} else if (checkSelfPermission(android.Manifest.permission.POST_NOTIFICATIONS) == PackageManager.PERMISSION_GRANTED) {
|
|
||||||
nm.notify(FOREGROUND_ID, notification)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Yield out so that the main looper can handle the notification event
|
|
||||||
// Without this yield, the notification sent above will not be shown in time.
|
|
||||||
yield()
|
|
||||||
} else {
|
|
||||||
stopForeground(STOP_FOREGROUND_REMOVE)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun postForegroundTaskFailureNotification(title: String) {
|
|
||||||
if (checkSelfPermission(android.Manifest.permission.POST_NOTIFICATIONS) != PackageManager.PERMISSION_GRANTED) {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
val notification = NotificationCompat.Builder(this, CHANNEL_ID)
|
|
||||||
.setContentTitle(title)
|
|
||||||
.setSmallIcon(R.drawable.ic_x_black)
|
|
||||||
.build()
|
|
||||||
NotificationManagerCompat.from(this).notify(TASK_FAILURE_ID, notification)
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Recover the subscriber to a foreground task that is recently launched.
|
|
||||||
*
|
|
||||||
* null if the task doesn't exist, or was launched too long ago.
|
|
||||||
*/
|
|
||||||
fun recoverForegroundTaskSubscriber(taskId: Long): ForegroundTaskSubscriberFlow? =
|
|
||||||
foregroundTaskSubscribers[taskId]?.let {
|
|
||||||
ForegroundTaskSubscriberFlow(taskId, it.applyCompletionTransform())
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Launch a potentially blocking foreground task in this service's lifecycle context.
|
|
||||||
* This function does not block, but returns a Flow that emits ForegroundTaskState
|
|
||||||
* updates associated with this task. The last update the returned flow will emit is
|
|
||||||
* always ForegroundTaskState.Done.
|
|
||||||
*
|
|
||||||
* The returned flow can only be subscribed to once even though the underlying implementation
|
|
||||||
* is a SharedFlow. This is due to the need to apply transformations so that the stream
|
|
||||||
* actually completes. In order to subscribe multiple times, use `recoverForegroundTaskSubscriber`
|
|
||||||
* to acquire another instance.
|
|
||||||
*
|
|
||||||
* The task closure is expected to update foregroundTaskState whenever appropriate.
|
|
||||||
* If a foreground task is already running, this function returns null.
|
|
||||||
*
|
|
||||||
* To wait for foreground tasks to be available, use waitForForegroundTask().
|
|
||||||
*
|
|
||||||
* The function will set the state back to Idle once it sees ForegroundTaskState.Done.
|
|
||||||
*/
|
|
||||||
private fun launchForegroundTask(
|
|
||||||
title: String,
|
|
||||||
failureTitle: String,
|
|
||||||
iconRes: Int,
|
|
||||||
task: suspend EuiccChannelManagerService.() -> Unit
|
|
||||||
): ForegroundTaskSubscriberFlow {
|
|
||||||
val taskID = System.currentTimeMillis()
|
|
||||||
|
|
||||||
// Atomically set the state to InProgress. If this returns true, we are
|
|
||||||
// the only task currently in progress.
|
|
||||||
if (!foregroundTaskState.compareAndSet(
|
|
||||||
ForegroundTaskState.Idle,
|
|
||||||
ForegroundTaskState.InProgress(0)
|
|
||||||
)
|
|
||||||
) {
|
|
||||||
return ForegroundTaskSubscriberFlow(
|
|
||||||
taskID,
|
|
||||||
flow { emit(ForegroundTaskState.Done(IllegalStateException("There are tasks currently running"))) })
|
|
||||||
}
|
|
||||||
|
|
||||||
lifecycleScope.launch(Dispatchers.Main) {
|
|
||||||
// Wait until our self-start command has succeeded.
|
|
||||||
// We can only call startForeground() after that
|
|
||||||
val res = withTimeoutOrNull(30 * 1000) {
|
|
||||||
foregroundStarted.first()
|
|
||||||
}
|
|
||||||
|
|
||||||
if (res == null) {
|
|
||||||
// The only case where the wait above could time out is if the subscriber
|
|
||||||
// to the flow is stuck. Or we failed to start foreground.
|
|
||||||
// In that case, we should just set our state back to Idle -- setting it
|
|
||||||
// to Done wouldn't help much because nothing is going to then set it Idle.
|
|
||||||
foregroundTaskState.value = ForegroundTaskState.Idle
|
|
||||||
return@launch
|
|
||||||
}
|
|
||||||
|
|
||||||
updateForegroundNotification(title, iconRes)
|
|
||||||
|
|
||||||
wakeLock.acquire(10 * 60 * 1000L /*10 minutes*/)
|
|
||||||
|
|
||||||
try {
|
|
||||||
withContext(Dispatchers.IO + NonCancellable) { // Any LPA-related task must always complete
|
|
||||||
this@EuiccChannelManagerService.task()
|
|
||||||
}
|
|
||||||
// This update will be sent by the subscriber (as shown below)
|
|
||||||
foregroundTaskState.value = ForegroundTaskState.Done(null)
|
|
||||||
} catch (t: Throwable) {
|
|
||||||
Log.e(TAG, "Foreground task encountered an error")
|
|
||||||
Log.e(TAG, Log.getStackTraceString(t))
|
|
||||||
foregroundTaskState.value = ForegroundTaskState.Done(t)
|
|
||||||
|
|
||||||
if (isActive) {
|
|
||||||
postForegroundTaskFailureNotification(failureTitle)
|
|
||||||
}
|
|
||||||
} finally {
|
|
||||||
wakeLock.release()
|
|
||||||
if (isActive) {
|
|
||||||
stopSelf()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// This is the flow we are going to return. We allow multiple subscribers by
|
|
||||||
// re-emitting state updates into this flow from another coroutine.
|
|
||||||
// replay = 2 ensures that we at least have 1 previous state whenever subscribed to.
|
|
||||||
// This is helpful when the task completed and is then re-subscribed to due to a
|
|
||||||
// UI recreation event -- this way, the UI will know at least one last progress event
|
|
||||||
// before completion / failure
|
|
||||||
val subscriberFlow = MutableSharedFlow<ForegroundTaskState>(
|
|
||||||
replay = 2,
|
|
||||||
onBufferOverflow = BufferOverflow.DROP_OLDEST
|
|
||||||
)
|
|
||||||
|
|
||||||
// We should be the only task running, so we can subscribe to foregroundTaskState
|
|
||||||
// until we encounter ForegroundTaskState.Done.
|
|
||||||
// Then, we complete the returned flow, but we also set the state back to Idle.
|
|
||||||
// The state update back to Idle won't show up in the returned stream, because
|
|
||||||
// it has been completed by that point.
|
|
||||||
lifecycleScope.launch(Dispatchers.Main) {
|
|
||||||
foregroundTaskState
|
|
||||||
.applyCompletionTransform()
|
|
||||||
.onEach {
|
|
||||||
// Also update our notification when we see an update
|
|
||||||
// But ignore the first progress = 0 update -- that is the current value.
|
|
||||||
// we need that to be handled by the main coroutine after it finishes.
|
|
||||||
if (it !is ForegroundTaskState.InProgress || it.progress != 0) {
|
|
||||||
updateForegroundNotification(title, iconRes)
|
|
||||||
}
|
|
||||||
|
|
||||||
subscriberFlow.emit(it)
|
|
||||||
}
|
|
||||||
.onCompletion {
|
|
||||||
// Reset state back to Idle when we are done.
|
|
||||||
// We do it here because otherwise Idle and Done might become conflated
|
|
||||||
// when emitted by the main coroutine in quick succession.
|
|
||||||
// Doing it here ensures we've seen Done. This Idle event won't be
|
|
||||||
// emitted to the consumer because the subscription has completed here.
|
|
||||||
foregroundTaskState.value = ForegroundTaskState.Idle
|
|
||||||
}
|
|
||||||
.collect()
|
|
||||||
}
|
|
||||||
|
|
||||||
foregroundTaskSubscribers[taskID] = subscriberFlow.asSharedFlow()
|
|
||||||
|
|
||||||
if (foregroundTaskSubscribers.size > 5) {
|
|
||||||
// Remove enough elements so that the size is kept at 5
|
|
||||||
for (key in foregroundTaskSubscribers.keys.sorted()
|
|
||||||
.take(foregroundTaskSubscribers.size - 5)) {
|
|
||||||
foregroundTaskSubscribers.remove(key)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Before we return, and after we have set everything up,
|
|
||||||
// self-start with foreground permission.
|
|
||||||
// This is going to unblock the main coroutine handling the task.
|
|
||||||
startForegroundService(
|
|
||||||
Intent(
|
|
||||||
this@EuiccChannelManagerService,
|
|
||||||
this@EuiccChannelManagerService::class.java
|
|
||||||
)
|
|
||||||
)
|
|
||||||
|
|
||||||
return ForegroundTaskSubscriberFlow(
|
|
||||||
taskID,
|
|
||||||
subscriberFlow.asSharedFlow().applyCompletionTransform()
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
suspend fun waitForForegroundTask() {
|
|
||||||
foregroundTaskState.takeWhile { it != ForegroundTaskState.Idle }
|
|
||||||
.collect()
|
|
||||||
}
|
|
||||||
|
|
||||||
fun launchProfileDownloadTask(
|
|
||||||
slotId: Int,
|
|
||||||
portId: Int,
|
|
||||||
smdp: String,
|
|
||||||
matchingId: String?,
|
|
||||||
confirmationCode: String?,
|
|
||||||
imei: String?
|
|
||||||
): ForegroundTaskSubscriberFlow =
|
|
||||||
launchForegroundTask(
|
|
||||||
getString(R.string.task_profile_download),
|
|
||||||
getString(R.string.task_profile_download_failure),
|
|
||||||
R.drawable.ic_task_sim_card_download
|
|
||||||
) {
|
|
||||||
euiccChannelManager.beginTrackedOperation(slotId, portId) {
|
|
||||||
euiccChannelManager.withEuiccChannel(slotId, portId) { channel ->
|
|
||||||
channel.lpa.downloadProfile(
|
|
||||||
smdp,
|
|
||||||
matchingId,
|
|
||||||
imei,
|
|
||||||
confirmationCode,
|
|
||||||
object : ProfileDownloadCallback {
|
|
||||||
override fun onStateUpdate(state: ProfileDownloadCallback.DownloadState) {
|
|
||||||
if (state.progress == 0) return
|
|
||||||
foregroundTaskState.value =
|
|
||||||
ForegroundTaskState.InProgress(state.progress)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
preferenceRepository.notificationDownloadFlow.first()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fun launchProfileRenameTask(
|
|
||||||
slotId: Int,
|
|
||||||
portId: Int,
|
|
||||||
iccid: String,
|
|
||||||
name: String
|
|
||||||
): ForegroundTaskSubscriberFlow =
|
|
||||||
launchForegroundTask(
|
|
||||||
getString(R.string.task_profile_rename),
|
|
||||||
getString(R.string.task_profile_rename_failure),
|
|
||||||
R.drawable.ic_task_rename
|
|
||||||
) {
|
|
||||||
euiccChannelManager.withEuiccChannel(slotId, portId) { channel ->
|
|
||||||
channel.lpa.setNickname(
|
|
||||||
iccid,
|
|
||||||
name
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fun launchProfileDeleteTask(
|
|
||||||
slotId: Int,
|
|
||||||
portId: Int,
|
|
||||||
iccid: String
|
|
||||||
): ForegroundTaskSubscriberFlow =
|
|
||||||
launchForegroundTask(
|
|
||||||
getString(R.string.task_profile_delete),
|
|
||||||
getString(R.string.task_profile_delete_failure),
|
|
||||||
R.drawable.ic_task_delete
|
|
||||||
) {
|
|
||||||
euiccChannelManager.beginTrackedOperation(slotId, portId) {
|
|
||||||
euiccChannelManager.withEuiccChannel(slotId, portId) { channel ->
|
|
||||||
channel.lpa.deleteProfile(iccid)
|
|
||||||
}
|
|
||||||
|
|
||||||
preferenceRepository.notificationDeleteFlow.first()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
class SwitchingProfilesRefreshException : Exception()
|
|
||||||
|
|
||||||
fun launchProfileSwitchTask(
|
|
||||||
slotId: Int,
|
|
||||||
portId: Int,
|
|
||||||
iccid: String,
|
|
||||||
enable: Boolean, // Enable or disable the profile indicated in iccid
|
|
||||||
reconnectTimeoutMillis: Long = 0 // 0 = do not wait for reconnect
|
|
||||||
) =
|
|
||||||
launchForegroundTask(
|
|
||||||
getString(R.string.task_profile_switch),
|
|
||||||
getString(R.string.task_profile_switch_failure),
|
|
||||||
R.drawable.ic_task_switch
|
|
||||||
) {
|
|
||||||
euiccChannelManager.beginTrackedOperation(slotId, portId) {
|
|
||||||
val (response, refreshed) =
|
|
||||||
euiccChannelManager.withEuiccChannel(slotId, portId) { channel ->
|
|
||||||
val refresh = preferenceRepository.refreshAfterSwitchFlow.first()
|
|
||||||
val response = channel.lpa.switchProfile(iccid, enable, refresh)
|
|
||||||
if (response || !refresh) {
|
|
||||||
Pair(response, refresh)
|
|
||||||
} else {
|
|
||||||
// refresh failed, but refresh was requested
|
|
||||||
// Sometimes, we *can* enable or disable the profile, but we cannot
|
|
||||||
// send the refresh command to the modem because the profile somehow
|
|
||||||
// makes the modem "busy". In this case, we can still switch by setting
|
|
||||||
// refresh to false, but then the switch cannot take effect until the
|
|
||||||
// user resets the modem manually by toggling airplane mode or rebooting.
|
|
||||||
Pair(
|
|
||||||
channel.lpa.switchProfile(iccid, enable, refresh = false),
|
|
||||||
false
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!response) {
|
|
||||||
throw RuntimeException("Could not switch profile")
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!refreshed && slotId != EuiccChannelManager.USB_CHANNEL_ID) {
|
|
||||||
// We may have switched the profile, but we could not refresh. Tell the caller about this
|
|
||||||
// but only if we are talking to a modem and not a USB reader
|
|
||||||
throw SwitchingProfilesRefreshException()
|
|
||||||
}
|
|
||||||
|
|
||||||
if (reconnectTimeoutMillis > 0) {
|
|
||||||
// Add an unconditional delay first to account for any race condition between
|
|
||||||
// the card sending the refresh command and the modem actually refreshing
|
|
||||||
delay(reconnectTimeoutMillis / 10)
|
|
||||||
|
|
||||||
// This throws TimeoutCancellationException if timed out
|
|
||||||
euiccChannelManager.waitForReconnect(
|
|
||||||
slotId,
|
|
||||||
portId,
|
|
||||||
reconnectTimeoutMillis / 10 * 9
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
preferenceRepository.notificationSwitchFlow.first()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fun launchMemoryReset(slotId: Int, portId: Int): ForegroundTaskSubscriberFlow =
|
|
||||||
launchForegroundTask(
|
|
||||||
getString(R.string.task_euicc_memory_reset),
|
|
||||||
getString(R.string.task_euicc_memory_reset_failure),
|
|
||||||
R.drawable.ic_euicc_memory_reset
|
|
||||||
) {
|
|
||||||
euiccChannelManager.beginTrackedOperation(slotId, portId) {
|
|
||||||
euiccChannelManager.withEuiccChannel(slotId, portId) { channel ->
|
|
||||||
channel.lpa.euiccMemoryReset()
|
|
||||||
}
|
|
||||||
|
|
||||||
preferenceRepository.notificationDeleteFlow.first()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -1,52 +0,0 @@
|
||||||
package im.angry.openeuicc.ui
|
|
||||||
|
|
||||||
import android.content.ComponentName
|
|
||||||
import android.content.Context
|
|
||||||
import android.content.Intent
|
|
||||||
import android.content.ServiceConnection
|
|
||||||
import android.os.Bundle
|
|
||||||
import android.os.IBinder
|
|
||||||
import androidx.appcompat.app.AppCompatActivity
|
|
||||||
import im.angry.openeuicc.core.EuiccChannelManager
|
|
||||||
import im.angry.openeuicc.service.EuiccChannelManagerService
|
|
||||||
import kotlinx.coroutines.CompletableDeferred
|
|
||||||
|
|
||||||
abstract class BaseEuiccAccessActivity : AppCompatActivity() {
|
|
||||||
val euiccChannelManagerLoaded = CompletableDeferred<Unit>()
|
|
||||||
lateinit var euiccChannelManager: EuiccChannelManager
|
|
||||||
lateinit var euiccChannelManagerService: EuiccChannelManagerService
|
|
||||||
|
|
||||||
private val euiccChannelManagerServiceConnection = object : ServiceConnection {
|
|
||||||
override fun onServiceConnected(name: ComponentName?, service: IBinder?) {
|
|
||||||
euiccChannelManagerService = (service!! as EuiccChannelManagerService.LocalBinder).service
|
|
||||||
euiccChannelManager = euiccChannelManagerService.euiccChannelManager
|
|
||||||
euiccChannelManagerLoaded.complete(Unit)
|
|
||||||
onInit()
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun onServiceDisconnected(name: ComponentName?) {
|
|
||||||
// These activities should never lose the EuiccChannelManagerService connection
|
|
||||||
finish()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun onCreate(savedInstanceState: Bundle?) {
|
|
||||||
super.onCreate(savedInstanceState)
|
|
||||||
|
|
||||||
bindService(
|
|
||||||
Intent(this, EuiccChannelManagerService::class.java),
|
|
||||||
euiccChannelManagerServiceConnection,
|
|
||||||
Context.BIND_AUTO_CREATE
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun onDestroy() {
|
|
||||||
super.onDestroy()
|
|
||||||
unbindService(euiccChannelManagerServiceConnection)
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* When called, euiccChannelManager is guaranteed to have been initialized
|
|
||||||
*/
|
|
||||||
abstract fun onInit()
|
|
||||||
}
|
|
|
@ -0,0 +1,43 @@
|
||||||
|
package im.angry.openeuicc.ui
|
||||||
|
|
||||||
|
import android.os.Bundle
|
||||||
|
import androidx.appcompat.app.AppCompatActivity
|
||||||
|
import androidx.lifecycle.lifecycleScope
|
||||||
|
import im.angry.openeuicc.util.*
|
||||||
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
import kotlinx.coroutines.withContext
|
||||||
|
|
||||||
|
class DirectProfileDownloadActivity : AppCompatActivity(), SlotSelectFragment.SlotSelectedListener, OpenEuiccContextMarker {
|
||||||
|
override fun onCreate(savedInstanceState: Bundle?) {
|
||||||
|
super.onCreate(savedInstanceState)
|
||||||
|
lifecycleScope.launch {
|
||||||
|
withContext(Dispatchers.IO) {
|
||||||
|
euiccChannelManager.enumerateEuiccChannels()
|
||||||
|
}
|
||||||
|
|
||||||
|
when {
|
||||||
|
euiccChannelManager.knownChannels.isEmpty() -> {
|
||||||
|
finish()
|
||||||
|
}
|
||||||
|
euiccChannelManager.knownChannels.hasMultipleChips -> {
|
||||||
|
SlotSelectFragment.newInstance()
|
||||||
|
.show(supportFragmentManager, SlotSelectFragment.TAG)
|
||||||
|
}
|
||||||
|
else -> {
|
||||||
|
// If the device has only one eSIM "chip" (but may be mapped to multiple slots),
|
||||||
|
// we can skip the slot selection dialog since there is only one chip to save to.
|
||||||
|
onSlotSelected(euiccChannelManager.knownChannels[0].slotId,
|
||||||
|
euiccChannelManager.knownChannels[0].portId)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onSlotSelected(slotId: Int, portId: Int) {
|
||||||
|
ProfileDownloadFragment.newInstance(slotId, portId, finishWhenDone = true)
|
||||||
|
.show(supportFragmentManager, ProfileDownloadFragment.TAG)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onSlotSelectCancelled() = finish()
|
||||||
|
}
|
|
@ -1,198 +0,0 @@
|
||||||
package im.angry.openeuicc.ui
|
|
||||||
|
|
||||||
import android.annotation.SuppressLint
|
|
||||||
import android.content.ClipData
|
|
||||||
import android.content.ClipboardManager
|
|
||||||
import android.os.Build
|
|
||||||
import android.os.Bundle
|
|
||||||
import android.view.LayoutInflater
|
|
||||||
import android.view.MenuItem
|
|
||||||
import android.view.View
|
|
||||||
import android.view.ViewGroup
|
|
||||||
import android.widget.TextView
|
|
||||||
import android.widget.Toast
|
|
||||||
import androidx.activity.enableEdgeToEdge
|
|
||||||
import androidx.annotation.StringRes
|
|
||||||
import androidx.lifecycle.lifecycleScope
|
|
||||||
import androidx.recyclerview.widget.DividerItemDecoration
|
|
||||||
import androidx.recyclerview.widget.LinearLayoutManager
|
|
||||||
import androidx.recyclerview.widget.RecyclerView
|
|
||||||
import androidx.recyclerview.widget.RecyclerView.ViewHolder
|
|
||||||
import androidx.swiperefreshlayout.widget.SwipeRefreshLayout
|
|
||||||
import im.angry.openeuicc.common.R
|
|
||||||
import im.angry.openeuicc.core.EuiccChannel
|
|
||||||
import im.angry.openeuicc.core.EuiccChannelManager
|
|
||||||
import im.angry.openeuicc.util.*
|
|
||||||
import kotlinx.coroutines.launch
|
|
||||||
import net.typeblog.lpac_jni.impl.PKID_GSMA_LIVE_CI
|
|
||||||
import net.typeblog.lpac_jni.impl.PKID_GSMA_TEST_CI
|
|
||||||
|
|
||||||
// https://euicc-manual.osmocom.org/docs/pki/eum/accredited.json
|
|
||||||
// ref: <https://regex101.com/r/5FFz8u>
|
|
||||||
private val RE_SAS = Regex(
|
|
||||||
"""^[A-Z]{2}-[A-Z]{2}(?:-UP)?-\d{4}T?(?:-\d+)?T?$""",
|
|
||||||
setOf(RegexOption.IGNORE_CASE),
|
|
||||||
)
|
|
||||||
|
|
||||||
class EuiccInfoActivity : BaseEuiccAccessActivity(), OpenEuiccContextMarker {
|
|
||||||
companion object {
|
|
||||||
private val YES_NO = Pair(R.string.yes, R.string.no)
|
|
||||||
}
|
|
||||||
|
|
||||||
private lateinit var swipeRefresh: SwipeRefreshLayout
|
|
||||||
private lateinit var infoList: RecyclerView
|
|
||||||
|
|
||||||
private var logicalSlotId: Int = -1
|
|
||||||
|
|
||||||
data class Item(
|
|
||||||
@StringRes
|
|
||||||
val titleResId: Int,
|
|
||||||
val content: String?,
|
|
||||||
val copiedToastResId: Int? = null,
|
|
||||||
)
|
|
||||||
|
|
||||||
override fun onCreate(savedInstanceState: Bundle?) {
|
|
||||||
enableEdgeToEdge()
|
|
||||||
super.onCreate(savedInstanceState)
|
|
||||||
setContentView(R.layout.activity_euicc_info)
|
|
||||||
setSupportActionBar(requireViewById(R.id.toolbar))
|
|
||||||
setupToolbarInsets()
|
|
||||||
supportActionBar!!.setDisplayHomeAsUpEnabled(true)
|
|
||||||
|
|
||||||
swipeRefresh = requireViewById(R.id.swipe_refresh)
|
|
||||||
infoList = requireViewById<RecyclerView>(R.id.recycler_view).also {
|
|
||||||
it.layoutManager = LinearLayoutManager(this, LinearLayoutManager.VERTICAL, false)
|
|
||||||
it.addItemDecoration(DividerItemDecoration(this, LinearLayoutManager.VERTICAL))
|
|
||||||
it.adapter = EuiccInfoAdapter()
|
|
||||||
}
|
|
||||||
|
|
||||||
logicalSlotId = intent.getIntExtra("logicalSlotId", 0)
|
|
||||||
|
|
||||||
val channelTitle = if (logicalSlotId == EuiccChannelManager.USB_CHANNEL_ID) {
|
|
||||||
getString(R.string.usb)
|
|
||||||
} else {
|
|
||||||
appContainer.customizableTextProvider.formatInternalChannelName(logicalSlotId)
|
|
||||||
}
|
|
||||||
|
|
||||||
title = getString(R.string.euicc_info_activity_title, channelTitle)
|
|
||||||
|
|
||||||
swipeRefresh.setOnRefreshListener { refresh() }
|
|
||||||
|
|
||||||
setupRootViewInsets(infoList)
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun onOptionsItemSelected(item: MenuItem): Boolean = when (item.itemId) {
|
|
||||||
android.R.id.home -> {
|
|
||||||
finish()
|
|
||||||
true
|
|
||||||
}
|
|
||||||
|
|
||||||
else -> super.onOptionsItemSelected(item)
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun onInit() {
|
|
||||||
refresh()
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun refresh() {
|
|
||||||
swipeRefresh.isRefreshing = true
|
|
||||||
|
|
||||||
lifecycleScope.launch {
|
|
||||||
(infoList.adapter!! as EuiccInfoAdapter).euiccInfoItems =
|
|
||||||
euiccChannelManager.withEuiccChannel(logicalSlotId, ::buildEuiccInfoItems)
|
|
||||||
|
|
||||||
swipeRefresh.isRefreshing = false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun buildEuiccInfoItems(channel: EuiccChannel) = buildList {
|
|
||||||
add(Item(R.string.euicc_info_access_mode, channel.type))
|
|
||||||
add(Item(R.string.euicc_info_removable, formatByBoolean(channel.port.card.isRemovable, YES_NO)))
|
|
||||||
add(Item(R.string.euicc_info_eid, channel.lpa.eID, copiedToastResId = R.string.toast_eid_copied))
|
|
||||||
add(Item(R.string.euicc_info_isdr_aid, channel.isdrAid.encodeHex()))
|
|
||||||
channel.tryParseEuiccVendorInfo()?.let { vendorInfo ->
|
|
||||||
vendorInfo.skuName?.let { add(Item(R.string.euicc_info_sku, it)) }
|
|
||||||
vendorInfo.serialNumber?.let { add(Item(R.string.euicc_info_sn, it, copiedToastResId = R.string.toast_sn_copied)) }
|
|
||||||
vendorInfo.firmwareVersion?.let { add(Item(R.string.euicc_info_fw_ver, it)) }
|
|
||||||
vendorInfo.bootloaderVersion?.let { add(Item(R.string.euicc_info_bl_ver, it)) }
|
|
||||||
}
|
|
||||||
channel.lpa.euiccInfo2?.let { info ->
|
|
||||||
add(Item(R.string.euicc_info_sgp22_version, info.sgp22Version.toString()))
|
|
||||||
add(Item(R.string.euicc_info_firmware_version, info.euiccFirmwareVersion.toString()))
|
|
||||||
add(Item(R.string.euicc_info_globalplatform_version, info.globalPlatformVersion.toString()))
|
|
||||||
add(Item(R.string.euicc_info_pp_version, info.ppVersion.toString()))
|
|
||||||
info.sasAccreditationNumber.trim().takeIf(RE_SAS::matches)
|
|
||||||
?.let { add(Item(R.string.euicc_info_sas_accreditation_number, it.uppercase())) }
|
|
||||||
add(Item(R.string.euicc_info_free_nvram, info.freeNvram.let(::formatFreeSpace)))
|
|
||||||
}
|
|
||||||
channel.lpa.euiccInfo2?.euiccCiPKIdListForSigning.orEmpty().let { signers ->
|
|
||||||
// SGP.28 v1.0, eSIM CI Registration Criteria (Page 5 of 9, 2019-10-24)
|
|
||||||
// https://www.gsma.com/newsroom/wp-content/uploads/SGP.28-v1.0.pdf#page=5
|
|
||||||
// FS.27 v2.0, Security Guidelines for UICC Profiles (Page 25 of 27, 2024-01-30)
|
|
||||||
// https://www.gsma.com/solutions-and-impact/technologies/security/wp-content/uploads/2024/01/FS.27-Security-Guidelines-for-UICC-Credentials-v2.0-FINAL-23-July.pdf#page=25
|
|
||||||
val resId = when {
|
|
||||||
signers.isEmpty() -> R.string.unknown // the case is not mp, but it's is not common
|
|
||||||
PKID_GSMA_LIVE_CI.any(signers::contains) -> R.string.euicc_info_ci_gsma_live
|
|
||||||
PKID_GSMA_TEST_CI.any(signers::contains) -> R.string.euicc_info_ci_gsma_test
|
|
||||||
else -> R.string.euicc_info_ci_unknown
|
|
||||||
}
|
|
||||||
add(Item(R.string.euicc_info_ci_type, getString(resId)))
|
|
||||||
}
|
|
||||||
val atr = channel.atr?.encodeHex() ?: getString(R.string.information_unavailable)
|
|
||||||
add(Item(R.string.euicc_info_atr, atr, copiedToastResId = R.string.toast_atr_copied))
|
|
||||||
}
|
|
||||||
|
|
||||||
@Suppress("SameParameterValue")
|
|
||||||
private fun formatByBoolean(b: Boolean, res: Pair<Int, Int>): String =
|
|
||||||
getString(if (b) res.first else res.second)
|
|
||||||
|
|
||||||
inner class EuiccInfoViewHolder(root: View) : ViewHolder(root) {
|
|
||||||
private val title: TextView = root.requireViewById(R.id.euicc_info_title)
|
|
||||||
private val content: TextView = root.requireViewById(R.id.euicc_info_content)
|
|
||||||
private var copiedToastResId: Int? = null
|
|
||||||
|
|
||||||
init {
|
|
||||||
root.setOnClickListener {
|
|
||||||
if (copiedToastResId != null) {
|
|
||||||
val label = title.text.toString()
|
|
||||||
getSystemService(ClipboardManager::class.java)!!
|
|
||||||
.setPrimaryClip(ClipData.newPlainText(label, content.text))
|
|
||||||
if (Build.VERSION.SDK_INT <= Build.VERSION_CODES.S_V2) {
|
|
||||||
Toast.makeText(
|
|
||||||
this@EuiccInfoActivity,
|
|
||||||
copiedToastResId!!,
|
|
||||||
Toast.LENGTH_SHORT
|
|
||||||
).show()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fun bind(item: Item) {
|
|
||||||
copiedToastResId = item.copiedToastResId
|
|
||||||
title.setText(item.titleResId)
|
|
||||||
content.text = item.content ?: getString(R.string.unknown)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
inner class EuiccInfoAdapter : RecyclerView.Adapter<EuiccInfoViewHolder>() {
|
|
||||||
var euiccInfoItems: List<Item> = listOf()
|
|
||||||
@SuppressLint("NotifyDataSetChanged")
|
|
||||||
set(newVal) {
|
|
||||||
field = newVal
|
|
||||||
notifyDataSetChanged()
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): EuiccInfoViewHolder {
|
|
||||||
val root = LayoutInflater.from(parent.context)
|
|
||||||
.inflate(R.layout.euicc_info_item, parent, false)
|
|
||||||
return EuiccInfoViewHolder(root)
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun getItemCount(): Int = euiccInfoItems.size
|
|
||||||
|
|
||||||
override fun onBindViewHolder(holder: EuiccInfoViewHolder, position: Int) {
|
|
||||||
holder.bind(euiccInfoItems[position])
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -1,12 +1,10 @@
|
||||||
package im.angry.openeuicc.ui
|
package im.angry.openeuicc.ui
|
||||||
|
|
||||||
import android.annotation.SuppressLint
|
import android.annotation.SuppressLint
|
||||||
import android.content.ClipData
|
|
||||||
import android.content.ClipboardManager
|
|
||||||
import android.content.Intent
|
import android.content.Intent
|
||||||
import android.os.Build
|
|
||||||
import android.os.Bundle
|
import android.os.Bundle
|
||||||
import android.text.method.PasswordTransformationMethod
|
import android.text.method.PasswordTransformationMethod
|
||||||
|
import android.util.Log
|
||||||
import android.view.LayoutInflater
|
import android.view.LayoutInflater
|
||||||
import android.view.Menu
|
import android.view.Menu
|
||||||
import android.view.MenuInflater
|
import android.view.MenuInflater
|
||||||
|
@ -18,11 +16,6 @@ import android.widget.ImageButton
|
||||||
import android.widget.PopupMenu
|
import android.widget.PopupMenu
|
||||||
import android.widget.TextView
|
import android.widget.TextView
|
||||||
import android.widget.Toast
|
import android.widget.Toast
|
||||||
import androidx.appcompat.app.AlertDialog
|
|
||||||
import androidx.core.view.ViewCompat
|
|
||||||
import androidx.core.view.WindowInsetsCompat
|
|
||||||
import androidx.core.view.isVisible
|
|
||||||
import androidx.core.view.updateLayoutParams
|
|
||||||
import androidx.fragment.app.Fragment
|
import androidx.fragment.app.Fragment
|
||||||
import androidx.lifecycle.lifecycleScope
|
import androidx.lifecycle.lifecycleScope
|
||||||
import androidx.recyclerview.widget.LinearLayoutManager
|
import androidx.recyclerview.widget.LinearLayoutManager
|
||||||
|
@ -31,18 +24,12 @@ import androidx.swiperefreshlayout.widget.SwipeRefreshLayout
|
||||||
import com.google.android.material.floatingactionbutton.FloatingActionButton
|
import com.google.android.material.floatingactionbutton.FloatingActionButton
|
||||||
import net.typeblog.lpac_jni.LocalProfileInfo
|
import net.typeblog.lpac_jni.LocalProfileInfo
|
||||||
import im.angry.openeuicc.common.R
|
import im.angry.openeuicc.common.R
|
||||||
import im.angry.openeuicc.service.EuiccChannelManagerService
|
|
||||||
import im.angry.openeuicc.service.EuiccChannelManagerService.Companion.waitDone
|
|
||||||
import im.angry.openeuicc.ui.wizard.DownloadWizardActivity
|
|
||||||
import im.angry.openeuicc.util.*
|
import im.angry.openeuicc.util.*
|
||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
import kotlinx.coroutines.TimeoutCancellationException
|
|
||||||
import kotlinx.coroutines.flow.StateFlow
|
|
||||||
import kotlinx.coroutines.flow.first
|
import kotlinx.coroutines.flow.first
|
||||||
import kotlinx.coroutines.flow.stateIn
|
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
import kotlinx.coroutines.runBlocking
|
|
||||||
import kotlinx.coroutines.withContext
|
import kotlinx.coroutines.withContext
|
||||||
|
import java.lang.Exception
|
||||||
|
|
||||||
open class EuiccManagementFragment : Fragment(), EuiccProfilesChangedListener,
|
open class EuiccManagementFragment : Fragment(), EuiccProfilesChangedListener,
|
||||||
EuiccChannelFragmentMarker {
|
EuiccChannelFragmentMarker {
|
||||||
|
@ -56,21 +43,9 @@ open class EuiccManagementFragment : Fragment(), EuiccProfilesChangedListener,
|
||||||
private lateinit var swipeRefresh: SwipeRefreshLayout
|
private lateinit var swipeRefresh: SwipeRefreshLayout
|
||||||
private lateinit var fab: FloatingActionButton
|
private lateinit var fab: FloatingActionButton
|
||||||
private lateinit var profileList: RecyclerView
|
private lateinit var profileList: RecyclerView
|
||||||
private var logicalSlotId: Int = -1
|
|
||||||
private lateinit var eid: String
|
|
||||||
|
|
||||||
private val adapter = EuiccProfileAdapter()
|
private val adapter = EuiccProfileAdapter()
|
||||||
|
|
||||||
// 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?) {
|
override fun onCreate(savedInstanceState: Bundle?) {
|
||||||
super.onCreate(savedInstanceState)
|
super.onCreate(savedInstanceState)
|
||||||
setHasOptionsMenu(true)
|
setHasOptionsMenu(true)
|
||||||
|
@ -83,24 +58,9 @@ open class EuiccManagementFragment : Fragment(), EuiccProfilesChangedListener,
|
||||||
): View {
|
): View {
|
||||||
val view = inflater.inflate(R.layout.fragment_euicc, container, false)
|
val view = inflater.inflate(R.layout.fragment_euicc, container, false)
|
||||||
|
|
||||||
swipeRefresh = view.requireViewById(R.id.swipe_refresh)
|
swipeRefresh = view.findViewById(R.id.swipe_refresh)
|
||||||
fab = view.requireViewById(R.id.fab)
|
fab = view.findViewById(R.id.fab)
|
||||||
profileList = view.requireViewById(R.id.profile_list)
|
profileList = view.findViewById(R.id.profile_list)
|
||||||
|
|
||||||
val origFabMarginRight = (fab.layoutParams as ViewGroup.MarginLayoutParams).rightMargin
|
|
||||||
val origFabMarginBottom = (fab.layoutParams as ViewGroup.MarginLayoutParams).bottomMargin
|
|
||||||
ViewCompat.setOnApplyWindowInsetsListener(fab) { v, insets ->
|
|
||||||
val bars = insets.getInsets(WindowInsetsCompat.Type.systemBars())
|
|
||||||
|
|
||||||
v.updateLayoutParams<ViewGroup.MarginLayoutParams> {
|
|
||||||
rightMargin = origFabMarginRight + bars.right
|
|
||||||
bottomMargin = origFabMarginBottom + bars.bottom
|
|
||||||
}
|
|
||||||
|
|
||||||
WindowInsetsCompat.CONSUMED
|
|
||||||
}
|
|
||||||
|
|
||||||
setupRootViewInsets(profileList)
|
|
||||||
|
|
||||||
return view
|
return view
|
||||||
}
|
}
|
||||||
|
@ -113,10 +73,8 @@ open class EuiccManagementFragment : Fragment(), EuiccProfilesChangedListener,
|
||||||
LinearLayoutManager(view.context, LinearLayoutManager.VERTICAL, false)
|
LinearLayoutManager(view.context, LinearLayoutManager.VERTICAL, false)
|
||||||
|
|
||||||
fab.setOnClickListener {
|
fab.setOnClickListener {
|
||||||
Intent(requireContext(), DownloadWizardActivity::class.java).apply {
|
ProfileDownloadFragment.newInstance(slotId, portId)
|
||||||
putExtra("selectedLogicalSlot", logicalSlotId)
|
.show(childFragmentManager, ProfileDownloadFragment.TAG)
|
||||||
startActivity(this)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -134,178 +92,78 @@ open class EuiccManagementFragment : Fragment(), EuiccProfilesChangedListener,
|
||||||
inflater.inflate(R.menu.fragment_euicc, menu)
|
inflater.inflate(R.menu.fragment_euicc, menu)
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun onPrepareOptionsMenu(menu: Menu) {
|
override fun onOptionsItemSelected(item: MenuItem): Boolean =
|
||||||
super.onPrepareOptionsMenu(menu)
|
when (item.itemId) {
|
||||||
menu.findItem(R.id.show_notifications).isVisible =
|
R.id.show_notifications -> {
|
||||||
logicalSlotId != -1
|
Intent(requireContext(), NotificationsActivity::class.java).apply {
|
||||||
menu.findItem(R.id.euicc_info).isVisible =
|
putExtra("logicalSlotId", channel.logicalSlotId)
|
||||||
logicalSlotId != -1
|
startActivity(this)
|
||||||
menu.findItem(R.id.euicc_memory_reset).isVisible =
|
}
|
||||||
runBlocking { preferenceRepository.euiccMemoryResetFlow.first() }
|
true
|
||||||
}
|
|
||||||
|
|
||||||
override fun onOptionsItemSelected(item: MenuItem) = when (item.itemId) {
|
|
||||||
R.id.show_notifications -> {
|
|
||||||
Intent(requireContext(), NotificationsActivity::class.java).apply {
|
|
||||||
putExtra("logicalSlotId", logicalSlotId)
|
|
||||||
startActivity(this)
|
|
||||||
}
|
}
|
||||||
true
|
else -> super.onOptionsItemSelected(item)
|
||||||
}
|
}
|
||||||
|
|
||||||
R.id.euicc_info -> {
|
protected open suspend fun onCreateFooterViews(parent: ViewGroup): List<View> = listOf()
|
||||||
Intent(requireContext(), EuiccInfoActivity::class.java).apply {
|
|
||||||
putExtra("logicalSlotId", logicalSlotId)
|
|
||||||
startActivity(this)
|
|
||||||
}
|
|
||||||
true
|
|
||||||
}
|
|
||||||
|
|
||||||
R.id.euicc_memory_reset -> {
|
|
||||||
EuiccMemoryResetFragment.newInstance(slotId, portId, eid)
|
|
||||||
.show(childFragmentManager, EuiccMemoryResetFragment.TAG)
|
|
||||||
true
|
|
||||||
}
|
|
||||||
|
|
||||||
else -> super.onOptionsItemSelected(item)
|
|
||||||
}
|
|
||||||
|
|
||||||
protected open suspend fun onCreateFooterViews(
|
|
||||||
parent: ViewGroup,
|
|
||||||
profiles: List<LocalProfileInfo>
|
|
||||||
): List<View> =
|
|
||||||
if (profiles.isEmpty()) {
|
|
||||||
val view = layoutInflater.inflate(R.layout.footer_no_profile, parent, false)
|
|
||||||
listOf(view)
|
|
||||||
} else {
|
|
||||||
listOf()
|
|
||||||
}
|
|
||||||
|
|
||||||
|
@SuppressLint("NotifyDataSetChanged")
|
||||||
private fun refresh() {
|
private fun refresh() {
|
||||||
if (invalid) return
|
|
||||||
swipeRefresh.isRefreshing = true
|
swipeRefresh.isRefreshing = true
|
||||||
|
|
||||||
lifecycleScope.launch {
|
lifecycleScope.launch {
|
||||||
doRefresh()
|
val profiles = withContext(Dispatchers.IO) {
|
||||||
}
|
euiccChannelManager.notifyEuiccProfilesChanged(channel.logicalSlotId)
|
||||||
}
|
|
||||||
|
|
||||||
@SuppressLint("NotifyDataSetChanged")
|
|
||||||
protected open suspend fun doRefresh() {
|
|
||||||
ensureEuiccChannelManager()
|
|
||||||
euiccChannelManagerService.waitForForegroundTask()
|
|
||||||
|
|
||||||
if (!::disableSafeguardFlow.isInitialized) {
|
|
||||||
disableSafeguardFlow =
|
|
||||||
preferenceRepository.disableSafeguardFlow.stateIn(lifecycleScope)
|
|
||||||
}
|
|
||||||
if (!::unfilteredProfileListFlow.isInitialized) {
|
|
||||||
unfilteredProfileListFlow =
|
|
||||||
preferenceRepository.unfilteredProfileListFlow.stateIn(lifecycleScope)
|
|
||||||
}
|
|
||||||
|
|
||||||
val profiles = withEuiccChannel { channel ->
|
|
||||||
logicalSlotId = channel.logicalSlotId
|
|
||||||
eid = channel.lpa.eID
|
|
||||||
euiccChannelManager.notifyEuiccProfilesChanged(channel.logicalSlotId)
|
|
||||||
if (unfilteredProfileListFlow.value)
|
|
||||||
channel.lpa.profiles
|
channel.lpa.profiles
|
||||||
else
|
}
|
||||||
channel.lpa.profiles.operational
|
|
||||||
}
|
|
||||||
|
|
||||||
withContext(Dispatchers.Main) {
|
withContext(Dispatchers.Main) {
|
||||||
adapter.profiles = profiles
|
adapter.profiles = profiles.operational
|
||||||
adapter.footerViews = onCreateFooterViews(profileList, profiles)
|
adapter.footerViews = onCreateFooterViews(profileList)
|
||||||
adapter.notifyDataSetChanged()
|
adapter.notifyDataSetChanged()
|
||||||
swipeRefresh.isRefreshing = false
|
swipeRefresh.isRefreshing = false
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private suspend fun showSwitchFailureText() = withContext(Dispatchers.Main) {
|
|
||||||
Toast.makeText(
|
|
||||||
context,
|
|
||||||
R.string.toast_profile_enable_failed,
|
|
||||||
Toast.LENGTH_LONG
|
|
||||||
).show()
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun enableOrDisableProfile(iccid: String, enable: Boolean) {
|
private fun enableOrDisableProfile(iccid: String, enable: Boolean) {
|
||||||
swipeRefresh.isRefreshing = true
|
swipeRefresh.isRefreshing = true
|
||||||
fab.isEnabled = false
|
fab.isEnabled = false
|
||||||
|
|
||||||
lifecycleScope.launch {
|
lifecycleScope.launch {
|
||||||
ensureEuiccChannelManager()
|
try {
|
||||||
euiccChannelManagerService.waitForForegroundTask()
|
if (enable) {
|
||||||
|
doEnableProfile(iccid)
|
||||||
val err = euiccChannelManagerService.launchProfileSwitchTask(
|
} else {
|
||||||
slotId,
|
doDisableProfile(iccid)
|
||||||
portId,
|
|
||||||
iccid,
|
|
||||||
enable,
|
|
||||||
reconnectTimeoutMillis = 30 * 1000
|
|
||||||
).waitDone()
|
|
||||||
|
|
||||||
when (err) {
|
|
||||||
null -> {}
|
|
||||||
is EuiccChannelManagerService.SwitchingProfilesRefreshException -> {
|
|
||||||
// This is only really fatal for internal eSIMs
|
|
||||||
if (!isUsb) {
|
|
||||||
withContext(Dispatchers.Main) {
|
|
||||||
AlertDialog.Builder(requireContext()).apply {
|
|
||||||
setMessage(R.string.switch_did_not_refresh)
|
|
||||||
setPositiveButton(android.R.string.ok) { dialog, _ ->
|
|
||||||
dialog.dismiss()
|
|
||||||
requireActivity().finish()
|
|
||||||
}
|
|
||||||
setOnDismissListener { _ ->
|
|
||||||
requireActivity().finish()
|
|
||||||
}
|
|
||||||
show()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
refresh()
|
||||||
is TimeoutCancellationException -> {
|
fab.isEnabled = true
|
||||||
withContext(Dispatchers.Main) {
|
} catch (e: Exception) {
|
||||||
// Prevent this Fragment from being used again
|
Log.d(TAG, "Failed to enable / disable profile $iccid")
|
||||||
invalid = true
|
Log.d(TAG, Log.getStackTraceString(e))
|
||||||
// Timed out waiting for SIM to come back online, we can no longer assume that the LPA is still valid
|
fab.isEnabled = true
|
||||||
AlertDialog.Builder(requireContext()).apply {
|
Toast.makeText(context, R.string.toast_profile_enable_failed, Toast.LENGTH_LONG).show()
|
||||||
setMessage(appContainer.customizableTextProvider.profileSwitchingTimeoutMessage)
|
|
||||||
setPositiveButton(android.R.string.ok) { dialog, _ ->
|
|
||||||
dialog.dismiss()
|
|
||||||
requireActivity().finish()
|
|
||||||
}
|
|
||||||
setOnDismissListener { _ ->
|
|
||||||
requireActivity().finish()
|
|
||||||
}
|
|
||||||
show()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
else -> showSwitchFailureText()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
refresh()
|
|
||||||
fab.isEnabled = true
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private suspend fun doEnableProfile(iccid: String) =
|
||||||
|
channel.lpa.beginOperation {
|
||||||
|
channel.lpa.enableProfile(iccid, reconnectTimeout = 15 * 1000) &&
|
||||||
|
preferenceRepository.notificationEnableFlow.first()
|
||||||
|
}
|
||||||
|
|
||||||
|
private suspend fun doDisableProfile(iccid: String) =
|
||||||
|
channel.lpa.beginOperation {
|
||||||
|
channel.lpa.disableProfile(iccid, reconnectTimeout = 15 * 1000) &&
|
||||||
|
preferenceRepository.notificationDisableFlow.first()
|
||||||
|
}
|
||||||
|
|
||||||
protected open fun populatePopupWithProfileActions(popup: PopupMenu, profile: LocalProfileInfo) {
|
protected open fun populatePopupWithProfileActions(popup: PopupMenu, profile: LocalProfileInfo) {
|
||||||
popup.inflate(R.menu.profile_options)
|
popup.inflate(R.menu.profile_options)
|
||||||
if (profile.isEnabled) {
|
if (profile.isEnabled) {
|
||||||
popup.menu.findItem(R.id.enable).isVisible = false
|
popup.menu.findItem(R.id.enable).isVisible = false
|
||||||
popup.menu.findItem(R.id.delete).isVisible = false
|
popup.menu.findItem(R.id.delete).isVisible = false
|
||||||
|
|
||||||
// We hide the disable option by default to avoid "bricking" some cards that won't get
|
|
||||||
// recognized again by the phone's modem. However we don't have that worry if we are
|
|
||||||
// accessing it through a USB card reader, or when the user explicitly opted in
|
|
||||||
if (isUsb || disableSafeguardFlow.value) {
|
|
||||||
popup.menu.findItem(R.id.disable).isVisible = true
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -316,19 +174,12 @@ open class EuiccManagementFragment : Fragment(), EuiccProfilesChangedListener,
|
||||||
|
|
||||||
companion object {
|
companion object {
|
||||||
fun fromInt(value: Int) =
|
fun fromInt(value: Int) =
|
||||||
entries.first { it.value == value }
|
Type.values().first { it.value == value }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
inner class FooterViewHolder: ViewHolder(FrameLayout(requireContext())) {
|
inner class FooterViewHolder: ViewHolder(FrameLayout(requireContext())) {
|
||||||
init {
|
|
||||||
itemView.layoutParams = ViewGroup.LayoutParams(
|
|
||||||
ViewGroup.LayoutParams.MATCH_PARENT,
|
|
||||||
ViewGroup.LayoutParams.WRAP_CONTENT
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
fun attach(view: View) {
|
fun attach(view: View) {
|
||||||
view.parent?.let { (it as ViewGroup).removeView(view) }
|
view.parent?.let { (it as ViewGroup).removeView(view) }
|
||||||
(itemView as FrameLayout).addView(view)
|
(itemView as FrameLayout).addView(view)
|
||||||
|
@ -340,13 +191,11 @@ open class EuiccManagementFragment : Fragment(), EuiccProfilesChangedListener,
|
||||||
}
|
}
|
||||||
|
|
||||||
inner class ProfileViewHolder(private val root: View) : ViewHolder(root) {
|
inner class ProfileViewHolder(private val root: View) : ViewHolder(root) {
|
||||||
private val iccid: TextView = root.requireViewById(R.id.iccid)
|
private val iccid: TextView = root.findViewById(R.id.iccid)
|
||||||
private val name: TextView = root.requireViewById(R.id.name)
|
private val name: TextView = root.findViewById(R.id.name)
|
||||||
private val state: TextView = root.requireViewById(R.id.state)
|
private val state: TextView = root.findViewById(R.id.state)
|
||||||
private val provider: TextView = root.requireViewById(R.id.provider)
|
private val provider: TextView = root.findViewById(R.id.provider)
|
||||||
private val profileClassLabel: TextView = root.requireViewById(R.id.profile_class_label)
|
private val profileMenu: ImageButton = root.findViewById(R.id.profile_menu)
|
||||||
private val profileClass: TextView = root.requireViewById(R.id.profile_class)
|
|
||||||
private val profileMenu: ImageButton = root.requireViewById(R.id.profile_menu)
|
|
||||||
|
|
||||||
init {
|
init {
|
||||||
iccid.setOnClickListener {
|
iccid.setOnClickListener {
|
||||||
|
@ -357,15 +206,6 @@ open class EuiccManagementFragment : Fragment(), EuiccProfilesChangedListener,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
iccid.setOnLongClickListener {
|
|
||||||
requireContext().getSystemService(ClipboardManager::class.java)!!
|
|
||||||
.setPrimaryClip(ClipData.newPlainText("iccid", iccid.text))
|
|
||||||
if (Build.VERSION.SDK_INT <= Build.VERSION_CODES.S_V2) Toast
|
|
||||||
.makeText(requireContext(), R.string.toast_iccid_copied, Toast.LENGTH_SHORT)
|
|
||||||
.show()
|
|
||||||
true
|
|
||||||
}
|
|
||||||
|
|
||||||
profileMenu.setOnClickListener { showOptionsMenu() }
|
profileMenu.setOnClickListener { showOptionsMenu() }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -383,23 +223,11 @@ open class EuiccManagementFragment : Fragment(), EuiccProfilesChangedListener,
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
provider.text = profile.providerName
|
provider.text = profile.providerName
|
||||||
profileClassLabel.isVisible = unfilteredProfileListFlow.value
|
|
||||||
profileClass.isVisible = unfilteredProfileListFlow.value
|
|
||||||
profileClass.setText(
|
|
||||||
when (profile.profileClass) {
|
|
||||||
LocalProfileInfo.Clazz.Testing -> R.string.profile_class_testing
|
|
||||||
LocalProfileInfo.Clazz.Provisioning -> R.string.profile_class_provisioning
|
|
||||||
LocalProfileInfo.Clazz.Operational -> R.string.profile_class_operational
|
|
||||||
}
|
|
||||||
)
|
|
||||||
iccid.text = profile.iccid
|
iccid.text = profile.iccid
|
||||||
iccid.transformationMethod = PasswordTransformationMethod.getInstance()
|
iccid.transformationMethod = PasswordTransformationMethod.getInstance()
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun showOptionsMenu() {
|
private fun showOptionsMenu() {
|
||||||
// Prevent users from doing multiple things at once
|
|
||||||
if (invalid || swipeRefresh.isRefreshing) return
|
|
||||||
|
|
||||||
PopupMenu(root.context, profileMenu).apply {
|
PopupMenu(root.context, profileMenu).apply {
|
||||||
setOnMenuItemClickListener(::onMenuItemClicked)
|
setOnMenuItemClickListener(::onMenuItemClicked)
|
||||||
populatePopupWithProfileActions(this, profile)
|
populatePopupWithProfileActions(this, profile)
|
||||||
|
|
|
@ -1,126 +0,0 @@
|
||||||
package im.angry.openeuicc.ui
|
|
||||||
|
|
||||||
import android.graphics.Typeface
|
|
||||||
import android.os.Bundle
|
|
||||||
import android.text.Editable
|
|
||||||
import android.util.Log
|
|
||||||
import android.widget.EditText
|
|
||||||
import android.widget.Toast
|
|
||||||
import androidx.appcompat.app.AlertDialog
|
|
||||||
import androidx.fragment.app.DialogFragment
|
|
||||||
import androidx.fragment.app.Fragment
|
|
||||||
import androidx.lifecycle.lifecycleScope
|
|
||||||
import im.angry.openeuicc.common.R
|
|
||||||
import im.angry.openeuicc.service.EuiccChannelManagerService.Companion.waitDone
|
|
||||||
import im.angry.openeuicc.util.EuiccChannelFragmentMarker
|
|
||||||
import im.angry.openeuicc.util.EuiccProfilesChangedListener
|
|
||||||
import im.angry.openeuicc.util.ensureEuiccChannelManager
|
|
||||||
import im.angry.openeuicc.util.euiccChannelManagerService
|
|
||||||
import im.angry.openeuicc.util.newInstanceEuicc
|
|
||||||
import im.angry.openeuicc.util.notifyEuiccProfilesChanged
|
|
||||||
import im.angry.openeuicc.util.portId
|
|
||||||
import im.angry.openeuicc.util.slotId
|
|
||||||
import kotlinx.coroutines.flow.onStart
|
|
||||||
import kotlinx.coroutines.launch
|
|
||||||
|
|
||||||
class EuiccMemoryResetFragment : DialogFragment(), EuiccChannelFragmentMarker {
|
|
||||||
companion object {
|
|
||||||
const val TAG = "EuiccMemoryResetFragment"
|
|
||||||
|
|
||||||
private const val FIELD_EID = "eid"
|
|
||||||
|
|
||||||
fun newInstance(slotId: Int, portId: Int, eid: String) =
|
|
||||||
newInstanceEuicc(EuiccMemoryResetFragment::class.java, slotId, portId) {
|
|
||||||
putString(FIELD_EID, eid)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private val eid: String by lazy { requireArguments().getString(FIELD_EID)!! }
|
|
||||||
|
|
||||||
private val confirmText: String by lazy {
|
|
||||||
getString(R.string.euicc_memory_reset_confirm_text, eid.takeLast(8))
|
|
||||||
}
|
|
||||||
|
|
||||||
private inline val isMatched: Boolean
|
|
||||||
get() = editText.text.toString() == confirmText
|
|
||||||
|
|
||||||
private var confirmed = false
|
|
||||||
|
|
||||||
private var toast: Toast? = null
|
|
||||||
set(value) {
|
|
||||||
toast?.cancel()
|
|
||||||
field = value
|
|
||||||
value?.show()
|
|
||||||
}
|
|
||||||
|
|
||||||
private val editText by lazy {
|
|
||||||
EditText(requireContext()).apply {
|
|
||||||
isLongClickable = false
|
|
||||||
typeface = Typeface.MONOSPACE
|
|
||||||
hint = Editable.Factory.getInstance()
|
|
||||||
.newEditable(getString(R.string.euicc_memory_reset_hint_text, confirmText))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private inline val alertDialog: AlertDialog
|
|
||||||
get() = requireDialog() as AlertDialog
|
|
||||||
|
|
||||||
override fun onCreateDialog(savedInstanceState: Bundle?) =
|
|
||||||
AlertDialog.Builder(requireContext(), R.style.AlertDialogTheme)
|
|
||||||
.setTitle(R.string.euicc_memory_reset_title)
|
|
||||||
.setMessage(getString(R.string.euicc_memory_reset_message, eid, confirmText))
|
|
||||||
.setView(editText)
|
|
||||||
// Set listener to null to prevent auto closing
|
|
||||||
.setNegativeButton(android.R.string.cancel, null)
|
|
||||||
.setPositiveButton(R.string.euicc_memory_reset_invoke_button, null)
|
|
||||||
.create()
|
|
||||||
|
|
||||||
override fun onResume() {
|
|
||||||
super.onResume()
|
|
||||||
alertDialog.setCanceledOnTouchOutside(false)
|
|
||||||
alertDialog.getButton(AlertDialog.BUTTON_POSITIVE)
|
|
||||||
.setOnClickListener { if (!confirmed) confirmation() }
|
|
||||||
alertDialog.getButton(AlertDialog.BUTTON_NEGATIVE)
|
|
||||||
.setOnClickListener { if (!confirmed) dismiss() }
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun confirmation() {
|
|
||||||
toast?.cancel()
|
|
||||||
if (!isMatched) {
|
|
||||||
Log.d(TAG, buildString {
|
|
||||||
appendLine("User input is mismatch:")
|
|
||||||
appendLine(editText.text)
|
|
||||||
appendLine(confirmText)
|
|
||||||
})
|
|
||||||
val resId = R.string.toast_euicc_memory_reset_confirm_text_mismatched
|
|
||||||
toast = Toast.makeText(requireContext(), resId, Toast.LENGTH_LONG)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
confirmed = true
|
|
||||||
preventUserAction()
|
|
||||||
|
|
||||||
requireParentFragment().lifecycleScope.launch {
|
|
||||||
ensureEuiccChannelManager()
|
|
||||||
euiccChannelManagerService.waitForForegroundTask()
|
|
||||||
|
|
||||||
euiccChannelManagerService.launchMemoryReset(slotId, portId)
|
|
||||||
.onStart {
|
|
||||||
parentFragment?.notifyEuiccProfilesChanged()
|
|
||||||
|
|
||||||
val resId = R.string.toast_euicc_memory_reset_finitshed
|
|
||||||
toast = Toast.makeText(requireContext(), resId, Toast.LENGTH_LONG)
|
|
||||||
|
|
||||||
runCatching(::dismiss)
|
|
||||||
}
|
|
||||||
.waitDone()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun preventUserAction() {
|
|
||||||
editText.isEnabled = false
|
|
||||||
alertDialog.setCancelable(false)
|
|
||||||
alertDialog.setCanceledOnTouchOutside(false)
|
|
||||||
alertDialog.getButton(AlertDialog.BUTTON_POSITIVE).isEnabled = false
|
|
||||||
alertDialog.getButton(AlertDialog.BUTTON_NEGATIVE).isEnabled = false
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -1,72 +0,0 @@
|
||||||
package im.angry.openeuicc.ui
|
|
||||||
|
|
||||||
import android.os.Bundle
|
|
||||||
import android.text.Editable
|
|
||||||
import android.view.Menu
|
|
||||||
import android.view.MenuItem
|
|
||||||
import android.widget.EditText
|
|
||||||
import android.widget.Toast
|
|
||||||
import androidx.activity.enableEdgeToEdge
|
|
||||||
import androidx.appcompat.app.AppCompatActivity
|
|
||||||
import androidx.lifecycle.lifecycleScope
|
|
||||||
import im.angry.openeuicc.common.R
|
|
||||||
import im.angry.openeuicc.util.preferenceRepository
|
|
||||||
import im.angry.openeuicc.util.setupToolbarInsets
|
|
||||||
import kotlinx.coroutines.flow.collect
|
|
||||||
import kotlinx.coroutines.flow.onEach
|
|
||||||
import kotlinx.coroutines.launch
|
|
||||||
|
|
||||||
class IsdrAidListActivity : AppCompatActivity() {
|
|
||||||
private lateinit var isdrAidListEditor: EditText
|
|
||||||
|
|
||||||
override fun onCreate(savedInstanceState: Bundle?) {
|
|
||||||
enableEdgeToEdge()
|
|
||||||
super.onCreate(savedInstanceState)
|
|
||||||
setContentView(R.layout.activity_isdr_aid_list)
|
|
||||||
setSupportActionBar(requireViewById(R.id.toolbar))
|
|
||||||
setupToolbarInsets()
|
|
||||||
supportActionBar!!.setDisplayHomeAsUpEnabled(true)
|
|
||||||
|
|
||||||
isdrAidListEditor = requireViewById(R.id.isdr_aid_list_editor)
|
|
||||||
|
|
||||||
lifecycleScope.launch {
|
|
||||||
preferenceRepository.isdrAidListFlow.onEach {
|
|
||||||
isdrAidListEditor.text = Editable.Factory.getInstance().newEditable(it)
|
|
||||||
}.collect()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun onCreateOptionsMenu(menu: Menu?): Boolean {
|
|
||||||
menuInflater.inflate(R.menu.activity_isdr_aid_list, menu)
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun onOptionsItemSelected(item: MenuItem): Boolean =
|
|
||||||
when (item.itemId) {
|
|
||||||
R.id.save -> {
|
|
||||||
lifecycleScope.launch {
|
|
||||||
preferenceRepository.isdrAidListFlow.updatePreference(isdrAidListEditor.text.toString())
|
|
||||||
Toast.makeText(
|
|
||||||
this@IsdrAidListActivity,
|
|
||||||
R.string.isdr_aid_list_saved,
|
|
||||||
Toast.LENGTH_SHORT
|
|
||||||
).show()
|
|
||||||
}
|
|
||||||
true
|
|
||||||
}
|
|
||||||
|
|
||||||
R.id.reset -> {
|
|
||||||
lifecycleScope.launch {
|
|
||||||
preferenceRepository.isdrAidListFlow.removePreference()
|
|
||||||
}
|
|
||||||
true
|
|
||||||
}
|
|
||||||
|
|
||||||
android.R.id.home -> {
|
|
||||||
finish()
|
|
||||||
true
|
|
||||||
}
|
|
||||||
|
|
||||||
else -> super.onOptionsItemSelected(item)
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -1,14 +1,13 @@
|
||||||
package im.angry.openeuicc.ui
|
package im.angry.openeuicc.ui
|
||||||
|
|
||||||
import android.icu.text.SimpleDateFormat
|
import android.icu.text.SimpleDateFormat
|
||||||
import android.os.Build
|
|
||||||
import android.os.Bundle
|
import android.os.Bundle
|
||||||
import android.view.Menu
|
import android.view.Menu
|
||||||
import android.view.MenuItem
|
import android.view.MenuItem
|
||||||
import android.view.View
|
import android.view.View
|
||||||
import android.widget.ScrollView
|
import android.widget.ScrollView
|
||||||
import android.widget.TextView
|
import android.widget.TextView
|
||||||
import androidx.activity.enableEdgeToEdge
|
import androidx.activity.result.contract.ActivityResultContracts
|
||||||
import androidx.appcompat.app.AppCompatActivity
|
import androidx.appcompat.app.AppCompatActivity
|
||||||
import androidx.lifecycle.lifecycleScope
|
import androidx.lifecycle.lifecycleScope
|
||||||
import androidx.swiperefreshlayout.widget.SwipeRefreshLayout
|
import androidx.swiperefreshlayout.widget.SwipeRefreshLayout
|
||||||
|
@ -17,6 +16,7 @@ import im.angry.openeuicc.util.*
|
||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
import kotlinx.coroutines.withContext
|
import kotlinx.coroutines.withContext
|
||||||
|
import java.io.FileOutputStream
|
||||||
import java.util.Date
|
import java.util.Date
|
||||||
|
|
||||||
class LogsActivity : AppCompatActivity() {
|
class LogsActivity : AppCompatActivity() {
|
||||||
|
@ -26,39 +26,25 @@ class LogsActivity : AppCompatActivity() {
|
||||||
private lateinit var logStr: String
|
private lateinit var logStr: String
|
||||||
|
|
||||||
private val saveLogs =
|
private val saveLogs =
|
||||||
setupLogSaving(
|
registerForActivityResult(ActivityResultContracts.CreateDocument("text/plain")) { uri ->
|
||||||
getLogFileName = {
|
if (uri == null) return@registerForActivityResult
|
||||||
getString(
|
if (!this::logStr.isInitialized) return@registerForActivityResult
|
||||||
R.string.logs_filename_template,
|
contentResolver.openFileDescriptor(uri, "w")?.use {
|
||||||
SimpleDateFormat.getDateTimeInstance().format(Date())
|
FileOutputStream(it.fileDescriptor).use { os ->
|
||||||
)
|
os.write(logStr.encodeToByteArray())
|
||||||
},
|
}
|
||||||
getLogText = ::buildLogText
|
}
|
||||||
)
|
}
|
||||||
|
|
||||||
private fun buildLogText() = buildString {
|
|
||||||
appendLine("Manufacturer: ${Build.MANUFACTURER}")
|
|
||||||
appendLine("Brand: ${Build.BRAND}")
|
|
||||||
appendLine("Model: ${Build.MODEL}")
|
|
||||||
appendLine("SDK Version: ${Build.VERSION.SDK_INT}")
|
|
||||||
appendLine("App Version: $selfAppVersion")
|
|
||||||
appendLine("-".repeat(10))
|
|
||||||
appendLine(logStr)
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun onCreate(savedInstanceState: Bundle?) {
|
override fun onCreate(savedInstanceState: Bundle?) {
|
||||||
enableEdgeToEdge()
|
|
||||||
super.onCreate(savedInstanceState)
|
super.onCreate(savedInstanceState)
|
||||||
setContentView(R.layout.activity_logs)
|
setContentView(R.layout.activity_logs)
|
||||||
setSupportActionBar(requireViewById(R.id.toolbar))
|
setSupportActionBar(findViewById(R.id.toolbar))
|
||||||
setupToolbarInsets()
|
|
||||||
supportActionBar!!.setDisplayHomeAsUpEnabled(true)
|
supportActionBar!!.setDisplayHomeAsUpEnabled(true)
|
||||||
|
|
||||||
swipeRefresh = requireViewById(R.id.swipe_refresh)
|
swipeRefresh = findViewById(R.id.swipe_refresh)
|
||||||
scrollView = requireViewById(R.id.scroll_view)
|
scrollView = findViewById(R.id.scroll_view)
|
||||||
logText = requireViewById(R.id.log_text)
|
logText = findViewById(R.id.log_text)
|
||||||
|
|
||||||
setupRootViewInsets(scrollView)
|
|
||||||
|
|
||||||
swipeRefresh.setOnRefreshListener {
|
swipeRefresh.setOnRefreshListener {
|
||||||
lifecycleScope.launch {
|
lifecycleScope.launch {
|
||||||
|
@ -80,12 +66,10 @@ class LogsActivity : AppCompatActivity() {
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun onOptionsItemSelected(item: MenuItem): Boolean = when (item.itemId) {
|
override fun onOptionsItemSelected(item: MenuItem): Boolean = when (item.itemId) {
|
||||||
android.R.id.home -> {
|
|
||||||
finish()
|
|
||||||
true
|
|
||||||
}
|
|
||||||
R.id.save -> {
|
R.id.save -> {
|
||||||
saveLogs()
|
saveLogs.launch(getString(R.string.logs_filename_template,
|
||||||
|
SimpleDateFormat.getDateTimeInstance().format(Date())
|
||||||
|
))
|
||||||
true
|
true
|
||||||
}
|
}
|
||||||
else -> super.onOptionsItemSelected(item)
|
else -> super.onOptionsItemSelected(item)
|
||||||
|
|
|
@ -1,234 +1,116 @@
|
||||||
package im.angry.openeuicc.ui
|
package im.angry.openeuicc.ui
|
||||||
|
|
||||||
import android.annotation.SuppressLint
|
|
||||||
import android.content.BroadcastReceiver
|
|
||||||
import android.content.Context
|
|
||||||
import android.content.Intent
|
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.os.Bundle
|
||||||
import android.telephony.TelephonyManager
|
import android.telephony.TelephonyManager
|
||||||
import android.util.Log
|
import android.util.Log
|
||||||
import android.view.Menu
|
import android.view.Menu
|
||||||
import android.view.MenuItem
|
import android.view.MenuItem
|
||||||
import android.view.View
|
import android.view.View
|
||||||
import android.widget.ProgressBar
|
import android.widget.AdapterView
|
||||||
import androidx.activity.enableEdgeToEdge
|
import android.widget.ArrayAdapter
|
||||||
import androidx.fragment.app.Fragment
|
import android.widget.Spinner
|
||||||
|
import androidx.appcompat.app.AppCompatActivity
|
||||||
import androidx.lifecycle.lifecycleScope
|
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.common.R
|
||||||
import im.angry.openeuicc.core.EuiccChannelManager
|
import im.angry.openeuicc.core.EuiccChannel
|
||||||
import im.angry.openeuicc.util.*
|
import im.angry.openeuicc.util.*
|
||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
import kotlinx.coroutines.flow.collect
|
|
||||||
import kotlinx.coroutines.flow.first
|
|
||||||
import kotlinx.coroutines.flow.onEach
|
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
import kotlinx.coroutines.withContext
|
import kotlinx.coroutines.withContext
|
||||||
|
|
||||||
@SuppressLint("NotifyDataSetChanged")
|
open class MainActivity : AppCompatActivity(), OpenEuiccContextMarker {
|
||||||
open class MainActivity : BaseEuiccAccessActivity(), OpenEuiccContextMarker {
|
|
||||||
companion object {
|
companion object {
|
||||||
const val TAG = "MainActivity"
|
const val TAG = "MainActivity"
|
||||||
|
|
||||||
const val PERMISSION_REQUEST_CODE = 1000
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private lateinit var loadingProgress: ProgressBar
|
private lateinit var spinnerAdapter: ArrayAdapter<String>
|
||||||
private lateinit var tabs: TabLayout
|
private lateinit var spinner: Spinner
|
||||||
private lateinit var viewPager: ViewPager2
|
|
||||||
|
|
||||||
private var refreshing = false
|
private val fragments = arrayListOf<EuiccManagementFragment>()
|
||||||
|
|
||||||
private data class Page(
|
private lateinit var noEuiccPlaceholder: View
|
||||||
val logicalSlotId: Int,
|
|
||||||
val title: String,
|
|
||||||
val createFragment: () -> Fragment
|
|
||||||
)
|
|
||||||
|
|
||||||
private val pages: MutableList<Page> = mutableListOf()
|
|
||||||
|
|
||||||
private val pagerAdapter by lazy {
|
|
||||||
object : FragmentStateAdapter(this) {
|
|
||||||
override fun getItemCount() = pages.size
|
|
||||||
|
|
||||||
override fun createFragment(position: Int): Fragment = pages[position].createFragment()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
protected lateinit var tm: TelephonyManager
|
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?) {
|
override fun onCreate(savedInstanceState: Bundle?) {
|
||||||
enableEdgeToEdge()
|
|
||||||
super.onCreate(savedInstanceState)
|
super.onCreate(savedInstanceState)
|
||||||
setContentView(R.layout.activity_main)
|
setContentView(R.layout.activity_main)
|
||||||
setSupportActionBar(requireViewById(R.id.toolbar))
|
setSupportActionBar(findViewById(R.id.toolbar))
|
||||||
setupToolbarInsets()
|
|
||||||
loadingProgress = requireViewById(R.id.loading)
|
|
||||||
tabs = requireViewById(R.id.main_tabs)
|
|
||||||
viewPager = requireViewById(R.id.view_pager)
|
|
||||||
|
|
||||||
viewPager.adapter = pagerAdapter
|
noEuiccPlaceholder = findViewById(R.id.no_euicc_placeholder)
|
||||||
TabLayoutMediator(tabs, viewPager) { tab, pos ->
|
|
||||||
tab.text = pages[pos].title
|
|
||||||
}.attach()
|
|
||||||
|
|
||||||
tm = telephonyManager
|
tm = telephonyManager
|
||||||
|
|
||||||
registerReceiver(usbReceiver, IntentFilter().apply {
|
spinnerAdapter = ArrayAdapter<String>(this, R.layout.spinner_item)
|
||||||
addAction(UsbManager.ACTION_USB_DEVICE_ATTACHED)
|
|
||||||
addAction(UsbManager.ACTION_USB_DEVICE_DETACHED)
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun onDestroy() {
|
lifecycleScope.launch {
|
||||||
super.onDestroy()
|
init()
|
||||||
unregisterReceiver(usbReceiver)
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun onCreateOptionsMenu(menu: Menu): Boolean {
|
override fun onCreateOptionsMenu(menu: Menu): Boolean {
|
||||||
menuInflater.inflate(R.menu.activity_main, menu)
|
menuInflater.inflate(R.menu.activity_main, menu)
|
||||||
|
|
||||||
|
if (!this::spinner.isInitialized) {
|
||||||
|
spinner = menu.findItem(R.id.spinner).actionView as Spinner
|
||||||
|
spinner.adapter = spinnerAdapter
|
||||||
|
spinner.onItemSelectedListener = object : AdapterView.OnItemSelectedListener {
|
||||||
|
override fun onItemSelected(
|
||||||
|
parent: AdapterView<*>?,
|
||||||
|
view: View?,
|
||||||
|
position: Int,
|
||||||
|
id: Long
|
||||||
|
) {
|
||||||
|
supportFragmentManager.beginTransaction()
|
||||||
|
.replace(R.id.fragment_root, fragments[position]).commit()
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onNothingSelected(parent: AdapterView<*>?) {
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Fragments may cause this menu to be inflated multiple times.
|
||||||
|
// Simply reuse the action view in that case
|
||||||
|
menu.findItem(R.id.spinner).actionView = spinner
|
||||||
|
}
|
||||||
|
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun onOptionsItemSelected(item: MenuItem): Boolean =
|
override fun onOptionsItemSelected(item: MenuItem): Boolean =
|
||||||
when (item.itemId) {
|
when (item.itemId) {
|
||||||
R.id.settings -> {
|
R.id.settings -> {
|
||||||
startActivity(Intent(this, SettingsActivity::class.java))
|
startActivity(Intent(this, SettingsActivity::class.java));
|
||||||
true
|
|
||||||
}
|
|
||||||
R.id.reload -> {
|
|
||||||
refresh()
|
|
||||||
true
|
true
|
||||||
}
|
}
|
||||||
else -> super.onOptionsItemSelected(item)
|
else -> super.onOptionsItemSelected(item)
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun onInit() {
|
private suspend fun init() {
|
||||||
lifecycleScope.launch {
|
withContext(Dispatchers.IO) {
|
||||||
init()
|
euiccChannelManager.enumerateEuiccChannels()
|
||||||
}
|
euiccChannelManager.knownChannels.forEach {
|
||||||
}
|
Log.d(TAG, "slot ${it.slotId} port ${it.portId}")
|
||||||
|
Log.d(TAG, it.lpa.eID)
|
||||||
private fun ensureNotificationPermissions() {
|
|
||||||
val needsNotificationPerms = Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU;
|
|
||||||
val notificationPermsGranted =
|
|
||||||
needsNotificationPerms && checkSelfPermission(android.Manifest.permission.POST_NOTIFICATIONS) == PackageManager.PERMISSION_GRANTED
|
|
||||||
if (needsNotificationPerms && !notificationPermsGranted) {
|
|
||||||
requestPermissions(
|
|
||||||
arrayOf(android.Manifest.permission.POST_NOTIFICATIONS),
|
|
||||||
PERMISSION_REQUEST_CODE
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private suspend fun init(fromUsbEvent: Boolean = false) {
|
|
||||||
refreshing = true // We don't check this here -- the check happens in refresh()
|
|
||||||
loadingProgress.visibility = View.VISIBLE
|
|
||||||
viewPager.visibility = View.GONE
|
|
||||||
tabs.visibility = View.GONE
|
|
||||||
// Prevent concurrent access with any running foreground task
|
|
||||||
euiccChannelManagerService.waitForForegroundTask()
|
|
||||||
|
|
||||||
val (usbDevice, _) = withContext(Dispatchers.IO) {
|
|
||||||
euiccChannelManager.tryOpenUsbEuiccChannel()
|
|
||||||
}
|
|
||||||
|
|
||||||
val newPages: MutableList<Page> = mutableListOf()
|
|
||||||
|
|
||||||
euiccChannelManager.flowInternalEuiccPorts().onEach { (slotId, portId) ->
|
|
||||||
Log.d(TAG, "slot $slotId port $portId")
|
|
||||||
|
|
||||||
euiccChannelManager.withEuiccChannel(slotId, portId) { channel ->
|
|
||||||
if (preferenceRepository.verboseLoggingFlow.first()) {
|
|
||||||
Log.d(TAG, channel.lpa.eID)
|
|
||||||
}
|
|
||||||
// Request the system to refresh the list of profiles every time we start
|
// Request the system to refresh the list of profiles every time we start
|
||||||
// Note that this is currently supposed to be no-op when unprivileged,
|
// Note that this is currently supposed to be no-op when unprivileged,
|
||||||
// but it could change in the future
|
// but it could change in the future
|
||||||
euiccChannelManager.notifyEuiccProfilesChanged(channel.logicalSlotId)
|
euiccChannelManager.notifyEuiccProfilesChanged(it.logicalSlotId)
|
||||||
|
|
||||||
val channelName =
|
|
||||||
appContainer.customizableTextProvider.formatInternalChannelName(channel.logicalSlotId)
|
|
||||||
newPages.add(Page(channel.logicalSlotId, channelName) {
|
|
||||||
appContainer.uiComponentFactory.createEuiccManagementFragment(slotId, portId)
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
}.collect()
|
|
||||||
|
|
||||||
// If USB readers exist, add them at the very last
|
|
||||||
// We use a wrapper fragment to handle logic specific to USB readers
|
|
||||||
usbDevice?.let {
|
|
||||||
val productName = it.productName ?: getString(R.string.usb)
|
|
||||||
newPages.add(Page(EuiccChannelManager.USB_CHANNEL_ID, productName) {
|
|
||||||
UsbCcidReaderFragment()
|
|
||||||
})
|
|
||||||
}
|
|
||||||
viewPager.visibility = View.VISIBLE
|
|
||||||
|
|
||||||
if (newPages.size > 1) {
|
|
||||||
tabs.visibility = View.VISIBLE
|
|
||||||
} else if (newPages.isEmpty()) {
|
|
||||||
newPages.add(Page(-1, "") {
|
|
||||||
appContainer.uiComponentFactory.createNoEuiccPlaceholderFragment()
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
|
|
||||||
newPages.sortBy { it.logicalSlotId }
|
withContext(Dispatchers.Main) {
|
||||||
|
euiccChannelManager.knownChannels.sortedBy { it.logicalSlotId }.forEach { channel ->
|
||||||
pages.clear()
|
spinnerAdapter.add(getString(R.string.channel_name_format, channel.logicalSlotId))
|
||||||
pages.addAll(newPages)
|
fragments.add(appContainer.uiComponentFactory.createEuiccManagementFragment(channel))
|
||||||
|
|
||||||
loadingProgress.visibility = View.GONE
|
|
||||||
pagerAdapter.notifyDataSetChanged()
|
|
||||||
// Reset the adapter so that the current view actually gets cleared
|
|
||||||
// notifyDataSetChanged() doesn't cause the current view to be removed.
|
|
||||||
viewPager.adapter = pagerAdapter
|
|
||||||
|
|
||||||
if (fromUsbEvent && usbDevice != null) {
|
|
||||||
// If this refresh was triggered by a USB insertion while active, scroll to that page
|
|
||||||
viewPager.post {
|
|
||||||
viewPager.setCurrentItem(pages.size - 1, true)
|
|
||||||
}
|
}
|
||||||
} else {
|
|
||||||
viewPager.currentItem = 0
|
|
||||||
}
|
|
||||||
|
|
||||||
if (pages.size > 0) {
|
if (fragments.isNotEmpty()) {
|
||||||
ensureNotificationPermissions()
|
noEuiccPlaceholder.visibility = View.GONE
|
||||||
}
|
supportFragmentManager.beginTransaction().replace(R.id.fragment_root, fragments.first()).commit()
|
||||||
|
}
|
||||||
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
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
|
@ -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.*
|
|
||||||
|
|
||||||
class NoEuiccPlaceholderFragment : Fragment(), OpenEuiccContextMarker {
|
|
||||||
override fun onCreateView(
|
|
||||||
inflater: LayoutInflater,
|
|
||||||
container: ViewGroup?,
|
|
||||||
savedInstanceState: Bundle?
|
|
||||||
): View? {
|
|
||||||
val view = inflater.inflate(R.layout.fragment_no_euicc_placeholder, container, false)
|
|
||||||
val textView = view.requireViewById<TextView>(R.id.no_euicc_placeholder)
|
|
||||||
textView.text = appContainer.customizableTextProvider.noEuiccExplanation
|
|
||||||
return view
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -11,8 +11,8 @@ import android.view.MenuItem.OnMenuItemClickListener
|
||||||
import android.view.View
|
import android.view.View
|
||||||
import android.view.ViewGroup
|
import android.view.ViewGroup
|
||||||
import android.widget.TextView
|
import android.widget.TextView
|
||||||
import androidx.activity.enableEdgeToEdge
|
|
||||||
import androidx.appcompat.app.AlertDialog
|
import androidx.appcompat.app.AlertDialog
|
||||||
|
import androidx.appcompat.app.AppCompatActivity
|
||||||
import androidx.core.view.forEach
|
import androidx.core.view.forEach
|
||||||
import androidx.lifecycle.lifecycleScope
|
import androidx.lifecycle.lifecycleScope
|
||||||
import androidx.recyclerview.widget.DividerItemDecoration
|
import androidx.recyclerview.widget.DividerItemDecoration
|
||||||
|
@ -20,53 +20,38 @@ import androidx.recyclerview.widget.LinearLayoutManager
|
||||||
import androidx.recyclerview.widget.RecyclerView
|
import androidx.recyclerview.widget.RecyclerView
|
||||||
import androidx.swiperefreshlayout.widget.SwipeRefreshLayout
|
import androidx.swiperefreshlayout.widget.SwipeRefreshLayout
|
||||||
import im.angry.openeuicc.common.R
|
import im.angry.openeuicc.common.R
|
||||||
import im.angry.openeuicc.core.EuiccChannelManager
|
import im.angry.openeuicc.core.EuiccChannel
|
||||||
import im.angry.openeuicc.util.*
|
import im.angry.openeuicc.util.*
|
||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
import kotlinx.coroutines.withContext
|
import kotlinx.coroutines.withContext
|
||||||
import net.typeblog.lpac_jni.LocalProfileNotification
|
import net.typeblog.lpac_jni.LocalProfileNotification
|
||||||
|
|
||||||
class NotificationsActivity: BaseEuiccAccessActivity(), OpenEuiccContextMarker {
|
class NotificationsActivity: AppCompatActivity(), OpenEuiccContextMarker {
|
||||||
private lateinit var swipeRefresh: SwipeRefreshLayout
|
private lateinit var swipeRefresh: SwipeRefreshLayout
|
||||||
private lateinit var notificationList: RecyclerView
|
private lateinit var notificationList: RecyclerView
|
||||||
private val notificationAdapter = NotificationAdapter()
|
private val notificationAdapter = NotificationAdapter()
|
||||||
|
|
||||||
private var logicalSlotId = -1
|
private lateinit var euiccChannel: EuiccChannel
|
||||||
|
|
||||||
override fun onCreate(savedInstanceState: Bundle?) {
|
override fun onCreate(savedInstanceState: Bundle?) {
|
||||||
enableEdgeToEdge()
|
|
||||||
super.onCreate(savedInstanceState)
|
super.onCreate(savedInstanceState)
|
||||||
setContentView(R.layout.activity_notifications)
|
setContentView(R.layout.activity_notifications)
|
||||||
setSupportActionBar(requireViewById(R.id.toolbar))
|
setSupportActionBar(findViewById(R.id.toolbar))
|
||||||
setupToolbarInsets()
|
|
||||||
supportActionBar!!.setDisplayHomeAsUpEnabled(true)
|
supportActionBar!!.setDisplayHomeAsUpEnabled(true)
|
||||||
|
|
||||||
swipeRefresh = requireViewById(R.id.swipe_refresh)
|
euiccChannel = euiccChannelManager
|
||||||
notificationList = requireViewById(R.id.recycler_view)
|
.findEuiccChannelBySlotBlocking(intent.getIntExtra("logicalSlotId", 0))!!
|
||||||
|
|
||||||
setupRootViewInsets(notificationList)
|
swipeRefresh = findViewById(R.id.swipe_refresh)
|
||||||
}
|
notificationList = findViewById(R.id.recycler_view)
|
||||||
|
|
||||||
override fun onInit() {
|
|
||||||
notificationList.layoutManager =
|
notificationList.layoutManager =
|
||||||
LinearLayoutManager(this, LinearLayoutManager.VERTICAL, false)
|
LinearLayoutManager(this, LinearLayoutManager.VERTICAL, false)
|
||||||
notificationList.addItemDecoration(DividerItemDecoration(this, LinearLayoutManager.VERTICAL))
|
notificationList.addItemDecoration(DividerItemDecoration(this, LinearLayoutManager.VERTICAL))
|
||||||
notificationList.adapter = notificationAdapter
|
notificationList.adapter = notificationAdapter
|
||||||
registerForContextMenu(notificationList)
|
registerForContextMenu(notificationList)
|
||||||
|
|
||||||
logicalSlotId = intent.getIntExtra("logicalSlotId", 0)
|
|
||||||
|
|
||||||
// This is slightly different from the MainActivity logic
|
|
||||||
// due to the length (we don't want to display the full USB product name)
|
|
||||||
val channelTitle = if (logicalSlotId == EuiccChannelManager.USB_CHANNEL_ID) {
|
|
||||||
getString(R.string.usb)
|
|
||||||
} else {
|
|
||||||
appContainer.customizableTextProvider.formatInternalChannelName(logicalSlotId)
|
|
||||||
}
|
|
||||||
|
|
||||||
title = getString(R.string.profile_notifications_detailed_format, channelTitle)
|
|
||||||
|
|
||||||
swipeRefresh.setOnRefreshListener {
|
swipeRefresh.setOnRefreshListener {
|
||||||
refresh()
|
refresh()
|
||||||
}
|
}
|
||||||
|
@ -103,10 +88,6 @@ class NotificationsActivity: BaseEuiccAccessActivity(), OpenEuiccContextMarker {
|
||||||
swipeRefresh.isRefreshing = true
|
swipeRefresh.isRefreshing = true
|
||||||
|
|
||||||
lifecycleScope.launch {
|
lifecycleScope.launch {
|
||||||
withContext(Dispatchers.IO) {
|
|
||||||
euiccChannelManagerLoaded.await()
|
|
||||||
}
|
|
||||||
|
|
||||||
task()
|
task()
|
||||||
|
|
||||||
swipeRefresh.isRefreshing = false
|
swipeRefresh.isRefreshing = false
|
||||||
|
@ -115,16 +96,15 @@ class NotificationsActivity: BaseEuiccAccessActivity(), OpenEuiccContextMarker {
|
||||||
|
|
||||||
private fun refresh() {
|
private fun refresh() {
|
||||||
launchTask {
|
launchTask {
|
||||||
notificationAdapter.notifications =
|
val profiles = withContext(Dispatchers.IO) {
|
||||||
euiccChannelManager.withEuiccChannel(logicalSlotId) { channel ->
|
euiccChannel.lpa.profiles
|
||||||
val nameMap = buildMap {
|
}
|
||||||
for (profile in channel.lpa.profiles) {
|
|
||||||
put(profile.iccid, profile.displayName)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
channel.lpa.notifications.map {
|
notificationAdapter.notifications =
|
||||||
LocalProfileNotificationWrapper(it, nameMap[it.iccid] ?: "???")
|
withContext(Dispatchers.IO) {
|
||||||
|
euiccChannel.lpa.notifications.map {
|
||||||
|
val profile = profiles.find { p -> p.iccid == it.iccid }
|
||||||
|
LocalProfileNotificationWrapper(it, profile?.displayName ?: "???")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -138,10 +118,8 @@ class NotificationsActivity: BaseEuiccAccessActivity(), OpenEuiccContextMarker {
|
||||||
@SuppressLint("ClickableViewAccessibility")
|
@SuppressLint("ClickableViewAccessibility")
|
||||||
inner class NotificationViewHolder(private val root: View):
|
inner class NotificationViewHolder(private val root: View):
|
||||||
RecyclerView.ViewHolder(root), View.OnCreateContextMenuListener, OnMenuItemClickListener {
|
RecyclerView.ViewHolder(root), View.OnCreateContextMenuListener, OnMenuItemClickListener {
|
||||||
private val address: TextView = root.requireViewById(R.id.notification_address)
|
private val address: TextView = root.findViewById(R.id.notification_address)
|
||||||
private val sequenceNumber: TextView =
|
private val profileName: TextView = root.findViewById(R.id.notification_profile_name)
|
||||||
root.requireViewById(R.id.notification_sequence_number)
|
|
||||||
private val profileName: TextView = root.requireViewById(R.id.notification_profile_name)
|
|
||||||
|
|
||||||
private lateinit var notification: LocalProfileNotificationWrapper
|
private lateinit var notification: LocalProfileNotificationWrapper
|
||||||
|
|
||||||
|
@ -162,7 +140,6 @@ class NotificationsActivity: BaseEuiccAccessActivity(), OpenEuiccContextMarker {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
private fun operationToLocalizedText(operation: LocalProfileNotification.Operation) =
|
private fun operationToLocalizedText(operation: LocalProfileNotification.Operation) =
|
||||||
root.context.getText(
|
root.context.getText(
|
||||||
when (operation) {
|
when (operation) {
|
||||||
|
@ -176,10 +153,6 @@ class NotificationsActivity: BaseEuiccAccessActivity(), OpenEuiccContextMarker {
|
||||||
notification = value
|
notification = value
|
||||||
|
|
||||||
address.text = value.inner.notificationAddress
|
address.text = value.inner.notificationAddress
|
||||||
sequenceNumber.text = root.context.getString(
|
|
||||||
R.string.profile_notification_sequence_number_format,
|
|
||||||
value.inner.seqNumber
|
|
||||||
)
|
|
||||||
profileName.text = Html.fromHtml(
|
profileName.text = Html.fromHtml(
|
||||||
root.context.getString(R.string.profile_notification_name_format,
|
root.context.getString(R.string.profile_notification_name_format,
|
||||||
operationToLocalizedText(value.inner.profileManagementOperation),
|
operationToLocalizedText(value.inner.profileManagementOperation),
|
||||||
|
@ -204,25 +177,19 @@ class NotificationsActivity: BaseEuiccAccessActivity(), OpenEuiccContextMarker {
|
||||||
R.id.notification_process -> {
|
R.id.notification_process -> {
|
||||||
launchTask {
|
launchTask {
|
||||||
withContext(Dispatchers.IO) {
|
withContext(Dispatchers.IO) {
|
||||||
euiccChannelManager.withEuiccChannel(logicalSlotId) { channel ->
|
euiccChannel.lpa.handleNotification(notification.inner.seqNumber)
|
||||||
channel.lpa.handleNotification(notification.inner.seqNumber)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
refresh()
|
|
||||||
}
|
}
|
||||||
|
refresh()
|
||||||
true
|
true
|
||||||
}
|
}
|
||||||
R.id.notification_delete -> {
|
R.id.notification_delete -> {
|
||||||
launchTask {
|
launchTask {
|
||||||
withContext(Dispatchers.IO) {
|
withContext(Dispatchers.IO) {
|
||||||
euiccChannelManager.withEuiccChannel(logicalSlotId) { channel ->
|
euiccChannel.lpa.deleteNotification(notification.inner.seqNumber)
|
||||||
channel.lpa.deleteNotification(notification.inner.seqNumber)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
refresh()
|
|
||||||
}
|
}
|
||||||
|
refresh()
|
||||||
true
|
true
|
||||||
}
|
}
|
||||||
else -> false
|
else -> false
|
||||||
|
|
|
@ -3,67 +3,56 @@ package im.angry.openeuicc.ui
|
||||||
import android.app.Dialog
|
import android.app.Dialog
|
||||||
import android.os.Bundle
|
import android.os.Bundle
|
||||||
import android.text.Editable
|
import android.text.Editable
|
||||||
|
import android.util.Log
|
||||||
import android.widget.EditText
|
import android.widget.EditText
|
||||||
import android.widget.Toast
|
|
||||||
import androidx.appcompat.app.AlertDialog
|
import androidx.appcompat.app.AlertDialog
|
||||||
import androidx.fragment.app.DialogFragment
|
import androidx.fragment.app.DialogFragment
|
||||||
import androidx.lifecycle.lifecycleScope
|
import androidx.lifecycle.lifecycleScope
|
||||||
import im.angry.openeuicc.common.R
|
import im.angry.openeuicc.common.R
|
||||||
import im.angry.openeuicc.service.EuiccChannelManagerService.Companion.waitDone
|
|
||||||
import im.angry.openeuicc.util.*
|
import im.angry.openeuicc.util.*
|
||||||
import kotlinx.coroutines.flow.onStart
|
import kotlinx.coroutines.flow.first
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
|
import java.lang.Exception
|
||||||
|
|
||||||
class ProfileDeleteFragment : DialogFragment(), EuiccChannelFragmentMarker {
|
class ProfileDeleteFragment : DialogFragment(), EuiccChannelFragmentMarker {
|
||||||
companion object {
|
companion object {
|
||||||
const val TAG = "ProfileDeleteFragment"
|
const val TAG = "ProfileDeleteFragment"
|
||||||
private const val FIELD_ICCID = "iccid"
|
|
||||||
private const val FIELD_NAME = "name"
|
|
||||||
|
|
||||||
fun newInstance(slotId: Int, portId: Int, iccid: String, name: String) =
|
fun newInstance(slotId: Int, portId: Int, iccid: String, name: String): ProfileDeleteFragment {
|
||||||
newInstanceEuicc(ProfileDeleteFragment::class.java, slotId, portId) {
|
val instance = newInstanceEuicc(ProfileDeleteFragment::class.java, slotId, portId)
|
||||||
putString(FIELD_ICCID, iccid)
|
instance.requireArguments().apply {
|
||||||
putString(FIELD_NAME, name)
|
putString("iccid", iccid)
|
||||||
|
putString("name", name)
|
||||||
|
}
|
||||||
|
return instance
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private val iccid by lazy {
|
|
||||||
requireArguments().getString(FIELD_ICCID)!!
|
|
||||||
}
|
|
||||||
|
|
||||||
private val name by lazy {
|
|
||||||
requireArguments().getString(FIELD_NAME)!!
|
|
||||||
}
|
|
||||||
|
|
||||||
private val editText by lazy {
|
private val editText by lazy {
|
||||||
EditText(requireContext()).apply {
|
EditText(requireContext()).apply {
|
||||||
hint = Editable.Factory.getInstance()
|
hint = Editable.Factory.getInstance().newEditable(
|
||||||
.newEditable(getString(R.string.profile_delete_confirm_input, name))
|
getString(R.string.profile_delete_confirm_input, requireArguments().getString("name")!!)
|
||||||
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private val inputMatchesName: Boolean
|
private val inputMatchesName: Boolean
|
||||||
get() = editText.text.toString() == name
|
get() = editText.text.toString() == requireArguments().getString("name")!!
|
||||||
|
|
||||||
private var toast: Toast? = null
|
|
||||||
|
|
||||||
private var deleting = false
|
private var deleting = false
|
||||||
|
|
||||||
private val alertDialog: AlertDialog
|
override fun onCreateDialog(savedInstanceState: Bundle?): Dialog {
|
||||||
get() = requireDialog() as AlertDialog
|
return AlertDialog.Builder(requireContext(), R.style.AlertDialogTheme).apply {
|
||||||
|
setMessage(getString(R.string.profile_delete_confirm, requireArguments().getString("name")))
|
||||||
override fun onCreateDialog(savedInstanceState: Bundle?): Dialog =
|
|
||||||
AlertDialog.Builder(requireContext(), R.style.AlertDialogTheme).apply {
|
|
||||||
setMessage(getString(R.string.profile_delete_confirm, name))
|
|
||||||
setView(editText)
|
setView(editText)
|
||||||
setPositiveButton(android.R.string.ok, null) // Set listener to null to prevent auto closing
|
setPositiveButton(android.R.string.ok, null) // Set listener to null to prevent auto closing
|
||||||
setNegativeButton(android.R.string.cancel, null)
|
setNegativeButton(android.R.string.cancel, null)
|
||||||
}.create()
|
}.create()
|
||||||
|
}
|
||||||
|
|
||||||
override fun onResume() {
|
override fun onResume() {
|
||||||
super.onResume()
|
super.onResume()
|
||||||
|
val alertDialog = dialog!! as AlertDialog
|
||||||
alertDialog.getButton(AlertDialog.BUTTON_POSITIVE).setOnClickListener {
|
alertDialog.getButton(AlertDialog.BUTTON_POSITIVE).setOnClickListener {
|
||||||
if (!deleting) delete()
|
if (!deleting && inputMatchesName) delete()
|
||||||
}
|
}
|
||||||
alertDialog.getButton(AlertDialog.BUTTON_NEGATIVE).setOnClickListener {
|
alertDialog.getButton(AlertDialog.BUTTON_NEGATIVE).setOnClickListener {
|
||||||
if (!deleting) dismiss()
|
if (!deleting) dismiss()
|
||||||
|
@ -71,29 +60,30 @@ class ProfileDeleteFragment : DialogFragment(), EuiccChannelFragmentMarker {
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun delete() {
|
private fun delete() {
|
||||||
toast?.cancel()
|
|
||||||
if (!inputMatchesName) {
|
|
||||||
val resId = R.string.toast_profile_delete_confirm_text_mismatched
|
|
||||||
toast = Toast.makeText(requireContext(), resId, Toast.LENGTH_LONG).also {
|
|
||||||
it.show()
|
|
||||||
}
|
|
||||||
return
|
|
||||||
}
|
|
||||||
deleting = true
|
deleting = true
|
||||||
|
val alertDialog = dialog!! as AlertDialog
|
||||||
alertDialog.setCanceledOnTouchOutside(false)
|
alertDialog.setCanceledOnTouchOutside(false)
|
||||||
alertDialog.setCancelable(false)
|
alertDialog.setCancelable(false)
|
||||||
alertDialog.getButton(AlertDialog.BUTTON_POSITIVE).isEnabled = false
|
alertDialog.getButton(AlertDialog.BUTTON_POSITIVE).isEnabled = false
|
||||||
alertDialog.getButton(AlertDialog.BUTTON_NEGATIVE).isEnabled = false
|
alertDialog.getButton(AlertDialog.BUTTON_NEGATIVE).isEnabled = false
|
||||||
|
|
||||||
requireParentFragment().lifecycleScope.launch {
|
lifecycleScope.launch {
|
||||||
ensureEuiccChannelManager()
|
try {
|
||||||
euiccChannelManagerService.waitForForegroundTask()
|
doDelete()
|
||||||
euiccChannelManagerService.launchProfileDeleteTask(slotId, portId, iccid)
|
} catch (e: Exception) {
|
||||||
.onStart {
|
Log.d(ProfileDownloadFragment.TAG, "Error deleting profile")
|
||||||
parentFragment?.notifyEuiccProfilesChanged()
|
Log.d(ProfileDownloadFragment.TAG, Log.getStackTraceString(e))
|
||||||
runCatching(::dismiss)
|
} finally {
|
||||||
|
if (parentFragment is EuiccProfilesChangedListener) {
|
||||||
|
(parentFragment as EuiccProfilesChangedListener).onEuiccProfilesChanged()
|
||||||
}
|
}
|
||||||
.waitDone()
|
dismiss()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private suspend fun doDelete() = channel.lpa.beginOperation {
|
||||||
|
channel.lpa.deleteProfile(requireArguments().getString("iccid")!!)
|
||||||
|
preferenceRepository.notificationDeleteFlow.first()
|
||||||
|
}
|
||||||
}
|
}
|
|
@ -0,0 +1,220 @@
|
||||||
|
package im.angry.openeuicc.ui
|
||||||
|
|
||||||
|
import android.annotation.SuppressLint
|
||||||
|
import android.app.Dialog
|
||||||
|
import android.content.DialogInterface
|
||||||
|
import android.os.Bundle
|
||||||
|
import android.text.Editable
|
||||||
|
import android.text.format.Formatter
|
||||||
|
import android.util.Log
|
||||||
|
import android.view.*
|
||||||
|
import android.widget.ProgressBar
|
||||||
|
import android.widget.TextView
|
||||||
|
import android.widget.Toast
|
||||||
|
import androidx.appcompat.widget.Toolbar
|
||||||
|
import androidx.lifecycle.lifecycleScope
|
||||||
|
import com.google.android.material.textfield.TextInputLayout
|
||||||
|
import com.journeyapps.barcodescanner.ScanContract
|
||||||
|
import com.journeyapps.barcodescanner.ScanOptions
|
||||||
|
import im.angry.openeuicc.common.R
|
||||||
|
import im.angry.openeuicc.util.*
|
||||||
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.flow.first
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
import kotlinx.coroutines.withContext
|
||||||
|
import net.typeblog.lpac_jni.ProfileDownloadCallback
|
||||||
|
import kotlin.Exception
|
||||||
|
|
||||||
|
class ProfileDownloadFragment : BaseMaterialDialogFragment(),
|
||||||
|
Toolbar.OnMenuItemClickListener, EuiccChannelFragmentMarker {
|
||||||
|
companion object {
|
||||||
|
const val TAG = "ProfileDownloadFragment"
|
||||||
|
|
||||||
|
fun newInstance(slotId: Int, portId: Int, finishWhenDone: Boolean = false): ProfileDownloadFragment =
|
||||||
|
newInstanceEuicc(ProfileDownloadFragment::class.java, slotId, portId) {
|
||||||
|
putBoolean("finishWhenDone", finishWhenDone)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private lateinit var toolbar: Toolbar
|
||||||
|
private lateinit var profileDownloadServer: TextInputLayout
|
||||||
|
private lateinit var profileDownloadCode: TextInputLayout
|
||||||
|
private lateinit var profileDownloadConfirmationCode: TextInputLayout
|
||||||
|
private lateinit var profileDownloadIMEI: TextInputLayout
|
||||||
|
private lateinit var profileDownloadFreeSpace: TextView
|
||||||
|
private lateinit var progress: ProgressBar
|
||||||
|
|
||||||
|
private var freeNvram: Int = -1
|
||||||
|
|
||||||
|
private var downloading = false
|
||||||
|
|
||||||
|
private val finishWhenDone by lazy {
|
||||||
|
requireArguments().getBoolean("finishWhenDone", false)
|
||||||
|
}
|
||||||
|
|
||||||
|
private val barcodeScannerLauncher = registerForActivityResult(ScanContract()) { result ->
|
||||||
|
result.contents?.let { content ->
|
||||||
|
Log.d(TAG, content)
|
||||||
|
val components = content.split("$")
|
||||||
|
if (components.size < 3 || components[0] != "LPA:1") return@registerForActivityResult
|
||||||
|
profileDownloadServer.editText?.setText(components[1])
|
||||||
|
profileDownloadCode.editText?.setText(components[2])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onCreateView(
|
||||||
|
inflater: LayoutInflater,
|
||||||
|
container: ViewGroup?,
|
||||||
|
savedInstanceState: Bundle?
|
||||||
|
): View {
|
||||||
|
val view = inflater.inflate(R.layout.fragment_profile_download, container, false)
|
||||||
|
|
||||||
|
toolbar = view.findViewById(R.id.toolbar)
|
||||||
|
profileDownloadServer = view.findViewById(R.id.profile_download_server)
|
||||||
|
profileDownloadCode = view.findViewById(R.id.profile_download_code)
|
||||||
|
profileDownloadConfirmationCode = view.findViewById(R.id.profile_download_confirmation_code)
|
||||||
|
profileDownloadIMEI = view.findViewById(R.id.profile_download_imei)
|
||||||
|
profileDownloadFreeSpace = view.findViewById(R.id.profile_download_free_space)
|
||||||
|
progress = view.findViewById(R.id.progress)
|
||||||
|
|
||||||
|
toolbar.inflateMenu(R.menu.fragment_profile_download)
|
||||||
|
|
||||||
|
return view
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
|
||||||
|
super.onViewCreated(view, savedInstanceState)
|
||||||
|
toolbar.apply {
|
||||||
|
setTitle(R.string.profile_download)
|
||||||
|
setNavigationOnClickListener {
|
||||||
|
if (!downloading) {
|
||||||
|
dismiss()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
setOnMenuItemClickListener(this@ProfileDownloadFragment)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onMenuItemClick(item: MenuItem): Boolean = downloading ||
|
||||||
|
when (item.itemId) {
|
||||||
|
R.id.scan -> {
|
||||||
|
barcodeScannerLauncher.launch(ScanOptions().apply {
|
||||||
|
setDesiredBarcodeFormats(ScanOptions.QR_CODE)
|
||||||
|
setOrientationLocked(false)
|
||||||
|
})
|
||||||
|
true
|
||||||
|
}
|
||||||
|
R.id.ok -> {
|
||||||
|
startDownloadProfile()
|
||||||
|
true
|
||||||
|
}
|
||||||
|
else -> false
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onResume() {
|
||||||
|
super.onResume()
|
||||||
|
setWidthPercent(95)
|
||||||
|
}
|
||||||
|
|
||||||
|
@SuppressLint("MissingPermission")
|
||||||
|
override fun onStart() {
|
||||||
|
super.onStart()
|
||||||
|
profileDownloadIMEI.editText!!.text = Editable.Factory.getInstance().newEditable(
|
||||||
|
try {
|
||||||
|
telephonyManager.getImei(channel.logicalSlotId)
|
||||||
|
} catch (e: Exception) {
|
||||||
|
""
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
lifecycleScope.launch(Dispatchers.IO) {
|
||||||
|
// Fetch remaining NVRAM
|
||||||
|
val str = channel.lpa.euiccInfo2?.freeNvram?.also {
|
||||||
|
freeNvram = it
|
||||||
|
}?.let { Formatter.formatShortFileSize(requireContext(), it.toLong()) }
|
||||||
|
|
||||||
|
withContext(Dispatchers.Main) {
|
||||||
|
profileDownloadFreeSpace.text = getString(R.string.profile_download_free_space,
|
||||||
|
str ?: getText(R.string.unknown))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onCreateDialog(savedInstanceState: Bundle?): Dialog {
|
||||||
|
return super.onCreateDialog(savedInstanceState).also {
|
||||||
|
it.setCanceledOnTouchOutside(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun startDownloadProfile() {
|
||||||
|
val server = profileDownloadServer.editText!!.let {
|
||||||
|
it.text.toString().trim().apply {
|
||||||
|
if (isEmpty()) {
|
||||||
|
it.requestFocus()
|
||||||
|
return@startDownloadProfile
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
val code = profileDownloadCode.editText!!.text.toString().trim()
|
||||||
|
.ifBlank { null }
|
||||||
|
val confirmationCode = profileDownloadConfirmationCode.editText!!.text.toString().trim()
|
||||||
|
.ifBlank { null }
|
||||||
|
val imei = profileDownloadIMEI.editText!!.text.toString().trim()
|
||||||
|
.ifBlank { null }
|
||||||
|
|
||||||
|
downloading = true
|
||||||
|
|
||||||
|
profileDownloadServer.editText!!.isEnabled = false
|
||||||
|
profileDownloadCode.editText!!.isEnabled = false
|
||||||
|
profileDownloadConfirmationCode.editText!!.isEnabled = false
|
||||||
|
profileDownloadIMEI.editText!!.isEnabled = false
|
||||||
|
|
||||||
|
progress.isIndeterminate = true
|
||||||
|
progress.visibility = View.VISIBLE
|
||||||
|
|
||||||
|
lifecycleScope.launch {
|
||||||
|
try {
|
||||||
|
doDownloadProfile(server, code, confirmationCode, imei)
|
||||||
|
} catch (e: Exception) {
|
||||||
|
Log.d(TAG, "Error downloading profile")
|
||||||
|
Log.d(TAG, Log.getStackTraceString(e))
|
||||||
|
Toast.makeText(context, R.string.profile_download_failed, Toast.LENGTH_LONG).show()
|
||||||
|
} finally {
|
||||||
|
if (parentFragment is EuiccProfilesChangedListener) {
|
||||||
|
(parentFragment as EuiccProfilesChangedListener).onEuiccProfilesChanged()
|
||||||
|
}
|
||||||
|
dismiss()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private suspend fun doDownloadProfile(server: String, code: String?, confirmationCode: String?, imei: String?) = channel.lpa.beginOperation {
|
||||||
|
downloadProfile(server, code, imei, confirmationCode, object : ProfileDownloadCallback {
|
||||||
|
override fun onStateUpdate(state: ProfileDownloadCallback.DownloadState) {
|
||||||
|
lifecycleScope.launch(Dispatchers.Main) {
|
||||||
|
progress.isIndeterminate = false
|
||||||
|
progress.progress = state.progress
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
// If we get here, we are successful
|
||||||
|
// Only send notifications if the user allowed us to
|
||||||
|
preferenceRepository.notificationDownloadFlow.first()
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onDismiss(dialog: DialogInterface) {
|
||||||
|
super.onDismiss(dialog)
|
||||||
|
if (finishWhenDone) {
|
||||||
|
activity?.finish()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onCancel(dialog: DialogInterface) {
|
||||||
|
super.onCancel(dialog)
|
||||||
|
if (finishWhenDone) {
|
||||||
|
activity?.finish()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
|
@ -2,33 +2,35 @@ package im.angry.openeuicc.ui
|
||||||
|
|
||||||
import android.app.Dialog
|
import android.app.Dialog
|
||||||
import android.os.Bundle
|
import android.os.Bundle
|
||||||
|
import android.util.Log
|
||||||
import android.view.LayoutInflater
|
import android.view.LayoutInflater
|
||||||
import android.view.View
|
import android.view.View
|
||||||
import android.view.ViewGroup
|
import android.view.ViewGroup
|
||||||
import android.widget.ProgressBar
|
import android.widget.ProgressBar
|
||||||
import android.widget.Toast
|
import android.widget.Toast
|
||||||
import androidx.annotation.StringRes
|
|
||||||
import androidx.appcompat.widget.Toolbar
|
import androidx.appcompat.widget.Toolbar
|
||||||
import androidx.lifecycle.lifecycleScope
|
import androidx.lifecycle.lifecycleScope
|
||||||
import com.google.android.material.textfield.TextInputLayout
|
import com.google.android.material.textfield.TextInputLayout
|
||||||
import im.angry.openeuicc.common.R
|
import im.angry.openeuicc.common.R
|
||||||
import im.angry.openeuicc.service.EuiccChannelManagerService.Companion.waitDone
|
|
||||||
import im.angry.openeuicc.util.*
|
import im.angry.openeuicc.util.*
|
||||||
|
import kotlinx.coroutines.Dispatchers
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
import net.typeblog.lpac_jni.LocalProfileAssistant
|
import kotlinx.coroutines.withContext
|
||||||
|
import java.lang.Exception
|
||||||
|
import java.lang.RuntimeException
|
||||||
|
|
||||||
class ProfileRenameFragment : BaseMaterialDialogFragment(), EuiccChannelFragmentMarker {
|
class ProfileRenameFragment : BaseMaterialDialogFragment(), EuiccChannelFragmentMarker {
|
||||||
companion object {
|
companion object {
|
||||||
private const val FIELD_ICCID = "iccid"
|
|
||||||
private const val FIELD_CURRENT_NAME = "currentName"
|
|
||||||
|
|
||||||
const val TAG = "ProfileRenameFragment"
|
const val TAG = "ProfileRenameFragment"
|
||||||
|
|
||||||
fun newInstance(slotId: Int, portId: Int, iccid: String, currentName: String) =
|
fun newInstance(slotId: Int, portId: Int, iccid: String, currentName: String): ProfileRenameFragment {
|
||||||
newInstanceEuicc(ProfileRenameFragment::class.java, slotId, portId) {
|
val instance = newInstanceEuicc(ProfileRenameFragment::class.java, slotId, portId)
|
||||||
putString(FIELD_ICCID, iccid)
|
instance.requireArguments().apply {
|
||||||
putString(FIELD_CURRENT_NAME, currentName)
|
putString("iccid", iccid)
|
||||||
|
putString("currentName", currentName)
|
||||||
}
|
}
|
||||||
|
return instance
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private lateinit var toolbar: Toolbar
|
private lateinit var toolbar: Toolbar
|
||||||
|
@ -37,14 +39,6 @@ class ProfileRenameFragment : BaseMaterialDialogFragment(), EuiccChannelFragment
|
||||||
|
|
||||||
private var renaming = false
|
private var renaming = false
|
||||||
|
|
||||||
private val iccid: String by lazy {
|
|
||||||
requireArguments().getString(FIELD_ICCID)!!
|
|
||||||
}
|
|
||||||
|
|
||||||
private val currentName: String by lazy {
|
|
||||||
requireArguments().getString(FIELD_CURRENT_NAME)!!
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun onCreateView(
|
override fun onCreateView(
|
||||||
inflater: LayoutInflater,
|
inflater: LayoutInflater,
|
||||||
container: ViewGroup?,
|
container: ViewGroup?,
|
||||||
|
@ -52,9 +46,9 @@ class ProfileRenameFragment : BaseMaterialDialogFragment(), EuiccChannelFragment
|
||||||
): View {
|
): View {
|
||||||
val view = inflater.inflate(R.layout.fragment_profile_rename, container, false)
|
val view = inflater.inflate(R.layout.fragment_profile_rename, container, false)
|
||||||
|
|
||||||
toolbar = view.requireViewById(R.id.toolbar)
|
toolbar = view.findViewById(R.id.toolbar)
|
||||||
profileRenameNewName = view.requireViewById(R.id.profile_rename_new_name)
|
profileRenameNewName = view.findViewById(R.id.profile_rename_new_name)
|
||||||
progress = view.requireViewById(R.id.progress)
|
progress = view.findViewById(R.id.progress)
|
||||||
|
|
||||||
toolbar.inflateMenu(R.menu.fragment_profile_rename)
|
toolbar.inflateMenu(R.menu.fragment_profile_rename)
|
||||||
|
|
||||||
|
@ -63,7 +57,6 @@ class ProfileRenameFragment : BaseMaterialDialogFragment(), EuiccChannelFragment
|
||||||
|
|
||||||
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
|
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
|
||||||
super.onViewCreated(view, savedInstanceState)
|
super.onViewCreated(view, savedInstanceState)
|
||||||
profileRenameNewName.editText!!.setText(currentName)
|
|
||||||
toolbar.apply {
|
toolbar.apply {
|
||||||
setTitle(R.string.rename)
|
setTitle(R.string.rename)
|
||||||
setNavigationOnClickListener {
|
setNavigationOnClickListener {
|
||||||
|
@ -76,6 +69,11 @@ class ProfileRenameFragment : BaseMaterialDialogFragment(), EuiccChannelFragment
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
override fun onStart() {
|
||||||
|
super.onStart()
|
||||||
|
profileRenameNewName.editText!!.setText(requireArguments().getString("currentName"))
|
||||||
|
}
|
||||||
|
|
||||||
override fun onResume() {
|
override fun onResume() {
|
||||||
super.onResume()
|
super.onResume()
|
||||||
setWidthPercent(95)
|
setWidthPercent(95)
|
||||||
|
@ -87,45 +85,35 @@ class ProfileRenameFragment : BaseMaterialDialogFragment(), EuiccChannelFragment
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun showErrorAndCancel(@StringRes resId: Int) {
|
|
||||||
Toast.makeText(requireContext(), resId, Toast.LENGTH_LONG).show()
|
|
||||||
|
|
||||||
renaming = false
|
|
||||||
progress.visibility = View.GONE
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun rename() {
|
private fun rename() {
|
||||||
|
val name = profileRenameNewName.editText!!.text.toString().trim()
|
||||||
|
if (name.length >= 64) {
|
||||||
|
Toast.makeText(context, R.string.toast_profile_name_too_long, Toast.LENGTH_LONG).show()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
renaming = true
|
renaming = true
|
||||||
progress.isIndeterminate = true
|
progress.isIndeterminate = true
|
||||||
progress.visibility = View.VISIBLE
|
progress.visibility = View.VISIBLE
|
||||||
|
|
||||||
val newName = profileRenameNewName.editText!!.text.toString().trim()
|
|
||||||
|
|
||||||
lifecycleScope.launch {
|
lifecycleScope.launch {
|
||||||
ensureEuiccChannelManager()
|
try {
|
||||||
euiccChannelManagerService.waitForForegroundTask()
|
doRename(name)
|
||||||
val response = euiccChannelManagerService
|
} catch (e: Exception) {
|
||||||
.launchProfileRenameTask(slotId, portId, iccid, newName).waitDone()
|
Log.d(TAG, "Failed to rename profile")
|
||||||
|
Log.d(TAG, Log.getStackTraceString(e))
|
||||||
when (response) {
|
} finally {
|
||||||
is LocalProfileAssistant.ProfileNameTooLongException -> {
|
if (parentFragment is EuiccProfilesChangedListener) {
|
||||||
showErrorAndCancel(R.string.profile_rename_too_long)
|
(parentFragment as EuiccProfilesChangedListener).onEuiccProfilesChanged()
|
||||||
}
|
|
||||||
|
|
||||||
is LocalProfileAssistant.ProfileNameIsInvalidUTF8Exception -> {
|
|
||||||
showErrorAndCancel(R.string.profile_rename_encoding_error)
|
|
||||||
}
|
|
||||||
|
|
||||||
is Throwable -> {
|
|
||||||
showErrorAndCancel(R.string.profile_rename_failure)
|
|
||||||
}
|
|
||||||
|
|
||||||
else -> {
|
|
||||||
parentFragment?.notifyEuiccProfilesChanged()
|
|
||||||
|
|
||||||
runCatching(::dismiss)
|
|
||||||
}
|
}
|
||||||
|
dismiss()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private suspend fun doRename(name: String) = withContext(Dispatchers.IO) {
|
||||||
|
if (!channel.lpa.setNickname(requireArguments().getString("iccid")!!, name)) {
|
||||||
|
throw RuntimeException("Profile nickname not changed")
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
|
@ -2,26 +2,17 @@ package im.angry.openeuicc.ui
|
||||||
|
|
||||||
import android.os.Bundle
|
import android.os.Bundle
|
||||||
import android.view.MenuItem
|
import android.view.MenuItem
|
||||||
import androidx.activity.enableEdgeToEdge
|
|
||||||
import androidx.appcompat.app.AppCompatActivity
|
import androidx.appcompat.app.AppCompatActivity
|
||||||
import im.angry.openeuicc.OpenEuiccApplication
|
|
||||||
import im.angry.openeuicc.common.R
|
import im.angry.openeuicc.common.R
|
||||||
import im.angry.openeuicc.util.*
|
|
||||||
|
|
||||||
class SettingsActivity: AppCompatActivity() {
|
class SettingsActivity: AppCompatActivity() {
|
||||||
private val appContainer
|
|
||||||
get() = (application as OpenEuiccApplication).appContainer
|
|
||||||
|
|
||||||
override fun onCreate(savedInstanceState: Bundle?) {
|
override fun onCreate(savedInstanceState: Bundle?) {
|
||||||
enableEdgeToEdge()
|
|
||||||
super.onCreate(savedInstanceState)
|
super.onCreate(savedInstanceState)
|
||||||
setContentView(R.layout.activity_settings)
|
setContentView(R.layout.activity_settings)
|
||||||
setSupportActionBar(requireViewById(R.id.toolbar))
|
setSupportActionBar(findViewById(R.id.toolbar))
|
||||||
setupToolbarInsets()
|
|
||||||
supportActionBar!!.setDisplayHomeAsUpEnabled(true)
|
supportActionBar!!.setDisplayHomeAsUpEnabled(true)
|
||||||
val settingsFragment = appContainer.uiComponentFactory.createSettingsFragment()
|
|
||||||
supportFragmentManager.beginTransaction()
|
supportFragmentManager.beginTransaction()
|
||||||
.replace(R.id.settings_container, settingsFragment)
|
.replace(R.id.settings_container, SettingsFragment())
|
||||||
.commit()
|
.commit()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -2,164 +2,60 @@ package im.angry.openeuicc.ui
|
||||||
|
|
||||||
import android.content.Intent
|
import android.content.Intent
|
||||||
import android.net.Uri
|
import android.net.Uri
|
||||||
import android.os.Build
|
|
||||||
import android.os.Bundle
|
import android.os.Bundle
|
||||||
import android.provider.Settings
|
import androidx.datastore.preferences.core.Preferences
|
||||||
import android.widget.Toast
|
|
||||||
import androidx.lifecycle.lifecycleScope
|
import androidx.lifecycle.lifecycleScope
|
||||||
import androidx.preference.CheckBoxPreference
|
import androidx.preference.CheckBoxPreference
|
||||||
import androidx.preference.Preference
|
import androidx.preference.Preference
|
||||||
import androidx.preference.PreferenceCategory
|
|
||||||
import androidx.preference.PreferenceFragmentCompat
|
import androidx.preference.PreferenceFragmentCompat
|
||||||
import im.angry.openeuicc.common.R
|
import im.angry.openeuicc.common.R
|
||||||
import im.angry.openeuicc.util.*
|
import im.angry.openeuicc.util.*
|
||||||
import kotlinx.coroutines.flow.collect
|
import kotlinx.coroutines.flow.Flow
|
||||||
import kotlinx.coroutines.flow.onEach
|
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
import kotlinx.coroutines.runBlocking
|
import kotlinx.coroutines.runBlocking
|
||||||
|
|
||||||
open class SettingsFragment: PreferenceFragmentCompat() {
|
class SettingsFragment: PreferenceFragmentCompat() {
|
||||||
private lateinit var developerPref: PreferenceCategory
|
|
||||||
|
|
||||||
// Hidden developer options switch
|
|
||||||
private var numClicks = 0
|
|
||||||
private var lastClickTimestamp = -1L
|
|
||||||
private var lastToast: Toast? = null
|
|
||||||
|
|
||||||
override fun onCreatePreferences(savedInstanceState: Bundle?, rootKey: String?) {
|
override fun onCreatePreferences(savedInstanceState: Bundle?, rootKey: String?) {
|
||||||
setPreferencesFromResource(R.xml.pref_settings, rootKey)
|
setPreferencesFromResource(R.xml.pref_settings, rootKey)
|
||||||
|
|
||||||
developerPref = requirePreference("pref_developer")
|
findPreference<Preference>("pref_info_app_version")
|
||||||
|
?.summary = requireContext().selfAppVersion
|
||||||
|
|
||||||
// Show / hide developer preference based on whether it is enabled
|
findPreference<Preference>("pref_info_source_code")
|
||||||
lifecycleScope.launch {
|
?.setOnPreferenceClickListener {
|
||||||
preferenceRepository.developerOptionsEnabledFlow
|
startActivity(Intent(Intent.ACTION_VIEW, Uri.parse(it.summary.toString())))
|
||||||
.onEach { developerPref.isVisible = it }
|
true
|
||||||
.collect()
|
|
||||||
}
|
|
||||||
|
|
||||||
requirePreference<Preference>("pref_info_app_version").apply {
|
|
||||||
summary = requireContext().selfAppVersion
|
|
||||||
|
|
||||||
// Enable developer options when this is clicked for 7 times
|
|
||||||
setOnPreferenceClickListener(::onAppVersionClicked)
|
|
||||||
}
|
|
||||||
|
|
||||||
requirePreference<Preference>("pref_advanced_language").apply {
|
|
||||||
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.TIRAMISU) return@apply
|
|
||||||
isVisible = true
|
|
||||||
intent = Intent(Settings.ACTION_APP_LOCALE_SETTINGS).apply {
|
|
||||||
data = Uri.fromParts("package", requireContext().packageName, null)
|
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
requirePreference<Preference>("pref_advanced_logs").apply {
|
findPreference<Preference>("pref_advanced_logs")
|
||||||
intent = Intent(requireContext(), LogsActivity::class.java)
|
?.setOnPreferenceClickListener {
|
||||||
}
|
startActivity(Intent(requireContext(), LogsActivity::class.java))
|
||||||
|
true
|
||||||
requirePreference<CheckBoxPreference>("pref_notifications_download")
|
|
||||||
.bindBooleanFlow(preferenceRepository.notificationDownloadFlow)
|
|
||||||
|
|
||||||
requirePreference<CheckBoxPreference>("pref_notifications_delete")
|
|
||||||
.bindBooleanFlow(preferenceRepository.notificationDeleteFlow)
|
|
||||||
|
|
||||||
requirePreference<CheckBoxPreference>("pref_notifications_switch")
|
|
||||||
.bindBooleanFlow(preferenceRepository.notificationSwitchFlow)
|
|
||||||
|
|
||||||
requirePreference<CheckBoxPreference>("pref_advanced_disable_safeguard_removable_esim")
|
|
||||||
.bindBooleanFlow(preferenceRepository.disableSafeguardFlow)
|
|
||||||
|
|
||||||
requirePreference<CheckBoxPreference>("pref_advanced_verbose_logging")
|
|
||||||
.bindBooleanFlow(preferenceRepository.verboseLoggingFlow)
|
|
||||||
|
|
||||||
requirePreference<CheckBoxPreference>("pref_developer_unfiltered_profile_list")
|
|
||||||
.bindBooleanFlow(preferenceRepository.unfilteredProfileListFlow)
|
|
||||||
|
|
||||||
requirePreference<CheckBoxPreference>("pref_developer_ignore_tls_certificate")
|
|
||||||
.bindBooleanFlow(preferenceRepository.ignoreTLSCertificateFlow)
|
|
||||||
|
|
||||||
requirePreference<CheckBoxPreference>("pref_developer_refresh_after_switch")
|
|
||||||
.bindBooleanFlow(preferenceRepository.refreshAfterSwitchFlow)
|
|
||||||
|
|
||||||
requirePreference<CheckBoxPreference>("pref_developer_euicc_memory_reset")
|
|
||||||
.bindBooleanFlow(preferenceRepository.euiccMemoryResetFlow)
|
|
||||||
|
|
||||||
requirePreference<Preference>("pref_developer_isdr_aid_list").apply {
|
|
||||||
intent = Intent(requireContext(), IsdrAidListActivity::class.java)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
protected fun <T : Preference> requirePreference(key: CharSequence) =
|
|
||||||
findPreference<T>(key)!!
|
|
||||||
|
|
||||||
override fun onStart() {
|
|
||||||
super.onStart()
|
|
||||||
setupRootViewInsets(requireView().requireViewById(R.id.recycler_view))
|
|
||||||
}
|
|
||||||
|
|
||||||
@Suppress("UNUSED_PARAMETER")
|
|
||||||
private fun onAppVersionClicked(pref: Preference): Boolean {
|
|
||||||
if (developerPref.isVisible) return false
|
|
||||||
val now = System.currentTimeMillis()
|
|
||||||
if (now - lastClickTimestamp >= 1000) {
|
|
||||||
numClicks = 1
|
|
||||||
} else {
|
|
||||||
numClicks++
|
|
||||||
}
|
|
||||||
lastClickTimestamp = now
|
|
||||||
|
|
||||||
if (numClicks == 7) {
|
|
||||||
lifecycleScope.launch {
|
|
||||||
preferenceRepository.developerOptionsEnabledFlow.updatePreference(true)
|
|
||||||
|
|
||||||
lastToast?.cancel()
|
|
||||||
Toast.makeText(
|
|
||||||
requireContext(),
|
|
||||||
R.string.developer_options_enabled,
|
|
||||||
Toast.LENGTH_SHORT
|
|
||||||
).show()
|
|
||||||
}
|
}
|
||||||
} else if (numClicks > 1) {
|
|
||||||
lastToast?.cancel()
|
|
||||||
lastToast = Toast.makeText(
|
|
||||||
requireContext(),
|
|
||||||
getString(R.string.developer_options_steps, 7 - numClicks),
|
|
||||||
Toast.LENGTH_SHORT
|
|
||||||
)
|
|
||||||
lastToast!!.show()
|
|
||||||
}
|
|
||||||
|
|
||||||
return true
|
findPreference<CheckBoxPreference>("pref_notifications_download")
|
||||||
|
?.bindBooleanFlow(preferenceRepository.notificationDownloadFlow, PreferenceKeys.NOTIFICATION_DOWNLOAD)
|
||||||
|
|
||||||
|
findPreference<CheckBoxPreference>("pref_notifications_delete")
|
||||||
|
?.bindBooleanFlow(preferenceRepository.notificationDeleteFlow, PreferenceKeys.NOTIFICATION_DELETE)
|
||||||
|
|
||||||
|
findPreference<CheckBoxPreference>("pref_notifications_enable")
|
||||||
|
?.bindBooleanFlow(preferenceRepository.notificationEnableFlow, PreferenceKeys.NOTIFICATION_ENABLE)
|
||||||
|
|
||||||
|
findPreference<CheckBoxPreference>("pref_notifications_disable")
|
||||||
|
?.bindBooleanFlow(preferenceRepository.notificationDisableFlow, PreferenceKeys.NOTIFICATION_DISABLE)
|
||||||
}
|
}
|
||||||
|
|
||||||
protected fun CheckBoxPreference.bindBooleanFlow(flow: PreferenceFlowWrapper<Boolean>) {
|
private fun CheckBoxPreference.bindBooleanFlow(flow: Flow<Boolean>, key: Preferences.Key<Boolean>) {
|
||||||
lifecycleScope.launch {
|
lifecycleScope.launch {
|
||||||
flow.collect { isChecked = it }
|
flow.collect { isChecked = it }
|
||||||
}
|
}
|
||||||
|
|
||||||
setOnPreferenceChangeListener { _, newValue ->
|
setOnPreferenceChangeListener { _, newValue ->
|
||||||
runBlocking {
|
runBlocking {
|
||||||
flow.updatePreference(newValue as Boolean)
|
preferenceRepository.updatePreference(key, newValue as Boolean)
|
||||||
}
|
}
|
||||||
true
|
true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
protected fun mergePreferenceOverlay(overlayKey: String, targetKey: String) {
|
|
||||||
val overlayCat = requirePreference<PreferenceCategory>(overlayKey)
|
|
||||||
val targetCat = requirePreference<PreferenceCategory>(targetKey)
|
|
||||||
|
|
||||||
val prefs = buildList {
|
|
||||||
for (i in 0..<overlayCat.preferenceCount) {
|
|
||||||
add(overlayCat.getPreference(i))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
prefs.forEach {
|
|
||||||
overlayCat.removePreference(it)
|
|
||||||
targetCat.addPreference(it)
|
|
||||||
}
|
|
||||||
|
|
||||||
overlayCat.parent?.removePreference(overlayCat)
|
|
||||||
}
|
|
||||||
}
|
}
|
|
@ -0,0 +1,77 @@
|
||||||
|
package im.angry.openeuicc.ui
|
||||||
|
|
||||||
|
import android.content.DialogInterface
|
||||||
|
import android.os.Bundle
|
||||||
|
import android.view.LayoutInflater
|
||||||
|
import android.view.View
|
||||||
|
import android.view.ViewGroup
|
||||||
|
import android.widget.ArrayAdapter
|
||||||
|
import android.widget.Spinner
|
||||||
|
import androidx.appcompat.widget.Toolbar
|
||||||
|
import im.angry.openeuicc.common.R
|
||||||
|
import im.angry.openeuicc.core.EuiccChannel
|
||||||
|
import im.angry.openeuicc.util.*
|
||||||
|
|
||||||
|
class SlotSelectFragment : BaseMaterialDialogFragment(), OpenEuiccContextMarker {
|
||||||
|
companion object {
|
||||||
|
const val TAG = "SlotSelectFragment"
|
||||||
|
|
||||||
|
fun newInstance(): SlotSelectFragment {
|
||||||
|
return SlotSelectFragment()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
interface SlotSelectedListener {
|
||||||
|
fun onSlotSelected(slotId: Int, portId: Int)
|
||||||
|
fun onSlotSelectCancelled()
|
||||||
|
}
|
||||||
|
|
||||||
|
private lateinit var toolbar: Toolbar
|
||||||
|
private lateinit var spinner: Spinner
|
||||||
|
private val channels: List<EuiccChannel> by lazy {
|
||||||
|
euiccChannelManager.knownChannels.sortedBy { it.logicalSlotId }
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onCreateView(
|
||||||
|
inflater: LayoutInflater,
|
||||||
|
container: ViewGroup?,
|
||||||
|
savedInstanceState: Bundle?
|
||||||
|
): View? {
|
||||||
|
val view = inflater.inflate(R.layout.fragment_slot_select, container, false)
|
||||||
|
|
||||||
|
toolbar = view.findViewById(R.id.toolbar)
|
||||||
|
toolbar.setTitle(R.string.slot_select)
|
||||||
|
toolbar.inflateMenu(R.menu.fragment_slot_select)
|
||||||
|
|
||||||
|
val adapter = ArrayAdapter<String>(inflater.context, R.layout.spinner_item)
|
||||||
|
|
||||||
|
spinner = view.findViewById(R.id.spinner)
|
||||||
|
spinner.adapter = adapter
|
||||||
|
|
||||||
|
channels.forEach { channel ->
|
||||||
|
adapter.add(getString(R.string.channel_name_format, channel.logicalSlotId))
|
||||||
|
}
|
||||||
|
|
||||||
|
toolbar.setNavigationOnClickListener {
|
||||||
|
(requireActivity() as SlotSelectedListener).onSlotSelectCancelled()
|
||||||
|
}
|
||||||
|
toolbar.setOnMenuItemClickListener {
|
||||||
|
val channel = channels[spinner.selectedItemPosition]
|
||||||
|
(requireActivity() as SlotSelectedListener).onSlotSelected(channel.slotId, channel.portId)
|
||||||
|
dismiss()
|
||||||
|
true
|
||||||
|
}
|
||||||
|
|
||||||
|
return view
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onResume() {
|
||||||
|
super.onResume()
|
||||||
|
setWidthPercent(75)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onCancel(dialog: DialogInterface) {
|
||||||
|
super.onCancel(dialog)
|
||||||
|
(requireActivity() as SlotSelectedListener).onSlotSelectCancelled()
|
||||||
|
}
|
||||||
|
}
|
|
@ -1,169 +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.ProgressBar
|
|
||||||
import android.widget.TextView
|
|
||||||
import androidx.fragment.app.Fragment
|
|
||||||
import androidx.fragment.app.commit
|
|
||||||
import androidx.lifecycle.lifecycleScope
|
|
||||||
import im.angry.openeuicc.common.R
|
|
||||||
import im.angry.openeuicc.core.EuiccChannelManager
|
|
||||||
import im.angry.openeuicc.util.*
|
|
||||||
import kotlinx.coroutines.Dispatchers
|
|
||||||
import kotlinx.coroutines.launch
|
|
||||||
import kotlinx.coroutines.withContext
|
|
||||||
|
|
||||||
/**
|
|
||||||
* A wrapper fragment over EuiccManagementFragment where we handle
|
|
||||||
* logic specific to USB devices. This is mainly USB permission
|
|
||||||
* requests, and the fact that USB devices may or may not be
|
|
||||||
* available by the time the user selects it from MainActivity.
|
|
||||||
*
|
|
||||||
* Having this fragment allows MainActivity to be (mostly) agnostic
|
|
||||||
* of the underlying implementation of different types of channels.
|
|
||||||
* When permission is granted, this fragment will simply load
|
|
||||||
* EuiccManagementFragment using its own childFragmentManager.
|
|
||||||
*
|
|
||||||
* Note that for now we assume there will only be one USB card reader
|
|
||||||
* device. This is also an implicit assumption in EuiccChannelManager.
|
|
||||||
*/
|
|
||||||
class UsbCcidReaderFragment : Fragment(), OpenEuiccContextMarker {
|
|
||||||
companion object {
|
|
||||||
const val ACTION_USB_PERMISSION = "im.angry.openeuicc.USB_PERMISSION"
|
|
||||||
}
|
|
||||||
|
|
||||||
private val euiccChannelManager: EuiccChannelManager by lazy {
|
|
||||||
(requireActivity() as MainActivity).euiccChannelManager
|
|
||||||
}
|
|
||||||
|
|
||||||
private val usbManager: UsbManager by lazy {
|
|
||||||
requireContext().getSystemService(Context.USB_SERVICE) as UsbManager
|
|
||||||
}
|
|
||||||
|
|
||||||
private val usbPermissionReceiver = object : BroadcastReceiver() {
|
|
||||||
override fun onReceive(context: Context?, intent: Intent?) {
|
|
||||||
if (intent?.action == ACTION_USB_PERMISSION) {
|
|
||||||
if (usbDevice != null && usbManager.hasPermission(usbDevice)) {
|
|
||||||
lifecycleScope.launch(Dispatchers.Main) {
|
|
||||||
tryLoadUsbChannel()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private lateinit var usbPendingIntent: PendingIntent
|
|
||||||
|
|
||||||
private lateinit var text: TextView
|
|
||||||
private lateinit var permissionButton: Button
|
|
||||||
private lateinit var loadingProgress: ProgressBar
|
|
||||||
|
|
||||||
private var usbDevice: UsbDevice? = null
|
|
||||||
|
|
||||||
override fun onCreateView(
|
|
||||||
inflater: LayoutInflater,
|
|
||||||
container: ViewGroup?,
|
|
||||||
savedInstanceState: Bundle?
|
|
||||||
): View? {
|
|
||||||
val view = inflater.inflate(R.layout.fragment_usb_ccid_reader, container, false)
|
|
||||||
|
|
||||||
text = view.requireViewById(R.id.usb_reader_text)
|
|
||||||
permissionButton = view.requireViewById(R.id.usb_grant_permission)
|
|
||||||
loadingProgress = view.requireViewById(R.id.loading)
|
|
||||||
|
|
||||||
permissionButton.setOnClickListener {
|
|
||||||
usbManager.requestPermission(usbDevice, usbPendingIntent)
|
|
||||||
}
|
|
||||||
|
|
||||||
return view
|
|
||||||
}
|
|
||||||
|
|
||||||
@SuppressLint("UnspecifiedRegisterReceiverFlag", "WrongConstant")
|
|
||||||
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
|
|
||||||
super.onViewCreated(view, savedInstanceState)
|
|
||||||
|
|
||||||
usbPendingIntent = PendingIntent.getBroadcast(
|
|
||||||
requireContext(), 0,
|
|
||||||
Intent(ACTION_USB_PERMISSION), PendingIntent.FLAG_IMMUTABLE
|
|
||||||
)
|
|
||||||
val filter = IntentFilter(ACTION_USB_PERMISSION)
|
|
||||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
|
|
||||||
requireContext().registerReceiver(
|
|
||||||
usbPermissionReceiver,
|
|
||||||
filter,
|
|
||||||
Context.RECEIVER_EXPORTED
|
|
||||||
)
|
|
||||||
} else {
|
|
||||||
requireContext().registerReceiver(usbPermissionReceiver, filter)
|
|
||||||
}
|
|
||||||
|
|
||||||
lifecycleScope.launch(Dispatchers.Main) {
|
|
||||||
tryLoadUsbChannel()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun onDetach() {
|
|
||||||
super.onDetach()
|
|
||||||
try {
|
|
||||||
requireContext().unregisterReceiver(usbPermissionReceiver)
|
|
||||||
} catch (_: Exception) {
|
|
||||||
// ignore
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun onDestroy() {
|
|
||||||
super.onDestroy()
|
|
||||||
try {
|
|
||||||
requireContext().unregisterReceiver(usbPermissionReceiver)
|
|
||||||
} catch (_: Exception) {
|
|
||||||
// ignore
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private suspend fun tryLoadUsbChannel() {
|
|
||||||
text.visibility = View.GONE
|
|
||||||
permissionButton.visibility = View.GONE
|
|
||||||
loadingProgress.visibility = View.VISIBLE
|
|
||||||
|
|
||||||
val (device, canOpen) = withContext(Dispatchers.IO) {
|
|
||||||
euiccChannelManager.tryOpenUsbEuiccChannel()
|
|
||||||
}
|
|
||||||
|
|
||||||
loadingProgress.visibility = View.GONE
|
|
||||||
|
|
||||||
usbDevice = device
|
|
||||||
|
|
||||||
if (device != null && !canOpen && !usbManager.hasPermission(device)) {
|
|
||||||
text.text = getString(R.string.usb_permission_needed)
|
|
||||||
text.visibility = View.VISIBLE
|
|
||||||
permissionButton.visibility = View.VISIBLE
|
|
||||||
} else if (device != null && canOpen) {
|
|
||||||
childFragmentManager.commit {
|
|
||||||
replace(
|
|
||||||
R.id.child_container,
|
|
||||||
appContainer.uiComponentFactory.createEuiccManagementFragment(
|
|
||||||
slotId = EuiccChannelManager.USB_CHANNEL_ID,
|
|
||||||
portId = 0
|
|
||||||
)
|
|
||||||
)
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
text.text = getString(R.string.usb_failed)
|
|
||||||
text.visibility = View.VISIBLE
|
|
||||||
permissionButton.visibility = View.GONE
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -18,4 +18,4 @@ class LongSummaryPreferenceCategory: PreferenceCategory {
|
||||||
summaryText.isSingleLine = false
|
summaryText.isSingleLine = false
|
||||||
summaryText.maxLines = 10
|
summaryText.maxLines = 10
|
||||||
}
|
}
|
||||||
}
|
}
|
|
@ -1,330 +0,0 @@
|
||||||
package im.angry.openeuicc.ui.wizard
|
|
||||||
|
|
||||||
import android.app.assist.AssistContent
|
|
||||||
import android.os.Bundle
|
|
||||||
import android.view.View
|
|
||||||
import android.view.WindowManager
|
|
||||||
import android.view.inputmethod.InputMethodManager
|
|
||||||
import android.widget.Button
|
|
||||||
import android.widget.ProgressBar
|
|
||||||
import android.widget.Toast
|
|
||||||
import androidx.activity.OnBackPressedCallback
|
|
||||||
import androidx.activity.enableEdgeToEdge
|
|
||||||
import androidx.core.net.toUri
|
|
||||||
import androidx.core.view.ViewCompat
|
|
||||||
import androidx.core.view.WindowInsetsCompat
|
|
||||||
import androidx.core.view.updatePadding
|
|
||||||
import androidx.fragment.app.Fragment
|
|
||||||
import androidx.lifecycle.lifecycleScope
|
|
||||||
import im.angry.openeuicc.common.R
|
|
||||||
import im.angry.openeuicc.core.EuiccChannelManager
|
|
||||||
import im.angry.openeuicc.ui.BaseEuiccAccessActivity
|
|
||||||
import im.angry.openeuicc.util.*
|
|
||||||
import kotlinx.coroutines.Dispatchers
|
|
||||||
import kotlinx.coroutines.launch
|
|
||||||
import net.typeblog.lpac_jni.LocalProfileAssistant
|
|
||||||
|
|
||||||
class DownloadWizardActivity: BaseEuiccAccessActivity() {
|
|
||||||
data class DownloadWizardState(
|
|
||||||
var currentStepFragmentClassName: String?,
|
|
||||||
var selectedLogicalSlot: Int,
|
|
||||||
var smdp: String,
|
|
||||||
var matchingId: String?,
|
|
||||||
var confirmationCode: String?,
|
|
||||||
var imei: String?,
|
|
||||||
var downloadStarted: Boolean,
|
|
||||||
var downloadTaskID: Long,
|
|
||||||
var downloadError: LocalProfileAssistant.ProfileDownloadException?,
|
|
||||||
var skipMethodSelect: Boolean,
|
|
||||||
var confirmationCodeRequired: Boolean,
|
|
||||||
)
|
|
||||||
|
|
||||||
private lateinit var state: DownloadWizardState
|
|
||||||
|
|
||||||
private lateinit var progressBar: ProgressBar
|
|
||||||
private lateinit var nextButton: Button
|
|
||||||
private lateinit var prevButton: Button
|
|
||||||
|
|
||||||
private var currentFragment: DownloadWizardStepFragment? = null
|
|
||||||
set(value) {
|
|
||||||
if (this::state.isInitialized) {
|
|
||||||
state.currentStepFragmentClassName = value?.javaClass?.name
|
|
||||||
}
|
|
||||||
field = value
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun onCreate(savedInstanceState: Bundle?) {
|
|
||||||
enableEdgeToEdge()
|
|
||||||
super.onCreate(savedInstanceState)
|
|
||||||
setContentView(R.layout.activity_download_wizard)
|
|
||||||
onBackPressedDispatcher.addCallback(object : OnBackPressedCallback(true) {
|
|
||||||
override fun handleOnBackPressed() {
|
|
||||||
// Make back == prev
|
|
||||||
onPrevPressed()
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
state = DownloadWizardState(
|
|
||||||
currentStepFragmentClassName = null,
|
|
||||||
selectedLogicalSlot = intent.getIntExtra("selectedLogicalSlot", 0),
|
|
||||||
smdp = "",
|
|
||||||
matchingId = null,
|
|
||||||
confirmationCode = null,
|
|
||||||
imei = null,
|
|
||||||
downloadStarted = false,
|
|
||||||
downloadTaskID = -1,
|
|
||||||
downloadError = null,
|
|
||||||
skipMethodSelect = false,
|
|
||||||
confirmationCodeRequired = false,
|
|
||||||
)
|
|
||||||
|
|
||||||
handleDeepLink()
|
|
||||||
|
|
||||||
progressBar = requireViewById(R.id.progress)
|
|
||||||
nextButton = requireViewById(R.id.download_wizard_next)
|
|
||||||
prevButton = requireViewById(R.id.download_wizard_back)
|
|
||||||
|
|
||||||
nextButton.setOnClickListener {
|
|
||||||
onNextPressed()
|
|
||||||
}
|
|
||||||
|
|
||||||
prevButton.setOnClickListener {
|
|
||||||
onPrevPressed()
|
|
||||||
}
|
|
||||||
|
|
||||||
val navigation = requireViewById<View>(R.id.download_wizard_navigation)
|
|
||||||
val origHeight = navigation.layoutParams.height
|
|
||||||
|
|
||||||
ViewCompat.setOnApplyWindowInsetsListener(navigation) { v, insets ->
|
|
||||||
val bars = insets.getInsets(
|
|
||||||
WindowInsetsCompat.Type.systemBars()
|
|
||||||
or WindowInsetsCompat.Type.displayCutout()
|
|
||||||
or WindowInsetsCompat.Type.ime()
|
|
||||||
)
|
|
||||||
v.updatePadding(bars.left, 0, bars.right, bars.bottom)
|
|
||||||
val newParams = navigation.layoutParams
|
|
||||||
newParams.height = origHeight + bars.bottom
|
|
||||||
navigation.layoutParams = newParams
|
|
||||||
WindowInsetsCompat.CONSUMED
|
|
||||||
}
|
|
||||||
|
|
||||||
val fragmentRoot = requireViewById<View>(R.id.step_fragment_container)
|
|
||||||
ViewCompat.setOnApplyWindowInsetsListener(fragmentRoot) { v, insets ->
|
|
||||||
val bars = insets.getInsets(
|
|
||||||
WindowInsetsCompat.Type.systemBars()
|
|
||||||
or WindowInsetsCompat.Type.displayCutout()
|
|
||||||
)
|
|
||||||
v.updatePadding(bars.left, bars.top, bars.right, 0)
|
|
||||||
WindowInsetsCompat.CONSUMED
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun handleDeepLink() {
|
|
||||||
// If we get an LPA string from deep-link intents, extract from there.
|
|
||||||
// Note that `onRestoreInstanceState` could override this with user input,
|
|
||||||
// but that _is_ the desired behavior.
|
|
||||||
val uri = intent.data
|
|
||||||
if (uri?.scheme == "lpa") {
|
|
||||||
val parsed = LPAString.parse(uri.schemeSpecificPart)
|
|
||||||
state.smdp = parsed.address
|
|
||||||
state.matchingId = parsed.matchingId
|
|
||||||
state.confirmationCodeRequired = parsed.confirmationCodeRequired
|
|
||||||
state.skipMethodSelect = true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun onProvideAssistContent(outContent: AssistContent?) {
|
|
||||||
super.onProvideAssistContent(outContent)
|
|
||||||
outContent?.webUri = try {
|
|
||||||
val activationCode = LPAString(
|
|
||||||
state.smdp,
|
|
||||||
state.matchingId,
|
|
||||||
null,
|
|
||||||
state.confirmationCode != null,
|
|
||||||
)
|
|
||||||
"LPA:$activationCode".toUri()
|
|
||||||
} catch (_: Exception) {
|
|
||||||
null
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun onSaveInstanceState(outState: Bundle) {
|
|
||||||
super.onSaveInstanceState(outState)
|
|
||||||
outState.putString("currentStepFragmentClassName", state.currentStepFragmentClassName)
|
|
||||||
outState.putInt("selectedLogicalSlot", state.selectedLogicalSlot)
|
|
||||||
outState.putString("smdp", state.smdp)
|
|
||||||
outState.putString("matchingId", state.matchingId)
|
|
||||||
outState.putString("confirmationCode", state.confirmationCode)
|
|
||||||
outState.putString("imei", state.imei)
|
|
||||||
outState.putBoolean("downloadStarted", state.downloadStarted)
|
|
||||||
outState.putLong("downloadTaskID", state.downloadTaskID)
|
|
||||||
outState.putBoolean("confirmationCodeRequired", state.confirmationCodeRequired)
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun onRestoreInstanceState(savedInstanceState: Bundle) {
|
|
||||||
super.onRestoreInstanceState(savedInstanceState)
|
|
||||||
state.currentStepFragmentClassName = savedInstanceState.getString(
|
|
||||||
"currentStepFragmentClassName",
|
|
||||||
state.currentStepFragmentClassName
|
|
||||||
)
|
|
||||||
state.selectedLogicalSlot =
|
|
||||||
savedInstanceState.getInt("selectedLogicalSlot", state.selectedLogicalSlot)
|
|
||||||
state.smdp = savedInstanceState.getString("smdp", state.smdp)
|
|
||||||
state.matchingId = savedInstanceState.getString("matchingId", state.matchingId)
|
|
||||||
state.imei = savedInstanceState.getString("imei", state.imei)
|
|
||||||
state.downloadStarted =
|
|
||||||
savedInstanceState.getBoolean("downloadStarted", state.downloadStarted)
|
|
||||||
state.downloadTaskID = savedInstanceState.getLong("downloadTaskID", state.downloadTaskID)
|
|
||||||
state.confirmationCode = savedInstanceState.getString("confirmationCode", state.confirmationCode)
|
|
||||||
state.confirmationCodeRequired = savedInstanceState.getBoolean("confirmationCodeRequired", state.confirmationCodeRequired)
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun onPrevPressed() {
|
|
||||||
hideIme()
|
|
||||||
|
|
||||||
if (currentFragment?.hasPrev == true) {
|
|
||||||
val prevFrag = currentFragment?.createPrevFragment()
|
|
||||||
if (prevFrag == null) {
|
|
||||||
finish()
|
|
||||||
} else {
|
|
||||||
showFragment(prevFrag, R.anim.slide_in_left, R.anim.slide_out_right)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun onNextPressed() {
|
|
||||||
hideIme()
|
|
||||||
|
|
||||||
nextButton.isEnabled = false
|
|
||||||
progressBar.visibility = View.VISIBLE
|
|
||||||
progressBar.isIndeterminate = true
|
|
||||||
|
|
||||||
lifecycleScope.launch(Dispatchers.Main) {
|
|
||||||
if (state.selectedLogicalSlot >= 0) {
|
|
||||||
try {
|
|
||||||
// This is run on IO by default
|
|
||||||
euiccChannelManager.withEuiccChannel(state.selectedLogicalSlot) { channel ->
|
|
||||||
// Be _very_ sure that the channel we got is valid
|
|
||||||
if (!channel.valid) throw EuiccChannelManager.EuiccChannelNotFoundException()
|
|
||||||
}
|
|
||||||
} catch (e: EuiccChannelManager.EuiccChannelNotFoundException) {
|
|
||||||
Toast.makeText(
|
|
||||||
this@DownloadWizardActivity,
|
|
||||||
R.string.download_wizard_slot_removed,
|
|
||||||
Toast.LENGTH_LONG
|
|
||||||
).show()
|
|
||||||
finish()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
progressBar.visibility = View.GONE
|
|
||||||
nextButton.isEnabled = true
|
|
||||||
|
|
||||||
if (currentFragment?.hasNext == true) {
|
|
||||||
currentFragment?.beforeNext()
|
|
||||||
val nextFrag = currentFragment?.createNextFragment()
|
|
||||||
if (nextFrag == null) {
|
|
||||||
finish()
|
|
||||||
} else {
|
|
||||||
showFragment(nextFrag, R.anim.slide_in_right, R.anim.slide_out_left)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun onInit() {
|
|
||||||
progressBar.visibility = View.GONE
|
|
||||||
|
|
||||||
if (state.currentStepFragmentClassName != null) {
|
|
||||||
val clazz = Class.forName(state.currentStepFragmentClassName!!)
|
|
||||||
showFragment(clazz.getDeclaredConstructor().newInstance() as DownloadWizardStepFragment)
|
|
||||||
} else {
|
|
||||||
showFragment(DownloadWizardSlotSelectFragment())
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun showFragment(
|
|
||||||
nextFrag: DownloadWizardStepFragment,
|
|
||||||
enterAnim: Int = 0,
|
|
||||||
exitAnim: Int = 0
|
|
||||||
) {
|
|
||||||
currentFragment = nextFrag
|
|
||||||
supportFragmentManager.beginTransaction().setCustomAnimations(enterAnim, exitAnim)
|
|
||||||
.replace(R.id.step_fragment_container, nextFrag)
|
|
||||||
.commit()
|
|
||||||
|
|
||||||
// Sync screen on state
|
|
||||||
if (nextFrag.keepScreenOn) {
|
|
||||||
window.addFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON)
|
|
||||||
} else {
|
|
||||||
window.clearFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON)
|
|
||||||
}
|
|
||||||
|
|
||||||
refreshButtons()
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun refreshButtons() {
|
|
||||||
currentFragment?.let {
|
|
||||||
nextButton.visibility = if (it.hasNext) {
|
|
||||||
View.VISIBLE
|
|
||||||
} else {
|
|
||||||
View.GONE
|
|
||||||
}
|
|
||||||
prevButton.visibility = if (it.hasPrev) {
|
|
||||||
View.VISIBLE
|
|
||||||
} else {
|
|
||||||
View.GONE
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun hideIme() {
|
|
||||||
currentFocus?.let {
|
|
||||||
val imm = getSystemService(InputMethodManager::class.java)
|
|
||||||
imm.hideSoftInputFromWindow(it.windowToken, 0)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
abstract class DownloadWizardStepFragment : Fragment(), OpenEuiccContextMarker {
|
|
||||||
protected val state: DownloadWizardState
|
|
||||||
get() = (requireActivity() as DownloadWizardActivity).state
|
|
||||||
|
|
||||||
open val keepScreenOn = false
|
|
||||||
|
|
||||||
abstract val hasNext: Boolean
|
|
||||||
abstract val hasPrev: Boolean
|
|
||||||
abstract fun createNextFragment(): DownloadWizardStepFragment?
|
|
||||||
abstract fun createPrevFragment(): DownloadWizardStepFragment?
|
|
||||||
|
|
||||||
protected fun gotoNextFragment(next: DownloadWizardStepFragment? = null) {
|
|
||||||
val realNext = next ?: createNextFragment()
|
|
||||||
(requireActivity() as DownloadWizardActivity).showFragment(
|
|
||||||
realNext!!,
|
|
||||||
R.anim.slide_in_right,
|
|
||||||
R.anim.slide_out_left
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
protected fun hideProgressBar() {
|
|
||||||
(requireActivity() as DownloadWizardActivity).progressBar.visibility = View.GONE
|
|
||||||
}
|
|
||||||
|
|
||||||
protected fun showProgressBar(progressValue: Int) {
|
|
||||||
(requireActivity() as DownloadWizardActivity).progressBar.apply {
|
|
||||||
visibility = View.VISIBLE
|
|
||||||
if (progressValue >= 0) {
|
|
||||||
isIndeterminate = false
|
|
||||||
progress = progressValue
|
|
||||||
} else {
|
|
||||||
isIndeterminate = true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
protected fun refreshButtons() {
|
|
||||||
(requireActivity() as DownloadWizardActivity).refreshButtons()
|
|
||||||
}
|
|
||||||
|
|
||||||
open fun beforeNext() {}
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -1,117 +0,0 @@
|
||||||
package im.angry.openeuicc.ui.wizard
|
|
||||||
|
|
||||||
import android.os.Bundle
|
|
||||||
import android.view.LayoutInflater
|
|
||||||
import android.view.View
|
|
||||||
import android.view.ViewGroup
|
|
||||||
import androidx.core.widget.addTextChangedListener
|
|
||||||
import com.google.android.material.textfield.TextInputLayout
|
|
||||||
import im.angry.openeuicc.common.R
|
|
||||||
|
|
||||||
class DownloadWizardDetailsFragment : DownloadWizardActivity.DownloadWizardStepFragment() {
|
|
||||||
private var inputComplete = false
|
|
||||||
|
|
||||||
override val hasNext: Boolean
|
|
||||||
get() = inputComplete
|
|
||||||
override val hasPrev: Boolean
|
|
||||||
get() = true
|
|
||||||
|
|
||||||
private lateinit var smdp: TextInputLayout
|
|
||||||
private lateinit var matchingId: TextInputLayout
|
|
||||||
private lateinit var confirmationCode: TextInputLayout
|
|
||||||
private lateinit var imei: TextInputLayout
|
|
||||||
|
|
||||||
private fun saveState() {
|
|
||||||
state.smdp = smdp.editText!!.text.toString().trim()
|
|
||||||
// Treat empty inputs as null -- this is important for the download step
|
|
||||||
state.matchingId = matchingId.editText!!.text.toString().trim().ifBlank { null }
|
|
||||||
state.confirmationCode = confirmationCode.editText!!.text.toString().trim().ifBlank { null }
|
|
||||||
state.imei = imei.editText!!.text.toString().ifBlank { null }
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun beforeNext() = saveState()
|
|
||||||
|
|
||||||
override fun createNextFragment(): DownloadWizardActivity.DownloadWizardStepFragment =
|
|
||||||
DownloadWizardProgressFragment()
|
|
||||||
|
|
||||||
override fun createPrevFragment(): DownloadWizardActivity.DownloadWizardStepFragment =
|
|
||||||
if (state.skipMethodSelect) {
|
|
||||||
DownloadWizardSlotSelectFragment()
|
|
||||||
} else {
|
|
||||||
DownloadWizardMethodSelectFragment()
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun onCreateView(
|
|
||||||
inflater: LayoutInflater,
|
|
||||||
container: ViewGroup?,
|
|
||||||
savedInstanceState: Bundle?
|
|
||||||
): View? {
|
|
||||||
val view = inflater.inflate(R.layout.fragment_download_details, container, false)
|
|
||||||
smdp = view.requireViewById(R.id.profile_download_server)
|
|
||||||
matchingId = view.requireViewById(R.id.profile_download_code)
|
|
||||||
confirmationCode = view.requireViewById(R.id.profile_download_confirmation_code)
|
|
||||||
imei = view.requireViewById(R.id.profile_download_imei)
|
|
||||||
smdp.editText!!.addTextChangedListener {
|
|
||||||
updateInputCompleteness()
|
|
||||||
}
|
|
||||||
confirmationCode.editText!!.addTextChangedListener {
|
|
||||||
updateInputCompleteness()
|
|
||||||
}
|
|
||||||
return view
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun onStart() {
|
|
||||||
super.onStart()
|
|
||||||
smdp.editText!!.setText(state.smdp)
|
|
||||||
matchingId.editText!!.setText(state.matchingId)
|
|
||||||
confirmationCode.editText!!.setText(state.confirmationCode)
|
|
||||||
imei.editText!!.setText(state.imei)
|
|
||||||
updateInputCompleteness()
|
|
||||||
|
|
||||||
if (state.confirmationCodeRequired) {
|
|
||||||
confirmationCode.editText!!.requestFocus()
|
|
||||||
confirmationCode.editText!!.hint =
|
|
||||||
getString(R.string.profile_download_confirmation_code_required)
|
|
||||||
} else {
|
|
||||||
confirmationCode.editText!!.hint =
|
|
||||||
getString(R.string.profile_download_confirmation_code)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun onPause() {
|
|
||||||
super.onPause()
|
|
||||||
saveState()
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun updateInputCompleteness() {
|
|
||||||
inputComplete = isValidAddress(smdp.editText!!.text)
|
|
||||||
if (state.confirmationCodeRequired) {
|
|
||||||
inputComplete = inputComplete && confirmationCode.editText!!.text.isNotEmpty()
|
|
||||||
}
|
|
||||||
refreshButtons()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun isValidAddress(input: CharSequence): Boolean {
|
|
||||||
if (!input.contains('.')) return false
|
|
||||||
var fqdn = input
|
|
||||||
var port = 443
|
|
||||||
if (input.contains(':')) {
|
|
||||||
val portIndex = input.lastIndexOf(':')
|
|
||||||
fqdn = input.substring(0, portIndex)
|
|
||||||
port = input.substring(portIndex + 1, input.length).toIntOrNull(10) ?: 0
|
|
||||||
}
|
|
||||||
// see https://en.wikipedia.org/wiki/Port_(computer_networking)
|
|
||||||
if (port < 1 || port > 0xffff) return false
|
|
||||||
// see https://en.wikipedia.org/wiki/Fully_qualified_domain_name
|
|
||||||
if (fqdn.isEmpty() || fqdn.length > 255) return false
|
|
||||||
for (part in fqdn.split('.')) {
|
|
||||||
if (part.isEmpty() || part.length > 64) return false
|
|
||||||
if (part.first() == '-' || part.last() == '-') return false
|
|
||||||
for (c in part) {
|
|
||||||
if (c.isLetterOrDigit() || c == '-') continue
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return true
|
|
||||||
}
|
|
|
@ -1,139 +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 java.util.Date
|
|
||||||
|
|
||||||
class DownloadWizardDiagnosticsFragment : DownloadWizardActivity.DownloadWizardStepFragment() {
|
|
||||||
override val hasNext: Boolean
|
|
||||||
get() = true
|
|
||||||
override val hasPrev: Boolean
|
|
||||||
get() = false
|
|
||||||
|
|
||||||
private lateinit var diagnosticTextView: TextView
|
|
||||||
|
|
||||||
private val saveDiagnostics =
|
|
||||||
setupLogSaving(
|
|
||||||
getLogFileName = {
|
|
||||||
getString(
|
|
||||||
R.string.download_wizard_diagnostics_file_template,
|
|
||||||
SimpleDateFormat.getDateTimeInstance().format(Date())
|
|
||||||
)
|
|
||||||
},
|
|
||||||
getLogText = { diagnosticTextView.text.toString() }
|
|
||||||
)
|
|
||||||
|
|
||||||
override fun createNextFragment(): DownloadWizardActivity.DownloadWizardStepFragment? = null
|
|
||||||
|
|
||||||
override fun createPrevFragment(): DownloadWizardActivity.DownloadWizardStepFragment? = null
|
|
||||||
|
|
||||||
override fun onCreateView(
|
|
||||||
inflater: LayoutInflater,
|
|
||||||
container: ViewGroup?,
|
|
||||||
savedInstanceState: Bundle?
|
|
||||||
): View? {
|
|
||||||
val view = inflater.inflate(R.layout.fragment_download_diagnostics, container, false)
|
|
||||||
view.requireViewById<View>(R.id.download_wizard_diagnostics_save).setOnClickListener {
|
|
||||||
saveDiagnostics()
|
|
||||||
}
|
|
||||||
diagnosticTextView = view.requireViewById(R.id.download_wizard_diagnostics_text)
|
|
||||||
return view
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun onStart() {
|
|
||||||
super.onStart()
|
|
||||||
val str = buildDiagnosticsText()
|
|
||||||
if (str == null) {
|
|
||||||
requireActivity().finish()
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
diagnosticTextView.text = str
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun buildDiagnosticsText(): String? = state.downloadError?.let { err ->
|
|
||||||
val ret = StringBuilder()
|
|
||||||
|
|
||||||
ret.appendLine(
|
|
||||||
getString(
|
|
||||||
R.string.download_wizard_diagnostics_error_code,
|
|
||||||
err.lpaErrorReason
|
|
||||||
)
|
|
||||||
)
|
|
||||||
ret.appendLine()
|
|
||||||
|
|
||||||
err.lastHttpResponse?.let { resp ->
|
|
||||||
if (resp.rcode != 200) {
|
|
||||||
// Only show the status if it's not 200
|
|
||||||
// Because we can have errors even if the rcode is 200 due to SM-DP+ servers being dumb
|
|
||||||
// and showing 200 might mislead users
|
|
||||||
ret.appendLine(
|
|
||||||
getString(
|
|
||||||
R.string.download_wizard_diagnostics_last_http_status,
|
|
||||||
resp.rcode
|
|
||||||
)
|
|
||||||
)
|
|
||||||
ret.appendLine()
|
|
||||||
}
|
|
||||||
|
|
||||||
ret.appendLine(getString(R.string.download_wizard_diagnostics_last_http_response))
|
|
||||||
ret.appendLine()
|
|
||||||
|
|
||||||
val str = resp.data.decodeToString(throwOnInvalidSequence = false)
|
|
||||||
ret.appendLine(
|
|
||||||
if (str.startsWith('{')) {
|
|
||||||
str.prettyPrintJson()
|
|
||||||
} else {
|
|
||||||
str
|
|
||||||
}
|
|
||||||
)
|
|
||||||
|
|
||||||
ret.appendLine()
|
|
||||||
}
|
|
||||||
|
|
||||||
err.lastHttpException?.let { e ->
|
|
||||||
ret.appendLine(getString(R.string.download_wizard_diagnostics_last_http_exception))
|
|
||||||
ret.appendLine()
|
|
||||||
ret.appendLine("${e.javaClass.name}: ${e.message}")
|
|
||||||
ret.appendLine(e.stackTrace.joinToString("\n"))
|
|
||||||
ret.appendLine()
|
|
||||||
}
|
|
||||||
|
|
||||||
err.lastApduResponse?.let { resp ->
|
|
||||||
val isSuccess =
|
|
||||||
resp.size >= 2 && resp[resp.size - 2] == 0x90.toByte() && resp[resp.size - 1] == 0x00.toByte()
|
|
||||||
|
|
||||||
if (isSuccess) {
|
|
||||||
ret.appendLine(getString(R.string.download_wizard_diagnostics_last_apdu_response_success))
|
|
||||||
} else {
|
|
||||||
// Only show the full APDU response when it's a failure
|
|
||||||
// Otherwise it's going to get very crammed
|
|
||||||
ret.appendLine(
|
|
||||||
getString(
|
|
||||||
R.string.download_wizard_diagnostics_last_apdu_response,
|
|
||||||
resp.encodeHex()
|
|
||||||
)
|
|
||||||
)
|
|
||||||
ret.appendLine()
|
|
||||||
|
|
||||||
ret.appendLine(getString(R.string.download_wizard_diagnostics_last_apdu_response_fail))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
err.lastApduException?.let { e ->
|
|
||||||
ret.appendLine(getString(R.string.download_wizard_diagnostics_last_apdu_exception))
|
|
||||||
ret.appendLine()
|
|
||||||
ret.appendLine("${e.javaClass.name}: ${e.message}")
|
|
||||||
ret.appendLine(e.stackTrace.joinToString("\n"))
|
|
||||||
ret.appendLine()
|
|
||||||
}
|
|
||||||
|
|
||||||
ret.toString()
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -1,173 +0,0 @@
|
||||||
package im.angry.openeuicc.ui.wizard
|
|
||||||
|
|
||||||
import android.app.AlertDialog
|
|
||||||
import android.content.ClipboardManager
|
|
||||||
import android.graphics.BitmapFactory
|
|
||||||
import android.os.Bundle
|
|
||||||
import android.view.LayoutInflater
|
|
||||||
import android.view.View
|
|
||||||
import android.view.ViewGroup
|
|
||||||
import android.widget.ImageView
|
|
||||||
import android.widget.TextView
|
|
||||||
import android.widget.Toast
|
|
||||||
import androidx.activity.result.contract.ActivityResultContracts
|
|
||||||
import androidx.lifecycle.lifecycleScope
|
|
||||||
import androidx.recyclerview.widget.DividerItemDecoration
|
|
||||||
import androidx.recyclerview.widget.LinearLayoutManager
|
|
||||||
import androidx.recyclerview.widget.RecyclerView
|
|
||||||
import androidx.recyclerview.widget.RecyclerView.ViewHolder
|
|
||||||
import com.journeyapps.barcodescanner.ScanContract
|
|
||||||
import com.journeyapps.barcodescanner.ScanOptions
|
|
||||||
import im.angry.openeuicc.common.R
|
|
||||||
import im.angry.openeuicc.util.*
|
|
||||||
import kotlinx.coroutines.Dispatchers
|
|
||||||
import kotlinx.coroutines.launch
|
|
||||||
import kotlinx.coroutines.withContext
|
|
||||||
|
|
||||||
class DownloadWizardMethodSelectFragment : DownloadWizardActivity.DownloadWizardStepFragment() {
|
|
||||||
data class DownloadMethod(
|
|
||||||
val iconRes: Int,
|
|
||||||
val titleRes: Int,
|
|
||||||
val onClick: () -> Unit
|
|
||||||
)
|
|
||||||
|
|
||||||
// TODO: Maybe we should find a better barcode scanner (or an external one?)
|
|
||||||
private val barcodeScannerLauncher = registerForActivityResult(ScanContract()) { result ->
|
|
||||||
result.contents?.let { content ->
|
|
||||||
processLpaString(content)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private val gallerySelectorLauncher =
|
|
||||||
registerForActivityResult(ActivityResultContracts.GetContent()) { result ->
|
|
||||||
if (result == null) return@registerForActivityResult
|
|
||||||
|
|
||||||
lifecycleScope.launch {
|
|
||||||
val decoded = withContext(Dispatchers.IO) {
|
|
||||||
runCatching {
|
|
||||||
requireContext().contentResolver.openInputStream(result)?.use { input ->
|
|
||||||
BitmapFactory.decodeStream(input).use(::decodeQrFromBitmap)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
decoded.getOrNull()?.let { processLpaString(it) }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
val downloadMethods = arrayOf(
|
|
||||||
DownloadMethod(R.drawable.ic_scan_black, R.string.download_wizard_method_qr_code) {
|
|
||||||
barcodeScannerLauncher.launch(ScanOptions().apply {
|
|
||||||
setDesiredBarcodeFormats(ScanOptions.QR_CODE)
|
|
||||||
setOrientationLocked(false)
|
|
||||||
})
|
|
||||||
},
|
|
||||||
DownloadMethod(R.drawable.ic_gallery_black, R.string.download_wizard_method_gallery) {
|
|
||||||
gallerySelectorLauncher.launch("image/*")
|
|
||||||
},
|
|
||||||
DownloadMethod(R.drawable.ic_paste_go, R.string.download_wizard_method_clipboard) {
|
|
||||||
handleLoadFromClipboard()
|
|
||||||
},
|
|
||||||
DownloadMethod(R.drawable.ic_edit, R.string.download_wizard_method_manual) {
|
|
||||||
gotoNextFragment(DownloadWizardDetailsFragment())
|
|
||||||
}
|
|
||||||
)
|
|
||||||
|
|
||||||
override val hasNext: Boolean
|
|
||||||
get() = false
|
|
||||||
override val hasPrev: Boolean
|
|
||||||
get() = true
|
|
||||||
|
|
||||||
override fun createNextFragment(): DownloadWizardActivity.DownloadWizardStepFragment? =
|
|
||||||
null
|
|
||||||
|
|
||||||
override fun createPrevFragment(): DownloadWizardActivity.DownloadWizardStepFragment =
|
|
||||||
DownloadWizardSlotSelectFragment()
|
|
||||||
|
|
||||||
override fun onCreateView(
|
|
||||||
inflater: LayoutInflater,
|
|
||||||
container: ViewGroup?,
|
|
||||||
savedInstanceState: Bundle?
|
|
||||||
): View? {
|
|
||||||
val view = inflater.inflate(R.layout.fragment_download_method_select, container, false)
|
|
||||||
val recyclerView = view.requireViewById<RecyclerView>(R.id.download_method_list)
|
|
||||||
recyclerView.adapter = DownloadMethodAdapter()
|
|
||||||
recyclerView.layoutManager =
|
|
||||||
LinearLayoutManager(view.context, LinearLayoutManager.VERTICAL, false)
|
|
||||||
recyclerView.addItemDecoration(
|
|
||||||
DividerItemDecoration(
|
|
||||||
requireContext(),
|
|
||||||
LinearLayoutManager.VERTICAL
|
|
||||||
)
|
|
||||||
)
|
|
||||||
return view
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun handleLoadFromClipboard() {
|
|
||||||
val clipboard = requireContext().getSystemService(ClipboardManager::class.java)
|
|
||||||
val text = clipboard.primaryClip?.getItemAt(0)?.text
|
|
||||||
|
|
||||||
if (text == null) {
|
|
||||||
Toast.makeText(
|
|
||||||
requireContext(),
|
|
||||||
R.string.profile_download_no_lpa_string,
|
|
||||||
Toast.LENGTH_SHORT
|
|
||||||
).show()
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
processLpaString(text.toString())
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun processLpaString(input: String) {
|
|
||||||
try {
|
|
||||||
val parsed = LPAString.parse(input)
|
|
||||||
state.smdp = parsed.address
|
|
||||||
state.matchingId = parsed.matchingId
|
|
||||||
state.confirmationCodeRequired = parsed.confirmationCodeRequired
|
|
||||||
gotoNextFragment(DownloadWizardDetailsFragment())
|
|
||||||
} catch (e: IllegalArgumentException) {
|
|
||||||
AlertDialog.Builder(requireContext()).apply {
|
|
||||||
setTitle(R.string.profile_download_incorrect_lpa_string)
|
|
||||||
setMessage(R.string.profile_download_incorrect_lpa_string_message)
|
|
||||||
setCancelable(true)
|
|
||||||
setNegativeButton(android.R.string.cancel, null)
|
|
||||||
show()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private inner class DownloadMethodViewHolder(private val root: View) : ViewHolder(root) {
|
|
||||||
private val icon = root.requireViewById<ImageView>(R.id.download_method_icon)
|
|
||||||
private val title = root.requireViewById<TextView>(R.id.download_method_title)
|
|
||||||
|
|
||||||
fun bind(item: DownloadMethod) {
|
|
||||||
icon.setImageResource(item.iconRes)
|
|
||||||
title.setText(item.titleRes)
|
|
||||||
root.setOnClickListener {
|
|
||||||
// If the user elected to use another download method, reset the confirmation code flag
|
|
||||||
// too
|
|
||||||
state.confirmationCodeRequired = false
|
|
||||||
item.onClick()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private inner class DownloadMethodAdapter : RecyclerView.Adapter<DownloadMethodViewHolder>() {
|
|
||||||
override fun onCreateViewHolder(
|
|
||||||
parent: ViewGroup,
|
|
||||||
viewType: Int
|
|
||||||
): DownloadMethodViewHolder {
|
|
||||||
val view = LayoutInflater.from(parent.context)
|
|
||||||
.inflate(R.layout.download_method_item, parent, false)
|
|
||||||
return DownloadMethodViewHolder(view)
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun getItemCount(): Int = downloadMethods.size
|
|
||||||
|
|
||||||
override fun onBindViewHolder(holder: DownloadMethodViewHolder, position: Int) {
|
|
||||||
holder.bind(downloadMethods[position])
|
|
||||||
}
|
|
||||||
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -1,243 +0,0 @@
|
||||||
package im.angry.openeuicc.ui.wizard
|
|
||||||
|
|
||||||
import android.os.Bundle
|
|
||||||
import android.view.LayoutInflater
|
|
||||||
import android.view.View
|
|
||||||
import android.view.ViewGroup
|
|
||||||
import android.widget.ImageView
|
|
||||||
import android.widget.ProgressBar
|
|
||||||
import android.widget.TextView
|
|
||||||
import androidx.lifecycle.lifecycleScope
|
|
||||||
import androidx.recyclerview.widget.DividerItemDecoration
|
|
||||||
import androidx.recyclerview.widget.LinearLayoutManager
|
|
||||||
import androidx.recyclerview.widget.RecyclerView
|
|
||||||
import im.angry.openeuicc.common.R
|
|
||||||
import im.angry.openeuicc.service.EuiccChannelManagerService
|
|
||||||
import im.angry.openeuicc.util.*
|
|
||||||
import kotlinx.coroutines.flow.collect
|
|
||||||
import kotlinx.coroutines.flow.onEach
|
|
||||||
import kotlinx.coroutines.launch
|
|
||||||
import net.typeblog.lpac_jni.LocalProfileAssistant
|
|
||||||
import net.typeblog.lpac_jni.ProfileDownloadCallback
|
|
||||||
|
|
||||||
class DownloadWizardProgressFragment : DownloadWizardActivity.DownloadWizardStepFragment() {
|
|
||||||
companion object {
|
|
||||||
/**
|
|
||||||
* An array of LPA-side state types, mapping 1:1 to progressItems
|
|
||||||
*/
|
|
||||||
val LPA_PROGRESS_STATES = arrayOf(
|
|
||||||
ProfileDownloadCallback.DownloadState.Preparing,
|
|
||||||
ProfileDownloadCallback.DownloadState.Connecting,
|
|
||||||
ProfileDownloadCallback.DownloadState.Authenticating,
|
|
||||||
ProfileDownloadCallback.DownloadState.Downloading,
|
|
||||||
ProfileDownloadCallback.DownloadState.Finalizing,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
private enum class ProgressState {
|
|
||||||
NotStarted,
|
|
||||||
InProgress,
|
|
||||||
Done,
|
|
||||||
Error
|
|
||||||
}
|
|
||||||
|
|
||||||
private data class ProgressItem(
|
|
||||||
val titleRes: Int,
|
|
||||||
var state: ProgressState
|
|
||||||
)
|
|
||||||
|
|
||||||
private val progressItems = arrayOf(
|
|
||||||
ProgressItem(R.string.download_wizard_progress_step_preparing, ProgressState.NotStarted),
|
|
||||||
ProgressItem(R.string.download_wizard_progress_step_connecting, ProgressState.NotStarted),
|
|
||||||
ProgressItem(
|
|
||||||
R.string.download_wizard_progress_step_authenticating,
|
|
||||||
ProgressState.NotStarted
|
|
||||||
),
|
|
||||||
ProgressItem(R.string.download_wizard_progress_step_downloading, ProgressState.NotStarted),
|
|
||||||
ProgressItem(R.string.download_wizard_progress_step_finalizing, ProgressState.NotStarted)
|
|
||||||
)
|
|
||||||
|
|
||||||
private val adapter = ProgressItemAdapter()
|
|
||||||
|
|
||||||
// We don't want to turn off the screen during a download
|
|
||||||
override val keepScreenOn = true
|
|
||||||
|
|
||||||
private var isDone = false
|
|
||||||
|
|
||||||
override val hasNext: Boolean
|
|
||||||
get() = isDone
|
|
||||||
override val hasPrev: Boolean
|
|
||||||
get() = false
|
|
||||||
|
|
||||||
override fun createNextFragment(): DownloadWizardActivity.DownloadWizardStepFragment? =
|
|
||||||
if (state.downloadError != null) {
|
|
||||||
DownloadWizardDiagnosticsFragment()
|
|
||||||
} else {
|
|
||||||
null
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun createPrevFragment(): DownloadWizardActivity.DownloadWizardStepFragment? = null
|
|
||||||
|
|
||||||
override fun onCreateView(
|
|
||||||
inflater: LayoutInflater,
|
|
||||||
container: ViewGroup?,
|
|
||||||
savedInstanceState: Bundle?
|
|
||||||
): View? {
|
|
||||||
val view = inflater.inflate(R.layout.fragment_download_progress, container, false)
|
|
||||||
val recyclerView = view.requireViewById<RecyclerView>(R.id.download_progress_list)
|
|
||||||
recyclerView.adapter = adapter
|
|
||||||
recyclerView.layoutManager =
|
|
||||||
LinearLayoutManager(view.context, LinearLayoutManager.VERTICAL, false)
|
|
||||||
recyclerView.addItemDecoration(
|
|
||||||
DividerItemDecoration(
|
|
||||||
requireContext(),
|
|
||||||
LinearLayoutManager.VERTICAL
|
|
||||||
)
|
|
||||||
)
|
|
||||||
return view
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun onStart() {
|
|
||||||
super.onStart()
|
|
||||||
|
|
||||||
lifecycleScope.launch {
|
|
||||||
showProgressBar(-1) // set indeterminate first
|
|
||||||
ensureEuiccChannelManager()
|
|
||||||
|
|
||||||
val subscriber = startDownloadOrSubscribe()
|
|
||||||
|
|
||||||
if (subscriber == null) {
|
|
||||||
requireActivity().finish()
|
|
||||||
return@launch
|
|
||||||
}
|
|
||||||
|
|
||||||
subscriber.onEach {
|
|
||||||
when (it) {
|
|
||||||
is EuiccChannelManagerService.ForegroundTaskState.Done -> {
|
|
||||||
hideProgressBar()
|
|
||||||
|
|
||||||
state.downloadError =
|
|
||||||
it.error as? LocalProfileAssistant.ProfileDownloadException
|
|
||||||
|
|
||||||
// Change the state of the last InProgress item to success (or error)
|
|
||||||
progressItems.forEachIndexed { index, progressItem ->
|
|
||||||
if (progressItem.state == ProgressState.InProgress) {
|
|
||||||
progressItem.state =
|
|
||||||
if (state.downloadError == null) ProgressState.Done else ProgressState.Error
|
|
||||||
}
|
|
||||||
|
|
||||||
adapter.notifyItemChanged(index)
|
|
||||||
}
|
|
||||||
|
|
||||||
isDone = true
|
|
||||||
refreshButtons()
|
|
||||||
}
|
|
||||||
|
|
||||||
is EuiccChannelManagerService.ForegroundTaskState.InProgress -> {
|
|
||||||
updateProgress(it.progress)
|
|
||||||
}
|
|
||||||
|
|
||||||
else -> {}
|
|
||||||
}
|
|
||||||
}.collect()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private suspend fun startDownloadOrSubscribe(): EuiccChannelManagerService.ForegroundTaskSubscriberFlow? =
|
|
||||||
if (state.downloadStarted) {
|
|
||||||
// This will also return null if task ID is -1 (uninitialized), too
|
|
||||||
euiccChannelManagerService.recoverForegroundTaskSubscriber(state.downloadTaskID)
|
|
||||||
} else {
|
|
||||||
euiccChannelManagerService.waitForForegroundTask()
|
|
||||||
|
|
||||||
val (slotId, portId) = euiccChannelManager.withEuiccChannel(state.selectedLogicalSlot) { channel ->
|
|
||||||
Pair(channel.slotId, channel.portId)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Set started to true even before we start -- in case we get killed in the middle
|
|
||||||
state.downloadStarted = true
|
|
||||||
|
|
||||||
val ret = euiccChannelManagerService.launchProfileDownloadTask(
|
|
||||||
slotId,
|
|
||||||
portId,
|
|
||||||
state.smdp,
|
|
||||||
state.matchingId,
|
|
||||||
state.confirmationCode,
|
|
||||||
state.imei
|
|
||||||
)
|
|
||||||
|
|
||||||
state.downloadTaskID = ret.taskId
|
|
||||||
|
|
||||||
ret
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun updateProgress(progress: Int) {
|
|
||||||
showProgressBar(progress)
|
|
||||||
|
|
||||||
val lpaState = ProfileDownloadCallback.lookupStateFromProgress(progress)
|
|
||||||
val stateIndex = LPA_PROGRESS_STATES.indexOf(lpaState)
|
|
||||||
|
|
||||||
if (stateIndex > 0) {
|
|
||||||
for (i in (0..<stateIndex)) {
|
|
||||||
if (progressItems[i].state != ProgressState.Done) {
|
|
||||||
progressItems[i].state = ProgressState.Done
|
|
||||||
adapter.notifyItemChanged(i)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (progressItems[stateIndex].state != ProgressState.InProgress) {
|
|
||||||
progressItems[stateIndex].state = ProgressState.InProgress
|
|
||||||
adapter.notifyItemChanged(stateIndex)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private inner class ProgressItemHolder(val root: View) : RecyclerView.ViewHolder(root) {
|
|
||||||
private val title = root.requireViewById<TextView>(R.id.download_progress_item_title)
|
|
||||||
private val progressBar =
|
|
||||||
root.requireViewById<ProgressBar>(R.id.download_progress_icon_progress)
|
|
||||||
private val icon = root.requireViewById<ImageView>(R.id.download_progress_icon)
|
|
||||||
|
|
||||||
fun bind(item: ProgressItem) {
|
|
||||||
title.text = getString(item.titleRes)
|
|
||||||
|
|
||||||
when (item.state) {
|
|
||||||
ProgressState.NotStarted -> {
|
|
||||||
progressBar.visibility = View.GONE
|
|
||||||
icon.visibility = View.GONE
|
|
||||||
}
|
|
||||||
|
|
||||||
ProgressState.InProgress -> {
|
|
||||||
progressBar.visibility = View.VISIBLE
|
|
||||||
icon.visibility = View.GONE
|
|
||||||
}
|
|
||||||
|
|
||||||
ProgressState.Done -> {
|
|
||||||
progressBar.visibility = View.GONE
|
|
||||||
icon.setImageResource(R.drawable.ic_checkmark_outline)
|
|
||||||
icon.visibility = View.VISIBLE
|
|
||||||
}
|
|
||||||
|
|
||||||
ProgressState.Error -> {
|
|
||||||
progressBar.visibility = View.GONE
|
|
||||||
icon.setImageResource(R.drawable.ic_error_outline)
|
|
||||||
icon.visibility = View.VISIBLE
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private inner class ProgressItemAdapter : RecyclerView.Adapter<ProgressItemHolder>() {
|
|
||||||
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): ProgressItemHolder {
|
|
||||||
val root = LayoutInflater.from(parent.context)
|
|
||||||
.inflate(R.layout.download_progress_item, parent, false)
|
|
||||||
return ProgressItemHolder(root)
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun getItemCount(): Int = progressItems.size
|
|
||||||
|
|
||||||
override fun onBindViewHolder(holder: ProgressItemHolder, position: Int) {
|
|
||||||
holder.bind(progressItems[position])
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -1,219 +0,0 @@
|
||||||
package im.angry.openeuicc.ui.wizard
|
|
||||||
|
|
||||||
import android.annotation.SuppressLint
|
|
||||||
import android.os.Bundle
|
|
||||||
import android.view.LayoutInflater
|
|
||||||
import android.view.View
|
|
||||||
import android.view.ViewGroup
|
|
||||||
import android.widget.CheckBox
|
|
||||||
import android.widget.TextView
|
|
||||||
import androidx.appcompat.app.AlertDialog
|
|
||||||
import androidx.lifecycle.lifecycleScope
|
|
||||||
import androidx.recyclerview.widget.DividerItemDecoration
|
|
||||||
import androidx.recyclerview.widget.LinearLayoutManager
|
|
||||||
import androidx.recyclerview.widget.RecyclerView
|
|
||||||
import androidx.recyclerview.widget.RecyclerView.ViewHolder
|
|
||||||
import im.angry.openeuicc.common.R
|
|
||||||
import im.angry.openeuicc.core.EuiccChannelManager
|
|
||||||
import im.angry.openeuicc.util.*
|
|
||||||
import kotlinx.coroutines.flow.map
|
|
||||||
import kotlinx.coroutines.flow.toList
|
|
||||||
import kotlinx.coroutines.launch
|
|
||||||
import net.typeblog.lpac_jni.LocalProfileInfo
|
|
||||||
|
|
||||||
class DownloadWizardSlotSelectFragment : DownloadWizardActivity.DownloadWizardStepFragment() {
|
|
||||||
companion object {
|
|
||||||
const val LOW_NVRAM_THRESHOLD =
|
|
||||||
30 * 1024 // < 30 KiB, alert about potential download failure
|
|
||||||
}
|
|
||||||
|
|
||||||
private data class SlotInfo(
|
|
||||||
val logicalSlotId: Int,
|
|
||||||
val isRemovable: Boolean,
|
|
||||||
val hasMultiplePorts: Boolean,
|
|
||||||
val portId: Int,
|
|
||||||
val eID: String,
|
|
||||||
val freeSpace: Int,
|
|
||||||
val imei: String,
|
|
||||||
val enabledProfileName: String?,
|
|
||||||
val intrinsicChannelName: String?,
|
|
||||||
)
|
|
||||||
|
|
||||||
private var loaded = false
|
|
||||||
|
|
||||||
private val adapter = SlotInfoAdapter()
|
|
||||||
|
|
||||||
override val hasNext: Boolean
|
|
||||||
get() = loaded && adapter.slots.isNotEmpty()
|
|
||||||
override val hasPrev: Boolean
|
|
||||||
get() = true
|
|
||||||
|
|
||||||
override fun createNextFragment(): DownloadWizardActivity.DownloadWizardStepFragment =
|
|
||||||
if (state.skipMethodSelect) {
|
|
||||||
DownloadWizardDetailsFragment()
|
|
||||||
} else {
|
|
||||||
DownloadWizardMethodSelectFragment()
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun createPrevFragment(): DownloadWizardActivity.DownloadWizardStepFragment? = null
|
|
||||||
|
|
||||||
override fun beforeNext() {
|
|
||||||
super.beforeNext()
|
|
||||||
|
|
||||||
if (adapter.selected.freeSpace < LOW_NVRAM_THRESHOLD) {
|
|
||||||
val activity = requireActivity()
|
|
||||||
|
|
||||||
AlertDialog.Builder(requireContext()).apply {
|
|
||||||
setTitle(R.string.profile_download_low_nvram_title)
|
|
||||||
setMessage(R.string.profile_download_low_nvram_message)
|
|
||||||
setCancelable(true)
|
|
||||||
setPositiveButton(android.R.string.ok, null)
|
|
||||||
setNegativeButton(android.R.string.cancel) { _, _ ->
|
|
||||||
activity.finish()
|
|
||||||
}
|
|
||||||
show()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun onCreateView(
|
|
||||||
inflater: LayoutInflater,
|
|
||||||
container: ViewGroup?,
|
|
||||||
savedInstanceState: Bundle?
|
|
||||||
): View? {
|
|
||||||
val view = inflater.inflate(R.layout.fragment_download_slot_select, container, false)
|
|
||||||
val recyclerView = view.requireViewById<RecyclerView>(R.id.download_slot_list)
|
|
||||||
recyclerView.adapter = adapter
|
|
||||||
recyclerView.layoutManager =
|
|
||||||
LinearLayoutManager(view.context, LinearLayoutManager.VERTICAL, false)
|
|
||||||
recyclerView.addItemDecoration(DividerItemDecoration(requireContext(), LinearLayoutManager.VERTICAL))
|
|
||||||
return view
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun onStart() {
|
|
||||||
super.onStart()
|
|
||||||
if (!loaded) {
|
|
||||||
lifecycleScope.launch { init() }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@SuppressLint("NotifyDataSetChanged", "MissingPermission")
|
|
||||||
private suspend fun init() {
|
|
||||||
ensureEuiccChannelManager()
|
|
||||||
showProgressBar(-1)
|
|
||||||
val slots = euiccChannelManager.flowAllOpenEuiccPorts().map { (slotId, portId) ->
|
|
||||||
euiccChannelManager.withEuiccChannel(slotId, portId) { channel ->
|
|
||||||
SlotInfo(
|
|
||||||
channel.logicalSlotId,
|
|
||||||
channel.port.card.isRemovable,
|
|
||||||
channel.port.card.ports.size > 1,
|
|
||||||
channel.portId,
|
|
||||||
channel.lpa.eID,
|
|
||||||
channel.lpa.euiccInfo2?.freeNvram ?: 0,
|
|
||||||
try {
|
|
||||||
telephonyManager.getImei(channel.logicalSlotId) ?: ""
|
|
||||||
} catch (e: Exception) {
|
|
||||||
""
|
|
||||||
},
|
|
||||||
channel.lpa.profiles.enabled?.displayName,
|
|
||||||
channel.intrinsicChannelName,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}.toList().sortedBy { it.logicalSlotId }
|
|
||||||
adapter.slots = slots
|
|
||||||
|
|
||||||
// Ensure we always have a selected slot by default
|
|
||||||
val selectedIdx = slots.indexOfFirst { it.logicalSlotId == state.selectedLogicalSlot }
|
|
||||||
adapter.currentSelectedIdx = if (selectedIdx > 0) {
|
|
||||||
selectedIdx
|
|
||||||
} else {
|
|
||||||
if (slots.isNotEmpty()) {
|
|
||||||
state.selectedLogicalSlot = slots[0].logicalSlotId
|
|
||||||
}
|
|
||||||
0
|
|
||||||
}
|
|
||||||
|
|
||||||
if (slots.isNotEmpty()) {
|
|
||||||
state.imei = slots[adapter.currentSelectedIdx].imei
|
|
||||||
}
|
|
||||||
|
|
||||||
adapter.notifyDataSetChanged()
|
|
||||||
hideProgressBar()
|
|
||||||
loaded = true
|
|
||||||
refreshButtons()
|
|
||||||
}
|
|
||||||
|
|
||||||
private inner class SlotItemHolder(val root: View) : ViewHolder(root) {
|
|
||||||
private val title = root.requireViewById<TextView>(R.id.slot_item_title)
|
|
||||||
private val type = root.requireViewById<TextView>(R.id.slot_item_type)
|
|
||||||
private val eID = root.requireViewById<TextView>(R.id.slot_item_eid)
|
|
||||||
private val activeProfile = root.requireViewById<TextView>(R.id.slot_item_active_profile)
|
|
||||||
private val freeSpace = root.requireViewById<TextView>(R.id.slot_item_free_space)
|
|
||||||
private val checkBox = root.requireViewById<CheckBox>(R.id.slot_checkbox)
|
|
||||||
|
|
||||||
private var curIdx = -1
|
|
||||||
|
|
||||||
init {
|
|
||||||
root.setOnClickListener(this::onSelect)
|
|
||||||
checkBox.setOnClickListener(this::onSelect)
|
|
||||||
}
|
|
||||||
|
|
||||||
@Suppress("UNUSED_PARAMETER")
|
|
||||||
fun onSelect(view: View) {
|
|
||||||
if (curIdx < 0) return
|
|
||||||
checkBox.isChecked = true
|
|
||||||
if (adapter.currentSelectedIdx == curIdx) return
|
|
||||||
val lastIdx = adapter.currentSelectedIdx
|
|
||||||
adapter.currentSelectedIdx = curIdx
|
|
||||||
adapter.notifyItemChanged(lastIdx)
|
|
||||||
adapter.notifyItemChanged(curIdx)
|
|
||||||
// Selected index isn't logical slot ID directly, needs a conversion
|
|
||||||
state.selectedLogicalSlot = adapter.slots[adapter.currentSelectedIdx].logicalSlotId
|
|
||||||
state.imei = adapter.slots[adapter.currentSelectedIdx].imei
|
|
||||||
}
|
|
||||||
|
|
||||||
fun bind(item: SlotInfo, idx: Int) {
|
|
||||||
curIdx = idx
|
|
||||||
|
|
||||||
type.text = if (item.isRemovable) {
|
|
||||||
root.context.getString(R.string.download_wizard_slot_type_removable)
|
|
||||||
} else if (!item.hasMultiplePorts) {
|
|
||||||
root.context.getString(R.string.download_wizard_slot_type_internal)
|
|
||||||
} else {
|
|
||||||
root.context.getString(
|
|
||||||
R.string.download_wizard_slot_type_internal_port,
|
|
||||||
item.portId
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
title.text = if (item.logicalSlotId == EuiccChannelManager.USB_CHANNEL_ID) {
|
|
||||||
item.intrinsicChannelName ?: root.context.getString(R.string.usb)
|
|
||||||
} else {
|
|
||||||
appContainer.customizableTextProvider.formatInternalChannelName(item.logicalSlotId)
|
|
||||||
}
|
|
||||||
eID.text = item.eID
|
|
||||||
activeProfile.text = item.enabledProfileName ?: root.context.getString(R.string.unknown)
|
|
||||||
freeSpace.text = formatFreeSpace(item.freeSpace)
|
|
||||||
checkBox.isChecked = adapter.currentSelectedIdx == idx
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private inner class SlotInfoAdapter : RecyclerView.Adapter<SlotItemHolder>() {
|
|
||||||
var slots: List<SlotInfo> = listOf()
|
|
||||||
var currentSelectedIdx = -1
|
|
||||||
|
|
||||||
val selected: SlotInfo
|
|
||||||
get() = slots[currentSelectedIdx]
|
|
||||||
|
|
||||||
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): SlotItemHolder {
|
|
||||||
val root = LayoutInflater.from(parent.context).inflate(R.layout.download_slot_item, parent, false)
|
|
||||||
return SlotItemHolder(root)
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun getItemCount(): Int = slots.size
|
|
||||||
|
|
||||||
override fun onBindViewHolder(holder: SlotItemHolder, position: Int) {
|
|
||||||
holder.bind(slots[position], position)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -3,69 +3,30 @@ package im.angry.openeuicc.util
|
||||||
import android.os.Bundle
|
import android.os.Bundle
|
||||||
import androidx.fragment.app.Fragment
|
import androidx.fragment.app.Fragment
|
||||||
import im.angry.openeuicc.core.EuiccChannel
|
import im.angry.openeuicc.core.EuiccChannel
|
||||||
import im.angry.openeuicc.core.EuiccChannelManager
|
|
||||||
import im.angry.openeuicc.service.EuiccChannelManagerService
|
|
||||||
import im.angry.openeuicc.ui.BaseEuiccAccessActivity
|
|
||||||
|
|
||||||
private const val FIELD_SLOT_ID = "slotId"
|
interface EuiccChannelFragmentMarker: OpenEuiccContextMarker
|
||||||
private const val FIELD_PORT_ID = "portId"
|
|
||||||
|
|
||||||
interface EuiccChannelFragmentMarker : OpenEuiccContextMarker
|
|
||||||
|
|
||||||
private typealias BundleSetter = Bundle.() -> Unit
|
|
||||||
|
|
||||||
// We must use extension functions because there is no way to add bounds to the type of "self"
|
// We must use extension functions because there is no way to add bounds to the type of "self"
|
||||||
// in the definition of an interface, so the only way is to limit where the extension functions
|
// in the definition of an interface, so the only way is to limit where the extension functions
|
||||||
// can be applied.
|
// can be applied.
|
||||||
fun <T> newInstanceEuicc(clazz: Class<T>, slotId: Int, portId: Int, addArguments: BundleSetter = {}): T
|
fun <T> newInstanceEuicc(clazz: Class<T>, slotId: Int, portId: Int, addArguments: Bundle.() -> Unit = {}): T where T: Fragment, T: EuiccChannelFragmentMarker {
|
||||||
where T : Fragment, T : EuiccChannelFragmentMarker =
|
val instance = clazz.newInstance()
|
||||||
clazz.getDeclaredConstructor().newInstance().apply {
|
instance.arguments = Bundle().apply {
|
||||||
arguments = Bundle()
|
putInt("slotId", slotId)
|
||||||
arguments!!.putInt(FIELD_SLOT_ID, slotId)
|
putInt("portId", portId)
|
||||||
arguments!!.putInt(FIELD_PORT_ID, portId)
|
addArguments()
|
||||||
arguments!!.addArguments()
|
|
||||||
}
|
}
|
||||||
|
return instance
|
||||||
// Convenient methods to avoid using `channel` for these
|
|
||||||
// `channel` requires that the channel actually exists in EuiccChannelManager, which is
|
|
||||||
// not always the case during operations such as switching
|
|
||||||
val <T> T.slotId: Int
|
|
||||||
where T : Fragment, T : EuiccChannelFragmentMarker
|
|
||||||
get() = requireArguments().getInt(FIELD_SLOT_ID)
|
|
||||||
val <T> T.portId: Int
|
|
||||||
where T : Fragment, T : EuiccChannelFragmentMarker
|
|
||||||
get() = requireArguments().getInt(FIELD_PORT_ID)
|
|
||||||
val <T> T.isUsb: Boolean
|
|
||||||
where T : Fragment, T : EuiccChannelFragmentMarker
|
|
||||||
get() = slotId == EuiccChannelManager.USB_CHANNEL_ID
|
|
||||||
|
|
||||||
private fun <T> T.requireEuiccActivity(): BaseEuiccAccessActivity
|
|
||||||
where T : Fragment, T : OpenEuiccContextMarker =
|
|
||||||
requireActivity() as BaseEuiccAccessActivity
|
|
||||||
|
|
||||||
val <T> T.euiccChannelManager: EuiccChannelManager
|
|
||||||
where T : Fragment, T : OpenEuiccContextMarker
|
|
||||||
get() = requireEuiccActivity().euiccChannelManager
|
|
||||||
|
|
||||||
val <T> T.euiccChannelManagerService: EuiccChannelManagerService
|
|
||||||
where T : Fragment, T : OpenEuiccContextMarker
|
|
||||||
get() = requireEuiccActivity().euiccChannelManagerService
|
|
||||||
|
|
||||||
suspend fun <T, R> T.withEuiccChannel(fn: suspend (EuiccChannel) -> R): R
|
|
||||||
where T : Fragment, T : EuiccChannelFragmentMarker {
|
|
||||||
ensureEuiccChannelManager()
|
|
||||||
return euiccChannelManager.withEuiccChannel(slotId, portId, fn)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
suspend fun <T> T.ensureEuiccChannelManager() where T : Fragment, T : OpenEuiccContextMarker =
|
val <T> T.slotId: Int where T: Fragment, T: EuiccChannelFragmentMarker
|
||||||
requireEuiccActivity().euiccChannelManagerLoaded.await()
|
get() = requireArguments().getInt("slotId")
|
||||||
|
val <T> T.portId: Int where T: Fragment, T: EuiccChannelFragmentMarker
|
||||||
|
get() = requireArguments().getInt("portId")
|
||||||
|
|
||||||
fun <T> T.notifyEuiccProfilesChanged() where T : Fragment {
|
val <T> T.channel: EuiccChannel where T: Fragment, T: EuiccChannelFragmentMarker
|
||||||
if (this !is EuiccProfilesChangedListener) return
|
get() =
|
||||||
// Trigger a refresh in the parent fragment -- it should wait until
|
euiccChannelManager.findEuiccChannelByPortBlocking(slotId, portId)!!
|
||||||
// any foreground task is completed before actually doing a refresh
|
|
||||||
this.onEuiccProfilesChanged()
|
|
||||||
}
|
|
||||||
|
|
||||||
interface EuiccProfilesChangedListener {
|
interface EuiccProfilesChangedListener {
|
||||||
fun onEuiccProfilesChanged()
|
fun onEuiccProfilesChanged()
|
||||||
|
|
|
@ -1,34 +0,0 @@
|
||||||
package im.angry.openeuicc.util
|
|
||||||
|
|
||||||
data class LPAString(
|
|
||||||
val address: String,
|
|
||||||
val matchingId: String?,
|
|
||||||
val oid: String?,
|
|
||||||
val confirmationCodeRequired: Boolean,
|
|
||||||
) {
|
|
||||||
companion object {
|
|
||||||
fun parse(input: String): LPAString {
|
|
||||||
var token = input
|
|
||||||
if (token.startsWith("LPA:", ignoreCase = true)) token = token.drop(4)
|
|
||||||
val components = token.split('$').map { it.trim().ifBlank { null } }
|
|
||||||
require(components.getOrNull(0) == "1") { "Invalid AC_Format" }
|
|
||||||
return LPAString(
|
|
||||||
requireNotNull(components.getOrNull(1)) { "SM-DP+ is required" },
|
|
||||||
components.getOrNull(2),
|
|
||||||
components.getOrNull(3),
|
|
||||||
components.getOrNull(4) == "1"
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun toString(): String {
|
|
||||||
val parts = arrayOf(
|
|
||||||
"1",
|
|
||||||
address,
|
|
||||||
matchingId ?: "",
|
|
||||||
oid ?: "",
|
|
||||||
if (confirmationCodeRequired) "1" else ""
|
|
||||||
)
|
|
||||||
return parts.joinToString("$").trimEnd('$')
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -1,106 +1,18 @@
|
||||||
package im.angry.openeuicc.util
|
package im.angry.openeuicc.util
|
||||||
|
|
||||||
import android.util.Log
|
|
||||||
import im.angry.openeuicc.core.EuiccChannel
|
|
||||||
import im.angry.openeuicc.core.EuiccChannelManager
|
|
||||||
import net.typeblog.lpac_jni.LocalProfileAssistant
|
import net.typeblog.lpac_jni.LocalProfileAssistant
|
||||||
import net.typeblog.lpac_jni.LocalProfileInfo
|
import net.typeblog.lpac_jni.LocalProfileInfo
|
||||||
|
|
||||||
const val TAG = "LPAUtils"
|
|
||||||
|
|
||||||
val LocalProfileInfo.displayName: String
|
val LocalProfileInfo.displayName: String
|
||||||
get() = nickName.ifEmpty { name }
|
get() = nickName.ifEmpty { name }
|
||||||
|
|
||||||
|
|
||||||
val LocalProfileInfo.isEnabled: Boolean
|
|
||||||
get() = state == LocalProfileInfo.State.Enabled
|
|
||||||
|
|
||||||
val List<LocalProfileInfo>.operational: List<LocalProfileInfo>
|
val List<LocalProfileInfo>.operational: List<LocalProfileInfo>
|
||||||
get() = filter { it.profileClass == LocalProfileInfo.Clazz.Operational }
|
get() = filter {
|
||||||
|
it.profileClass == LocalProfileInfo.Clazz.Operational
|
||||||
val List<LocalProfileInfo>.enabled: LocalProfileInfo?
|
|
||||||
get() = find { it.isEnabled }
|
|
||||||
|
|
||||||
val List<EuiccChannel>.hasMultipleChips: Boolean
|
|
||||||
get() = distinctBy { it.slotId }.size > 1
|
|
||||||
|
|
||||||
fun LocalProfileAssistant.switchProfile(
|
|
||||||
iccid: String,
|
|
||||||
enable: Boolean = false,
|
|
||||||
refresh: Boolean = false
|
|
||||||
): Boolean =
|
|
||||||
if (enable) {
|
|
||||||
enableProfile(iccid, refresh)
|
|
||||||
} else {
|
|
||||||
disableProfile(iccid, refresh)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
fun LocalProfileAssistant.disableActiveProfileWithUndo(): () -> Unit =
|
||||||
* Disable the current active profile if any. If refresh is true, also cause a refresh command.
|
profiles.find { it.state == LocalProfileInfo.State.Enabled }?.let {
|
||||||
* See EuiccManager.waitForReconnect()
|
disableProfile(it.iccid)
|
||||||
*/
|
return { enableProfile(it.iccid) }
|
||||||
fun LocalProfileAssistant.disableActiveProfile(refresh: Boolean): Boolean =
|
} ?: { }
|
||||||
profiles.enabled?.let {
|
|
||||||
Log.i(TAG, "Disabling active profile ${it.iccid}")
|
|
||||||
disableProfile(it.iccid, refresh)
|
|
||||||
} ?: true
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Disable the current active profile if any. If refresh is true, also cause a refresh command.
|
|
||||||
* See EuiccManager.waitForReconnect()
|
|
||||||
*
|
|
||||||
* Return the iccid of the profile being disabled, or null if no active profile found or failed to
|
|
||||||
* disable.
|
|
||||||
*/
|
|
||||||
fun LocalProfileAssistant.disableActiveProfileKeepIccId(refresh: Boolean): String? =
|
|
||||||
profiles.enabled?.let {
|
|
||||||
Log.i(TAG, "Disabling active profile ${it.iccid}")
|
|
||||||
if (disableProfile(it.iccid, refresh)) {
|
|
||||||
it.iccid
|
|
||||||
} else {
|
|
||||||
null
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Begin a "tracked" operation where notifications may be generated by the eSIM
|
|
||||||
* Automatically handle any newly generated notification during the operation
|
|
||||||
* if the function "op" returns true.
|
|
||||||
*
|
|
||||||
* This requires the EuiccChannelManager object and a slotId / portId instead of
|
|
||||||
* just an LPA object, because a LPA might become invalid during an operation
|
|
||||||
* that generates notifications. As such, we will end up having to reconnect
|
|
||||||
* when this happens.
|
|
||||||
*
|
|
||||||
* Note that however, if reconnect is required and will not be instant, waiting
|
|
||||||
* should be the concern of op() itself, and this function assumes that when
|
|
||||||
* op() returns, the slotId and portId will correspond to a valid channel again.
|
|
||||||
*/
|
|
||||||
suspend inline fun EuiccChannelManager.beginTrackedOperation(
|
|
||||||
slotId: Int,
|
|
||||||
portId: Int,
|
|
||||||
op: () -> Boolean
|
|
||||||
) {
|
|
||||||
val latestSeq = withEuiccChannel(slotId, portId) { channel ->
|
|
||||||
channel.lpa.notifications.firstOrNull()?.seqNumber
|
|
||||||
?: 0
|
|
||||||
}
|
|
||||||
Log.d(TAG, "Latest notification is $latestSeq before operation")
|
|
||||||
if (op()) {
|
|
||||||
Log.d(TAG, "Operation has requested notification handling")
|
|
||||||
try {
|
|
||||||
// Note that the exact instance of "channel" might have changed here if reconnected;
|
|
||||||
// this is why we need to use two distinct calls to withEuiccChannel()
|
|
||||||
withEuiccChannel(slotId, portId) { channel ->
|
|
||||||
channel.lpa.notifications.filter { it.seqNumber > latestSeq }.forEach {
|
|
||||||
Log.d(TAG, "Handling notification $it")
|
|
||||||
channel.lpa.handleNotification(it.seqNumber)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} catch (e: Exception) {
|
|
||||||
// Ignore any error during notification handling
|
|
||||||
e.printStackTrace()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Log.d(TAG, "Operation complete")
|
|
||||||
}
|
|
|
@ -5,13 +5,11 @@ import androidx.datastore.core.DataStore
|
||||||
import androidx.datastore.preferences.core.Preferences
|
import androidx.datastore.preferences.core.Preferences
|
||||||
import androidx.datastore.preferences.core.booleanPreferencesKey
|
import androidx.datastore.preferences.core.booleanPreferencesKey
|
||||||
import androidx.datastore.preferences.core.edit
|
import androidx.datastore.preferences.core.edit
|
||||||
import androidx.datastore.preferences.core.stringPreferencesKey
|
|
||||||
import androidx.datastore.preferences.preferencesDataStore
|
import androidx.datastore.preferences.preferencesDataStore
|
||||||
import androidx.fragment.app.Fragment
|
import androidx.fragment.app.Fragment
|
||||||
import im.angry.openeuicc.OpenEuiccApplication
|
import im.angry.openeuicc.OpenEuiccApplication
|
||||||
import kotlinx.coroutines.flow.Flow
|
import kotlinx.coroutines.flow.Flow
|
||||||
import kotlinx.coroutines.flow.map
|
import kotlinx.coroutines.flow.map
|
||||||
import java.util.Base64
|
|
||||||
|
|
||||||
private val Context.dataStore: DataStore<Preferences> by preferencesDataStore(name = "prefs")
|
private val Context.dataStore: DataStore<Preferences> by preferencesDataStore(name = "prefs")
|
||||||
|
|
||||||
|
@ -21,105 +19,34 @@ val Context.preferenceRepository: PreferenceRepository
|
||||||
val Fragment.preferenceRepository: PreferenceRepository
|
val Fragment.preferenceRepository: PreferenceRepository
|
||||||
get() = requireContext().preferenceRepository
|
get() = requireContext().preferenceRepository
|
||||||
|
|
||||||
internal object PreferenceKeys {
|
object PreferenceKeys {
|
||||||
// ---- Profile Notifications ----
|
|
||||||
val NOTIFICATION_DOWNLOAD = booleanPreferencesKey("notification_download")
|
val NOTIFICATION_DOWNLOAD = booleanPreferencesKey("notification_download")
|
||||||
val NOTIFICATION_DELETE = booleanPreferencesKey("notification_delete")
|
val NOTIFICATION_DELETE = booleanPreferencesKey("notification_delete")
|
||||||
val NOTIFICATION_SWITCH = booleanPreferencesKey("notification_switch")
|
val NOTIFICATION_ENABLE = booleanPreferencesKey("notification_enable")
|
||||||
|
val NOTIFICATION_DISABLE = booleanPreferencesKey("notification_disable")
|
||||||
// ---- Advanced ----
|
|
||||||
val DISABLE_SAFEGUARD_REMOVABLE_ESIM = booleanPreferencesKey("disable_safeguard_removable_esim")
|
|
||||||
val VERBOSE_LOGGING = booleanPreferencesKey("verbose_logging")
|
|
||||||
|
|
||||||
// ---- Developer Options ----
|
|
||||||
val DEVELOPER_OPTIONS_ENABLED = booleanPreferencesKey("developer_options_enabled")
|
|
||||||
val REFRESH_AFTER_SWITCH = booleanPreferencesKey("refresh_after_switch")
|
|
||||||
val UNFILTERED_PROFILE_LIST = booleanPreferencesKey("unfiltered_profile_list")
|
|
||||||
val IGNORE_TLS_CERTIFICATE = booleanPreferencesKey("ignore_tls_certificate")
|
|
||||||
val EUICC_MEMORY_RESET = booleanPreferencesKey("euicc_memory_reset")
|
|
||||||
val ISDR_AID_LIST = stringPreferencesKey("isdr_aid_list")
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const val EUICC_DEFAULT_ISDR_AID = "A0000005591010FFFFFFFF8900000100"
|
class PreferenceRepository(context: Context) {
|
||||||
|
private val dataStore = context.dataStore
|
||||||
|
|
||||||
internal object PreferenceConstants {
|
|
||||||
val DEFAULT_AID_LIST = """
|
|
||||||
# One AID per line. Comment lines start with #.
|
|
||||||
# Refs: <https://euicc-manual.osmocom.org/docs/lpa/applet-id-oem/>
|
|
||||||
|
|
||||||
# eUICC standard
|
|
||||||
$EUICC_DEFAULT_ISDR_AID
|
|
||||||
|
|
||||||
# eSTK.me
|
|
||||||
A06573746B6D65FFFFFFFF4953442D52
|
|
||||||
|
|
||||||
# eSIM.me
|
|
||||||
A0000005591010000000008900000300
|
|
||||||
|
|
||||||
# 5ber.eSIM
|
|
||||||
A0000005591010FFFFFFFF8900050500
|
|
||||||
|
|
||||||
# Xesim
|
|
||||||
A0000005591010FFFFFFFF8900000177
|
|
||||||
""".trimIndent()
|
|
||||||
}
|
|
||||||
|
|
||||||
open class PreferenceRepository(private val context: Context) {
|
|
||||||
// Expose flows so that we can also handle default values
|
// Expose flows so that we can also handle default values
|
||||||
// ---- Profile Notifications ----
|
// ---- Profile Notifications ----
|
||||||
val notificationDownloadFlow = bindFlow(PreferenceKeys.NOTIFICATION_DOWNLOAD, true)
|
val notificationDownloadFlow: Flow<Boolean> =
|
||||||
val notificationDeleteFlow = bindFlow(PreferenceKeys.NOTIFICATION_DELETE, true)
|
dataStore.data.map { it[PreferenceKeys.NOTIFICATION_DOWNLOAD] ?: true }
|
||||||
val notificationSwitchFlow = bindFlow(PreferenceKeys.NOTIFICATION_SWITCH, false)
|
|
||||||
|
|
||||||
// ---- Advanced ----
|
val notificationDeleteFlow: Flow<Boolean> =
|
||||||
val disableSafeguardFlow = bindFlow(PreferenceKeys.DISABLE_SAFEGUARD_REMOVABLE_ESIM, false)
|
dataStore.data.map { it[PreferenceKeys.NOTIFICATION_DELETE] ?: true }
|
||||||
val verboseLoggingFlow = bindFlow(PreferenceKeys.VERBOSE_LOGGING, false)
|
|
||||||
|
|
||||||
// ---- Developer Options ----
|
// Enabling / disabling notifications are not sent by default
|
||||||
val refreshAfterSwitchFlow = bindFlow(PreferenceKeys.REFRESH_AFTER_SWITCH, true)
|
val notificationEnableFlow: Flow<Boolean> =
|
||||||
val developerOptionsEnabledFlow = bindFlow(PreferenceKeys.DEVELOPER_OPTIONS_ENABLED, false)
|
dataStore.data.map { it[PreferenceKeys.NOTIFICATION_ENABLE] ?: false }
|
||||||
val unfilteredProfileListFlow = bindFlow(PreferenceKeys.UNFILTERED_PROFILE_LIST, false)
|
|
||||||
val ignoreTLSCertificateFlow = bindFlow(PreferenceKeys.IGNORE_TLS_CERTIFICATE, false)
|
|
||||||
val euiccMemoryResetFlow = bindFlow(PreferenceKeys.EUICC_MEMORY_RESET, false)
|
|
||||||
val isdrAidListFlow = bindFlow(
|
|
||||||
PreferenceKeys.ISDR_AID_LIST,
|
|
||||||
PreferenceConstants.DEFAULT_AID_LIST,
|
|
||||||
{ Base64.getEncoder().encodeToString(it.encodeToByteArray()) },
|
|
||||||
{ Base64.getDecoder().decode(it).decodeToString() })
|
|
||||||
|
|
||||||
protected fun <T> bindFlow(
|
val notificationDisableFlow: Flow<Boolean> =
|
||||||
key: Preferences.Key<T>,
|
dataStore.data.map { it[PreferenceKeys.NOTIFICATION_DISABLE] ?: false }
|
||||||
defaultValue: T,
|
|
||||||
encoder: (T) -> T = { it },
|
|
||||||
decoder: (T) -> T = { it }
|
|
||||||
): PreferenceFlowWrapper<T> =
|
|
||||||
PreferenceFlowWrapper(context, key, defaultValue, encoder, decoder)
|
|
||||||
}
|
|
||||||
|
|
||||||
class PreferenceFlowWrapper<T> private constructor(
|
suspend fun <T> updatePreference(key: Preferences.Key<T>, value: T) {
|
||||||
private val context: Context,
|
dataStore.edit {
|
||||||
private val key: Preferences.Key<T>,
|
it[key] = value
|
||||||
inner: Flow<T>,
|
}
|
||||||
private val encoder: (T) -> T,
|
|
||||||
) : Flow<T> by inner {
|
|
||||||
internal constructor(
|
|
||||||
context: Context,
|
|
||||||
key: Preferences.Key<T>,
|
|
||||||
defaultValue: T,
|
|
||||||
encoder: (T) -> T,
|
|
||||||
decoder: (T) -> T
|
|
||||||
) : this(
|
|
||||||
context,
|
|
||||||
key,
|
|
||||||
context.dataStore.data.map { it[key]?.let(decoder) ?: defaultValue },
|
|
||||||
encoder
|
|
||||||
)
|
|
||||||
|
|
||||||
suspend fun updatePreference(value: T) {
|
|
||||||
context.dataStore.edit { it[key] = encoder(value) }
|
|
||||||
}
|
}
|
||||||
|
}
|
||||||
suspend fun removePreference() {
|
|
||||||
context.dataStore.edit { it.remove(key) }
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -1,7 +1,7 @@
|
||||||
package im.angry.openeuicc.util
|
package im.angry.openeuicc.util
|
||||||
|
|
||||||
fun String.decodeHex(): ByteArray {
|
fun String.decodeHex(): ByteArray {
|
||||||
require(length % 2 == 0) { "Must have an even length" }
|
check(length % 2 == 0) { "Must have an even length" }
|
||||||
|
|
||||||
val decodedLength = length / 2
|
val decodedLength = length / 2
|
||||||
val out = ByteArray(decodedLength)
|
val out = ByteArray(decodedLength)
|
||||||
|
@ -19,95 +19,4 @@ fun ByteArray.encodeHex(): String {
|
||||||
sb.append(String.format("%02X", this[i]))
|
sb.append(String.format("%02X", this[i]))
|
||||||
}
|
}
|
||||||
return sb.toString()
|
return sb.toString()
|
||||||
}
|
|
||||||
|
|
||||||
fun formatFreeSpace(size: Int): String =
|
|
||||||
// SIM cards probably won't have much more space anytime soon.
|
|
||||||
if (size >= 1024) {
|
|
||||||
"%.2f KiB".format(size.toDouble() / 1024)
|
|
||||||
} else {
|
|
||||||
"$size B"
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Decode a list of potential ISDR AIDs, one per line. Lines starting with '#' are ignored.
|
|
||||||
* If none is found, at least EUICC_DEFAULT_ISDR_AID is returned
|
|
||||||
*/
|
|
||||||
fun parseIsdrAidList(s: String): List<ByteArray> =
|
|
||||||
s.split('\n')
|
|
||||||
.map(String::trim)
|
|
||||||
.filter { !it.startsWith('#') }
|
|
||||||
.map(String::trim)
|
|
||||||
.filter(String::isNotEmpty)
|
|
||||||
.mapNotNull { runCatching(it::decodeHex).getOrNull() }
|
|
||||||
.ifEmpty { listOf(EUICC_DEFAULT_ISDR_AID.decodeHex()) }
|
|
||||||
|
|
||||||
fun String.prettyPrintJson(): String {
|
|
||||||
val ret = StringBuilder()
|
|
||||||
var inQuotes = false
|
|
||||||
var escaped = false
|
|
||||||
val indentSymbolStack = ArrayDeque<Char>()
|
|
||||||
|
|
||||||
val addNewLine = {
|
|
||||||
ret.append('\n')
|
|
||||||
repeat(indentSymbolStack.size) {
|
|
||||||
ret.append('\t')
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
var lastChar = ' '
|
|
||||||
|
|
||||||
for (c in this) {
|
|
||||||
when {
|
|
||||||
!inQuotes && (c == '{' || c == '[') -> {
|
|
||||||
ret.append(c)
|
|
||||||
indentSymbolStack.addLast(c)
|
|
||||||
addNewLine()
|
|
||||||
}
|
|
||||||
|
|
||||||
!inQuotes && (c == '}' || c == ']') -> {
|
|
||||||
indentSymbolStack.removeLast()
|
|
||||||
if (lastChar != ',') {
|
|
||||||
addNewLine()
|
|
||||||
}
|
|
||||||
ret.append(c)
|
|
||||||
}
|
|
||||||
|
|
||||||
!inQuotes && c == ',' -> {
|
|
||||||
ret.append(c)
|
|
||||||
addNewLine()
|
|
||||||
}
|
|
||||||
|
|
||||||
!inQuotes && c == ':' -> {
|
|
||||||
ret.append(c)
|
|
||||||
ret.append(' ')
|
|
||||||
}
|
|
||||||
|
|
||||||
inQuotes && c == '\\' -> {
|
|
||||||
ret.append(c)
|
|
||||||
escaped = true
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
!escaped && c == '"' -> {
|
|
||||||
ret.append(c)
|
|
||||||
inQuotes = !inQuotes
|
|
||||||
}
|
|
||||||
|
|
||||||
!inQuotes && c == ' ' -> {
|
|
||||||
// Do nothing -- we ignore spaces outside of quotes by default
|
|
||||||
// This is to ensure predictable formatting
|
|
||||||
}
|
|
||||||
|
|
||||||
else -> ret.append(c)
|
|
||||||
}
|
|
||||||
|
|
||||||
if (escaped) {
|
|
||||||
escaped = false
|
|
||||||
}
|
|
||||||
|
|
||||||
lastChar = c
|
|
||||||
}
|
|
||||||
|
|
||||||
return ret.toString()
|
|
||||||
}
|
}
|
|
@ -45,8 +45,6 @@ fun SEService.getUiccReaderCompat(slotNumber: Int): Reader {
|
||||||
interface UiccCardInfoCompat {
|
interface UiccCardInfoCompat {
|
||||||
val physicalSlotIndex: Int
|
val physicalSlotIndex: Int
|
||||||
val ports: Collection<UiccPortInfoCompat>
|
val ports: Collection<UiccPortInfoCompat>
|
||||||
val isRemovable: Boolean
|
|
||||||
get() = true // This defaults to removable unless overridden
|
|
||||||
}
|
}
|
||||||
|
|
||||||
interface UiccPortInfoCompat {
|
interface UiccPortInfoCompat {
|
||||||
|
|
|
@ -1,24 +1,9 @@
|
||||||
package im.angry.openeuicc.util
|
package im.angry.openeuicc.util
|
||||||
|
|
||||||
import android.content.ClipData
|
|
||||||
import android.content.Context
|
|
||||||
import android.content.Intent
|
|
||||||
import android.content.res.Resources
|
import android.content.res.Resources
|
||||||
import android.graphics.Rect
|
import android.graphics.Rect
|
||||||
import android.view.View
|
|
||||||
import android.view.ViewGroup
|
import android.view.ViewGroup
|
||||||
import androidx.activity.result.ActivityResultCaller
|
|
||||||
import androidx.activity.result.contract.ActivityResultContracts
|
|
||||||
import androidx.appcompat.app.AlertDialog
|
|
||||||
import androidx.appcompat.app.AppCompatActivity
|
|
||||||
import androidx.core.view.ViewCompat
|
|
||||||
import androidx.core.view.WindowInsetsCompat
|
|
||||||
import androidx.core.view.updateLayoutParams
|
|
||||||
import androidx.core.view.updatePadding
|
|
||||||
import androidx.fragment.app.DialogFragment
|
import androidx.fragment.app.DialogFragment
|
||||||
import androidx.fragment.app.Fragment
|
|
||||||
import im.angry.openeuicc.common.R
|
|
||||||
import java.io.FileOutputStream
|
|
||||||
|
|
||||||
// Source: <https://stackoverflow.com/questions/12478520/how-to-set-dialogfragments-width-and-height>
|
// Source: <https://stackoverflow.com/questions/12478520/how-to-set-dialogfragments-width-and-height>
|
||||||
/**
|
/**
|
||||||
|
@ -41,84 +26,3 @@ fun DialogFragment.setWidthPercent(percentage: Int) {
|
||||||
fun DialogFragment.setFullScreen() {
|
fun DialogFragment.setFullScreen() {
|
||||||
dialog?.window?.setLayout(ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.WRAP_CONTENT)
|
dialog?.window?.setLayout(ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.WRAP_CONTENT)
|
||||||
}
|
}
|
||||||
|
|
||||||
fun AppCompatActivity.setupToolbarInsets() {
|
|
||||||
val spacer = requireViewById<View>(R.id.toolbar_spacer)
|
|
||||||
ViewCompat.setOnApplyWindowInsetsListener(requireViewById(R.id.toolbar)) { v, insets ->
|
|
||||||
val bars = insets.getInsets(
|
|
||||||
WindowInsetsCompat.Type.systemBars()
|
|
||||||
or WindowInsetsCompat.Type.displayCutout()
|
|
||||||
)
|
|
||||||
|
|
||||||
v.updateLayoutParams<ViewGroup.MarginLayoutParams> {
|
|
||||||
topMargin = bars.top
|
|
||||||
}
|
|
||||||
v.updatePadding(bars.left, v.paddingTop, bars.right, v.paddingBottom)
|
|
||||||
|
|
||||||
spacer.updateLayoutParams {
|
|
||||||
height = v.top
|
|
||||||
}
|
|
||||||
|
|
||||||
WindowInsetsCompat.CONSUMED
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fun setupRootViewInsets(view: ViewGroup) {
|
|
||||||
// Disable clipToPadding to make sure content actually display
|
|
||||||
view.clipToPadding = false
|
|
||||||
ViewCompat.setOnApplyWindowInsetsListener(view) { v, insets ->
|
|
||||||
val bars = insets.getInsets(
|
|
||||||
WindowInsetsCompat.Type.systemBars()
|
|
||||||
or WindowInsetsCompat.Type.displayCutout()
|
|
||||||
)
|
|
||||||
|
|
||||||
v.updatePadding(bars.left, v.paddingTop, bars.right, bars.bottom)
|
|
||||||
|
|
||||||
WindowInsetsCompat.CONSUMED
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fun <T : ActivityResultCaller> T.setupLogSaving(
|
|
||||||
getLogFileName: () -> String,
|
|
||||||
getLogText: () -> String
|
|
||||||
): () -> Unit {
|
|
||||||
var lastFileName = "untitled"
|
|
||||||
|
|
||||||
val launchSaveIntent =
|
|
||||||
registerForActivityResult(ActivityResultContracts.CreateDocument("text/plain")) { uri ->
|
|
||||||
if (uri == null) return@registerForActivityResult
|
|
||||||
|
|
||||||
val context = when (this@setupLogSaving) {
|
|
||||||
is Context -> this@setupLogSaving
|
|
||||||
is Fragment -> requireContext()
|
|
||||||
else -> throw IllegalArgumentException("Must be either Context or Fragment!")
|
|
||||||
}
|
|
||||||
|
|
||||||
context.contentResolver.openFileDescriptor(uri, "w")?.use {
|
|
||||||
FileOutputStream(it.fileDescriptor).use { os ->
|
|
||||||
os.write(getLogText().encodeToByteArray())
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
AlertDialog.Builder(context).apply {
|
|
||||||
setMessage(R.string.logs_saved_message)
|
|
||||||
setNegativeButton(R.string.no) { _, _ -> }
|
|
||||||
setPositiveButton(R.string.yes) { _, _ ->
|
|
||||||
val intent = Intent(Intent.ACTION_SEND).apply {
|
|
||||||
type = "text/plain"
|
|
||||||
clipData = ClipData.newUri(context.contentResolver, lastFileName, uri)
|
|
||||||
putExtra(Intent.EXTRA_TITLE, lastFileName)
|
|
||||||
putExtra(Intent.EXTRA_STREAM, uri)
|
|
||||||
addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION)
|
|
||||||
}
|
|
||||||
|
|
||||||
context.startActivity(Intent.createChooser(intent, null))
|
|
||||||
}
|
|
||||||
}.show()
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
lastFileName = getLogFileName()
|
|
||||||
launchSaveIntent.launch(lastFileName)
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -2,22 +2,20 @@ package im.angry.openeuicc.util
|
||||||
|
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
import android.content.pm.PackageManager
|
import android.content.pm.PackageManager
|
||||||
import android.graphics.Bitmap
|
|
||||||
import android.se.omapi.SEService
|
import android.se.omapi.SEService
|
||||||
import android.telephony.TelephonyManager
|
import android.telephony.TelephonyManager
|
||||||
import androidx.fragment.app.Fragment
|
import androidx.fragment.app.Fragment
|
||||||
import com.google.zxing.BinaryBitmap
|
|
||||||
import com.google.zxing.RGBLuminanceSource
|
|
||||||
import com.google.zxing.common.HybridBinarizer
|
|
||||||
import com.google.zxing.qrcode.QRCodeReader
|
|
||||||
import im.angry.openeuicc.OpenEuiccApplication
|
import im.angry.openeuicc.OpenEuiccApplication
|
||||||
|
import im.angry.openeuicc.core.EuiccChannel
|
||||||
|
import im.angry.openeuicc.core.EuiccChannelManager
|
||||||
import im.angry.openeuicc.di.AppContainer
|
import im.angry.openeuicc.di.AppContainer
|
||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
import kotlinx.coroutines.runBlocking
|
import kotlinx.coroutines.runBlocking
|
||||||
import kotlinx.coroutines.sync.Mutex
|
import kotlinx.coroutines.sync.Mutex
|
||||||
import kotlinx.coroutines.sync.withLock
|
import kotlinx.coroutines.sync.withLock
|
||||||
import kotlinx.coroutines.withContext
|
import kotlinx.coroutines.withContext
|
||||||
import kotlin.RuntimeException
|
import net.typeblog.lpac_jni.LocalProfileInfo
|
||||||
|
import java.lang.RuntimeException
|
||||||
import kotlin.coroutines.resume
|
import kotlin.coroutines.resume
|
||||||
import kotlin.coroutines.resumeWithException
|
import kotlin.coroutines.resumeWithException
|
||||||
import kotlin.coroutines.suspendCoroutine
|
import kotlin.coroutines.suspendCoroutine
|
||||||
|
@ -26,7 +24,7 @@ val Context.selfAppVersion: String
|
||||||
get() =
|
get() =
|
||||||
try {
|
try {
|
||||||
val pInfo = packageManager.getPackageInfo(packageName, 0)
|
val pInfo = packageManager.getPackageInfo(packageName, 0)
|
||||||
pInfo.versionName!!
|
pInfo.versionName
|
||||||
} catch (e: PackageManager.NameNotFoundException) {
|
} catch (e: PackageManager.NameNotFoundException) {
|
||||||
throw RuntimeException(e)
|
throw RuntimeException(e)
|
||||||
}
|
}
|
||||||
|
@ -54,13 +52,19 @@ interface OpenEuiccContextMarker {
|
||||||
val appContainer: AppContainer
|
val appContainer: AppContainer
|
||||||
get() = openEuiccApplication.appContainer
|
get() = openEuiccApplication.appContainer
|
||||||
|
|
||||||
val preferenceRepository: PreferenceRepository
|
val euiccChannelManager: EuiccChannelManager
|
||||||
get() = appContainer.preferenceRepository
|
get() = appContainer.euiccChannelManager
|
||||||
|
|
||||||
val telephonyManager: TelephonyManager
|
val telephonyManager: TelephonyManager
|
||||||
get() = appContainer.telephonyManager
|
get() = appContainer.telephonyManager
|
||||||
}
|
}
|
||||||
|
|
||||||
|
val LocalProfileInfo.isEnabled: Boolean
|
||||||
|
get() = state == LocalProfileInfo.State.Enabled
|
||||||
|
|
||||||
|
val List<EuiccChannel>.hasMultipleChips: Boolean
|
||||||
|
get() = distinctBy { it.slotId }.size > 1
|
||||||
|
|
||||||
// Create an instance of OMAPI SEService in a manner that "makes sense" without unpredictable callbacks
|
// Create an instance of OMAPI SEService in a manner that "makes sense" without unpredictable callbacks
|
||||||
suspend fun connectSEService(context: Context): SEService = suspendCoroutine { cont ->
|
suspend fun connectSEService(context: Context): SEService = suspendCoroutine { cont ->
|
||||||
// Use a Mutex to make sure the continuation is run *after* the "service" variable is assigned
|
// Use a Mutex to make sure the continuation is run *after* the "service" variable is assigned
|
||||||
|
@ -87,22 +91,4 @@ suspend fun connectSEService(context: Context): SEService = suspendCoroutine { c
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
inline fun <T> Bitmap.use(f: (Bitmap) -> T): T =
|
|
||||||
try {
|
|
||||||
f(this)
|
|
||||||
} finally {
|
|
||||||
recycle()
|
|
||||||
}
|
|
||||||
|
|
||||||
fun decodeQrFromBitmap(bmp: Bitmap): String? =
|
|
||||||
runCatching {
|
|
||||||
val pixels = IntArray(bmp.width * bmp.height)
|
|
||||||
bmp.getPixels(pixels, 0, bmp.width, 0, 0, bmp.width, bmp.height)
|
|
||||||
|
|
||||||
val luminanceSource = RGBLuminanceSource(bmp.width, bmp.height, pixels)
|
|
||||||
val binaryBmp = BinaryBitmap(HybridBinarizer(luminanceSource))
|
|
||||||
|
|
||||||
QRCodeReader().decode(binaryBmp).text
|
|
||||||
}.getOrNull()
|
|
|
@ -1,112 +0,0 @@
|
||||||
package im.angry.openeuicc.util
|
|
||||||
|
|
||||||
import android.util.Log
|
|
||||||
import im.angry.openeuicc.core.ApduInterfaceAtrProvider
|
|
||||||
import im.angry.openeuicc.core.EuiccChannel
|
|
||||||
import net.typeblog.lpac_jni.Version
|
|
||||||
|
|
||||||
data class EuiccVendorInfo(
|
|
||||||
val skuName: String?,
|
|
||||||
val serialNumber: String?,
|
|
||||||
val bootloaderVersion: String?,
|
|
||||||
val firmwareVersion: String?,
|
|
||||||
)
|
|
||||||
|
|
||||||
private val EUICC_VENDORS: Array<EuiccVendor> = arrayOf(EstkMe(), SimLink())
|
|
||||||
|
|
||||||
fun EuiccChannel.tryParseEuiccVendorInfo(): EuiccVendorInfo? {
|
|
||||||
EUICC_VENDORS.forEach { vendor ->
|
|
||||||
vendor.tryParseEuiccVendorInfo(this@tryParseEuiccVendorInfo)?.let {
|
|
||||||
return it
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return null
|
|
||||||
}
|
|
||||||
|
|
||||||
interface EuiccVendor {
|
|
||||||
fun tryParseEuiccVendorInfo(channel: EuiccChannel): EuiccVendorInfo?
|
|
||||||
}
|
|
||||||
|
|
||||||
private class EstkMe : EuiccVendor {
|
|
||||||
companion object {
|
|
||||||
private val PRODUCT_AID = "A06573746B6D65FFFFFFFFFFFF6D6774".decodeHex()
|
|
||||||
private val PRODUCT_ATR_FPR = "estk.me".encodeToByteArray()
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun checkAtr(channel: EuiccChannel): Boolean {
|
|
||||||
val iface = channel.apduInterface
|
|
||||||
if (iface !is ApduInterfaceAtrProvider) return false
|
|
||||||
val atr = iface.atr ?: return false
|
|
||||||
for (index in atr.indices) {
|
|
||||||
if (atr.size - index < PRODUCT_ATR_FPR.size) break
|
|
||||||
if (atr.sliceArray(index until index + PRODUCT_ATR_FPR.size)
|
|
||||||
.contentEquals(PRODUCT_ATR_FPR)
|
|
||||||
) return true
|
|
||||||
}
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun decodeAsn1String(b: ByteArray): String? {
|
|
||||||
if (b.size < 2) return null
|
|
||||||
if (b[b.size - 2] != 0x90.toByte() || b[b.size - 1] != 0x00.toByte()) return null
|
|
||||||
return b.sliceArray(0 until b.size - 2).decodeToString()
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun tryParseEuiccVendorInfo(channel: EuiccChannel): EuiccVendorInfo? {
|
|
||||||
if (!checkAtr(channel)) return null
|
|
||||||
|
|
||||||
val iface = channel.apduInterface
|
|
||||||
return try {
|
|
||||||
iface.withLogicalChannel(PRODUCT_AID) { transmit ->
|
|
||||||
fun invoke(p1: Byte) =
|
|
||||||
decodeAsn1String(transmit(byteArrayOf(0x00, 0x00, p1, 0x00, 0x00)))
|
|
||||||
EuiccVendorInfo(
|
|
||||||
skuName = invoke(0x03),
|
|
||||||
serialNumber = invoke(0x00),
|
|
||||||
bootloaderVersion = invoke(0x01),
|
|
||||||
firmwareVersion = invoke(0x02),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
} catch (e: Exception) {
|
|
||||||
Log.d(TAG, "Failed to get ESTKmeInfo", e)
|
|
||||||
null
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private class SimLink : EuiccVendor {
|
|
||||||
companion object {
|
|
||||||
private val EID_PATTERN = Regex("^89044045(84|21)67274948")
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun tryParseEuiccVendorInfo(channel: EuiccChannel): EuiccVendorInfo? {
|
|
||||||
val eid = channel.lpa.eID
|
|
||||||
val version = channel.lpa.euiccInfo2?.euiccFirmwareVersion
|
|
||||||
if (version == null || EID_PATTERN.find(eid, 0) == null) return null
|
|
||||||
val versionName = when {
|
|
||||||
// @formatter:off
|
|
||||||
version >= Version(37, 1, 41) -> "v3.1 (beta 1)"
|
|
||||||
version >= Version(36, 18, 5) -> "v3 (final)"
|
|
||||||
version >= Version(36, 17, 39) -> "v3 (beta)"
|
|
||||||
version >= Version(36, 17, 4) -> "v2s"
|
|
||||||
version >= Version(36, 9, 3) -> "v2.1"
|
|
||||||
version >= Version(36, 7, 2) -> "v2"
|
|
||||||
// @formatter:on
|
|
||||||
else -> null
|
|
||||||
}
|
|
||||||
|
|
||||||
val skuName = if (versionName == null) {
|
|
||||||
"9eSIM"
|
|
||||||
} else {
|
|
||||||
"9eSIM $versionName"
|
|
||||||
}
|
|
||||||
|
|
||||||
return EuiccVendorInfo(
|
|
||||||
skuName = skuName,
|
|
||||||
serialNumber = null,
|
|
||||||
bootloaderVersion = null,
|
|
||||||
firmwareVersion = null
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -1,6 +0,0 @@
|
||||||
<?xml version="1.0" encoding="utf-8"?>
|
|
||||||
<translate xmlns:android="http://schemas.android.com/apk/res/android"
|
|
||||||
android:duration="@android:integer/config_shortAnimTime"
|
|
||||||
android:interpolator="@android:anim/decelerate_interpolator"
|
|
||||||
android:fromXDelta="-100%"
|
|
||||||
android:toXDelta="0%" />
|
|
|
@ -1,6 +0,0 @@
|
||||||
<?xml version="1.0" encoding="utf-8"?>
|
|
||||||
<translate xmlns:android="http://schemas.android.com/apk/res/android"
|
|
||||||
android:duration="@android:integer/config_shortAnimTime"
|
|
||||||
android:interpolator="@android:anim/decelerate_interpolator"
|
|
||||||
android:fromXDelta="100%"
|
|
||||||
android:toXDelta="0%" />
|
|
|
@ -1,6 +0,0 @@
|
||||||
<!-- res/anim/slide_out.xml -->
|
|
||||||
<translate xmlns:android="http://schemas.android.com/apk/res/android"
|
|
||||||
android:duration="@android:integer/config_shortAnimTime"
|
|
||||||
android:interpolator="@android:anim/decelerate_interpolator"
|
|
||||||
android:fromXDelta="0%"
|
|
||||||
android:toXDelta="-100%" />
|
|
|
@ -1,6 +0,0 @@
|
||||||
<!-- res/anim/slide_out.xml -->
|
|
||||||
<translate xmlns:android="http://schemas.android.com/apk/res/android"
|
|
||||||
android:duration="@android:integer/config_shortAnimTime"
|
|
||||||
android:interpolator="@android:anim/decelerate_interpolator"
|
|
||||||
android:fromXDelta="0%"
|
|
||||||
android:toXDelta="100%" />
|
|
|
@ -1,5 +0,0 @@
|
||||||
<vector xmlns:android="http://schemas.android.com/apk/res/android" android:height="24dp" android:tint="#000000" android:viewportHeight="24" android:viewportWidth="24" android:width="24dp">
|
|
||||||
|
|
||||||
<path android:fillColor="@android:color/white" android:pathData="M15.41,7.41L14,6l-6,6 6,6 1.41,-1.41L10.83,12z"/>
|
|
||||||
|
|
||||||
</vector>
|
|
|
@ -1,5 +0,0 @@
|
||||||
<vector xmlns:android="http://schemas.android.com/apk/res/android" android:height="24dp" android:tint="#000000" android:viewportHeight="24" android:viewportWidth="24" android:width="24dp">
|
|
||||||
|
|
||||||
<path android:fillColor="@android:color/white" android:pathData="M10,6L8.59,7.41 13.17,12l-4.58,4.59L10,18l6,-6z"/>
|
|
||||||
|
|
||||||
</vector>
|
|
|
@ -1,5 +0,0 @@
|
||||||
<vector xmlns:android="http://schemas.android.com/apk/res/android" android:height="24dp" android:tint="#000000" android:viewportHeight="24" android:viewportWidth="24" android:width="24dp">
|
|
||||||
|
|
||||||
<path android:fillColor="@android:color/white" android:pathData="M3,17.25V21h3.75L17.81,9.94l-3.75,-3.75L3,17.25zM20.71,7.04c0.39,-0.39 0.39,-1.02 0,-1.41l-2.34,-2.34c-0.39,-0.39 -1.02,-0.39 -1.41,0l-1.83,1.83 3.75,3.75 1.83,-1.83z"/>
|
|
||||||
|
|
||||||
</vector>
|
|
|
@ -1,18 +0,0 @@
|
||||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
|
||||||
android:width="24dp"
|
|
||||||
android:height="24dp"
|
|
||||||
android:viewportWidth="21"
|
|
||||||
android:viewportHeight="21">
|
|
||||||
<path
|
|
||||||
android:pathData="m3.578,6.487c1.385,-2.384 3.966,-3.987 6.922,-3.987 4.418,0 8,3.582 8,8s-3.582,8 -8,8 -8,-3.582 -8,-8"
|
|
||||||
android:strokeWidth="1"
|
|
||||||
android:strokeColor="@android:color/white"
|
|
||||||
android:strokeLineCap="round"
|
|
||||||
android:strokeLineJoin="round" />
|
|
||||||
<path
|
|
||||||
android:pathData="m7.5,6.5l-4,0l-0,-4"
|
|
||||||
android:strokeWidth="1"
|
|
||||||
android:strokeColor="@android:color/white"
|
|
||||||
android:strokeLineCap="round"
|
|
||||||
android:strokeLineJoin="round" />
|
|
||||||
</vector>
|
|
|
@ -1,5 +0,0 @@
|
||||||
<vector android:height="24dp" android:tint="?attr/colorControlNormal"
|
|
||||||
android:viewportHeight="24" android:viewportWidth="24"
|
|
||||||
android:width="24dp" xmlns:android="http://schemas.android.com/apk/res/android">
|
|
||||||
<path android:fillColor="@android:color/white" android:pathData="M22,16L22,4c0,-1.1 -0.9,-2 -2,-2L8,2c-1.1,0 -2,0.9 -2,2v12c0,1.1 0.9,2 2,2h12c1.1,0 2,-0.9 2,-2zM11,12l2.03,2.71L16,11l4,5L8,16l3,-4zM2,6v14c0,1.1 0.9,2 2,2h14v-2L4,20L4,6L2,6z"/>
|
|
||||||
</vector>
|
|
|
@ -1,7 +0,0 @@
|
||||||
<vector xmlns:android="http://schemas.android.com/apk/res/android" android:height="24dp" android:tint="?attr/colorControlNormal" android:viewportHeight="24" android:viewportWidth="24" android:width="24dp">
|
|
||||||
|
|
||||||
<path android:fillColor="@android:color/white" android:pathData="M5,5h2v3h10V5h2v6h2V5c0,-1.1 -0.9,-2 -2,-2h-4.18C14.4,1.84 13.3,1 12,1S9.6,1.84 9.18,3H5C3.9,3 3,3.9 3,5v14c0,1.1 0.9,2 2,2h5v-2H5V5zM12,3c0.55,0 1,0.45 1,1s-0.45,1 -1,1s-1,-0.45 -1,-1S11.45,3 12,3z"/>
|
|
||||||
|
|
||||||
<path android:fillColor="@android:color/white" android:pathData="M18.01,13l-1.42,1.41l1.58,1.58l-6.17,0l0,2l6.17,0l-1.58,1.59l1.42,1.41l3.99,-4z"/>
|
|
||||||
|
|
||||||
</vector>
|
|
|
@ -1,5 +0,0 @@
|
||||||
<vector android:height="24dp" android:tint="?attr/colorControlNormal"
|
|
||||||
android:viewportHeight="24" android:viewportWidth="24"
|
|
||||||
android:width="24dp" xmlns:android="http://schemas.android.com/apk/res/android">
|
|
||||||
<path android:fillColor="@android:color/white" android:pathData="M17.65,6.35C16.2,4.9 14.21,4 12,4c-4.42,0 -7.99,3.58 -7.99,8s3.57,8 7.99,8c3.73,0 6.84,-2.55 7.73,-6h-2.08c-0.82,2.33 -3.04,4 -5.65,4 -3.31,0 -6,-2.69 -6,-6s2.69,-6 6,-6c1.66,0 3.14,0.69 4.22,1.78L13,11h7V4l-2.35,2.35z"/>
|
|
||||||
</vector>
|
|
|
@ -1,5 +0,0 @@
|
||||||
<vector xmlns:android="http://schemas.android.com/apk/res/android" android:height="24dp" android:tint="#000000" android:viewportHeight="24" android:viewportWidth="24" android:width="24dp">
|
|
||||||
|
|
||||||
<path android:fillColor="@android:color/white" android:pathData="M6,19c0,1.1 0.9,2 2,2h8c1.1,0 2,-0.9 2,-2V7H6v12zM19,4h-3.5l-1,-1h-5l-1,1H5v2h14V4z"/>
|
|
||||||
|
|
||||||
</vector>
|
|
|
@ -1,5 +0,0 @@
|
||||||
<vector xmlns:android="http://schemas.android.com/apk/res/android" android:height="24dp" android:tint="#000000" android:viewportHeight="24" android:viewportWidth="24" android:width="24dp">
|
|
||||||
|
|
||||||
<path android:fillColor="@android:color/white" android:pathData="M3,17.25V21h3.75L17.81,9.94l-3.75,-3.75L3,17.25zM20.71,7.04c0.39,-0.39 0.39,-1.02 0,-1.41l-2.34,-2.34c-0.39,-0.39 -1.02,-0.39 -1.41,0l-1.83,1.83 3.75,3.75 1.83,-1.83z"/>
|
|
||||||
|
|
||||||
</vector>
|
|
|
@ -1,5 +0,0 @@
|
||||||
<vector xmlns:android="http://schemas.android.com/apk/res/android" android:height="24dp" android:tint="#000000" android:viewportHeight="24" android:viewportWidth="24" android:width="24dp">
|
|
||||||
|
|
||||||
<path android:fillColor="@android:color/white" android:pathData="M18,2h-8L4,8v12c0,1.1 0.9,2 2,2h12c1.1,0 2,-0.9 2,-2V4C20,2.9 19.1,2 18,2zM12,17l-4,-4h3V9.02L13,9v4h3L12,17z"/>
|
|
||||||
|
|
||||||
</vector>
|
|
|
@ -1,5 +0,0 @@
|
||||||
<vector xmlns:android="http://schemas.android.com/apk/res/android" android:height="24dp" android:tint="#000000" android:viewportHeight="24" android:viewportWidth="24" android:width="24dp">
|
|
||||||
|
|
||||||
<path android:fillColor="@android:color/white" android:pathData="M17.65,6.35C16.2,4.9 14.21,4 12,4c-4.42,0 -7.99,3.58 -7.99,8s3.57,8 7.99,8c3.73,0 6.84,-2.55 7.73,-6h-2.08c-0.82,2.33 -3.04,4 -5.65,4 -3.31,0 -6,-2.69 -6,-6s2.69,-6 6,-6c1.66,0 3.14,0.69 4.22,1.78L13,11h7V4l-2.35,2.35z"/>
|
|
||||||
|
|
||||||
</vector>
|
|
|
@ -1,5 +0,0 @@
|
||||||
<vector xmlns:android="http://schemas.android.com/apk/res/android" android:height="24dp" android:tint="#000000" android:viewportHeight="24" android:viewportWidth="24" android:width="24dp">
|
|
||||||
|
|
||||||
<path android:fillColor="@android:color/white" android:pathData="M19,6.41L17.59,5 12,10.59 6.41,5 5,6.41 10.59,12 5,17.59 6.41,19 12,13.41 17.59,19 19,17.59 13.41,12z"/>
|
|
||||||
|
|
||||||
</vector>
|
|
|
@ -1,74 +0,0 @@
|
||||||
<?xml version="1.0" encoding="utf-8"?>
|
|
||||||
<androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android"
|
|
||||||
android:layout_width="match_parent"
|
|
||||||
android:layout_height="match_parent"
|
|
||||||
xmlns:app="http://schemas.android.com/apk/res-auto">
|
|
||||||
|
|
||||||
<FrameLayout
|
|
||||||
android:id="@+id/step_fragment_container"
|
|
||||||
android:layout_width="0dp"
|
|
||||||
android:layout_height="0dp"
|
|
||||||
app:layout_constraintTop_toTopOf="parent"
|
|
||||||
app:layout_constraintBottom_toTopOf="@id/download_wizard_navigation"
|
|
||||||
app:layout_constraintStart_toStartOf="parent"
|
|
||||||
app:layout_constraintEnd_toEndOf="parent" />
|
|
||||||
|
|
||||||
<View
|
|
||||||
android:id="@+id/guideline"
|
|
||||||
android:layout_width="0dp"
|
|
||||||
android:layout_height="0dp"
|
|
||||||
android:orientation="vertical"
|
|
||||||
android:visibility="invisible"
|
|
||||||
app:layout_constraintBottom_toTopOf="@id/download_wizard_navigation"
|
|
||||||
app:layout_constraintEnd_toEndOf="parent"
|
|
||||||
app:layout_constraintStart_toStartOf="parent" />
|
|
||||||
|
|
||||||
<ProgressBar
|
|
||||||
android:id="@+id/progress"
|
|
||||||
android:layout_width="0dp"
|
|
||||||
android:layout_height="wrap_content"
|
|
||||||
android:indeterminate="true"
|
|
||||||
app:layout_constraintLeft_toLeftOf="parent"
|
|
||||||
app:layout_constraintRight_toRightOf="parent"
|
|
||||||
app:layout_constraintTop_toBottomOf="@id/guideline"
|
|
||||||
app:layout_constraintBottom_toTopOf="@id/download_wizard_navigation"
|
|
||||||
style="@style/Widget.AppCompat.ProgressBar.Horizontal" />
|
|
||||||
|
|
||||||
<androidx.constraintlayout.widget.ConstraintLayout
|
|
||||||
android:id="@+id/download_wizard_navigation"
|
|
||||||
android:layout_width="match_parent"
|
|
||||||
android:layout_height="48dp"
|
|
||||||
android:background="?attr/colorSurfaceContainer"
|
|
||||||
app:layout_constraintBottom_toBottomOf="parent"
|
|
||||||
app:layout_constraintStart_toStartOf="parent"
|
|
||||||
app:layout_constraintEnd_toEndOf="parent">
|
|
||||||
|
|
||||||
<com.google.android.material.button.MaterialButton
|
|
||||||
android:id="@+id/download_wizard_back"
|
|
||||||
android:text="@string/download_wizard_back"
|
|
||||||
android:background="?attr/selectableItemBackground"
|
|
||||||
android:textColor="?attr/colorPrimary"
|
|
||||||
android:layout_width="wrap_content"
|
|
||||||
android:layout_height="48dp"
|
|
||||||
app:icon="@drawable/ic_chevron_left"
|
|
||||||
app:iconGravity="start"
|
|
||||||
app:iconTint="?attr/colorPrimary"
|
|
||||||
app:layout_constraintStart_toStartOf="parent"
|
|
||||||
app:layout_constraintTop_toTopOf="parent" />
|
|
||||||
|
|
||||||
<com.google.android.material.button.MaterialButton
|
|
||||||
android:id="@+id/download_wizard_next"
|
|
||||||
android:text="@string/download_wizard_next"
|
|
||||||
android:background="?attr/selectableItemBackground"
|
|
||||||
android:textColor="?attr/colorPrimary"
|
|
||||||
android:layout_width="wrap_content"
|
|
||||||
android:layout_height="48dp"
|
|
||||||
app:icon="@drawable/ic_chevron_right"
|
|
||||||
app:iconGravity="end"
|
|
||||||
app:iconTint="?attr/colorPrimary"
|
|
||||||
app:layout_constraintEnd_toEndOf="parent"
|
|
||||||
app:layout_constraintTop_toTopOf="parent" />
|
|
||||||
|
|
||||||
</androidx.constraintlayout.widget.ConstraintLayout>
|
|
||||||
|
|
||||||
</androidx.constraintlayout.widget.ConstraintLayout>
|
|
|
@ -1,25 +0,0 @@
|
||||||
<?xml version="1.0" encoding="utf-8"?>
|
|
||||||
<androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android"
|
|
||||||
android:layout_width="match_parent"
|
|
||||||
android:layout_height="match_parent"
|
|
||||||
xmlns:app="http://schemas.android.com/apk/res-auto">
|
|
||||||
|
|
||||||
<include layout="@layout/toolbar_activity" />
|
|
||||||
|
|
||||||
<androidx.swiperefreshlayout.widget.SwipeRefreshLayout
|
|
||||||
android:id="@+id/swipe_refresh"
|
|
||||||
android:layout_width="0dp"
|
|
||||||
android:layout_height="0dp"
|
|
||||||
app:layout_constraintTop_toBottomOf="@id/toolbar"
|
|
||||||
app:layout_constraintStart_toStartOf="parent"
|
|
||||||
app:layout_constraintEnd_toEndOf="parent"
|
|
||||||
app:layout_constraintBottom_toBottomOf="parent">
|
|
||||||
|
|
||||||
<androidx.recyclerview.widget.RecyclerView
|
|
||||||
android:id="@+id/recycler_view"
|
|
||||||
android:layout_width="match_parent"
|
|
||||||
android:layout_height="match_parent" />
|
|
||||||
|
|
||||||
</androidx.swiperefreshlayout.widget.SwipeRefreshLayout>
|
|
||||||
|
|
||||||
</androidx.constraintlayout.widget.ConstraintLayout>
|
|
|
@ -1,24 +0,0 @@
|
||||||
<?xml version="1.0" encoding="utf-8"?>
|
|
||||||
<androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android"
|
|
||||||
xmlns:tools="http://schemas.android.com/tools"
|
|
||||||
xmlns:app="http://schemas.android.com/apk/res-auto"
|
|
||||||
android:layout_width="match_parent"
|
|
||||||
android:layout_height="match_parent">
|
|
||||||
|
|
||||||
<include layout="@layout/toolbar_activity" />
|
|
||||||
|
|
||||||
<EditText
|
|
||||||
android:id="@+id/isdr_aid_list_editor"
|
|
||||||
android:layout_width="0dp"
|
|
||||||
android:layout_height="0dp"
|
|
||||||
android:fontFamily="monospace"
|
|
||||||
android:importantForAutofill="no"
|
|
||||||
android:inputType="textMultiLine"
|
|
||||||
android:gravity="top|start"
|
|
||||||
app:layout_constraintTop_toBottomOf="@id/toolbar"
|
|
||||||
app:layout_constraintStart_toStartOf="parent"
|
|
||||||
app:layout_constraintEnd_toEndOf="parent"
|
|
||||||
app:layout_constraintBottom_toBottomOf="parent"
|
|
||||||
tools:ignore="LabelFor" />
|
|
||||||
|
|
||||||
</androidx.constraintlayout.widget.ConstraintLayout>
|
|
|
@ -5,7 +5,13 @@
|
||||||
android:layout_height="match_parent"
|
android:layout_height="match_parent"
|
||||||
xmlns:app="http://schemas.android.com/apk/res-auto">
|
xmlns:app="http://schemas.android.com/apk/res-auto">
|
||||||
|
|
||||||
<include layout="@layout/toolbar_activity" />
|
<com.google.android.material.appbar.MaterialToolbar
|
||||||
|
android:id="@+id/toolbar"
|
||||||
|
android:layout_width="0dp"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
app:layout_constraintTop_toTopOf="parent"
|
||||||
|
app:layout_constraintLeft_toLeftOf="parent"
|
||||||
|
app:layout_constraintWidth_percent="1" />
|
||||||
|
|
||||||
<androidx.swiperefreshlayout.widget.SwipeRefreshLayout
|
<androidx.swiperefreshlayout.widget.SwipeRefreshLayout
|
||||||
android:id="@+id/swipe_refresh"
|
android:id="@+id/swipe_refresh"
|
||||||
|
|
|
@ -3,39 +3,33 @@
|
||||||
xmlns:app="http://schemas.android.com/apk/res-auto"
|
xmlns:app="http://schemas.android.com/apk/res-auto"
|
||||||
xmlns:tools="http://schemas.android.com/tools"
|
xmlns:tools="http://schemas.android.com/tools"
|
||||||
android:layout_width="match_parent"
|
android:layout_width="match_parent"
|
||||||
android:layout_height="match_parent">
|
android:layout_height="match_parent"
|
||||||
|
tools:context=".ui.MainActivity">
|
||||||
|
|
||||||
<include layout="@layout/toolbar_activity" />
|
<com.google.android.material.appbar.MaterialToolbar
|
||||||
|
android:id="@+id/toolbar"
|
||||||
<com.google.android.material.tabs.TabLayout
|
android:layout_width="0dp"
|
||||||
android:id="@+id/main_tabs"
|
|
||||||
android:background="?attr/colorSurfaceVariant"
|
|
||||||
android:layout_width="match_parent"
|
|
||||||
android:layout_height="wrap_content"
|
android:layout_height="wrap_content"
|
||||||
android:visibility="gone"
|
app:layout_constraintTop_toTopOf="parent"
|
||||||
app:tabTextColor="?attr/colorOnSurfaceVariant"
|
app:layout_constraintLeft_toLeftOf="parent"
|
||||||
app:tabSelectedTextColor="?attr/colorOnSurfaceVariant"
|
app:layout_constraintWidth_percent="1" />
|
||||||
app:layout_constraintTop_toBottomOf="@id/toolbar"
|
|
||||||
app:layout_constraintStart_toStartOf="parent" />
|
|
||||||
|
|
||||||
<ProgressBar
|
<FrameLayout
|
||||||
android:id="@+id/loading"
|
android:id="@+id/fragment_root"
|
||||||
android:layout_width="wrap_content"
|
|
||||||
android:layout_height="wrap_content"
|
|
||||||
android:indeterminate="true"
|
|
||||||
app:layout_constraintStart_toStartOf="parent"
|
|
||||||
app:layout_constraintEnd_toEndOf="parent"
|
|
||||||
app:layout_constraintTop_toBottomOf="@id/main_tabs"
|
|
||||||
app:layout_constraintBottom_toBottomOf="parent" />
|
|
||||||
|
|
||||||
<androidx.viewpager2.widget.ViewPager2
|
|
||||||
android:id="@+id/view_pager"
|
|
||||||
android:layout_width="0dp"
|
android:layout_width="0dp"
|
||||||
android:layout_height="0dp"
|
android:layout_height="0dp"
|
||||||
android:visibility="gone"
|
|
||||||
app:layout_constraintBottom_toBottomOf="parent"
|
app:layout_constraintBottom_toBottomOf="parent"
|
||||||
app:layout_constraintLeft_toLeftOf="parent"
|
app:layout_constraintLeft_toLeftOf="parent"
|
||||||
app:layout_constraintRight_toRightOf="parent"
|
app:layout_constraintRight_toRightOf="parent"
|
||||||
app:layout_constraintTop_toBottomOf="@id/main_tabs"/>
|
app:layout_constraintTop_toBottomOf="@id/toolbar">
|
||||||
|
<TextView
|
||||||
|
android:id="@+id/no_euicc_placeholder"
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="match_parent"
|
||||||
|
android:layout_marginStart="40dp"
|
||||||
|
android:layout_marginEnd="40dp"
|
||||||
|
android:gravity="center"
|
||||||
|
android:text="@string/no_euicc" />
|
||||||
|
</FrameLayout>
|
||||||
|
|
||||||
</androidx.constraintlayout.widget.ConstraintLayout>
|
</androidx.constraintlayout.widget.ConstraintLayout>
|
|
@ -4,7 +4,13 @@
|
||||||
android:layout_height="match_parent"
|
android:layout_height="match_parent"
|
||||||
xmlns:app="http://schemas.android.com/apk/res-auto">
|
xmlns:app="http://schemas.android.com/apk/res-auto">
|
||||||
|
|
||||||
<include layout="@layout/toolbar_activity" />
|
<com.google.android.material.appbar.MaterialToolbar
|
||||||
|
android:id="@+id/toolbar"
|
||||||
|
android:layout_width="0dp"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
app:layout_constraintTop_toTopOf="parent"
|
||||||
|
app:layout_constraintLeft_toLeftOf="parent"
|
||||||
|
app:layout_constraintWidth_percent="1" />
|
||||||
|
|
||||||
<androidx.swiperefreshlayout.widget.SwipeRefreshLayout
|
<androidx.swiperefreshlayout.widget.SwipeRefreshLayout
|
||||||
android:id="@+id/swipe_refresh"
|
android:id="@+id/swipe_refresh"
|
||||||
|
|
|
@ -4,7 +4,13 @@
|
||||||
android:layout_height="match_parent"
|
android:layout_height="match_parent"
|
||||||
xmlns:app="http://schemas.android.com/apk/res-auto">
|
xmlns:app="http://schemas.android.com/apk/res-auto">
|
||||||
|
|
||||||
<include layout="@layout/toolbar_activity" />
|
<com.google.android.material.appbar.MaterialToolbar
|
||||||
|
android:id="@+id/toolbar"
|
||||||
|
android:layout_width="0dp"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
app:layout_constraintTop_toTopOf="parent"
|
||||||
|
app:layout_constraintLeft_toLeftOf="parent"
|
||||||
|
app:layout_constraintWidth_percent="1" />
|
||||||
|
|
||||||
<FrameLayout
|
<FrameLayout
|
||||||
android:id="@+id/settings_container"
|
android:id="@+id/settings_container"
|
||||||
|
|
|
@ -1,44 +0,0 @@
|
||||||
<?xml version="1.0" encoding="utf-8"?>
|
|
||||||
<androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android"
|
|
||||||
android:layout_width="match_parent"
|
|
||||||
android:layout_height="wrap_content"
|
|
||||||
xmlns:app="http://schemas.android.com/apk/res-auto"
|
|
||||||
android:padding="20dp"
|
|
||||||
android:background="?attr/selectableItemBackground">
|
|
||||||
|
|
||||||
<ImageView
|
|
||||||
android:id="@+id/download_method_icon"
|
|
||||||
android:layout_width="30dp"
|
|
||||||
android:layout_height="30dp"
|
|
||||||
app:tint="?attr/colorAccent"
|
|
||||||
app:layout_constraintTop_toTopOf="parent"
|
|
||||||
app:layout_constraintBottom_toBottomOf="parent"
|
|
||||||
app:layout_constraintStart_toStartOf="parent" />
|
|
||||||
|
|
||||||
<TextView
|
|
||||||
android:id="@+id/download_method_title"
|
|
||||||
android:layout_width="wrap_content"
|
|
||||||
android:layout_height="wrap_content"
|
|
||||||
android:layout_marginStart="20dp"
|
|
||||||
android:layout_marginEnd="20dp"
|
|
||||||
android:textSize="15sp"
|
|
||||||
android:maxLines="1"
|
|
||||||
android:ellipsize="marquee"
|
|
||||||
app:layout_constraintTop_toTopOf="parent"
|
|
||||||
app:layout_constraintBottom_toBottomOf="parent"
|
|
||||||
app:layout_constraintStart_toEndOf="@id/download_method_icon"
|
|
||||||
app:layout_constraintEnd_toStartOf="@id/download_method_chevron"
|
|
||||||
app:layout_constraintHorizontal_bias="0.0"
|
|
||||||
app:layout_constrainedWidth="true" />
|
|
||||||
|
|
||||||
<ImageView
|
|
||||||
android:id="@+id/download_method_chevron"
|
|
||||||
android:src="@drawable/ic_chevron_right"
|
|
||||||
android:layout_width="30dp"
|
|
||||||
android:layout_height="30dp"
|
|
||||||
app:tint="?attr/colorAccent"
|
|
||||||
app:layout_constraintTop_toTopOf="parent"
|
|
||||||
app:layout_constraintBottom_toBottomOf="parent"
|
|
||||||
app:layout_constraintEnd_toEndOf="parent" />
|
|
||||||
|
|
||||||
</androidx.constraintlayout.widget.ConstraintLayout>
|
|
|
@ -1,45 +0,0 @@
|
||||||
<?xml version="1.0" encoding="utf-8"?>
|
|
||||||
<androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android"
|
|
||||||
android:layout_width="match_parent"
|
|
||||||
android:layout_height="wrap_content"
|
|
||||||
xmlns:app="http://schemas.android.com/apk/res-auto">
|
|
||||||
|
|
||||||
<TextView
|
|
||||||
android:id="@+id/download_progress_item_title"
|
|
||||||
android:layout_width="wrap_content"
|
|
||||||
android:layout_height="wrap_content"
|
|
||||||
android:layout_margin="20dp"
|
|
||||||
android:textSize="14sp"
|
|
||||||
app:layout_constraintTop_toTopOf="parent"
|
|
||||||
app:layout_constraintStart_toStartOf="parent"
|
|
||||||
app:layout_constraintBottom_toBottomOf="parent"
|
|
||||||
app:layout_constraintEnd_toStartOf="@id/download_progress_icon_container"
|
|
||||||
app:layout_constrainedWidth="true"
|
|
||||||
app:layout_constraintHorizontal_bias="0.0" />
|
|
||||||
|
|
||||||
<FrameLayout
|
|
||||||
android:id="@+id/download_progress_icon_container"
|
|
||||||
android:layout_margin="20dp"
|
|
||||||
android:layout_width="30dp"
|
|
||||||
android:layout_height="30dp"
|
|
||||||
app:layout_constraintTop_toTopOf="parent"
|
|
||||||
app:layout_constraintBottom_toBottomOf="parent"
|
|
||||||
app:layout_constraintEnd_toEndOf="parent">
|
|
||||||
|
|
||||||
<ProgressBar
|
|
||||||
android:id="@+id/download_progress_icon_progress"
|
|
||||||
android:layout_width="match_parent"
|
|
||||||
android:layout_height="match_parent"
|
|
||||||
android:indeterminate="true"
|
|
||||||
android:visibility="gone" />
|
|
||||||
|
|
||||||
<ImageView
|
|
||||||
android:id="@+id/download_progress_icon"
|
|
||||||
android:layout_width="match_parent"
|
|
||||||
android:layout_height="match_parent"
|
|
||||||
android:visibility="gone"
|
|
||||||
app:tint="?attr/colorPrimary" />
|
|
||||||
|
|
||||||
</FrameLayout>
|
|
||||||
|
|
||||||
</androidx.constraintlayout.widget.ConstraintLayout>
|
|
|
@ -1,108 +0,0 @@
|
||||||
<?xml version="1.0" encoding="utf-8"?>
|
|
||||||
<androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android"
|
|
||||||
xmlns:app="http://schemas.android.com/apk/res-auto"
|
|
||||||
android:layout_width="match_parent"
|
|
||||||
android:layout_height="wrap_content"
|
|
||||||
android:paddingBottom="20sp"
|
|
||||||
android:paddingTop="10sp"
|
|
||||||
android:paddingStart="20sp"
|
|
||||||
android:paddingEnd="20sp"
|
|
||||||
android:background="?attr/selectableItemBackground">
|
|
||||||
|
|
||||||
<TextView
|
|
||||||
android:id="@+id/slot_item_title"
|
|
||||||
android:layout_width="0dp"
|
|
||||||
android:layout_height="wrap_content"
|
|
||||||
android:layout_margin="10sp"
|
|
||||||
android:textSize="18sp"
|
|
||||||
app:layout_constraintEnd_toEndOf="parent"
|
|
||||||
app:layout_constraintStart_toStartOf="parent"
|
|
||||||
app:layout_constraintTop_toTopOf="parent" />
|
|
||||||
|
|
||||||
<TextView
|
|
||||||
android:id="@+id/slot_item_type_label"
|
|
||||||
android:layout_width="wrap_content"
|
|
||||||
android:layout_height="wrap_content"
|
|
||||||
android:minWidth="100dp"
|
|
||||||
android:text="@string/download_wizard_slot_type"
|
|
||||||
android:textSize="14sp" />
|
|
||||||
|
|
||||||
<TextView
|
|
||||||
android:id="@+id/slot_item_type"
|
|
||||||
android:layout_width="0dp"
|
|
||||||
android:layout_height="wrap_content"
|
|
||||||
android:textSize="14sp" />
|
|
||||||
|
|
||||||
<TextView
|
|
||||||
android:id="@+id/slot_item_eid_label"
|
|
||||||
android:layout_width="wrap_content"
|
|
||||||
android:layout_height="wrap_content"
|
|
||||||
android:minWidth="100dp"
|
|
||||||
android:text="@string/download_wizard_slot_eid"
|
|
||||||
android:textSize="14sp" />
|
|
||||||
|
|
||||||
<TextView
|
|
||||||
android:id="@+id/slot_item_eid"
|
|
||||||
android:layout_width="0dp"
|
|
||||||
android:layout_height="wrap_content"
|
|
||||||
android:textSize="14sp" />
|
|
||||||
|
|
||||||
<TextView
|
|
||||||
android:id="@+id/slot_item_active_profile_label"
|
|
||||||
android:layout_width="wrap_content"
|
|
||||||
android:layout_height="wrap_content"
|
|
||||||
android:minWidth="100dp"
|
|
||||||
android:text="@string/download_wizard_slot_active_profile"
|
|
||||||
android:textSize="14sp" />
|
|
||||||
|
|
||||||
<TextView
|
|
||||||
android:id="@+id/slot_item_active_profile"
|
|
||||||
android:layout_width="0dp"
|
|
||||||
android:layout_height="wrap_content"
|
|
||||||
android:textSize="14sp" />
|
|
||||||
|
|
||||||
<TextView
|
|
||||||
android:id="@+id/slot_item_free_space_label"
|
|
||||||
android:layout_width="wrap_content"
|
|
||||||
android:layout_height="wrap_content"
|
|
||||||
android:minWidth="100dp"
|
|
||||||
android:text="@string/download_wizard_slot_free_space"
|
|
||||||
android:textSize="14sp" />
|
|
||||||
|
|
||||||
<TextView
|
|
||||||
android:id="@+id/slot_item_free_space"
|
|
||||||
android:layout_width="0dp"
|
|
||||||
android:layout_height="wrap_content"
|
|
||||||
android:textSize="14sp" />
|
|
||||||
|
|
||||||
<androidx.constraintlayout.helper.widget.Flow
|
|
||||||
android:id="@+id/flow1"
|
|
||||||
android:layout_width="0dp"
|
|
||||||
android:layout_height="wrap_content"
|
|
||||||
android:layout_marginStart="10sp"
|
|
||||||
android:layout_marginTop="20sp"
|
|
||||||
android:layout_marginEnd="10sp"
|
|
||||||
app:constraint_referenced_ids="slot_item_type_label,slot_item_type,slot_item_eid_label,slot_item_eid,slot_item_active_profile_label,slot_item_active_profile,slot_item_free_space_label,slot_item_free_space"
|
|
||||||
app:flow_wrapMode="aligned"
|
|
||||||
app:flow_horizontalAlign="start"
|
|
||||||
app:flow_horizontalBias="1"
|
|
||||||
app:flow_horizontalGap="10sp"
|
|
||||||
app:flow_horizontalStyle="packed"
|
|
||||||
app:flow_maxElementsWrap="2"
|
|
||||||
app:flow_verticalBias="0"
|
|
||||||
app:flow_verticalGap="16sp"
|
|
||||||
app:flow_verticalStyle="packed"
|
|
||||||
app:layout_constraintEnd_toStartOf="@id/slot_checkbox"
|
|
||||||
app:layout_constraintStart_toStartOf="parent"
|
|
||||||
app:layout_constraintTop_toBottomOf="@id/slot_item_title" />
|
|
||||||
|
|
||||||
<CheckBox
|
|
||||||
android:id="@+id/slot_checkbox"
|
|
||||||
android:layout_width="48dp"
|
|
||||||
android:layout_height="48dp"
|
|
||||||
app:layout_constraintBottom_toBottomOf="parent"
|
|
||||||
app:layout_constraintEnd_toEndOf="parent"
|
|
||||||
app:layout_constraintStart_toEndOf="@id/flow1"
|
|
||||||
app:layout_constraintTop_toTopOf="parent" />
|
|
||||||
|
|
||||||
</androidx.constraintlayout.widget.ConstraintLayout>
|
|
Some files were not shown because too many files have changed in this diff Show more
Loading…
Add table
Reference in a new issue