Compare commits

...

193 commits

Author SHA1 Message Date
c60ba8e103 JMP SIM Manager v1.2.1
All checks were successful
/ build-debug (push) Successful in 6m13s
/ release (push) Successful in 6m24s
This is a release only to fix F-Droid builds; no actual change to app is
made.
2025-01-26 16:30:41 -05:00
cb7d6a5fc2 jmp: Exclude dependency metadata from apks to fix F-Droid verification
All checks were successful
/ build-debug (push) Successful in 4m50s
2025-01-26 16:23:24 -05:00
b4ea193de2 JMP SIM Manager v1.2.0
All checks were successful
/ build-debug (push) Successful in 5m44s
/ release (push) Successful in 6m2s
2024-12-22 11:49:53 -05:00
654932a9f0 i18n: typo
All checks were successful
/ build-debug (push) Successful in 4m27s
2024-12-22 11:09:56 -05:00
a46849de40 i18n: Translate JMP-specific strings
All checks were successful
/ build-debug (push) Successful in 4m33s
2024-12-22 10:50:29 -05:00
0818abf71b jmp: Migrate customized text to CustomizableTextProvider
All checks were successful
/ build-debug (push) Successful in 4m33s
2024-12-22 10:35:54 -05:00
0673cf370a Merge remote-tracking branch 'openeuicc/master' into jmp
All checks were successful
/ build-debug (push) Successful in 4m30s
Conflicts:
	.forgejo/workflows/build-debug.yml
	app-unpriv/src/main/java/im/angry/openeuicc/di/UnprivilegedUiComponentFactory.kt
2024-12-22 08:56:31 -05:00
bcd1295a18 ui: Disable language picker for privileged OpenEUICC (for now)
...because AOSP is stupid and doesn't allow apps signed with the
platform key to use language settings.
2024-12-21 15:01:07 -05:00
50ba81f131 i18n: Update translations of all new strings 2024-12-21 14:31:14 -05:00
d0b3d54c66 ui: Allow multi-line strings in EuiccInfoActivity 2024-12-20 19:33:43 -05:00
3a860601a3 ui: Optimize ATR strings 2024-12-20 19:32:58 -05:00
6b4723daee refactor: ATR should not be the concern of lpac-jni
...instead, use a separate interface to represent channel types that do
support reading ATR.
2024-12-20 19:30:33 -05:00
3ef78a23db feat: atr in euiccinfo activity
commit 0fbec512ab7dd8be207bb771129a29eb5f9434a8
Author: septs <github@septs.pw>
Date:   Wed Dec 18 21:27:53 2024 +0800

    feat: atr in euiccinfo activity
