Compare commits
No commits in common. "master" and "master" have entirely different histories.
190 changed files with 2073 additions and 7393 deletions
|
@ -1,7 +1,7 @@
|
||||||
on:
|
on:
|
||||||
push:
|
push:
|
||||||
branches:
|
branches:
|
||||||
- '*'
|
- 'master'
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
build-debug:
|
build-debug:
|
||||||
|
@ -35,12 +35,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
|
||||||
|
|
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
29
README.md
29
README.md
|
@ -2,26 +2,17 @@
|
||||||
|
|
||||||
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 |
|
- This version supports two modes of operation:
|
||||||
| USB Readers | Supported | Supported |
|
1. Inserted, removable eSIMs: Due to obvious security requirements, EasyEUICC is only able to access eSIM chips whose [ARF/ARA](https://source.android.com/docs/core/connect/uicc#arf) contains the hash of EasyEUICC's signing certificate.
|
||||||
| Requires allowlisting by eSIM | No | Yes -- except USB |
|
2. USB CCID Card Readers: Only `T=0` readers that use the standard [USB CCID protocol](https://en.wikipedia.org/wiki/CCID_(protocol)) are supported. In this mode, EasyEUICC can access any eSIM chip loaded in the card reader regardless of their ARF/ARA, as long as they implement the [SGP.22 standard](https://www.gsma.com/solutions-and-impact/technologies/esim/wp-content/uploads/2021/07/SGP.22-v2.3.pdf).
|
||||||
| System Integration | Partial (carrier partner API unimplemented yet) | No |
|
- Prebuilt release-mode EasyEUICC apks can be downloaded [here](https://gitea.angry.im/PeterCxy/OpenEUICC/releases)
|
||||||
|
- For removable eSIM chip vendors: to have your chip supported by official builds of EasyEUICC when inserted, include the ARA-M hash `2A2FA878BC7C3354C2CF82935A5945A3EDAE4AFA`
|
||||||
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)
|
||||||
===
|
===
|
||||||
|
|
|
@ -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,40 +16,14 @@
|
||||||
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"
|
||||||
|
@ -62,7 +31,6 @@
|
||||||
|
|
||||||
<service
|
<service
|
||||||
android:name="im.angry.openeuicc.service.EuiccChannelManagerService"
|
android:name="im.angry.openeuicc.service.EuiccChannelManagerService"
|
||||||
android:foregroundServiceType="shortService"
|
|
||||||
android:exported="false" />
|
android:exported="false" />
|
||||||
</application>
|
</application>
|
||||||
</manifest>
|
</manifest>
|
||||||
|
|
|
@ -1,5 +0,0 @@
|
||||||
package im.angry.openeuicc.core
|
|
||||||
|
|
||||||
interface ApduInterfaceAtrProvider {
|
|
||||||
val atr: ByteArray?
|
|
||||||
}
|
|
|
@ -1,92 +1,59 @@
|
||||||
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.UsbInterface
|
||||||
|
import android.hardware.usb.UsbManager
|
||||||
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.UsbApduInterface
|
||||||
import im.angry.openeuicc.core.usb.UsbCcidContext
|
import im.angry.openeuicc.core.usb.getIoEndpoints
|
||||||
import im.angry.openeuicc.util.*
|
import im.angry.openeuicc.util.*
|
||||||
import java.lang.IllegalArgumentException
|
import java.lang.IllegalArgumentException
|
||||||
|
|
||||||
open class DefaultEuiccChannelFactory(protected val context: Context) : EuiccChannelFactory {
|
open class DefaultEuiccChannelFactory(protected val context: Context) : EuiccChannelFactory {
|
||||||
private var seService: SEService? = null
|
private var seService: SEService? = null
|
||||||
|
|
||||||
|
private val usbManager by lazy {
|
||||||
|
context.getSystemService(Context.USB_SERVICE) as UsbManager
|
||||||
|
}
|
||||||
|
|
||||||
private suspend fun ensureSEService() {
|
private suspend fun ensureSEService() {
|
||||||
if (seService == null || !seService!!.isConnected) {
|
if (seService == null || !seService!!.isConnected) {
|
||||||
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 EuiccChannel(port, OmapiApduInterface(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(
|
override fun tryOpenUsbEuiccChannel(usbDevice: UsbDevice, usbInterface: UsbInterface): EuiccChannel? {
|
||||||
ccidCtx: UsbCcidContext,
|
val (bulkIn, bulkOut) = usbInterface.getIoEndpoints()
|
||||||
isdrAid: ByteArray
|
if (bulkIn == null || bulkOut == null) return null
|
||||||
): EuiccChannel? {
|
val conn = usbManager.openDevice(usbDevice) ?: return null
|
||||||
try {
|
if (!conn.claimInterface(usbInterface, true)) return null
|
||||||
return EuiccChannelImpl(
|
return EuiccChannel(
|
||||||
context.getString(R.string.usb),
|
FakeUiccPortInfoCompat(FakeUiccCardInfoCompat(EuiccChannelManager.USB_CHANNEL_ID)),
|
||||||
FakeUiccPortInfoCompat(FakeUiccCardInfoCompat(EuiccChannelManager.USB_CHANNEL_ID)),
|
UsbApduInterface(conn, bulkIn, bulkOut)
|
||||||
intrinsicChannelName = ccidCtx.productName,
|
)
|
||||||
UsbApduInterface(
|
|
||||||
ccidCtx
|
|
||||||
),
|
|
||||||
isdrAid,
|
|
||||||
context.preferenceRepository.verboseLoggingFlow,
|
|
||||||
context.preferenceRepository.ignoreTLSCertificateFlow,
|
|
||||||
)
|
|
||||||
} catch (_: IllegalArgumentException) {
|
|
||||||
// Failed
|
|
||||||
Log.w(
|
|
||||||
DefaultEuiccChannelManager.TAG,
|
|
||||||
"USB APDU interface unavailable for ISD-R AID: ${isdrAid.encodeHex()}."
|
|
||||||
)
|
|
||||||
}
|
|
||||||
return null
|
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun cleanup() {
|
override fun cleanup() {
|
||||||
|
|
|
@ -5,18 +5,12 @@ import android.hardware.usb.UsbDevice
|
||||||
import android.hardware.usb.UsbManager
|
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.getSmartCardInterface
|
||||||
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.delay
|
||||||
import kotlinx.coroutines.flow.Flow
|
import kotlinx.coroutines.runBlocking
|
||||||
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
|
||||||
|
@ -51,24 +45,6 @@ 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) {
|
if (port.card.physicalSlotIndex == EuiccChannelManager.USB_CHANNEL_ID) {
|
||||||
|
@ -96,10 +72,9 @@ open class DefaultEuiccChannelManager(
|
||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
|
|
||||||
val channel =
|
val channel = euiccChannelFactory.tryOpenEuiccChannel(port) ?: return null
|
||||||
tryOpenChannelFirstValidAid { euiccChannelFactory.tryOpenEuiccChannel(port, it) }
|
|
||||||
|
|
||||||
if (channel != null) {
|
if (channel.valid) {
|
||||||
channelCache.add(channel)
|
channelCache.add(channel)
|
||||||
return channel
|
return channel
|
||||||
} else {
|
} else {
|
||||||
|
@ -107,29 +82,50 @@ open class DefaultEuiccChannelManager(
|
||||||
TAG,
|
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."
|
"Was able to open channel for logical slot ${port.logicalSlotIndex}, but the channel is invalid (cannot get eID or profiles without errors). This slot might be broken, aborting."
|
||||||
)
|
)
|
||||||
|
channel.close()
|
||||||
return null
|
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
|
if (logicalSlotId == EuiccChannelManager.USB_CHANNEL_ID) {
|
||||||
}
|
return@withContext usbChannel
|
||||||
|
}
|
||||||
|
|
||||||
for (card in uiccCards) {
|
for (card in uiccCards) {
|
||||||
for (port in card.ports) {
|
for (port in card.ports) {
|
||||||
if (port.logicalSlotIndex == logicalSlotId) {
|
if (port.logicalSlotIndex == logicalSlotId) {
|
||||||
return@withContext tryOpenEuiccChannel(port)
|
return@withContext tryOpenEuiccChannel(port)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
null
|
null
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private suspend fun findAllEuiccChannelsByPhysicalSlot(physicalSlotId: Int): List<EuiccChannel>? {
|
override fun findEuiccChannelByPhysicalSlotBlocking(physicalSlotId: Int): EuiccChannel? =
|
||||||
|
runBlocking {
|
||||||
|
withContext(Dispatchers.IO) {
|
||||||
|
if (physicalSlotId == EuiccChannelManager.USB_CHANNEL_ID) {
|
||||||
|
return@withContext usbChannel
|
||||||
|
}
|
||||||
|
|
||||||
|
for (card in uiccCards) {
|
||||||
|
if (card.physicalSlotIndex != physicalSlotId) continue
|
||||||
|
for (port in card.ports) {
|
||||||
|
tryOpenEuiccChannel(port)?.let { return@withContext it }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override suspend fun findAllEuiccChannelsByPhysicalSlot(physicalSlotId: Int): List<EuiccChannel>? {
|
||||||
if (physicalSlotId == EuiccChannelManager.USB_CHANNEL_ID) {
|
if (physicalSlotId == EuiccChannelManager.USB_CHANNEL_ID) {
|
||||||
return usbChannel?.let { listOf(it) }
|
return usbChannel?.let { listOf(it) }
|
||||||
}
|
}
|
||||||
|
@ -142,7 +138,12 @@ open class DefaultEuiccChannelManager(
|
||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
|
|
||||||
private suspend fun findEuiccChannelByPort(physicalSlotId: Int, portId: Int): EuiccChannel? =
|
override fun findAllEuiccChannelsByPhysicalSlotBlocking(physicalSlotId: Int): List<EuiccChannel>? =
|
||||||
|
runBlocking {
|
||||||
|
findAllEuiccChannelsByPhysicalSlot(physicalSlotId)
|
||||||
|
}
|
||||||
|
|
||||||
|
override suspend fun findEuiccChannelByPort(physicalSlotId: Int, portId: Int): EuiccChannel? =
|
||||||
withContext(Dispatchers.IO) {
|
withContext(Dispatchers.IO) {
|
||||||
if (physicalSlotId == EuiccChannelManager.USB_CHANNEL_ID) {
|
if (physicalSlotId == EuiccChannelManager.USB_CHANNEL_ID) {
|
||||||
return@withContext usbChannel
|
return@withContext usbChannel
|
||||||
|
@ -153,155 +154,72 @@ open class DefaultEuiccChannelManager(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
override suspend fun findFirstAvailablePort(physicalSlotId: Int): Int =
|
override fun findEuiccChannelByPortBlocking(physicalSlotId: Int, portId: Int): EuiccChannel? =
|
||||||
withContext(Dispatchers.IO) {
|
runBlocking {
|
||||||
if (physicalSlotId == EuiccChannelManager.USB_CHANNEL_ID) {
|
findEuiccChannelByPort(physicalSlotId, portId)
|
||||||
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) {
|
override suspend fun waitForReconnect(physicalSlotId: Int, portId: Int, timeoutMillis: Long) {
|
||||||
if (physicalSlotId == EuiccChannelManager.USB_CHANNEL_ID) {
|
if (physicalSlotId == EuiccChannelManager.USB_CHANNEL_ID) return
|
||||||
usbChannel?.close()
|
|
||||||
usbChannel = null
|
// If there is already a valid channel, we close it proactively
|
||||||
} else {
|
// Sometimes the current channel can linger on for a bit even after it should have become invalid
|
||||||
// If there is already a valid channel, we close it proactively
|
channelCache.find { it.slotId == physicalSlotId && it.portId == portId }?.apply {
|
||||||
// Sometimes the current channel can linger on for a bit even after it should have become invalid
|
if (valid) close()
|
||||||
channelCache.find { it.slotId == physicalSlotId && it.portId == portId }?.apply {
|
|
||||||
if (valid) close()
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
withTimeout(timeoutMillis) {
|
withTimeout(timeoutMillis) {
|
||||||
while (true) {
|
while (true) {
|
||||||
try {
|
try {
|
||||||
val channel = if (physicalSlotId == EuiccChannelManager.USB_CHANNEL_ID) {
|
// tryOpenEuiccChannel() will automatically dispose of invalid channels
|
||||||
// tryOpenUsbEuiccChannel() will always try to reopen the channel, even if
|
// and recreate when needed
|
||||||
// a USB channel already exists
|
val channel = findEuiccChannelByPortBlocking(physicalSlotId, portId)!!
|
||||||
tryOpenUsbEuiccChannel()
|
|
||||||
usbChannel!!
|
|
||||||
} else {
|
|
||||||
// tryOpenEuiccChannel() will automatically dispose of invalid channels
|
|
||||||
// and recreate when needed
|
|
||||||
findEuiccChannelByPort(physicalSlotId, portId)!!
|
|
||||||
}
|
|
||||||
check(channel.valid) { "Invalid channel" }
|
check(channel.valid) { "Invalid channel" }
|
||||||
break
|
break
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
Log.d(
|
Log.d(TAG, "Slot $physicalSlotId port $portId reconnect failure, retrying in 1000 ms")
|
||||||
TAG,
|
|
||||||
"Slot $physicalSlotId port $portId reconnect failure, retrying in 1000 ms"
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
delay(1000)
|
delay(1000)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun flowInternalEuiccPorts(): Flow<Pair<Int, Int>> = flow {
|
override suspend fun enumerateEuiccChannels(): List<EuiccChannel> =
|
||||||
uiccCards.forEach { info ->
|
withContext(Dispatchers.IO) {
|
||||||
info.ports.forEach { port ->
|
uiccCards.flatMap { info ->
|
||||||
tryOpenEuiccChannel(port)?.also {
|
info.ports.mapNotNull { port ->
|
||||||
Log.d(
|
tryOpenEuiccChannel(port)?.also {
|
||||||
TAG,
|
Log.d(
|
||||||
"Found eUICC on slot ${info.physicalSlotIndex} port ${port.portIndex}"
|
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>> =
|
override suspend fun enumerateUsbEuiccChannel(): Pair<UsbDevice?, EuiccChannel?> =
|
||||||
merge(flowInternalEuiccPorts(), flow {
|
|
||||||
if (tryOpenUsbEuiccChannel().second) {
|
|
||||||
emit(Pair(EuiccChannelManager.USB_CHANNEL_ID, 0))
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
override suspend fun tryOpenUsbEuiccChannel(): Pair<UsbDevice?, Boolean> =
|
|
||||||
withContext(Dispatchers.IO) {
|
withContext(Dispatchers.IO) {
|
||||||
usbManager.deviceList.values.forEach { device ->
|
usbManager.deviceList.values.forEach { device ->
|
||||||
Log.i(TAG, "Scanning USB device ${device.deviceId}:${device.vendorId}")
|
Log.i(TAG, "Scanning USB device ${device.deviceId}:${device.vendorId}")
|
||||||
val iface = device.interfaces.smartCard ?: return@forEach
|
val iface = device.getSmartCardInterface() ?: return@forEach
|
||||||
// If we don't have permission, tell UI code that we found a candidate device, but we
|
// 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
|
// need permission to be able to do anything with it
|
||||||
if (!usbManager.hasPermission(device)) return@withContext Pair(device, false)
|
if (!usbManager.hasPermission(device)) return@withContext Pair(device, null)
|
||||||
Log.i(
|
Log.i(TAG, "Found CCID interface on ${device.deviceId}:${device.vendorId}, and has permission; trying to open channel")
|
||||||
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 {
|
try {
|
||||||
val channel = tryOpenChannelFirstValidAid {
|
val channel = euiccChannelFactory.tryOpenUsbEuiccChannel(device, iface)
|
||||||
euiccChannelFactory.tryOpenUsbEuiccChannel(ccidCtx, it)
|
|
||||||
}
|
|
||||||
if (channel != null && channel.lpa.valid) {
|
if (channel != null && channel.lpa.valid) {
|
||||||
ccidCtx.allowDisconnect = true
|
|
||||||
usbChannel = channel
|
usbChannel = channel
|
||||||
return@withContext Pair(device, true)
|
return@withContext Pair(device, channel)
|
||||||
}
|
}
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
// Ignored -- skip forward
|
// Ignored -- skip forward
|
||||||
e.printStackTrace()
|
e.printStackTrace()
|
||||||
}
|
}
|
||||||
|
Log.i(TAG, "No valid eUICC channel found on USB device ${device.deviceId}:${device.vendorId}")
|
||||||
ccidCtx.allowDisconnect = true
|
|
||||||
ccidCtx.disconnect()
|
|
||||||
|
|
||||||
Log.i(
|
|
||||||
TAG,
|
|
||||||
"No valid eUICC channel found on USB device ${device.deviceId}:${device.vendorId}"
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
return@withContext Pair(null, false)
|
return@withContext Pair(null, null)
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun invalidate() {
|
override fun invalidate() {
|
||||||
|
|
|
@ -3,41 +3,21 @@ 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.ApduInterface
|
||||||
import net.typeblog.lpac_jni.LocalProfileAssistant
|
import net.typeblog.lpac_jni.LocalProfileAssistant
|
||||||
|
import net.typeblog.lpac_jni.impl.HttpInterfaceImpl
|
||||||
|
import net.typeblog.lpac_jni.impl.LocalProfileAssistantImpl
|
||||||
|
|
||||||
interface EuiccChannel {
|
class EuiccChannel(
|
||||||
val type: String
|
val port: UiccPortInfoCompat,
|
||||||
|
apduInterface: ApduInterface,
|
||||||
|
) {
|
||||||
|
val slotId = port.card.physicalSlotIndex // PHYSICAL slot
|
||||||
|
val logicalSlotId = port.logicalSlotIndex
|
||||||
|
val portId = port.portIndex
|
||||||
|
|
||||||
val port: UiccPortInfoCompat
|
val lpa: LocalProfileAssistant = LocalProfileAssistantImpl(apduInterface, HttpInterfaceImpl())
|
||||||
|
|
||||||
val slotId: Int // PHYSICAL slot
|
|
||||||
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,15 @@
|
||||||
package im.angry.openeuicc.core
|
package im.angry.openeuicc.core
|
||||||
|
|
||||||
import im.angry.openeuicc.core.usb.UsbCcidContext
|
import android.hardware.usb.UsbDevice
|
||||||
|
import android.hardware.usb.UsbInterface
|
||||||
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(
|
fun tryOpenUsbEuiccChannel(usbDevice: UsbDevice, usbInterface: UsbInterface): EuiccChannel?
|
||||||
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,7 +1,6 @@
|
||||||
package im.angry.openeuicc.core
|
package im.angry.openeuicc.core
|
||||||
|
|
||||||
import android.hardware.usb.UsbDevice
|
import android.hardware.usb.UsbDevice
|
||||||
import kotlinx.coroutines.flow.Flow
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* EuiccChannelManager holds references to, and manages the lifecycles of, individual
|
* EuiccChannelManager holds references to, and manages the lifecycles of, individual
|
||||||
|
@ -19,35 +18,19 @@ interface EuiccChannelManager {
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Scan all possible _device internal_ sources for EuiccChannels, as a flow, return their physical
|
* Scan all possible _device internal_ sources for EuiccChannels, return them and have all
|
||||||
* (slotId, portId) and have all scanned channels cached; these channels will remain open
|
* scanned channels cached; these channels will remain open for the entire lifetime of
|
||||||
* for the entire lifetime of this EuiccChannelManager object, unless disconnected externally
|
* this EuiccChannelManager object, unless disconnected externally or invalidate()'d
|
||||||
* or invalidate()'d.
|
|
||||||
*
|
|
||||||
* To obtain a temporary reference to a EuiccChannel, use `withEuiccChannel()`.
|
|
||||||
*/
|
*/
|
||||||
fun flowInternalEuiccPorts(): Flow<Pair<Int, Int>>
|
suspend fun enumerateEuiccChannels(): List<EuiccChannel>
|
||||||
|
|
||||||
/**
|
|
||||||
* Same as flowInternalEuiccPorts(), except that this includes non-device internal eUICC chips
|
|
||||||
* as well. Namely, this includes the USB reader.
|
|
||||||
*
|
|
||||||
* Non-internal readers will only be included if they have been opened properly, i.e. with permissions
|
|
||||||
* granted by the user.
|
|
||||||
*/
|
|
||||||
fun flowAllOpenEuiccPorts(): Flow<Pair<Int, Int>>
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Scan all possible USB devices for CCID readers that may contain eUICC cards.
|
* Scan all possible USB devices for CCID readers that may contain eUICC cards.
|
||||||
* If found, try to open it for access, and add it to the internal EuiccChannel cache
|
* If found, try to open it for access, and add it to the internal EuiccChannel cache
|
||||||
* as a "port" with id 99. When user interaction is required to obtain permission
|
* as a "port" with id 99. When user interaction is required to obtain permission
|
||||||
* to interact with the device, the second return value will be false.
|
* to interact with the device, the second return value (EuiccChannel) will be null.
|
||||||
*
|
|
||||||
* 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>
|
suspend fun enumerateUsbEuiccChannel(): Pair<UsbDevice?, EuiccChannel?>
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Wait for a slot + port to reconnect (i.e. become valid again)
|
* Wait for a slot + port to reconnect (i.e. become valid again)
|
||||||
|
@ -57,40 +40,29 @@ interface EuiccChannelManager {
|
||||||
suspend fun waitForReconnect(physicalSlotId: Int, portId: Int, timeoutMillis: Long = 1000)
|
suspend fun waitForReconnect(physicalSlotId: Int, portId: Int, timeoutMillis: Long = 1000)
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Returns the first mapped & available port ID for a physical slot, or -1 if
|
* Returns the EuiccChannel corresponding to a **logical** slot
|
||||||
* not found.
|
|
||||||
*/
|
*/
|
||||||
suspend fun findFirstAvailablePort(physicalSlotId: Int): Int
|
fun findEuiccChannelBySlotBlocking(logicalSlotId: Int): EuiccChannel?
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Returns all mapped & available port IDs for a physical slot.
|
* Returns the first EuiccChannel corresponding to a **physical** slot
|
||||||
|
* If the physical slot supports MEP and has multiple ports, it is undefined
|
||||||
|
* which of the two channels will be returned.
|
||||||
*/
|
*/
|
||||||
suspend fun findAvailablePorts(physicalSlotId: Int): List<Int>
|
fun findEuiccChannelByPhysicalSlotBlocking(physicalSlotId: Int): EuiccChannel?
|
||||||
|
|
||||||
class EuiccChannelNotFoundException: Exception("EuiccChannel not found")
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Find a EuiccChannel by its slot and port, then run a callback with a reference to it.
|
* Returns all EuiccChannels corresponding to a **physical** slot
|
||||||
* The reference is not supposed to be held outside of the callback. This is enforced via
|
* Multiple channels are possible in the case of MEP
|
||||||
* 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(
|
suspend fun findAllEuiccChannelsByPhysicalSlot(physicalSlotId: Int): List<EuiccChannel>?
|
||||||
physicalSlotId: Int,
|
fun findAllEuiccChannelsByPhysicalSlotBlocking(physicalSlotId: Int): List<EuiccChannel>?
|
||||||
portId: Int,
|
|
||||||
fn: suspend (EuiccChannel) -> R
|
|
||||||
): R
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Same as withEuiccChannel(Int, Int, (EuiccChannel) -> R) but instead uses logical slot ID
|
* Returns the EuiccChannel corresponding to a **physical** slot and a port ID
|
||||||
*/
|
*/
|
||||||
suspend fun <R> withEuiccChannel(
|
suspend fun findEuiccChannelByPort(physicalSlotId: Int, portId: Int): EuiccChannel?
|
||||||
logicalSlotId: Int,
|
fun findEuiccChannelByPortBlocking(physicalSlotId: Int, portId: Int): EuiccChannel?
|
||||||
fn: suspend (EuiccChannel) -> R
|
|
||||||
): R
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Invalidate all EuiccChannels previously cached by this Manager
|
* Invalidate all EuiccChannels previously cached by this Manager
|
||||||
|
@ -102,7 +74,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,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,33 +3,19 @@ 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
|
|
||||||
|
|
||||||
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
|
override val valid: Boolean
|
||||||
get() = service.isConnected && (this::session.isInitialized && !session.isClosed)
|
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,26 @@ 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
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
|
@ -1,41 +1,49 @@
|
||||||
package im.angry.openeuicc.core.usb
|
package im.angry.openeuicc.core.usb
|
||||||
|
|
||||||
|
import android.hardware.usb.UsbDeviceConnection
|
||||||
|
import android.hardware.usb.UsbEndpoint
|
||||||
import android.util.Log
|
import android.util.Log
|
||||||
import im.angry.openeuicc.core.ApduInterfaceAtrProvider
|
|
||||||
import im.angry.openeuicc.util.*
|
import im.angry.openeuicc.util.*
|
||||||
import net.typeblog.lpac_jni.ApduInterface
|
import net.typeblog.lpac_jni.ApduInterface
|
||||||
|
|
||||||
class UsbApduInterface(
|
class UsbApduInterface(
|
||||||
private val ccidCtx: UsbCcidContext
|
private val conn: UsbDeviceConnection,
|
||||||
) : ApduInterface, ApduInterfaceAtrProvider {
|
private val bulkIn: UsbEndpoint,
|
||||||
|
private val bulkOut: UsbEndpoint
|
||||||
|
): ApduInterface {
|
||||||
companion object {
|
companion object {
|
||||||
private const val TAG = "UsbApduInterface"
|
private const val TAG = "UsbApduInterface"
|
||||||
}
|
}
|
||||||
|
|
||||||
override val atr: ByteArray?
|
private lateinit var ccidDescription: UsbCcidDescription
|
||||||
get() = ccidCtx.atr
|
private lateinit var transceiver: UsbCcidTransceiver
|
||||||
|
|
||||||
override val valid: Boolean
|
private var channelId = -1
|
||||||
get() = channels.isNotEmpty()
|
|
||||||
|
|
||||||
private var channels = mutableSetOf<Int>()
|
|
||||||
|
|
||||||
override fun connect() {
|
override fun connect() {
|
||||||
ccidCtx.connect()
|
ccidDescription = UsbCcidDescription.fromRawDescriptors(conn.rawDescriptors)!!
|
||||||
|
|
||||||
// Send Terminal Capabilities
|
if (!ccidDescription.hasT0Protocol) {
|
||||||
// Specs: ETSI TS 102 221 v15.0.0 - 11.1.19 TERMINAL CAPABILITY
|
throw IllegalArgumentException("Unsupported card reader; T=0 support is required")
|
||||||
val terminalCapabilities = buildCmd(
|
}
|
||||||
0x80.toByte(), 0xaa.toByte(), 0x00, 0x00,
|
|
||||||
"A9088100820101830107".decodeHex(),
|
transceiver = UsbCcidTransceiver(conn, bulkIn, bulkOut, ccidDescription)
|
||||||
le = null,
|
|
||||||
)
|
try {
|
||||||
transmitApduByChannel(terminalCapabilities, 0)
|
transceiver.iccPowerOn()
|
||||||
|
} catch (e: Exception) {
|
||||||
|
e.printStackTrace()
|
||||||
|
throw e
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun disconnect() = ccidCtx.disconnect()
|
override fun disconnect() {
|
||||||
|
conn.close()
|
||||||
|
}
|
||||||
|
|
||||||
override fun logicalChannelOpen(aid: ByteArray): Int {
|
override fun logicalChannelOpen(aid: ByteArray): Int {
|
||||||
|
check(channelId == -1) { "Logical channel already opened" }
|
||||||
|
|
||||||
// OPEN LOGICAL CHANNEL
|
// OPEN LOGICAL CHANNEL
|
||||||
val req = manageChannelCmd(true, 0)
|
val req = manageChannelCmd(true, 0)
|
||||||
|
|
||||||
|
@ -51,7 +59,7 @@ class UsbApduInterface(
|
||||||
return -1
|
return -1
|
||||||
}
|
}
|
||||||
|
|
||||||
val channelId = resp[0].toInt()
|
channelId = resp[0].toInt()
|
||||||
Log.d(TAG, "channelId = $channelId")
|
Log.d(TAG, "channelId = $channelId")
|
||||||
|
|
||||||
// Then, select AID
|
// Then, select AID
|
||||||
|
@ -63,32 +71,32 @@ class UsbApduInterface(
|
||||||
return -1
|
return -1
|
||||||
}
|
}
|
||||||
|
|
||||||
channels.add(channelId)
|
|
||||||
|
|
||||||
return channelId
|
return channelId
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun logicalChannelClose(handle: Int) {
|
override fun logicalChannelClose(handle: Int) {
|
||||||
check(channels.contains(handle)) {
|
check(handle == channelId) { "Logical channel ID mismatch" }
|
||||||
"Invalid logical channel handle $handle"
|
check(channelId != -1) { "Logical channel is not opened" }
|
||||||
}
|
|
||||||
// CLOSE LOGICAL CHANNEL
|
// CLOSE LOGICAL CHANNEL
|
||||||
val req = manageChannelCmd(false, handle.toByte())
|
val req = manageChannelCmd(false, channelId.toByte())
|
||||||
val resp = transmitApduByChannel(req, handle.toByte())
|
val resp = transmitApduByChannel(req, channelId.toByte())
|
||||||
|
|
||||||
if (!isSuccessResponse(resp)) {
|
if (!isSuccessResponse(resp)) {
|
||||||
Log.d(TAG, "CLOSE LOGICAL CHANNEL failed: ${resp.encodeHex()}")
|
Log.d(TAG, "CLOSE LOGICAL CHANNEL failed: ${resp.encodeHex()}")
|
||||||
}
|
}
|
||||||
channels.remove(handle)
|
|
||||||
|
channelId = -1
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun transmit(handle: Int, tx: ByteArray): ByteArray {
|
override fun transmit(tx: ByteArray): ByteArray {
|
||||||
check(channels.contains(handle)) {
|
check(channelId != -1) { "Logical channel is not opened" }
|
||||||
"Invalid logical channel handle $handle"
|
return transmitApduByChannel(tx, channelId.toByte())
|
||||||
}
|
|
||||||
return transmitApduByChannel(tx, handle.toByte())
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
override val valid: Boolean
|
||||||
|
get() = channelId != -1
|
||||||
|
|
||||||
private fun isSuccessResponse(resp: ByteArray): Boolean =
|
private fun isSuccessResponse(resp: ByteArray): Boolean =
|
||||||
resp.size >= 2 && resp[resp.size - 2] == 0x90.toByte() && resp[resp.size - 1] == 0x00.toByte()
|
resp.size >= 2 && resp[resp.size - 2] == 0x90.toByte() && resp[resp.size - 1] == 0x00.toByte()
|
||||||
|
|
||||||
|
@ -122,7 +130,7 @@ class UsbApduInterface(
|
||||||
// OR the channel mask into the CLA byte
|
// OR the channel mask into the CLA byte
|
||||||
realTx[0] = ((realTx[0].toInt() and 0xFC) or channel.toInt()).toByte()
|
realTx[0] = ((realTx[0].toInt() and 0xFC) or channel.toInt()).toByte()
|
||||||
|
|
||||||
var resp = ccidCtx.transceiver.sendXfrBlock(realTx).data!!
|
var resp = transceiver.sendXfrBlock(realTx).data!!
|
||||||
|
|
||||||
if (resp.size < 2) throw RuntimeException("APDU response smaller than 2 (sw1 + sw2)!")
|
if (resp.size < 2) throw RuntimeException("APDU response smaller than 2 (sw1 + sw2)!")
|
||||||
|
|
||||||
|
@ -133,7 +141,7 @@ class UsbApduInterface(
|
||||||
// 0x6C = wrong le
|
// 0x6C = wrong le
|
||||||
// so we fix the le field here
|
// so we fix the le field here
|
||||||
realTx[realTx.size - 1] = resp[resp.size - 1]
|
realTx[realTx.size - 1] = resp[resp.size - 1]
|
||||||
resp = ccidCtx.transceiver.sendXfrBlock(realTx).data!!
|
resp = transceiver.sendXfrBlock(realTx).data!!
|
||||||
} else if (sw1 == 0x61) {
|
} else if (sw1 == 0x61) {
|
||||||
// 0x61 = X bytes available
|
// 0x61 = X bytes available
|
||||||
// continue reading by GET RESPONSE
|
// continue reading by GET RESPONSE
|
||||||
|
@ -143,7 +151,7 @@ class UsbApduInterface(
|
||||||
realTx[0], 0xC0.toByte(), 0x00, 0x00, sw2.toByte()
|
realTx[0], 0xC0.toByte(), 0x00, 0x00, sw2.toByte()
|
||||||
)
|
)
|
||||||
|
|
||||||
val tmp = ccidCtx.transceiver.sendXfrBlock(getResponseCmd).data!!
|
val tmp = transceiver.sendXfrBlock(getResponseCmd).data!!
|
||||||
|
|
||||||
resp = resp.sliceArray(0 until (resp.size - 2)) + tmp
|
resp = resp.sliceArray(0 until (resp.size - 2)) + tmp
|
||||||
|
|
||||||
|
|
|
@ -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
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -20,12 +20,12 @@ data class UsbCcidDescription(
|
||||||
|
|
||||||
private const val FEATURE_EXCHANGE_LEVEL_TPDU = 0x10000
|
private const val FEATURE_EXCHANGE_LEVEL_TPDU = 0x10000
|
||||||
private const val FEATURE_EXCHANGE_LEVEL_SHORT_APDU = 0x20000
|
private const val FEATURE_EXCHANGE_LEVEL_SHORT_APDU = 0x20000
|
||||||
private const val FEATURE_EXCHANGE_LEVEL_EXTENDED_APDU = 0x40000
|
private const val FEATURE_EXCHAGE_LEVEL_EXTENDED_APDU = 0x40000
|
||||||
|
|
||||||
// bVoltageSupport Masks
|
// bVoltageSupport Masks
|
||||||
private const val VOLTAGE_5V0: Byte = 1
|
private const val VOLTAGE_5V: Byte = 1
|
||||||
private const val VOLTAGE_3V0: Byte = 2
|
private const val VOLTAGE_3V: Byte = 2
|
||||||
private const val VOLTAGE_1V8: Byte = 4
|
private const val VOLTAGE_1_8V: Byte = 4
|
||||||
|
|
||||||
private const val SLOT_OFFSET = 4
|
private const val SLOT_OFFSET = 4
|
||||||
private const val FEATURES_OFFSET = 40
|
private const val FEATURES_OFFSET = 40
|
||||||
|
@ -71,24 +71,31 @@ data class UsbCcidDescription(
|
||||||
}
|
}
|
||||||
|
|
||||||
enum class Voltage(powerOnValue: Int, mask: Int) {
|
enum class Voltage(powerOnValue: Int, mask: Int) {
|
||||||
// @formatter:off
|
AUTO(0, 0), _5V(1, VOLTAGE_5V.toInt()), _3V(2, VOLTAGE_3V.toInt()), _1_8V(
|
||||||
AUTO(0, 0),
|
3,
|
||||||
V50(1, VOLTAGE_5V0.toInt()),
|
VOLTAGE_1_8V.toInt()
|
||||||
V30(2, VOLTAGE_3V0.toInt()),
|
);
|
||||||
V18(3, VOLTAGE_1V8.toInt());
|
|
||||||
// @formatter:on
|
|
||||||
|
|
||||||
val mask = powerOnValue.toByte()
|
val mask = powerOnValue.toByte()
|
||||||
val powerOnValue = mask.toByte()
|
val powerOnValue = mask.toByte()
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun hasFeature(feature: Int) = (dwFeatures and feature) != 0
|
private fun hasFeature(feature: Int): Boolean =
|
||||||
|
(dwFeatures and feature) != 0
|
||||||
|
|
||||||
val voltages: List<Voltage>
|
val voltages: Array<Voltage>
|
||||||
get() {
|
get() =
|
||||||
if (hasFeature(FEATURE_AUTOMATIC_VOLTAGE)) return listOf(Voltage.AUTO)
|
if (hasFeature(FEATURE_AUTOMATIC_VOLTAGE)) {
|
||||||
return Voltage.entries.filter { (it.mask.toInt() and bVoltageSupport.toInt()) != 0 }
|
arrayOf(Voltage.AUTO)
|
||||||
}
|
} else {
|
||||||
|
Voltage.values().mapNotNull {
|
||||||
|
if ((it.mask.toInt() and bVoltageSupport.toInt()) != 0) {
|
||||||
|
it
|
||||||
|
} else {
|
||||||
|
null
|
||||||
|
}
|
||||||
|
}.toTypedArray()
|
||||||
|
}
|
||||||
|
|
||||||
val hasAutomaticPps: Boolean
|
val hasAutomaticPps: Boolean
|
||||||
get() = hasFeature(FEATURE_AUTOMATIC_PPS)
|
get() = hasFeature(FEATURE_AUTOMATIC_PPS)
|
||||||
|
|
|
@ -5,9 +5,6 @@ import android.hardware.usb.UsbEndpoint
|
||||||
import android.os.SystemClock
|
import android.os.SystemClock
|
||||||
import android.util.Log
|
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 java.nio.ByteBuffer
|
import java.nio.ByteBuffer
|
||||||
import java.nio.ByteOrder
|
import java.nio.ByteOrder
|
||||||
|
|
||||||
|
@ -21,8 +18,7 @@ class UsbCcidTransceiver(
|
||||||
private val usbConnection: UsbDeviceConnection,
|
private val usbConnection: UsbDeviceConnection,
|
||||||
private val usbBulkIn: UsbEndpoint,
|
private val usbBulkIn: UsbEndpoint,
|
||||||
private val usbBulkOut: UsbEndpoint,
|
private val usbBulkOut: UsbEndpoint,
|
||||||
private val usbCcidDescription: UsbCcidDescription,
|
private val usbCcidDescription: UsbCcidDescription
|
||||||
private val verboseLoggingFlow: Flow<Boolean>
|
|
||||||
) {
|
) {
|
||||||
companion object {
|
companion object {
|
||||||
private const val TAG = "UsbCcidTransceiver"
|
private const val TAG = "UsbCcidTransceiver"
|
||||||
|
@ -95,7 +91,6 @@ class UsbCcidTransceiver(
|
||||||
data class UsbCcidErrorException(val msg: String, val errorResponse: CcidDataBlock) :
|
data class UsbCcidErrorException(val msg: String, val errorResponse: CcidDataBlock) :
|
||||||
Exception(msg)
|
Exception(msg)
|
||||||
|
|
||||||
@Suppress("ArrayInDataClass")
|
|
||||||
data class CcidDataBlock(
|
data class CcidDataBlock(
|
||||||
val dwLength: Int,
|
val dwLength: Int,
|
||||||
val bSlot: Byte,
|
val bSlot: Byte,
|
||||||
|
@ -183,27 +178,30 @@ class UsbCcidTransceiver(
|
||||||
readBytes = usbConnection.bulkTransfer(
|
readBytes = usbConnection.bulkTransfer(
|
||||||
usbBulkIn, inputBuffer, inputBuffer.size, DEVICE_COMMUNICATE_TIMEOUT_MILLIS
|
usbBulkIn, inputBuffer, inputBuffer.size, DEVICE_COMMUNICATE_TIMEOUT_MILLIS
|
||||||
)
|
)
|
||||||
if (runBlocking { verboseLoggingFlow.first() }) {
|
Log.d(TAG, "Received " + readBytes + " bytes: " + inputBuffer.encodeHex())
|
||||||
Log.d(TAG, "Received $readBytes bytes: ${inputBuffer.encodeHex()}")
|
|
||||||
}
|
|
||||||
} while (readBytes <= 0 && attempts-- > 0)
|
} while (readBytes <= 0 && attempts-- > 0)
|
||||||
if (readBytes < CCID_HEADER_LENGTH) {
|
if (readBytes < CCID_HEADER_LENGTH) {
|
||||||
throw UsbTransportException("USB-CCID error - failed to receive CCID header")
|
throw UsbTransportException("USB-CCID error - failed to receive CCID header")
|
||||||
}
|
}
|
||||||
if (inputBuffer[0] != MESSAGE_TYPE_RDR_TO_PC_DATA_BLOCK.toByte()) {
|
if (inputBuffer[0] != MESSAGE_TYPE_RDR_TO_PC_DATA_BLOCK.toByte()) {
|
||||||
throw UsbTransportException(buildString {
|
if (expectedSequenceNumber != inputBuffer[6]) {
|
||||||
append("USB-CCID error - bad CCID header")
|
throw UsbTransportException(
|
||||||
append(", type ")
|
((("USB-CCID error - bad CCID header, type " + inputBuffer[0]) + " (expected " +
|
||||||
append("%d (expected %d)".format(inputBuffer[0], MESSAGE_TYPE_RDR_TO_PC_DATA_BLOCK))
|
MESSAGE_TYPE_RDR_TO_PC_DATA_BLOCK) + "), sequence number " + inputBuffer[6]
|
||||||
if (expectedSequenceNumber != inputBuffer[6]) {
|
) + " (expected " +
|
||||||
append(", sequence number ")
|
expectedSequenceNumber + ")"
|
||||||
append("%d (expected %d)".format(inputBuffer[6], expectedSequenceNumber))
|
)
|
||||||
}
|
}
|
||||||
})
|
throw UsbTransportException(
|
||||||
|
"USB-CCID error - bad CCID header type " + inputBuffer[0]
|
||||||
|
)
|
||||||
}
|
}
|
||||||
var result = CcidDataBlock.parseHeaderFromBytes(inputBuffer)
|
var result = CcidDataBlock.parseHeaderFromBytes(inputBuffer)
|
||||||
if (expectedSequenceNumber != result.bSeq) {
|
if (expectedSequenceNumber != result.bSeq) {
|
||||||
throw UsbTransportException("USB-CCID error - expected sequence number $expectedSequenceNumber, got $result")
|
throw UsbTransportException(
|
||||||
|
("USB-CCID error - expected sequence number " +
|
||||||
|
expectedSequenceNumber + ", got " + result)
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
val dataBuffer = ByteArray(result.dwLength)
|
val dataBuffer = ByteArray(result.dwLength)
|
||||||
|
@ -214,7 +212,9 @@ class UsbCcidTransceiver(
|
||||||
usbBulkIn, inputBuffer, inputBuffer.size, DEVICE_COMMUNICATE_TIMEOUT_MILLIS
|
usbBulkIn, inputBuffer, inputBuffer.size, DEVICE_COMMUNICATE_TIMEOUT_MILLIS
|
||||||
)
|
)
|
||||||
if (readBytes < 0) {
|
if (readBytes < 0) {
|
||||||
throw UsbTransportException("USB error - failed reading response data! Header: $result")
|
throw UsbTransportException(
|
||||||
|
"USB error - failed reading response data! Header: $result"
|
||||||
|
)
|
||||||
}
|
}
|
||||||
System.arraycopy(inputBuffer, 0, dataBuffer, bufferedBytes, readBytes)
|
System.arraycopy(inputBuffer, 0, dataBuffer, bufferedBytes, readBytes)
|
||||||
bufferedBytes += readBytes
|
bufferedBytes += readBytes
|
||||||
|
@ -279,7 +279,7 @@ class UsbCcidTransceiver(
|
||||||
}
|
}
|
||||||
val ccidDataBlock = receiveDataBlock(sequenceNumber)
|
val ccidDataBlock = receiveDataBlock(sequenceNumber)
|
||||||
val elapsedTime = SystemClock.elapsedRealtime() - startTime
|
val elapsedTime = SystemClock.elapsedRealtime() - startTime
|
||||||
Log.d(TAG, "USB XferBlock call took ${elapsedTime}ms")
|
Log.d(TAG, "USB XferBlock call took " + elapsedTime + "ms")
|
||||||
return ccidDataBlock
|
return ccidDataBlock
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -287,13 +287,13 @@ class UsbCcidTransceiver(
|
||||||
val startTime = SystemClock.elapsedRealtime()
|
val startTime = SystemClock.elapsedRealtime()
|
||||||
skipAvailableInput()
|
skipAvailableInput()
|
||||||
var response: CcidDataBlock? = null
|
var response: CcidDataBlock? = null
|
||||||
for (voltage in usbCcidDescription.voltages) {
|
for (v in usbCcidDescription.voltages) {
|
||||||
Log.v(TAG, "CCID: attempting to power on with voltage $voltage")
|
Log.v(TAG, "CCID: attempting to power on with voltage $v")
|
||||||
response = try {
|
response = try {
|
||||||
iccPowerOnVoltage(voltage.powerOnValue)
|
iccPowerOnVoltage(v.powerOnValue)
|
||||||
} catch (e: UsbCcidErrorException) {
|
} catch (e: UsbCcidErrorException) {
|
||||||
if (e.errorResponse.bError.toInt() == 7) { // Power select error
|
if (e.errorResponse.bError.toInt() == 7) { // Power select error
|
||||||
Log.v(TAG, "CCID: failed to power on with voltage $voltage")
|
Log.v(TAG, "CCID: failed to power on with voltage $v")
|
||||||
iccPowerOff()
|
iccPowerOff()
|
||||||
Log.v(TAG, "CCID: powered off")
|
Log.v(TAG, "CCID: powered off")
|
||||||
continue
|
continue
|
||||||
|
@ -308,11 +308,8 @@ class UsbCcidTransceiver(
|
||||||
val elapsedTime = SystemClock.elapsedRealtime() - startTime
|
val elapsedTime = SystemClock.elapsedRealtime() - startTime
|
||||||
Log.d(
|
Log.d(
|
||||||
TAG,
|
TAG,
|
||||||
buildString {
|
"Usb transport connected, took " + elapsedTime + "ms, ATR=" +
|
||||||
append("Usb transport connected")
|
response.data?.encodeHex()
|
||||||
append(", took ", elapsedTime, "ms")
|
|
||||||
append(", ATR=", response.data?.encodeHex())
|
|
||||||
}
|
|
||||||
)
|
)
|
||||||
return response
|
return response
|
||||||
}
|
}
|
||||||
|
|
|
@ -6,22 +6,31 @@ import android.hardware.usb.UsbDevice
|
||||||
import android.hardware.usb.UsbEndpoint
|
import android.hardware.usb.UsbEndpoint
|
||||||
import android.hardware.usb.UsbInterface
|
import android.hardware.usb.UsbInterface
|
||||||
|
|
||||||
class UsbTransportException(message: String) : Exception(message)
|
class UsbTransportException(msg: String) : Exception(msg)
|
||||||
|
|
||||||
val UsbDevice.interfaces: Iterable<UsbInterface>
|
fun UsbInterface.getIoEndpoints(): Pair<UsbEndpoint?, UsbEndpoint?> {
|
||||||
get() = (0 until interfaceCount).map(::getInterface)
|
var bulkIn: UsbEndpoint? = null
|
||||||
|
var bulkOut: UsbEndpoint? = null
|
||||||
val Iterable<UsbInterface>.smartCard: UsbInterface?
|
for (i in 0 until endpointCount) {
|
||||||
get() = find { it.interfaceClass == UsbConstants.USB_CLASS_CSCID }
|
val endpoint = getEndpoint(i)
|
||||||
|
if (endpoint.type != UsbConstants.USB_ENDPOINT_XFER_BULK) {
|
||||||
val UsbInterface.endpoints: Iterable<UsbEndpoint>
|
continue
|
||||||
get() = (0 until endpointCount).map(::getEndpoint)
|
}
|
||||||
|
if (endpoint.direction == UsbConstants.USB_DIR_IN) {
|
||||||
val Iterable<UsbEndpoint>.bulkPair: Pair<UsbEndpoint?, UsbEndpoint?>
|
bulkIn = endpoint
|
||||||
get() {
|
} else if (endpoint.direction == UsbConstants.USB_DIR_OUT) {
|
||||||
val endpoints = filter { it.type == UsbConstants.USB_ENDPOINT_XFER_BULK }
|
bulkOut = endpoint
|
||||||
return Pair(
|
}
|
||||||
endpoints.find { it.direction == UsbConstants.USB_DIR_IN },
|
|
||||||
endpoints.find { it.direction == UsbConstants.USB_DIR_OUT },
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
return Pair(bulkIn, bulkOut)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun UsbDevice.getSmartCardInterface(): UsbInterface? {
|
||||||
|
for (i in 0 until interfaceCount) {
|
||||||
|
val anInterface = getInterface(i)
|
||||||
|
if (anInterface.interfaceClass == UsbConstants.USB_CLASS_CSCID) {
|
||||||
|
return anInterface
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null
|
||||||
|
}
|
|
@ -15,5 +15,4 @@ interface AppContainer {
|
||||||
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
|
|
||||||
}
|
|
|
@ -38,8 +38,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,13 @@
|
||||||
package im.angry.openeuicc.di
|
package im.angry.openeuicc.di
|
||||||
|
|
||||||
import androidx.fragment.app.Fragment
|
import androidx.fragment.app.Fragment
|
||||||
import androidx.preference.PreferenceFragmentCompat
|
import im.angry.openeuicc.core.EuiccChannel
|
||||||
import im.angry.openeuicc.ui.EuiccManagementFragment
|
import im.angry.openeuicc.ui.EuiccManagementFragment
|
||||||
import im.angry.openeuicc.ui.NoEuiccPlaceholderFragment
|
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 createNoEuiccPlaceholderFragment(): Fragment = NoEuiccPlaceholderFragment()
|
||||||
|
|
||||||
override fun createSettingsFragment(): Fragment = SettingsFragment()
|
|
||||||
}
|
}
|
|
@ -1,11 +1,10 @@
|
||||||
package im.angry.openeuicc.di
|
package im.angry.openeuicc.di
|
||||||
|
|
||||||
import androidx.fragment.app.Fragment
|
import androidx.fragment.app.Fragment
|
||||||
import androidx.preference.PreferenceFragmentCompat
|
import im.angry.openeuicc.core.EuiccChannel
|
||||||
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 createNoEuiccPlaceholderFragment(): Fragment
|
||||||
fun createSettingsFragment(): Fragment
|
|
||||||
}
|
}
|
|
@ -1,42 +1,11 @@
|
||||||
package im.angry.openeuicc.service
|
package im.angry.openeuicc.service
|
||||||
|
|
||||||
|
import android.app.Service
|
||||||
import android.content.Intent
|
import android.content.Intent
|
||||||
import android.content.pm.PackageManager
|
|
||||||
import android.os.Binder
|
import android.os.Binder
|
||||||
import android.os.IBinder
|
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.core.EuiccChannelManager
|
||||||
import im.angry.openeuicc.util.*
|
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.
|
* An Android Service wrapper for EuiccChannelManager.
|
||||||
|
@ -48,41 +17,8 @@ import net.typeblog.lpac_jni.ProfileDownloadCallback
|
||||||
* instance of EuiccChannelManager. UI components can keep being bound to this service for
|
* 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
|
* their entire lifecycles, since the whole purpose of them is to expose the current state
|
||||||
* to the user.
|
* 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 {
|
class EuiccChannelManagerService : Service(), 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() {
|
inner class LocalBinder : Binder() {
|
||||||
val service = this@EuiccChannelManagerService
|
val service = this@EuiccChannelManagerService
|
||||||
}
|
}
|
||||||
|
@ -92,436 +28,14 @@ class EuiccChannelManagerService : LifecycleService(), OpenEuiccContextMarker {
|
||||||
}
|
}
|
||||||
val euiccChannelManager: EuiccChannelManager by euiccChannelManagerDelegate
|
val euiccChannelManager: EuiccChannelManager by euiccChannelManagerDelegate
|
||||||
|
|
||||||
private val wakeLock: PowerManager.WakeLock by lazy {
|
override fun onBind(intent: Intent?): IBinder = LocalBinder()
|
||||||
(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() {
|
override fun onDestroy() {
|
||||||
super.onDestroy()
|
super.onDestroy()
|
||||||
|
// This is the whole reason of the existence of this service:
|
||||||
|
// we can clean up opened channels when no one is using them
|
||||||
if (euiccChannelManagerDelegate.isInitialized()) {
|
if (euiccChannelManagerDelegate.isInitialized()) {
|
||||||
euiccChannelManager.invalidate()
|
euiccChannelManager.invalidate()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
|
|
||||||
return super.onStartCommand(intent, flags, startId).also {
|
|
||||||
lifecycleScope.launch {
|
|
||||||
foregroundStarted.emit(Unit)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun ensureForegroundTaskNotificationChannel() {
|
|
||||||
val nm = NotificationManagerCompat.from(this)
|
|
||||||
if (nm.getNotificationChannelCompat(CHANNEL_ID) == null) {
|
|
||||||
val channel =
|
|
||||||
NotificationChannelCompat.Builder(
|
|
||||||
CHANNEL_ID,
|
|
||||||
NotificationManagerCompat.IMPORTANCE_LOW
|
|
||||||
)
|
|
||||||
.setName(getString(R.string.task_notification))
|
|
||||||
.setVibrationEnabled(false)
|
|
||||||
.build()
|
|
||||||
nm.createNotificationChannel(channel)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private suspend fun updateForegroundNotification(title: String, iconRes: Int) {
|
|
||||||
ensureForegroundTaskNotificationChannel()
|
|
||||||
|
|
||||||
val nm = NotificationManagerCompat.from(this)
|
|
||||||
val state = foregroundTaskState.value
|
|
||||||
|
|
||||||
if (state is ForegroundTaskState.InProgress) {
|
|
||||||
val notification = NotificationCompat.Builder(this, CHANNEL_ID)
|
|
||||||
.setContentTitle(title)
|
|
||||||
.setProgress(100, state.progress, state.progress == 0)
|
|
||||||
.setSmallIcon(iconRes)
|
|
||||||
.setPriority(NotificationCompat.PRIORITY_LOW)
|
|
||||||
.setOngoing(true)
|
|
||||||
.setOnlyAlertOnce(true)
|
|
||||||
.build()
|
|
||||||
|
|
||||||
if (state.progress == 0) {
|
|
||||||
startForeground(FOREGROUND_ID, notification)
|
|
||||||
} else if (checkSelfPermission(android.Manifest.permission.POST_NOTIFICATIONS) == PackageManager.PERMISSION_GRANTED) {
|
|
||||||
nm.notify(FOREGROUND_ID, notification)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Yield out so that the main looper can handle the notification event
|
|
||||||
// Without this yield, the notification sent above will not be shown in time.
|
|
||||||
yield()
|
|
||||||
} else {
|
|
||||||
stopForeground(STOP_FOREGROUND_REMOVE)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun postForegroundTaskFailureNotification(title: String) {
|
|
||||||
if (checkSelfPermission(android.Manifest.permission.POST_NOTIFICATIONS) != PackageManager.PERMISSION_GRANTED) {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
val notification = NotificationCompat.Builder(this, CHANNEL_ID)
|
|
||||||
.setContentTitle(title)
|
|
||||||
.setSmallIcon(R.drawable.ic_x_black)
|
|
||||||
.build()
|
|
||||||
NotificationManagerCompat.from(this).notify(TASK_FAILURE_ID, notification)
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Recover the subscriber to a foreground task that is recently launched.
|
|
||||||
*
|
|
||||||
* null if the task doesn't exist, or was launched too long ago.
|
|
||||||
*/
|
|
||||||
fun recoverForegroundTaskSubscriber(taskId: Long): ForegroundTaskSubscriberFlow? =
|
|
||||||
foregroundTaskSubscribers[taskId]?.let {
|
|
||||||
ForegroundTaskSubscriberFlow(taskId, it.applyCompletionTransform())
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Launch a potentially blocking foreground task in this service's lifecycle context.
|
|
||||||
* This function does not block, but returns a Flow that emits ForegroundTaskState
|
|
||||||
* updates associated with this task. The last update the returned flow will emit is
|
|
||||||
* always ForegroundTaskState.Done.
|
|
||||||
*
|
|
||||||
* The returned flow can only be subscribed to once even though the underlying implementation
|
|
||||||
* is a SharedFlow. This is due to the need to apply transformations so that the stream
|
|
||||||
* actually completes. In order to subscribe multiple times, use `recoverForegroundTaskSubscriber`
|
|
||||||
* to acquire another instance.
|
|
||||||
*
|
|
||||||
* The task closure is expected to update foregroundTaskState whenever appropriate.
|
|
||||||
* If a foreground task is already running, this function returns null.
|
|
||||||
*
|
|
||||||
* To wait for foreground tasks to be available, use waitForForegroundTask().
|
|
||||||
*
|
|
||||||
* The function will set the state back to Idle once it sees ForegroundTaskState.Done.
|
|
||||||
*/
|
|
||||||
private fun launchForegroundTask(
|
|
||||||
title: String,
|
|
||||||
failureTitle: String,
|
|
||||||
iconRes: Int,
|
|
||||||
task: suspend EuiccChannelManagerService.() -> Unit
|
|
||||||
): ForegroundTaskSubscriberFlow {
|
|
||||||
val taskID = System.currentTimeMillis()
|
|
||||||
|
|
||||||
// Atomically set the state to InProgress. If this returns true, we are
|
|
||||||
// the only task currently in progress.
|
|
||||||
if (!foregroundTaskState.compareAndSet(
|
|
||||||
ForegroundTaskState.Idle,
|
|
||||||
ForegroundTaskState.InProgress(0)
|
|
||||||
)
|
|
||||||
) {
|
|
||||||
return ForegroundTaskSubscriberFlow(
|
|
||||||
taskID,
|
|
||||||
flow { emit(ForegroundTaskState.Done(IllegalStateException("There are tasks currently running"))) })
|
|
||||||
}
|
|
||||||
|
|
||||||
lifecycleScope.launch(Dispatchers.Main) {
|
|
||||||
// Wait until our self-start command has succeeded.
|
|
||||||
// We can only call startForeground() after that
|
|
||||||
val res = withTimeoutOrNull(30 * 1000) {
|
|
||||||
foregroundStarted.first()
|
|
||||||
}
|
|
||||||
|
|
||||||
if (res == null) {
|
|
||||||
// The only case where the wait above could time out is if the subscriber
|
|
||||||
// to the flow is stuck. Or we failed to start foreground.
|
|
||||||
// In that case, we should just set our state back to Idle -- setting it
|
|
||||||
// to Done wouldn't help much because nothing is going to then set it Idle.
|
|
||||||
foregroundTaskState.value = ForegroundTaskState.Idle
|
|
||||||
return@launch
|
|
||||||
}
|
|
||||||
|
|
||||||
updateForegroundNotification(title, iconRes)
|
|
||||||
|
|
||||||
wakeLock.acquire(10 * 60 * 1000L /*10 minutes*/)
|
|
||||||
|
|
||||||
try {
|
|
||||||
withContext(Dispatchers.IO + NonCancellable) { // Any LPA-related task must always complete
|
|
||||||
this@EuiccChannelManagerService.task()
|
|
||||||
}
|
|
||||||
// This update will be sent by the subscriber (as shown below)
|
|
||||||
foregroundTaskState.value = ForegroundTaskState.Done(null)
|
|
||||||
} catch (t: Throwable) {
|
|
||||||
Log.e(TAG, "Foreground task encountered an error")
|
|
||||||
Log.e(TAG, Log.getStackTraceString(t))
|
|
||||||
foregroundTaskState.value = ForegroundTaskState.Done(t)
|
|
||||||
|
|
||||||
if (isActive) {
|
|
||||||
postForegroundTaskFailureNotification(failureTitle)
|
|
||||||
}
|
|
||||||
} finally {
|
|
||||||
wakeLock.release()
|
|
||||||
if (isActive) {
|
|
||||||
stopSelf()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// This is the flow we are going to return. We allow multiple subscribers by
|
|
||||||
// re-emitting state updates into this flow from another coroutine.
|
|
||||||
// replay = 2 ensures that we at least have 1 previous state whenever subscribed to.
|
|
||||||
// This is helpful when the task completed and is then re-subscribed to due to a
|
|
||||||
// UI recreation event -- this way, the UI will know at least one last progress event
|
|
||||||
// before completion / failure
|
|
||||||
val subscriberFlow = MutableSharedFlow<ForegroundTaskState>(
|
|
||||||
replay = 2,
|
|
||||||
onBufferOverflow = BufferOverflow.DROP_OLDEST
|
|
||||||
)
|
|
||||||
|
|
||||||
// We should be the only task running, so we can subscribe to foregroundTaskState
|
|
||||||
// until we encounter ForegroundTaskState.Done.
|
|
||||||
// Then, we complete the returned flow, but we also set the state back to Idle.
|
|
||||||
// The state update back to Idle won't show up in the returned stream, because
|
|
||||||
// it has been completed by that point.
|
|
||||||
lifecycleScope.launch(Dispatchers.Main) {
|
|
||||||
foregroundTaskState
|
|
||||||
.applyCompletionTransform()
|
|
||||||
.onEach {
|
|
||||||
// Also update our notification when we see an update
|
|
||||||
// But ignore the first progress = 0 update -- that is the current value.
|
|
||||||
// we need that to be handled by the main coroutine after it finishes.
|
|
||||||
if (it !is ForegroundTaskState.InProgress || it.progress != 0) {
|
|
||||||
updateForegroundNotification(title, iconRes)
|
|
||||||
}
|
|
||||||
|
|
||||||
subscriberFlow.emit(it)
|
|
||||||
}
|
|
||||||
.onCompletion {
|
|
||||||
// Reset state back to Idle when we are done.
|
|
||||||
// We do it here because otherwise Idle and Done might become conflated
|
|
||||||
// when emitted by the main coroutine in quick succession.
|
|
||||||
// Doing it here ensures we've seen Done. This Idle event won't be
|
|
||||||
// emitted to the consumer because the subscription has completed here.
|
|
||||||
foregroundTaskState.value = ForegroundTaskState.Idle
|
|
||||||
}
|
|
||||||
.collect()
|
|
||||||
}
|
|
||||||
|
|
||||||
foregroundTaskSubscribers[taskID] = subscriberFlow.asSharedFlow()
|
|
||||||
|
|
||||||
if (foregroundTaskSubscribers.size > 5) {
|
|
||||||
// Remove enough elements so that the size is kept at 5
|
|
||||||
for (key in foregroundTaskSubscribers.keys.sorted()
|
|
||||||
.take(foregroundTaskSubscribers.size - 5)) {
|
|
||||||
foregroundTaskSubscribers.remove(key)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Before we return, and after we have set everything up,
|
|
||||||
// self-start with foreground permission.
|
|
||||||
// This is going to unblock the main coroutine handling the task.
|
|
||||||
startForegroundService(
|
|
||||||
Intent(
|
|
||||||
this@EuiccChannelManagerService,
|
|
||||||
this@EuiccChannelManagerService::class.java
|
|
||||||
)
|
|
||||||
)
|
|
||||||
|
|
||||||
return ForegroundTaskSubscriberFlow(
|
|
||||||
taskID,
|
|
||||||
subscriberFlow.asSharedFlow().applyCompletionTransform()
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
suspend fun waitForForegroundTask() {
|
|
||||||
foregroundTaskState.takeWhile { it != ForegroundTaskState.Idle }
|
|
||||||
.collect()
|
|
||||||
}
|
|
||||||
|
|
||||||
fun launchProfileDownloadTask(
|
|
||||||
slotId: Int,
|
|
||||||
portId: Int,
|
|
||||||
smdp: String,
|
|
||||||
matchingId: String?,
|
|
||||||
confirmationCode: String?,
|
|
||||||
imei: String?
|
|
||||||
): ForegroundTaskSubscriberFlow =
|
|
||||||
launchForegroundTask(
|
|
||||||
getString(R.string.task_profile_download),
|
|
||||||
getString(R.string.task_profile_download_failure),
|
|
||||||
R.drawable.ic_task_sim_card_download
|
|
||||||
) {
|
|
||||||
euiccChannelManager.beginTrackedOperation(slotId, portId) {
|
|
||||||
euiccChannelManager.withEuiccChannel(slotId, portId) { channel ->
|
|
||||||
channel.lpa.downloadProfile(
|
|
||||||
smdp,
|
|
||||||
matchingId,
|
|
||||||
imei,
|
|
||||||
confirmationCode,
|
|
||||||
object : ProfileDownloadCallback {
|
|
||||||
override fun onStateUpdate(state: ProfileDownloadCallback.DownloadState) {
|
|
||||||
if (state.progress == 0) return
|
|
||||||
foregroundTaskState.value =
|
|
||||||
ForegroundTaskState.InProgress(state.progress)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
preferenceRepository.notificationDownloadFlow.first()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fun launchProfileRenameTask(
|
|
||||||
slotId: Int,
|
|
||||||
portId: Int,
|
|
||||||
iccid: String,
|
|
||||||
name: String
|
|
||||||
): ForegroundTaskSubscriberFlow =
|
|
||||||
launchForegroundTask(
|
|
||||||
getString(R.string.task_profile_rename),
|
|
||||||
getString(R.string.task_profile_rename_failure),
|
|
||||||
R.drawable.ic_task_rename
|
|
||||||
) {
|
|
||||||
euiccChannelManager.withEuiccChannel(slotId, portId) { channel ->
|
|
||||||
channel.lpa.setNickname(
|
|
||||||
iccid,
|
|
||||||
name
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fun launchProfileDeleteTask(
|
|
||||||
slotId: Int,
|
|
||||||
portId: Int,
|
|
||||||
iccid: String
|
|
||||||
): ForegroundTaskSubscriberFlow =
|
|
||||||
launchForegroundTask(
|
|
||||||
getString(R.string.task_profile_delete),
|
|
||||||
getString(R.string.task_profile_delete_failure),
|
|
||||||
R.drawable.ic_task_delete
|
|
||||||
) {
|
|
||||||
euiccChannelManager.beginTrackedOperation(slotId, portId) {
|
|
||||||
euiccChannelManager.withEuiccChannel(slotId, portId) { channel ->
|
|
||||||
channel.lpa.deleteProfile(iccid)
|
|
||||||
}
|
|
||||||
|
|
||||||
preferenceRepository.notificationDeleteFlow.first()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
class SwitchingProfilesRefreshException : Exception()
|
|
||||||
|
|
||||||
fun launchProfileSwitchTask(
|
|
||||||
slotId: Int,
|
|
||||||
portId: Int,
|
|
||||||
iccid: String,
|
|
||||||
enable: Boolean, // Enable or disable the profile indicated in iccid
|
|
||||||
reconnectTimeoutMillis: Long = 0 // 0 = do not wait for reconnect
|
|
||||||
) =
|
|
||||||
launchForegroundTask(
|
|
||||||
getString(R.string.task_profile_switch),
|
|
||||||
getString(R.string.task_profile_switch_failure),
|
|
||||||
R.drawable.ic_task_switch
|
|
||||||
) {
|
|
||||||
euiccChannelManager.beginTrackedOperation(slotId, portId) {
|
|
||||||
val (response, refreshed) =
|
|
||||||
euiccChannelManager.withEuiccChannel(slotId, portId) { channel ->
|
|
||||||
val refresh = preferenceRepository.refreshAfterSwitchFlow.first()
|
|
||||||
val response = channel.lpa.switchProfile(iccid, enable, refresh)
|
|
||||||
if (response || !refresh) {
|
|
||||||
Pair(response, refresh)
|
|
||||||
} else {
|
|
||||||
// refresh failed, but refresh was requested
|
|
||||||
// Sometimes, we *can* enable or disable the profile, but we cannot
|
|
||||||
// send the refresh command to the modem because the profile somehow
|
|
||||||
// makes the modem "busy". In this case, we can still switch by setting
|
|
||||||
// refresh to false, but then the switch cannot take effect until the
|
|
||||||
// user resets the modem manually by toggling airplane mode or rebooting.
|
|
||||||
Pair(
|
|
||||||
channel.lpa.switchProfile(iccid, enable, refresh = false),
|
|
||||||
false
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!response) {
|
|
||||||
throw RuntimeException("Could not switch profile")
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!refreshed && slotId != EuiccChannelManager.USB_CHANNEL_ID) {
|
|
||||||
// We may have switched the profile, but we could not refresh. Tell the caller about this
|
|
||||||
// but only if we are talking to a modem and not a USB reader
|
|
||||||
throw SwitchingProfilesRefreshException()
|
|
||||||
}
|
|
||||||
|
|
||||||
if (reconnectTimeoutMillis > 0) {
|
|
||||||
// Add an unconditional delay first to account for any race condition between
|
|
||||||
// the card sending the refresh command and the modem actually refreshing
|
|
||||||
delay(reconnectTimeoutMillis / 10)
|
|
||||||
|
|
||||||
// This throws TimeoutCancellationException if timed out
|
|
||||||
euiccChannelManager.waitForReconnect(
|
|
||||||
slotId,
|
|
||||||
portId,
|
|
||||||
reconnectTimeoutMillis / 10 * 9
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
preferenceRepository.notificationSwitchFlow.first()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fun launchMemoryReset(slotId: Int, portId: Int): ForegroundTaskSubscriberFlow =
|
|
||||||
launchForegroundTask(
|
|
||||||
getString(R.string.task_euicc_memory_reset),
|
|
||||||
getString(R.string.task_euicc_memory_reset_failure),
|
|
||||||
R.drawable.ic_euicc_memory_reset
|
|
||||||
) {
|
|
||||||
euiccChannelManager.beginTrackedOperation(slotId, portId) {
|
|
||||||
euiccChannelManager.withEuiccChannel(slotId, portId) { channel ->
|
|
||||||
channel.lpa.euiccMemoryReset()
|
|
||||||
}
|
|
||||||
|
|
||||||
preferenceRepository.notificationDeleteFlow.first()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
|
@ -9,18 +9,14 @@ import android.os.IBinder
|
||||||
import androidx.appcompat.app.AppCompatActivity
|
import androidx.appcompat.app.AppCompatActivity
|
||||||
import im.angry.openeuicc.core.EuiccChannelManager
|
import im.angry.openeuicc.core.EuiccChannelManager
|
||||||
import im.angry.openeuicc.service.EuiccChannelManagerService
|
import im.angry.openeuicc.service.EuiccChannelManagerService
|
||||||
import kotlinx.coroutines.CompletableDeferred
|
|
||||||
|
|
||||||
abstract class BaseEuiccAccessActivity : AppCompatActivity() {
|
abstract class BaseEuiccAccessActivity : AppCompatActivity() {
|
||||||
val euiccChannelManagerLoaded = CompletableDeferred<Unit>()
|
|
||||||
lateinit var euiccChannelManager: EuiccChannelManager
|
lateinit var euiccChannelManager: EuiccChannelManager
|
||||||
lateinit var euiccChannelManagerService: EuiccChannelManagerService
|
|
||||||
|
|
||||||
private val euiccChannelManagerServiceConnection = object : ServiceConnection {
|
private val euiccChannelManagerServiceConnection = object : ServiceConnection {
|
||||||
override fun onServiceConnected(name: ComponentName?, service: IBinder?) {
|
override fun onServiceConnected(name: ComponentName?, service: IBinder?) {
|
||||||
euiccChannelManagerService = (service!! as EuiccChannelManagerService.LocalBinder).service
|
euiccChannelManager =
|
||||||
euiccChannelManager = euiccChannelManagerService.euiccChannelManager
|
(service!! as EuiccChannelManagerService.LocalBinder).service.euiccChannelManager
|
||||||
euiccChannelManagerLoaded.complete(Unit)
|
|
||||||
onInit()
|
onInit()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -0,0 +1,40 @@
|
||||||
|
package im.angry.openeuicc.ui
|
||||||
|
|
||||||
|
import androidx.lifecycle.lifecycleScope
|
||||||
|
import im.angry.openeuicc.util.*
|
||||||
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
import kotlinx.coroutines.withContext
|
||||||
|
|
||||||
|
class DirectProfileDownloadActivity : BaseEuiccAccessActivity(), SlotSelectFragment.SlotSelectedListener, OpenEuiccContextMarker {
|
||||||
|
override fun onInit() {
|
||||||
|
lifecycleScope.launch {
|
||||||
|
val knownChannels = withContext(Dispatchers.IO) {
|
||||||
|
euiccChannelManager.enumerateEuiccChannels()
|
||||||
|
}
|
||||||
|
|
||||||
|
when {
|
||||||
|
knownChannels.isEmpty() -> {
|
||||||
|
finish()
|
||||||
|
}
|
||||||
|
knownChannels.hasMultipleChips -> {
|
||||||
|
SlotSelectFragment.newInstance(knownChannels.sortedBy { it.logicalSlotId })
|
||||||
|
.show(supportFragmentManager, SlotSelectFragment.TAG)
|
||||||
|
}
|
||||||
|
else -> {
|
||||||
|
// If the device has only one eSIM "chip" (but may be mapped to multiple slots),
|
||||||
|
// we can skip the slot selection dialog since there is only one chip to save to.
|
||||||
|
onSlotSelected(knownChannels[0].slotId,
|
||||||
|
knownChannels[0].portId)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onSlotSelected(slotId: Int, portId: Int) {
|
||||||
|
ProfileDownloadFragment.newInstance(slotId, portId, finishWhenDone = true)
|
||||||
|
.show(supportFragmentManager, ProfileDownloadFragment.TAG)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onSlotSelectCancelled() = finish()
|
||||||
|
}
|
|
@ -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])
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -4,9 +4,9 @@ import android.annotation.SuppressLint
|
||||||
import android.content.ClipData
|
import android.content.ClipData
|
||||||
import android.content.ClipboardManager
|
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
|
||||||
|
@ -19,10 +19,6 @@ 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.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,9 +27,7 @@ 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.core.EuiccChannelManager
|
||||||
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.TimeoutCancellationException
|
||||||
|
@ -41,7 +35,6 @@ import kotlinx.coroutines.flow.StateFlow
|
||||||
import kotlinx.coroutines.flow.first
|
import kotlinx.coroutines.flow.first
|
||||||
import kotlinx.coroutines.flow.stateIn
|
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
|
||||||
|
|
||||||
open class EuiccManagementFragment : Fragment(), EuiccProfilesChangedListener,
|
open class EuiccManagementFragment : Fragment(), EuiccProfilesChangedListener,
|
||||||
|
@ -56,8 +49,6 @@ 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()
|
||||||
|
|
||||||
|
@ -69,8 +60,6 @@ open class EuiccManagementFragment : Fragment(), EuiccProfilesChangedListener,
|
||||||
// This gives us access to the "latest" state without having to launch coroutines
|
// This gives us access to the "latest" state without having to launch coroutines
|
||||||
private lateinit var disableSafeguardFlow: StateFlow<Boolean>
|
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)
|
||||||
|
@ -87,21 +76,6 @@ open class EuiccManagementFragment : Fragment(), EuiccProfilesChangedListener,
|
||||||
fab = view.requireViewById(R.id.fab)
|
fab = view.requireViewById(R.id.fab)
|
||||||
profileList = view.requireViewById(R.id.profile_list)
|
profileList = view.requireViewById(R.id.profile_list)
|
||||||
|
|
||||||
val origFabMarginRight = (fab.layoutParams as ViewGroup.MarginLayoutParams).rightMargin
|
|
||||||
val origFabMarginBottom = (fab.layoutParams as ViewGroup.MarginLayoutParams).bottomMargin
|
|
||||||
ViewCompat.setOnApplyWindowInsetsListener(fab) { v, insets ->
|
|
||||||
val bars = insets.getInsets(WindowInsetsCompat.Type.systemBars())
|
|
||||||
|
|
||||||
v.updateLayoutParams<ViewGroup.MarginLayoutParams> {
|
|
||||||
rightMargin = origFabMarginRight + bars.right
|
|
||||||
bottomMargin = origFabMarginBottom + bars.bottom
|
|
||||||
}
|
|
||||||
|
|
||||||
WindowInsetsCompat.CONSUMED
|
|
||||||
}
|
|
||||||
|
|
||||||
setupRootViewInsets(profileList)
|
|
||||||
|
|
||||||
return view
|
return view
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -113,15 +87,10 @@ 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)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
override fun onStart() {
|
|
||||||
super.onStart()
|
|
||||||
refresh()
|
refresh()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -134,42 +103,19 @@ 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
|
|
||||||
}
|
|
||||||
|
|
||||||
R.id.euicc_info -> {
|
else -> super.onOptionsItemSelected(item)
|
||||||
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(
|
protected open suspend fun onCreateFooterViews(
|
||||||
parent: ViewGroup,
|
parent: ViewGroup,
|
||||||
profiles: List<LocalProfileInfo>
|
profiles: List<LocalProfileInfo>
|
||||||
|
@ -181,99 +127,65 @@ open class EuiccManagementFragment : Fragment(), EuiccProfilesChangedListener,
|
||||||
listOf()
|
listOf()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@SuppressLint("NotifyDataSetChanged")
|
||||||
private fun refresh() {
|
private fun refresh() {
|
||||||
if (invalid) return
|
if (invalid) return
|
||||||
swipeRefresh.isRefreshing = true
|
swipeRefresh.isRefreshing = true
|
||||||
|
|
||||||
lifecycleScope.launch {
|
lifecycleScope.launch {
|
||||||
doRefresh()
|
if (!this@EuiccManagementFragment::disableSafeguardFlow.isInitialized) {
|
||||||
}
|
disableSafeguardFlow =
|
||||||
}
|
preferenceRepository.disableSafeguardFlow.stateIn(lifecycleScope)
|
||||||
|
}
|
||||||
|
|
||||||
@SuppressLint("NotifyDataSetChanged")
|
val profiles = withContext(Dispatchers.IO) {
|
||||||
protected open suspend fun doRefresh() {
|
euiccChannelManager.notifyEuiccProfilesChanged(channel.logicalSlotId)
|
||||||
ensureEuiccChannelManager()
|
|
||||||
euiccChannelManagerService.waitForForegroundTask()
|
|
||||||
|
|
||||||
if (!::disableSafeguardFlow.isInitialized) {
|
|
||||||
disableSafeguardFlow =
|
|
||||||
preferenceRepository.disableSafeguardFlow.stateIn(lifecycleScope)
|
|
||||||
}
|
|
||||||
if (!::unfilteredProfileListFlow.isInitialized) {
|
|
||||||
unfilteredProfileListFlow =
|
|
||||||
preferenceRepository.unfilteredProfileListFlow.stateIn(lifecycleScope)
|
|
||||||
}
|
|
||||||
|
|
||||||
val profiles = withEuiccChannel { channel ->
|
|
||||||
logicalSlotId = channel.logicalSlotId
|
|
||||||
eid = channel.lpa.eID
|
|
||||||
euiccChannelManager.notifyEuiccProfilesChanged(channel.logicalSlotId)
|
|
||||||
if (unfilteredProfileListFlow.value)
|
|
||||||
channel.lpa.profiles
|
|
||||||
else
|
|
||||||
channel.lpa.profiles.operational
|
channel.lpa.profiles.operational
|
||||||
}
|
}
|
||||||
|
|
||||||
withContext(Dispatchers.Main) {
|
withContext(Dispatchers.Main) {
|
||||||
adapter.profiles = profiles
|
adapter.profiles = profiles
|
||||||
adapter.footerViews = onCreateFooterViews(profileList, profiles)
|
adapter.footerViews = onCreateFooterViews(profileList, profiles)
|
||||||
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()
|
beginTrackedOperation {
|
||||||
euiccChannelManagerService.waitForForegroundTask()
|
val (res, refreshed) =
|
||||||
|
if (!channel.lpa.switchProfile(iccid, enable, refresh = true)) {
|
||||||
val err = euiccChannelManagerService.launchProfileSwitchTask(
|
// Sometimes, we *can* enable or disable the profile, but we cannot
|
||||||
slotId,
|
// send the refresh command to the modem because the profile somehow
|
||||||
portId,
|
// makes the modem "busy". In this case, we can still switch by setting
|
||||||
iccid,
|
// refresh to false, but then the switch cannot take effect until the
|
||||||
enable,
|
// user resets the modem manually by toggling airplane mode or rebooting.
|
||||||
reconnectTimeoutMillis = 30 * 1000
|
Pair(channel.lpa.switchProfile(iccid, enable, refresh = false), false)
|
||||||
).waitDone()
|
} else {
|
||||||
|
Pair(true, true)
|
||||||
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()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (!res) {
|
||||||
|
Log.d(TAG, "Failed to enable / disable profile $iccid")
|
||||||
|
withContext(Dispatchers.Main) {
|
||||||
|
Toast.makeText(
|
||||||
|
context,
|
||||||
|
R.string.toast_profile_enable_failed,
|
||||||
|
Toast.LENGTH_LONG
|
||||||
|
).show()
|
||||||
|
}
|
||||||
|
return@beginTrackedOperation false
|
||||||
}
|
}
|
||||||
|
|
||||||
is TimeoutCancellationException -> {
|
if (!refreshed && !isUsb) {
|
||||||
withContext(Dispatchers.Main) {
|
withContext(Dispatchers.Main) {
|
||||||
// Prevent this Fragment from being used again
|
|
||||||
invalid = true
|
|
||||||
// Timed out waiting for SIM to come back online, we can no longer assume that the LPA is still valid
|
|
||||||
AlertDialog.Builder(requireContext()).apply {
|
AlertDialog.Builder(requireContext()).apply {
|
||||||
setMessage(appContainer.customizableTextProvider.profileSwitchingTimeoutMessage)
|
setMessage(R.string.switch_did_not_refresh)
|
||||||
setPositiveButton(android.R.string.ok) { dialog, _ ->
|
setPositiveButton(android.R.string.ok) { dialog, _ ->
|
||||||
dialog.dismiss()
|
dialog.dismiss()
|
||||||
requireActivity().finish()
|
requireActivity().finish()
|
||||||
|
@ -284,11 +196,39 @@ open class EuiccManagementFragment : Fragment(), EuiccProfilesChangedListener,
|
||||||
show()
|
show()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
return@beginTrackedOperation true
|
||||||
}
|
}
|
||||||
|
|
||||||
else -> showSwitchFailureText()
|
if (!isUsb) {
|
||||||
}
|
try {
|
||||||
|
euiccChannelManager.waitForReconnect(
|
||||||
|
slotId,
|
||||||
|
portId,
|
||||||
|
timeoutMillis = 30 * 1000
|
||||||
|
)
|
||||||
|
} catch (e: TimeoutCancellationException) {
|
||||||
|
withContext(Dispatchers.Main) {
|
||||||
|
// Prevent this Fragment from being used again
|
||||||
|
invalid = true
|
||||||
|
// Timed out waiting for SIM to come back online, we can no longer assume that the LPA is still valid
|
||||||
|
AlertDialog.Builder(requireContext()).apply {
|
||||||
|
setMessage(R.string.enable_disable_timeout)
|
||||||
|
setPositiveButton(android.R.string.ok) { dialog, _ ->
|
||||||
|
dialog.dismiss()
|
||||||
|
requireActivity().finish()
|
||||||
|
}
|
||||||
|
setOnDismissListener { _ ->
|
||||||
|
requireActivity().finish()
|
||||||
|
}
|
||||||
|
show()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return@beginTrackedOperation false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
preferenceRepository.notificationSwitchFlow.first()
|
||||||
|
}
|
||||||
refresh()
|
refresh()
|
||||||
fab.isEnabled = true
|
fab.isEnabled = true
|
||||||
}
|
}
|
||||||
|
@ -316,7 +256,7 @@ 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 }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -344,8 +284,6 @@ open class EuiccManagementFragment : Fragment(), EuiccProfilesChangedListener,
|
||||||
private val name: TextView = root.requireViewById(R.id.name)
|
private val name: TextView = root.requireViewById(R.id.name)
|
||||||
private val state: TextView = root.requireViewById(R.id.state)
|
private val state: TextView = root.requireViewById(R.id.state)
|
||||||
private val provider: TextView = root.requireViewById(R.id.provider)
|
private val provider: TextView = root.requireViewById(R.id.provider)
|
||||||
private val profileClassLabel: TextView = root.requireViewById(R.id.profile_class_label)
|
|
||||||
private val profileClass: TextView = root.requireViewById(R.id.profile_class)
|
|
||||||
private val profileMenu: ImageButton = root.requireViewById(R.id.profile_menu)
|
private val profileMenu: ImageButton = root.requireViewById(R.id.profile_menu)
|
||||||
|
|
||||||
init {
|
init {
|
||||||
|
@ -358,10 +296,9 @@ open class EuiccManagementFragment : Fragment(), EuiccProfilesChangedListener,
|
||||||
}
|
}
|
||||||
|
|
||||||
iccid.setOnLongClickListener {
|
iccid.setOnLongClickListener {
|
||||||
requireContext().getSystemService(ClipboardManager::class.java)!!
|
requireContext().getSystemService(ClipboardManager::class.java)
|
||||||
.setPrimaryClip(ClipData.newPlainText("iccid", iccid.text))
|
.setPrimaryClip(ClipData.newPlainText("iccid", iccid.text))
|
||||||
if (Build.VERSION.SDK_INT <= Build.VERSION_CODES.S_V2) Toast
|
Toast.makeText(requireContext(), R.string.toast_iccid_copied, Toast.LENGTH_SHORT)
|
||||||
.makeText(requireContext(), R.string.toast_iccid_copied, Toast.LENGTH_SHORT)
|
|
||||||
.show()
|
.show()
|
||||||
true
|
true
|
||||||
}
|
}
|
||||||
|
@ -383,15 +320,6 @@ 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()
|
||||||
}
|
}
|
||||||
|
|
|
@ -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,40 +26,26 @@ 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(requireViewById(R.id.toolbar))
|
||||||
setupToolbarInsets()
|
|
||||||
supportActionBar!!.setDisplayHomeAsUpEnabled(true)
|
supportActionBar!!.setDisplayHomeAsUpEnabled(true)
|
||||||
|
|
||||||
swipeRefresh = requireViewById(R.id.swipe_refresh)
|
swipeRefresh = requireViewById(R.id.swipe_refresh)
|
||||||
scrollView = requireViewById(R.id.scroll_view)
|
scrollView = requireViewById(R.id.scroll_view)
|
||||||
logText = requireViewById(R.id.log_text)
|
logText = requireViewById(R.id.log_text)
|
||||||
|
|
||||||
setupRootViewInsets(scrollView)
|
|
||||||
|
|
||||||
swipeRefresh.setOnRefreshListener {
|
swipeRefresh.setOnRefreshListener {
|
||||||
lifecycleScope.launch {
|
lifecycleScope.launch {
|
||||||
reload()
|
reload()
|
||||||
|
@ -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)
|
||||||
|
|
|
@ -5,9 +5,7 @@ import android.content.BroadcastReceiver
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
import android.content.Intent
|
import android.content.Intent
|
||||||
import android.content.IntentFilter
|
import android.content.IntentFilter
|
||||||
import android.content.pm.PackageManager
|
|
||||||
import android.hardware.usb.UsbManager
|
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
|
||||||
|
@ -15,7 +13,6 @@ 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.ProgressBar
|
||||||
import androidx.activity.enableEdgeToEdge
|
|
||||||
import androidx.fragment.app.Fragment
|
import androidx.fragment.app.Fragment
|
||||||
import androidx.lifecycle.lifecycleScope
|
import androidx.lifecycle.lifecycleScope
|
||||||
import androidx.viewpager2.adapter.FragmentStateAdapter
|
import androidx.viewpager2.adapter.FragmentStateAdapter
|
||||||
|
@ -23,12 +20,8 @@ import androidx.viewpager2.widget.ViewPager2
|
||||||
import com.google.android.material.tabs.TabLayout
|
import com.google.android.material.tabs.TabLayout
|
||||||
import com.google.android.material.tabs.TabLayoutMediator
|
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.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
|
||||||
|
|
||||||
|
@ -36,8 +29,6 @@ import kotlinx.coroutines.withContext
|
||||||
open class MainActivity : BaseEuiccAccessActivity(), 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 loadingProgress: ProgressBar
|
||||||
|
@ -47,7 +38,6 @@ open class MainActivity : BaseEuiccAccessActivity(), OpenEuiccContextMarker {
|
||||||
private var refreshing = false
|
private var refreshing = false
|
||||||
|
|
||||||
private data class Page(
|
private data class Page(
|
||||||
val logicalSlotId: Int,
|
|
||||||
val title: String,
|
val title: String,
|
||||||
val createFragment: () -> Fragment
|
val createFragment: () -> Fragment
|
||||||
)
|
)
|
||||||
|
@ -74,11 +64,9 @@ open class MainActivity : BaseEuiccAccessActivity(), OpenEuiccContextMarker {
|
||||||
|
|
||||||
@SuppressLint("WrongConstant", "UnspecifiedRegisterReceiverFlag")
|
@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(requireViewById(R.id.toolbar))
|
||||||
setupToolbarInsets()
|
|
||||||
loadingProgress = requireViewById(R.id.loading)
|
loadingProgress = requireViewById(R.id.loading)
|
||||||
tabs = requireViewById(R.id.main_tabs)
|
tabs = requireViewById(R.id.main_tabs)
|
||||||
viewPager = requireViewById(R.id.view_pager)
|
viewPager = requireViewById(R.id.view_pager)
|
||||||
|
@ -109,7 +97,7 @@ open class MainActivity : BaseEuiccAccessActivity(), OpenEuiccContextMarker {
|
||||||
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
|
true
|
||||||
}
|
}
|
||||||
R.id.reload -> {
|
R.id.reload -> {
|
||||||
|
@ -125,95 +113,65 @@ open class MainActivity : BaseEuiccAccessActivity(), OpenEuiccContextMarker {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun ensureNotificationPermissions() {
|
|
||||||
val needsNotificationPerms = Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU;
|
|
||||||
val notificationPermsGranted =
|
|
||||||
needsNotificationPerms && checkSelfPermission(android.Manifest.permission.POST_NOTIFICATIONS) == PackageManager.PERMISSION_GRANTED
|
|
||||||
if (needsNotificationPerms && !notificationPermsGranted) {
|
|
||||||
requestPermissions(
|
|
||||||
arrayOf(android.Manifest.permission.POST_NOTIFICATIONS),
|
|
||||||
PERMISSION_REQUEST_CODE
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private suspend fun init(fromUsbEvent: Boolean = false) {
|
private suspend fun init(fromUsbEvent: Boolean = false) {
|
||||||
refreshing = true // We don't check this here -- the check happens in refresh()
|
refreshing = true // We don't check this here -- the check happens in refresh()
|
||||||
loadingProgress.visibility = View.VISIBLE
|
loadingProgress.visibility = View.VISIBLE
|
||||||
viewPager.visibility = View.GONE
|
viewPager.visibility = View.GONE
|
||||||
tabs.visibility = View.GONE
|
tabs.visibility = View.GONE
|
||||||
// Prevent concurrent access with any running foreground task
|
|
||||||
euiccChannelManagerService.waitForForegroundTask()
|
|
||||||
|
|
||||||
val (usbDevice, _) = withContext(Dispatchers.IO) {
|
val knownChannels = withContext(Dispatchers.IO) {
|
||||||
euiccChannelManager.tryOpenUsbEuiccChannel()
|
euiccChannelManager.enumerateEuiccChannels().onEach {
|
||||||
}
|
Log.d(TAG, "slot ${it.slotId} port ${it.portId}")
|
||||||
|
Log.d(TAG, it.lpa.eID)
|
||||||
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 }
|
val (usbDevice, _) = withContext(Dispatchers.IO) {
|
||||||
|
euiccChannelManager.enumerateUsbEuiccChannel()
|
||||||
|
}
|
||||||
|
|
||||||
pages.clear()
|
withContext(Dispatchers.Main) {
|
||||||
pages.addAll(newPages)
|
loadingProgress.visibility = View.GONE
|
||||||
|
|
||||||
loadingProgress.visibility = View.GONE
|
knownChannels.sortedBy { it.logicalSlotId }.forEach { channel ->
|
||||||
pagerAdapter.notifyDataSetChanged()
|
pages.add(Page(
|
||||||
// Reset the adapter so that the current view actually gets cleared
|
getString(R.string.channel_name_format, channel.logicalSlotId)
|
||||||
// notifyDataSetChanged() doesn't cause the current view to be removed.
|
) { appContainer.uiComponentFactory.createEuiccManagementFragment(channel) })
|
||||||
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 USB readers exist, add them at the very last
|
||||||
ensureNotificationPermissions()
|
// We use a wrapper fragment to handle logic specific to USB readers
|
||||||
}
|
usbDevice?.let {
|
||||||
|
pages.add(Page(it.productName ?: getString(R.string.usb)) { UsbCcidReaderFragment() })
|
||||||
|
}
|
||||||
|
viewPager.visibility = View.VISIBLE
|
||||||
|
|
||||||
refreshing = false
|
if (pages.size > 1) {
|
||||||
|
tabs.visibility = View.VISIBLE
|
||||||
|
} else if (pages.isEmpty()) {
|
||||||
|
pages.add(Page("") { appContainer.uiComponentFactory.createNoEuiccPlaceholderFragment() })
|
||||||
|
}
|
||||||
|
|
||||||
|
pagerAdapter.notifyDataSetChanged()
|
||||||
|
// Reset the adapter so that the current view actually gets cleared
|
||||||
|
// notifyDataSetChanged() doesn't cause the current view to be removed.
|
||||||
|
viewPager.adapter = pagerAdapter
|
||||||
|
|
||||||
|
if (fromUsbEvent && usbDevice != null) {
|
||||||
|
// If this refresh was triggered by a USB insertion while active, scroll to that page
|
||||||
|
viewPager.post {
|
||||||
|
viewPager.setCurrentItem(pages.size - 1, true)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
viewPager.currentItem = 0
|
||||||
|
}
|
||||||
|
|
||||||
|
refreshing = false
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun refresh(fromUsbEvent: Boolean = false) {
|
private fun refresh(fromUsbEvent: Boolean = false) {
|
||||||
|
|
|
@ -4,20 +4,15 @@ import android.os.Bundle
|
||||||
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.TextView
|
|
||||||
import androidx.fragment.app.Fragment
|
import androidx.fragment.app.Fragment
|
||||||
import im.angry.openeuicc.common.R
|
import im.angry.openeuicc.common.R
|
||||||
import im.angry.openeuicc.util.*
|
|
||||||
|
|
||||||
class NoEuiccPlaceholderFragment : Fragment(), OpenEuiccContextMarker {
|
class NoEuiccPlaceholderFragment : Fragment() {
|
||||||
override fun onCreateView(
|
override fun onCreateView(
|
||||||
inflater: LayoutInflater,
|
inflater: LayoutInflater,
|
||||||
container: ViewGroup?,
|
container: ViewGroup?,
|
||||||
savedInstanceState: Bundle?
|
savedInstanceState: Bundle?
|
||||||
): View? {
|
): View? {
|
||||||
val view = inflater.inflate(R.layout.fragment_no_euicc_placeholder, container, false)
|
return 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,7 +11,6 @@ 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.core.view.forEach
|
import androidx.core.view.forEach
|
||||||
import androidx.lifecycle.lifecycleScope
|
import androidx.lifecycle.lifecycleScope
|
||||||
|
@ -20,6 +19,7 @@ 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.EuiccChannel
|
||||||
import im.angry.openeuicc.core.EuiccChannelManager
|
import im.angry.openeuicc.core.EuiccChannelManager
|
||||||
import im.angry.openeuicc.util.*
|
import im.angry.openeuicc.util.*
|
||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
@ -32,37 +32,34 @@ class NotificationsActivity: BaseEuiccAccessActivity(), OpenEuiccContextMarker {
|
||||||
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(requireViewById(R.id.toolbar))
|
||||||
setupToolbarInsets()
|
|
||||||
supportActionBar!!.setDisplayHomeAsUpEnabled(true)
|
supportActionBar!!.setDisplayHomeAsUpEnabled(true)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onInit() {
|
||||||
|
euiccChannel = euiccChannelManager
|
||||||
|
.findEuiccChannelBySlotBlocking(intent.getIntExtra("logicalSlotId", 0))!!
|
||||||
|
|
||||||
swipeRefresh = requireViewById(R.id.swipe_refresh)
|
swipeRefresh = requireViewById(R.id.swipe_refresh)
|
||||||
notificationList = requireViewById(R.id.recycler_view)
|
notificationList = requireViewById(R.id.recycler_view)
|
||||||
|
|
||||||
setupRootViewInsets(notificationList)
|
|
||||||
}
|
|
||||||
|
|
||||||
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
|
// This is slightly different from the MainActivity logic
|
||||||
// due to the length (we don't want to display the full USB product name)
|
// due to the length (we don't want to display the full USB product name)
|
||||||
val channelTitle = if (logicalSlotId == EuiccChannelManager.USB_CHANNEL_ID) {
|
val channelTitle = if (euiccChannel.logicalSlotId == EuiccChannelManager.USB_CHANNEL_ID) {
|
||||||
getString(R.string.usb)
|
getString(R.string.usb)
|
||||||
} else {
|
} else {
|
||||||
appContainer.customizableTextProvider.formatInternalChannelName(logicalSlotId)
|
getString(R.string.channel_name_format, euiccChannel.logicalSlotId)
|
||||||
}
|
}
|
||||||
|
|
||||||
title = getString(R.string.profile_notifications_detailed_format, channelTitle)
|
title = getString(R.string.profile_notifications_detailed_format, channelTitle)
|
||||||
|
@ -103,10 +100,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 +108,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 ?: "???")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -139,8 +131,6 @@ class NotificationsActivity: BaseEuiccAccessActivity(), OpenEuiccContextMarker {
|
||||||
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.requireViewById(R.id.notification_address)
|
||||||
private val sequenceNumber: TextView =
|
|
||||||
root.requireViewById(R.id.notification_sequence_number)
|
|
||||||
private val profileName: TextView = root.requireViewById(R.id.notification_profile_name)
|
private val profileName: TextView = root.requireViewById(R.id.notification_profile_name)
|
||||||
|
|
||||||
private lateinit var notification: LocalProfileNotificationWrapper
|
private lateinit var notification: LocalProfileNotificationWrapper
|
||||||
|
@ -162,7 +152,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 +165,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,9 +189,7 @@ 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()
|
||||||
|
@ -216,9 +199,7 @@ class NotificationsActivity: BaseEuiccAccessActivity(), OpenEuiccContextMarker {
|
||||||
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()
|
||||||
|
|
|
@ -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() = beginTrackedOperation {
|
||||||
|
channel.lpa.deleteProfile(requireArguments().getString("iccid")!!)
|
||||||
|
preferenceRepository.notificationDeleteFlow.first()
|
||||||
|
}
|
||||||
}
|
}
|
|
@ -0,0 +1,266 @@
|
||||||
|
package im.angry.openeuicc.ui
|
||||||
|
|
||||||
|
import android.annotation.SuppressLint
|
||||||
|
import android.app.Dialog
|
||||||
|
import android.content.DialogInterface
|
||||||
|
import android.graphics.BitmapFactory
|
||||||
|
import android.os.Bundle
|
||||||
|
import android.text.Editable
|
||||||
|
import android.util.Log
|
||||||
|
import android.view.*
|
||||||
|
import android.widget.ProgressBar
|
||||||
|
import android.widget.TextView
|
||||||
|
import android.widget.Toast
|
||||||
|
import androidx.activity.result.contract.ActivityResultContracts
|
||||||
|
import androidx.appcompat.widget.Toolbar
|
||||||
|
import androidx.lifecycle.lifecycleScope
|
||||||
|
import com.google.android.material.textfield.TextInputLayout
|
||||||
|
import com.journeyapps.barcodescanner.ScanContract
|
||||||
|
import com.journeyapps.barcodescanner.ScanOptions
|
||||||
|
import im.angry.openeuicc.common.R
|
||||||
|
import im.angry.openeuicc.util.*
|
||||||
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.flow.first
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
import kotlinx.coroutines.withContext
|
||||||
|
import net.typeblog.lpac_jni.ProfileDownloadCallback
|
||||||
|
import kotlin.Exception
|
||||||
|
|
||||||
|
class ProfileDownloadFragment : BaseMaterialDialogFragment(),
|
||||||
|
Toolbar.OnMenuItemClickListener, EuiccChannelFragmentMarker {
|
||||||
|
companion object {
|
||||||
|
const val TAG = "ProfileDownloadFragment"
|
||||||
|
|
||||||
|
fun newInstance(slotId: Int, portId: Int, finishWhenDone: Boolean = false): ProfileDownloadFragment =
|
||||||
|
newInstanceEuicc(ProfileDownloadFragment::class.java, slotId, portId) {
|
||||||
|
putBoolean("finishWhenDone", finishWhenDone)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private lateinit var toolbar: Toolbar
|
||||||
|
private lateinit var profileDownloadServer: TextInputLayout
|
||||||
|
private lateinit var profileDownloadCode: TextInputLayout
|
||||||
|
private lateinit var profileDownloadConfirmationCode: TextInputLayout
|
||||||
|
private lateinit var profileDownloadIMEI: TextInputLayout
|
||||||
|
private lateinit var profileDownloadFreeSpace: TextView
|
||||||
|
private lateinit var progress: ProgressBar
|
||||||
|
|
||||||
|
private var freeNvram: Int = -1
|
||||||
|
|
||||||
|
private var downloading = false
|
||||||
|
|
||||||
|
private val finishWhenDone by lazy {
|
||||||
|
requireArguments().getBoolean("finishWhenDone", false)
|
||||||
|
}
|
||||||
|
|
||||||
|
private val barcodeScannerLauncher = registerForActivityResult(ScanContract()) { result ->
|
||||||
|
result.contents?.let { content ->
|
||||||
|
Log.d(TAG, content)
|
||||||
|
onScanResult(content)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private val gallerySelectorLauncher = registerForActivityResult(ActivityResultContracts.GetContent()) { result ->
|
||||||
|
if (result == null) return@registerForActivityResult
|
||||||
|
|
||||||
|
lifecycleScope.launch(Dispatchers.IO) {
|
||||||
|
runCatching {
|
||||||
|
requireContext().contentResolver.openInputStream(result)?.let { input ->
|
||||||
|
val bmp = BitmapFactory.decodeStream(input)
|
||||||
|
input.close()
|
||||||
|
|
||||||
|
decodeQrFromBitmap(bmp)?.let {
|
||||||
|
withContext(Dispatchers.Main) {
|
||||||
|
onScanResult(it)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
bmp.recycle()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun onScanResult(result: String) {
|
||||||
|
val components = result.split("$")
|
||||||
|
if (components.size < 3 || components[0] != "LPA:1") return
|
||||||
|
profileDownloadServer.editText?.setText(components[1])
|
||||||
|
profileDownloadCode.editText?.setText(components[2])
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onCreateView(
|
||||||
|
inflater: LayoutInflater,
|
||||||
|
container: ViewGroup?,
|
||||||
|
savedInstanceState: Bundle?
|
||||||
|
): View {
|
||||||
|
val view = inflater.inflate(R.layout.fragment_profile_download, container, false)
|
||||||
|
|
||||||
|
toolbar = view.requireViewById(R.id.toolbar)
|
||||||
|
profileDownloadServer = view.requireViewById(R.id.profile_download_server)
|
||||||
|
profileDownloadCode = view.requireViewById(R.id.profile_download_code)
|
||||||
|
profileDownloadConfirmationCode = view.requireViewById(R.id.profile_download_confirmation_code)
|
||||||
|
profileDownloadIMEI = view.requireViewById(R.id.profile_download_imei)
|
||||||
|
profileDownloadFreeSpace = view.requireViewById(R.id.profile_download_free_space)
|
||||||
|
progress = view.requireViewById(R.id.progress)
|
||||||
|
|
||||||
|
toolbar.inflateMenu(R.menu.fragment_profile_download)
|
||||||
|
|
||||||
|
return view
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
|
||||||
|
super.onViewCreated(view, savedInstanceState)
|
||||||
|
toolbar.apply {
|
||||||
|
setTitle(R.string.profile_download)
|
||||||
|
setNavigationOnClickListener {
|
||||||
|
if (!downloading) {
|
||||||
|
dismiss()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
setOnMenuItemClickListener(this@ProfileDownloadFragment)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onMenuItemClick(item: MenuItem): Boolean = downloading ||
|
||||||
|
when (item.itemId) {
|
||||||
|
R.id.scan -> {
|
||||||
|
barcodeScannerLauncher.launch(ScanOptions().apply {
|
||||||
|
setDesiredBarcodeFormats(ScanOptions.QR_CODE)
|
||||||
|
setOrientationLocked(false)
|
||||||
|
})
|
||||||
|
true
|
||||||
|
}
|
||||||
|
R.id.scan_from_gallery -> {
|
||||||
|
gallerySelectorLauncher.launch("image/*")
|
||||||
|
true
|
||||||
|
}
|
||||||
|
R.id.ok -> {
|
||||||
|
startDownloadProfile()
|
||||||
|
true
|
||||||
|
}
|
||||||
|
else -> false
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onResume() {
|
||||||
|
super.onResume()
|
||||||
|
setWidthPercent(95)
|
||||||
|
}
|
||||||
|
|
||||||
|
@SuppressLint("MissingPermission")
|
||||||
|
override fun onStart() {
|
||||||
|
super.onStart()
|
||||||
|
profileDownloadIMEI.editText!!.text = Editable.Factory.getInstance().newEditable(
|
||||||
|
try {
|
||||||
|
telephonyManager.getImei(channel.logicalSlotId) ?: ""
|
||||||
|
} catch (e: Exception) {
|
||||||
|
""
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
lifecycleScope.launch(Dispatchers.IO) {
|
||||||
|
// Fetch remaining NVRAM
|
||||||
|
val str = channel.lpa.euiccInfo2?.freeNvram?.also {
|
||||||
|
freeNvram = it
|
||||||
|
}?.let { formatFreeSpace(it) }
|
||||||
|
|
||||||
|
withContext(Dispatchers.Main) {
|
||||||
|
profileDownloadFreeSpace.text = getString(R.string.profile_download_free_space,
|
||||||
|
str ?: getText(R.string.unknown))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onCreateDialog(savedInstanceState: Bundle?): Dialog {
|
||||||
|
return super.onCreateDialog(savedInstanceState).also {
|
||||||
|
it.setCanceledOnTouchOutside(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun startDownloadProfile() {
|
||||||
|
val server = profileDownloadServer.editText!!.let {
|
||||||
|
it.text.toString().trim().apply {
|
||||||
|
if (isEmpty()) {
|
||||||
|
it.requestFocus()
|
||||||
|
return@startDownloadProfile
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
val code = profileDownloadCode.editText!!.text.toString().trim()
|
||||||
|
.ifBlank { null }
|
||||||
|
val confirmationCode = profileDownloadConfirmationCode.editText!!.text.toString().trim()
|
||||||
|
.ifBlank { null }
|
||||||
|
val imei = profileDownloadIMEI.editText!!.text.toString().trim()
|
||||||
|
.ifBlank { null }
|
||||||
|
|
||||||
|
downloading = true
|
||||||
|
|
||||||
|
profileDownloadServer.editText!!.isEnabled = false
|
||||||
|
profileDownloadCode.editText!!.isEnabled = false
|
||||||
|
profileDownloadConfirmationCode.editText!!.isEnabled = false
|
||||||
|
profileDownloadIMEI.editText!!.isEnabled = false
|
||||||
|
|
||||||
|
progress.isIndeterminate = true
|
||||||
|
progress.visibility = View.VISIBLE
|
||||||
|
|
||||||
|
lifecycleScope.launch {
|
||||||
|
try {
|
||||||
|
doDownloadProfile(server, code, confirmationCode, imei)
|
||||||
|
} catch (e: Exception) {
|
||||||
|
Log.d(TAG, "Error downloading profile")
|
||||||
|
Log.d(TAG, Log.getStackTraceString(e))
|
||||||
|
Toast.makeText(context, R.string.profile_download_failed, Toast.LENGTH_LONG).show()
|
||||||
|
} finally {
|
||||||
|
if (parentFragment is EuiccProfilesChangedListener) {
|
||||||
|
(parentFragment as EuiccProfilesChangedListener).onEuiccProfilesChanged()
|
||||||
|
}
|
||||||
|
dismiss()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private suspend fun doDownloadProfile(
|
||||||
|
server: String,
|
||||||
|
code: String?,
|
||||||
|
confirmationCode: String?,
|
||||||
|
imei: String?
|
||||||
|
) = beginTrackedOperation {
|
||||||
|
val res = channel.lpa.downloadProfile(
|
||||||
|
server,
|
||||||
|
code,
|
||||||
|
imei,
|
||||||
|
confirmationCode,
|
||||||
|
object : ProfileDownloadCallback {
|
||||||
|
override fun onStateUpdate(state: ProfileDownloadCallback.DownloadState) {
|
||||||
|
lifecycleScope.launch(Dispatchers.Main) {
|
||||||
|
progress.isIndeterminate = false
|
||||||
|
progress.progress = state.progress
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!res) {
|
||||||
|
// TODO: Provide more details on the error
|
||||||
|
throw RuntimeException("Failed to download profile; this is typically caused by another error happened before.")
|
||||||
|
}
|
||||||
|
|
||||||
|
// If we get here, we are successful
|
||||||
|
// This function is wrapped in beginTrackedOperation, so by returning the settings value,
|
||||||
|
// We only send notifications if the user allowed us to
|
||||||
|
preferenceRepository.notificationDownloadFlow.first()
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onDismiss(dialog: DialogInterface) {
|
||||||
|
super.onDismiss(dialog)
|
||||||
|
if (finishWhenDone) {
|
||||||
|
activity?.finish()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onCancel(dialog: DialogInterface) {
|
||||||
|
super.onCancel(dialog)
|
||||||
|
if (finishWhenDone) {
|
||||||
|
activity?.finish()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
|
@ -2,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?,
|
||||||
|
@ -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(requireViewById(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_switch")
|
||||||
|
?.bindBooleanFlow(preferenceRepository.notificationSwitchFlow, PreferenceKeys.NOTIFICATION_SWITCH)
|
||||||
|
|
||||||
|
findPreference<CheckBoxPreference>("pref_advanced_disable_safeguard_removable_esim")
|
||||||
|
?.bindBooleanFlow(preferenceRepository.disableSafeguardFlow, PreferenceKeys.DISABLE_SAFEGUARD_REMOVABLE_ESIM)
|
||||||
}
|
}
|
||||||
|
|
||||||
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,93 @@
|
||||||
|
package im.angry.openeuicc.ui
|
||||||
|
|
||||||
|
import android.content.DialogInterface
|
||||||
|
import android.os.Bundle
|
||||||
|
import android.view.LayoutInflater
|
||||||
|
import android.view.View
|
||||||
|
import android.view.ViewGroup
|
||||||
|
import android.widget.ArrayAdapter
|
||||||
|
import android.widget.Spinner
|
||||||
|
import androidx.appcompat.widget.Toolbar
|
||||||
|
import im.angry.openeuicc.common.R
|
||||||
|
import im.angry.openeuicc.core.EuiccChannel
|
||||||
|
import im.angry.openeuicc.util.*
|
||||||
|
|
||||||
|
class SlotSelectFragment : BaseMaterialDialogFragment(), OpenEuiccContextMarker {
|
||||||
|
companion object {
|
||||||
|
const val TAG = "SlotSelectFragment"
|
||||||
|
|
||||||
|
fun newInstance(knownChannels: List<EuiccChannel>): SlotSelectFragment {
|
||||||
|
return SlotSelectFragment().apply {
|
||||||
|
arguments = Bundle().apply {
|
||||||
|
putIntArray("slotIds", knownChannels.map { it.slotId }.toIntArray())
|
||||||
|
putIntArray("logicalSlotIds", knownChannels.map { it.logicalSlotId }.toIntArray())
|
||||||
|
putIntArray("portIds", knownChannels.map { it.portId }.toIntArray())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
interface SlotSelectedListener {
|
||||||
|
fun onSlotSelected(slotId: Int, portId: Int)
|
||||||
|
fun onSlotSelectCancelled()
|
||||||
|
}
|
||||||
|
|
||||||
|
private lateinit var toolbar: Toolbar
|
||||||
|
private lateinit var spinner: Spinner
|
||||||
|
private lateinit var adapter: ArrayAdapter<String>
|
||||||
|
private lateinit var slotIds: IntArray
|
||||||
|
private lateinit var logicalSlotIds: IntArray
|
||||||
|
private lateinit var portIds: IntArray
|
||||||
|
|
||||||
|
override fun onCreateView(
|
||||||
|
inflater: LayoutInflater,
|
||||||
|
container: ViewGroup?,
|
||||||
|
savedInstanceState: Bundle?
|
||||||
|
): View? {
|
||||||
|
val view = inflater.inflate(R.layout.fragment_slot_select, container, false)
|
||||||
|
|
||||||
|
toolbar = view.requireViewById(R.id.toolbar)
|
||||||
|
toolbar.setTitle(R.string.slot_select)
|
||||||
|
toolbar.inflateMenu(R.menu.fragment_slot_select)
|
||||||
|
|
||||||
|
adapter = ArrayAdapter<String>(inflater.context, R.layout.spinner_item)
|
||||||
|
|
||||||
|
spinner = view.requireViewById(R.id.spinner)
|
||||||
|
spinner.adapter = adapter
|
||||||
|
|
||||||
|
return view
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onStart() {
|
||||||
|
super.onStart()
|
||||||
|
|
||||||
|
slotIds = requireArguments().getIntArray("slotIds")!!
|
||||||
|
logicalSlotIds = requireArguments().getIntArray("logicalSlotIds")!!
|
||||||
|
portIds = requireArguments().getIntArray("portIds")!!
|
||||||
|
|
||||||
|
logicalSlotIds.forEach { id ->
|
||||||
|
adapter.add(getString(R.string.channel_name_format, id))
|
||||||
|
}
|
||||||
|
|
||||||
|
toolbar.setNavigationOnClickListener {
|
||||||
|
(requireActivity() as SlotSelectedListener).onSlotSelectCancelled()
|
||||||
|
}
|
||||||
|
toolbar.setOnMenuItemClickListener {
|
||||||
|
val slotId = slotIds[spinner.selectedItemPosition]
|
||||||
|
val portId = portIds[spinner.selectedItemPosition]
|
||||||
|
(requireActivity() as SlotSelectedListener).onSlotSelected(slotId, portId)
|
||||||
|
dismiss()
|
||||||
|
true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onResume() {
|
||||||
|
super.onResume()
|
||||||
|
setWidthPercent(75)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onCancel(dialog: DialogInterface) {
|
||||||
|
super.onCancel(dialog)
|
||||||
|
(requireActivity() as SlotSelectedListener).onSlotSelectCancelled()
|
||||||
|
}
|
||||||
|
}
|
|
@ -20,6 +20,7 @@ import androidx.fragment.app.Fragment
|
||||||
import androidx.fragment.app.commit
|
import androidx.fragment.app.commit
|
||||||
import androidx.lifecycle.lifecycleScope
|
import androidx.lifecycle.lifecycleScope
|
||||||
import im.angry.openeuicc.common.R
|
import im.angry.openeuicc.common.R
|
||||||
|
import im.angry.openeuicc.core.EuiccChannel
|
||||||
import im.angry.openeuicc.core.EuiccChannelManager
|
import im.angry.openeuicc.core.EuiccChannelManager
|
||||||
import im.angry.openeuicc.util.*
|
import im.angry.openeuicc.util.*
|
||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
@ -72,6 +73,7 @@ class UsbCcidReaderFragment : Fragment(), OpenEuiccContextMarker {
|
||||||
private lateinit var loadingProgress: ProgressBar
|
private lateinit var loadingProgress: ProgressBar
|
||||||
|
|
||||||
private var usbDevice: UsbDevice? = null
|
private var usbDevice: UsbDevice? = null
|
||||||
|
private var usbChannel: EuiccChannel? = null
|
||||||
|
|
||||||
override fun onCreateView(
|
override fun onCreateView(
|
||||||
inflater: LayoutInflater,
|
inflater: LayoutInflater,
|
||||||
|
@ -120,7 +122,7 @@ class UsbCcidReaderFragment : Fragment(), OpenEuiccContextMarker {
|
||||||
try {
|
try {
|
||||||
requireContext().unregisterReceiver(usbPermissionReceiver)
|
requireContext().unregisterReceiver(usbPermissionReceiver)
|
||||||
} catch (_: Exception) {
|
} catch (_: Exception) {
|
||||||
// ignore
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -129,7 +131,7 @@ class UsbCcidReaderFragment : Fragment(), OpenEuiccContextMarker {
|
||||||
try {
|
try {
|
||||||
requireContext().unregisterReceiver(usbPermissionReceiver)
|
requireContext().unregisterReceiver(usbPermissionReceiver)
|
||||||
} catch (_: Exception) {
|
} catch (_: Exception) {
|
||||||
// ignore
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -138,26 +140,24 @@ class UsbCcidReaderFragment : Fragment(), OpenEuiccContextMarker {
|
||||||
permissionButton.visibility = View.GONE
|
permissionButton.visibility = View.GONE
|
||||||
loadingProgress.visibility = View.VISIBLE
|
loadingProgress.visibility = View.VISIBLE
|
||||||
|
|
||||||
val (device, canOpen) = withContext(Dispatchers.IO) {
|
val (device, channel) = withContext(Dispatchers.IO) {
|
||||||
euiccChannelManager.tryOpenUsbEuiccChannel()
|
euiccChannelManager.enumerateUsbEuiccChannel()
|
||||||
}
|
}
|
||||||
|
|
||||||
loadingProgress.visibility = View.GONE
|
loadingProgress.visibility = View.GONE
|
||||||
|
|
||||||
usbDevice = device
|
usbDevice = device
|
||||||
|
usbChannel = channel
|
||||||
|
|
||||||
if (device != null && !canOpen && !usbManager.hasPermission(device)) {
|
if (device != null && channel == null && !usbManager.hasPermission(device)) {
|
||||||
text.text = getString(R.string.usb_permission_needed)
|
text.text = getString(R.string.usb_permission_needed)
|
||||||
text.visibility = View.VISIBLE
|
text.visibility = View.VISIBLE
|
||||||
permissionButton.visibility = View.VISIBLE
|
permissionButton.visibility = View.VISIBLE
|
||||||
} else if (device != null && canOpen) {
|
} else if (device != null && channel != null) {
|
||||||
childFragmentManager.commit {
|
childFragmentManager.commit {
|
||||||
replace(
|
replace(
|
||||||
R.id.child_container,
|
R.id.child_container,
|
||||||
appContainer.uiComponentFactory.createEuiccManagementFragment(
|
appContainer.uiComponentFactory.createEuiccManagementFragment(channel)
|
||||||
slotId = EuiccChannelManager.USB_CHANNEL_ID,
|
|
||||||
portId = 0
|
|
||||||
)
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
|
|
|
@ -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)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -4,69 +4,49 @@ 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.core.EuiccChannelManager
|
||||||
import im.angry.openeuicc.service.EuiccChannelManagerService
|
|
||||||
import im.angry.openeuicc.ui.BaseEuiccAccessActivity
|
import im.angry.openeuicc.ui.BaseEuiccAccessActivity
|
||||||
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.withContext
|
||||||
|
|
||||||
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
|
// Convenient methods to avoid using `channel` for these
|
||||||
// `channel` requires that the channel actually exists in EuiccChannelManager, which is
|
// `channel` requires that the channel actually exists in EuiccChannelManager, which is
|
||||||
// not always the case during operations such as switching
|
// not always the case during operations such as switching
|
||||||
val <T> T.slotId: Int
|
val <T> T.slotId: Int where T: Fragment, T: EuiccChannelFragmentMarker
|
||||||
where T : Fragment, T : EuiccChannelFragmentMarker
|
get() = requireArguments().getInt("slotId")
|
||||||
get() = requireArguments().getInt(FIELD_SLOT_ID)
|
val <T> T.portId: Int where T: Fragment, T: EuiccChannelFragmentMarker
|
||||||
val <T> T.portId: Int
|
get() = requireArguments().getInt("portId")
|
||||||
where T : Fragment, T : EuiccChannelFragmentMarker
|
val <T> T.isUsb: Boolean where T: Fragment, T: EuiccChannelFragmentMarker
|
||||||
get() = requireArguments().getInt(FIELD_PORT_ID)
|
get() = requireArguments().getInt("slotId") == EuiccChannelManager.USB_CHANNEL_ID
|
||||||
val <T> T.isUsb: Boolean
|
|
||||||
where T : Fragment, T : EuiccChannelFragmentMarker
|
|
||||||
get() = slotId == EuiccChannelManager.USB_CHANNEL_ID
|
|
||||||
|
|
||||||
private fun <T> T.requireEuiccActivity(): BaseEuiccAccessActivity
|
val <T> T.euiccChannelManager: EuiccChannelManager where T: Fragment, T: EuiccChannelFragmentMarker
|
||||||
where T : Fragment, T : OpenEuiccContextMarker =
|
get() = (requireActivity() as BaseEuiccAccessActivity).euiccChannelManager
|
||||||
requireActivity() as BaseEuiccAccessActivity
|
val <T> T.channel: EuiccChannel where T: Fragment, T: EuiccChannelFragmentMarker
|
||||||
|
get() =
|
||||||
val <T> T.euiccChannelManager: EuiccChannelManager
|
euiccChannelManager.findEuiccChannelByPortBlocking(slotId, portId)!!
|
||||||
where T : Fragment, T : OpenEuiccContextMarker
|
|
||||||
get() = requireEuiccActivity().euiccChannelManager
|
|
||||||
|
|
||||||
val <T> T.euiccChannelManagerService: EuiccChannelManagerService
|
|
||||||
where T : Fragment, T : OpenEuiccContextMarker
|
|
||||||
get() = requireEuiccActivity().euiccChannelManagerService
|
|
||||||
|
|
||||||
suspend fun <T, R> T.withEuiccChannel(fn: suspend (EuiccChannel) -> R): R
|
|
||||||
where T : Fragment, T : EuiccChannelFragmentMarker {
|
|
||||||
ensureEuiccChannelManager()
|
|
||||||
return euiccChannelManager.withEuiccChannel(slotId, portId, fn)
|
|
||||||
}
|
|
||||||
|
|
||||||
suspend fun <T> T.ensureEuiccChannelManager() where T : Fragment, T : OpenEuiccContextMarker =
|
|
||||||
requireEuiccActivity().euiccChannelManagerLoaded.await()
|
|
||||||
|
|
||||||
fun <T> T.notifyEuiccProfilesChanged() where T : Fragment {
|
|
||||||
if (this !is EuiccProfilesChangedListener) return
|
|
||||||
// Trigger a refresh in the parent fragment -- it should wait until
|
|
||||||
// any foreground task is completed before actually doing a refresh
|
|
||||||
this.onEuiccProfilesChanged()
|
|
||||||
}
|
|
||||||
|
|
||||||
interface EuiccProfilesChangedListener {
|
interface EuiccProfilesChangedListener {
|
||||||
fun onEuiccProfilesChanged()
|
fun onEuiccProfilesChanged()
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun <T> T.beginTrackedOperation(op: suspend () -> Boolean) where T: Fragment, T: EuiccChannelFragmentMarker {
|
||||||
|
withContext(Dispatchers.IO) {
|
||||||
|
euiccChannelManager.beginTrackedOperationBlocking(slotId, portId) {
|
||||||
|
op()
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
|
@ -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('$')
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -3,6 +3,9 @@ package im.angry.openeuicc.util
|
||||||
import android.util.Log
|
import android.util.Log
|
||||||
import im.angry.openeuicc.core.EuiccChannel
|
import im.angry.openeuicc.core.EuiccChannel
|
||||||
import im.angry.openeuicc.core.EuiccChannelManager
|
import im.angry.openeuicc.core.EuiccChannelManager
|
||||||
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.runBlocking
|
||||||
|
import kotlinx.coroutines.withContext
|
||||||
import net.typeblog.lpac_jni.LocalProfileAssistant
|
import net.typeblog.lpac_jni.LocalProfileAssistant
|
||||||
import net.typeblog.lpac_jni.LocalProfileInfo
|
import net.typeblog.lpac_jni.LocalProfileInfo
|
||||||
|
|
||||||
|
@ -16,10 +19,9 @@ val LocalProfileInfo.isEnabled: Boolean
|
||||||
get() = state == LocalProfileInfo.State.Enabled
|
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
|
val List<EuiccChannel>.hasMultipleChips: Boolean
|
||||||
get() = distinctBy { it.slotId }.size > 1
|
get() = distinctBy { it.slotId }.size > 1
|
||||||
|
@ -40,27 +42,22 @@ fun LocalProfileAssistant.switchProfile(
|
||||||
* See EuiccManager.waitForReconnect()
|
* See EuiccManager.waitForReconnect()
|
||||||
*/
|
*/
|
||||||
fun LocalProfileAssistant.disableActiveProfile(refresh: Boolean): Boolean =
|
fun LocalProfileAssistant.disableActiveProfile(refresh: Boolean): Boolean =
|
||||||
profiles.enabled?.let {
|
profiles.find { it.isEnabled }?.let {
|
||||||
Log.i(TAG, "Disabling active profile ${it.iccid}")
|
Log.i(TAG, "Disabling active profile ${it.iccid}")
|
||||||
disableProfile(it.iccid, refresh)
|
disableProfile(it.iccid, refresh)
|
||||||
} ?: true
|
} ?: true
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Disable the current active profile if any. If refresh is true, also cause a refresh command.
|
* Disable the active profile, return a lambda that reverts this action when called.
|
||||||
|
* If refreshOnDisable is true, also cause a eUICC refresh command. Note that refreshing
|
||||||
|
* will disconnect the eUICC and might need some time before being operational again.
|
||||||
* See EuiccManager.waitForReconnect()
|
* 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? =
|
fun LocalProfileAssistant.disableActiveProfileWithUndo(refreshOnDisable: Boolean): () -> Unit =
|
||||||
profiles.enabled?.let {
|
profiles.find { it.isEnabled }?.let {
|
||||||
Log.i(TAG, "Disabling active profile ${it.iccid}")
|
disableProfile(it.iccid, refreshOnDisable)
|
||||||
if (disableProfile(it.iccid, refresh)) {
|
return { enableProfile(it.iccid) }
|
||||||
it.iccid
|
} ?: { }
|
||||||
} else {
|
|
||||||
null
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Begin a "tracked" operation where notifications may be generated by the eSIM
|
* Begin a "tracked" operation where notifications may be generated by the eSIM
|
||||||
|
@ -76,26 +73,29 @@ fun LocalProfileAssistant.disableActiveProfileKeepIccId(refresh: Boolean): Strin
|
||||||
* should be the concern of op() itself, and this function assumes that when
|
* 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.
|
* op() returns, the slotId and portId will correspond to a valid channel again.
|
||||||
*/
|
*/
|
||||||
suspend inline fun EuiccChannelManager.beginTrackedOperation(
|
inline fun EuiccChannelManager.beginTrackedOperationBlocking(
|
||||||
slotId: Int,
|
slotId: Int,
|
||||||
portId: Int,
|
portId: Int,
|
||||||
op: () -> Boolean
|
op: () -> Boolean
|
||||||
) {
|
) {
|
||||||
val latestSeq = withEuiccChannel(slotId, portId) { channel ->
|
val latestSeq =
|
||||||
channel.lpa.notifications.firstOrNull()?.seqNumber
|
findEuiccChannelByPortBlocking(slotId, portId)!!.lpa.notifications.firstOrNull()?.seqNumber
|
||||||
?: 0
|
?: 0
|
||||||
}
|
|
||||||
Log.d(TAG, "Latest notification is $latestSeq before operation")
|
Log.d(TAG, "Latest notification is $latestSeq before operation")
|
||||||
if (op()) {
|
if (op()) {
|
||||||
Log.d(TAG, "Operation has requested notification handling")
|
Log.d(TAG, "Operation has requested notification handling")
|
||||||
try {
|
try {
|
||||||
// Note that the exact instance of "channel" might have changed here if reconnected;
|
// 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()
|
// so we MUST use the automatic getter for "channel"
|
||||||
withEuiccChannel(slotId, portId) { channel ->
|
findEuiccChannelByPortBlocking(
|
||||||
channel.lpa.notifications.filter { it.seqNumber > latestSeq }.forEach {
|
slotId,
|
||||||
Log.d(TAG, "Handling notification $it")
|
portId
|
||||||
channel.lpa.handleNotification(it.seqNumber)
|
)?.lpa?.notifications?.filter { it.seqNumber > latestSeq }?.forEach {
|
||||||
}
|
Log.d(TAG, "Handling notification $it")
|
||||||
|
findEuiccChannelByPortBlocking(
|
||||||
|
slotId,
|
||||||
|
portId
|
||||||
|
)?.lpa?.handleNotification(it.seqNumber)
|
||||||
}
|
}
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
// Ignore any error during notification handling
|
// Ignore any error during notification handling
|
||||||
|
|
|
@ -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_SWITCH = booleanPreferencesKey("notification_switch")
|
||||||
|
|
||||||
// ---- Advanced ----
|
|
||||||
val DISABLE_SAFEGUARD_REMOVABLE_ESIM = booleanPreferencesKey("disable_safeguard_removable_esim")
|
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)
|
|
||||||
|
val notificationDeleteFlow: Flow<Boolean> =
|
||||||
|
dataStore.data.map { it[PreferenceKeys.NOTIFICATION_DELETE] ?: true }
|
||||||
|
|
||||||
|
val notificationSwitchFlow: Flow<Boolean> =
|
||||||
|
dataStore.data.map { it[PreferenceKeys.NOTIFICATION_SWITCH] ?: false }
|
||||||
|
|
||||||
// ---- Advanced ----
|
// ---- Advanced ----
|
||||||
val disableSafeguardFlow = bindFlow(PreferenceKeys.DISABLE_SAFEGUARD_REMOVABLE_ESIM, false)
|
val disableSafeguardFlow: Flow<Boolean> =
|
||||||
val verboseLoggingFlow = bindFlow(PreferenceKeys.VERBOSE_LOGGING, false)
|
dataStore.data.map { it[PreferenceKeys.DISABLE_SAFEGUARD_REMOVABLE_ESIM] ?: false }
|
||||||
|
|
||||||
// ---- Developer Options ----
|
suspend fun <T> updatePreference(key: Preferences.Key<T>, value: T) {
|
||||||
val refreshAfterSwitchFlow = bindFlow(PreferenceKeys.REFRESH_AFTER_SWITCH, true)
|
dataStore.edit {
|
||||||
val developerOptionsEnabledFlow = bindFlow(PreferenceKeys.DEVELOPER_OPTIONS_ENABLED, false)
|
it[key] = value
|
||||||
val unfilteredProfileListFlow = bindFlow(PreferenceKeys.UNFILTERED_PROFILE_LIST, false)
|
}
|
||||||
val ignoreTLSCertificateFlow = bindFlow(PreferenceKeys.IGNORE_TLS_CERTIFICATE, false)
|
|
||||||
val euiccMemoryResetFlow = bindFlow(PreferenceKeys.EUICC_MEMORY_RESET, false)
|
|
||||||
val isdrAidListFlow = bindFlow(
|
|
||||||
PreferenceKeys.ISDR_AID_LIST,
|
|
||||||
PreferenceConstants.DEFAULT_AID_LIST,
|
|
||||||
{ Base64.getEncoder().encodeToString(it.encodeToByteArray()) },
|
|
||||||
{ Base64.getDecoder().decode(it).decodeToString() })
|
|
||||||
|
|
||||||
protected fun <T> bindFlow(
|
|
||||||
key: Preferences.Key<T>,
|
|
||||||
defaultValue: T,
|
|
||||||
encoder: (T) -> T = { it },
|
|
||||||
decoder: (T) -> T = { it }
|
|
||||||
): PreferenceFlowWrapper<T> =
|
|
||||||
PreferenceFlowWrapper(context, key, defaultValue, encoder, decoder)
|
|
||||||
}
|
|
||||||
|
|
||||||
class PreferenceFlowWrapper<T> private constructor(
|
|
||||||
private val context: Context,
|
|
||||||
private val key: Preferences.Key<T>,
|
|
||||||
inner: Flow<T>,
|
|
||||||
private val encoder: (T) -> T,
|
|
||||||
) : Flow<T> by inner {
|
|
||||||
internal constructor(
|
|
||||||
context: Context,
|
|
||||||
key: Preferences.Key<T>,
|
|
||||||
defaultValue: T,
|
|
||||||
encoder: (T) -> T,
|
|
||||||
decoder: (T) -> T
|
|
||||||
) : this(
|
|
||||||
context,
|
|
||||||
key,
|
|
||||||
context.dataStore.data.map { it[key]?.let(decoder) ?: defaultValue },
|
|
||||||
encoder
|
|
||||||
)
|
|
||||||
|
|
||||||
suspend fun updatePreference(value: T) {
|
|
||||||
context.dataStore.edit { it[key] = encoder(value) }
|
|
||||||
}
|
}
|
||||||
|
}
|
||||||
suspend fun removePreference() {
|
|
||||||
context.dataStore.edit { it.remove(key) }
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -1,7 +1,7 @@
|
||||||
package im.angry.openeuicc.util
|
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)
|
||||||
|
@ -27,87 +27,4 @@ fun formatFreeSpace(size: Int): String =
|
||||||
"%.2f KiB".format(size.toDouble() / 1024)
|
"%.2f KiB".format(size.toDouble() / 1024)
|
||||||
} else {
|
} else {
|
||||||
"$size B"
|
"$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)
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -54,9 +54,6 @@ interface OpenEuiccContextMarker {
|
||||||
val appContainer: AppContainer
|
val appContainer: AppContainer
|
||||||
get() = openEuiccApplication.appContainer
|
get() = openEuiccApplication.appContainer
|
||||||
|
|
||||||
val preferenceRepository: PreferenceRepository
|
|
||||||
get() = appContainer.preferenceRepository
|
|
||||||
|
|
||||||
val telephonyManager: TelephonyManager
|
val telephonyManager: TelephonyManager
|
||||||
get() = appContainer.telephonyManager
|
get() = appContainer.telephonyManager
|
||||||
}
|
}
|
||||||
|
@ -89,13 +86,6 @@ 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? =
|
fun decodeQrFromBitmap(bmp: Bitmap): String? =
|
||||||
runCatching {
|
runCatching {
|
||||||
val pixels = IntArray(bmp.width * bmp.height)
|
val pixels = IntArray(bmp.width * bmp.height)
|
||||||
|
|
|
@ -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,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 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"
|
||||||
|
|
|
@ -5,7 +5,13 @@
|
||||||
android:layout_width="match_parent"
|
android:layout_width="match_parent"
|
||||||
android:layout_height="match_parent">
|
android:layout_height="match_parent">
|
||||||
|
|
||||||
<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" />
|
||||||
|
|
||||||
<com.google.android.material.tabs.TabLayout
|
<com.google.android.material.tabs.TabLayout
|
||||||
android:id="@+id/main_tabs"
|
android:id="@+id/main_tabs"
|
||||||
|
|
|
@ -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>
|
|
|
@ -1,30 +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"
|
|
||||||
android:background="?android:attr/selectableItemBackground"
|
|
||||||
xmlns:app="http://schemas.android.com/apk/res-auto">
|
|
||||||
|
|
||||||
<TextView
|
|
||||||
android:id="@+id/euicc_info_title"
|
|
||||||
android:layout_width="0dp"
|
|
||||||
android:layout_height="wrap_content"
|
|
||||||
android:layout_marginHorizontal="24dp"
|
|
||||||
android:layout_marginVertical="12dp"
|
|
||||||
android:textStyle="bold"
|
|
||||||
app:layout_constraintStart_toStartOf="parent"
|
|
||||||
app:layout_constraintEnd_toEndOf="parent"
|
|
||||||
app:layout_constraintTop_toTopOf="parent" />
|
|
||||||
|
|
||||||
<TextView
|
|
||||||
android:id="@+id/euicc_info_content"
|
|
||||||
android:layout_width="0dp"
|
|
||||||
android:layout_height="wrap_content"
|
|
||||||
android:layout_marginHorizontal="24dp"
|
|
||||||
android:layout_marginVertical="12dp"
|
|
||||||
app:layout_constraintStart_toStartOf="parent"
|
|
||||||
app:layout_constraintEnd_toEndOf="parent"
|
|
||||||
app:layout_constraintTop_toBottomOf="@id/euicc_info_title"
|
|
||||||
app:layout_constraintBottom_toBottomOf="parent" />
|
|
||||||
|
|
||||||
</androidx.constraintlayout.widget.ConstraintLayout>
|
|
|
@ -28,8 +28,7 @@
|
||||||
app:layout_constraintRight_toLeftOf="@+id/profile_menu"
|
app:layout_constraintRight_toLeftOf="@+id/profile_menu"
|
||||||
app:layout_constraintTop_toTopOf="parent"
|
app:layout_constraintTop_toTopOf="parent"
|
||||||
app:layout_constraintBottom_toTopOf="@+id/state"
|
app:layout_constraintBottom_toTopOf="@+id/state"
|
||||||
app:layout_constraintHorizontal_bias="0"
|
app:layout_constraintHorizontal_bias="0" />
|
||||||
app:layout_constrainedWidth="true" />
|
|
||||||
|
|
||||||
<androidx.appcompat.widget.AppCompatImageButton
|
<androidx.appcompat.widget.AppCompatImageButton
|
||||||
android:id="@+id/profile_menu"
|
android:id="@+id/profile_menu"
|
||||||
|
@ -63,45 +62,18 @@
|
||||||
android:singleLine="true"
|
android:singleLine="true"
|
||||||
app:layout_constraintLeft_toLeftOf="parent"
|
app:layout_constraintLeft_toLeftOf="parent"
|
||||||
app:layout_constraintTop_toBottomOf="@id/state"
|
app:layout_constraintTop_toBottomOf="@id/state"
|
||||||
app:layout_constraintBottom_toTopOf="@+id/profile_class_label"/>
|
app:layout_constraintBottom_toTopOf="@+id/iccid_label"/>
|
||||||
|
|
||||||
<TextView
|
<TextView
|
||||||
android:id="@+id/provider"
|
android:id="@+id/provider"
|
||||||
android:layout_width="wrap_content"
|
android:layout_width="wrap_content"
|
||||||
android:layout_height="wrap_content"
|
android:layout_height="wrap_content"
|
||||||
android:layout_marginTop="6dp"
|
android:layout_marginTop="6dp"
|
||||||
android:layout_marginStart="7dp"
|
android:layout_marginLeft="7dp"
|
||||||
android:textSize="14sp"
|
android:textSize="14sp"
|
||||||
android:singleLine="true"
|
android:singleLine="true"
|
||||||
app:layout_constraintLeft_toRightOf="@id/provider_label"
|
app:layout_constraintLeft_toRightOf="@id/provider_label"
|
||||||
app:layout_constraintTop_toBottomOf="@id/state"
|
app:layout_constraintTop_toBottomOf="@id/state"
|
||||||
app:layout_constraintBottom_toTopOf="@+id/profile_class"/>
|
|
||||||
|
|
||||||
<TextView
|
|
||||||
android:id="@+id/profile_class_label"
|
|
||||||
android:text="@string/profile_class"
|
|
||||||
android:layout_width="wrap_content"
|
|
||||||
android:layout_height="wrap_content"
|
|
||||||
android:layout_marginTop="6dp"
|
|
||||||
android:textSize="14sp"
|
|
||||||
android:textStyle="bold"
|
|
||||||
android:singleLine="true"
|
|
||||||
android:visibility="gone"
|
|
||||||
app:layout_constraintLeft_toLeftOf="parent"
|
|
||||||
app:layout_constraintTop_toBottomOf="@id/provider_label"
|
|
||||||
app:layout_constraintBottom_toTopOf="@+id/iccid_label"/>
|
|
||||||
|
|
||||||
<TextView
|
|
||||||
android:id="@+id/profile_class"
|
|
||||||
android:layout_width="wrap_content"
|
|
||||||
android:layout_height="wrap_content"
|
|
||||||
android:layout_marginTop="6dp"
|
|
||||||
android:layout_marginStart="7dp"
|
|
||||||
android:textSize="14sp"
|
|
||||||
android:singleLine="true"
|
|
||||||
android:visibility="gone"
|
|
||||||
app:layout_constraintLeft_toRightOf="@id/profile_class_label"
|
|
||||||
app:layout_constraintTop_toBottomOf="@id/provider"
|
|
||||||
app:layout_constraintBottom_toTopOf="@+id/iccid"/>
|
app:layout_constraintBottom_toTopOf="@+id/iccid"/>
|
||||||
|
|
||||||
<TextView
|
<TextView
|
||||||
|
@ -114,7 +86,7 @@
|
||||||
android:textStyle="bold"
|
android:textStyle="bold"
|
||||||
android:singleLine="true"
|
android:singleLine="true"
|
||||||
app:layout_constraintLeft_toLeftOf="parent"
|
app:layout_constraintLeft_toLeftOf="parent"
|
||||||
app:layout_constraintTop_toBottomOf="@id/profile_class_label"
|
app:layout_constraintTop_toBottomOf="@id/provider_label"
|
||||||
app:layout_constraintBottom_toBottomOf="parent"/>
|
app:layout_constraintBottom_toBottomOf="parent"/>
|
||||||
|
|
||||||
<TextView
|
<TextView
|
||||||
|
@ -122,11 +94,11 @@
|
||||||
android:layout_width="wrap_content"
|
android:layout_width="wrap_content"
|
||||||
android:layout_height="wrap_content"
|
android:layout_height="wrap_content"
|
||||||
android:layout_marginTop="6dp"
|
android:layout_marginTop="6dp"
|
||||||
android:layout_marginStart="7dp"
|
android:layout_marginLeft="7dp"
|
||||||
android:textSize="14sp"
|
android:textSize="14sp"
|
||||||
android:singleLine="true"
|
android:singleLine="true"
|
||||||
app:layout_constraintLeft_toRightOf="@id/iccid_label"
|
app:layout_constraintLeft_toRightOf="@id/iccid_label"
|
||||||
app:layout_constraintTop_toBottomOf="@id/profile_class"
|
app:layout_constraintTop_toBottomOf="@id/provider"
|
||||||
app:layout_constraintBottom_toBottomOf="parent"/>
|
app:layout_constraintBottom_toBottomOf="parent"/>
|
||||||
|
|
||||||
</androidx.constraintlayout.widget.ConstraintLayout>
|
</androidx.constraintlayout.widget.ConstraintLayout>
|
||||||
|
|
|
@ -1,104 +0,0 @@
|
||||||
<?xml version="1.0" encoding="utf-8"?>
|
|
||||||
<ScrollView xmlns:android="http://schemas.android.com/apk/res/android"
|
|
||||||
xmlns:app="http://schemas.android.com/apk/res-auto"
|
|
||||||
android:layout_width="match_parent"
|
|
||||||
android:layout_height="match_parent"
|
|
||||||
android:fillViewport="true">
|
|
||||||
|
|
||||||
<androidx.constraintlayout.widget.ConstraintLayout
|
|
||||||
android:layout_width="match_parent"
|
|
||||||
android:layout_height="wrap_content">
|
|
||||||
|
|
||||||
<TextView
|
|
||||||
android:id="@+id/download_wizard_details_title"
|
|
||||||
android:text="@string/download_wizard_details"
|
|
||||||
android:layout_width="wrap_content"
|
|
||||||
android:layout_height="wrap_content"
|
|
||||||
android:gravity="center_horizontal"
|
|
||||||
android:textSize="20sp"
|
|
||||||
android:layout_marginTop="20dp"
|
|
||||||
android:layout_marginBottom="20dp"
|
|
||||||
android:layout_marginStart="60dp"
|
|
||||||
android:layout_marginEnd="60dp"
|
|
||||||
app:layout_constraintStart_toStartOf="parent"
|
|
||||||
app:layout_constraintEnd_toEndOf="parent"
|
|
||||||
app:layout_constrainedWidth="true"
|
|
||||||
app:layout_constraintTop_toTopOf="parent" />
|
|
||||||
|
|
||||||
<com.google.android.material.textfield.TextInputLayout
|
|
||||||
android:id="@+id/profile_download_server"
|
|
||||||
android:layout_width="0dp"
|
|
||||||
android:layout_height="wrap_content"
|
|
||||||
android:hint="@string/profile_download_server">
|
|
||||||
|
|
||||||
<com.google.android.material.textfield.TextInputEditText
|
|
||||||
android:maxLines="1"
|
|
||||||
android:inputType="text"
|
|
||||||
android:layout_width="match_parent"
|
|
||||||
android:layout_height="match_parent" />
|
|
||||||
|
|
||||||
</com.google.android.material.textfield.TextInputLayout>
|
|
||||||
|
|
||||||
<com.google.android.material.textfield.TextInputLayout
|
|
||||||
android:id="@+id/profile_download_code"
|
|
||||||
android:layout_width="0dp"
|
|
||||||
android:layout_height="wrap_content"
|
|
||||||
android:hint="@string/profile_download_code"
|
|
||||||
app:passwordToggleEnabled="true">
|
|
||||||
|
|
||||||
<com.google.android.material.textfield.TextInputEditText
|
|
||||||
android:maxLines="1"
|
|
||||||
android:layout_width="match_parent"
|
|
||||||
android:layout_height="match_parent"
|
|
||||||
android:inputType="textPassword" />
|
|
||||||
|
|
||||||
</com.google.android.material.textfield.TextInputLayout>
|
|
||||||
|
|
||||||
<com.google.android.material.textfield.TextInputLayout
|
|
||||||
android:id="@+id/profile_download_confirmation_code"
|
|
||||||
android:layout_width="0dp"
|
|
||||||
android:layout_height="wrap_content"
|
|
||||||
android:hint="@string/profile_download_confirmation_code"
|
|
||||||
app:passwordToggleEnabled="true">
|
|
||||||
|
|
||||||
<com.google.android.material.textfield.TextInputEditText
|
|
||||||
android:maxLines="1"
|
|
||||||
android:layout_width="match_parent"
|
|
||||||
android:layout_height="match_parent"
|
|
||||||
android:inputType="textPassword" />
|
|
||||||
|
|
||||||
</com.google.android.material.textfield.TextInputLayout>
|
|
||||||
|
|
||||||
<com.google.android.material.textfield.TextInputLayout
|
|
||||||
android:id="@+id/profile_download_imei"
|
|
||||||
android:layout_width="0dp"
|
|
||||||
android:layout_height="wrap_content"
|
|
||||||
android:layout_marginTop="15dp"
|
|
||||||
android:layout_marginBottom="6dp"
|
|
||||||
android:hint="@string/profile_download_imei"
|
|
||||||
app:passwordToggleEnabled="true">
|
|
||||||
|
|
||||||
<com.google.android.material.textfield.TextInputEditText
|
|
||||||
android:maxLines="1"
|
|
||||||
android:layout_width="match_parent"
|
|
||||||
android:layout_height="match_parent"
|
|
||||||
android:inputType="numberPassword" />
|
|
||||||
|
|
||||||
</com.google.android.material.textfield.TextInputLayout>
|
|
||||||
|
|
||||||
<androidx.constraintlayout.helper.widget.Flow
|
|
||||||
android:layout_width="match_parent"
|
|
||||||
android:layout_height="wrap_content"
|
|
||||||
android:orientation="vertical"
|
|
||||||
android:layout_marginHorizontal="20dp"
|
|
||||||
app:constraint_referenced_ids="profile_download_server,profile_download_code,profile_download_confirmation_code,profile_download_imei"
|
|
||||||
app:flow_verticalGap="16dp"
|
|
||||||
app:layout_constraintStart_toStartOf="parent"
|
|
||||||
app:layout_constraintTop_toBottomOf="@id/download_wizard_details_title"
|
|
||||||
app:layout_constraintBottom_toBottomOf="parent"
|
|
||||||
app:layout_constraintEnd_toEndOf="parent"
|
|
||||||
app:layout_constrainedWidth="true" />
|
|
||||||
|
|
||||||
</androidx.constraintlayout.widget.ConstraintLayout>
|
|
||||||
|
|
||||||
</ScrollView>
|
|
|
@ -1,59 +0,0 @@
|
||||||
<?xml version="1.0" encoding="utf-8"?>
|
|
||||||
<ScrollView xmlns:android="http://schemas.android.com/apk/res/android"
|
|
||||||
xmlns:app="http://schemas.android.com/apk/res-auto"
|
|
||||||
android:layout_width="match_parent"
|
|
||||||
android:layout_height="match_parent"
|
|
||||||
xmlns:tools="http://schemas.android.com/tools"
|
|
||||||
android:fillViewport="true">
|
|
||||||
|
|
||||||
<androidx.constraintlayout.widget.ConstraintLayout
|
|
||||||
android:layout_width="match_parent"
|
|
||||||
android:layout_height="wrap_content">
|
|
||||||
|
|
||||||
<TextView
|
|
||||||
android:id="@+id/download_wizard_diagnostics_title"
|
|
||||||
android:text="@string/download_wizard_diagnostics"
|
|
||||||
android:layout_width="wrap_content"
|
|
||||||
android:layout_height="wrap_content"
|
|
||||||
android:gravity="center_horizontal"
|
|
||||||
android:textSize="20sp"
|
|
||||||
android:layout_marginTop="20dp"
|
|
||||||
android:layout_marginBottom="20dp"
|
|
||||||
android:layout_marginStart="60dp"
|
|
||||||
android:layout_marginEnd="60dp"
|
|
||||||
app:layout_constraintStart_toStartOf="parent"
|
|
||||||
app:layout_constraintEnd_toEndOf="parent"
|
|
||||||
app:layout_constrainedWidth="true"
|
|
||||||
app:layout_constraintTop_toTopOf="parent" />
|
|
||||||
|
|
||||||
<ImageButton
|
|
||||||
android:id="@+id/download_wizard_diagnostics_save"
|
|
||||||
android:src="@drawable/ic_save_as_black"
|
|
||||||
android:layout_margin="20dp"
|
|
||||||
android:layout_width="24dp"
|
|
||||||
android:layout_height="24dp"
|
|
||||||
android:contentDescription="@string/download_wizard_diagnostics_save"
|
|
||||||
app:tint="?attr/colorAccent"
|
|
||||||
app:layout_constraintTop_toTopOf="parent"
|
|
||||||
app:layout_constraintEnd_toEndOf="parent" />
|
|
||||||
|
|
||||||
<TextView
|
|
||||||
android:id="@+id/download_wizard_diagnostics_text"
|
|
||||||
android:layout_width="match_parent"
|
|
||||||
android:layout_height="wrap_content"
|
|
||||||
android:padding="10dp"
|
|
||||||
android:textIsSelectable="true"
|
|
||||||
android:focusable="true"
|
|
||||||
android:textSize="10sp"
|
|
||||||
android:fontFamily="monospace"
|
|
||||||
android:lineSpacingMultiplier="1.1"
|
|
||||||
android:longClickable="true"
|
|
||||||
app:layout_constraintTop_toBottomOf="@id/download_wizard_diagnostics_title"
|
|
||||||
app:layout_constraintStart_toStartOf="parent"
|
|
||||||
app:layout_constraintEnd_toEndOf="parent"
|
|
||||||
app:layout_constraintBottom_toBottomOf="parent"
|
|
||||||
tools:ignore="SmallSp" />
|
|
||||||
|
|
||||||
</androidx.constraintlayout.widget.ConstraintLayout>
|
|
||||||
|
|
||||||
</ScrollView>
|
|
|
@ -1,33 +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">
|
|
||||||
|
|
||||||
<TextView
|
|
||||||
android:id="@+id/download_method_select_title"
|
|
||||||
android:text="@string/download_wizard_method_select"
|
|
||||||
android:layout_width="wrap_content"
|
|
||||||
android:layout_height="wrap_content"
|
|
||||||
android:gravity="center_horizontal"
|
|
||||||
android:textSize="20sp"
|
|
||||||
android:layout_marginTop="20dp"
|
|
||||||
android:layout_marginBottom="20dp"
|
|
||||||
android:layout_marginStart="60dp"
|
|
||||||
android:layout_marginEnd="60dp"
|
|
||||||
app:layout_constraintStart_toStartOf="parent"
|
|
||||||
app:layout_constraintEnd_toEndOf="parent"
|
|
||||||
app:layout_constrainedWidth="true"
|
|
||||||
app:layout_constraintTop_toTopOf="parent" />
|
|
||||||
|
|
||||||
<androidx.recyclerview.widget.RecyclerView
|
|
||||||
android:id="@+id/download_method_list"
|
|
||||||
android:layout_width="0dp"
|
|
||||||
android:layout_height="wrap_content"
|
|
||||||
app:layout_constraintTop_toBottomOf="@id/download_method_select_title"
|
|
||||||
app:layout_constraintStart_toStartOf="parent"
|
|
||||||
app:layout_constraintEnd_toEndOf="parent"
|
|
||||||
app:layout_constraintBottom_toBottomOf="parent"
|
|
||||||
app:layout_constrainedHeight="true" />
|
|
||||||
|
|
||||||
</androidx.constraintlayout.widget.ConstraintLayout>
|
|
|
@ -1,33 +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">
|
|
||||||
|
|
||||||
<TextView
|
|
||||||
android:id="@+id/download_progress_title"
|
|
||||||
android:text="@string/download_wizard_progress"
|
|
||||||
android:layout_width="wrap_content"
|
|
||||||
android:layout_height="wrap_content"
|
|
||||||
android:gravity="center_horizontal"
|
|
||||||
android:textSize="20sp"
|
|
||||||
android:layout_marginTop="20dp"
|
|
||||||
android:layout_marginBottom="20dp"
|
|
||||||
android:layout_marginStart="60dp"
|
|
||||||
android:layout_marginEnd="60dp"
|
|
||||||
app:layout_constraintStart_toStartOf="parent"
|
|
||||||
app:layout_constraintEnd_toEndOf="parent"
|
|
||||||
app:layout_constrainedWidth="true"
|
|
||||||
app:layout_constraintTop_toTopOf="parent" />
|
|
||||||
|
|
||||||
<androidx.recyclerview.widget.RecyclerView
|
|
||||||
android:id="@+id/download_progress_list"
|
|
||||||
android:layout_width="0dp"
|
|
||||||
android:layout_height="wrap_content"
|
|
||||||
app:layout_constraintTop_toBottomOf="@id/download_progress_title"
|
|
||||||
app:layout_constraintStart_toStartOf="parent"
|
|
||||||
app:layout_constraintEnd_toEndOf="parent"
|
|
||||||
app:layout_constraintBottom_toBottomOf="parent"
|
|
||||||
app:layout_constrainedHeight="true" />
|
|
||||||
|
|
||||||
</androidx.constraintlayout.widget.ConstraintLayout>
|
|
|
@ -1,33 +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">
|
|
||||||
|
|
||||||
<TextView
|
|
||||||
android:id="@+id/download_slot_select_title"
|
|
||||||
android:text="@string/download_wizard_slot_select"
|
|
||||||
android:layout_width="wrap_content"
|
|
||||||
android:layout_height="wrap_content"
|
|
||||||
android:gravity="center_horizontal"
|
|
||||||
android:textSize="20sp"
|
|
||||||
android:layout_marginTop="20dp"
|
|
||||||
android:layout_marginBottom="20dp"
|
|
||||||
android:layout_marginStart="60dp"
|
|
||||||
android:layout_marginEnd="60dp"
|
|
||||||
app:layout_constraintStart_toStartOf="parent"
|
|
||||||
app:layout_constraintEnd_toEndOf="parent"
|
|
||||||
app:layout_constrainedWidth="true"
|
|
||||||
app:layout_constraintTop_toTopOf="parent" />
|
|
||||||
|
|
||||||
<androidx.recyclerview.widget.RecyclerView
|
|
||||||
android:id="@+id/download_slot_list"
|
|
||||||
android:layout_width="0dp"
|
|
||||||
android:layout_height="wrap_content"
|
|
||||||
app:layout_constraintTop_toBottomOf="@id/download_slot_select_title"
|
|
||||||
app:layout_constraintStart_toStartOf="parent"
|
|
||||||
app:layout_constraintEnd_toEndOf="parent"
|
|
||||||
app:layout_constraintBottom_toBottomOf="parent"
|
|
||||||
app:layout_constrainedHeight="true" />
|
|
||||||
|
|
||||||
</androidx.constraintlayout.widget.ConstraintLayout>
|
|
Some files were not shown because too many files have changed in this diff Show more
Loading…
Add table
Reference in a new issue