2024-12-20 19:06:23 -05:00
31d595a6b1 ui: Don't reset profile name while resuming the rename fragment 2024-12-20 19:04:31 -05:00
e7ef370e46 feat: sgp.22 version in euicc info activity (#130)
Co-authored-by: Peter Cai <peter@typeblog.net>
Reviewed-on: PeterCxy/OpenEUICC#130
Co-authored-by: septs <github@septs.pw>
Co-committed-by: septs <github@septs.pw>
2024-12-20 22:56:31 +01:00
653a7b32ee ui: wizard: Prevent clicking next multiple times 2024-12-18 21:13:08 -05:00
0f8749ee04 ui: wizard: Verify the EuiccChannel is still valid every time next is pressed 2024-12-18 21:11:15 -05:00
c0a6917645 Remove unused isForegroundTaskRunning
idk what this was used for
2024-12-18 20:57:37 -05:00
6e3176668a core: Reconnect to USB readers after switching profiles as well
Apparently some reader + card combination results in the need to
re-establish the ISD-R even though this is not a modem.

It doesn't hurt to run waitForReconnect() anyway :)
2024-12-18 20:44:08 -05:00
66bee041a0 ui: wizard: IMEI input type should be numberPassword 2024-12-18 20:05:27 -05:00
43f247a71b fix: share file name display (#122)
Co-authored-by: Peter Cai <peter@typeblog.net>
Reviewed-on: PeterCxy/OpenEUICC#122
Co-authored-by: septs <github@septs.pw>
Co-committed-by: septs <github@septs.pw>
2024-12-19 00:19:34 +01:00
960f8855ad refactor: simplify profile operation (#129)
Co-authored-by: Peter Cai <peter@typeblog.net>
Reviewed-on: PeterCxy/OpenEUICC#129
Co-authored-by: septs <github@septs.pw>
Co-committed-by: septs <github@septs.pw>
2024-12-19 00:18:59 +01:00
de3ae19a10 ui: wizard: Resize activity root view with IME
...and hide the IME whenever we switch between the fragments.
2024-12-18 18:16:33 -05:00
75d3894462 ui: wizard: Save input state on pause
Fixes #131 and supersedes #132.
2024-12-18 18:02:24 -05:00
895899d03a i18n: Optimize strings and update translations 2024-12-17 22:34:06 -05:00
a87f154653 EuiccChannelManagerService: Stop throwing exceptions for USB channels when switching 2024-12-17 22:02:37 -05:00
b88345057c feat: add header to saved log file (#123)
Co-authored-by: Peter Cai <peter@typeblog.net>
Reviewed-on: PeterCxy/OpenEUICC#123
Co-authored-by: septs <github@septs.pw>
Co-committed-by: septs <github@septs.pw>
2024-12-18 01:28:24 +01:00
9596b8632c refactor: strong preference key constraint (#128)
Reviewed-on: PeterCxy/OpenEUICC#128
Co-authored-by: septs <github@septs.pw>
Co-committed-by: septs <github@septs.pw>
2024-12-18 01:26:06 +01:00
087c760010 fix: password mask toggle in profile download wizard (#125)
![image](/attachments/540449a1-7f12-4194-881f-21d0787101b8)

Reviewed-on: PeterCxy/OpenEUICC#125
Co-authored-by: septs <github@septs.pw>
Co-committed-by: septs <github@septs.pw>
2024-12-17 04:17:47 +01:00
24076e8fb4 ui: Remove old toast for profile name length
We'll add back i18n later.
2024-12-15 22:51:36 -05:00
f135a0da60 ui: Fixup rename error toasts 2024-12-15 22:49:02 -05:00
3b7bd8b31e fix: Validate nickname and convert to proper UTF-8 before passing to JNI
The JNI "modified" UTF-8 isn't what SGP.22 mandates. Let's encode
properly, validate the length, and pass the string as a C
null-terminated string directly over JNI.

This also introduces new exceptions that are exposed via UI as Toasts.
2024-12-15 16:02:27 -05:00
74e946cc8f i18n: Update message for LPA string parsing failure 2024-12-15 14:42:12 -05:00
3430406603 ui: wizard: Add toast for when clipboard is empty 2024-12-15 14:40:59 -05:00
24f04f54e4 Add icon for loading from clipboard 2024-12-15 14:37:30 -05:00
0fbda7dd78 feat: load lpa string from clipboard 2024-12-15 14:34:00 -05:00
905d0c897e ui: wizard: Save activity before showing nvram warning dialog 2024-12-15 13:19:23 -05:00
f395cee2e0 chore: cleanup unused resource from 343dfb43f8 (#119)
Reviewed-on: PeterCxy/OpenEUICC#119
Co-authored-by: septs <github@septs.pw>
Co-committed-by: septs <github@septs.pw>
2024-12-15 18:47:57 +01:00
456754db5d fix: move language switcher component name in advanced category (#118)
Co-authored-by: Peter Cai <peter@typeblog.net>
Reviewed-on: PeterCxy/OpenEUICC#118
Co-authored-by: septs <github@septs.pw>
Co-committed-by: septs <github@septs.pw>
2024-12-15 16:46:06 +01:00
7d1c7663bc fix: format channel name (#117)
Reviewed-on: PeterCxy/OpenEUICC#117
Co-authored-by: septs <github@septs.pw>
Co-committed-by: septs <github@septs.pw>
2024-12-15 16:42:59 +01:00
9d18253e44 ui: Cancel download from the low nvram warning dialog 2024-12-14 22:47:00 -05:00
6039679693 refactor: Remove the need for specifying preference keys when binding
Co-authored-by: septs <github@septs.pw>
2024-12-14 20:59:15 -05:00
5a8d92c3df feat: hide copied toast in android 13 or higher (#114)
see https://developer.android.com/develop/ui/views/touch-and-input/copy-paste#duplicate-notifications

Co-authored-by: Peter Cai <peter@typeblog.net>
Reviewed-on: PeterCxy/OpenEUICC#114
Co-authored-by: septs <github@septs.pw>
Co-committed-by: septs <github@septs.pw>
2024-12-15 02:44:44 +01:00
55c99831f3 ui: Copyable eid string
peter: redid parts of EuiccInfoViewHolder to remove the need for root
context (we can get it from activity directly).

Co-Authored-By: septs <github@septs.pw>

commit 2a6eb746bff034ed6032b8eac7462963bb39bcf1
Author: septs <github@septs.pw>
Date:   Sat Dec 14 11:26:42 2024 +0800

    feat: hide copied toast in android 13 or higher

commit d5bfd090b7b2e2c74c2cef698a43fc3607db260c
Merge: 0b2a498 aed2479
Author: septs <github@septs.pw>
Date:   Sat Dec 14 03:58:59 2024 +0800

    Merge branch 'master' into copyable-eid-string

commit 0b2a4988f2a2c2c7131a06867fc6bf622b281828
Author: septs <github@septs.pw>
Date:   Fri Dec 13 12:35:27 2024 +0800

    refactor: eid copied toast

commit ce854575aae304de68bddc0490998af5729632ab
Author: septs <github@septs.pw>
Date:   Fri Dec 13 12:27:18 2024 +0800

    revert: EuiccManagementFragment

commit 41229cac5727001ecb21992f5005b5b96b1d9557
Author: septs <github@septs.pw>
Date:   Fri Dec 13 12:27:10 2024 +0800

    revert: EuiccManagementFragment

commit 1f2b2d73ddfdc690442c0167e7f2f1748a78c921
Author: septs <github@septs.pw>
Date:   Fri Dec 13 12:25:01 2024 +0800

    feat: add euiccinfo activity specific strings

commit ca76ac841eab489ff1f825fef2f2aecd1ae88bb1
Merge: 110d490 55d96c6
Author: septs <github@septs.pw>
Date:   Fri Dec 13 12:23:34 2024 +0800

    Merge branch 'master' into copyable-eid-string

commit 110d49005a0cba26ee72eedfda0fc9e38a501d2a
Author: septs <github@septs.pw>
Date:   Fri Dec 13 12:23:21 2024 +0800

    revert: common strings

commit dc12e8d4107b9c80a1ca2bee0c5897f3e7d8dfa4
Author: septs <github@septs.pw>
Date:   Fri Dec 13 12:22:47 2024 +0800

    revert: unpriv strings

commit ccba9be141c533145464da3dbbc88afa16fac1fd
Merge: 34c5e41 0687354
Author: septs <github@septs.pw>
Date:   Fri Dec 13 12:15:42 2024 +0800

    Merge branch 'master' of ssh://gitea-ssh.angry.im:2222/PeterCxy/OpenEUICC into copyable-eid-string

commit 34c5e41dde663026b72b2c80301bfde2d14c0964
Author: septs <github@septs.pw>
Date:   Thu Dec 12 11:45:59 2024 +0800

    chore: simplify clickable

commit 4f1ed329f12b590691d273ac2150cb81ded11521
Author: septs <github@septs.pw>
Date:   Thu Dec 12 11:37:15 2024 +0800

    feat: copyable eid string
2024-12-14 20:38:24 -05:00
343dfb43f8 Remove unused SlotSelectFragment 2024-12-14 16:13:47 -05:00
815d4d4324 Expose USB device name as intrinsic name for use with download wizard 2024-12-14 16:11:49 -05:00
ec334d104a ui: Implement log content sharing 2024-12-14 15:18:47 -05:00
70f1e00eb4 refactor: Extract shared log-saving behavior
...so that we implement log-sharing once and apply it to both spots.
2024-12-14 14:55:27 -05:00
bc238c45cd ui: Add switching timeout message to the new DI text provider 2024-12-14 14:38:09 -05:00
14ea84c36e ui: Add placeholder text for when no eUICC is found to TextProvider 2024-12-14 14:21:05 -05:00
aefa79b18b refactor: Channel format should use DI instead of resource overriding
i18n makes resource overriding unreliable.
2024-12-14 13:47:23 -05:00
aed2479044 ui: wizard: Prevent de-selecting checkboxes 2024-12-13 08:34:21 -05:00
f294fb5e17 ui: wizard: Reintroduce support for downloading to USB readers
...and fixup our API which caused this problem in the first place.
2024-12-13 08:33:02 -05:00
55d96c6732 feat: if profile delete confirm text is mismatched then show toast (#110)
Reviewed-on: PeterCxy/OpenEUICC#110
Co-authored-by: septs <github@septs.pw>
Co-committed-by: septs <github@septs.pw>
2024-12-13 05:15:08 +01:00
06873545e2 refactor: certificate issuer detecting (#108)
Reviewed-on: PeterCxy/OpenEUICC#108
Co-authored-by: septs <github@septs.pw>
Co-committed-by: septs <github@septs.pw>
2024-12-13 04:41:03 +01:00
6d962a12b5 fix: euiccinfo activity channel title (#103)
Co-authored-by: Peter Cai <peter@typeblog.net>
Reviewed-on: PeterCxy/OpenEUICC#103
Co-authored-by: septs <github@septs.pw>
Co-committed-by: septs <github@septs.pw>
2024-12-12 02:09:01 +01:00
8f9c7137f6 feat: show incorrect lpa string alert dialog (#101)
Reviewed-on: PeterCxy/OpenEUICC#101
Co-authored-by: septs <github@septs.pw>
Co-committed-by: septs <github@septs.pw>
2024-12-12 01:59:06 +01:00
125f1da6af i18n: Add Simplified Chinese (zh-CN) translation
Also fix up some untranslatable strings and translation mistakes in
ja while we are at it.

Co-authored-by: sekaiacg <sekaiacg@gmail.com>
2024-12-08 19:57:16 -05:00
4a482b9c73 Implement a generic preference overlay mechanism
We'll need this as we'll probably soon have more priv/unpriv-specific
preferences. For example, exposing removable eSIMs to the system.
2024-12-08 17:22:46 -05:00
ca46b578f7 ui: Display ARA-M SHA-1 under info 2024-12-08 17:15:36 -05:00
23022b14be feat: copy ara-m sha-1 in settings 2024-12-08 17:12:51 -05:00
2d66c1f334 ui: Switch completely to the new download flow
...and delete the old ProfileDownloadFragment
2024-12-08 16:26:36 -05:00
09b98b37ab Export DownloadWizardActivity 2024-12-08 16:19:14 -05:00
fdbf9b3252 ui: wizard: Reimplement low nvram warning 2024-12-08 16:17:50 -05:00
84f47cb0f0 ui: wizard: Use dp instead of sp in margins 2024-12-08 16:09:39 -05:00
0229ef41df ui: wizard: Allow saving diagnostics text 2024-12-08 16:07:23 -05:00
15d3b701a5 Switch LuiActivity to the new wizard
...and rename / alias the old DirectProfileDownloadActivity to the new
DownloadWizardActivity
2024-12-08 15:42:24 -05:00
700578a369 ui: wizard: Handle download success
Turns out when you test only errors you forget things can actually
succeed...
2024-12-08 15:34:38 -05:00
eab60bf3d3 ui: priv: Set isMEP and isRemovable when creating footer views
Else, footer views may be created before we actually intialize that
info.
2024-12-08 13:44:02 -05:00
5b80afd5fe ui: Expose download error reason in diagnostics 2024-12-08 13:39:15 -05:00
400c2ff9f9 ProfileDeleteFragment: Stop using non-local returns for no reason 2024-12-08 12:59:27 -05:00
b4f562f90b Make MainActivity permission checks clearer 2024-12-08 12:58:24 -05:00
5a000278d3 Revert meaningless PermissionUtils 2024-12-08 12:55:42 -05:00
38d38523f9 Revert MainActivity changes 2024-12-08 12:54:33 -05:00
022ca1da9d chore: improve readability (#95)
Reviewed-on: PeterCxy/OpenEUICC#95
Co-authored-by: septs <github@septs.pw>
Co-committed-by: septs <github@septs.pw>
2024-12-08 18:51:48 +01:00
1140ddb249 lpac-jni: IgnoreTLSCertificate -> AllowAllTrustManager 2024-12-08 11:15:11 -05:00
9be1ae7cd1 lpac-jni: Expose error reason enum as string 2024-12-08 11:14:10 -05:00
a7e97378fc Rework EuiccInfoActivity formatting 2024-12-08 10:51:14 -05:00
790cbb5a58 i18n: Update ja translations 2024-12-08 10:42:32 -05:00
2247749b37 ui: Set layout_constrainedWidth for profile title 2024-12-08 10:35:03 -05:00
249aea482b refactor: euicc info activity (#98)
improve maintainability

Co-authored-by: Peter Cai <peter@typeblog.net>
Reviewed-on: PeterCxy/OpenEUICC#98
Co-authored-by: septs <github@septs.pw>
Co-committed-by: septs <github@septs.pw>
2024-12-08 16:31:39 +01:00
dc0489a693 chore: simplify source code intent (#97)
Co-authored-by: Peter Cai <peter@typeblog.net>
Reviewed-on: PeterCxy/OpenEUICC#97
Co-authored-by: septs <github@septs.pw>
Co-committed-by: septs <github@septs.pw>
2024-12-08 16:30:00 +01:00
d3e54ece58 chore: improve compatibility check (#96)
Reviewed-on: PeterCxy/OpenEUICC#96
Co-authored-by: septs <github@septs.pw>
Co-committed-by: septs <github@septs.pw>
2024-12-08 16:29:05 +01:00
4e5bb5b11e retire Android.mk to prevent AOSP from complaining
we don't use Android.mk for AOSP, but we do for the NDK JNI build. Let's
rename it so that the latest AOSP 15 QPR 1 stops complaining.
2024-12-07 21:25:10 -05:00
68cc6adc9b chore: reduce translatable strings (#93)
Co-authored-by: Peter Cai <peter@typeblog.net>
Reviewed-on: PeterCxy/OpenEUICC#93
Co-authored-by: septs <github@septs.pw>
Co-committed-by: septs <github@septs.pw>
2024-12-08 03:11:44 +01:00
9e637f766d fix: profile class always display (#92)
Reviewed-on: PeterCxy/OpenEUICC#92
Co-authored-by: septs <github@septs.pw>
Co-committed-by: septs <github@septs.pw>
2024-12-08 03:10:53 +01:00
acce39fd3b chore: flatten install packages (#85)
Co-authored-by: Peter Cai <peter@typeblog.net>
Reviewed-on: PeterCxy/OpenEUICC#85
Co-authored-by: septs <github@septs.pw>
Co-committed-by: septs <github@septs.pw>
2024-12-07 15:58:56 +01:00
fb8b6de350 chore: cleanup (#81)
Co-authored-by: Peter Cai <peter@typeblog.net>
Reviewed-on: PeterCxy/OpenEUICC#81
Co-authored-by: septs <github@septs.pw>
Co-committed-by: septs <github@septs.pw>
2024-12-07 15:57:14 +01:00
75221fcf79 fix: build failed (ES10c.EuiccMemoryReset) (#90)
Reviewed-on: PeterCxy/OpenEUICC#90
Co-authored-by: septs <github@septs.pw>
Co-committed-by: septs <github@septs.pw>
2024-12-07 15:56:24 +01:00
4ae19aea3b feat: expose ES10c.EuiccMemoryReset (#88)
Co-authored-by: Peter Cai <peter@typeblog.net>
Reviewed-on: PeterCxy/OpenEUICC#88
Co-authored-by: septs <github@septs.pw>
Co-committed-by: septs <github@septs.pw>
2024-12-07 04:43:31 +01:00
ff6bd45ac6 feat: show profile class with unfiltered profile list (#86)
Co-authored-by: Peter Cai <peter@typeblog.net>
Reviewed-on: PeterCxy/OpenEUICC#86
Co-authored-by: septs <github@septs.pw>
Co-committed-by: septs <github@septs.pw>
2024-12-07 04:41:49 +01:00
858b6d55d6 feat: show notification sequence number in upper right (#84)
![](https://gitea.angry.im/attachments/049f819a-b647-4db6-ae3d-6519cf8ff2e9)

Reviewed-on: PeterCxy/OpenEUICC#84
Co-authored-by: septs <github@septs.pw>
Co-committed-by: septs <github@septs.pw>
2024-12-07 04:39:44 +01:00
78bf3612ee lpac-jni: Expose error reason as return value 2024-12-01 18:47:58 -05:00
afeb5c5282 i18n: Fixup Japanese translation for notification types
~ing in the en text means the action itself, not the process.
2024-12-01 17:22:58 -05:00
f74145d0b7 i18n: Update new strings 2024-12-01 17:15:51 -05:00
c6de599db0 lpac-jni: Cancel es9p/10b sessions on download failure 2024-12-01 17:08:09 -05:00
0a78daee8b feat: language preferences (#76)
see https://developer.android.com/guide/topics/resources/app-languages

Reviewed-on: PeterCxy/OpenEUICC#76
Co-authored-by: septs <github@septs.pw>
Co-committed-by: septs <github@septs.pw>
2024-12-01 20:26:14 +01:00
e7f58bbaaf chore: simplify settings intent (#77)
Reviewed-on: PeterCxy/OpenEUICC#77
Co-authored-by: septs <github@septs.pw>
Co-committed-by: septs <github@septs.pw>
2024-12-01 14:57:30 +01:00
562e5922be chore: suppress gradle warning (#75)
We recommend using a newer Android Gradle plugin to use `compileSdk = 35`

This Android Gradle plugin (8.1.2) was tested up to `compileSdk = 34`.

You are strongly encouraged to update your project to use a newer
Android Gradle plugin that has been tested with `compileSdk = 35`.

If you are already using the latest version of the Android Gradle plugin,
you may need to wait until a newer version with support for `compileSdk = 35` is available.

To suppress this warning, add/update

```
android.suppressUnsupportedCompileSdk=35
```

to this project's gradle.properties.

Reviewed-on: PeterCxy/OpenEUICC#75
Co-authored-by: septs <github@septs.pw>
Co-committed-by: septs <github@septs.pw>
2024-12-01 14:46:42 +01:00
50c77ea467 refactor: preference repository (#74)
reduce template code

Reviewed-on: PeterCxy/OpenEUICC#74
Co-authored-by: septs <github@septs.pw>
Co-committed-by: septs <github@septs.pw>
2024-11-30 16:05:44 +01:00
6bb1a16aee feat: show unfiltered profile list in developer options (#73)
resolves #40 #30

Reviewed-on: PeterCxy/OpenEUICC#73
Co-authored-by: septs <github@septs.pw>
Co-committed-by: septs <github@septs.pw>
2024-11-30 15:47:54 +01:00
92daa56f1a Add Japanese translate (#71)
Reviewed-on: PeterCxy/OpenEUICC#71
Co-authored-by: reindex <reindex@noreply.gitea.angry.im>
Co-committed-by: reindex <reindex@noreply.gitea.angry.im>
2024-11-30 15:29:09 +01:00
90878438f9 refactor: gitignore (#69)
Rebuild root dot-gitignore file

Update subdirectory dot-gitignore file

Reviewed-on: PeterCxy/OpenEUICC#69
Co-authored-by: septs <github@septs.pw>
Co-committed-by: septs <github@septs.pw>
2024-11-27 15:06:32 +01:00
96bc9865ff lpac_jni: Clear exceptions before setting response 2024-11-24 19:50:27 -05:00
dcae65011e lpac_jni: Move HTTP diagnostics to LPA 2024-11-24 19:49:47 -05:00
1c4263a47a ui: wizard: Make clear what HTTP and APDU mean 2024-11-24 19:45:45 -05:00
d7214141e6 ui: wizard: Only show full APDU response when it is a failure 2024-11-24 19:45:05 -05:00
326b39ed05 ui: wizard: Add APDU errors to diagnostics 2024-11-24 19:43:42 -05:00
26d037048d ui: wizard: Show HTTP exception in diagnostics 2024-11-24 19:26:47 -05:00
5476e335b1 Move ProfileDownloadException to LPA 2024-11-24 19:20:47 -05:00
426e5c0197 util: Ignore spaces in JSON string 2024-11-24 17:45:11 -05:00
74d7da35dc ui: wizard: Quick and dirty JSON pretty-printer 2024-11-24 17:29:51 -05:00
07072667db ui: wizard: Add error diagnostics
We finally have it!!!
2024-11-24 17:10:46 -05:00
895cbdd53d lpa: Track last HTTP response on failure
We'll have a "error diagnosis" page for the new download wizard.

We'll probably want to do this for APDU too.
2024-11-24 15:56:44 -05:00
1a3fd621d9 EuiccChannelManagerService: move applyCompletionTransform() to companion object 2024-11-24 15:34:36 -05:00
74489a9ae0 EuiccChannelManagerservice: Fix completion event in returned flows 2024-11-24 13:25:11 -05:00
d68a7172de EuiccChannelManagerService: Fix support for multiple subscribers
We have to use another SharedFlow here. Otherwise, the flow transforms
break our ability to subscribe to it more than once, which is needed for
UI state to preserve across recreate events.
2024-11-24 13:14:16 -05:00
5b079c95ac ui: wizard: Implement the download process 2024-11-24 11:23:27 -05:00
f2c233fe1c EuiccChannelManagerService: Introduce IDs for tasks 2024-11-24 10:42:02 -05:00
3507c17834 EuiccChannalManagerService: manually buffer the returned flow 2024-11-24 10:18:54 -05:00
b2abe5ee84 ui: wizard: Make download details nullable 2024-11-20 21:03:42 -05:00
67c9612627 ui: wizard: Restrict inputs to single lines 2024-11-20 21:00:50 -05:00
39b40f9b0d ui: wizard: Lay out the download progress UI 2024-11-20 20:57:35 -05:00
f236b40cd4 lpac-jni: Add lookup from progress to state 2024-11-19 20:49:34 -05:00
e7a0482281 ui: wizard: Save current state to bundle 2024-11-19 20:40:53 -05:00
81f34f9b1c ui: wizard: Sort by slot ID 2024-11-19 20:14:30 -05:00
8c73615fbb ui: wizard: Implement input by scanning / gallery 2024-11-19 20:11:37 -05:00
9cf95ad47c ui: Add a input details fragment for download wizard 2024-11-19 18:38:59 -05:00
723ec70730 ui: Use prev button action for back pressed 2024-11-18 21:06:46 -05:00
dbdadd33b3 ui: Add slide-in and slide-out animation for wizard steps 2024-11-18 21:02:12 -05:00
92b7b46598 ui: Lay out the method select fragment for wizard 2024-11-18 20:54:42 -05:00
0c519af376 ui: Update slot select prompt text 2024-11-18 20:19:28 -05:00
aaca9e807a ui: Show free space when selecting slot 2024-11-18 20:01:43 -05:00
98e16ee5aa ui: Hook up prev / next buttons for new download wizard 2024-11-18 19:57:01 -05:00
b9d5c1c5bb chore: simplify dot-idea gitignore (#68)
Reviewed-on: PeterCxy/OpenEUICC#68
Co-authored-by: septs <github@septs.pw>
Co-committed-by: septs <github@septs.pw>
2024-11-18 23:39:09 +01:00
c4b513fc0a ui: Save selected slot as state in DownloadWizardActivity 2024-11-17 22:00:03 -05:00
6458f54db2 ui: Ensure EuiccChannelManager is available in slot select fragment 2024-11-17 21:42:41 -05:00
87f36f4166 ui: Expose more info in slot select fragment 2024-11-17 21:41:40 -05:00
4fb59a4b01 ui: Fixup strings for eSIM slot selection 2024-11-17 19:17:29 -05:00
16636988b0 ui: Fixup loaded state 2024-11-17 19:15:42 -05:00
93e7297caa ui: Implement single selection for new wizard 2024-11-17 19:14:00 -05:00
1087a676d4 ui: Fix slot item alignment in new wizard 2024-11-17 19:04:41 -05:00
375d13b7c4 ui: Start designing UI for selectiing slot in the new download flow
incomplete
2024-11-17 17:32:42 -05:00
a3d59a0761 feat: ignore tls certificate (#66)
Reviewed-on: PeterCxy/OpenEUICC#66
Co-authored-by: septs <github@septs.pw>
Co-committed-by: septs <github@septs.pw>
2024-11-17 03:43:38 +01:00
5f0dbe3098 ui: Hide developer settings behind 7 clicks 2024-11-16 20:59:36 -05:00
efa9b8bfa4 ui: Set up progress bar for the new wizard 2024-11-16 20:37:43 -05:00
47d5c3881c ui: Add skeleton of an experimental new download flow
This doesn't work yet at all, and is hidden behind an experimental
settings switch.
2024-11-16 18:34:45 -05:00
e9f4d3d1f9 fix: unified alert dialog style (#65)
see https://gitea.angry.im/PeterCxy/OpenEUICC/search/branch/master?q=.app.AlertDialog

Reviewed-on: PeterCxy/OpenEUICC#65
Co-authored-by: septs <github@septs.pw>
Co-committed-by: septs <github@septs.pw>
2024-11-14 14:59:13 +01:00
506b0e530a feat: low nvram alert (#62)
If free nvram available is less 30 kib, then alert dialog

Reviewed-on: PeterCxy/OpenEUICC#62
Co-authored-by: septs <github@septs.pw>
Co-committed-by: septs <github@septs.pw>
2024-11-14 14:02:27 +01:00
e8db3d1206 chore: simplify CompatibilityCheckActivity logical (#61)
Reviewed-on: PeterCxy/OpenEUICC#61
Co-authored-by: septs <github@septs.pw>
Co-committed-by: septs <github@septs.pw>
2024-11-13 22:47:35 +01:00
071304349a fix: crash with disable com.android.stk app (#60)
```console
pm disable-user com.android.stk
```

after running this command, the app will crash when launched

Reviewed-on: PeterCxy/OpenEUICC#60
Co-authored-by: septs <github@septs.pw>
Co-committed-by: septs <github@septs.pw>
2024-11-12 02:42:25 +01:00
6f8aef8ea8 EuiccChannelManager: Remove last trace of blocking methods 2024-11-10 20:56:56 -05:00
8e806c3ae5 EuiccChannelManager: slot -> logicalSlot 2024-11-10 20:55:12 -05:00
42c870192c Remove last trace of findEuiccChannelBySlotBlocking() public usage 2024-11-10 20:53:35 -05:00
9201ee416e OpenEuiccService: Remove more useless stuff 2024-11-10 20:45:48 -05:00
7105c43ae4 EuiccChannelManager: Remove findEuiccChannelByPort() as public method 2024-11-10 20:42:21 -05:00
d846f0cdc4 LPAUtils: Remove usage of findEuiccChannelByPort() 2024-11-10 20:40:05 -05:00
5dacb75717 EuiccChannelManager: Remove some unused methods 2024-11-10 20:35:01 -05:00
f28867ef2e OpenEuiccService: Remove traces of EuiccChannel usage 2024-11-10 20:31:09 -05:00
7215a2351b OpenEuiccService: Switch onSwitchToSubscriptionWithPort() to use the new service 2024-11-10 20:29:25 -05:00
837c34ba70 Add convience "Done" subscriber method for Flow<ForegroundTaskState> 2024-11-10 17:11:55 -05:00
fe6d4264e3 OpenEuiccService: switch onDeleteSubscription to use
EuiccChannelManagerService
2024-11-10 17:00:41 -05:00
13085ec202 Add findAvailablePorts() to EuiccChannelManager
For use with OpenEuiccService
2024-11-10 15:57:32 -05:00
9d8e58a95d refactor: open sim toolkit logical (#58)
Reviewed-on: PeterCxy/OpenEUICC#58
Co-authored-by: septs <github@septs.pw>
Co-committed-by: septs <github@septs.pw>
2024-11-09 01:10:55 +01:00
22ec3e3baf OpenEuiccService: Start migrating to withEuiccChannel() 2024-11-03 10:53:24 -05:00
32f5e3f71a PrivilegedTelephonyUtils: Nuke direct EuiccChannel usage 2024-11-02 21:36:08 -04:00
04debd62d5 MainActivity: Fixup ViewPager update 2024-11-02 20:58:56 -04:00
0ef435956c EuiccChannelManager: retire enumerateEuiccChannels() 2024-11-02 19:09:57 -04:00
573dce56a6 EuiccChannelManager: Stop emitting real EuiccChannel for USB 2024-11-02 19:06:31 -04:00
272ab953e0 PrivilegedTelephonyUtils: Switch to flowEuiccPorts() 2024-11-02 18:09:12 -04:00
6257a03058 DirectProfileDownloadActivity: use flowEuiccPorts() 2024-11-02 17:16:43 -04:00
5e5210ae2d MainActivity: switch to flowEuiccPorts() 2024-11-02 16:54:30 -04:00
87eb497f40 feat: open stk from menu (#57)
Reviewed-on: PeterCxy/OpenEUICC#57
Co-authored-by: septs <github@septs.pw>
Co-committed-by: septs <github@septs.pw>
2024-11-02 19:29:37 +01:00
1dc5004681 feat: enhanced visual hints for available slots (#54)
Reviewed-on: PeterCxy/OpenEUICC#54
Co-authored-by: septs <github@septs.pw>
Co-committed-by: septs <github@septs.pw>
2024-11-02 19:27:42 +01:00
2ece6af174 Add EuiccChannelManager.flowEuiccPorts()
This is to prepare for refactoring EuiccChannel usage out everywhere
2024-10-29 20:18:23 -04:00
59b4b9e4ab Fix EuiccInfoActivity crash 2024-10-28 21:10:01 -04:00
826c120ca5 EuiccInfoActivity: support back button 2024-10-27 15:58:33 -04:00
5cefbc24f5 OpenEuiccService: fixup 2024-10-27 15:57:46 -04:00
f285eacd55 Show channel access mode and removable status 2024-10-27 15:17:00 -04:00
481b9ce196 Show slot ID in EuiccInfoActivity 2024-10-27 11:24:22 -04:00
ce7fb29c14 Display supported certificates (GSMA Test or Prod) in EuiccInfoActivity 2024-10-27 11:22:10 -04:00
c2cc8ceb2a feat: EuiccInfoActivity 2024-10-27 11:04:45 -04:00
3d4704e77b Remove more EuiccChannel usage in PrivilegedEuiccManagementFragment 2024-10-26 22:15:02 -04:00
6a2d4d66dd Move EuiccChannelManagerService to withEuiccChannel() 2024-10-26 21:52:33 -04:00
8ac46bd778 Move findEuiccChannelBySlot to non-blocking 2024-10-26 21:46:13 -04:00
0961ef70f4 New withEuiccChannel() variant with logical slot ID 2024-10-26 21:45:14 -04:00
3b868e4f9a Move some fragments to withEuiccChannel() 2024-10-26 21:41:56 -04:00
95b24e6151 Add withEuiccChannel helper for EuiccChannelFragment 2024-10-26 21:29:09 -04:00
ef62274057 Wrappers shouldn't hold references indefinitely 2024-10-26 15:49:38 -04:00
76e8fbd56b Use wrappers to enforce that withEuiccChannel can't leak references 2024-10-26 15:46:43 -04:00
d54fcf2589 refactor: Make EuiccChannel abstract
This allows wrapping to control reference lifetime outside of
EuiccChannelManager.
2024-10-26 15:32:31 -04:00
7cb872a664 Add new withEuiccChannel() method to EuiccChannelManager 2024-10-26 15:32:31 -04:00
65c9a7dc39 fix: refer url (#53)
Reviewed-on: PeterCxy/OpenEUICC#53
Co-authored-by: septs <github@septs.pw>
Co-committed-by: septs <github@septs.pw>
2024-10-23 04:17:48 +02:00
142 changed files with 4624 additions and 1434 deletions

View file

@ -38,11 +38,12 @@ jobs:
- name: Build Debug Bundle
run: ./gradlew --no-daemon :app-unpriv:bundleJmpDebug
- name: Copy Artifacts
run: find . -name 'app*-debug.apk' -exec cp {} . \;
- name: Upload Artifacts
uses: https://gitea.angry.im/actions/upload-artifact@v3
with:
name: Debug APKs
compression-level: 0
path: |
app-unpriv/build/outputs/apk/jmp/debug/app-unpriv-jmp-debug.apk
app-unpriv/build/outputs/bundle/jmpDebug/app-unpriv-jmp-debug.aab
path: app*-debug.apk

29
.gitignore vendored
View file

@ -1,20 +1,11 @@
*.iml
.gradle
/local.properties
/keystore.properties
/.idea/caches
/.idea/libraries
/.idea/modules.xml
/.idea/workspace.xml
/.idea/navEditor.xml
/.idea/assetWizardSettings.xml
/.idea/deploymentTargetDropDown.xml
.DS_Store
/build
/.gradle
/captures
.externalNativeBuild
.cxx
local.properties
/libs/**/build
/buildSrc/build
/app-deps/libs
# Configuration files
/keystore.properties
/local.properties
# macOS
.DS_Store

14
.idea/.gitignore generated vendored
View file

@ -1,3 +1,13 @@
# Default ignored files
/shelf/
/shelf
/caches
/libraries
/assetWizardSettings.xml
/deploymentTargetDropDown.xml
/gradle.xml
/misc.xml
/modules.xml
/navEditor.xml
/runConfigurations.xml
/workspace.xml
**/*.iml

1
.idea/.name generated Normal file
View file

@ -0,0 +1 @@
OpenEUICC

12
.idea/compiler.xml generated
View file

@ -1,16 +1,6 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="CompilerConfiguration">
<bytecodeTargetLevel target="1.7">
<module name="OpenEUICC.app" target="17" />
<module name="OpenEUICC.app-common" target="17" />
<module name="OpenEUICC.app-deps" target="17" />
<module name="OpenEUICC.app-unpriv" target="17" />
<module name="OpenEUICC.buildSrc" target="17" />
<module name="OpenEUICC.buildSrc.main" target="17" />
<module name="OpenEUICC.buildSrc.test" target="17" />
<module name="OpenEUICC.libs.hidden-apis-shim" target="17" />
<module name="OpenEUICC.libs.lpac-jni" target="17" />
</bytecodeTargetLevel>
<bytecodeTargetLevel target="1.7" />
</component>
</project>

39
.idea/gradle.xml generated
View file

@ -1,39 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="GradleMigrationSettings" migrationVersion="1" />
<component name="GradleSettings">
<option name="linkedExternalProjectsSettings">
<GradleProjectSettings>
<compositeConfiguration>
<compositeBuild compositeDefinitionSource="SCRIPT">
<builds>
<build path="$PROJECT_DIR$/buildSrc" name="buildSrc">
<projects>
<project path="$PROJECT_DIR$/buildSrc" />
</projects>
</build>
</builds>
</compositeBuild>
</compositeConfiguration>
<option name="externalProjectPath" value="$PROJECT_DIR$" />
<option name="gradleHome" value="/usr/share/java/gradle" />
<option name="gradleJvm" value="#GRADLE_LOCAL_JAVA_HOME" />
<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>
<option name="resolveExternalAnnotations" value="false" />
</GradleProjectSettings>
</option>
</component>
</project>

25
.idea/misc.xml generated
View file

@ -1,25 +0,0 @@
<project version="4">
<component name="DesignSurface">
<option name="filePathToZoomLevelMap">
<map>
<entry key="app/src/main/res/drawable/ic_add.xml" value="0.2015" />
<entry key="app/src/main/res/layout/activity_main.xml" value="0.19375" />
<entry key="app/src/main/res/layout/euicc_profile.xml" value="0.19375" />
<entry key="app/src/main/res/layout/fragment_euicc.xml" value="0.19375" />
<entry key="app/src/main/res/layout/fragment_profile_download.xml" value="0.19375" />
<entry key="app/src/main/res/layout/fragment_profile_rename.xml" value="0.19375" />
<entry key="app/src/main/res/menu/activity_main.xml" value="0.19375" />
<entry key="app/src/main/res/menu/activity_main_slot_spinner.xml" value="0.19375" />
<entry key="app/src/main/res/menu/fragment_profile_download.xml" value="0.19375" />
<entry key="app/src/main/res/menu/fragment_profile_rename.xml" value="0.19375" />
<entry key="app/src/main/res/menu/profile_options.xml" value="0.19375" />
</map>
</option>
</component>
<component name="ProjectRootManager" version="2" languageLevel="JDK_1_7" project-jdk-name="jbr-17" project-jdk-type="JavaSDK">
<output url="file://$PROJECT_DIR$/build/classes" />
</component>
<component name="ProjectType">
<option name="id" value="Android" />
</component>
</project>

View file

View file

@ -1 +1 @@
287
294

View file

@ -20,14 +20,23 @@
android:label="@string/profile_notifications" />
<activity
android:name="im.angry.openeuicc.ui.DirectProfileDownloadActivity"
android:label="@string/profile_download"
android:theme="@style/Theme.AppCompat.Translucent" />
android:name="im.angry.openeuicc.ui.EuiccInfoActivity"
android:label="@string/euicc_info" />
<activity
android:name="im.angry.openeuicc.ui.LogsActivity"
android:label="@string/pref_advanced_logs" />
<activity
android:exported="true"
android:name="im.angry.openeuicc.ui.wizard.DownloadWizardActivity"
android:label="@string/download_wizard" />
<activity-alias
android:exported="true"
android:name="im.angry.openeuicc.ui.DirectProfileDownloadActivity"
android:targetActivity="im.angry.openeuicc.ui.wizard.DownloadWizardActivity" />
<activity
android:name="com.journeyapps.barcodescanner.CaptureActivity"
android:screenOrientation="fullSensor"

View file

@ -0,0 +1,5 @@
package im.angry.openeuicc.core
interface ApduInterfaceAtrProvider {
val atr: ByteArray?
}

View file

@ -6,6 +6,7 @@ import android.hardware.usb.UsbInterface
import android.hardware.usb.UsbManager
import android.se.omapi.SEService
import android.util.Log
import im.angry.openeuicc.common.R
import im.angry.openeuicc.core.usb.UsbApduInterface
import im.angry.openeuicc.core.usb.getIoEndpoints
import im.angry.openeuicc.util.*
@ -33,14 +34,17 @@ open class DefaultEuiccChannelFactory(protected val context: Context) : EuiccCha
Log.i(DefaultEuiccChannelManager.TAG, "Trying OMAPI for physical slot ${port.card.physicalSlotIndex}")
try {
return EuiccChannel(
return EuiccChannelImpl(
context.getString(R.string.omapi),
port,
intrinsicChannelName = null,
OmapiApduInterface(
seService!!,
port,
context.preferenceRepository.verboseLoggingFlow
),
context.preferenceRepository.verboseLoggingFlow
context.preferenceRepository.verboseLoggingFlow,
context.preferenceRepository.ignoreTLSCertificateFlow,
).also {
Log.i(DefaultEuiccChannelManager.TAG, "Is OMAPI channel, setting MSS to 60")
it.lpa.setEs10xMss(60)
@ -61,15 +65,18 @@ open class DefaultEuiccChannelFactory(protected val context: Context) : EuiccCha
if (bulkIn == null || bulkOut == null) return null
val conn = usbManager.openDevice(usbDevice) ?: return null
if (!conn.claimInterface(usbInterface, true)) return null
return EuiccChannel(
return EuiccChannelImpl(
context.getString(R.string.usb),
FakeUiccPortInfoCompat(FakeUiccCardInfoCompat(EuiccChannelManager.USB_CHANNEL_ID)),
intrinsicChannelName = usbDevice.productName,
UsbApduInterface(
conn,
bulkIn,
bulkOut,
context.preferenceRepository.verboseLoggingFlow
),
context.preferenceRepository.verboseLoggingFlow
context.preferenceRepository.verboseLoggingFlow,
context.preferenceRepository.ignoreTLSCertificateFlow,
)
}

View file

@ -10,7 +10,10 @@ import im.angry.openeuicc.di.AppContainer
import im.angry.openeuicc.util.*
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.delay
import kotlinx.coroutines.runBlocking
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.flow
import kotlinx.coroutines.flow.flowOn
import kotlinx.coroutines.flow.merge
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
import kotlinx.coroutines.withContext
@ -88,44 +91,24 @@ open class DefaultEuiccChannelManager(
}
}
override fun findEuiccChannelBySlotBlocking(logicalSlotId: Int): EuiccChannel? =
runBlocking {
withContext(Dispatchers.IO) {
if (logicalSlotId == EuiccChannelManager.USB_CHANNEL_ID) {
return@withContext usbChannel
}
protected suspend fun findEuiccChannelByLogicalSlot(logicalSlotId: Int): EuiccChannel? =
withContext(Dispatchers.IO) {
if (logicalSlotId == EuiccChannelManager.USB_CHANNEL_ID) {
return@withContext usbChannel
}
for (card in uiccCards) {
for (port in card.ports) {
if (port.logicalSlotIndex == logicalSlotId) {
return@withContext tryOpenEuiccChannel(port)
}
for (card in uiccCards) {
for (port in card.ports) {
if (port.logicalSlotIndex == logicalSlotId) {
return@withContext tryOpenEuiccChannel(port)
}
}
null
}
null
}
override fun findEuiccChannelByPhysicalSlotBlocking(physicalSlotId: Int): EuiccChannel? =
runBlocking {
withContext(Dispatchers.IO) {
if (physicalSlotId == EuiccChannelManager.USB_CHANNEL_ID) {
return@withContext usbChannel
}
for (card in uiccCards) {
if (card.physicalSlotIndex != physicalSlotId) continue
for (port in card.ports) {
tryOpenEuiccChannel(port)?.let { return@withContext it }
}
}
null
}
}
override suspend fun findAllEuiccChannelsByPhysicalSlot(physicalSlotId: Int): List<EuiccChannel>? {
private suspend fun findAllEuiccChannelsByPhysicalSlot(physicalSlotId: Int): List<EuiccChannel>? {
if (physicalSlotId == EuiccChannelManager.USB_CHANNEL_ID) {
return usbChannel?.let { listOf(it) }
}
@ -138,12 +121,7 @@ open class DefaultEuiccChannelManager(
return null
}
override fun findAllEuiccChannelsByPhysicalSlotBlocking(physicalSlotId: Int): List<EuiccChannel>? =
runBlocking {
findAllEuiccChannelsByPhysicalSlot(physicalSlotId)
}
override suspend fun findEuiccChannelByPort(physicalSlotId: Int, portId: Int): EuiccChannel? =
private suspend fun findEuiccChannelByPort(physicalSlotId: Int, portId: Int): EuiccChannel? =
withContext(Dispatchers.IO) {
if (physicalSlotId == EuiccChannelManager.USB_CHANNEL_ID) {
return@withContext usbChannel
@ -154,26 +132,82 @@ open class DefaultEuiccChannelManager(
}
}
override fun findEuiccChannelByPortBlocking(physicalSlotId: Int, portId: Int): EuiccChannel? =
runBlocking {
findEuiccChannelByPort(physicalSlotId, portId)
override suspend fun findFirstAvailablePort(physicalSlotId: Int): Int =
withContext(Dispatchers.IO) {
if (physicalSlotId == EuiccChannelManager.USB_CHANNEL_ID) {
return@withContext 0
}
findAllEuiccChannelsByPhysicalSlot(physicalSlotId)?.getOrNull(0)?.portId ?: -1
}
override suspend fun waitForReconnect(physicalSlotId: Int, portId: Int, timeoutMillis: Long) {
if (physicalSlotId == EuiccChannelManager.USB_CHANNEL_ID) return
override suspend fun findAvailablePorts(physicalSlotId: Int): List<Int> =
withContext(Dispatchers.IO) {
if (physicalSlotId == EuiccChannelManager.USB_CHANNEL_ID) {
return@withContext listOf(0)
}
// If there is already a valid channel, we close it proactively
// Sometimes the current channel can linger on for a bit even after it should have become invalid
channelCache.find { it.slotId == physicalSlotId && it.portId == portId }?.apply {
if (valid) close()
findAllEuiccChannelsByPhysicalSlot(physicalSlotId)?.map { it.portId } ?: listOf()
}
override suspend fun <R> withEuiccChannel(
physicalSlotId: Int,
portId: Int,
fn: suspend (EuiccChannel) -> R
): R {
val channel = findEuiccChannelByPort(physicalSlotId, portId)
?: throw EuiccChannelManager.EuiccChannelNotFoundException()
val wrapper = EuiccChannelWrapper(channel)
try {
return withContext(Dispatchers.IO) {
fn(wrapper)
}
} finally {
wrapper.invalidateWrapper()
}
}
override suspend fun <R> withEuiccChannel(
logicalSlotId: Int,
fn: suspend (EuiccChannel) -> R
): R {
val channel = findEuiccChannelByLogicalSlot(logicalSlotId)
?: throw EuiccChannelManager.EuiccChannelNotFoundException()
val wrapper = EuiccChannelWrapper(channel)
try {
return withContext(Dispatchers.IO) {
fn(wrapper)
}
} finally {
wrapper.invalidateWrapper()
}
}
override suspend fun waitForReconnect(physicalSlotId: Int, portId: Int, timeoutMillis: Long) {
if (physicalSlotId == EuiccChannelManager.USB_CHANNEL_ID) {
usbChannel?.close()
usbChannel = null
} else {
// If there is already a valid channel, we close it proactively
// Sometimes the current channel can linger on for a bit even after it should have become invalid
channelCache.find { it.slotId == physicalSlotId && it.portId == portId }?.apply {
if (valid) close()
}
}
withTimeout(timeoutMillis) {
while (true) {
try {
// tryOpenEuiccChannel() will automatically dispose of invalid channels
// and recreate when needed
val channel = findEuiccChannelByPort(physicalSlotId, portId)!!
val channel = if (physicalSlotId == EuiccChannelManager.USB_CHANNEL_ID) {
// tryOpenUsbEuiccChannel() will always try to reopen the channel, even if
// a USB channel already exists
tryOpenUsbEuiccChannel()
usbChannel!!
} else {
// tryOpenEuiccChannel() will automatically dispose of invalid channels
// and recreate when needed
findEuiccChannelByPort(physicalSlotId, portId)!!
}
check(channel.valid) { "Invalid channel" }
break
} catch (e: Exception) {
@ -184,34 +218,42 @@ open class DefaultEuiccChannelManager(
}
}
override suspend fun enumerateEuiccChannels(): List<EuiccChannel> =
withContext(Dispatchers.IO) {
uiccCards.flatMap { info ->
info.ports.mapNotNull { port ->
tryOpenEuiccChannel(port)?.also {
Log.d(
TAG,
"Found eUICC on slot ${info.physicalSlotIndex} port ${port.portIndex}"
)
}
override fun flowInternalEuiccPorts(): Flow<Pair<Int, Int>> = flow {
uiccCards.forEach { info ->
info.ports.forEach { port ->
tryOpenEuiccChannel(port)?.also {
Log.d(
TAG,
"Found eUICC on slot ${info.physicalSlotIndex} port ${port.portIndex}"
)
emit(Pair(info.physicalSlotIndex, port.portIndex))
}
}
}
}.flowOn(Dispatchers.IO)
override suspend fun enumerateUsbEuiccChannel(): Pair<UsbDevice?, EuiccChannel?> =
override fun flowAllOpenEuiccPorts(): Flow<Pair<Int, Int>> =
merge(flowInternalEuiccPorts(), flow {
if (tryOpenUsbEuiccChannel().second) {
emit(Pair(EuiccChannelManager.USB_CHANNEL_ID, 0))
}
})
override suspend fun tryOpenUsbEuiccChannel(): Pair<UsbDevice?, Boolean> =
withContext(Dispatchers.IO) {
usbManager.deviceList.values.forEach { device ->
Log.i(TAG, "Scanning USB device ${device.deviceId}:${device.vendorId}")
val iface = device.getSmartCardInterface() ?: return@forEach
// If we don't have permission, tell UI code that we found a candidate device, but we
// need permission to be able to do anything with it
if (!usbManager.hasPermission(device)) return@withContext Pair(device, null)
if (!usbManager.hasPermission(device)) return@withContext Pair(device, false)
Log.i(TAG, "Found CCID interface on ${device.deviceId}:${device.vendorId}, and has permission; trying to open channel")
try {
val channel = euiccChannelFactory.tryOpenUsbEuiccChannel(device, iface)
if (channel != null && channel.lpa.valid) {
usbChannel = channel
return@withContext Pair(device, channel)
return@withContext Pair(device, true)
}
} catch (e: Exception) {
// Ignored -- skip forward
@ -219,7 +261,7 @@ open class DefaultEuiccChannelManager(
}
Log.i(TAG, "No valid eUICC channel found on USB device ${device.deviceId}:${device.vendorId}")
}
return@withContext Pair(null, null)
return@withContext Pair(null, false)
}
override fun invalidate() {

View file

@ -1,26 +1,32 @@
package im.angry.openeuicc.core
import im.angry.openeuicc.util.*
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 EuiccChannel(
val port: UiccPortInfoCompat,
apduInterface: ApduInterface,
verboseLoggingFlow: Flow<Boolean>
) {
val slotId = port.card.physicalSlotIndex // PHYSICAL slot
val logicalSlotId = port.logicalSlotIndex
val portId = port.portIndex
interface EuiccChannel {
val type: String
val lpa: LocalProfileAssistant =
LocalProfileAssistantImpl(apduInterface, HttpInterfaceImpl(verboseLoggingFlow))
val port: UiccPortInfoCompat
val slotId: Int // PHYSICAL slot
val logicalSlotId: Int
val portId: Int
val lpa: LocalProfileAssistant
val valid: Boolean
get() = lpa.valid
fun close() = lpa.close()
}
/**
* Answer to Reset (ATR) value of the underlying interface, if any
*/
val atr: ByteArray?
/**
* Intrinsic name of this channel. For device-internal SIM slots,
* this should be null; for USB readers, this should be the name of
* the reader device.
*/
val intrinsicChannelName: String?
fun close()
}

View file

@ -0,0 +1,32 @@
package im.angry.openeuicc.core
import im.angry.openeuicc.util.*
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?,
private val apduInterface: ApduInterface,
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(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()
}

View file

@ -1,6 +1,7 @@
package im.angry.openeuicc.core
import android.hardware.usb.UsbDevice
import kotlinx.coroutines.flow.Flow
/**
* EuiccChannelManager holds references to, and manages the lifecycles of, individual
@ -18,19 +19,35 @@ interface EuiccChannelManager {
}
/**
* Scan all possible _device internal_ sources for EuiccChannels, return them and have all
* scanned channels cached; these channels will remain open for the entire lifetime of
* this EuiccChannelManager object, unless disconnected externally or invalidate()'d
* Scan all possible _device internal_ sources for EuiccChannels, as a flow, return their physical
* (slotId, portId) and have all scanned channels cached; these channels will remain open
* for the entire lifetime of this EuiccChannelManager object, unless disconnected externally
* or invalidate()'d.
*
* To obtain a temporary reference to a EuiccChannel, use `withEuiccChannel()`.
*/
suspend fun enumerateEuiccChannels(): List<EuiccChannel>
fun flowInternalEuiccPorts(): Flow<Pair<Int, Int>>
/**
* Same as flowInternalEuiccPorts(), except that this includes non-device internal eUICC chips
* as well. Namely, this includes the USB reader.
*
* Non-internal readers will only be included if they have been opened properly, i.e. with permissions
* granted by the user.
*/
fun flowAllOpenEuiccPorts(): Flow<Pair<Int, Int>>
/**
* Scan all possible USB devices for CCID readers that may contain eUICC cards.
* If found, try to open it for access, and add it to the internal EuiccChannel cache
* as a "port" with id 99. When user interaction is required to obtain permission
* to interact with the device, the second return value (EuiccChannel) will be null.
* to interact with the device, the second return value will be false.
*
* Returns (usbDevice, canOpen). canOpen is false if either (1) no usb reader is found;
* or (2) usb reader is found, but user interaction is required for access;
* or (3) usb reader is found, but we are unable to open ISD-R.
*/
suspend fun enumerateUsbEuiccChannel(): Pair<UsbDevice?, EuiccChannel?>
suspend fun tryOpenUsbEuiccChannel(): Pair<UsbDevice?, Boolean>
/**
* Wait for a slot + port to reconnect (i.e. become valid again)
@ -40,29 +57,40 @@ interface EuiccChannelManager {
suspend fun waitForReconnect(physicalSlotId: Int, portId: Int, timeoutMillis: Long = 1000)
/**
* Returns the EuiccChannel corresponding to a **logical** slot
* Returns the first mapped & available port ID for a physical slot, or -1 if
* not found.
*/
fun findEuiccChannelBySlotBlocking(logicalSlotId: Int): EuiccChannel?
suspend fun findFirstAvailablePort(physicalSlotId: Int): Int
/**
* Returns the first EuiccChannel corresponding to a **physical** slot
* If the physical slot supports MEP and has multiple ports, it is undefined
* which of the two channels will be returned.
* Returns all mapped & available port IDs for a physical slot.
*/
fun findEuiccChannelByPhysicalSlotBlocking(physicalSlotId: Int): EuiccChannel?
suspend fun findAvailablePorts(physicalSlotId: Int): List<Int>
class EuiccChannelNotFoundException: Exception("EuiccChannel not found")
/**
* Returns all EuiccChannels corresponding to a **physical** slot
* Multiple channels are possible in the case of MEP
* Find a EuiccChannel by its slot and port, then run a callback with a reference to it.
* The reference is not supposed to be held outside of the callback. This is enforced via
* a wrapper object.
*
* The callback is run on Dispatchers.IO by default.
*
* If a channel for that slot / port is not found, EuiccChannelNotFoundException is thrown
*/
suspend fun findAllEuiccChannelsByPhysicalSlot(physicalSlotId: Int): List<EuiccChannel>?
fun findAllEuiccChannelsByPhysicalSlotBlocking(physicalSlotId: Int): List<EuiccChannel>?
suspend fun <R> withEuiccChannel(
physicalSlotId: Int,
portId: Int,
fn: suspend (EuiccChannel) -> R
): R
/**
* Returns the EuiccChannel corresponding to a **physical** slot and a port ID
* Same as withEuiccChannel(Int, Int, (EuiccChannel) -> R) but instead uses logical slot ID
*/
suspend fun findEuiccChannelByPort(physicalSlotId: Int, portId: Int): EuiccChannel?
fun findEuiccChannelByPortBlocking(physicalSlotId: Int, portId: Int): EuiccChannel?
suspend fun <R> withEuiccChannel(
logicalSlotId: Int,
fn: suspend (EuiccChannel) -> R
): R
/**
* Invalidate all EuiccChannels previously cached by this Manager
@ -74,7 +102,7 @@ interface EuiccChannelManager {
* This is only expected to be implemented when the application is privileged
* TODO: Remove this from the common interface
*/
fun notifyEuiccProfilesChanged(logicalSlotId: Int) {
suspend fun notifyEuiccProfilesChanged(logicalSlotId: Int) {
// no-op by default
}
}

View file

@ -0,0 +1,48 @@
package im.angry.openeuicc.core
import im.angry.openeuicc.util.*
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 atr: ByteArray?
get() = channel.atr
override fun close() = channel.close()
fun invalidateWrapper() {
_inner = null
if (lpaDelegate.isInitialized()) {
(lpa as LocalProfileAssistantWrapper).invalidateWrapper()
}
}
}

View file

@ -0,0 +1,66 @@
package im.angry.openeuicc.core
import net.typeblog.lpac_jni.EuiccInfo2
import net.typeblog.lpac_jni.LocalProfileAssistant
import net.typeblog.lpac_jni.LocalProfileInfo
import net.typeblog.lpac_jni.LocalProfileNotification
import net.typeblog.lpac_jni.ProfileDownloadCallback
class LocalProfileAssistantWrapper(orig: LocalProfileAssistant) :
LocalProfileAssistant {
private var _inner: LocalProfileAssistant? = orig
private val lpa: LocalProfileAssistant
get() {
if (_inner == null) {
throw IllegalStateException("This wrapper has been invalidated")
}
return _inner!!
}
override val valid: Boolean
get() = lpa.valid
override val profiles: List<LocalProfileInfo>
get() = lpa.profiles
override val notifications: List<LocalProfileNotification>
get() = lpa.notifications
override val eID: String
get() = lpa.eID
override val euiccInfo2: EuiccInfo2?
get() = lpa.euiccInfo2
override fun setEs10xMss(mss: Byte) = lpa.setEs10xMss(mss)
override fun enableProfile(iccid: String, refresh: Boolean): Boolean =
lpa.enableProfile(iccid, refresh)
override fun disableProfile(iccid: String, refresh: Boolean): Boolean =
lpa.disableProfile(iccid, refresh)
override fun deleteProfile(iccid: String): Boolean = lpa.deleteProfile(iccid)
override fun downloadProfile(
smdp: String,
matchingId: String?,
imei: String?,
confirmationCode: String?,
callback: ProfileDownloadCallback
) = lpa.downloadProfile(smdp, matchingId, imei, confirmationCode, callback)
override fun deleteNotification(seqNumber: Long): Boolean = lpa.deleteNotification(seqNumber)
override fun handleNotification(seqNumber: Long): Boolean = lpa.handleNotification(seqNumber)
override fun euiccMemoryReset() = lpa.euiccMemoryReset()
override fun setNickname(iccid: String, nickname: String) {
lpa.setNickname(iccid, nickname)
}
override fun close() = lpa.close()
fun invalidateWrapper() {
_inner = null
}
}

View file

@ -15,7 +15,7 @@ class OmapiApduInterface(
private val service: SEService,
private val port: UiccPortInfoCompat,
private val verboseLoggingFlow: Flow<Boolean>
): ApduInterface {
): ApduInterface, ApduInterfaceAtrProvider {
companion object {
const val TAG = "OmapiApduInterface"
}
@ -26,6 +26,9 @@ class OmapiApduInterface(
override val valid: Boolean
get() = service.isConnected && (this::session.isInitialized && !session.isClosed)
override val atr: ByteArray?
get() = session.atr
override fun connect() {
session = service.getUiccReaderCompat(port.logicalSlotIndex + 1).openSession()
}
@ -38,8 +41,8 @@ class OmapiApduInterface(
check(!this::lastChannel.isInitialized) {
"Can only open one channel"
}
lastChannel = session.openLogicalChannel(aid)!!;
return 1;
lastChannel = session.openLogicalChannel(aid)!!
return 1
}
override fun logicalChannelClose(handle: Int) {

View file

@ -3,6 +3,7 @@ package im.angry.openeuicc.core.usb
import android.hardware.usb.UsbDeviceConnection
import android.hardware.usb.UsbEndpoint
import android.util.Log
import im.angry.openeuicc.core.ApduInterfaceAtrProvider
import im.angry.openeuicc.util.*
import kotlinx.coroutines.flow.Flow
import net.typeblog.lpac_jni.ApduInterface
@ -12,7 +13,7 @@ class UsbApduInterface(
private val bulkIn: UsbEndpoint,
private val bulkOut: UsbEndpoint,
private val verboseLoggingFlow: Flow<Boolean>
): ApduInterface {
) : ApduInterface, ApduInterfaceAtrProvider {
companion object {
private const val TAG = "UsbApduInterface"
}
@ -22,6 +23,8 @@ class UsbApduInterface(
private var channelId = -1
override var atr: ByteArray? = null
override fun connect() {
ccidDescription = UsbCcidDescription.fromRawDescriptors(conn.rawDescriptors)!!
@ -32,7 +35,9 @@ class UsbApduInterface(
transceiver = UsbCcidTransceiver(conn, bulkIn, bulkOut, ccidDescription, verboseLoggingFlow)
try {
transceiver.iccPowerOn()
// 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

View file

@ -15,4 +15,5 @@ interface AppContainer {
val preferenceRepository: PreferenceRepository
val uiComponentFactory: UiComponentFactory
val euiccChannelFactory: EuiccChannelFactory
val customizableTextProvider: CustomizableTextProvider
}

View file

@ -0,0 +1,20 @@
package im.angry.openeuicc.di
interface CustomizableTextProvider {
/**
* Explanation string for when no eUICC is found on the device.
* This could be different depending on whether the app is privileged or not.
*/
val noEuiccExplanation: String
/**
* Shown when we timed out switching between profiles.
*/
val profileSwitchingTimeoutMessage: String
/**
* Format the name of a logical slot; internal only -- not intended for
* other channels such as USB.
*/
fun formatInternalChannelName(logicalSlotId: Int): String
}

View file

@ -38,4 +38,8 @@ open class DefaultAppContainer(context: Context) : AppContainer {
override val euiccChannelFactory by lazy {
DefaultEuiccChannelFactory(context)
}
override val customizableTextProvider by lazy {
DefaultCustomizableTextProvider(context)
}
}

View file

@ -0,0 +1,15 @@
package im.angry.openeuicc.di
import android.content.Context
import im.angry.openeuicc.common.R
open class DefaultCustomizableTextProvider(private val context: Context) : CustomizableTextProvider {
override val noEuiccExplanation: String
get() = context.getString(R.string.no_euicc)
override val profileSwitchingTimeoutMessage: String
get() = context.getString(R.string.enable_disable_timeout)
override fun formatInternalChannelName(logicalSlotId: Int): String =
context.getString(R.string.channel_name_format, logicalSlotId)
}

View file

@ -1,13 +1,16 @@
package im.angry.openeuicc.di
import androidx.fragment.app.Fragment
import im.angry.openeuicc.core.EuiccChannel
import androidx.preference.PreferenceFragmentCompat
import im.angry.openeuicc.ui.EuiccManagementFragment
import im.angry.openeuicc.ui.NoEuiccPlaceholderFragment
import im.angry.openeuicc.ui.SettingsFragment
open class DefaultUiComponentFactory : UiComponentFactory {
override fun createEuiccManagementFragment(channel: EuiccChannel): EuiccManagementFragment =
EuiccManagementFragment.newInstance(channel.slotId, channel.portId)
override fun createEuiccManagementFragment(slotId: Int, portId: Int): EuiccManagementFragment =
EuiccManagementFragment.newInstance(slotId, portId)
override fun createNoEuiccPlaceholderFragment(): Fragment = NoEuiccPlaceholderFragment()
override fun createSettingsFragment(): Fragment = SettingsFragment()
}

View file

@ -1,10 +1,11 @@
package im.angry.openeuicc.di
import androidx.fragment.app.Fragment
import im.angry.openeuicc.core.EuiccChannel
import androidx.preference.PreferenceFragmentCompat
import im.angry.openeuicc.ui.EuiccManagementFragment
interface UiComponentFactory {
fun createEuiccManagementFragment(channel: EuiccChannel): EuiccManagementFragment
fun createEuiccManagementFragment(slotId: Int, portId: Int): EuiccManagementFragment
fun createNoEuiccPlaceholderFragment(): Fragment
fun createSettingsFragment(): Fragment
}

View file

@ -15,14 +15,19 @@ import im.angry.openeuicc.core.EuiccChannelManager
import im.angry.openeuicc.util.*
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.NonCancellable
import kotlinx.coroutines.channels.BufferOverflow
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharedFlow
import kotlinx.coroutines.flow.asSharedFlow
import kotlinx.coroutines.flow.collect
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.flow
import kotlinx.coroutines.flow.last
import kotlinx.coroutines.flow.onCompletion
import kotlinx.coroutines.flow.onStart
import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.flow.takeWhile
import kotlinx.coroutines.flow.transformWhile
import kotlinx.coroutines.isActive
@ -55,7 +60,26 @@ class EuiccChannelManagerService : LifecycleService(), OpenEuiccContextMarker {
private const val TAG = "EuiccChannelManagerService"
private const val CHANNEL_ID = "tasks"
private const val FOREGROUND_ID = 1000
private const val TASK_FAILURE_ID = 1001
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() {
@ -89,6 +113,25 @@ class EuiccChannelManagerService : LifecycleService(), OpenEuiccContextMarker {
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()
@ -166,12 +209,26 @@ class EuiccChannelManagerService : LifecycleService(), OpenEuiccContextMarker {
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 MUST be started in order for the
* foreground task to run.
* 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.
@ -185,7 +242,9 @@ class EuiccChannelManagerService : LifecycleService(), OpenEuiccContextMarker {
failureTitle: String,
iconRes: Int,
task: suspend EuiccChannelManagerService.() -> Unit
): Flow<ForegroundTaskState>? {
): 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(
@ -193,7 +252,9 @@ class EuiccChannelManagerService : LifecycleService(), OpenEuiccContextMarker {
ForegroundTaskState.InProgress(0)
)
) {
return null
return ForegroundTaskSubscriberFlow(
taskID,
flow { emit(ForegroundTaskState.Done(IllegalStateException("There are tasks currently running"))) })
}
lifecycleScope.launch(Dispatchers.Main) {
@ -235,38 +296,71 @@ class EuiccChannelManagerService : LifecycleService(), OpenEuiccContextMarker {
}
}
// 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.
return foregroundTaskState.transformWhile {
// 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) {
withContext(Dispatchers.Main) {
updateForegroundNotification(title, iconRes)
}
}
emit(it)
it !is ForegroundTaskState.Done
}.onStart {
// When this Flow is started, we unblock the coroutine launched above by
// self-starting as a foreground service.
withContext(Dispatchers.Main) {
startForegroundService(
Intent(
this@EuiccChannelManagerService,
this@EuiccChannelManagerService::class.java
)
)
}
}.onCompletion { foregroundTaskState.value = ForegroundTaskState.Idle }
}
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)
}
val isForegroundTaskRunning: Boolean
get() = foregroundTaskState.value != ForegroundTaskState.Idle
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 }
@ -280,30 +374,26 @@ class EuiccChannelManagerService : LifecycleService(), OpenEuiccContextMarker {
matchingId: String?,
confirmationCode: String?,
imei: String?
): Flow<ForegroundTaskState>? =
): 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) {
val channel = euiccChannelManager.findEuiccChannelByPort(slotId, portId)
val res = 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)
}
})
if (!res) {
// TODO: Provide more details on the error
throw RuntimeException("Failed to download profile; this is typically caused by another error happened before.")
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()
@ -315,19 +405,17 @@ class EuiccChannelManagerService : LifecycleService(), OpenEuiccContextMarker {
portId: Int,
iccid: String,
name: String
): Flow<ForegroundTaskState>? =
): ForegroundTaskSubscriberFlow =
launchForegroundTask(
getString(R.string.task_profile_rename),
getString(R.string.task_profile_rename_failure),
R.drawable.ic_task_rename
) {
val res = euiccChannelManager.findEuiccChannelByPort(slotId, portId)!!.lpa.setNickname(
iccid,
name
)
if (!res) {
throw RuntimeException("Profile not renamed")
euiccChannelManager.withEuiccChannel(slotId, portId) { channel ->
channel.lpa.setNickname(
iccid,
name
)
}
}
@ -335,17 +423,16 @@ class EuiccChannelManagerService : LifecycleService(), OpenEuiccContextMarker {
slotId: Int,
portId: Int,
iccid: String
): Flow<ForegroundTaskState>? =
): 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.findEuiccChannelByPort(
slotId,
portId
)!!.lpa.deleteProfile(iccid)
euiccChannelManager.withEuiccChannel(slotId, portId) { channel ->
channel.lpa.deleteProfile(iccid)
}
preferenceRepository.notificationDeleteFlow.first()
}
@ -358,16 +445,18 @@ class EuiccChannelManagerService : LifecycleService(), OpenEuiccContextMarker {
portId: Int,
iccid: String,
enable: Boolean, // Enable or disable the profile indicated in iccid
reconnectTimeoutMillis: Long = 0 // 0 = do not wait for reconnect, useful for USB readers
): Flow<ForegroundTaskState>? =
reconnectTimeoutMillis: Long = 0 // 0 = do not wait for reconnect
): ForegroundTaskSubscriberFlow =
launchForegroundTask(
getString(R.string.task_profile_switch),
getString(R.string.task_profile_switch_failure),
R.drawable.ic_task_switch
) {
euiccChannelManager.beginTrackedOperation(slotId, portId) {
val channel = euiccChannelManager.findEuiccChannelByPort(slotId, portId)!!
val (res, refreshed) =
val (res, refreshed) = euiccChannelManager.withEuiccChannel(
slotId,
portId
) { channel ->
if (!channel.lpa.switchProfile(iccid, enable, refresh = true)) {
// Sometimes, we *can* enable or disable the profile, but we cannot
// send the refresh command to the modem because the profile somehow
@ -378,13 +467,15 @@ class EuiccChannelManagerService : LifecycleService(), OpenEuiccContextMarker {
} else {
Pair(true, true)
}
}
if (!res) {
throw RuntimeException("Could not switch profile")
}
if (!refreshed) {
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()
}

View file

@ -1,40 +0,0 @@
package im.angry.openeuicc.ui
import androidx.lifecycle.lifecycleScope
import im.angry.openeuicc.util.*
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
class DirectProfileDownloadActivity : BaseEuiccAccessActivity(), SlotSelectFragment.SlotSelectedListener, OpenEuiccContextMarker {
override fun onInit() {
lifecycleScope.launch {
val knownChannels = withContext(Dispatchers.IO) {
euiccChannelManager.enumerateEuiccChannels()
}
when {
knownChannels.isEmpty() -> {
finish()
}
knownChannels.hasMultipleChips -> {
SlotSelectFragment.newInstance(knownChannels.sortedBy { it.logicalSlotId })
.show(supportFragmentManager, SlotSelectFragment.TAG)
}
else -> {
// If the device has only one eSIM "chip" (but may be mapped to multiple slots),
// we can skip the slot selection dialog since there is only one chip to save to.
onSlotSelected(knownChannels[0].slotId,
knownChannels[0].portId)
}
}
}
}
override fun onSlotSelected(slotId: Int, portId: Int) {
ProfileDownloadFragment.newInstance(slotId, portId, finishWhenDone = true)
.show(supportFragmentManager, ProfileDownloadFragment.TAG)
}
override fun onSlotSelectCancelled() = finish()
}

View file

@ -0,0 +1,204 @@
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
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
)
)
channel.lpa.euiccInfo2.let { info ->
add(Item(R.string.euicc_info_sgp22_version, info?.sgp22Version))
add(Item(R.string.euicc_info_firmware_version, info?.euiccFirmwareVersion))
add(Item(R.string.euicc_info_globalplatform_version, info?.globalPlatformVersion))
add(Item(R.string.euicc_info_pp_version, info?.ppVersion))
add(Item(R.string.euicc_info_sas_accreditation_number, info?.sasAccreditationNumber))
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)))
}
add(
Item(
R.string.euicc_info_atr,
channel.atr?.encodeHex() ?: getString(R.string.information_unavailable),
copiedToastResId = R.string.toast_atr_copied,
)
)
}
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])
}
}
}

View file

@ -4,9 +4,9 @@ import android.annotation.SuppressLint
import android.content.ClipData
import android.content.ClipboardManager
import android.content.Intent
import android.os.Build
import android.os.Bundle
import android.text.method.PasswordTransformationMethod
import android.util.Log
import android.view.LayoutInflater
import android.view.Menu
import android.view.MenuInflater
@ -21,6 +21,7 @@ import android.widget.Toast
import androidx.appcompat.app.AlertDialog
import androidx.core.view.ViewCompat
import androidx.core.view.WindowInsetsCompat
import androidx.core.view.isVisible
import androidx.core.view.updateLayoutParams
import androidx.fragment.app.Fragment
import androidx.lifecycle.lifecycleScope
@ -31,11 +32,12 @@ import com.google.android.material.floatingactionbutton.FloatingActionButton
import net.typeblog.lpac_jni.LocalProfileInfo
import im.angry.openeuicc.common.R
import im.angry.openeuicc.service.EuiccChannelManagerService
import im.angry.openeuicc.service.EuiccChannelManagerService.Companion.waitDone
import im.angry.openeuicc.ui.wizard.DownloadWizardActivity
import im.angry.openeuicc.util.*
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.TimeoutCancellationException
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.last
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
@ -52,6 +54,7 @@ open class EuiccManagementFragment : Fragment(), EuiccProfilesChangedListener,
private lateinit var swipeRefresh: SwipeRefreshLayout
private lateinit var fab: FloatingActionButton
private lateinit var profileList: RecyclerView
private var logicalSlotId: Int = -1
private val adapter = EuiccProfileAdapter()
@ -63,6 +66,8 @@ open class EuiccManagementFragment : Fragment(), EuiccProfilesChangedListener,
// This gives us access to the "latest" state without having to launch coroutines
private lateinit var disableSafeguardFlow: StateFlow<Boolean>
private lateinit var unfilteredProfileListFlow: StateFlow<Boolean>
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setHasOptionsMenu(true)
@ -105,8 +110,10 @@ open class EuiccManagementFragment : Fragment(), EuiccProfilesChangedListener,
LinearLayoutManager(view.context, LinearLayoutManager.VERTICAL, false)
fab.setOnClickListener {
ProfileDownloadFragment.newInstance(slotId, portId)
.show(childFragmentManager, ProfileDownloadFragment.TAG)
Intent(requireContext(), DownloadWizardActivity::class.java).apply {
putExtra("selectedLogicalSlot", logicalSlotId)
startActivity(this)
}
}
}
@ -127,9 +134,21 @@ open class EuiccManagementFragment : Fragment(), EuiccProfilesChangedListener,
override fun onOptionsItemSelected(item: MenuItem): Boolean =
when (item.itemId) {
R.id.show_notifications -> {
Intent(requireContext(), NotificationsActivity::class.java).apply {
putExtra("logicalSlotId", channel.logicalSlotId)
startActivity(this)
if (logicalSlotId != -1) {
Intent(requireContext(), NotificationsActivity::class.java).apply {
putExtra("logicalSlotId", logicalSlotId)
startActivity(this)
}
}
true
}
R.id.euicc_info -> {
if (logicalSlotId != -1) {
Intent(requireContext(), EuiccInfoActivity::class.java).apply {
putExtra("logicalSlotId", logicalSlotId)
startActivity(this)
}
}
true
}
@ -148,31 +167,43 @@ open class EuiccManagementFragment : Fragment(), EuiccProfilesChangedListener,
listOf()
}
@SuppressLint("NotifyDataSetChanged")
private fun refresh() {
if (invalid) return
swipeRefresh.isRefreshing = true
lifecycleScope.launch {
ensureEuiccChannelManager()
euiccChannelManagerService.waitForForegroundTask()
doRefresh()
}
}
if (!this@EuiccManagementFragment::disableSafeguardFlow.isInitialized) {
disableSafeguardFlow =
preferenceRepository.disableSafeguardFlow.stateIn(lifecycleScope)
}
@SuppressLint("NotifyDataSetChanged")
protected open suspend fun doRefresh() {
ensureEuiccChannelManager()
euiccChannelManagerService.waitForForegroundTask()
val profiles = withContext(Dispatchers.IO) {
euiccChannelManager.notifyEuiccProfilesChanged(channel.logicalSlotId)
if (!::disableSafeguardFlow.isInitialized) {
disableSafeguardFlow =
preferenceRepository.disableSafeguardFlow.stateIn(lifecycleScope)
}
if (!::unfilteredProfileListFlow.isInitialized) {
unfilteredProfileListFlow =
preferenceRepository.unfilteredProfileListFlow.stateIn(lifecycleScope)
}
val profiles = withEuiccChannel { channel ->
logicalSlotId = channel.logicalSlotId
euiccChannelManager.notifyEuiccProfilesChanged(channel.logicalSlotId)
if (unfilteredProfileListFlow.value)
channel.lpa.profiles
else
channel.lpa.profiles.operational
}
}
withContext(Dispatchers.Main) {
adapter.profiles = profiles
adapter.footerViews = onCreateFooterViews(profileList, profiles)
adapter.notifyDataSetChanged()
swipeRefresh.isRefreshing = false
}
withContext(Dispatchers.Main) {
adapter.profiles = profiles
adapter.footerViews = onCreateFooterViews(profileList, profiles)
adapter.notifyDataSetChanged()
swipeRefresh.isRefreshing = false
}
}
@ -192,24 +223,15 @@ open class EuiccManagementFragment : Fragment(), EuiccProfilesChangedListener,
ensureEuiccChannelManager()
euiccChannelManagerService.waitForForegroundTask()
val res = euiccChannelManagerService.launchProfileSwitchTask(
val err = euiccChannelManagerService.launchProfileSwitchTask(
slotId,
portId,
iccid,
enable,
reconnectTimeoutMillis = if (isUsb) {
0
} else {
30 * 1000
}
)?.last() as? EuiccChannelManagerService.ForegroundTaskState.Done
reconnectTimeoutMillis = 30 * 1000
).waitDone()
if (res == null) {
showSwitchFailureText()
return@launch
}
when (res.error) {
when (err) {
null -> {}
is EuiccChannelManagerService.SwitchingProfilesRefreshException -> {
// This is only really fatal for internal eSIMs
@ -236,7 +258,7 @@ open class EuiccManagementFragment : Fragment(), EuiccProfilesChangedListener,
invalid = true
// Timed out waiting for SIM to come back online, we can no longer assume that the LPA is still valid
AlertDialog.Builder(requireContext()).apply {
setMessage(R.string.enable_disable_timeout)
setMessage(appContainer.customizableTextProvider.profileSwitchingTimeoutMessage)
setPositiveButton(android.R.string.ok) { dialog, _ ->
dialog.dismiss()
requireActivity().finish()
@ -279,7 +301,7 @@ open class EuiccManagementFragment : Fragment(), EuiccProfilesChangedListener,
companion object {
fun fromInt(value: Int) =
Type.values().first { it.value == value }
entries.first { it.value == value }
}
}
}
@ -307,6 +329,8 @@ open class EuiccManagementFragment : Fragment(), EuiccProfilesChangedListener,
private val name: TextView = root.requireViewById(R.id.name)
private val state: TextView = root.requireViewById(R.id.state)
private val provider: TextView = root.requireViewById(R.id.provider)
private val profileClassLabel: TextView = root.requireViewById(R.id.profile_class_label)
private val profileClass: TextView = root.requireViewById(R.id.profile_class)
private val profileMenu: ImageButton = root.requireViewById(R.id.profile_menu)
init {
@ -321,7 +345,8 @@ open class EuiccManagementFragment : Fragment(), EuiccProfilesChangedListener,
iccid.setOnLongClickListener {
requireContext().getSystemService(ClipboardManager::class.java)!!
.setPrimaryClip(ClipData.newPlainText("iccid", iccid.text))
Toast.makeText(requireContext(), R.string.toast_iccid_copied, Toast.LENGTH_SHORT)
if (Build.VERSION.SDK_INT <= Build.VERSION_CODES.S_V2) Toast
.makeText(requireContext(), R.string.toast_iccid_copied, Toast.LENGTH_SHORT)
.show()
true
}
@ -343,6 +368,15 @@ open class EuiccManagementFragment : Fragment(), EuiccProfilesChangedListener,
}
)
provider.text = profile.providerName
profileClassLabel.isVisible = unfilteredProfileListFlow.value
profileClass.isVisible = unfilteredProfileListFlow.value
profileClass.setText(
when (profile.profileClass) {
LocalProfileInfo.Clazz.Testing -> R.string.profile_class_testing
LocalProfileInfo.Clazz.Provisioning -> R.string.profile_class_provisioning
LocalProfileInfo.Clazz.Operational -> R.string.profile_class_operational
}
)
iccid.text = profile.iccid
iccid.transformationMethod = PasswordTransformationMethod.getInstance()
}

View file

@ -1,6 +1,7 @@
package im.angry.openeuicc.ui
import android.icu.text.SimpleDateFormat
import android.os.Build
import android.os.Bundle
import android.view.Menu
import android.view.MenuItem
@ -8,7 +9,6 @@ import android.view.View
import android.widget.ScrollView
import android.widget.TextView
import androidx.activity.enableEdgeToEdge
import androidx.activity.result.contract.ActivityResultContracts
import androidx.appcompat.app.AppCompatActivity
import androidx.lifecycle.lifecycleScope
import androidx.swiperefreshlayout.widget.SwipeRefreshLayout
@ -17,7 +17,6 @@ import im.angry.openeuicc.util.*
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import java.io.FileOutputStream
import java.util.Date
class LogsActivity : AppCompatActivity() {
@ -27,15 +26,25 @@ class LogsActivity : AppCompatActivity() {
private lateinit var logStr: String
private val saveLogs =
registerForActivityResult(ActivityResultContracts.CreateDocument("text/plain")) { uri ->
if (uri == null) return@registerForActivityResult
if (!this::logStr.isInitialized) return@registerForActivityResult
contentResolver.openFileDescriptor(uri, "w")?.use {
FileOutputStream(it.fileDescriptor).use { os ->
os.write(logStr.encodeToByteArray())
}
}
}
setupLogSaving(
getLogFileName = {
getString(
R.string.logs_filename_template,
SimpleDateFormat.getDateTimeInstance().format(Date())
)
},
getLogText = ::buildLogText
)
private fun buildLogText() = buildString {
appendLine("Manufacturer: ${Build.MANUFACTURER}")
appendLine("Brand: ${Build.BRAND}")
appendLine("Model: ${Build.MODEL}")
appendLine("SDK Version: ${Build.VERSION.SDK_INT}")
appendLine("App Version: $selfAppVersion")
appendLine("-".repeat(10))
appendLine(logStr)
}
override fun onCreate(savedInstanceState: Bundle?) {
enableEdgeToEdge()
@ -76,9 +85,7 @@ class LogsActivity : AppCompatActivity() {
true
}
R.id.save -> {
saveLogs.launch(getString(R.string.logs_filename_template,
SimpleDateFormat.getDateTimeInstance().format(Date())
))
saveLogs()
true
}
else -> super.onOptionsItemSelected(item)

View file

@ -23,9 +23,12 @@ import androidx.viewpager2.widget.ViewPager2
import com.google.android.material.tabs.TabLayout
import com.google.android.material.tabs.TabLayoutMediator
import im.angry.openeuicc.common.R
import im.angry.openeuicc.core.EuiccChannelManager
import im.angry.openeuicc.util.*
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.collect
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
@ -44,6 +47,7 @@ open class MainActivity : BaseEuiccAccessActivity(), OpenEuiccContextMarker {
private var refreshing = false
private data class Page(
val logicalSlotId: Int,
val title: String,
val createFragment: () -> Fragment
)
@ -105,7 +109,7 @@ open class MainActivity : BaseEuiccAccessActivity(), OpenEuiccContextMarker {
override fun onOptionsItemSelected(item: MenuItem): Boolean =
when (item.itemId) {
R.id.settings -> {
startActivity(Intent(this, SettingsActivity::class.java));
startActivity(Intent(this, SettingsActivity::class.java))
true
}
R.id.reload -> {
@ -122,7 +126,10 @@ open class MainActivity : BaseEuiccAccessActivity(), OpenEuiccContextMarker {
}
private fun ensureNotificationPermissions() {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU && checkSelfPermission(android.Manifest.permission.POST_NOTIFICATIONS) != PackageManager.PERMISSION_GRANTED) {
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
@ -138,65 +145,75 @@ open class MainActivity : BaseEuiccAccessActivity(), OpenEuiccContextMarker {
// Prevent concurrent access with any running foreground task
euiccChannelManagerService.waitForForegroundTask()
val knownChannels = withContext(Dispatchers.IO) {
euiccChannelManager.enumerateEuiccChannels().onEach {
Log.d(TAG, "slot ${it.slotId} port ${it.portId}")
val (usbDevice, _) = withContext(Dispatchers.IO) {
euiccChannelManager.tryOpenUsbEuiccChannel()
}
val newPages: MutableList<Page> = mutableListOf()
euiccChannelManager.flowInternalEuiccPorts().onEach { (slotId, portId) ->
Log.d(TAG, "slot $slotId port $portId")
euiccChannelManager.withEuiccChannel(slotId, portId) { channel ->
if (preferenceRepository.verboseLoggingFlow.first()) {
Log.d(TAG, it.lpa.eID)
Log.d(TAG, channel.lpa.eID)
}
// Request the system to refresh the list of profiles every time we start
// Note that this is currently supposed to be no-op when unprivileged,
// but it could change in the future
euiccChannelManager.notifyEuiccProfilesChanged(it.logicalSlotId)
euiccChannelManager.notifyEuiccProfilesChanged(channel.logicalSlotId)
val channelName =
appContainer.customizableTextProvider.formatInternalChannelName(channel.logicalSlotId)
newPages.add(Page(channel.logicalSlotId, channelName) {
appContainer.uiComponentFactory.createEuiccManagementFragment(slotId, portId)
})
}
}.collect()
// If USB readers exist, add them at the very last
// We use a wrapper fragment to handle logic specific to USB readers
usbDevice?.let {
val productName = it.productName ?: getString(R.string.usb)
newPages.add(Page(EuiccChannelManager.USB_CHANNEL_ID, productName) {
UsbCcidReaderFragment()
})
}
viewPager.visibility = View.VISIBLE
if (newPages.size > 1) {
tabs.visibility = View.VISIBLE
} else if (newPages.isEmpty()) {
newPages.add(Page(-1, "") {
appContainer.uiComponentFactory.createNoEuiccPlaceholderFragment()
})
}
val (usbDevice, _) = withContext(Dispatchers.IO) {
euiccChannelManager.enumerateUsbEuiccChannel()
newPages.sortBy { it.logicalSlotId }
pages.clear()
pages.addAll(newPages)
loadingProgress.visibility = View.GONE
pagerAdapter.notifyDataSetChanged()
// Reset the adapter so that the current view actually gets cleared
// notifyDataSetChanged() doesn't cause the current view to be removed.
viewPager.adapter = pagerAdapter
if (fromUsbEvent && usbDevice != null) {
// If this refresh was triggered by a USB insertion while active, scroll to that page
viewPager.post {
viewPager.setCurrentItem(pages.size - 1, true)
}
} else {
viewPager.currentItem = 0
}
withContext(Dispatchers.Main) {
loadingProgress.visibility = View.GONE
knownChannels.sortedBy { it.logicalSlotId }.forEach { channel ->
pages.add(Page(
getString(R.string.channel_name_format, channel.logicalSlotId)
) { appContainer.uiComponentFactory.createEuiccManagementFragment(channel) })
}
// If USB readers exist, add them at the very last
// We use a wrapper fragment to handle logic specific to USB readers
usbDevice?.let {
pages.add(Page(it.productName ?: getString(R.string.usb)) { UsbCcidReaderFragment() })
}
viewPager.visibility = View.VISIBLE
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
}
if (pages.size > 0) {
ensureNotificationPermissions()
}
refreshing = false
if (pages.size > 0) {
ensureNotificationPermissions()
}
refreshing = false
}
private fun refresh(fromUsbEvent: Boolean = false) {

View file

@ -4,15 +4,20 @@ import android.os.Bundle
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import android.widget.TextView
import androidx.fragment.app.Fragment
import im.angry.openeuicc.common.R
import im.angry.openeuicc.util.*
class NoEuiccPlaceholderFragment : Fragment() {
class NoEuiccPlaceholderFragment : Fragment(), OpenEuiccContextMarker {
override fun onCreateView(
inflater: LayoutInflater,
container: ViewGroup?,
savedInstanceState: Bundle?
): View? {
return inflater.inflate(R.layout.fragment_no_euicc_placeholder, container, false)
val view = inflater.inflate(R.layout.fragment_no_euicc_placeholder, container, false)
val textView = view.requireViewById<TextView>(R.id.no_euicc_placeholder)
textView.text = appContainer.customizableTextProvider.noEuiccExplanation
return view
}
}

View file

@ -20,7 +20,6 @@ import androidx.recyclerview.widget.LinearLayoutManager
import androidx.recyclerview.widget.RecyclerView
import androidx.swiperefreshlayout.widget.SwipeRefreshLayout
import im.angry.openeuicc.common.R
import im.angry.openeuicc.core.EuiccChannel
import im.angry.openeuicc.core.EuiccChannelManager
import im.angry.openeuicc.util.*
import kotlinx.coroutines.Dispatchers
@ -33,7 +32,7 @@ class NotificationsActivity: BaseEuiccAccessActivity(), OpenEuiccContextMarker {
private lateinit var notificationList: RecyclerView
private val notificationAdapter = NotificationAdapter()
private lateinit var euiccChannel: EuiccChannel
private var logicalSlotId = -1
override fun onCreate(savedInstanceState: Bundle?) {
enableEdgeToEdge()
@ -56,14 +55,14 @@ class NotificationsActivity: BaseEuiccAccessActivity(), OpenEuiccContextMarker {
notificationList.adapter = notificationAdapter
registerForContextMenu(notificationList)
val logicalSlotId = intent.getIntExtra("logicalSlotId", 0)
logicalSlotId = intent.getIntExtra("logicalSlotId", 0)
// This is slightly different from the MainActivity logic
// due to the length (we don't want to display the full USB product name)
val channelTitle = if (logicalSlotId == EuiccChannelManager.USB_CHANNEL_ID) {
getString(R.string.usb)
} else {
getString(R.string.channel_name_format, logicalSlotId)
appContainer.customizableTextProvider.formatInternalChannelName(logicalSlotId)
}
title = getString(R.string.profile_notifications_detailed_format, channelTitle)
@ -104,16 +103,8 @@ class NotificationsActivity: BaseEuiccAccessActivity(), OpenEuiccContextMarker {
swipeRefresh.isRefreshing = true
lifecycleScope.launch {
if (!this@NotificationsActivity::euiccChannel.isInitialized) {
withContext(Dispatchers.IO) {
euiccChannelManagerLoaded.await()
euiccChannel = euiccChannelManager.findEuiccChannelBySlotBlocking(
intent.getIntExtra(
"logicalSlotId",
0
)
)!!
}
withContext(Dispatchers.IO) {
euiccChannelManagerLoaded.await()
}
task()
@ -124,15 +115,16 @@ class NotificationsActivity: BaseEuiccAccessActivity(), OpenEuiccContextMarker {
private fun refresh() {
launchTask {
val profiles = withContext(Dispatchers.IO) {
euiccChannel.lpa.profiles
}
notificationAdapter.notifications =
withContext(Dispatchers.IO) {
euiccChannel.lpa.notifications.map {
val profile = profiles.find { p -> p.iccid == it.iccid }
LocalProfileNotificationWrapper(it, profile?.displayName ?: "???")
euiccChannelManager.withEuiccChannel(logicalSlotId) { channel ->
val nameMap = buildMap {
for (profile in channel.lpa.profiles) {
put(profile.iccid, profile.displayName)
}
}
channel.lpa.notifications.map {
LocalProfileNotificationWrapper(it, nameMap[it.iccid] ?: "???")
}
}
}
@ -147,6 +139,8 @@ class NotificationsActivity: BaseEuiccAccessActivity(), OpenEuiccContextMarker {
inner class NotificationViewHolder(private val root: View):
RecyclerView.ViewHolder(root), View.OnCreateContextMenuListener, OnMenuItemClickListener {
private val address: TextView = root.requireViewById(R.id.notification_address)
private val sequenceNumber: TextView =
root.requireViewById(R.id.notification_sequence_number)
private val profileName: TextView = root.requireViewById(R.id.notification_profile_name)
private lateinit var notification: LocalProfileNotificationWrapper
@ -168,6 +162,7 @@ class NotificationsActivity: BaseEuiccAccessActivity(), OpenEuiccContextMarker {
}
}
private fun operationToLocalizedText(operation: LocalProfileNotification.Operation) =
root.context.getText(
when (operation) {
@ -181,6 +176,10 @@ class NotificationsActivity: BaseEuiccAccessActivity(), OpenEuiccContextMarker {
notification = value
address.text = value.inner.notificationAddress
sequenceNumber.text = root.context.getString(
R.string.profile_notification_sequence_number_format,
value.inner.seqNumber
)
profileName.text = Html.fromHtml(
root.context.getString(R.string.profile_notification_name_format,
operationToLocalizedText(value.inner.profileManagementOperation),
@ -205,7 +204,9 @@ class NotificationsActivity: BaseEuiccAccessActivity(), OpenEuiccContextMarker {
R.id.notification_process -> {
launchTask {
withContext(Dispatchers.IO) {
euiccChannel.lpa.handleNotification(notification.inner.seqNumber)
euiccChannelManager.withEuiccChannel(logicalSlotId) { channel ->
channel.lpa.handleNotification(notification.inner.seqNumber)
}
}
refresh()
@ -215,7 +216,9 @@ class NotificationsActivity: BaseEuiccAccessActivity(), OpenEuiccContextMarker {
R.id.notification_delete -> {
launchTask {
withContext(Dispatchers.IO) {
euiccChannel.lpa.deleteNotification(notification.inner.seqNumber)
euiccChannelManager.withEuiccChannel(logicalSlotId) { channel ->
channel.lpa.deleteNotification(notification.inner.seqNumber)
}
}
refresh()

View file

@ -4,54 +4,69 @@ import android.app.Dialog
import android.os.Bundle
import android.text.Editable
import android.widget.EditText
import android.widget.Toast
import androidx.appcompat.app.AlertDialog
import androidx.fragment.app.DialogFragment
import androidx.lifecycle.lifecycleScope
import im.angry.openeuicc.common.R
import im.angry.openeuicc.service.EuiccChannelManagerService.Companion.waitDone
import im.angry.openeuicc.util.*
import kotlinx.coroutines.flow.collect
import kotlinx.coroutines.flow.onStart
import kotlinx.coroutines.launch
class ProfileDeleteFragment : DialogFragment(), EuiccChannelFragmentMarker {
companion object {
const val TAG = "ProfileDeleteFragment"
private const val FIELD_ICCID = "iccid"
private const val FIELD_NAME = "name"
fun newInstance(slotId: Int, portId: Int, iccid: String, name: String): ProfileDeleteFragment {
val instance = newInstanceEuicc(ProfileDeleteFragment::class.java, slotId, portId)
instance.requireArguments().apply {
putString("iccid", iccid)
putString("name", name)
putString(FIELD_ICCID, iccid)
putString(FIELD_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 {
EditText(requireContext()).apply {
hint = Editable.Factory.getInstance().newEditable(
getString(R.string.profile_delete_confirm_input, requireArguments().getString("name")!!)
)
hint = Editable.Factory.getInstance()
.newEditable(getString(R.string.profile_delete_confirm_input, name))
}
}
private val inputMatchesName: Boolean
get() = editText.text.toString() == requireArguments().getString("name")!!
get() = editText.text.toString() == name
private var toast: Toast? = null
private var deleting = false
override fun onCreateDialog(savedInstanceState: Bundle?): Dialog {
return AlertDialog.Builder(requireContext(), R.style.AlertDialogTheme).apply {
setMessage(getString(R.string.profile_delete_confirm, requireArguments().getString("name")))
private val alertDialog: AlertDialog
get() = requireDialog() as AlertDialog
override fun onCreateDialog(savedInstanceState: Bundle?): Dialog =
AlertDialog.Builder(requireContext(), R.style.AlertDialogTheme).apply {
setMessage(getString(R.string.profile_delete_confirm, name))
setView(editText)
setPositiveButton(android.R.string.ok, null) // Set listener to null to prevent auto closing
setNegativeButton(android.R.string.cancel, null)
}.create()
}
override fun onResume() {
super.onResume()
val alertDialog = dialog!! as AlertDialog
alertDialog.getButton(AlertDialog.BUTTON_POSITIVE).setOnClickListener {
if (!deleting && inputMatchesName) delete()
if (!deleting) delete()
}
alertDialog.getButton(AlertDialog.BUTTON_NEGATIVE).setOnClickListener {
if (!deleting) dismiss()
@ -59,8 +74,15 @@ class ProfileDeleteFragment : DialogFragment(), EuiccChannelFragmentMarker {
}
private fun delete() {
toast?.cancel()
if (!inputMatchesName) {
val resId = R.string.toast_profile_delete_confirm_text_mismatched
toast = Toast.makeText(requireContext(), resId, Toast.LENGTH_LONG).also {
it.show()
}
return
}
deleting = true
val alertDialog = dialog!! as AlertDialog
alertDialog.setCanceledOnTouchOutside(false)
alertDialog.setCancelable(false)
alertDialog.getButton(AlertDialog.BUTTON_POSITIVE).isEnabled = false
@ -69,12 +91,7 @@ class ProfileDeleteFragment : DialogFragment(), EuiccChannelFragmentMarker {
requireParentFragment().lifecycleScope.launch {
ensureEuiccChannelManager()
euiccChannelManagerService.waitForForegroundTask()
euiccChannelManagerService.launchProfileDeleteTask(
slotId,
portId,
requireArguments().getString("iccid")!!
)!!.onStart {
euiccChannelManagerService.launchProfileDeleteTask(slotId, portId, iccid).onStart {
if (parentFragment is EuiccProfilesChangedListener) {
// Trigger a refresh in the parent fragment -- it should wait until
// any foreground task is completed before actually doing a refresh
@ -86,7 +103,7 @@ class ProfileDeleteFragment : DialogFragment(), EuiccChannelFragmentMarker {
} catch (e: IllegalStateException) {
// Ignored
}
}.collect()
}.waitDone()
}
}
}

View file

@ -1,282 +0,0 @@
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.service.EuiccChannelManagerService
import im.angry.openeuicc.util.*
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.last
import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
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 ->
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()
lifecycleScope.launch(Dispatchers.IO) {
ensureEuiccChannelManager()
if (euiccChannelManagerService.isForegroundTaskRunning) {
withContext(Dispatchers.Main) {
dismiss()
}
return@launch
}
val imei = try {
telephonyManager.getImei(channel.logicalSlotId) ?: ""
} catch (e: Exception) {
""
}
// 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))
profileDownloadIMEI.editText!!.text =
Editable.Factory.getInstance().newEditable(imei)
}
}
}
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 {
ensureEuiccChannelManager()
euiccChannelManagerService.waitForForegroundTask()
val res = doDownloadProfile(server, code, confirmationCode, imei)
if (res == null || res.error != null) {
Log.d(TAG, "Error downloading profile")
if (res?.error != null) {
Log.d(TAG, Log.getStackTraceString(res.error))
}
Toast.makeText(requireContext(), R.string.profile_download_failed, Toast.LENGTH_LONG).show()
}
if (parentFragment is EuiccProfilesChangedListener) {
(parentFragment as EuiccProfilesChangedListener).onEuiccProfilesChanged()
}
try {
dismiss()
} catch (e: IllegalStateException) {
// Ignored
}
}
}
private suspend fun doDownloadProfile(
server: String,
code: String?,
confirmationCode: String?,
imei: String?
) = withContext(Dispatchers.Main) {
// The service is responsible for launching the actual blocking part on the IO context
val res = euiccChannelManagerService.launchProfileDownloadTask(
slotId,
portId,
server,
code,
confirmationCode,
imei
)!!.onEach {
if (it is EuiccChannelManagerService.ForegroundTaskState.InProgress) {
progress.progress = it.progress
progress.isIndeterminate = it.progress == 0
} else {
progress.progress = 100
progress.isIndeterminate = false
}
}.last()
res as? EuiccChannelManagerService.ForegroundTaskState.Done
}
override fun onDismiss(dialog: DialogInterface) {
super.onDismiss(dialog)
if (finishWhenDone) {
activity?.finish()
}
}
override fun onCancel(dialog: DialogInterface) {
super.onCancel(dialog)
if (finishWhenDone) {
activity?.finish()
}
}
}

View file

@ -11,9 +11,10 @@ import androidx.appcompat.widget.Toolbar
import androidx.lifecycle.lifecycleScope
import com.google.android.material.textfield.TextInputLayout
import im.angry.openeuicc.common.R
import im.angry.openeuicc.service.EuiccChannelManagerService.Companion.waitDone
import im.angry.openeuicc.util.*
import kotlinx.coroutines.flow.collect
import kotlinx.coroutines.launch
import net.typeblog.lpac_jni.LocalProfileAssistant
class ProfileRenameFragment : BaseMaterialDialogFragment(), EuiccChannelFragmentMarker {
companion object {
@ -53,6 +54,7 @@ class ProfileRenameFragment : BaseMaterialDialogFragment(), EuiccChannelFragment
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
profileRenameNewName.editText!!.setText(requireArguments().getString("currentName"))
toolbar.apply {
setTitle(R.string.rename)
setNavigationOnClickListener {
@ -65,11 +67,6 @@ class ProfileRenameFragment : BaseMaterialDialogFragment(), EuiccChannelFragment
}
}
override fun onStart() {
super.onStart()
profileRenameNewName.editText!!.setText(requireArguments().getString("currentName"))
}
override fun onResume() {
super.onResume()
setWidthPercent(95)
@ -81,13 +78,18 @@ class ProfileRenameFragment : BaseMaterialDialogFragment(), EuiccChannelFragment
}
}
private fun rename() {
val name = profileRenameNewName.editText!!.text.toString().trim()
if (name.length >= 64) {
Toast.makeText(context, R.string.toast_profile_name_too_long, Toast.LENGTH_LONG).show()
return
}
private fun showErrorAndCancel(errorStrRes: Int) {
Toast.makeText(
requireContext(),
errorStrRes,
Toast.LENGTH_LONG
).show()
renaming = false
progress.visibility = View.GONE
}
private fun rename() {
renaming = true
progress.isIndeterminate = true
progress.visibility = View.VISIBLE
@ -95,21 +97,37 @@ class ProfileRenameFragment : BaseMaterialDialogFragment(), EuiccChannelFragment
lifecycleScope.launch {
ensureEuiccChannelManager()
euiccChannelManagerService.waitForForegroundTask()
euiccChannelManagerService.launchProfileRenameTask(
val res = euiccChannelManagerService.launchProfileRenameTask(
slotId,
portId,
requireArguments().getString("iccid")!!,
name
)?.collect()
profileRenameNewName.editText!!.text.toString().trim()
).waitDone()
if (parentFragment is EuiccProfilesChangedListener) {
(parentFragment as EuiccProfilesChangedListener).onEuiccProfilesChanged()
}
when (res) {
is LocalProfileAssistant.ProfileNameTooLongException -> {
showErrorAndCancel(R.string.profile_rename_too_long)
}
try {
dismiss()
} catch (e: IllegalStateException) {
// Ignored
is LocalProfileAssistant.ProfileNameIsInvalidUTF8Exception -> {
showErrorAndCancel(R.string.profile_rename_encoding_error)
}
is Throwable -> {
showErrorAndCancel(R.string.profile_rename_failure)
}
else -> {
if (parentFragment is EuiccProfilesChangedListener) {
(parentFragment as EuiccProfilesChangedListener).onEuiccProfilesChanged()
}
try {
dismiss()
} catch (e: IllegalStateException) {
// Ignored
}
}
}
}
}

View file

@ -4,10 +4,14 @@ import android.os.Bundle
import android.view.MenuItem
import androidx.activity.enableEdgeToEdge
import androidx.appcompat.app.AppCompatActivity
import im.angry.openeuicc.OpenEuiccApplication
import im.angry.openeuicc.common.R
import im.angry.openeuicc.util.*
class SettingsActivity: AppCompatActivity() {
private val appContainer
get() = (application as OpenEuiccApplication).appContainer
override fun onCreate(savedInstanceState: Bundle?) {
enableEdgeToEdge()
super.onCreate(savedInstanceState)
@ -15,8 +19,9 @@ class SettingsActivity: AppCompatActivity() {
setSupportActionBar(requireViewById(R.id.toolbar))
setupToolbarInsets()
supportActionBar!!.setDisplayHomeAsUpEnabled(true)
val settingsFragment = appContainer.uiComponentFactory.createSettingsFragment()
supportFragmentManager.beginTransaction()
.replace(R.id.settings_container, SettingsFragment())
.replace(R.id.settings_container, settingsFragment)
.commit()
}

View file

@ -2,71 +2,154 @@ package im.angry.openeuicc.ui
import android.content.Intent
import android.net.Uri
import android.os.Build
import android.os.Bundle
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import androidx.datastore.preferences.core.Preferences
import android.provider.Settings
import android.widget.Toast
import androidx.lifecycle.lifecycleScope
import androidx.preference.CheckBoxPreference
import androidx.preference.Preference
import androidx.preference.PreferenceCategory
import androidx.preference.PreferenceFragmentCompat
import im.angry.openeuicc.common.R
import im.angry.openeuicc.util.*
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.collect
import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.launch
import kotlinx.coroutines.runBlocking
class SettingsFragment: PreferenceFragmentCompat() {
open class SettingsFragment: PreferenceFragmentCompat() {
private lateinit var developerPref: PreferenceCategory
// Hidden developer options switch
private var numClicks = 0
private var lastClickTimestamp = -1L
private var lastToast: Toast? = null
override fun onCreatePreferences(savedInstanceState: Bundle?, rootKey: String?) {
setPreferencesFromResource(R.xml.pref_settings, rootKey)
findPreference<Preference>("pref_info_app_version")
?.summary = requireContext().selfAppVersion
developerPref = requirePreference("pref_developer")
findPreference<Preference>("pref_info_source_code")
?.setOnPreferenceClickListener {
startActivity(Intent(Intent.ACTION_VIEW, Uri.parse(it.summary.toString())))
true
// Show / hide developer preference based on whether it is enabled
lifecycleScope.launch {
preferenceRepository.developerOptionsEnabledFlow
.onEach { developerPref.isVisible = it }
.collect()
}
requirePreference<Preference>("pref_info_app_version").apply {
summary = requireContext().selfAppVersion
// Enable developer options when this is clicked for 7 times
setOnPreferenceClickListener(::onAppVersionClicked)
}
requirePreference<Preference>("pref_advanced_language").apply {
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.TIRAMISU) return@apply
isVisible = true
intent = Intent(Settings.ACTION_APP_LOCALE_SETTINGS).apply {
data = Uri.fromParts("package", requireContext().packageName, null)
}
}
findPreference<Preference>("pref_advanced_logs")
?.setOnPreferenceClickListener {
startActivity(Intent(requireContext(), LogsActivity::class.java))
true
}
requirePreference<Preference>("pref_advanced_logs").apply {
intent = Intent(requireContext(), LogsActivity::class.java)
}
findPreference<CheckBoxPreference>("pref_notifications_download")
?.bindBooleanFlow(preferenceRepository.notificationDownloadFlow, PreferenceKeys.NOTIFICATION_DOWNLOAD)
requirePreference<CheckBoxPreference>("pref_notifications_download")
.bindBooleanFlow(preferenceRepository.notificationDownloadFlow)
findPreference<CheckBoxPreference>("pref_notifications_delete")
?.bindBooleanFlow(preferenceRepository.notificationDeleteFlow, PreferenceKeys.NOTIFICATION_DELETE)
requirePreference<CheckBoxPreference>("pref_notifications_delete")
.bindBooleanFlow(preferenceRepository.notificationDeleteFlow)
findPreference<CheckBoxPreference>("pref_notifications_switch")
?.bindBooleanFlow(preferenceRepository.notificationSwitchFlow, PreferenceKeys.NOTIFICATION_SWITCH)
requirePreference<CheckBoxPreference>("pref_notifications_switch")
.bindBooleanFlow(preferenceRepository.notificationSwitchFlow)
findPreference<CheckBoxPreference>("pref_advanced_disable_safeguard_removable_esim")
?.bindBooleanFlow(preferenceRepository.disableSafeguardFlow, PreferenceKeys.DISABLE_SAFEGUARD_REMOVABLE_ESIM)
requirePreference<CheckBoxPreference>("pref_advanced_disable_safeguard_removable_esim")
.bindBooleanFlow(preferenceRepository.disableSafeguardFlow)
findPreference<CheckBoxPreference>("pref_advanced_verbose_logging")
?.bindBooleanFlow(preferenceRepository.verboseLoggingFlow, PreferenceKeys.VERBOSE_LOGGING)
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)
}
protected fun <T : Preference> requirePreference(key: CharSequence) =
findPreference<T>(key)!!
override fun onStart() {
super.onStart()
setupRootViewInsets(requireView().requireViewById(androidx.preference.R.id.recycler_view))
setupRootViewInsets(requireView().requireViewById(R.id.recycler_view))
}
private fun CheckBoxPreference.bindBooleanFlow(flow: Flow<Boolean>, key: Preferences.Key<Boolean>) {
@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
}
private fun CheckBoxPreference.bindBooleanFlow(flow: PreferenceFlowWrapper<Boolean>) {
lifecycleScope.launch {
flow.collect { isChecked = it }
}
setOnPreferenceChangeListener { _, newValue ->
runBlocking {
preferenceRepository.updatePreference(key, newValue as Boolean)
flow.updatePreference(newValue as Boolean)
}
true
}
}
protected fun mergePreferenceOverlay(overlayKey: String, targetKey: String) {
val overlayCat = requirePreference<PreferenceCategory>(overlayKey)
val targetCat = requirePreference<PreferenceCategory>(targetKey)
val prefs = buildList {
for (i in 0..<overlayCat.preferenceCount) {
add(overlayCat.getPreference(i))
}
}
prefs.forEach {
overlayCat.removePreference(it)
targetCat.addPreference(it)
}
overlayCat.parent?.removePreference(overlayCat)
}
}

View file

@ -1,93 +0,0 @@
package im.angry.openeuicc.ui
import android.content.DialogInterface
import android.os.Bundle
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import android.widget.ArrayAdapter
import android.widget.Spinner
import androidx.appcompat.widget.Toolbar
import im.angry.openeuicc.common.R
import im.angry.openeuicc.core.EuiccChannel
import im.angry.openeuicc.util.*
class SlotSelectFragment : BaseMaterialDialogFragment(), OpenEuiccContextMarker {
companion object {
const val TAG = "SlotSelectFragment"
fun newInstance(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()
}
}

View file

@ -20,7 +20,6 @@ import androidx.fragment.app.Fragment
import androidx.fragment.app.commit
import androidx.lifecycle.lifecycleScope
import im.angry.openeuicc.common.R
import im.angry.openeuicc.core.EuiccChannel
import im.angry.openeuicc.core.EuiccChannelManager
import im.angry.openeuicc.util.*
import kotlinx.coroutines.Dispatchers
@ -73,7 +72,6 @@ class UsbCcidReaderFragment : Fragment(), OpenEuiccContextMarker {
private lateinit var loadingProgress: ProgressBar
private var usbDevice: UsbDevice? = null
private var usbChannel: EuiccChannel? = null
override fun onCreateView(
inflater: LayoutInflater,
@ -122,7 +120,7 @@ class UsbCcidReaderFragment : Fragment(), OpenEuiccContextMarker {
try {
requireContext().unregisterReceiver(usbPermissionReceiver)
} catch (_: Exception) {
// ignore
}
}
@ -131,7 +129,7 @@ class UsbCcidReaderFragment : Fragment(), OpenEuiccContextMarker {
try {
requireContext().unregisterReceiver(usbPermissionReceiver)
} catch (_: Exception) {
// ignore
}
}
@ -140,24 +138,26 @@ class UsbCcidReaderFragment : Fragment(), OpenEuiccContextMarker {
permissionButton.visibility = View.GONE
loadingProgress.visibility = View.VISIBLE
val (device, channel) = withContext(Dispatchers.IO) {
euiccChannelManager.enumerateUsbEuiccChannel()
val (device, canOpen) = withContext(Dispatchers.IO) {
euiccChannelManager.tryOpenUsbEuiccChannel()
}
loadingProgress.visibility = View.GONE
usbDevice = device
usbChannel = channel
if (device != null && channel == null && !usbManager.hasPermission(device)) {
if (device != null && !canOpen && !usbManager.hasPermission(device)) {
text.text = getString(R.string.usb_permission_needed)
text.visibility = View.VISIBLE
permissionButton.visibility = View.VISIBLE
} else if (device != null && channel != null) {
} else if (device != null && canOpen) {
childFragmentManager.commit {
replace(
R.id.child_container,
appContainer.uiComponentFactory.createEuiccManagementFragment(channel)
appContainer.uiComponentFactory.createEuiccManagementFragment(
slotId = EuiccChannelManager.USB_CHANNEL_ID,
portId = 0
)
)
}
} else {

View file

@ -0,0 +1,280 @@
package im.angry.openeuicc.ui.wizard
import android.os.Bundle
import android.view.View
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.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 kotlinx.coroutines.withContext
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?,
)
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(
null,
intent.getIntExtra("selectedLogicalSlot", 0),
"",
null,
null,
null,
false,
-1,
null
)
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
}
}
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)
}
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)
}
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()
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
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() {}
}
}

View file

@ -0,0 +1,75 @@
package im.angry.openeuicc.ui.wizard
import android.os.Bundle
import android.util.Patterns
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 =
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()
}
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()
}
override fun onPause() {
super.onPause()
saveState()
}
private fun updateInputCompleteness() {
inputComplete = Patterns.DOMAIN_NAME.matcher(smdp.editText!!.text).matches()
refreshButtons()
}
}

View file

@ -0,0 +1,139 @@
package im.angry.openeuicc.ui.wizard
import android.icu.text.SimpleDateFormat
import android.os.Bundle
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import android.widget.TextView
import im.angry.openeuicc.common.R
import im.angry.openeuicc.util.*
import java.util.Date
class DownloadWizardDiagnosticsFragment : DownloadWizardActivity.DownloadWizardStepFragment() {
override val hasNext: Boolean
get() = true
override val hasPrev: Boolean
get() = false
private lateinit var diagnosticTextView: TextView
private val saveDiagnostics =
setupLogSaving(
getLogFileName = {
getString(
R.string.download_wizard_diagnostics_file_template,
SimpleDateFormat.getDateTimeInstance().format(Date())
)
},
getLogText = { diagnosticTextView.text.toString() }
)
override fun createNextFragment(): DownloadWizardActivity.DownloadWizardStepFragment? = null
override fun createPrevFragment(): DownloadWizardActivity.DownloadWizardStepFragment? = null
override fun onCreateView(
inflater: LayoutInflater,
container: ViewGroup?,
savedInstanceState: Bundle?
): View? {
val view = inflater.inflate(R.layout.fragment_download_diagnostics, container, false)
view.requireViewById<View>(R.id.download_wizard_diagnostics_save).setOnClickListener {
saveDiagnostics()
}
diagnosticTextView = view.requireViewById(R.id.download_wizard_diagnostics_text)
return view
}
override fun onStart() {
super.onStart()
val str = buildDiagnosticsText()
if (str == null) {
requireActivity().finish()
return
}
diagnosticTextView.text = str
}
private fun buildDiagnosticsText(): String? = state.downloadError?.let { err ->
val ret = StringBuilder()
ret.appendLine(
getString(
R.string.download_wizard_diagnostics_error_code,
err.lpaErrorReason
)
)
ret.appendLine()
err.lastHttpResponse?.let { resp ->
if (resp.rcode != 200) {
// Only show the status if it's not 200
// Because we can have errors even if the rcode is 200 due to SM-DP+ servers being dumb
// and showing 200 might mislead users
ret.appendLine(
getString(
R.string.download_wizard_diagnostics_last_http_status,
resp.rcode
)
)
ret.appendLine()
}
ret.appendLine(getString(R.string.download_wizard_diagnostics_last_http_response))
ret.appendLine()
val str = resp.data.decodeToString(throwOnInvalidSequence = false)
ret.appendLine(
if (str.startsWith('{')) {
str.prettyPrintJson()
} else {
str
}
)
ret.appendLine()
}
err.lastHttpException?.let { e ->
ret.appendLine(getString(R.string.download_wizard_diagnostics_last_http_exception))
ret.appendLine()
ret.appendLine("${e.javaClass.name}: ${e.message}")
ret.appendLine(e.stackTrace.joinToString("\n"))
ret.appendLine()
}
err.lastApduResponse?.let { resp ->
val isSuccess =
resp.size >= 2 && resp[resp.size - 2] == 0x90.toByte() && resp[resp.size - 1] == 0x00.toByte()
if (isSuccess) {
ret.appendLine(getString(R.string.download_wizard_diagnostics_last_apdu_response_success))
} else {
// Only show the full APDU response when it's a failure
// Otherwise it's going to get very crammed
ret.appendLine(
getString(
R.string.download_wizard_diagnostics_last_apdu_response,
resp.encodeHex()
)
)
ret.appendLine()
ret.appendLine(getString(R.string.download_wizard_diagnostics_last_apdu_response_fail))
}
}
err.lastApduException?.let { e ->
ret.appendLine(getString(R.string.download_wizard_diagnostics_last_apdu_exception))
ret.appendLine()
ret.appendLine("${e.javaClass.name}: ${e.message}")
ret.appendLine(e.stackTrace.joinToString("\n"))
ret.appendLine()
}
ret.toString()
}
}

View file

@ -0,0 +1,172 @@
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(Dispatchers.IO) {
runCatching {
requireContext().contentResolver.openInputStream(result)?.let { input ->
val bmp = BitmapFactory.decodeStream(input)
input.close()
decodeQrFromBitmap(bmp)?.let {
withContext(Dispatchers.Main) {
processLpaString(it)
}
}
bmp.recycle()
}
}
}
}
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(s: String) {
val components = s.split("$")
if (components.size < 3 || components[0] != "LPA:1") {
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()
}
return
}
state.smdp = components[1]
state.matchingId = components[2]
gotoNextFragment(DownloadWizardDetailsFragment())
}
private 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 { 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])
}
}
}

View file

@ -0,0 +1,240 @@
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()
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])
}
}
}

View file

@ -0,0 +1,215 @@
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 =
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)
}
}
}

View file

@ -32,15 +32,17 @@ val <T> T.portId: Int where T: Fragment, T: EuiccChannelFragmentMarker
val <T> T.isUsb: Boolean where T: Fragment, T: EuiccChannelFragmentMarker
get() = requireArguments().getInt("slotId") == EuiccChannelManager.USB_CHANNEL_ID
val <T> T.euiccChannelManager: EuiccChannelManager where T: Fragment, T: EuiccChannelFragmentMarker
val <T> T.euiccChannelManager: EuiccChannelManager where T: Fragment, T: OpenEuiccContextMarker
get() = (requireActivity() as BaseEuiccAccessActivity).euiccChannelManager
val <T> T.euiccChannelManagerService: EuiccChannelManagerService where T: Fragment, T: EuiccChannelFragmentMarker
val <T> T.euiccChannelManagerService: EuiccChannelManagerService where T: Fragment, T: OpenEuiccContextMarker
get() = (requireActivity() as BaseEuiccAccessActivity).euiccChannelManagerService
val <T> T.channel: EuiccChannel where T: Fragment, T: EuiccChannelFragmentMarker
get() =
euiccChannelManager.findEuiccChannelByPortBlocking(slotId, portId)!!
suspend fun <T> T.ensureEuiccChannelManager() where T: Fragment, T: EuiccChannelFragmentMarker =
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 =
(requireActivity() as BaseEuiccAccessActivity).euiccChannelManagerLoaded.await()
interface EuiccProfilesChangedListener {

View file

@ -3,9 +3,6 @@ package im.angry.openeuicc.util
import android.util.Log
import im.angry.openeuicc.core.EuiccChannel
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.LocalProfileInfo
@ -19,9 +16,10 @@ val LocalProfileInfo.isEnabled: Boolean
get() = state == LocalProfileInfo.State.Enabled
val List<LocalProfileInfo>.operational: List<LocalProfileInfo>
get() = filter {
it.profileClass == LocalProfileInfo.Clazz.Operational
}
get() = filter { it.profileClass == LocalProfileInfo.Clazz.Operational }
val List<LocalProfileInfo>.enabled: LocalProfileInfo?
get() = find { it.isEnabled }
val List<EuiccChannel>.hasMultipleChips: Boolean
get() = distinctBy { it.slotId }.size > 1
@ -42,22 +40,27 @@ fun LocalProfileAssistant.switchProfile(
* See EuiccManager.waitForReconnect()
*/
fun LocalProfileAssistant.disableActiveProfile(refresh: Boolean): Boolean =
profiles.find { it.isEnabled }?.let {
profiles.enabled?.let {
Log.i(TAG, "Disabling active profile ${it.iccid}")
disableProfile(it.iccid, refresh)
} ?: true
/**
* 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.
* Disable the current active profile if any. If refresh is true, also cause a refresh command.
* See EuiccManager.waitForReconnect()
*
* Return the iccid of the profile being disabled, or null if no active profile found or failed to
* disable.
*/
fun LocalProfileAssistant.disableActiveProfileWithUndo(refreshOnDisable: Boolean): () -> Unit =
profiles.find { it.isEnabled }?.let {
disableProfile(it.iccid, refreshOnDisable)
return { enableProfile(it.iccid) }
} ?: { }
fun LocalProfileAssistant.disableActiveProfileKeepIccId(refresh: Boolean): String? =
profiles.enabled?.let {
Log.i(TAG, "Disabling active profile ${it.iccid}")
if (disableProfile(it.iccid, refresh)) {
it.iccid
} else {
null
}
}
/**
* Begin a "tracked" operation where notifications may be generated by the eSIM
@ -78,60 +81,21 @@ suspend inline fun EuiccChannelManager.beginTrackedOperation(
portId: Int,
op: () -> Boolean
) {
val latestSeq =
findEuiccChannelByPort(slotId, portId)!!.lpa.notifications.firstOrNull()?.seqNumber
val latestSeq = withEuiccChannel(slotId, portId) { channel ->
channel.lpa.notifications.firstOrNull()?.seqNumber
?: 0
Log.d(TAG, "Latest notification is $latestSeq before operation")
if (op()) {
Log.d(TAG, "Operation has requested notification handling")
try {
// Note that the exact instance of "channel" might have changed here if reconnected;
// so we MUST use the automatic getter for "channel"
findEuiccChannelByPort(
slotId,
portId
)?.lpa?.notifications?.filter { it.seqNumber > latestSeq }?.forEach {
Log.d(TAG, "Handling notification $it")
findEuiccChannelByPort(
slotId,
portId
)?.lpa?.handleNotification(it.seqNumber)
}
} catch (e: Exception) {
// Ignore any error during notification handling
e.printStackTrace()
}
}
Log.d(TAG, "Operation complete")
}
/**
* Same as beginTrackedOperation but uses blocking primitives.
* TODO: This function needs to be phased out of use.
*/
inline fun EuiccChannelManager.beginTrackedOperationBlocking(
slotId: Int,
portId: Int,
op: () -> Boolean
) {
val latestSeq =
findEuiccChannelByPortBlocking(slotId, portId)!!.lpa.notifications.firstOrNull()?.seqNumber
?: 0
Log.d(TAG, "Latest notification is $latestSeq before operation")
if (op()) {
Log.d(TAG, "Operation has requested notification handling")
try {
// Note that the exact instance of "channel" might have changed here if reconnected;
// so we MUST use the automatic getter for "channel"
findEuiccChannelByPortBlocking(
slotId,
portId
)?.lpa?.notifications?.filter { it.seqNumber > latestSeq }?.forEach {
Log.d(TAG, "Handling notification $it")
findEuiccChannelByPortBlocking(
slotId,
portId
)?.lpa?.handleNotification(it.seqNumber)
// this is why we need to use two distinct calls to withEuiccChannel()
withEuiccChannel(slotId, portId) { channel ->
channel.lpa.notifications.filter { it.seqNumber > latestSeq }.forEach {
Log.d(TAG, "Handling notification $it")
channel.lpa.handleNotification(it.seqNumber)
}
}
} catch (e: Exception) {
// Ignore any error during notification handling

View file

@ -19,38 +19,54 @@ val Context.preferenceRepository: PreferenceRepository
val Fragment.preferenceRepository: PreferenceRepository
get() = requireContext().preferenceRepository
object PreferenceKeys {
internal object PreferenceKeys {
// ---- Profile Notifications ----
val NOTIFICATION_DOWNLOAD = booleanPreferencesKey("notification_download")
val NOTIFICATION_DELETE = booleanPreferencesKey("notification_delete")
val NOTIFICATION_SWITCH = booleanPreferencesKey("notification_switch")
val DISABLE_SAFEGUARD_REMOVABLE_ESIM = booleanPreferencesKey("disable_safeguard_removable_esim")
val VERBOSE_LOGGING = booleanPreferencesKey("verbose_logging")
}
class PreferenceRepository(context: Context) {
private val dataStore = context.dataStore
// Expose flows so that we can also handle default values
// ---- Profile Notifications ----
val notificationDownloadFlow: Flow<Boolean> =
dataStore.data.map { it[PreferenceKeys.NOTIFICATION_DOWNLOAD] ?: true }
val notificationDeleteFlow: Flow<Boolean> =
dataStore.data.map { it[PreferenceKeys.NOTIFICATION_DELETE] ?: true }
val notificationSwitchFlow: Flow<Boolean> =
dataStore.data.map { it[PreferenceKeys.NOTIFICATION_SWITCH] ?: false }
// ---- Advanced ----
val disableSafeguardFlow: Flow<Boolean> =
dataStore.data.map { it[PreferenceKeys.DISABLE_SAFEGUARD_REMOVABLE_ESIM] ?: false }
val DISABLE_SAFEGUARD_REMOVABLE_ESIM = booleanPreferencesKey("disable_safeguard_removable_esim")
val VERBOSE_LOGGING = booleanPreferencesKey("verbose_logging")
val verboseLoggingFlow: Flow<Boolean> =
dataStore.data.map { it[PreferenceKeys.VERBOSE_LOGGING] ?: false }
// ---- Developer Options ----
val DEVELOPER_OPTIONS_ENABLED = booleanPreferencesKey("developer_options_enabled")
val UNFILTERED_PROFILE_LIST = booleanPreferencesKey("unfiltered_profile_list")
val IGNORE_TLS_CERTIFICATE = booleanPreferencesKey("ignore_tls_certificate")
}
suspend fun <T> updatePreference(key: Preferences.Key<T>, value: T) {
dataStore.edit {
it[key] = value
}
class PreferenceRepository(private val context: Context) {
// Expose flows so that we can also handle default values
// ---- Profile Notifications ----
val notificationDownloadFlow = bindFlow(PreferenceKeys.NOTIFICATION_DOWNLOAD, true)
val notificationDeleteFlow = bindFlow(PreferenceKeys.NOTIFICATION_DELETE, true)
val notificationSwitchFlow = bindFlow(PreferenceKeys.NOTIFICATION_SWITCH, false)
// ---- Advanced ----
val disableSafeguardFlow = bindFlow(PreferenceKeys.DISABLE_SAFEGUARD_REMOVABLE_ESIM, false)
val verboseLoggingFlow = bindFlow(PreferenceKeys.VERBOSE_LOGGING, false)
// ---- Developer Options ----
val developerOptionsEnabledFlow = bindFlow(PreferenceKeys.DEVELOPER_OPTIONS_ENABLED, false)
val unfilteredProfileListFlow = bindFlow(PreferenceKeys.UNFILTERED_PROFILE_LIST, false)
val ignoreTLSCertificateFlow = bindFlow(PreferenceKeys.IGNORE_TLS_CERTIFICATE, false)
private fun <T> bindFlow(key: Preferences.Key<T>, defaultValue: T): PreferenceFlowWrapper<T> =
PreferenceFlowWrapper(context, key, defaultValue)
}
class PreferenceFlowWrapper<T> private constructor(
private val context: Context,
private val key: Preferences.Key<T>,
inner: Flow<T>
) : Flow<T> by inner {
internal constructor(context: Context, key: Preferences.Key<T>, defaultValue: T) : this(
context,
key,
context.dataStore.data.map { it[key] ?: defaultValue }
)
suspend fun updatePreference(value: T) {
context.dataStore.edit { it[key] = value }
}
}

View file

@ -27,4 +27,74 @@ fun formatFreeSpace(size: Int): String =
"%.2f KiB".format(size.toDouble() / 1024)
} else {
"$size B"
}
}
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()
}

View file

@ -45,6 +45,8 @@ fun SEService.getUiccReaderCompat(slotNumber: Int): Reader {
interface UiccCardInfoCompat {
val physicalSlotIndex: Int
val ports: Collection<UiccPortInfoCompat>
val isRemovable: Boolean
get() = true // This defaults to removable unless overridden
}
interface UiccPortInfoCompat {

View file

@ -1,17 +1,24 @@
package im.angry.openeuicc.util
import android.content.ClipData
import android.content.Context
import android.content.Intent
import android.content.res.Resources
import android.graphics.Rect
import android.view.View
import android.view.ViewGroup
import androidx.activity.result.ActivityResultCaller
import androidx.activity.result.contract.ActivityResultContracts
import androidx.appcompat.app.AlertDialog
import androidx.appcompat.app.AppCompatActivity
import androidx.appcompat.widget.Toolbar
import androidx.core.view.ViewCompat
import androidx.core.view.WindowInsetsCompat
import androidx.core.view.updateLayoutParams
import androidx.core.view.updatePadding
import androidx.fragment.app.DialogFragment
import androidx.fragment.app.Fragment
import im.angry.openeuicc.common.R
import java.io.FileOutputStream
// Source: <https://stackoverflow.com/questions/12478520/how-to-set-dialogfragments-width-and-height>
/**
@ -69,4 +76,49 @@ fun setupRootViewInsets(view: ViewGroup) {
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)
}
}

View file

@ -0,0 +1,6 @@
<?xml version="1.0" encoding="utf-8"?>
<translate xmlns:android="http://schemas.android.com/apk/res/android"
android:duration="@android:integer/config_shortAnimTime"
android:interpolator="@android:anim/decelerate_interpolator"
android:fromXDelta="-100%"
android:toXDelta="0%" />

View file

@ -0,0 +1,6 @@
<?xml version="1.0" encoding="utf-8"?>
<translate xmlns:android="http://schemas.android.com/apk/res/android"
android:duration="@android:integer/config_shortAnimTime"
android:interpolator="@android:anim/decelerate_interpolator"
android:fromXDelta="100%"
android:toXDelta="0%" />

View file

@ -0,0 +1,6 @@
<!-- res/anim/slide_out.xml -->
<translate xmlns:android="http://schemas.android.com/apk/res/android"
android:duration="@android:integer/config_shortAnimTime"
android:interpolator="@android:anim/decelerate_interpolator"
android:fromXDelta="0%"
android:toXDelta="-100%" />

View file

@ -0,0 +1,6 @@
<!-- res/anim/slide_out.xml -->
<translate xmlns:android="http://schemas.android.com/apk/res/android"
android:duration="@android:integer/config_shortAnimTime"
android:interpolator="@android:anim/decelerate_interpolator"
android:fromXDelta="0%"
android:toXDelta="100%" />

View file

@ -0,0 +1,5 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android" android:height="24dp" android:tint="#000000" android:viewportHeight="24" android:viewportWidth="24" android:width="24dp">
<path android:fillColor="@android:color/white" android:pathData="M15.41,7.41L14,6l-6,6 6,6 1.41,-1.41L10.83,12z"/>
</vector>

View file

@ -0,0 +1,5 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android" android:height="24dp" android:tint="#000000" android:viewportHeight="24" android:viewportWidth="24" android:width="24dp">
<path android:fillColor="@android:color/white" android:pathData="M10,6L8.59,7.41 13.17,12l-4.58,4.59L10,18l6,-6z"/>
</vector>

View file

@ -0,0 +1,5 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android" android:height="24dp" android:tint="#000000" android:viewportHeight="24" android:viewportWidth="24" android:width="24dp">
<path android:fillColor="@android:color/white" android:pathData="M3,17.25V21h3.75L17.81,9.94l-3.75,-3.75L3,17.25zM20.71,7.04c0.39,-0.39 0.39,-1.02 0,-1.41l-2.34,-2.34c-0.39,-0.39 -1.02,-0.39 -1.41,0l-1.83,1.83 3.75,3.75 1.83,-1.83z"/>
</vector>

View file

@ -0,0 +1,7 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android" android:height="24dp" android:tint="?attr/colorControlNormal" android:viewportHeight="24" android:viewportWidth="24" android:width="24dp">
<path android:fillColor="@android:color/white" android:pathData="M5,5h2v3h10V5h2v6h2V5c0,-1.1 -0.9,-2 -2,-2h-4.18C14.4,1.84 13.3,1 12,1S9.6,1.84 9.18,3H5C3.9,3 3,3.9 3,5v14c0,1.1 0.9,2 2,2h5v-2H5V5zM12,3c0.55,0 1,0.45 1,1s-0.45,1 -1,1s-1,-0.45 -1,-1S11.45,3 12,3z"/>
<path android:fillColor="@android:color/white" android:pathData="M18.01,13l-1.42,1.41l1.58,1.58l-6.17,0l0,2l6.17,0l-1.58,1.59l1.42,1.41l3.99,-4z"/>
</vector>

View file

@ -0,0 +1,74 @@
<?xml version="1.0" encoding="utf-8"?>
<androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_width="match_parent"
android:layout_height="match_parent"
xmlns:app="http://schemas.android.com/apk/res-auto">
<FrameLayout
android:id="@+id/step_fragment_container"
android:layout_width="0dp"
android:layout_height="0dp"
app:layout_constraintTop_toTopOf="parent"
app:layout_constraintBottom_toTopOf="@id/download_wizard_navigation"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintEnd_toEndOf="parent" />
<View
android:id="@+id/guideline"
android:layout_width="0dp"
android:layout_height="0dp"
android:orientation="vertical"
android:visibility="invisible"
app:layout_constraintBottom_toTopOf="@id/download_wizard_navigation"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent" />
<ProgressBar
android:id="@+id/progress"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:indeterminate="true"
app:layout_constraintLeft_toLeftOf="parent"
app:layout_constraintRight_toRightOf="parent"
app:layout_constraintTop_toBottomOf="@id/guideline"
app:layout_constraintBottom_toTopOf="@id/download_wizard_navigation"
style="@style/Widget.AppCompat.ProgressBar.Horizontal" />
<androidx.constraintlayout.widget.ConstraintLayout
android:id="@+id/download_wizard_navigation"
android:layout_width="match_parent"
android:layout_height="48dp"
android:background="?attr/colorSurfaceContainer"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintEnd_toEndOf="parent">
<com.google.android.material.button.MaterialButton
android:id="@+id/download_wizard_back"
android:text="@string/download_wizard_back"
android:background="?attr/selectableItemBackground"
android:textColor="?attr/colorPrimary"
android:layout_width="wrap_content"
android:layout_height="48dp"
app:icon="@drawable/ic_chevron_left"
app:iconGravity="start"
app:iconTint="?attr/colorPrimary"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent" />
<com.google.android.material.button.MaterialButton
android:id="@+id/download_wizard_next"
android:text="@string/download_wizard_next"
android:background="?attr/selectableItemBackground"
android:textColor="?attr/colorPrimary"
android:layout_width="wrap_content"
android:layout_height="48dp"
app:icon="@drawable/ic_chevron_right"
app:iconGravity="end"
app:iconTint="?attr/colorPrimary"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintTop_toTopOf="parent" />
</androidx.constraintlayout.widget.ConstraintLayout>
</androidx.constraintlayout.widget.ConstraintLayout>

View file

@ -0,0 +1,25 @@
<?xml version="1.0" encoding="utf-8"?>
<androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_width="match_parent"
android:layout_height="match_parent"
xmlns:app="http://schemas.android.com/apk/res-auto">
<include layout="@layout/toolbar_activity" />
<androidx.swiperefreshlayout.widget.SwipeRefreshLayout
android:id="@+id/swipe_refresh"
android:layout_width="0dp"
android:layout_height="0dp"
app:layout_constraintTop_toBottomOf="@id/toolbar"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintBottom_toBottomOf="parent">
<androidx.recyclerview.widget.RecyclerView
android:id="@+id/recycler_view"
android:layout_width="match_parent"
android:layout_height="match_parent" />
</androidx.swiperefreshlayout.widget.SwipeRefreshLayout>
</androidx.constraintlayout.widget.ConstraintLayout>

View file

@ -0,0 +1,44 @@
<?xml version="1.0" encoding="utf-8"?>
<androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_width="match_parent"
android:layout_height="wrap_content"
xmlns:app="http://schemas.android.com/apk/res-auto"
android:padding="20dp"
android:background="?attr/selectableItemBackground">
<ImageView
android:id="@+id/download_method_icon"
android:layout_width="30dp"
android:layout_height="30dp"
app:tint="?attr/colorAccent"
app:layout_constraintTop_toTopOf="parent"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintStart_toStartOf="parent" />
<TextView
android:id="@+id/download_method_title"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginStart="20dp"
android:layout_marginEnd="20dp"
android:textSize="15sp"
android:maxLines="1"
android:ellipsize="marquee"
app:layout_constraintTop_toTopOf="parent"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintStart_toEndOf="@id/download_method_icon"
app:layout_constraintEnd_toStartOf="@id/download_method_chevron"
app:layout_constraintHorizontal_bias="0.0"
app:layout_constrainedWidth="true" />
<ImageView
android:id="@+id/download_method_chevron"
android:src="@drawable/ic_chevron_right"
android:layout_width="30dp"
android:layout_height="30dp"
app:tint="?attr/colorAccent"
app:layout_constraintTop_toTopOf="parent"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="parent" />
</androidx.constraintlayout.widget.ConstraintLayout>

View file

@ -0,0 +1,45 @@
<?xml version="1.0" encoding="utf-8"?>
<androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_width="match_parent"
android:layout_height="wrap_content"
xmlns:app="http://schemas.android.com/apk/res-auto">
<TextView
android:id="@+id/download_progress_item_title"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_margin="20dp"
android:textSize="14sp"
app:layout_constraintTop_toTopOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toStartOf="@id/download_progress_icon_container"
app:layout_constrainedWidth="true"
app:layout_constraintHorizontal_bias="0.0" />
<FrameLayout
android:id="@+id/download_progress_icon_container"
android:layout_margin="20dp"
android:layout_width="30dp"
android:layout_height="30dp"
app:layout_constraintTop_toTopOf="parent"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="parent">
<ProgressBar
android:id="@+id/download_progress_icon_progress"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:indeterminate="true"
android:visibility="gone" />
<ImageView
android:id="@+id/download_progress_icon"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:visibility="gone"
app:tint="?attr/colorPrimary" />
</FrameLayout>
</androidx.constraintlayout.widget.ConstraintLayout>

View file

@ -0,0 +1,108 @@
<?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>

View file

@ -0,0 +1,30 @@
<?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>

View file

@ -28,7 +28,8 @@
app:layout_constraintRight_toLeftOf="@+id/profile_menu"
app:layout_constraintTop_toTopOf="parent"
app:layout_constraintBottom_toTopOf="@+id/state"
app:layout_constraintHorizontal_bias="0" />
app:layout_constraintHorizontal_bias="0"
app:layout_constrainedWidth="true" />
<androidx.appcompat.widget.AppCompatImageButton
android:id="@+id/profile_menu"
@ -62,18 +63,45 @@
android:singleLine="true"
app:layout_constraintLeft_toLeftOf="parent"
app:layout_constraintTop_toBottomOf="@id/state"
app:layout_constraintBottom_toTopOf="@+id/iccid_label"/>
app:layout_constraintBottom_toTopOf="@+id/profile_class_label"/>
<TextView
android:id="@+id/provider"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginTop="6dp"
android:layout_marginLeft="7dp"
android:layout_marginStart="7dp"
android:textSize="14sp"
android:singleLine="true"
app:layout_constraintLeft_toRightOf="@id/provider_label"
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"/>
<TextView
@ -86,7 +114,7 @@
android:textStyle="bold"
android:singleLine="true"
app:layout_constraintLeft_toLeftOf="parent"
app:layout_constraintTop_toBottomOf="@id/provider_label"
app:layout_constraintTop_toBottomOf="@id/profile_class_label"
app:layout_constraintBottom_toBottomOf="parent"/>
<TextView
@ -94,11 +122,11 @@
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginTop="6dp"
android:layout_marginLeft="7dp"
android:layout_marginStart="7dp"
android:textSize="14sp"
android:singleLine="true"
app:layout_constraintLeft_toRightOf="@id/iccid_label"
app:layout_constraintTop_toBottomOf="@id/provider"
app:layout_constraintTop_toBottomOf="@id/profile_class"
app:layout_constraintBottom_toBottomOf="parent"/>
</androidx.constraintlayout.widget.ConstraintLayout>

View file

@ -0,0 +1,104 @@
<?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>

View file

@ -0,0 +1,59 @@
<?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>

View file

@ -0,0 +1,33 @@
<?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>

View file

@ -0,0 +1,33 @@
<?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>

View file

@ -0,0 +1,33 @@
<?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>

View file

@ -1,126 +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="match_parent"
android:background="?attr/colorSurface">
<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"
app:navigationIcon="?homeAsUpIndicator" />
<View
android:id="@+id/guideline"
android:layout_width="0dp"
android:layout_height="0dp"
android:orientation="vertical"
android:visibility="invisible"
app:layout_constraintBottom_toBottomOf="@id/toolbar"
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:visibility="gone"
app:layout_constraintTop_toBottomOf="@id/toolbar"
app:layout_constraintLeft_toLeftOf="parent"
app:layout_constraintRight_toRightOf="parent"
app:layout_constraintBottom_toTopOf="@id/guideline"
style="@style/Widget.AppCompat.ProgressBar.Horizontal" />
<com.google.android.material.textfield.TextInputLayout
android:id="@+id/profile_download_server"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginTop="15dp"
android:hint="@string/profile_download_server"
app:layout_constraintTop_toBottomOf="@id/toolbar"
app:layout_constraintLeft_toLeftOf="parent"
app:layout_constraintRight_toRightOf="parent"
app:layout_constraintWidth_percent=".8">
<com.google.android.material.textfield.TextInputEditText
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:layout_marginVertical="15dp"
android:hint="@string/profile_download_code"
app:layout_constraintTop_toBottomOf="@id/profile_download_server"
app:layout_constraintLeft_toLeftOf="parent"
app:layout_constraintRight_toRightOf="parent"
app:layout_constraintWidth_percent=".8"
app:passwordToggleEnabled="true">
<com.google.android.material.textfield.TextInputEditText
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:layout_marginVertical="15dp"
android:hint="@string/profile_download_confirmation_code"
app:layout_constraintTop_toBottomOf="@id/profile_download_code"
app:layout_constraintLeft_toLeftOf="parent"
app:layout_constraintRight_toRightOf="parent"
app:layout_constraintWidth_percent=".8"
app:passwordToggleEnabled="true">
<com.google.android.material.textfield.TextInputEditText
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:layout_constraintTop_toBottomOf="@id/profile_download_confirmation_code"
app:layout_constraintLeft_toLeftOf="parent"
app:layout_constraintRight_toRightOf="parent"
app:layout_constraintBottom_toTopOf="@id/profile_download_free_space"
app:layout_constraintWidth_percent=".8"
app:passwordToggleEnabled="true">
<com.google.android.material.textfield.TextInputEditText
android:layout_width="match_parent"
android:layout_height="match_parent"
android:inputType="textPassword" />
</com.google.android.material.textfield.TextInputLayout>
<TextView
android:id="@+id/profile_download_free_space"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:gravity="center"
android:textSize="11sp"
android:layout_marginBottom="4dp"
app:layout_constraintTop_toBottomOf="@id/profile_download_imei"
app:layout_constraintLeft_toLeftOf="parent"
app:layout_constraintRight_toRightOf="parent"
app:layout_constraintBottom_toBottomOf="parent" />
</androidx.constraintlayout.widget.ConstraintLayout>

View file

@ -1,27 +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="match_parent"
android:background="?attr/colorSurface">
<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"
app:navigationIcon="?homeAsUpIndicator" />
<Spinner
android:id="@+id/spinner"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginVertical="48dp"
app:layout_constraintTop_toBottomOf="@id/toolbar"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintEnd_toEndOf="parent"/>
</androidx.constraintlayout.widget.ConstraintLayout>

View file

@ -15,6 +15,15 @@
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintTop_toTopOf="parent" />
<TextView
android:id="@+id/notification_sequence_number"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginHorizontal="24dp"
android:layout_marginVertical="12dp"
app:layout_constraintRight_toRightOf="parent"
app:layout_constraintTop_toTopOf="parent" />
<TextView
android:id="@+id/notification_profile_name"
android:layout_width="0dp"

View file

@ -5,4 +5,9 @@
android:id="@+id/show_notifications"
android:title="@string/profile_notifications_show"
app:showAsAction="never" />
<item
android:id="@+id/euicc_info"
android:title="@string/euicc_info"
app:showAsAction="never" />
</menu>

View file

@ -1,21 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<menu xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto">
<item
android:id="@+id/scan"
android:icon="@drawable/ic_scan_black"
android:title="@string/profile_download_scan"
app:showAsAction="ifRoom"/>
<item
android:id="@+id/scan_from_gallery"
android:icon="@drawable/ic_gallery_black"
android:title="@string/profile_download_scan_from_gallery"
app:showAsAction="ifRoom" />
<item
android:id="@+id/ok"
android:icon="@drawable/ic_check_black"
android:title="@string/profile_download_ok"
app:showAsAction="always"/>
</menu>

View file

@ -0,0 +1,147 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<string name="no_euicc">このアプリでアクセスできるリムーバブル eUICC カードがデバイス上で検出されていません。互換性のあるカード挿入または USB リーダーを接続してください。</string>
<string name="no_profile">この eSIM にはプロファイルがありません。</string>
<string name="unknown">不明</string>
<string name="information_unavailable">情報なし</string>
<string name="help">ヘルプ</string>
<string name="reload">スロットを再読み込み</string>
<string name="channel_name_format">論理スロット %d</string>
<string name="enabled">有効済み</string>
<string name="disabled">無効済み</string>
<string name="provider">プロバイダー:</string>
<string name="enable">有効化</string>
<string name="disable">無効化</string>
<string name="delete">削除</string>
<string name="rename">名前を変更</string>
<string name="enable_disable_timeout">eSIM チップがプロファイルの切り替えの待機中にタイムアウトしました。これはデバイスのモデムファームウェアのバグの可能性があります。機内モードに切り替えるかアプリを再起動、デバイスを再起動してください。</string>
<string name="switch_did_not_refresh">操作は成功しましたが、デバイスのモデムが更新を拒否しました。新しいプロファイルを使用するには機内モードに切り替えるか、再起動する必要があります。</string>
<string name="toast_profile_enable_failed">新しい eSIM プロファイルに切り替えることができません。</string>
<string name="toast_profile_delete_confirm_text_mismatched">入力した確認用テキストは一致していません</string>
<string name="toast_iccid_copied">ICCID をクリップボードにコピーしました</string>
<string name="toast_eid_copied">EID をクリップボードにコピーしました</string>
<string name="toast_atr_copied">ATR をクリップボードにコピーしました</string>
<string name="usb_permission">USB の権限を許可</string>
<string name="usb_permission_needed">USB スマートカードリーダーにアクセスするには許可が必要です。</string>
<string name="usb_failed">USB スマートカードリーダー経由で eSIM に接続できません。</string>
<string name="task_notification">長時間実行されるタスク</string>
<string name="task_profile_download">eSIM プロファイルをダウンロード中です</string>
<string name="task_profile_download_failure">eSIM プロファイルのダウンロードに失敗しました</string>
<string name="task_profile_rename">eSIM プロファイルの名前を変更中です</string>
<string name="task_profile_rename_failure">eSIM プロファイルの名前の変更に失敗しました</string>
<string name="task_profile_delete">eSIM プロファイルを削除中です</string>
<string name="task_profile_delete_failure">eSIM プロファイルの削除に失敗しました</string>
<string name="task_profile_switch">eSIM プロファイルを切り替え中</string>
<string name="task_profile_switch_failure">eSIM プロファイルの切り替えに失敗しました</string>
<string name="profile_download">新しい eSIM</string>
<string name="profile_download_server">サーバー (RSP / SM-DP+)</string>
<string name="profile_download_code">アクティベーションコード</string>
<string name="profile_download_confirmation_code">確認コード (オプション)</string>
<string name="profile_download_imei">IMEI (オプション)</string>
<string name="profile_download_low_nvram_title">ダウンロードに失敗する可能性があります</string>
<string name="profile_download_low_nvram_message">残り容量が少ないため、ダウンロードに失敗する可能性があります。</string>
<string name="profile_download_no_lpa_string">クリップボードに LPA コードが見つかりません</string>
<string name="profile_download_incorrect_lpa_string">LPA コードを解析できません</string>
<string name="profile_download_incorrect_lpa_string_message">クリップボードまたは QR コードの内容を LPA コードとして解析できません</string>
<string name="download_wizard">ダウンロードウィザード</string>
<string name="download_wizard_back">戻る</string>
<string name="download_wizard_next">次へ</string>
<string name="download_wizard_slot_removed">選択された SIM が取り外されました</string>
<string name="download_wizard_slot_select">ダウンロードする eSIM を選択または確認:</string>
<string name="download_wizard_slot_type">タイプ:</string>
<string name="download_wizard_slot_type_removable">リムーバブル</string>
<string name="download_wizard_slot_type_internal">内部</string>
<string name="download_wizard_slot_type_internal_port">内部 - ポート: %d</string>
<string name="download_wizard_slot_active_profile">有効なプロファイル:</string>
<string name="download_wizard_slot_free_space">空き容量:</string>
<string name="download_wizard_method_select">eSIM プロファイルをどの方法でダウンロードしますか?</string>
<string name="download_wizard_method_qr_code">カメラで QR コードをスキャン</string>
<string name="download_wizard_method_gallery">ギャラリーから QR コードをスキャン</string>
<string name="download_wizard_method_clipboard">クリップボードから読み込む</string>
<string name="download_wizard_method_manual">手動で入力</string>
<string name="download_wizard_details">eSIM をダウンロードするための詳細情報を入力または確認:</string>
<string name="download_wizard_progress">eSIM をダウンロード中です…</string>
<string name="download_wizard_progress_step_preparing">準備中</string>
<string name="download_wizard_progress_step_connecting">サーバーへの接続を確立しています</string>
<string name="download_wizard_progress_step_authenticating">サーバーでデバイスを認証中です</string>
<string name="download_wizard_progress_step_downloading">eSIM プロファイルをダウンロード中です</string>
<string name="download_wizard_progress_step_finalizing">eSIM プロファイルをストレージに読み込み中です</string>
<string name="download_wizard_diagnostics">エラー診断</string>
<string name="download_wizard_diagnostics_error_code">エラーコード: %s</string>
<string name="download_wizard_diagnostics_last_http_status">最終の HTTP ステータス (サーバー): %d</string>
<string name="download_wizard_diagnostics_last_http_response">最終の HTTP レスポンス (サーバー):</string>
<string name="download_wizard_diagnostics_last_http_exception">最終の HTTP 例外:</string>
<string name="download_wizard_diagnostics_last_apdu_response">最終の APDU レスポンス (SIM): %s</string>
<string name="download_wizard_diagnostics_last_apdu_response_success">最終の APDU レスポンス (SIM) は成功しました</string>
<string name="download_wizard_diagnostics_last_apdu_response_fail">最終の APDU レスポンス (SIM) は失敗しました</string>
<string name="download_wizard_diagnostics_last_apdu_exception">最終の APDU 例外:</string>
<string name="download_wizard_diagnostics_save">保存</string>
<string name="download_wizard_diagnostics_file_template">%s のエラー診断</string>
<string name="logs_saved_message">ログは指定されたパスに保存しました。他のアプリにシェアしますか?</string>
<string name="profile_rename_new_name">新しいニックネーム</string>
<string name="profile_rename_encoding_error">ニックネームを UTF-8 にエンコードできません</string>
<string name="profile_rename_too_long">ニックネームは 64 文字以内にしてください</string>
<string name="profile_rename_failure">ニックネームの変更で予期せぬエラーが発生しました</string>
<string name="profile_delete_confirm">%s のプロファイルを削除してもよろしいですか?この操作は元に戻せません。</string>
<string name="profile_delete_confirm_input">削除を確認するには「%s」を入力してください</string>
<string name="profile_notifications">通知</string>
<string name="profile_notifications_detailed_format">通知 (%s)</string>
<string name="profile_notifications_show">通知の管理</string>
<string name="profile_notifications_help">eSIM プロファイルはダウンロードや削除、有効化や無効化されたときに通信事業者に通知を送信できます。送信されるこれらの通知のキューはここにリストされます。\n\n設定では、各タイプの通知を自動的に送信するかどうかを指定できます。通知が送信された場合でもキューのスペースが不足していない限り、記録から自動的に削除されることはありません。\n\nここでは保留中の各通知を手動で送信または削除できます。</string>
<string name="profile_notification_operation_download">ダウンロードしました</string>
<string name="profile_notification_operation_delete">削除しました</string>
<string name="profile_notification_operation_enable">有効化しました</string>
<string name="profile_notification_operation_disable">無効化しました</string>
<string name="profile_notification_process">処理</string>
<string name="profile_notification_delete">削除</string>
<string name="euicc_info">eUICC 情報</string>
<string name="euicc_info_activity_title">eUICC 情報 (%s)</string>
<string name="euicc_info_access_mode">アクセスモード</string>
<string name="euicc_info_removable">リムーバブル</string>
<string name="euicc_info_sgp22_version">SGP.22 バージョン</string>
<string name="euicc_info_firmware_version">eUICC OS のバージョン</string>
<string name="euicc_info_globalplatform_version">グローバルプラットフォームのバージョン</string>
<string name="euicc_info_sas_accreditation_number">SAS 認定番号</string>
<string name="euicc_info_pp_version">Protected Profileのバージョン</string>
<string name="euicc_info_free_nvram">NVRAM の空き容量 (eSIM プロファイルストレージ)</string>
<string name="euicc_info_ci_type">証明書の発行者 (CI)</string>
<string name="euicc_info_ci_gsma_live">GSMA プロダクション CI</string>
<string name="euicc_info_ci_gsma_test">GSMA テスト CI</string>
<string name="euicc_info_ci_unknown">未知の eSIM CI</string>
<string name="yes">はい</string>
<string name="no">いいえ</string>
<string name="logs_save">保存</string>
<string name="logs_filename_template">%s のログ</string>
<string name="developer_options_steps">開発者になるまであと %d ステップです。</string>
<string name="developer_options_enabled">あなたは開発者になりました!</string>
<string name="pref_settings">設定</string>
<string name="pref_notifications">通知</string>
<string name="pref_notifications_desc">eSIM のプロファイル操作により、通信事業者に通知が送信されます。ここでは、どのタイプの通知を送信するのかを微調整できます。</string>
<string name="pref_notifications_download">ダウンロード</string>
<string name="pref_notifications_download_desc">プロファイルの<i>ダウンロード済み</i>の通知を送信します</string>
<string name="pref_notifications_delete">削除</string>
<string name="pref_notifications_delete_desc">プロファイルの<i>削除済み</i>の通知を送信します</string>
<string name="pref_notifications_switch">切り替え</string>
<string name="pref_notifications_switch_desc">プロファイルの<i>切り替え済み</i>の通知を送信します\nこのタイプの通知は有効化しても必ず送信するとは限らないことに注意してください。</string>
<string name="pref_advanced">高度な設定</string>
<string name="pref_advanced_disable_safeguard_removable_esim">有効なプロファイルの無効化と削除を許可する</string>
<string name="pref_advanced_disable_safeguard_removable_esim_desc">デフォルトでは、このアプリでデバイスに挿入された取り外し可能な eSIM の有効なプロファイルを無効化することを防いでいます。なぜなのかというと<i>時々</i>アクセスができなくなるからです。\nこのチェックボックスを ON にすることで、この保護機能を<i>解除</i>します。</string>
<string name="pref_advanced_verbose_logging">詳細ログ</string>
<string name="pref_advanced_verbose_logging_desc">詳細ログを有効化します。これには個人的な情報が含まれている可能性があります。この機能を ON にした後は、信頼できるユーザーとのみログを共有してください。</string>
<string name="pref_advanced_logs">ログ</string>
<string name="pref_advanced_logs_desc">アプリの最新デバッグログを表示します</string>
<string name="pref_developer">開発者オプション</string>
<string name="pref_developer_ignore_tls_certificate">SM-DP+ TLS 証明書を無視する</string>
<string name="pref_developer_ignore_tls_certificate_desc">SM-DP+ TLS 証明書を無視して任意の RSP を許可します</string>
<string name="pref_info">情報</string>
<string name="pref_info_app_version">アプリバージョン</string>
<string name="pref_info_source_code">ソースコード</string>
<string name="pref_advanced_language">言語</string>
<string name="pref_advanced_language_desc">アプリの言語を選択</string>
<string name="pref_developer_unfiltered_profile_list">すべてのプロファイルを表示</string>
<string name="pref_developer_unfiltered_profile_list_desc">プロダクション以外のプロファイルも表示する</string>
<string name="profile_class">タイプ:</string>
<string name="profile_class_testing">テスティング</string>
<string name="profile_class_provisioning">準備中</string>
<string name="profile_class_operational">動作中</string>
</resources>

View file

@ -0,0 +1,147 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<string name="no_euicc">在此设备上未检测到此应用程序可访问的可插拔 eUICC 卡。请插入兼容卡或 USB 读卡器。</string>
<string name="no_profile">此 eSIM 上还没有配置文件</string>
<string name="unknown">未知</string>
<string name="help">帮助</string>
<string name="reload">重新加载卡槽</string>
<string name="channel_name_format">逻辑卡槽 %d</string>
<string name="enabled">已启用</string>
<string name="disabled">已禁用</string>
<string name="provider">提供商:</string>
<string name="profile_class">类型:</string>
<string name="enable">启用</string>
<string name="disable">禁用</string>
<string name="delete">删除</string>
<string name="rename">重命名</string>
<string name="enable_disable_timeout">等待 eSIM 芯片切换配置文件时超时。这可能是您手机基带固件中的一个错误。请尝试切换飞行模式、重新启动应用程序或重新启动手机</string>
<string name="switch_did_not_refresh">操作成功, 但是您手机的基带拒绝刷新。您可能需要切换飞行模式或重新启动,以便使用新的配置文件。</string>
<string name="toast_profile_enable_failed">无法切换到新的 eSIM 配置文件。</string>
<string name="toast_profile_delete_confirm_text_mismatched">输入的确认文本不匹配</string>
<string name="toast_iccid_copied">已复制 ICCID 到剪贴板</string>
<string name="toast_eid_copied">已复制 EID 到剪贴板</string>
<string name="toast_atr_copied">已复制 ATR 到剪贴板</string>
<string name="usb_permission">授予 USB 权限</string>
<string name="usb_permission_needed">需要获得访问 USB 智能卡读卡器的权限。</string>
<string name="usb_failed">无法通过 USB 智能卡读卡器连接到 eSIM。</string>
<string name="task_notification">长时间运行的后台任务</string>
<string name="task_profile_download">正在下载 eSIM 配置文件</string>
<string name="task_profile_download_failure">无法下载 eSIM 配置文件</string>
<string name="task_profile_rename">正在重命名 eSIM 配置文件</string>
<string name="task_profile_rename_failure">无法重命名 eSIM 配置文件</string>
<string name="task_profile_delete">正在删除 eSIM 配置文件</string>
<string name="task_profile_delete_failure">无法删除 eSIM 配置文件</string>
<string name="task_profile_switch">正在切换 eSIM 配置文件</string>
<string name="task_profile_switch_failure">无法切换 eSIM 配置文件</string>
<string name="profile_download">添加新 eSIM</string>
<string name="profile_download_server">服务器 (RSP / SM-DP+)</string>
<string name="profile_download_code">激活码</string>
<string name="profile_download_confirmation_code">确认码 (可选)</string>
<string name="profile_download_imei">IMEI (可选)</string>
<string name="profile_download_low_nvram_title">本次下载可能会失败</string>
<string name="profile_download_low_nvram_message">当前芯片的剩余空间不足,可能导致配置下载失败。\n是否继续下载</string>
<string name="logs_saved_message">日志已保存到指定路径。需要通过其他 App 分享吗?</string>
<string name="profile_rename_new_name">新昵称</string>
<string name="profile_rename_encoding_error">无法将昵称编码为 UTF-8</string>
<string name="profile_rename_too_long">昵称长于 64 字符</string>
<string name="profile_rename_failure">重命名配置文件时发生了未知错误</string>
<string name="profile_delete_confirm">您确定要删除 %s 吗?此操作是不可逆的。</string>
<string name="profile_delete_confirm_input">请输入\'%s\'以确认删除</string>
<string name="profile_notifications">通知列表</string>
<string name="profile_notifications_detailed_format">通知列表 (%s)</string>
<string name="profile_notifications_show">管理通知</string>
<string name="profile_notifications_help">eSIM 配置文件可以在下载、删除、启用或禁用时向运营商发送通知。此处列出了要发送的这些通知的队列。\n\n在\"设置\"中,您可以指定是否自动发送每种类型的通知。请注意,即使通知已发送,也不会自动从记录中删除,除非队列空间不足。\n\n在这里您可以手动发送或删除每个待处理的通知。</string>
<string name="profile_notification_operation_download">已下载</string>
<string name="profile_notification_operation_delete">已删除</string>
<string name="profile_notification_operation_enable">已启用</string>
<string name="profile_notification_operation_disable">已禁用</string>
<string name="profile_notification_process">处理</string>
<string name="profile_notification_delete">删除</string>
<string name="logs_save">保存日志</string>
<string name="logs_filename_template">%s 的日志</string>
<string name="pref_settings">设置</string>
<string name="pref_notifications">通知</string>
<string name="pref_notifications_desc">操作 eSIM 配置文件会向运营商发送通知。根据需要在此处微调此行为。</string>
<string name="pref_notifications_download">下载</string>
<string name="pref_notifications_download_desc">发送 <i>下载</i> 配置文件的通知</string>
<string name="pref_notifications_delete">删除</string>
<string name="pref_notifications_delete_desc">发送 <i>删除</i> 配置文件的通知</string>
<string name="pref_notifications_switch">切换</string>
<string name="pref_notifications_switch_desc">发送 <i>切换</i> 配置文件的通知\n注意这种类型的通知是不可靠的。</string>
<string name="pref_advanced">高级</string>
<string name="pref_advanced_disable_safeguard_removable_esim">允许 禁用/删除 已启用的配置文件</string>
<string name="pref_advanced_disable_safeguard_removable_esim_desc">默认情况下,此应用程序会阻止您禁用可插拔 eSIM 中已启用的配置文件。\n因为这样做 <i>有时</i> 会使其无法访问。\n勾选此框以 <i>移除</i> 此保护措施。</string>
<string name="pref_advanced_verbose_logging">记录详细日志</string>
<string name="pref_advanced_verbose_logging_desc">详细日志中包含敏感信息,开启此功能后请仅与你信任的人共享你的日志。</string>
<string name="pref_advanced_logs">日志</string>
<string name="pref_advanced_logs_desc">查看应用程序的最新调试日志</string>
<string name="pref_info">信息</string>
<string name="pref_info_app_version">App 版本</string>
<string name="pref_info_source_code">源码</string>
<string name="profile_class_testing">测试</string>
<string name="profile_class_provisioning">准备中</string>
<string name="profile_class_operational">可用</string>
<string name="profile_download_no_lpa_string">未在剪贴板上发现 LPA 码</string>
<string name="profile_download_incorrect_lpa_string">LPA 码解析错误</string>
<string name="profile_download_incorrect_lpa_string_message">无法将二维码或剪贴板内容解析为 LPA 码</string>
<string name="download_wizard">下载向导</string>
<string name="download_wizard_back">返回</string>
<string name="download_wizard_next">下一步</string>
<string name="download_wizard_slot_removed">您选择的 SIM 已被移除</string>
<string name="download_wizard_slot_select">请选择或确认下载目标 eSIM 卡槽:</string>
<string name="download_wizard_slot_type">类型:</string>
<string name="download_wizard_slot_type_removable">可插拔</string>
<string name="download_wizard_slot_type_internal">内置</string>
<string name="download_wizard_slot_type_internal_port">内置, 端口 %d</string>
<string name="download_wizard_slot_active_profile">当前配置文件:</string>
<string name="download_wizard_slot_free_space">剩余空间:</string>
<string name="download_wizard_method_select">您想要如何下载 eSIM 配置文件?</string>
<string name="download_wizard_method_qr_code">用相机扫描二维码</string>
<string name="download_wizard_method_gallery">从图库选择二维码</string>
<string name="download_wizard_method_clipboard">从剪贴板读取</string>
<string name="download_wizard_method_manual">手动输入</string>
<string name="download_wizard_details">请输入或确认下载 eSIM 的详细信息:</string>
<string name="download_wizard_progress">正在下载您的 eSIM...</string>
<string name="download_wizard_progress_step_preparing">准备中</string>
<string name="download_wizard_progress_step_connecting">正在连接服务器</string>
<string name="download_wizard_progress_step_authenticating">正在向服务器认证您的设备</string>
<string name="download_wizard_progress_step_downloading">正在下载 eSIM 配置文件</string>
<string name="download_wizard_progress_step_finalizing">正在写入 eSIM 配置文件</string>
<string name="download_wizard_diagnostics">错误诊断</string>
<string name="download_wizard_diagnostics_error_code">错误代码: %s</string>
<string name="download_wizard_diagnostics_last_http_status">上次 HTTP 状态码 (来自服务器): %d</string>
<string name="download_wizard_diagnostics_last_http_response">上次 HTTP 应答 (来自服务器):</string>
<string name="download_wizard_diagnostics_last_http_exception">上次 HTTP 错误:</string>
<string name="download_wizard_diagnostics_last_apdu_response">上次 APDU 应答 (来自 SIM): %s</string>
<string name="download_wizard_diagnostics_last_apdu_response_success">上次 APDU 应答 (来自 SIM) 是成功的</string>
<string name="download_wizard_diagnostics_last_apdu_response_fail">上次 APDU 应答 (来自 SIM) 是失败的</string>
<string name="download_wizard_diagnostics_last_apdu_exception">上次 APDU 错误:</string>
<string name="download_wizard_diagnostics_save">保存</string>
<string name="download_wizard_diagnostics_file_template">%s 的错误诊断</string>
<string name="euicc_info">eUICC 详情</string>
<string name="euicc_info_activity_title">eUICC 详情 (%s)</string>
<string name="euicc_info_access_mode">访问方式</string>
<string name="euicc_info_removable">可插拔</string>
<string name="euicc_info_sgp22_version">SGP.22 版本</string>
<string name="euicc_info_firmware_version">eUICC OS 版本</string>
<string name="euicc_info_globalplatform_version">GlobalPlatform 版本</string>
<string name="euicc_info_sas_accreditation_number">SAS 认证号码</string>
<string name="euicc_info_pp_version">Protected Profile 版本</string>
<string name="euicc_info_free_nvram">NVRAM 剩余空间 (eSIM 存储容量)</string>
<string name="euicc_info_ci_type">证书签发者 (CI)</string>
<string name="euicc_info_ci_gsma_live">GSMA 生产环境 CI</string>
<string name="euicc_info_ci_gsma_test">GSMA 测试 CI</string>
<string name="euicc_info_ci_unknown">未知 eSIM CI</string>
<string name="yes"></string>
<string name="no"></string>
<string name="developer_options_steps">还有 %d 步成为开发者</string>
<string name="developer_options_enabled">你现在是开发者了!</string>
<string name="pref_advanced_language">语言</string>
<string name="pref_advanced_language_desc">选择 App 语言</string>
<string name="pref_developer">开发者选项</string>
<string name="pref_developer_unfiltered_profile_list">显示未经过滤的配置文件列表</string>
<string name="pref_developer_unfiltered_profile_list_desc">在配置文件列表中包括非生产环境的配置文件</string>
<string name="pref_developer_ignore_tls_certificate">无视 SM-DP+ 的 TLS 证书</string>
<string name="pref_developer_ignore_tls_certificate_desc">允许 RSP 服务器使用任意证书</string>
<string name="information_unavailable">无信息</string>
</resources>

View file

@ -3,16 +3,22 @@
<string name="no_euicc">No removable eUICC card accessible by this app is detected on this device. Insert a compatible card or a USB reader.</string>
<string name="no_profile">No profiles (yet) on this eSIM.</string>
<string name="unknown">Unknown</string>
<string name="information_unavailable">Information Unavailable</string>
<string name="help">Help</string>
<string name="reload">Reload Slots</string>
<string name="channel_name_format">Logical Slot %d</string>
<string name="usb">USB</string>
<string name="usb" translatable="false">USB</string>
<string name="omapi" translatable="false">OpenMobile API (OMAPI)</string>
<string name="enabled">Enabled</string>
<string name="disabled">Disabled</string>
<string name="provider">Provider:</string>
<string name="iccid">ICCID:</string>
<string name="profile_class">Class:</string>
<string name="profile_class_testing">Testing</string>
<string name="profile_class_provisioning">Provisioning</string>
<string name="profile_class_operational">Operational</string>
<string name="iccid" translatable="false">ICCID:</string>
<string name="enable">Enable</string>
<string name="disable">Disable</string>
@ -23,11 +29,10 @@
<string name="switch_did_not_refresh">The operation was successful, but your phone\'s modem refused to refresh. You might need to toggle airplane mode or reboot in order to use the new profile.</string>
<string name="toast_profile_enable_failed">Cannot switch to new eSIM profile.</string>
<string name="toast_profile_name_too_long">Nickname cannot be longer than 64 characters</string>
<string name="toast_profile_delete_confirm_text_mismatched">Confirmation string mismatch</string>
<string name="toast_iccid_copied">ICCID copied to clipboard</string>
<string name="slot_select">Select Slot</string>
<string name="slot_select_select">Select</string>
<string name="toast_eid_copied">EID copied to clipboard</string>
<string name="toast_atr_copied">ATR copied to clipboard</string>
<string name="usb_permission">Grant USB permission</string>
<string name="usb_permission_needed">Permission is needed to access the USB smart card reader.</string>
@ -48,13 +53,55 @@
<string name="profile_download_code">Activation Code</string>
<string name="profile_download_confirmation_code">Confirmation Code (Optional)</string>
<string name="profile_download_imei">IMEI (Optional)</string>
<string name="profile_download_free_space">Space remaining: %s</string>
<string name="profile_download_scan">Scan QR Code</string>
<string name="profile_download_scan_from_gallery">Scan QR Code from Gallery</string>
<string name="profile_download_ok">Download</string>
<string name="profile_download_failed">Failed to download eSIM. Check your activation / QR code.</string>
<string name="profile_download_low_nvram_title">This download may fail</string>
<string name="profile_download_low_nvram_message">This download may fail due to low remaining capacity.</string>
<string name="profile_download_no_lpa_string">No LPA code found in clipboard</string>
<string name="profile_download_incorrect_lpa_string">Unable to parse</string>
<string name="profile_download_incorrect_lpa_string_message">Could not parse QR code or clipboard content as a LPA code.</string>
<string name="download_wizard">Download Wizard</string>
<string name="download_wizard_back">Back</string>
<string name="download_wizard_next">Next</string>
<string name="download_wizard_slot_removed">Selected SIM has been removed</string>
<string name="download_wizard_slot_select">Select or confirm the eSIM you would like to download to:</string>
<string name="download_wizard_slot_type">Type:</string>
<string name="download_wizard_slot_type_removable">Removable</string>
<string name="download_wizard_slot_type_internal">Internal</string>
<string name="download_wizard_slot_type_internal_port">Internal, port %d</string>
<string name="download_wizard_slot_eid" translatable="false">eID:</string>
<string name="download_wizard_slot_active_profile">Active Profile:</string>
<string name="download_wizard_slot_free_space">Free Space:</string>
<string name="download_wizard_method_select">How would you like to download the eSIM profile?</string>
<string name="download_wizard_method_qr_code">Scan a QR code with camera</string>
<string name="download_wizard_method_gallery">Load a QR code from gallery</string>
<string name="download_wizard_method_clipboard">Load from Clipboard</string>
<string name="download_wizard_method_manual">Enter manually</string>
<string name="download_wizard_details">Input or confirm details for downloading your eSIM:</string>
<string name="download_wizard_progress">Downloading your eSIM…</string>
<string name="download_wizard_progress_step_preparing">Preparing</string>
<string name="download_wizard_progress_step_connecting">Establishing connection to server</string>
<string name="download_wizard_progress_step_authenticating">Authenticating your device with server</string>
<string name="download_wizard_progress_step_downloading">Downloading eSIM profile</string>
<string name="download_wizard_progress_step_finalizing">Loading eSIM profile into storage</string>
<string name="download_wizard_diagnostics">Error diagnostics</string>
<string name="download_wizard_diagnostics_error_code">Error code: %s</string>
<string name="download_wizard_diagnostics_last_http_status">Last HTTP status (from server): %d</string>
<string name="download_wizard_diagnostics_last_http_response">Last HTTP response (from server):</string>
<string name="download_wizard_diagnostics_last_http_exception">Last HTTP exception:</string>
<string name="download_wizard_diagnostics_last_apdu_response">Last APDU response (from SIM): %s</string>
<string name="download_wizard_diagnostics_last_apdu_response_success">Last APDU response (from SIM) is successful</string>
<string name="download_wizard_diagnostics_last_apdu_response_fail">Last APDU response (from SIM) is a failure</string>
<string name="download_wizard_diagnostics_last_apdu_exception">Last APDU exception:</string>
<string name="download_wizard_diagnostics_save">Save</string>
<string name="download_wizard_diagnostics_file_template">Diagnostics at %s</string>
<string name="logs_saved_message">Logs have been saved to the selected path. Would you like to share the log through another app?</string>
<string name="profile_rename_new_name">New nickname</string>
<string name="profile_rename_encoding_error">Failed to encode nickname as UTF-8</string>
<string name="profile_rename_too_long">Nickname is longer than 64 characters</string>
<string name="profile_rename_failure">Unknown failure when renaming profile</string>
<string name="profile_delete_confirm">Are you sure you want to delete the profile %s? This operation is irreversible.</string>
<string name="profile_delete_confirm_input">Type \'%s\' here to confirm deletion</string>
@ -67,13 +114,37 @@
<string name="profile_notification_operation_delete">Deleted</string>
<string name="profile_notification_operation_enable">Enabled</string>
<string name="profile_notification_operation_disable">Disabled</string>
<string name="profile_notification_name_format">&lt;b&gt;%1$s&lt;/b&gt; %2$s (%3$s)</string>
<string name="profile_notification_name_format" translatable="false">&lt;b&gt;%1$s&lt;/b&gt; %2$s (%3$s)</string>
<string name="profile_notification_sequence_number_format" translatable="false">#%d</string>
<string name="profile_notification_process">Process</string>
<string name="profile_notification_delete">Delete</string>
<string name="euicc_info">eUICC Info</string>
<string name="euicc_info_activity_title">eUICC Info (%s)</string>
<string name="euicc_info_access_mode">Access Mode</string>
<string name="euicc_info_removable">Removable</string>
<string name="euicc_info_eid" translatable="false">EID</string>
<string name="euicc_info_sgp22_version">SGP.22 Version</string>
<string name="euicc_info_firmware_version">eUICC OS Version</string>
<string name="euicc_info_globalplatform_version">GlobalPlatform Version</string>
<string name="euicc_info_sas_accreditation_number">SAS Accreditation Number</string>
<string name="euicc_info_pp_version">Protected Profile Version</string>
<string name="euicc_info_free_nvram">Free NVRAM (eSIM profile storage)</string>
<string name="euicc_info_ci_type">Certificate Issuer (CI)</string>
<string name="euicc_info_ci_gsma_live">GSMA Live CI</string>
<string name="euicc_info_ci_gsma_test">GSMA Test CI</string>
<string name="euicc_info_ci_unknown">Unknown eSIM CI</string>
<string name="euicc_info_atr" translatable="false">Answer To Reset (ATR)</string>
<string name="yes">Yes</string>
<string name="no">No</string>
<string name="logs_save">Save</string>
<string name="logs_filename_template">Logs at %s</string>
<string name="developer_options_steps">You are %d steps away from being a developer.</string>
<string name="developer_options_enabled">You are now a developer!</string>
<string name="pref_settings">Settings</string>
<string name="pref_notifications">Notifications</string>
<string name="pref_notifications_desc">eSIM profile operations send notifications to the carrier. Fine-tune this behavior as needed here.</string>
@ -88,8 +159,15 @@
<string name="pref_advanced_disable_safeguard_removable_esim_desc">By default, this app prevents you from disabling the active profile on a removable eSIM inserted in the device, because doing so may <i>sometimes</i> render it inaccessible.\nCheck this box to <i>remove</i> this safeguard.</string>
<string name="pref_advanced_verbose_logging">Verbose Logging</string>
<string name="pref_advanced_verbose_logging_desc">Enable verbose logs, which may contain sensitive information. Only share your logs with someone you trust after turning this on.</string>
<string name="pref_advanced_language">Language</string>
<string name="pref_advanced_language_desc">Select app language</string>
<string name="pref_advanced_logs">Logs</string>
<string name="pref_advanced_logs_desc">View recent debug logs of the application</string>
<string name="pref_developer">Developer Options</string>
<string name="pref_developer_unfiltered_profile_list">Show unfiltered profile list</string>
<string name="pref_developer_unfiltered_profile_list_desc">Include non-production profiles in the list</string>
<string name="pref_developer_ignore_tls_certificate">Ignore SM-DP+ TLS certificate</string>
<string name="pref_developer_ignore_tls_certificate_desc">Accept any TLS certificate used by the RSP server</string>
<string name="pref_info">Info</string>
<string name="pref_info_app_version">App Version</string>
<string name="pref_info_source_code">Source Code</string>

View file

@ -0,0 +1,6 @@
<?xml version="1.0" encoding="utf-8"?>
<locale-config xmlns:android="http://schemas.android.com/apk/res/android">
<locale android:name="en-US" />
<locale android:name="ja" />
<locale android:name="zh-CN" />
</locale-config>

View file

@ -1,5 +1,6 @@
<?xml version="1.0" encoding="utf-8"?>
<PreferenceScreen xmlns:app="http://schemas.android.com/apk/res-auto">
<PreferenceScreen xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto">
<im.angry.openeuicc.ui.preference.LongSummaryPreferenceCategory
app:title="@string/pref_notifications"
app:summary="@string/pref_notifications_desc"
@ -36,14 +37,42 @@
app:title="@string/pref_advanced_verbose_logging"
app:summary="@string/pref_advanced_verbose_logging_desc" />
<Preference
app:iconSpaceReserved="false"
app:isPreferenceVisible="false"
app:key="pref_advanced_language"
app:summary="@string/pref_advanced_language_desc"
app:title="@string/pref_advanced_language" />
<Preference
app:key="pref_advanced_logs"
app:iconSpaceReserved="false"
app:title="@string/pref_advanced_logs"
app:summary="@string/pref_advanced_logs_desc" />
</PreferenceCategory>
<PreferenceCategory
app:key="pref_developer"
app:title="@string/pref_developer"
app:iconSpaceReserved="false">
<CheckBoxPreference
app:iconSpaceReserved="false"
app:key="pref_developer_unfiltered_profile_list"
app:summary="@string/pref_developer_unfiltered_profile_list_desc"
app:title="@string/pref_developer_unfiltered_profile_list" />
<CheckBoxPreference
app:iconSpaceReserved="false"
app:key="pref_developer_ignore_tls_certificate"
app:summary="@string/pref_developer_ignore_tls_certificate_desc"
app:title="@string/pref_developer_ignore_tls_certificate" />
</PreferenceCategory>
<PreferenceCategory
app:key="pref_info"
app:title="@string/pref_info"
app:iconSpaceReserved="false">
<Preference
@ -55,6 +84,10 @@
app:iconSpaceReserved="false"
app:title="@string/pref_info_source_code"
app:summary="@string/pref_info_source_code_url"
app:key="pref_info_source_code"/>
app:key="pref_info_source_code">
<intent
android:action="android.intent.action.VIEW"
android:data="@string/pref_info_source_code_url" />
</Preference>
</PreferenceCategory>
</PreferenceScreen>

3
app-deps/.gitignore vendored
View file

@ -1 +1,2 @@
/build
/build
/libs

View file

@ -49,6 +49,10 @@ android {
kotlinOptions {
jvmTarget = "1.8"
}
dependenciesInfo {
// Disable dependency metadata -- breaks compatibility with F-Droid
includeInApk = false
}
}
dependencies {

View file

@ -9,5 +9,6 @@
android:roundIcon="@mipmap/ic_launcher_jmp"
android:label="@string/app_name"
android:supportsRtl="true"
android:localeConfig="@xml/locale_config"
android:theme="@style/Theme.OpenEUICC" />
</manifest>

View file

@ -6,4 +6,8 @@ class JmpAppContainer(context: Context) : UnprivilegedAppContainer(context) {
override val uiComponentFactory by lazy {
JmpUiComponentFactory()
}
override val customizableTextProvider by lazy {
JmpCustomizableTextProvider(context)
}
}

View file

@ -0,0 +1,12 @@
package im.angry.openeuicc.di
import android.content.Context
import im.angry.easyeuicc.R
class JmpCustomizableTextProvider(private val context: Context) :
UnprivilegedCustomizableTextProvider(context) {
override val noEuiccExplanation: String
get() = context.getString(R.string.no_euicc_jmp)
override val profileSwitchingTimeoutMessage: String
get() = context.getString(R.string.enable_disable_timeout_jmp)
}

View file

@ -11,7 +11,7 @@
android:layout_marginStart="40dp"
android:layout_marginEnd="40dp"
android:gravity="center"
android:text="@string/no_euicc"
android:text="@string/no_euicc_jmp"
app:layout_constraintTop_toTopOf="parent"
app:layout_constraintBottom_toBottomOf="parent" />

View file

@ -0,0 +1,6 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<string name="no_euicc_jmp">このデバイスで JMP eSIM Adapter を見つかりません。JMP eSIM Adapter をデバイスに挿入、または USB リーダーに経由し接続してください。</string>
<string name="purchase_esim">JMP eSIM Adapter を購入</string>
<string name="enable_disable_timeout_jmp">eSIM チップがプロファイルの切り替えの待機中にタイムアウトしました。 SIM ツールキットの Tools -> Reboot を選択し、eSIM Adapter をリフレッシュしてください。</string>
</resources>

View file

@ -0,0 +1,6 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<string name="no_euicc_jmp">没有在此设备上发现 JMP eSIM Adapter。请将其插入本设备或 USB 读卡器。</string>
<string name="purchase_esim">购入 JMP eSIM Adapter</string>
<string name="enable_disable_timeout_jmp">等待 eSIM 芯片切换配置文件超时。请使用 SIM Toolkit 中的 Tools -> Reboot 手动刷新 eSIM Adapter。</string>
</resources>

View file

@ -1,10 +1,10 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<string name="app_name" translatable="false">JMP SIM Manager</string>
<string name="no_euicc">No JMP eSIM Adapter found on this device. Insert one into the device or through a USB card reader.</string>
<string name="no_euicc_jmp">No JMP eSIM Adapter found on this device. Insert one into the device or through a USB card reader.</string>
<string name="purchase_esim">Buy JMP eSIM Adapter</string>
<string name="purchase_sim_url" translatable="false">https://jmp.chat/esim-adapter</string>
<string name="pref_info_source_code_url" translatable="false">https://gitea.angry.im/jmp-sim/jmp-sim-manager</string>
<string name="enable_disable_timeout">Timed out waiting for the eSIM chip to switch profiles. Please manually refresh the eSIM adapter by going to SIM Toolkit, and select Tools -> Reboot.</string>
<string name="enable_disable_timeout_jmp">Timed out waiting for the eSIM chip to switch profiles. Please manually refresh the eSIM adapter by going to SIM Toolkit, and select Tools -> Reboot.</string>
</resources>

View file

@ -1,5 +1,6 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools">
<application
android:name="im.angry.openeuicc.UnprivilegedOpenEuiccApplication"
@ -8,7 +9,9 @@
android:label="@string/app_name"
android:roundIcon="@mipmap/ic_launcher_round"
android:supportsRtl="true"
android:theme="@style/Theme.OpenEUICC">
android:localeConfig="@xml/locale_config"
android:theme="@style/Theme.OpenEUICC"
tools:targetApi="tiramisu">
<activity
android:name="im.angry.openeuicc.ui.UnprivilegedMainActivity"
@ -22,9 +25,22 @@
<activity
android:name="im.angry.openeuicc.ui.CompatibilityCheckActivity"
android:label="@string/compatibility_check"
android:exported="false" />
android:exported="false"
android:label="@string/compatibility_check" />
<service
android:name="androidx.appcompat.app.AppLocalesMetadataHolderService"
android:enabled="false"
android:exported="false">
<meta-data
android:name="autoStoreLocales"
android:value="true" />
</service>
</application>
<queries>
<package android:name="com.android.stk" />
<package android:name="com.android.stk1" />
<package android:name="com.android.stk2" />
</queries>
</manifest>