Compare commits

...

76 commits

Author SHA1 Message Date
3926108ea6 i18n: More fixes
All checks were successful
Build Debug APKs / build-debug (push) Successful in 4m16s
2026-03-15 11:42:27 -04:00
aeb837b50c i18n: Update STK menu shortcut translation
All checks were successful
Build Debug APKs / build-debug (push) Successful in 4m20s
2026-03-15 11:34:03 -04:00
297880fa53 feat: Optional confirmation signal for profileDownloadTask
All checks were successful
Build Debug APKs / build-debug (push) Successful in 4m38s
This is to prepare for confirmation UI and EuiccService preview impl.
2026-03-14 21:00:21 -04:00
0f369b3b12 fix: Pass logicalSlotId to DownloadWizardActivity, not slotId
All checks were successful
Build Debug APKs / build-debug (push) Successful in 4m28s
This is confusing and we should probably fix this eventually.
2026-03-14 14:48:46 -04:00
ba45a7eb7f refactor: Pass RemoteProfileInfo into ProfileDownloadState
All checks were successful
Build Debug APKs / build-debug (push) Successful in 4m26s
...instead of using a different callback. This will help us hook this up
into EuiccService properly later (we still need a way for EuiccService
to send events back to EuiccChannelManagerService)
2026-03-14 14:38:12 -04:00
6837aba211 feat: language switcher with platform signed on privileged
All checks were successful
Build Debug APKs / build-debug (push) Successful in 4m59s
2026-03-09 20:43:51 -04:00
eec2a105ff Trigger new actions run
All checks were successful
Build Debug APKs / build-debug (push) Successful in 7m33s
2026-03-09 09:47:49 -04:00
d4855f130c lpac-jni: Export profile metadata during download and allow cancellation at that point
All checks were successful
Build Debug APKs / build-debug (push) Successful in 9m15s
(Shoutout to Mark Gallagher <mark@fts.scot> who in #313 introduced
a similar feature. Note that we still have not exposed profile
installation result here, but what's really useful would only be the
sequence number for us)

This leaves a TODO to actually hook up the UI to allow cancellation
after metadata is displayed. But most of this is intended to support
EuiccService APIs to allow apps to download profiles directly, which
requires the use of profilePolicyRules (not done yet; lpac pending) in
the preview metadata.
2026-03-08 19:31:39 -04:00
2108696646 lpac-jni: Upgrade lpac (#315)
All checks were successful
Build Debug APKs / build-debug (push) Successful in 6m58s
Upstream no longer bundles cjson so we pull it in ourselves. Android.bp is also updated for AOSP builds.

Reviewed-on: #315
2026-03-08 22:20:34 +01:00
6210c81201 ui: Switch to MODE_SCROLLABLE when tab width is about to overflow
All checks were successful
Build Debug APKs / build-debug (push) Successful in 5m30s
...to prevent tabs from inserting line breaks.
2026-03-01 22:29:26 -05:00
0a353a3df6 fix: version name suffix in debug (#290)
All checks were successful
Build Debug APKs / build-debug (push) Successful in 6m24s
Reviewed-on: #290
Co-authored-by: septs <github@septs.pw>
Co-committed-by: septs <github@septs.pw>
2026-02-28 23:52:27 +01:00
713ffec26d feat: multi-SE support for USB channels (#309)
All checks were successful
Build Debug APKs / build-debug (push) Successful in 6m6s
All channels now support multi-SE. UsbCcidReaderFragment now no longer wraps inner EuiccManagementFragment's but instead calls MainActivity to instantiate tabs for USB channels. Also, we now no longer use the USB product name as channel titles but instead use "USB Reader" and "USB Reader, SE x" for all of them.

Reviewed-on: #309
Reviewed-by: septs <github@septs.pw>
2026-02-28 23:43:03 +01:00
c676c27338 fix: channel title (#292)
All checks were successful
Build Debug APKs / build-debug (push) Successful in 9m26s
Reviewed-on: #292
Co-authored-by: septs <github@septs.pw>
Co-committed-by: septs <github@septs.pw>
2026-02-28 22:35:39 +01:00
81810b22fb Revert "Switch to gradle daemon mode"
All checks were successful
Build Debug APKs / build-debug (push) Successful in 5m50s
This reverts commit fdde41329b.
2026-02-27 20:44:12 -05:00
fdde41329b Switch to gradle daemon mode
Some checks failed
Build Debug APKs / build-debug (push) Failing after 1m3s
2026-02-27 18:48:52 -05:00
2cf2d9490a Support TPDU-based CCID readers (#295)
All checks were successful
Build Debug APKs / build-debug (push) Successful in 5m33s
resolves #37

For TPDU based readers like USB 2.0-CRW 2 additional commands are needed in initialisation. Add them

Reviewed-on: #295
Reviewed-by: septs <github@septs.pw>
Co-authored-by: Vladimir Serbinenko <phcoder@gmail.com>
Co-committed-by: Vladimir Serbinenko <phcoder@gmail.com>
2026-02-26 02:22:11 +01:00
b9863e2e54 refactor: profile download callback (#284)
Some checks failed
Build Debug APKs / build-debug (push) Has been cancelled
Reviewed-on: #284
Co-authored-by: septs <github@septs.pw>
Co-committed-by: septs <github@septs.pw>
2026-02-26 02:20:04 +01:00
56fbd34616 feat: paste lpa string into smdp address input box (#291)
Some checks failed
Build Debug APKs / build-debug (push) Has been cancelled
Reviewed-on: #291
Co-authored-by: septs <github@septs.pw>
Co-committed-by: septs <github@septs.pw>
2026-02-26 02:19:42 +01:00
419db21b12 refactor: simplify sim toolkit design (#296)
All checks were successful
Build Debug APKs / build-debug (push) Successful in 6m24s
Reviewed-on: #296
Co-authored-by: septs <github@septs.pw>
Co-committed-by: septs <github@septs.pw>
2026-02-24 00:26:20 +01:00
9496370135 fix: improve language settings (#300)
Some checks failed
Build Debug APKs / build-debug (push) Has been cancelled
resolves #299 (may be)

Reviewed-on: #300
Co-authored-by: septs <github@septs.pw>
Co-committed-by: septs <github@septs.pw>
2026-02-24 00:23:53 +01:00
84f57a4ef7 feat: detect network lock in quick compatibility (#303)
Some checks failed
Build Debug APKs / build-debug (push) Has been cancelled
Reviewed-on: #303
Co-authored-by: septs <github@septs.pw>
Co-committed-by: septs <github@septs.pw>
2026-02-24 00:22:50 +01:00
a75478dd31 feat: assets statements (#304)
Some checks failed
Build Debug APKs / build-debug (push) Has been cancelled
see https://developer.chrome.com/docs/capabilities/get-installed-related-apps?hl=en

Reviewed-on: #304
Co-authored-by: septs <github@septs.pw>
Co-committed-by: septs <github@septs.pw>
2026-02-24 00:22:23 +01:00
f17e171372 feat: make debug and release version name different (#288)
All checks were successful
Build Debug APKs / build-debug (push) Successful in 5m25s
Reviewed-on: #288
Co-authored-by: septs <github@septs.pw>
Co-committed-by: septs <github@septs.pw>
2025-12-22 00:55:49 +01:00
e60f98fdef fix: Don't even try to open channels on multiple SEs unless vendor implementation exists
All checks were successful
Build Release / release (push) Successful in 8m39s
Build Debug APKs / build-debug (push) Successful in 8m52s
2025-12-18 21:50:20 -05:00
31991c909d Update README.md with new copyright date and (upcoming) website for
All checks were successful
Build Debug APKs / build-debug (push) Successful in 5m52s
Build Release / release (push) Successful in 5m34s
EasyEUICC
2025-12-15 20:55:32 -05:00
392a2927cb Fix AOSP build by importing im.angry.openeuicc.util as wildcard
All checks were successful
Build Debug APKs / build-debug (push) Successful in 9m16s
This is REQUIRED for AOSP build to work.
2025-12-14 20:13:49 -05:00
f3dbba0229 core: Disable multi-SE support over USB for now
All checks were successful
Build Debug APKs / build-debug (push) Successful in 10m10s
I would like to release the next version before I actually receive a
sample w/ multi-SE and fix up the USB side properly.
2025-12-14 19:17:39 -05:00
0c305ff6ff ui: Fix multi-SE channel title in the rest of activities
Some checks failed
Build Debug APKs / build-debug (push) Has been cancelled
Of course all of this needs to be refactored including adding support
for multi-SE chips over USB readers, but that will be a task for the
next release cycle.
2025-12-14 19:11:28 -05:00
e5568e2164 feat: hide fab on scroll profile list (#281)
All checks were successful
Build Debug APKs / build-debug (push) Successful in 6m26s
Reviewed-on: #281
Co-authored-by: septs <github@septs.pw>
Co-committed-by: septs <github@septs.pw>
2025-12-13 21:02:10 +01:00
639c1d0ea6 feat: official website url (#286)
Some checks failed
Build Debug APKs / build-debug (push) Has been cancelled
Reviewed-on: #286
Co-authored-by: septs <github@septs.pw>
Co-committed-by: septs <github@septs.pw>
2025-12-13 21:01:39 +01:00
d7fb53ce5c fix: download a esim on multi-se (#282)
Some checks failed
Build Debug APKs / build-debug (push) Has been cancelled
Reviewed-on: #282
Co-authored-by: septs <github@septs.pw>
Co-committed-by: septs <github@septs.pw>
2025-12-13 20:58:29 +01:00
d40b839911 feat: add linksfield aid (#275)
Some checks failed
Build Debug APKs / build-debug (push) Has been cancelled
Reviewed-on: #275
Co-authored-by: septs <github@septs.pw>
Co-committed-by: septs <github@septs.pw>
2025-12-13 20:56:43 +01:00
76ef63efdf refactor: Only handle window inset events in the main view hierarchy
All checks were successful
Build Debug APKs / build-debug (push) Successful in 6m15s
...so that this isn't completely broken by some versions of Android with
the broken dispatch behavior.
2025-12-09 21:05:12 -05:00
b7fc164a3f ui: Set window inset listener only once
All checks were successful
Build Debug APKs / build-debug (push) Successful in 5m45s
On API 29 and lower seems like Android has a bug where it does not
dispatch window insets properly to sibling views:

https://github.com/streetcomplete/StreetComplete/issues/6030

The fix here is to... not set window inset handler on multiple views
simultaneously. Hopefully this fixes it.
2025-12-07 20:37:20 -05:00
19b127df3f Revert "ui: Improve window inset calculation"
This reverts commit aca541593c.
2025-12-07 20:29:20 -05:00
aca541593c ui: Improve window inset calculation
All checks were successful
Build Debug APKs / build-debug (push) Successful in 5m28s
We don't care if system bars are hidden or not: we should always apply
the full inset.
2025-12-07 20:01:17 -05:00
fd79236591 Remove unneeded seId default values
All checks were successful
Build Debug APKs / build-debug (push) Successful in 6m1s
2025-12-07 19:26:24 -05:00
f498c22ad8 refactor: Pass multi-SE metadata inside EuiccChannel so it can be shown in MainActivity
All checks were successful
Build Debug APKs / build-debug (push) Successful in 5m35s
2025-12-07 19:19:43 -05:00
c23ad21421 refactor: Deal with multi-SE chips better with waitForReconnect
All checks were successful
Build Debug APKs / build-debug (push) Successful in 4m55s
We should close all channels and reopen all when waiting for reconnect.
This applies on open as well -- if any channel from a slot is invalid,
we should close them all.
2025-12-07 19:02:15 -05:00
65a4c887f1 refactor: tryOpenEuiccChannel should always return all SEs for a port
All checks were successful
Build Debug APKs / build-debug (push) Successful in 4m38s
2025-12-07 17:48:28 -05:00
2bb10c72f6 fix: build warnings (#272)
All checks were successful
Build Debug APKs / build-debug (push) Successful in 5m7s
/workspace/PeterCxy/OpenEUICC/app-unpriv/src/main/res/values/strings.xml:2:4: Multiple substitutions specified in non-positional format of string resource string/channel_name_format_se. Did you mean to add the formatted="false" attribute?

Reviewed-on: #272
Co-authored-by: septs <github@septs.pw>
Co-committed-by: septs <github@septs.pw>
2025-12-03 23:57:58 +01:00
7ca832f68e fix: build failed (#271)
All checks were successful
Build Debug APKs / build-debug (push) Successful in 5m49s
Reviewed-on: #271
Co-authored-by: septs <github@septs.pw>
Co-committed-by: septs <github@septs.pw>
2025-12-02 04:35:02 +01:00
7e1c4907bb style: reformat all files (#268)
Some checks failed
Build Debug APKs / build-debug (push) Failing after 6m22s
Reviewed-on: #268
Co-authored-by: septs <github@septs.pw>
Co-committed-by: septs <github@septs.pw>
2025-12-02 04:13:18 +01:00
22a7a5145c fix: notification handling in multiple se (#269)
Some checks failed
/ build-debug (push) Has been cancelled
Reviewed-on: #269
Co-authored-by: septs <github@septs.pw>
Co-committed-by: septs <github@septs.pw>
2025-12-02 04:13:07 +01:00
6e5bbc4085 fix: privileged channel manager, close all channels (#270)
Some checks failed
/ build-debug (push) Has been cancelled
Reviewed-on: #270
Co-authored-by: septs <github@septs.pw>
Co-committed-by: septs <github@septs.pw>
2025-12-02 04:12:22 +01:00
709a962314 fix: Set seId when calling tryOpenEuiccChannel
All checks were successful
/ build-debug (push) Successful in 5m49s
2025-11-30 22:04:32 -05:00
755499e878 refactor: remove euicc memory reset preference (#266)
Some checks failed
/ build-debug (push) Has been cancelled
Reviewed-on: #266
Co-authored-by: septs <github@septs.pw>
Co-committed-by: septs <github@septs.pw>
2025-12-01 04:00:05 +01:00
6b0e058d5c refactor: remove low nvram warnings (#263)
Some checks failed
/ build-debug (push) Has been cancelled
It did not work as expected and may require an external, continuously updated profile size database as a statistical reference.

Reviewed-on: #263
Co-authored-by: septs <github@septs.pw>
Co-committed-by: septs <github@septs.pw>
2025-12-01 03:59:52 +01:00
1938a4ff5a fix: typo (#267)
Some checks failed
/ build-debug (push) Has been cancelled
Reviewed-on: #267
Co-authored-by: septs <github@septs.pw>
Co-committed-by: septs <github@septs.pw>
2025-12-01 03:59:42 +01:00
f2c7e79bee refactor: app shortcuts (#261)
All checks were successful
/ build-debug (push) Successful in 4m36s
Reviewed-on: #261
Co-authored-by: septs <github@septs.pw>
Co-committed-by: septs <github@septs.pw>
2025-11-30 21:53:48 +01:00
552aba87c0 i18n: Update and correct translation strings
Some checks failed
/ build-debug (push) Has been cancelled
- The "Erase" in "Erase eSIM" should not translated as 消去 directly IMHO
  since we are not erasing the eSIM chip itself, we are erasing the _data_
  on the eSIM chip. The English expression "erase the eSIM chip" implies
  erasing the data, but 消去 does not.
- Initial translation of added strings and rewrote some new strings for
  better English grammar.
2025-11-30 15:51:18 -05:00
cd34597362 chore: unifi nouns (#265)
All checks were successful
/ build-debug (push) Successful in 4m46s
batch replace eUICC strings to eSIM

Reviewed-on: #265
Co-authored-by: septs <github@septs.pw>
Co-committed-by: septs <github@septs.pw>
2025-11-30 21:38:58 +01:00
c396223a02 feat: Support for removable eSIM with multiple SEs (#239)
Some checks failed
/ build-debug (push) Has been cancelled
Closes #232

Reviewed-on: #239
2025-11-30 21:34:33 +01:00
69fe2764c9 feat: app shortcuts (#259)
All checks were successful
/ build-debug (push) Successful in 4m30s
Reviewed-on: #259
Co-authored-by: septs <github@septs.pw>
Co-committed-by: septs <github@septs.pw>
2025-11-22 16:25:14 +01:00
8ec0757e28 refactor: simplify notification permission checks (#260)
Some checks failed
/ build-debug (push) Has been cancelled
Reviewed-on: #260
Co-authored-by: septs <github@septs.pw>
Co-committed-by: septs <github@septs.pw>
2025-11-22 16:24:52 +01:00
55e7731197 fix: profile state italic cutoff (#258)
All checks were successful
/ build-debug (push) Successful in 4m38s
![image](/attachments/0eb471dc-6c3a-49ac-bf01-9ef658fd29fa)

Reviewed-on: #258
Co-authored-by: septs <github@septs.pw>
Co-committed-by: septs <github@septs.pw>
2025-11-17 14:06:32 +01:00
938e298ca3 fix: ensure logical channel is closed on select DF failure (#257)
All checks were successful
/ build-debug (push) Successful in 4m48s
Reviewed-on: #257
Co-authored-by: septs <github@septs.pw>
Co-committed-by: septs <github@septs.pw>
2025-11-16 16:00:13 +01:00
8f51abe1af refactor: include enabled profiles in operational list filter (#255)
All checks were successful
/ build-debug (push) Successful in 5m55s
Reviewed-on: #255
Co-authored-by: septs <github@septs.pw>
Co-committed-by: septs <github@septs.pw>
2025-11-16 04:58:44 +01:00
5e7dfd36ab feat: limits cross profile class enable profile (#253)
Some checks failed
/ build-debug (push) Has been cancelled
Reviewed-on: #253
Co-authored-by: septs <github@septs.pw>
Co-committed-by: septs <github@septs.pw>
2025-11-16 04:58:30 +01:00
4d43ab4824 refactor: simplify atr check (#252)
Some checks failed
/ build-debug (push) Has been cancelled
Reviewed-on: #252
Co-authored-by: septs <github@septs.pw>
Co-committed-by: septs <github@septs.pw>
2025-11-16 04:57:27 +01:00
4e873f4e88 fix: correct typo in free nvram hint string (#251)
Some checks failed
/ build-debug (push) Has been cancelled
Reviewed-on: #251
Co-authored-by: septs <github@septs.pw>
Co-committed-by: septs <github@septs.pw>
2025-11-16 04:56:51 +01:00
ebdffc5032 refactor: simplify estkme firmware version (#250)
Some checks failed
/ build-debug (push) Has been cancelled
Similar to the display format of the FW field on an ESTKme card.

![Screenshot_20251111-172138](/attachments/759f8cfe-be01-48b6-9b6a-7e79e3cdc2f4)

Reviewed-on: #250
Co-authored-by: septs <github@septs.pw>
Co-committed-by: septs <github@septs.pw>
2025-11-16 04:56:27 +01:00
cc9f99a2a9 refactor: display customized ISD-R AID in EuiccInfoActivity (#246)
All checks were successful
/ build-debug (push) Successful in 4m52s
Reviewed-on: #246
Co-authored-by: septs <github@septs.pw>
Co-committed-by: septs <github@septs.pw>
2025-11-10 14:19:17 +01:00
3821cb7de0 fix: typo (#249)
All checks were successful
/ build-debug (push) Successful in 6m13s
Reviewed-on: #249
Co-authored-by: septs <github@septs.pw>
Co-committed-by: septs <github@septs.pw>
2025-11-04 02:04:58 +01:00
1933cffd82 chore: update profile delete confirmation text (#244)
All checks were successful
/ build-debug (push) Successful in 6m25s
Reviewed-on: #244
Co-authored-by: septs <github@septs.pw>
Co-committed-by: septs <github@septs.pw>
2025-11-03 05:00:34 +01:00
987eb4f71b refactor: improve read vendors (#247)
Some checks failed
/ build-debug (push) Has been cancelled
Reviewed-on: #247
Co-authored-by: septs <github@septs.pw>
Co-committed-by: septs <github@septs.pw>
2025-11-03 05:00:15 +01:00
418010ba69 refactor: simplify euicc info activity (#245)
All checks were successful
/ build-debug (push) Successful in 6m9s
Reviewed-on: #245
Co-authored-by: septs <github@septs.pw>
Co-committed-by: septs <github@septs.pw>
2025-10-31 21:58:54 +01:00
f3f97cc942 Improve zh-TW localization (#243)
All checks were successful
/ build-debug (push) Successful in 5m17s
Polish Traditional Chinese (zh-TW) phrasing

Reviewed-on: #243
Co-authored-by: smanlin <smanlin@noreply.gitea.angry.im>
Co-committed-by: smanlin <smanlin@noreply.gitea.angry.im>
2025-10-27 23:06:38 +01:00
af6270add2 fix: simplified error messages (#238)
All checks were successful
/ build-debug (push) Successful in 5m38s
Reviewed-on: #238
Co-authored-by: septs <github@septs.pw>
Co-committed-by: septs <github@septs.pw>
2025-10-25 21:55:47 +02:00
d0bd5e7dfb Update Japanese & Fix (#237)
Some checks failed
/ build-debug (push) Has been cancelled
変更されたStringsに合わせて翻訳を調整と些細な修正。

Reviewed-on: #237
Co-authored-by: reindex <reindex@noreply.gitea.angry.im>
Co-committed-by: reindex <reindex@noreply.gitea.angry.im>
2025-10-25 21:54:16 +02:00
3b0e25b8ab chore: allow tag trigger debug build (#240)
Some checks failed
/ build-debug (push) Has been cancelled
Reviewed-on: #240
Co-authored-by: septs <github@septs.pw>
Co-committed-by: septs <github@septs.pw>
2025-10-25 21:53:50 +02:00
c74c1f309c docs: enhance README formatting and clarity (#235)
All checks were successful
/ build-debug (push) Successful in 5m11s
Reviewed-on: #235
Co-authored-by: septs <github@septs.pw>
Co-committed-by: septs <github@septs.pw>
2025-10-02 14:25:45 +02:00
b245a9c893 fix magisk module bug (#234)
Some checks failed
/ build-debug (push) Has been cancelled
部分设备magisk模块安装出现失败情况,对此进行修复
如图为在magisk27.1中无法安装

Reviewed-on: #234
Co-authored-by: MasterOfStar <hzh4363703@live.com>
Co-committed-by: MasterOfStar <hzh4363703@live.com>
2025-10-02 14:24:51 +02:00
351567a972 chore: improve variant compare (#230)
All checks were successful
/ build-debug (push) Successful in 6m18s
Reviewed-on: #230
Co-authored-by: septs <github@septs.pw>
Co-committed-by: septs <github@septs.pw>
2025-09-22 00:50:30 +02:00
0096b9005f chore: update ES10x MSS preference labels for clarity (#231)
Some checks failed
/ build-debug (push) Has been cancelled
Reviewed-on: #231
Co-authored-by: septs <github@septs.pw>
Co-committed-by: septs <github@septs.pw>
2025-09-22 00:50:16 +02:00
525212c1b8 Update Japanese Language (#229)
Some checks failed
/ build-debug (push) Has been cancelled
日本語を更新しといたよ。
間違ったところも修正済み。

Reviewed-on: #229
Co-authored-by: reindex <reindex@noreply.gitea.angry.im>
Co-committed-by: reindex <reindex@noreply.gitea.angry.im>
2025-09-22 00:49:57 +02:00
228 changed files with 4160 additions and 2393 deletions

22
.editorconfig Normal file
View file

@ -0,0 +1,22 @@
root = true
[*]
charset = utf-8
indent_size = 2
indent_style = space
end_of_line = lf
insert_final_newline = true
trim_trailing_whitespace = true
max_line_length = 120
[*.{kt,java}]
indent_size = 4
[*.gradle.kts]
indent_size = 4
[*.xml]
indent_size = 4
[.idea/**/*.xml]
indent_size = 2

View file

@ -1,11 +1,15 @@
name: Build Debug APKs
on:
push:
branches:
- '*'
tags:
- '*'
jobs:
build-debug:
runs-on: [docker, android-app-certs]
runs-on: [ docker, android-app-certs ]
container:
volumes:
- android-app-keystore:/keystore

View file

@ -1,10 +1,12 @@
name: Build Release
on:
push:
tags: '*'
jobs:
release:
runs-on: [docker, android-app-certs]
runs-on: [ docker, android-app-certs ]
container:
volumes:
- android-app-keystore:/keystore

3
.gitmodules vendored
View file

@ -1,3 +1,6 @@
[submodule "libs/lpac-jni/src/main/jni/lpac"]
path = libs/lpac-jni/src/main/jni/lpac
url = https://github.com/estkme-group/lpac.git
[submodule "libs/lpac-jni/src/main/jni/cJSON"]
path = libs/lpac-jni/src/main/jni/cjson/cjson
url = https://github.com/DaveGamble/cJSON

View file

@ -1,6 +1,45 @@
<component name="ProjectCodeStyleConfiguration">
<code_scheme name="Project" version="173">
<JavaCodeStyleSettings>
<option name="IMPORT_LAYOUT_TABLE">
<value>
<package name="android" withSubpackages="true" static="true" />
<package name="androidx" withSubpackages="true" static="true" />
<package name="com" withSubpackages="true" static="true" />
<package name="junit" withSubpackages="true" static="true" />
<package name="net" withSubpackages="true" static="true" />
<package name="org" withSubpackages="true" static="true" />
<package name="java" withSubpackages="true" static="true" />
<package name="javax" withSubpackages="true" static="true" />
<package name="" withSubpackages="true" static="true" />
<emptyLine />
<package name="android" withSubpackages="true" static="false" />
<emptyLine />
<package name="androidx" withSubpackages="true" static="false" />
<emptyLine />
<package name="com" withSubpackages="true" static="false" />
<emptyLine />
<package name="junit" withSubpackages="true" static="false" />
<emptyLine />
<package name="net" withSubpackages="true" static="false" />
<emptyLine />
<package name="org" withSubpackages="true" static="false" />
<emptyLine />
<package name="java" withSubpackages="true" static="false" />
<emptyLine />
<package name="javax" withSubpackages="true" static="false" />
<emptyLine />
<package name="" withSubpackages="true" static="false" />
<emptyLine />
</value>
</option>
</JavaCodeStyleSettings>
<JetCodeStyleSettings>
<option name="PACKAGES_TO_USE_STAR_IMPORTS">
<value>
<package name="im.angry.openeuicc.util" alias="false" withSubpackages="true" />
</value>
</option>
<option name="CODE_STYLE_DEFAULTS" value="KOTLIN_OFFICIAL" />
</JetCodeStyleSettings>
<codeStyleSettings language="XML">

View file

@ -1,6 +1,5 @@
<component name="ProjectCodeStyleConfiguration">
<state>
<option name="USE_PER_PROJECT_SETTINGS" value="true" />
<option name="PREFERRED_PROJECT_CODE_STYLE" value="Default" />
</state>
</component>

2
.idea/compiler.xml generated
View file

@ -3,4 +3,4 @@
<component name="CompilerConfiguration">
<bytecodeTargetLevel target="1.7" />
</component>
</project>
</project>

2
.idea/kotlinc.xml generated
View file

@ -3,4 +3,4 @@
<component name="KotlinJpsPluginSettings">
<option name="version" value="1.9.24" />
</component>
</project>
</project>

2
.idea/migrations.xml generated
View file

@ -7,4 +7,4 @@
</set>
</option>
</component>
</project>
</project>

1
.idea/vcs.xml generated
View file

@ -2,6 +2,7 @@
<project version="4">
<component name="VcsDirectoryMappings">
<mapping directory="$PROJECT_DIR$" vcs="Git" />
<mapping directory="$PROJECT_DIR$/libs/lpac-jni/src/main/jni/cjson/cjson" vcs="Git" />
<mapping directory="$PROJECT_DIR$/libs/lpac-jni/src/main/jni/lpac" vcs="Git" />
</component>
</project>

114
README.md
View file

@ -2,29 +2,53 @@
A fully free and open-source Local Profile Assistant implementation for Android devices.
There are two variants of this project, OpenEUICC and EasyEUICC:
There are two variants of this project, OpenEUICC and [EasyEUICC](https://easyeuicc.org):
| | OpenEUICC | EasyEUICC |
|:------------------------------|:-----------------------------------------------:|:-----------------:|
| Privileged | Must be installed as system app | No |
| Internal eSIM | Supported | Unsupported |
| External (Removable) eSIM | Supported | Supported |
| USB Readers | Supported | Supported |
| Requires allowlisting by eSIM | No | Yes -- except USB |
| System Integration | Partial (carrier partner API unimplemented yet) | No |
| | OpenEUICC | EasyEUICC |
|:------------------------------|:-------------------------------:|:-------------------:|
| Privileged | Must be installed as system app | No |
| Internal eSIM | Supported | Unsupported |
| External eSIM [^1] | Supported | Supported |
| USB Readers | Supported | Supported |
| Requires allowlisting by eSIM | No | Yes -- except USB |
| System Integration | Partial [^2] | No |
| Minimum Android Version | Android 11 or higher | Android 9 or higher |
[^1]: Also known as "Removable eSIM"
[^2]: Carrier Partner API unimplemented yet
Some side notes:
1. When privileged, OpenEUICC supports any eUICC chip that implements the SGP.22 standard, internal or external. However, there is __no guarantee__ that external (removable) eSIMs actually follow the standard. Please __DO NOT__ submit bug reports for non-functioning removable eSIMs. They are __NOT__ officially supported unless they also support / are supported by EasyEUICC, the unprivileged variant.
2. Both variants support accessing eUICC chips through USB CCID readers, regardless of whether the chip contains the correct ARA-M hash to allow for unprivileged access. However, only `T=0` readers that use the standard [USB CCID protocol](https://en.wikipedia.org/wiki/CCID_(protocol)) are supported.
3. Prebuilt release-mode EasyEUICC apks can be downloaded [here](https://gitea.angry.im/PeterCxy/OpenEUICC/releases). For OpenEUICC, no official release is currently provided and only debug mode APKs and Magisk modules can be found in the [CI page](https://gitea.angry.im/PeterCxy/OpenEUICC/actions).
4. For removable eSIM chip vendors: to have your chip supported by official builds of EasyEUICC when inserted, include the ARA-M hash `2A2FA878BC7C3354C2CF82935A5945A3EDAE4AFA`.
__This project is Free Software licensed under GNU GPL v3, WITHOUT the "or later" clause.__ Any modification and derivative work __MUST__ be released under the SAME license, which means, at the very least, that the source code __MUST__ be available upon request.
1. When privileged, OpenEUICC supports any eUICC chip that implements the [SGP.22] standard, internal or external.
However, there is **no guarantee** that external (removable) eSIMs actually follow the standard.
Please **DO NOT** submit bug reports for non-functioning removable eSIMs.
They are **NOT** officially supported unless they also support / are supported by EasyEUICC, the unprivileged
variant.
2. Both variants support accessing eUICC chips through USB CCID readers,
regardless of whether the chip contains the correct ARA-M hash to allow for unprivileged access.
However, only `T=0` readers that use the standard [USB CCID protocol][usb-ccid] are supported.
3. Prebuilt release-mode EasyEUICC apks can be downloaded [here][releases].
For OpenEUICC, no official release is currently provided and only debug mode APKs and Magisk modules can be found in
the [CI page][actions].
4. For removable eSIM chip vendors: to have your chip supported by official builds of EasyEUICC when inserted,
include the ARA-M hash `2A2FA878BC7C3354C2CF82935A5945A3EDAE4AFA`.
__If you are releasing a modification of this app, you are kindly asked to make changes to at least the app name and package name.__
[sgp.22]: https://www.gsma.com/solutions-and-impact/technologies/esim/gsma_resources/sgp-22-v2-2-2/ "SGP.22 v2.2.2"
Building (Gradle)
===
[usb-ccid]: https://en.wikipedia.org/wiki/CCID_%28protocol%29 "USB CCID Protocol"
[releases]: https://gitea.angry.im/PeterCxy/OpenEUICC/releases "EasyEUICC Releases"
[actions]: https://gitea.angry.im/PeterCxy/OpenEUICC/actions "OpenEUICC Actions"
**This project is Free Software licensed under GNU GPL v3, WITHOUT the "or later" clause.**
Any modification and derivative work **MUST** be released under the SAME license, which means, at the very least, that
the source code **MUST** be available upon request.
**If you are releasing a modification of this app, you are kindly asked to make changes to at least the app name and
package name.**
# Building (Gradle)
Make sure you have all submodules cloned and updated by running
@ -57,38 +81,50 @@ For EasyEUICC:
./gradlew :app-unpriv:assembleRelease
```
Building (AOSP)
===
# Building (AOSP)
There are two ways to include OpenEUICC in your AOSP-based system image:
1. Include this project and its [dependencies](https://gitea.angry.im/PeterCxy/android_prebuilts_openeuicc-deps) inside the AOSP tree.
- If inclusion in `manifest.xml` is required, remember to set the `sync-s` option to clone submodules.
- The module name is `OpenEUICC`. You can include it in `PRODUCT_PACKAGES`, or simply build it standalone using `mm`.
- Compilation of this project is **only** tested against the latest AOSP release version. The app itself should be compatible with older AOSP versions, but the source may not compile against an older AOSP source tree.
2. If compilation against AOSP source tree is not possible, consider [building with gradle](#building-gradle) and import the apk as a prebuilt.
- No official `Android.bp` is provided for this case but it should be straightforward to write.
- You might want to include `privapp_whitelist_im.angry.openeuicc.xml` as well.
1. Include this project and its [dependencies](https://gitea.angry.im/PeterCxy/android_prebuilts_openeuicc-deps) inside
the AOSP tree.
FAQs
===
- If inclusion in `manifest.xml` is required, remember to set the `sync-s` option to clone submodules.
- The module name is `OpenEUICC`. You can include it in `PRODUCT_PACKAGES`, or simply build it standalone using `mm`.
- Compilation of this project is **only** tested against the latest AOSP release version. The app itself should be
compatible with older AOSP versions, but the source may not compile against an older AOSP source tree.
- Q: Do you provide prebuilt binaries for OpenEUICC?
- A: Debug-mode APKs and Magisk modules are available continuously as an artifact of the [Actions](https://gitea.angry.im/PeterCxy/OpenEUICC/actions) CI used by this project. However, these debug-mode APKs are **not** intended for inclusion inside system images, nor are they supported by the developer in any sense. If you are a custom ROM developer, either include the entire OpenEUICC repository in your AOSP source tree, or generate an APK using `gradle` and import that as a prebuilt system app. Note that you might want `privapp_whitelist_im.angry.openeuicc.xml` as well.
2. If compilation against AOSP source tree is not possible, consider [building with gradle](#building-gradle) and import
the apk as a prebuilt.
- Q: Can EasyEUICC manage my phone's internal eSIM?
- A: No. For EasyEUICC to work, the eSIM chip MUST proactively grant access via its ARA-M field.
- No official `Android.bp` is provided for this case but it should be straightforward to write.
- You might want to include [`privapp_whitelist_im.angry.openeuicc.xml`] as well.
- Q: Removable eSIMs? Are they a joke?
- A: No, even though the name "removable embedded SIM" can sound like an oxymoron. In fact, there can be many advantages to these chips compared to fully embedded ones. For example, the ability to transfer eSIM profiles without carrier support or approval, or the ability to use eSIM on devices that do not and may never get the support, such as Wi-Fi hotspots.
[`privapp_whitelist_im.angry.openeuicc.xml`]: privapp_whitelist_im.angry.openeuicc.xml "OpenEUICC Privapp Whitelist"
Copyright
===
# FAQs
- Q: Do you provide prebuilt binaries for OpenEUICC? \
A: Debug-mode APKs and Magisk modules are available continuously as an artifact of the [Actions] CI used by this
project. However, these debug-mode APKs are **not** intended for inclusion inside system images, nor are they
supported by the developer in any sense. If you are a custom ROM developer, either include the entire OpenEUICC
repository in your AOSP source tree, or generate an APK using `gradle` and import that as a prebuilt system app. Note
that you might want [`privapp_whitelist_im.angry.openeuicc.xml`] as well.
- Q: Can EasyEUICC manage my phone's internal eSIM? \
A: No. For EasyEUICC to work, the eSIM chip MUST proactively grant access via its ARA-M field.
- Q: Removable eSIMs? Are they a joke? \
A: No, even though the name "removable embedded SIM" can sound like an oxymoron. In fact, there can be many advantages
to these chips compared to fully embedded ones. For example, the ability to transfer eSIM profiles without carrier
support or approval, or the ability to use eSIM on devices that do not and may never get the support, such as Wi-Fi
hotspots.
# Copyright
Everything except `libs/lpac-jni` and `art/`:
```
Copyright 2022-2024 OpenEUICC contributors
Copyright 2022-2026 OpenEUICC contributors
This program is free software; you can redistribute it and/or
modify it under the terms of the GNU General Public License
@ -107,7 +143,7 @@ Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
`libs/lpac-jni`:
```
Copyright (C) 2022-2024 OpenEUICC contributiors
Copyright (C) 2022-2026 OpenEUICC contributiors
This library is free software; you can redistribute it and/or
modify it under the terms of the GNU Lesser General Public
@ -123,4 +159,4 @@ License along with this library; if not, write to the Free Software
Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA
```
`art/`: Courtesy of [Aikoyori](https://github.com/Aikoyori), CC NC-SA 4.0.
`art/`: Courtesy of [Aikoyori](https://github.com/Aikoyori), CC NC-SA 4.0.

View file

@ -1,13 +1,11 @@
package im.angry.openeuicc.common
import androidx.test.platform.app.InstrumentationRegistry
import androidx.test.ext.junit.runners.AndroidJUnit4
import androidx.test.platform.app.InstrumentationRegistry
import org.junit.Assert.assertEquals
import org.junit.Test
import org.junit.runner.RunWith
import org.junit.Assert.*
/**
* Instrumented test, which will execute on an Android device.
*
@ -21,4 +19,4 @@ class ExampleInstrumentedTest {
val appContext = InstrumentationRegistry.getInstrumentation().targetContext
assertEquals("im.angry.openeuicc.common.test", appContext.packageName)
}
}
}

View file

@ -1,6 +1,6 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:tools="http://schemas.android.com/tools"
xmlns:android="http://schemas.android.com/apk/res/android"
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools"
package="im.angry.openeuicc.common">
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
@ -33,8 +33,8 @@
android:label="@string/isdr_aid_list" />
<activity
android:exported="true"
android:name="im.angry.openeuicc.ui.wizard.DownloadWizardActivity"
android:exported="true"
android:label="@string/download_wizard">
<intent-filter>
<action android:name="android.intent.action.VIEW" />
@ -46,14 +46,16 @@
<!-- for example: "LPA:1$..." -->
<!-- refs: https://www.iana.org/assignments/uri-schemes/prov/lpa -->
<data android:scheme="lpa" />
<data android:scheme="LPA" tools:ignore="AppLinkUrlError" />
<data
android:scheme="LPA"
tools:ignore="AppLinkUrlError" />
<data android:sspPrefix="1$" />
</intent-filter>
</activity>
<activity-alias
android:exported="true"
android:name="im.angry.openeuicc.ui.DirectProfileDownloadActivity"
android:exported="true"
android:targetActivity="im.angry.openeuicc.ui.wizard.DownloadWizardActivity" />
<activity
@ -63,7 +65,7 @@
<service
android:name="im.angry.openeuicc.service.EuiccChannelManagerService"
android:foregroundServiceType="shortService"
android:exported="false" />
android:exported="false"
android:foregroundServiceType="shortService" />
</application>
</manifest>

View file

@ -7,7 +7,6 @@ import im.angry.openeuicc.common.R
import im.angry.openeuicc.core.usb.UsbApduInterface
import im.angry.openeuicc.core.usb.UsbCcidContext
import im.angry.openeuicc.util.*
import java.lang.IllegalArgumentException
open class DefaultEuiccChannelFactory(protected val context: Context) : EuiccChannelFactory {
private var seService: SEService? = null
@ -20,7 +19,8 @@ open class DefaultEuiccChannelFactory(protected val context: Context) : EuiccCha
override suspend fun tryOpenEuiccChannel(
port: UiccPortInfoCompat,
isdrAid: ByteArray
isdrAid: ByteArray,
seId: EuiccChannel.SecureElementId,
): EuiccChannel? = try {
if (port.portIndex != 0) {
Log.w(
@ -38,13 +38,13 @@ open class DefaultEuiccChannelFactory(protected val context: Context) : EuiccCha
EuiccChannelImpl(
context.getString(R.string.channel_type_omapi),
port,
intrinsicChannelName = null,
OmapiApduInterface(
seService!!,
port,
context.preferenceRepository.verboseLoggingFlow
),
isdrAid,
seId,
context.preferenceRepository.verboseLoggingFlow,
context.preferenceRepository.ignoreTLSCertificateFlow,
context.preferenceRepository.es10xMssFlow,
@ -60,16 +60,17 @@ open class DefaultEuiccChannelFactory(protected val context: Context) : EuiccCha
override fun tryOpenUsbEuiccChannel(
ccidCtx: UsbCcidContext,
isdrAid: ByteArray
isdrAid: ByteArray,
seId: EuiccChannel.SecureElementId
): EuiccChannel? = try {
EuiccChannelImpl(
context.getString(R.string.channel_type_usb),
FakeUiccPortInfoCompat(FakeUiccCardInfoCompat(EuiccChannelManager.USB_CHANNEL_ID)),
intrinsicChannelName = ccidCtx.productName,
UsbApduInterface(
ccidCtx
),
isdrAid,
seId,
context.preferenceRepository.verboseLoggingFlow,
context.preferenceRepository.ignoreTLSCertificateFlow,
context.preferenceRepository.es10xMssFlow,
@ -87,4 +88,4 @@ open class DefaultEuiccChannelFactory(protected val context: Context) : EuiccCha
seService?.shutdown()
seService = null
}
}
}

View file

@ -6,8 +6,8 @@ import android.hardware.usb.UsbManager
import android.telephony.SubscriptionManager
import android.util.Log
import im.angry.openeuicc.core.usb.UsbCcidContext
import im.angry.openeuicc.core.usb.smartCard
import im.angry.openeuicc.core.usb.interfaces
import im.angry.openeuicc.core.usb.smartCard
import im.angry.openeuicc.di.AppContainer
import im.angry.openeuicc.util.*
import kotlinx.coroutines.Dispatchers
@ -32,7 +32,7 @@ open class DefaultEuiccChannelManager(
private val channelCache = mutableListOf<EuiccChannel>()
private var usbChannel: EuiccChannel? = null
private var usbChannels = mutableListOf<EuiccChannel>()
private val lock = Mutex()
@ -51,43 +51,94 @@ open class DefaultEuiccChannelManager(
protected open val uiccCards: Collection<UiccCardInfoCompat>
get() = (0..<tm.activeModemCountCompat).map { FakeUiccCardInfoCompat(it) }
private suspend inline fun tryOpenChannelFirstValidAid(openFn: (ByteArray) -> EuiccChannel?): EuiccChannel? {
val isdrAidList =
private suspend inline fun tryOpenChannelWithKnownAids(
openFn: (ByteArray, EuiccChannel.SecureElementId) -> EuiccChannel?
): List<EuiccChannel> {
var isdrAidList =
parseIsdrAidList(appContainer.preferenceRepository.isdrAidListFlow.first())
val ret = mutableListOf<EuiccChannel>()
val openedAids = mutableListOf<ByteArray>()
var hasReset = false
var vendorDecider: VendorAidDecider? = null
var seId = 0
return isdrAidList.firstNotNullOfOrNull {
Log.i(TAG, "Opening channel, trying ISDR AID ${it.encodeHex()}")
outer@ while (true) {
for (aid in isdrAidList) {
if (vendorDecider != null && !vendorDecider.shouldOpenMore(openedAids, aid)) {
break@outer
}
openFn(it)?.let { channel ->
if (channel.valid) {
channel
} else {
channel.close()
null
val channel =
openFn(aid, EuiccChannel.SecureElementId.createFromInt(seId))?.let { channel ->
if (channel.valid) {
seId += 1
channel
} else {
channel.close()
null
}
}
if (!hasReset) {
val res = channel?.queryVendorAidListTransformation(isdrAidList)
if (res != null) {
// Reset the for loop since we needed to replace the AID list due to vendor-specific code
Log.i(TAG, "AID list replaced, resetting open attempt")
isdrAidList = res.first
vendorDecider = res.second
seId = 0
ret.clear()
openedAids.clear()
channel.close()
hasReset = true // Don't let anything reset again
continue@outer
}
}
if (channel != null) {
ret.add(channel)
openedAids.add(aid)
// Don't try opening more than 1 channel unless there is a vendor
// implementation for deciding when we should stop opening more channels
if (vendorDecider == null) {
break@outer
}
}
}
// If we get here we should exit, since the inner loop completed without resetting
break
}
// Set the hasMultipleSE field now since we only get to know that after we have iterated all AIDs
// This also flips a flag in EuiccChannelImpl and prevents the field from being set again
ret.forEach { it.hasMultipleSE = (seId > 1) }
return ret
}
private suspend fun tryOpenEuiccChannel(port: UiccPortInfoCompat): EuiccChannel? {
private suspend fun tryOpenEuiccChannel(
port: UiccPortInfoCompat,
): List<EuiccChannel>? {
lock.withLock {
if (port.card.physicalSlotIndex == EuiccChannelManager.USB_CHANNEL_ID) {
return if (usbChannel != null && usbChannel!!.valid) {
usbChannel
} else {
usbChannel = null
null
}
return usbChannels
}
// First get all channels for the requested port
val existing =
channelCache.find { it.slotId == port.card.physicalSlotIndex && it.portId == port.portIndex }
if (existing != null) {
if (existing.valid && port.logicalSlotIndex == existing.logicalSlotId) {
channelCache.filter { it.slotId == port.card.physicalSlotIndex && it.portId == port.portIndex }
if (existing.isNotEmpty()) {
if (existing.all { it.valid && it.logicalSlotId == port.logicalSlotIndex }) {
return existing
} else {
existing.close()
channelCache.remove(existing)
// If any channel shouldn't be considered valid anymore, close all existing for the same slot / port
// and reopen
existing.forEach {
it.close()
channelCache.remove(it)
}
}
}
@ -96,12 +147,19 @@ open class DefaultEuiccChannelManager(
return null
}
val channel =
tryOpenChannelFirstValidAid { euiccChannelFactory.tryOpenEuiccChannel(port, it) }
// This function is not responsible for managing USB channels (see the initial check)
val channels =
tryOpenChannelWithKnownAids { isdrAid, seId ->
euiccChannelFactory.tryOpenEuiccChannel(
port,
isdrAid,
seId
)
}
if (channel != null) {
channelCache.add(channel)
return channel
if (channels.isNotEmpty()) {
channelCache.addAll(channels)
return channels
} else {
Log.i(
TAG,
@ -112,16 +170,19 @@ open class DefaultEuiccChannelManager(
}
}
protected suspend fun findEuiccChannelByLogicalSlot(logicalSlotId: Int): EuiccChannel? =
protected suspend fun findEuiccChannelByLogicalSlot(
logicalSlotId: Int,
seId: EuiccChannel.SecureElementId
): EuiccChannel? =
withContext(Dispatchers.IO) {
if (logicalSlotId == EuiccChannelManager.USB_CHANNEL_ID) {
return@withContext usbChannel
return@withContext usbChannels.find { it.seId == seId }
}
for (card in uiccCards) {
for (port in card.ports) {
if (port.logicalSlotIndex == logicalSlotId) {
return@withContext tryOpenEuiccChannel(port)
return@withContext tryOpenEuiccChannel(port)?.find { it.seId == seId }
}
}
}
@ -129,23 +190,35 @@ open class DefaultEuiccChannelManager(
null
}
/**
* Find all EuiccChannels associated with a _physical_ slot, including all secure elements
* on cards with multiple of them.
*/
private suspend fun findAllEuiccChannelsByPhysicalSlot(physicalSlotId: Int): List<EuiccChannel>? {
if (physicalSlotId == EuiccChannelManager.USB_CHANNEL_ID) {
return usbChannel?.let { listOf(it) }
return usbChannels.ifEmpty { null }
}
for (card in uiccCards) {
if (card.physicalSlotIndex != physicalSlotId) continue
return card.ports.mapNotNull { tryOpenEuiccChannel(it) }
.flatten()
.ifEmpty { null }
}
return null
}
private suspend fun findEuiccChannelByPort(physicalSlotId: Int, portId: Int): EuiccChannel? =
/**
* Finds all EuiccChannels associated with a physical slot + port. Note that this
* may return multiple in case there are multiple SEs.
*/
private suspend fun findEuiccChannelsByPort(
physicalSlotId: Int,
portId: Int,
): List<EuiccChannel>? =
withContext(Dispatchers.IO) {
if (physicalSlotId == EuiccChannelManager.USB_CHANNEL_ID) {
return@withContext usbChannel
return@withContext usbChannels.ifEmpty { null }
}
uiccCards.find { it.physicalSlotIndex == physicalSlotId }?.let { card ->
@ -168,15 +241,17 @@ open class DefaultEuiccChannelManager(
return@withContext listOf(0)
}
findAllEuiccChannelsByPhysicalSlot(physicalSlotId)?.map { it.portId } ?: listOf()
findAllEuiccChannelsByPhysicalSlot(physicalSlotId)?.map { it.portId }?.toSet()?.toList()
?: listOf()
}
override suspend fun <R> withEuiccChannel(
physicalSlotId: Int,
portId: Int,
seId: EuiccChannel.SecureElementId,
fn: suspend (EuiccChannel) -> R
): R {
val channel = findEuiccChannelByPort(physicalSlotId, portId)
val channel = findEuiccChannelsByPort(physicalSlotId, portId)?.find { it.seId == seId }
?: throw EuiccChannelManager.EuiccChannelNotFoundException()
val wrapper = EuiccChannelWrapper(channel)
try {
@ -190,9 +265,10 @@ open class DefaultEuiccChannelManager(
override suspend fun <R> withEuiccChannel(
logicalSlotId: Int,
seId: EuiccChannel.SecureElementId,
fn: suspend (EuiccChannel) -> R
): R {
val channel = findEuiccChannelByLogicalSlot(logicalSlotId)
val channel = findEuiccChannelByLogicalSlot(logicalSlotId, seId)
?: throw EuiccChannelManager.EuiccChannelNotFoundException()
val wrapper = EuiccChannelWrapper(channel)
try {
@ -205,37 +281,48 @@ open class DefaultEuiccChannelManager(
}
override suspend fun waitForReconnect(physicalSlotId: Int, portId: Int, timeoutMillis: Long) {
if (physicalSlotId == EuiccChannelManager.USB_CHANNEL_ID) {
usbChannel?.close()
usbChannel = null
val numChannelsBefore = if (physicalSlotId == EuiccChannelManager.USB_CHANNEL_ID) {
usbChannels.size
} 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()
// Don't use find* methods since they reopen channels if not found
channelCache.filter { it.slotId == physicalSlotId && it.portId == portId }.size
}
val resetChannels = {
if (physicalSlotId == EuiccChannelManager.USB_CHANNEL_ID) {
usbChannels.forEach { it.close() }
usbChannels.clear()
} else {
// If there is already a valid channel, we close it proactively
channelCache.filter { it.slotId == physicalSlotId && it.portId == portId }.forEach { it.close() }
}
}
resetChannels()
withTimeout(timeoutMillis) {
while (true) {
try {
val channel = if (physicalSlotId == EuiccChannelManager.USB_CHANNEL_ID) {
val channels = if (physicalSlotId == EuiccChannelManager.USB_CHANNEL_ID) {
// tryOpenUsbEuiccChannel() will always try to reopen the channel, even if
// a USB channel already exists
tryOpenUsbEuiccChannel()
usbChannel!!
usbChannels
} else {
// tryOpenEuiccChannel() will automatically dispose of invalid channels
// and recreate when needed
findEuiccChannelByPort(physicalSlotId, portId)!!
findEuiccChannelsByPort(physicalSlotId, portId)!!
}
check(channel.valid) { "Invalid channel" }
check(channels.isNotEmpty()) { "No channel" }
check(channels.all { it.valid }) { "Invalid channel" }
check(numChannelsBefore > 0 && channels.size >= numChannelsBefore) { "Less channels than before" }
break
} catch (e: Exception) {
Log.d(
TAG,
"Slot $physicalSlotId port $portId reconnect failure, retrying in 1000 ms"
)
resetChannels()
}
delay(1000)
}
@ -264,6 +351,15 @@ open class DefaultEuiccChannelManager(
}
})
override fun flowEuiccSecureElements(
slotId: Int,
portId: Int
): Flow<EuiccChannel.SecureElementId> = flow {
findEuiccChannelsByPort(slotId, portId)?.forEach {
emit(it.seId)
}
}
override suspend fun tryOpenUsbEuiccChannel(): Pair<UsbDevice?, Boolean> =
withContext(Dispatchers.IO) {
usbManager.deviceList.values.forEach { device ->
@ -277,15 +373,17 @@ open class DefaultEuiccChannelManager(
"Found CCID interface on ${device.deviceId}:${device.vendorId}, and has permission; trying to open channel"
)
val ccidCtx = UsbCcidContext.createFromUsbDevice(context, device, iface) ?: return@forEach
val ccidCtx =
UsbCcidContext.createFromUsbDevice(context, device, iface) ?: return@forEach
try {
val channel = tryOpenChannelFirstValidAid {
euiccChannelFactory.tryOpenUsbEuiccChannel(ccidCtx, it)
val channels = tryOpenChannelWithKnownAids { isdrAid, seId ->
euiccChannelFactory.tryOpenUsbEuiccChannel(ccidCtx, isdrAid, seId)
}
if (channel != null && channel.lpa.valid) {
if (channels.isNotEmpty() && channels[0].valid) {
ccidCtx.allowDisconnect = true
usbChannel = channel
usbChannels.clear()
usbChannels.addAll(channels)
return@withContext Pair(device, true)
}
} catch (e: Exception) {
@ -309,9 +407,9 @@ open class DefaultEuiccChannelManager(
channel.close()
}
usbChannel?.close()
usbChannel = null
usbChannels.forEach { it.close() }
usbChannels.clear()
channelCache.clear()
euiccChannelFactory.cleanup()
}
}
}

View file

@ -1,5 +1,7 @@
package im.angry.openeuicc.core
import android.os.Parcel
import android.os.Parcelable
import im.angry.openeuicc.util.*
import net.typeblog.lpac_jni.ApduInterface
import net.typeblog.lpac_jni.LocalProfileAssistant
@ -13,6 +15,67 @@ interface EuiccChannel {
val logicalSlotId: Int
val portId: Int
/**
* A semi-obscure wrapper over the integer ID of a secure element on a card.
*
* Because the ID is arbitrary, this is intended to discourage the use of the
* integer value directly. Additionally, it prevents accidentally calling the
* wrong function in EuiccChannelManager with a ton of integer parameters.
*/
class SecureElementId private constructor(val id: Int) : Parcelable {
companion object {
val DEFAULT = SecureElementId(0)
/**
* Create a SecureElementId from an integer ID. You should not call this directly
* unless you know what you're doing.
*
* This is currently only ever used in the download flow.
*/
fun createFromInt(id: Int): SecureElementId =
SecureElementId(id)
@Suppress("unused")
@JvmField
val CREATOR = object : Parcelable.Creator<SecureElementId> {
override fun createFromParcel(parcel: Parcel): SecureElementId =
createFromInt(parcel.readInt())
override fun newArray(size: Int): Array<SecureElementId?> = arrayOfNulls(size)
}
}
override fun hashCode(): Int =
id.hashCode()
override fun equals(other: Any?): Boolean =
if (other is SecureElementId) {
this.id == other.id
} else {
super.equals(other)
}
override fun describeContents(): Int = id
override fun writeToParcel(parcel: Parcel, flags: Int) {
parcel.writeInt(id)
}
}
/**
* Some chips support multiple SEs on one chip. The seId here is intended
* to distinguish channels opened from these different SEs.
*/
val seId: SecureElementId
/**
* Does this channel belong to a chip that supports multiple SEs?
* Note that this is only made `var` to make initialization a bit less annoying --
* this should never be set again after the channel is originally opened.
* Attempting to do so will yield an exception.
*/
var hasMultipleSE: Boolean
val lpa: LocalProfileAssistant
val valid: Boolean
@ -22,13 +85,6 @@ interface EuiccChannel {
*/
val atr: ByteArray?
/**
* Intrinsic name of this channel. For device-internal SIM slots,
* this should be null; for USB readers, this should be the name of
* the reader device.
*/
val intrinsicChannelName: String?
/**
* The underlying APDU interface for this channel
*/
@ -40,4 +96,4 @@ interface EuiccChannel {
val isdrAid: ByteArray
fun close()
}
}

View file

@ -6,11 +6,16 @@ import im.angry.openeuicc.util.*
// This class is here instead of inside DI because it contains a bit more logic than just
// "dumb" dependency injection.
interface EuiccChannelFactory {
suspend fun tryOpenEuiccChannel(port: UiccPortInfoCompat, isdrAid: ByteArray): EuiccChannel?
suspend fun tryOpenEuiccChannel(
port: UiccPortInfoCompat,
isdrAid: ByteArray,
seId: EuiccChannel.SecureElementId
): EuiccChannel?
fun tryOpenUsbEuiccChannel(
ccidCtx: UsbCcidContext,
isdrAid: ByteArray
isdrAid: ByteArray,
seId: EuiccChannel.SecureElementId
): EuiccChannel?
/**
@ -19,4 +24,4 @@ interface EuiccChannelFactory {
* re-acquired when this happens
*/
fun cleanup()
}
}

View file

@ -1,6 +1,6 @@
package im.angry.openeuicc.core
import im.angry.openeuicc.util.UiccPortInfoCompat
import im.angry.openeuicc.util.*
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.runBlocking
@ -12,9 +12,9 @@ import net.typeblog.lpac_jni.impl.LocalProfileAssistantImpl
class EuiccChannelImpl(
override val type: String,
override val port: UiccPortInfoCompat,
override val intrinsicChannelName: String?,
override val apduInterface: ApduInterface,
override val isdrAid: ByteArray,
override val seId: EuiccChannel.SecureElementId,
verboseLoggingFlow: Flow<Boolean>,
ignoreTLSCertificateFlow: Flow<Boolean>,
es10xMssFlow: Flow<Int>,
@ -38,5 +38,16 @@ class EuiccChannelImpl(
override val valid: Boolean
get() = lpa.valid
private var hasMultipleSEInitialized = false
override var hasMultipleSE: Boolean = false
set(value) {
if (hasMultipleSEInitialized) {
throw IllegalStateException("already initialized")
}
field = value
}
override fun close() = lpa.close()
}

View file

@ -10,7 +10,7 @@ import kotlinx.coroutines.flow.Flow
* or when this instance is destroyed.
*
* To precisely control the lifecycle of this object itself (and thus its cached channels),
* all other compoents must access EuiccChannelManager objects through EuiccChannelManagerService.
* all other components must access EuiccChannelManager objects through EuiccChannelManagerService.
* Holding references independent of EuiccChannelManagerService is unsupported.
*/
interface EuiccChannelManager {
@ -37,6 +37,14 @@ interface EuiccChannelManager {
*/
fun flowAllOpenEuiccPorts(): Flow<Pair<Int, Int>>
/**
* Iterate over all the Secure Elements available on one eUICC.
*
* This is going to almost always return only 1 result, except in the case where
* a card has multiple SEs.
*/
fun flowEuiccSecureElements(slotId: Int, portId: Int): Flow<EuiccChannel.SecureElementId>
/**
* Scan all possible USB devices for CCID readers that may contain eUICC cards.
* If found, try to open it for access, and add it to the internal EuiccChannel cache
@ -67,7 +75,7 @@ interface EuiccChannelManager {
*/
suspend fun findAvailablePorts(physicalSlotId: Int): List<Int>
class EuiccChannelNotFoundException: Exception("EuiccChannel not found")
class EuiccChannelNotFoundException : Exception("EuiccChannel not found")
/**
* Find a EuiccChannel by its slot and port, then run a callback with a reference to it.
@ -78,19 +86,12 @@ interface EuiccChannelManager {
*
* If a channel for that slot / port is not found, EuiccChannelNotFoundException is thrown
*/
suspend fun <R> withEuiccChannel(
physicalSlotId: Int,
portId: Int,
fn: suspend (EuiccChannel) -> R
): R
suspend fun <R> withEuiccChannel(physicalSlotId: Int, portId: Int, seId: EuiccChannel.SecureElementId, fn: suspend (EuiccChannel) -> R): R
/**
* Same as withEuiccChannel(Int, Int, (EuiccChannel) -> R) but instead uses logical slot ID
* Same as withEuiccChannel(Int, Int, SecureElementId, (EuiccChannel) -> R) but instead uses logical slot ID
*/
suspend fun <R> withEuiccChannel(
logicalSlotId: Int,
fn: suspend (EuiccChannel) -> R
): R
suspend fun <R> withEuiccChannel(logicalSlotId: Int, seId: EuiccChannel.SecureElementId, fn: suspend (EuiccChannel) -> R): R
/**
* Invalidate all EuiccChannels previously cached by this Manager
@ -105,4 +106,4 @@ interface EuiccChannelManager {
suspend fun notifyEuiccProfilesChanged(logicalSlotId: Int) {
// no-op by default
}
}
}

View file

@ -26,20 +26,25 @@ class EuiccChannelWrapper(orig: EuiccChannel) : EuiccChannel {
get() = channel.logicalSlotId
override val portId: Int
get() = channel.portId
override val seId: EuiccChannel.SecureElementId
get() = channel.seId
private val lpaDelegate = lazy {
LocalProfileAssistantWrapper(channel.lpa)
}
override val lpa: LocalProfileAssistant by lpaDelegate
override val valid: Boolean
get() = channel.valid
override val intrinsicChannelName: String?
get() = channel.intrinsicChannelName
override val apduInterface: ApduInterface
get() = channel.apduInterface
override val atr: ByteArray?
get() = channel.atr
override val isdrAid: ByteArray
get() = channel.isdrAid
override var hasMultipleSE: Boolean
get() = channel.hasMultipleSE
set(value) {
channel.hasMultipleSE = value
}
override fun close() = channel.close()
@ -50,4 +55,4 @@ class EuiccChannelWrapper(orig: EuiccChannel) : EuiccChannel {
(lpa as LocalProfileAssistantWrapper).invalidateWrapper()
}
}
}
}

View file

@ -1,5 +1,6 @@
package im.angry.openeuicc.core
import net.typeblog.lpac_jni.ProfileDownloadInput
import net.typeblog.lpac_jni.EuiccInfo2
import net.typeblog.lpac_jni.LocalProfileAssistant
import net.typeblog.lpac_jni.LocalProfileInfo
@ -40,13 +41,8 @@ class LocalProfileAssistantWrapper(orig: LocalProfileAssistant) :
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 downloadProfile(input: ProfileDownloadInput, callback: ProfileDownloadCallback) =
lpa.downloadProfile(input, callback)
override fun deleteNotification(seqNumber: Long): Boolean = lpa.deleteNotification(seqNumber)
@ -63,4 +59,4 @@ class LocalProfileAssistantWrapper(orig: LocalProfileAssistant) :
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, ApduInterfaceAtrProvider {
) : ApduInterface, ApduInterfaceAtrProvider {
companion object {
const val TAG = "OmapiApduInterface"
}
@ -83,4 +83,4 @@ class OmapiApduInterface(
throw e
}
}
}
}

View file

@ -2,7 +2,8 @@ package im.angry.openeuicc.core.usb
import android.util.Log
import im.angry.openeuicc.core.ApduInterfaceAtrProvider
import im.angry.openeuicc.util.*
import im.angry.openeuicc.util.decodeHex
import im.angry.openeuicc.util.encodeHex
import net.typeblog.lpac_jni.ApduInterface
class UsbApduInterface(
@ -20,9 +21,70 @@ class UsbApduInterface(
private var channels = mutableSetOf<Int>()
// ATR parser
// Specs: ISO/IEC 7816-3:2006 8.2 Answer-to-Reset
// See also: https://en.wikipedia.org/wiki/Answer_to_reset
class ParsedAtr private constructor(val ts: Byte?, val t0: Byte?, val ta1: Byte?, val tb1: Byte?, val tc1: Byte?, val td1: Byte?, val ta2: Byte?, val tb2: Byte?, val tc2: Byte?, val td2: Byte?) {
companion object {
fun parse(atr: ByteArray): ParsedAtr {
val ts = atr[0]
val t0 = atr[1]
val tx1 = arrayOf<Byte?>(null, null, null, null)
val tx2 = arrayOf<Byte?>(null, null, null, null)
var pointer = 2
for (i in 0..3) {
if (t0.toInt() and (0x10 shl i) != 0) {
tx1[i] = atr[pointer]
pointer++
}
}
val td1 = tx1[3] ?: 0
for (i in 0..3) {
if (td1.toInt() and (0x10 shl i) != 0) {
tx2[i] = atr[pointer]
pointer++
}
}
return ParsedAtr(ts=ts, t0=t0, ta1=tx1[0], tb1=tx1[1], tc1=tx1[2], td1=tx1[3],
ta2=tx2[0], tb2=tx2[1], tc2=tx2[2], td2=tx2[3],
)
}
}
}
override fun connect() {
ccidCtx.connect()
if (ccidCtx.transceiver.isTpdu) {
// Send parameter selection
// Specs: USB-CCID 3.2.1 TPDU level of exchange
val parsedAtr = ParsedAtr.parse(atr!!)
val ta1 = parsedAtr.ta1 ?: 0x11.toByte()
val pts1 = ta1 // TODO: Check that reader supports baud rate proposed by the card
val pps = byteArrayOf(0xff.toByte(), 0x10.toByte(), pts1, 0x00.toByte())
Log.d(TAG, "PTS1=${pts1} PPS: ${pps.encodeHex()}")
ccidCtx.transceiver.sendXfrBlock(pps)
// Send Set Parameters
// Specs: USB-CCID 6.1.7 PC_to_RDR_SetParameters
val param = byteArrayOf(
pts1,
(if (parsedAtr.ts == 0x3F.toByte()) 0x02 else 0x00),
parsedAtr.tc1 ?: 0,
parsedAtr.tc2 ?: 0x0A,
0x00
)
Log.d(TAG, "Param: ${param.encodeHex()}")
ccidCtx.transceiver.sendParamBlock(param)
}
// Send Terminal Capabilities
// Specs: ETSI TS 102 221 v15.0.0 - 11.1.19 TERMINAL CAPABILITY
val terminalCapabilities = buildCmd(
@ -53,6 +115,7 @@ class UsbApduInterface(
val channelId = resp[0].toInt()
Log.d(TAG, "channelId = $channelId")
channels.add(channelId)
// Then, select AID
val selectAid = selectByDfCmd(aid, channelId.toByte())
@ -60,11 +123,11 @@ class UsbApduInterface(
if (!isSuccessResponse(selectAidResp)) {
Log.d(TAG, "Select DF failed : ${selectAidResp.encodeHex()}")
logicalChannelClose(channelId)
Log.d(TAG, "Closed logical channel $channelId due to select DF failure")
return -1
}
channels.add(channelId)
return channelId
}
@ -154,4 +217,4 @@ class UsbApduInterface(
return resp
}
}
}

View file

@ -20,7 +20,6 @@ class UsbCcidContext private constructor(
private val conn: UsbDeviceConnection,
private val bulkIn: UsbEndpoint,
private val bulkOut: UsbEndpoint,
val productName: String,
val verboseLoggingFlow: Flow<Boolean>
) {
companion object {
@ -38,7 +37,6 @@ class UsbCcidContext private constructor(
conn,
bulkIn,
bulkOut,
usbDevice.productName ?: "USB",
context.preferenceRepository.verboseLoggingFlow
)
}.getOrNull()

View file

@ -84,6 +84,8 @@ data class UsbCcidDescription(
private fun hasFeature(feature: Int) = (dwFeatures and feature) != 0
val isTpdu = hasFeature(0x10000)
val voltages: List<Voltage>
get() {
if (hasFeature(FEATURE_AUTOMATIC_VOLTAGE)) return listOf(Voltage.AUTO)
@ -95,4 +97,4 @@ data class UsbCcidDescription(
val hasT0Protocol: Boolean
get() = (dwProtocols and MASK_T0_PROTO) != 0
}
}

View file

@ -4,7 +4,7 @@ import android.hardware.usb.UsbDeviceConnection
import android.hardware.usb.UsbEndpoint
import android.os.SystemClock
import android.util.Log
import im.angry.openeuicc.util.*
import im.angry.openeuicc.util.encodeHex
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.runBlocking
@ -143,6 +143,8 @@ class UsbCcidTransceiver(
val hasAutomaticPps = usbCcidDescription.hasAutomaticPps
val isTpdu = usbCcidDescription.isTpdu
private val inputBuffer = ByteArray(usbBulkIn.maxPacketSize)
private var currentSequenceNumber: Byte = 0
@ -158,6 +160,46 @@ class UsbCcidTransceiver(
}
}
private fun receiveParamBlock(expectedSequenceNumber: Byte): ByteArray {
var response: ByteArray?
do {
response = receiveParamBlockImmediate(expectedSequenceNumber)
} while (response!![7] == 0x80.toByte())
return response
}
private fun receiveParamBlockImmediate(expectedSequenceNumber: Byte): ByteArray {
/*
* Some USB CCID devices (notably NitroKey 3) may time-out and need a subsequent poke to
* carry on communications. No particular reason why the number 3 was chosen. If we get a
* zero-sized reply (or a time-out), we try again. Clamped retries prevent an infinite loop
* if things really turn sour.
*/
var attempts = 3
Log.d(TAG, "Receive data block immediate seq=$expectedSequenceNumber")
var readBytes: Int
do {
readBytes = usbConnection.bulkTransfer(
usbBulkIn, inputBuffer, inputBuffer.size, DEVICE_COMMUNICATE_TIMEOUT_MILLIS
)
if (runBlocking { verboseLoggingFlow.first() }) {
Log.d(TAG, "Received $readBytes bytes: ${inputBuffer.encodeHex()}")
}
} while (readBytes <= 0 && attempts-- > 0)
if (inputBuffer[0] != 0x82.toByte()) {
throw UsbTransportException(buildString {
append("USB-CCID error - bad CCID header")
append(", type ")
append("%d (expected %d)".format(inputBuffer[0], MESSAGE_TYPE_RDR_TO_PC_DATA_BLOCK))
if (expectedSequenceNumber != inputBuffer[6]) {
append(", sequence number ")
append("%d (expected %d)".format(inputBuffer[6], expectedSequenceNumber))
}
})
}
return inputBuffer
}
private fun receiveDataBlock(expectedSequenceNumber: Byte): CcidDataBlock {
var response: CcidDataBlock?
do {
@ -283,6 +325,38 @@ class UsbCcidTransceiver(
return ccidDataBlock
}
fun sendParamBlock(
payload: ByteArray
): ByteArray {
val startTime = SystemClock.elapsedRealtime()
val l = payload.size
val sequenceNumber: Byte = currentSequenceNumber++
val headerData = byteArrayOf(
0x61.toByte(),
l.toByte(),
(l shr 8).toByte(),
(l shr 16).toByte(),
(l shr 24).toByte(),
SLOT_NUMBER.toByte(),
sequenceNumber,
0x00.toByte(),
0x00.toByte(),
0x00.toByte()
)
val data: ByteArray = headerData + payload
Log.d(TAG, "USB ParamBlock: ${data.encodeHex()}")
var sentBytes = 0
while (sentBytes < data.size) {
val bytesToSend = usbBulkOut.maxPacketSize.coerceAtMost(data.size - sentBytes)
sendRaw(data, sentBytes, bytesToSend)
sentBytes += bytesToSend
}
val ccidDataBlock = receiveParamBlock(sequenceNumber)
val elapsedTime = SystemClock.elapsedRealtime() - startTime
Log.d(TAG, "USB ParamBlock call took ${elapsedTime}ms")
return ccidDataBlock
}
fun iccPowerOn(): CcidDataBlock {
val startTime = SystemClock.elapsedRealtime()
skipAvailableInput()
@ -342,4 +416,4 @@ class UsbCcidTransceiver(
)
sendRaw(iccPowerCommand, 0, iccPowerCommand.size)
}
}
}

View file

@ -16,4 +16,4 @@ interface AppContainer {
val uiComponentFactory: UiComponentFactory
val euiccChannelFactory: EuiccChannelFactory
val customizableTextProvider: CustomizableTextProvider
}
}

View file

@ -1,5 +1,8 @@
package im.angry.openeuicc.di
import android.net.Uri
import im.angry.openeuicc.core.EuiccChannel
interface CustomizableTextProvider {
/**
* Explanation string for when no eUICC is found on the device.
@ -13,8 +16,18 @@ interface CustomizableTextProvider {
val profileSwitchingTimeoutMessage: String
/**
* Format the name of a logical slot; internal only -- not intended for
* other channels such as USB.
* Display the website link in settings; null if not available.
*/
fun formatInternalChannelName(logicalSlotId: Int): String
}
val websiteUri: Uri?
/**
* Format the name of a logical slot -- not for USB channels
*/
fun formatNonUsbChannelName(logicalSlotId: Int): String
/**
* Format the name of a logical slot with a SE ID, in case of multi-SE chips; currently
* this is used in the download flow to distinguish between them on the same chip.
*/
fun formatNonUsbChannelNameWithSeId(logicalSlotId: Int, seId: EuiccChannel.SecureElementId): String
}

View file

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

View file

@ -1,7 +1,9 @@
package im.angry.openeuicc.di
import android.content.Context
import android.net.Uri
import im.angry.openeuicc.common.R
import im.angry.openeuicc.core.EuiccChannel
open class DefaultCustomizableTextProvider(private val context: Context) : CustomizableTextProvider {
override val noEuiccExplanation: String
@ -10,6 +12,12 @@ open class DefaultCustomizableTextProvider(private val context: Context) : Custo
override val profileSwitchingTimeoutMessage: String
get() = context.getString(R.string.profile_switch_timeout)
override fun formatInternalChannelName(logicalSlotId: Int): String =
override val websiteUri: Uri?
get() = null
override fun formatNonUsbChannelName(logicalSlotId: Int): String =
context.getString(R.string.channel_name_format, logicalSlotId)
}
override fun formatNonUsbChannelNameWithSeId(logicalSlotId: Int, seId: EuiccChannel.SecureElementId): String =
context.getString(R.string.channel_name_format_se, logicalSlotId, seId.id)
}

View file

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

View file

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

View file

@ -12,11 +12,15 @@ import androidx.core.app.NotificationManagerCompat
import androidx.lifecycle.LifecycleService
import androidx.lifecycle.lifecycleScope
import im.angry.openeuicc.common.R
import im.angry.openeuicc.core.EuiccChannel
import im.angry.openeuicc.core.EuiccChannelManager
import im.angry.openeuicc.util.*
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.NonCancellable
import kotlinx.coroutines.TimeoutCancellationException
import kotlinx.coroutines.channels.BufferOverflow
import kotlinx.coroutines.channels.Channel
import kotlinx.coroutines.channels.trySendBlocking
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableSharedFlow
@ -33,10 +37,13 @@ import kotlinx.coroutines.flow.takeWhile
import kotlinx.coroutines.flow.transformWhile
import kotlinx.coroutines.isActive
import kotlinx.coroutines.launch
import kotlinx.coroutines.runBlocking
import kotlinx.coroutines.withContext
import kotlinx.coroutines.withTimeout
import kotlinx.coroutines.withTimeoutOrNull
import kotlinx.coroutines.yield
import net.typeblog.lpac_jni.ProfileDownloadCallback
import net.typeblog.lpac_jni.ProfileDownloadInput
import net.typeblog.lpac_jni.ProfileDownloadState
/**
* An Android Service wrapper for EuiccChannelManager.
@ -103,7 +110,7 @@ class EuiccChannelManagerService : LifecycleService(), OpenEuiccContextMarker {
*/
sealed interface ForegroundTaskState {
data object Idle : ForegroundTaskState
data class InProgress(val progress: Int) : ForegroundTaskState
data class InProgress(val progress: Int, val context: Any? = null) : ForegroundTaskState
data class Done(val error: Throwable?) : ForegroundTaskState
}
@ -378,32 +385,56 @@ class EuiccChannelManagerService : LifecycleService(), OpenEuiccContextMarker {
}
fun launchProfileDownloadTask(
slotId: Int,
portId: Int,
smdp: String,
matchingId: String?,
confirmationCode: String?,
imei: String?
slotId: Int, portId: Int, seId: EuiccChannel.SecureElementId,
input: ProfileDownloadInput,
// Optionally, a Channel to send confirmation signal when metadata preview is received.
// When we emit a ForegroundTaskState.InProgress with ProfileDownloadState.ConfirmingMetadata,
// the caller can send a true/false value into this channel to either continue immediately or cancel the download.
// Note that there is a timeout of 1 minute, after which we default to cancelling.
// When absent, the default value is just a buffered channel with 1 true value in it, so effectively no-op.
confirmationSignal: Channel<Boolean> = Channel<Boolean>(1).apply { trySendBlocking(true) }
): ForegroundTaskSubscriberFlow =
launchForegroundTask(
getString(R.string.task_profile_download),
getString(R.string.task_profile_download_failure),
R.drawable.ic_task_sim_card_download
) {
euiccChannelManager.beginTrackedOperation(slotId, portId) {
euiccChannelManager.withEuiccChannel(slotId, portId) { channel ->
channel.lpa.downloadProfile(
smdp,
matchingId,
imei,
confirmationCode,
object : ProfileDownloadCallback {
override fun onStateUpdate(state: ProfileDownloadCallback.DownloadState) {
if (state.progress == 0) return
foregroundTaskState.value =
ForegroundTaskState.InProgress(state.progress)
euiccChannelManager.beginTrackedOperation(slotId, portId, seId) {
euiccChannelManager.withEuiccChannel(slotId, portId, seId) { channel ->
channel.lpa.downloadProfile(input) { state ->
val progress = state.downloadProgress
foregroundTaskState.value = ForegroundTaskState.InProgress(
progress,
state
)
if (state is ProfileDownloadState.ConfirmingDownload) {
state.metadata?.let { metadata ->
// TODO: Actually do something here and not just logging?
Log.i(
TAG,
"Downloading profile provider=${metadata.providerName} name=${metadata.name}"
)
}
})
// Try to receive a signal for confirmation while blocking this thread
// This of course assumes we're NOT on the main thread here. We aren't,
// because we don't run download on the main thread; see withEuiccChannel.
return@downloadProfile runBlocking {
try {
// We can't wait indefinitely; just time out after 1 minute.
withTimeout(60 * 1000) {
confirmationSignal.receive()
}
} catch (_: TimeoutCancellationException) {
// Default to cancelling / aborting here if we didn't receive a confirmation signal
false
}
}
}
true
}
}
preferenceRepository.notificationDownloadFlow.first()
@ -413,6 +444,7 @@ class EuiccChannelManagerService : LifecycleService(), OpenEuiccContextMarker {
fun launchProfileRenameTask(
slotId: Int,
portId: Int,
seId: EuiccChannel.SecureElementId,
iccid: String,
name: String
): ForegroundTaskSubscriberFlow =
@ -421,7 +453,7 @@ class EuiccChannelManagerService : LifecycleService(), OpenEuiccContextMarker {
getString(R.string.task_profile_rename_failure),
R.drawable.ic_task_rename
) {
euiccChannelManager.withEuiccChannel(slotId, portId) { channel ->
euiccChannelManager.withEuiccChannel(slotId, portId, seId) { channel ->
channel.lpa.setNickname(
iccid,
name
@ -432,6 +464,7 @@ class EuiccChannelManagerService : LifecycleService(), OpenEuiccContextMarker {
fun launchProfileDeleteTask(
slotId: Int,
portId: Int,
seId: EuiccChannel.SecureElementId,
iccid: String
): ForegroundTaskSubscriberFlow =
launchForegroundTask(
@ -439,8 +472,8 @@ class EuiccChannelManagerService : LifecycleService(), OpenEuiccContextMarker {
getString(R.string.task_profile_delete_failure),
R.drawable.ic_task_delete
) {
euiccChannelManager.beginTrackedOperation(slotId, portId) {
euiccChannelManager.withEuiccChannel(slotId, portId) { channel ->
euiccChannelManager.beginTrackedOperation(slotId, portId, seId) {
euiccChannelManager.withEuiccChannel(slotId, portId, seId) { channel ->
channel.lpa.deleteProfile(iccid)
}
@ -453,6 +486,7 @@ class EuiccChannelManagerService : LifecycleService(), OpenEuiccContextMarker {
fun launchProfileSwitchTask(
slotId: Int,
portId: Int,
seId: EuiccChannel.SecureElementId,
iccid: String,
enable: Boolean, // Enable or disable the profile indicated in iccid
reconnectTimeoutMillis: Long = 0 // 0 = do not wait for reconnect
@ -462,9 +496,9 @@ class EuiccChannelManagerService : LifecycleService(), OpenEuiccContextMarker {
getString(R.string.task_profile_switch_failure),
R.drawable.ic_task_switch
) {
euiccChannelManager.beginTrackedOperation(slotId, portId) {
euiccChannelManager.beginTrackedOperation(slotId, portId, seId) {
val (response, refreshed) =
euiccChannelManager.withEuiccChannel(slotId, portId) { channel ->
euiccChannelManager.withEuiccChannel(slotId, portId, seId) { channel ->
val refresh = preferenceRepository.refreshAfterSwitchFlow.first()
val response = channel.lpa.switchProfile(iccid, enable, refresh)
if (response || !refresh) {
@ -510,18 +544,22 @@ class EuiccChannelManagerService : LifecycleService(), OpenEuiccContextMarker {
}
}
fun launchMemoryReset(slotId: Int, portId: Int): ForegroundTaskSubscriberFlow =
fun launchMemoryReset(
slotId: Int,
portId: Int,
seId: EuiccChannel.SecureElementId
): ForegroundTaskSubscriberFlow =
launchForegroundTask(
getString(R.string.task_euicc_memory_reset),
getString(R.string.task_euicc_memory_reset_failure),
R.drawable.ic_euicc_memory_reset
) {
euiccChannelManager.beginTrackedOperation(slotId, portId) {
euiccChannelManager.withEuiccChannel(slotId, portId) { channel ->
euiccChannelManager.beginTrackedOperation(slotId, portId, seId) {
euiccChannelManager.withEuiccChannel(slotId, portId, seId) { channel ->
channel.lpa.euiccMemoryReset()
}
preferenceRepository.notificationDeleteFlow.first()
}
}
}
}

View file

@ -1,7 +1,6 @@
package im.angry.openeuicc.ui
import android.content.ComponentName
import android.content.Context
import android.content.Intent
import android.content.ServiceConnection
import android.os.Bundle
@ -36,7 +35,7 @@ abstract class BaseEuiccAccessActivity : AppCompatActivity() {
bindService(
Intent(this, EuiccChannelManagerService::class.java),
euiccChannelManagerServiceConnection,
Context.BIND_AUTO_CREATE
BIND_AUTO_CREATE
)
}
@ -49,4 +48,4 @@ abstract class BaseEuiccAccessActivity : AppCompatActivity() {
* When called, euiccChannelManager is guaranteed to have been initialized
*/
abstract fun onInit()
}
}

View file

@ -9,7 +9,7 @@ import androidx.fragment.app.DialogFragment
import com.google.android.material.color.DynamicColors
import im.angry.openeuicc.common.R
abstract class BaseMaterialDialogFragment: DialogFragment() {
abstract class BaseMaterialDialogFragment : DialogFragment() {
override fun onGetLayoutInflater(savedInstanceState: Bundle?): LayoutInflater {
val inflater = super.onGetLayoutInflater(savedInstanceState)
val wrappedContext = ContextThemeWrapper(requireContext(), R.style.Theme_OpenEUICC)
@ -23,4 +23,4 @@ abstract class BaseMaterialDialogFragment: DialogFragment() {
it.window?.setBackgroundDrawableResource(R.drawable.dialog_background)
}
}
}
}

View file

@ -23,7 +23,9 @@ import im.angry.openeuicc.common.R
import im.angry.openeuicc.core.EuiccChannel
import im.angry.openeuicc.core.EuiccChannelManager
import im.angry.openeuicc.util.*
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import net.typeblog.lpac_jni.impl.PKID_GSMA_LIVE_CI
import net.typeblog.lpac_jni.impl.PKID_GSMA_TEST_CI
@ -43,9 +45,10 @@ class EuiccInfoActivity : BaseEuiccAccessActivity(), OpenEuiccContextMarker {
private lateinit var infoList: RecyclerView
private var logicalSlotId: Int = -1
private var seId: EuiccChannel.SecureElementId = EuiccChannel.SecureElementId.DEFAULT
data class Item(
@StringRes
@get:StringRes
val titleResId: Int,
val content: String?,
val copiedToastResId: Int? = null,
@ -56,7 +59,6 @@ class EuiccInfoActivity : BaseEuiccAccessActivity(), OpenEuiccContextMarker {
super.onCreate(savedInstanceState)
setContentView(R.layout.activity_euicc_info)
setSupportActionBar(requireViewById(R.id.toolbar))
setupToolbarInsets()
supportActionBar!!.setDisplayHomeAsUpEnabled(true)
swipeRefresh = requireViewById(R.id.swipe_refresh)
@ -67,18 +69,27 @@ class EuiccInfoActivity : BaseEuiccAccessActivity(), OpenEuiccContextMarker {
}
logicalSlotId = intent.getIntExtra("logicalSlotId", 0)
val channelTitle = if (logicalSlotId == EuiccChannelManager.USB_CHANNEL_ID) {
getString(R.string.channel_type_usb)
seId = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
intent.getParcelableExtra("seId", EuiccChannel.SecureElementId::class.java)
} else {
appContainer.customizableTextProvider.formatInternalChannelName(logicalSlotId)
}
@Suppress("DEPRECATION")
intent.getParcelableExtra("seId")
} ?: EuiccChannel.SecureElementId.DEFAULT
title = getString(R.string.euicc_info_activity_title, channelTitle)
setChannelTitle(
if (logicalSlotId == EuiccChannelManager.USB_CHANNEL_ID)
getString(R.string.channel_name_format_usb) else
appContainer.customizableTextProvider.formatNonUsbChannelName(logicalSlotId)
)
swipeRefresh.setOnRefreshListener { refresh() }
setupRootViewInsets(infoList)
setupRootViewSystemBarInsets(
window.decorView.rootView, arrayOf(
this::activityToolbarInsetHandler,
mainViewPaddingInsetHandler(infoList)
)
)
}
override fun onOptionsItemSelected(item: MenuItem): Boolean = when (item.itemId) {
@ -90,6 +101,10 @@ class EuiccInfoActivity : BaseEuiccAccessActivity(), OpenEuiccContextMarker {
else -> super.onOptionsItemSelected(item)
}
private fun setChannelTitle(title: CharSequence) {
super.setTitle(getString(R.string.euicc_info_activity_title, title))
}
override fun onInit() {
refresh()
}
@ -98,8 +113,24 @@ class EuiccInfoActivity : BaseEuiccAccessActivity(), OpenEuiccContextMarker {
swipeRefresh.isRefreshing = true
lifecycleScope.launch {
(infoList.adapter!! as EuiccInfoAdapter).euiccInfoItems =
euiccChannelManager.withEuiccChannel(logicalSlotId, ::buildEuiccInfoItems)
euiccChannelManager.withEuiccChannel(logicalSlotId, seId) { channel ->
if (channel.hasMultipleSE) {
withContext(Dispatchers.Main) {
val title = if (logicalSlotId == EuiccChannelManager.USB_CHANNEL_ID) {
getString(R.string.channel_name_format_usb_se, seId.id)
} else {
appContainer.customizableTextProvider.formatNonUsbChannelNameWithSeId(logicalSlotId, seId)
}
setChannelTitle(title)
}
}
val items = buildEuiccInfoItems(channel)
withContext(Dispatchers.Main) {
(infoList.adapter!! as EuiccInfoAdapter).euiccInfoItems = items
}
}
swipeRefresh.isRefreshing = false
}
@ -109,18 +140,19 @@ class EuiccInfoActivity : BaseEuiccAccessActivity(), OpenEuiccContextMarker {
add(Item(R.string.euicc_info_access_mode, channel.type))
add(Item(R.string.euicc_info_removable, formatByBoolean(channel.port.card.isRemovable, YES_NO)))
add(Item(R.string.euicc_info_eid, channel.lpa.eID, copiedToastResId = R.string.toast_eid_copied))
add(Item(R.string.euicc_info_isdr_aid, channel.isdrAid.encodeHex()))
if (!channel.isdrAid.contentEquals(EUICC_DEFAULT_ISDR_AID.decodeHex())) {
// Only show if it's not the default ISD-R AID
add(Item(R.string.euicc_info_isdr_aid, channel.isdrAid.encodeHex()))
}
channel.tryParseEuiccVendorInfo()?.let { vendorInfo ->
// @formatter:off
vendorInfo.skuName?.let { add(Item(R.string.euicc_info_sku, it)) }
vendorInfo.serialNumber?.let { add(Item(R.string.euicc_info_sn, it, copiedToastResId = R.string.toast_sn_copied)) }
vendorInfo.firmwareVersion?.let { add(Item(R.string.euicc_info_fw_ver, it)) }
vendorInfo.bootloaderVersion?.let { add(Item(R.string.euicc_info_bl_ver, it)) }
// @formatter:on
}
channel.lpa.euiccInfo2?.let { info ->
add(Item(R.string.euicc_info_sgp22_version, info.sgp22Version.toString()))
add(Item(R.string.euicc_info_firmware_version, info.euiccFirmwareVersion.toString()))
add(Item(R.string.euicc_info_gp_version, info.globalPlatformVersion.toString()))
add(Item(R.string.euicc_info_pp_version, info.ppVersion.toString()))
info.sasAccreditationNumber.trim().takeIf(RE_SAS::matches)
?.let { add(Item(R.string.euicc_info_sas_accreditation_number, it.uppercase())) }
@ -137,7 +169,7 @@ class EuiccInfoActivity : BaseEuiccAccessActivity(), OpenEuiccContextMarker {
// FS.27 v2.0, Security Guidelines for UICC Profiles (Page 25 of 27, 2024-01-30)
// https://www.gsma.com/solutions-and-impact/technologies/security/wp-content/uploads/2024/01/FS.27-Security-Guidelines-for-UICC-Credentials-v2.0-FINAL-23-July.pdf#page=25
val resId = when {
signers.isEmpty() -> R.string.euicc_info_unknown // the case is not mp, but it's is not common
signers.isEmpty() -> R.string.euicc_info_unknown // the case is not mp, but it is not common
PKID_GSMA_LIVE_CI.any(signers::contains) -> R.string.euicc_info_ci_gsma_live
PKID_GSMA_TEST_CI.any(signers::contains) -> R.string.euicc_info_ci_gsma_test
else -> R.string.euicc_info_ci_unknown
@ -201,4 +233,4 @@ class EuiccInfoActivity : BaseEuiccAccessActivity(), OpenEuiccContextMarker {
holder.bind(euiccInfoItems[position])
}
}
}
}

View file

@ -19,8 +19,6 @@ import android.widget.PopupMenu
import android.widget.TextView
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
@ -29,8 +27,8 @@ import androidx.recyclerview.widget.LinearLayoutManager
import androidx.recyclerview.widget.RecyclerView
import androidx.swiperefreshlayout.widget.SwipeRefreshLayout
import com.google.android.material.floatingactionbutton.FloatingActionButton
import net.typeblog.lpac_jni.LocalProfileInfo
import im.angry.openeuicc.common.R
import im.angry.openeuicc.core.EuiccChannel
import im.angry.openeuicc.service.EuiccChannelManagerService
import im.angry.openeuicc.service.EuiccChannelManagerService.Companion.waitDone
import im.angry.openeuicc.ui.wizard.DownloadWizardActivity
@ -38,19 +36,23 @@ import im.angry.openeuicc.util.*
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.TimeoutCancellationException
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.launch
import kotlinx.coroutines.runBlocking
import kotlinx.coroutines.withContext
import net.typeblog.lpac_jni.LocalProfileInfo
import net.typeblog.lpac_jni.ProfileClass
open class EuiccManagementFragment : Fragment(), EuiccProfilesChangedListener,
EuiccChannelFragmentMarker {
companion object {
const val TAG = "EuiccManagementFragment"
fun newInstance(slotId: Int, portId: Int): EuiccManagementFragment =
newInstanceEuicc(EuiccManagementFragment::class.java, slotId, portId)
fun newInstance(
slotId: Int,
portId: Int,
seId: EuiccChannel.SecureElementId
): EuiccManagementFragment =
newInstanceEuicc(EuiccManagementFragment::class.java, slotId, portId, seId)
}
private lateinit var swipeRefresh: SwipeRefreshLayout
@ -58,6 +60,7 @@ open class EuiccManagementFragment : Fragment(), EuiccProfilesChangedListener,
private lateinit var profileList: RecyclerView
private var logicalSlotId: Int = -1
private lateinit var eid: String
private var enabledProfile: LocalProfileInfo? = null
private val adapter = EuiccProfileAdapter()
@ -89,18 +92,22 @@ open class EuiccManagementFragment : Fragment(), EuiccProfilesChangedListener,
val origFabMarginRight = (fab.layoutParams as ViewGroup.MarginLayoutParams).rightMargin
val origFabMarginBottom = (fab.layoutParams as ViewGroup.MarginLayoutParams).bottomMargin
ViewCompat.setOnApplyWindowInsetsListener(fab) { v, insets ->
val bars = insets.getInsets(WindowInsetsCompat.Type.systemBars())
v.updateLayoutParams<ViewGroup.MarginLayoutParams> {
rightMargin = origFabMarginRight + bars.right
bottomMargin = origFabMarginBottom + bars.bottom
setupRootViewSystemBarInsets(
view, arrayOf(
mainViewPaddingInsetHandler(profileList),
{ insets ->
fab.updateLayoutParams<ViewGroup.MarginLayoutParams> {
rightMargin = origFabMarginRight + insets.right
bottomMargin = origFabMarginBottom + insets.bottom
}
}
))
WindowInsetsCompat.CONSUMED
}
setupRootViewInsets(profileList)
profileList.addOnScrollListener(object : RecyclerView.OnScrollListener() {
override fun onScrollStateChanged(view: RecyclerView, newState: Int) =
if (newState == RecyclerView.SCROLL_STATE_IDLE) fab.show() else fab.hide()
})
return view
}
@ -113,10 +120,8 @@ open class EuiccManagementFragment : Fragment(), EuiccProfilesChangedListener,
LinearLayoutManager(view.context, LinearLayoutManager.VERTICAL, false)
fab.setOnClickListener {
Intent(requireContext(), DownloadWizardActivity::class.java).apply {
putExtra("selectedLogicalSlot", logicalSlotId)
startActivity(this)
}
val intent = DownloadWizardActivity.newIntent(requireContext(), logicalSlotId, seId)
startActivity(intent)
}
}
@ -141,13 +146,14 @@ open class EuiccManagementFragment : Fragment(), EuiccProfilesChangedListener,
menu.findItem(R.id.euicc_info).isVisible =
logicalSlotId != -1
menu.findItem(R.id.euicc_memory_reset).isVisible =
runBlocking { preferenceRepository.euiccMemoryResetFlow.first() }
enabledProfile == null
}
override fun onOptionsItemSelected(item: MenuItem) = when (item.itemId) {
R.id.show_notifications -> {
Intent(requireContext(), NotificationsActivity::class.java).apply {
putExtra("logicalSlotId", logicalSlotId)
putExtra("seId", seId)
startActivity(this)
}
true
@ -156,13 +162,14 @@ open class EuiccManagementFragment : Fragment(), EuiccProfilesChangedListener,
R.id.euicc_info -> {
Intent(requireContext(), EuiccInfoActivity::class.java).apply {
putExtra("logicalSlotId", logicalSlotId)
putExtra("seId", seId)
startActivity(this)
}
true
}
R.id.euicc_memory_reset -> {
EuiccMemoryResetFragment.newInstance(slotId, portId, eid)
EuiccMemoryResetFragment.newInstance(slotId, portId, seId, eid)
.show(childFragmentManager, EuiccMemoryResetFragment.TAG)
true
}
@ -207,6 +214,7 @@ open class EuiccManagementFragment : Fragment(), EuiccProfilesChangedListener,
val profiles = withEuiccChannel { channel ->
logicalSlotId = channel.logicalSlotId
eid = channel.lpa.eID
enabledProfile = channel.lpa.profiles.enabled
euiccChannelManager.notifyEuiccProfilesChanged(channel.logicalSlotId)
if (unfilteredProfileListFlow.value)
channel.lpa.profiles
@ -223,11 +231,8 @@ open class EuiccManagementFragment : Fragment(), EuiccProfilesChangedListener,
}
private suspend fun showSwitchFailureText() = withContext(Dispatchers.Main) {
Toast.makeText(
context,
R.string.toast_profile_enable_failed,
Toast.LENGTH_LONG
).show()
val resId = R.string.toast_profile_enable_failed
Toast.makeText(context, resId, Toast.LENGTH_LONG).show()
}
private fun enableOrDisableProfile(iccid: String, enable: Boolean) {
@ -238,13 +243,12 @@ open class EuiccManagementFragment : Fragment(), EuiccProfilesChangedListener,
ensureEuiccChannelManager()
euiccChannelManagerService.waitForForegroundTask()
val err = euiccChannelManagerService.launchProfileSwitchTask(
slotId,
portId,
iccid,
enable,
reconnectTimeoutMillis = 30 * 1000
).waitDone()
val err = euiccChannelManagerService
.launchProfileSwitchTask(
slotId, portId, seId, iccid, enable,
reconnectTimeoutMillis = 30 * 1000
)
.waitDone()
when (err) {
null -> {}
@ -294,19 +298,20 @@ open class EuiccManagementFragment : Fragment(), EuiccProfilesChangedListener,
}
}
protected open fun populatePopupWithProfileActions(popup: PopupMenu, profile: LocalProfileInfo) {
protected open fun populatePopupWithProfileActions(
popup: PopupMenu,
profile: LocalProfileInfo
) {
popup.inflate(R.menu.profile_options)
if (profile.isEnabled) {
popup.menu.findItem(R.id.enable).isVisible = false
popup.menu.findItem(R.id.delete).isVisible = false
if (!profile.isEnabled) return
popup.menu.findItem(R.id.enable).isVisible = false
popup.menu.findItem(R.id.delete).isVisible = false
// We hide the disable option by default to avoid "bricking" some cards that won't get
// recognized again by the phone's modem. However we don't have that worry if we are
// accessing it through a USB card reader, or when the user explicitly opted in
if (isUsb || disableSafeguardFlow.value) {
popup.menu.findItem(R.id.disable).isVisible = true
}
}
// We hide the disable option by default to avoid "bricking" some cards that won't get
// recognized again by the phone's modem. However, we don't have that worry if we are
// accessing it through a USB card reader, or when the user explicitly opted in
if (!isUsb && !disableSafeguardFlow.value) return
popup.menu.findItem(R.id.disable).isVisible = true
}
sealed class ViewHolder(root: View) : RecyclerView.ViewHolder(root) {
@ -321,7 +326,7 @@ open class EuiccManagementFragment : Fragment(), EuiccProfilesChangedListener,
}
}
inner class FooterViewHolder: ViewHolder(FrameLayout(requireContext())) {
inner class FooterViewHolder : ViewHolder(FrameLayout(requireContext())) {
init {
itemView.layoutParams = ViewGroup.LayoutParams(
ViewGroup.LayoutParams.MATCH_PARENT,
@ -373,6 +378,7 @@ open class EuiccManagementFragment : Fragment(), EuiccProfilesChangedListener,
}
private lateinit var profile: LocalProfileInfo
private var canEnable: Boolean = false
fun setProfile(profile: LocalProfileInfo) {
this.profile = profile
@ -390,9 +396,9 @@ open class EuiccManagementFragment : Fragment(), EuiccProfilesChangedListener,
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
ProfileClass.Testing -> R.string.profile_class_testing
ProfileClass.Provisioning -> R.string.profile_class_provisioning
ProfileClass.Operational -> R.string.profile_class_operational
}
)
iccid.text = profile.iccid
@ -406,6 +412,13 @@ open class EuiccManagementFragment : Fragment(), EuiccProfilesChangedListener,
)
}
fun setEnabledProfile(enabledProfile: LocalProfileInfo?) {
// cannot cross profile class enable profile
// e.g: testing -> operational or operational -> testing
canEnable = enabledProfile == null ||
enabledProfile.profileClass == profile.profileClass
}
private fun showOptionsMenu() {
// Prevent users from doing multiple things at once
if (invalid || swipeRefresh.isRefreshing) return
@ -420,23 +433,45 @@ open class EuiccManagementFragment : Fragment(), EuiccProfilesChangedListener,
private fun onMenuItemClicked(item: MenuItem): Boolean =
when (item.itemId) {
R.id.enable -> {
enableOrDisableProfile(profile.iccid, true)
if (canEnable) {
enableOrDisableProfile(profile.iccid, true)
} else {
val resId = R.string.toast_profile_enable_cross_class
Toast.makeText(requireContext(), resId, Toast.LENGTH_LONG)
.show()
}
true
}
R.id.disable -> {
enableOrDisableProfile(profile.iccid, false)
true
}
R.id.rename -> {
ProfileRenameFragment.newInstance(slotId, portId, profile.iccid, profile.displayName)
ProfileRenameFragment.newInstance(
slotId,
portId,
seId,
profile.iccid,
profile.displayName
)
.show(childFragmentManager, ProfileRenameFragment.TAG)
true
}
R.id.delete -> {
ProfileDeleteFragment.newInstance(slotId, portId, profile.iccid, profile.displayName)
ProfileDeleteFragment.newInstance(
slotId,
portId,
seId,
profile.iccid,
profile.displayName
)
.show(childFragmentManager, ProfileDeleteFragment.TAG)
true
}
else -> false
}
}
@ -448,9 +483,11 @@ open class EuiccManagementFragment : Fragment(), EuiccProfilesChangedListener,
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): ViewHolder =
when (ViewHolder.Type.fromInt(viewType)) {
ViewHolder.Type.PROFILE -> {
val view = LayoutInflater.from(parent.context).inflate(R.layout.euicc_profile, parent, false)
val view = LayoutInflater.from(parent.context)
.inflate(R.layout.euicc_profile, parent, false)
ProfileViewHolder(view)
}
ViewHolder.Type.FOOTER -> {
FooterViewHolder()
}
@ -461,9 +498,11 @@ open class EuiccManagementFragment : Fragment(), EuiccProfilesChangedListener,
position < profiles.size -> {
ViewHolder.Type.PROFILE.value
}
position >= profiles.size && position < profiles.size + footerViews.size -> {
ViewHolder.Type.FOOTER.value
}
else -> -1
}
@ -471,8 +510,10 @@ open class EuiccManagementFragment : Fragment(), EuiccProfilesChangedListener,
when (holder) {
is ProfileViewHolder -> {
holder.setProfile(profiles[position])
holder.setEnabledProfile(profiles.enabled)
holder.setProfileSequenceNumber(position + 1)
}
is FooterViewHolder -> {
holder.attach(footerViews[position - profiles.size])
}
@ -487,4 +528,4 @@ open class EuiccManagementFragment : Fragment(), EuiccProfilesChangedListener,
override fun getItemCount(): Int = profiles.size + footerViews.size
}
}
}

View file

@ -8,18 +8,11 @@ import android.widget.EditText
import android.widget.Toast
import androidx.appcompat.app.AlertDialog
import androidx.fragment.app.DialogFragment
import androidx.fragment.app.Fragment
import androidx.lifecycle.lifecycleScope
import im.angry.openeuicc.common.R
import im.angry.openeuicc.core.EuiccChannel
import im.angry.openeuicc.service.EuiccChannelManagerService.Companion.waitDone
import im.angry.openeuicc.util.EuiccChannelFragmentMarker
import im.angry.openeuicc.util.EuiccProfilesChangedListener
import im.angry.openeuicc.util.ensureEuiccChannelManager
import im.angry.openeuicc.util.euiccChannelManagerService
import im.angry.openeuicc.util.newInstanceEuicc
import im.angry.openeuicc.util.notifyEuiccProfilesChanged
import im.angry.openeuicc.util.portId
import im.angry.openeuicc.util.slotId
import im.angry.openeuicc.util.*
import kotlinx.coroutines.flow.onStart
import kotlinx.coroutines.launch
@ -29,8 +22,8 @@ class EuiccMemoryResetFragment : DialogFragment(), EuiccChannelFragmentMarker {
private const val FIELD_EID = "eid"
fun newInstance(slotId: Int, portId: Int, eid: String) =
newInstanceEuicc(EuiccMemoryResetFragment::class.java, slotId, portId) {
fun newInstance(slotId: Int, portId: Int, seId: EuiccChannel.SecureElementId, eid: String) =
newInstanceEuicc(EuiccMemoryResetFragment::class.java, slotId, portId, seId) {
putString(FIELD_EID, eid)
}
}
@ -103,7 +96,7 @@ class EuiccMemoryResetFragment : DialogFragment(), EuiccChannelFragmentMarker {
ensureEuiccChannelManager()
euiccChannelManagerService.waitForForegroundTask()
euiccChannelManagerService.launchMemoryReset(slotId, portId)
euiccChannelManagerService.launchMemoryReset(slotId, portId, seId)
.onStart {
parentFragment?.notifyEuiccProfilesChanged()

View file

@ -10,8 +10,7 @@ import androidx.activity.enableEdgeToEdge
import androidx.appcompat.app.AppCompatActivity
import androidx.lifecycle.lifecycleScope
import im.angry.openeuicc.common.R
import im.angry.openeuicc.util.preferenceRepository
import im.angry.openeuicc.util.setupToolbarInsets
import im.angry.openeuicc.util.*
import kotlinx.coroutines.flow.collect
import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.launch
@ -24,11 +23,17 @@ class IsdrAidListActivity : AppCompatActivity() {
super.onCreate(savedInstanceState)
setContentView(R.layout.activity_isdr_aid_list)
setSupportActionBar(requireViewById(R.id.toolbar))
setupToolbarInsets()
supportActionBar!!.setDisplayHomeAsUpEnabled(true)
isdrAidListEditor = requireViewById(R.id.isdr_aid_list_editor)
setupRootViewSystemBarInsets(
window.decorView.rootView, arrayOf(
this::activityToolbarInsetHandler,
mainViewPaddingInsetHandler(isdrAidListEditor)
)
)
lifecycleScope.launch {
preferenceRepository.isdrAidListFlow.onEach {
isdrAidListEditor.text = Editable.Factory.getInstance().newEditable(it)
@ -69,4 +74,4 @@ class IsdrAidListActivity : AppCompatActivity() {
else -> super.onOptionsItemSelected(item)
}
}
}

View file

@ -51,14 +51,18 @@ class LogsActivity : AppCompatActivity() {
super.onCreate(savedInstanceState)
setContentView(R.layout.activity_logs)
setSupportActionBar(requireViewById(R.id.toolbar))
setupToolbarInsets()
supportActionBar!!.setDisplayHomeAsUpEnabled(true)
swipeRefresh = requireViewById(R.id.swipe_refresh)
scrollView = requireViewById(R.id.scroll_view)
logText = requireViewById(R.id.log_text)
setupRootViewInsets(scrollView)
setupRootViewSystemBarInsets(
window.decorView.rootView, arrayOf(
this::activityToolbarInsetHandler,
mainViewPaddingInsetHandler(scrollView)
)
)
swipeRefresh.setOnRefreshListener {
lifecycleScope.launch {
@ -84,10 +88,12 @@ class LogsActivity : AppCompatActivity() {
finish()
true
}
R.id.save -> {
saveLogs()
true
}
else -> super.onOptionsItemSelected(item)
}
@ -107,4 +113,4 @@ class LogsActivity : AppCompatActivity() {
scrollView.fullScroll(View.FOCUS_DOWN)
}
}
}
}

View file

@ -16,6 +16,9 @@ import android.view.MenuItem
import android.view.View
import android.widget.ProgressBar
import androidx.activity.enableEdgeToEdge
import androidx.core.content.pm.ShortcutInfoCompat
import androidx.core.content.pm.ShortcutManagerCompat
import androidx.core.graphics.drawable.IconCompat
import androidx.fragment.app.Fragment
import androidx.lifecycle.lifecycleScope
import androidx.viewpager2.adapter.FragmentStateAdapter
@ -23,7 +26,9 @@ import androidx.viewpager2.widget.ViewPager2
import com.google.android.material.tabs.TabLayout
import com.google.android.material.tabs.TabLayoutMediator
import im.angry.openeuicc.common.R
import im.angry.openeuicc.core.EuiccChannel
import im.angry.openeuicc.core.EuiccChannelManager
import im.angry.openeuicc.ui.wizard.DownloadWizardActivity
import im.angry.openeuicc.util.*
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.collect
@ -47,18 +52,30 @@ open class MainActivity : BaseEuiccAccessActivity(), OpenEuiccContextMarker {
private var refreshing = false
private data class Page(
val id: Long,
val logicalSlotId: Int,
val title: String,
val createFragment: () -> Fragment
)
private val pages: MutableList<Page> = mutableListOf()
private var nextPageId = 0L
private fun newPage(
logicalSlotId: Int,
title: String,
createFragment: () -> Fragment
): Page = Page(nextPageId++, logicalSlotId, title, createFragment)
private val pagerAdapter by lazy {
object : FragmentStateAdapter(this) {
override fun getItemCount() = pages.size
override fun createFragment(position: Int): Fragment = pages[position].createFragment()
override fun getItemId(position: Int): Long = pages[position].id
override fun containsItem(itemId: Long): Boolean = pages.any { it.id == itemId }
}
}
@ -78,7 +95,6 @@ open class MainActivity : BaseEuiccAccessActivity(), OpenEuiccContextMarker {
super.onCreate(savedInstanceState)
setContentView(R.layout.activity_main)
setSupportActionBar(requireViewById(R.id.toolbar))
setupToolbarInsets()
loadingProgress = requireViewById(R.id.loading)
tabs = requireViewById(R.id.main_tabs)
viewPager = requireViewById(R.id.view_pager)
@ -94,6 +110,12 @@ open class MainActivity : BaseEuiccAccessActivity(), OpenEuiccContextMarker {
addAction(UsbManager.ACTION_USB_DEVICE_ATTACHED)
addAction(UsbManager.ACTION_USB_DEVICE_DETACHED)
})
setupRootViewSystemBarInsets(
window.decorView.rootView, arrayOf(
this::activityToolbarInsetHandler
), consume = false
)
}
override fun onDestroy() {
@ -112,10 +134,12 @@ open class MainActivity : BaseEuiccAccessActivity(), OpenEuiccContextMarker {
startActivity(Intent(this, SettingsActivity::class.java))
true
}
R.id.reload -> {
refresh()
true
}
else -> super.onOptionsItemSelected(item)
}
@ -126,15 +150,10 @@ open class MainActivity : BaseEuiccAccessActivity(), OpenEuiccContextMarker {
}
private fun ensureNotificationPermissions() {
val needsNotificationPerms = Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU;
val notificationPermsGranted =
needsNotificationPerms && checkSelfPermission(android.Manifest.permission.POST_NOTIFICATIONS) == PackageManager.PERMISSION_GRANTED
if (needsNotificationPerms && !notificationPermsGranted) {
requestPermissions(
arrayOf(android.Manifest.permission.POST_NOTIFICATIONS),
PERMISSION_REQUEST_CODE
)
}
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.TIRAMISU) return
val permissions = arrayOf(android.Manifest.permission.POST_NOTIFICATIONS)
if (permissions.all { checkSelfPermission(it) == PackageManager.PERMISSION_GRANTED }) return
requestPermissions(permissions, PERMISSION_REQUEST_CODE)
}
private suspend fun init(fromUsbEvent: Boolean = false) {
@ -154,29 +173,40 @@ open class MainActivity : BaseEuiccAccessActivity(), OpenEuiccContextMarker {
euiccChannelManager.flowInternalEuiccPorts().onEach { (slotId, portId) ->
Log.d(TAG, "slot $slotId port $portId")
euiccChannelManager.withEuiccChannel(slotId, portId) { channel ->
if (preferenceRepository.verboseLoggingFlow.first()) {
Log.d(TAG, channel.lpa.eID)
}
// Request the system to refresh the list of profiles every time we start
// Note that this is currently supposed to be no-op when unprivileged,
// but it could change in the future
euiccChannelManager.notifyEuiccProfilesChanged(channel.logicalSlotId)
euiccChannelManager.flowEuiccSecureElements(slotId, portId).onEach { seId ->
euiccChannelManager.withEuiccChannel(slotId, portId, seId) { channel ->
if (preferenceRepository.verboseLoggingFlow.first()) {
Log.d(TAG, channel.lpa.eID)
}
// Request the system to refresh the list of profiles every time we start
// Note that this is currently supposed to be no-op when unprivileged,
// but it could change in the future
euiccChannelManager.notifyEuiccProfilesChanged(channel.logicalSlotId)
val channelName =
appContainer.customizableTextProvider.formatInternalChannelName(channel.logicalSlotId)
newPages.add(Page(channel.logicalSlotId, channelName) {
appContainer.uiComponentFactory.createEuiccManagementFragment(slotId, portId)
})
}
val channelName = if (channel.hasMultipleSE) {
appContainer.customizableTextProvider.formatNonUsbChannelNameWithSeId(
channel.logicalSlotId,
channel.seId
)
} else {
appContainer.customizableTextProvider.formatNonUsbChannelName(channel.logicalSlotId)
}
newPages.add(newPage(channel.logicalSlotId, channelName) {
appContainer.uiComponentFactory.createEuiccManagementFragment(
slotId,
portId,
seId
)
})
}
}.collect()
}.collect()
// If USB readers exist, add them at the very last
// We use a wrapper fragment to handle logic specific to USB readers
usbDevice?.let {
val productName = it.productName ?: getString(R.string.channel_type_usb)
newPages.add(Page(EuiccChannelManager.USB_CHANNEL_ID, productName) {
UsbCcidReaderFragment()
newPages.add(newPage(EuiccChannelManager.USB_CHANNEL_ID, getString(R.string.channel_name_format_usb)) {
UsbCcidReaderPermissionFragment()
})
}
viewPager.visibility = View.VISIBLE
@ -184,7 +214,7 @@ open class MainActivity : BaseEuiccAccessActivity(), OpenEuiccContextMarker {
if (newPages.size > 1) {
tabs.visibility = View.VISIBLE
} else if (newPages.isEmpty()) {
newPages.add(Page(-1, "") {
newPages.add(newPage(-1, "") {
appContainer.uiComponentFactory.createNoEuiccPlaceholderFragment()
})
}
@ -209,10 +239,12 @@ open class MainActivity : BaseEuiccAccessActivity(), OpenEuiccContextMarker {
viewPager.currentItem = 0
}
if (pages.size > 0) {
if (pages.isNotEmpty()) {
ensureNotificationPermissions()
}
ShortcutManagerCompat.setDynamicShortcuts(this, buildShortcuts().take(4))
refreshing = false
}
@ -231,4 +263,44 @@ open class MainActivity : BaseEuiccAccessActivity(), OpenEuiccContextMarker {
init(fromUsbEvent) // will set refreshing = false
}
}
}
protected open fun buildShortcuts(): List<ShortcutInfoCompat> {
val downloadShortcut = ShortcutInfoCompat.Builder(this, "download")
.setShortLabel(getString(R.string.profile_download))
.setIcon(IconCompat.createWithResource(this, R.drawable.ic_task_sim_card_download))
.setIntent(DownloadWizardActivity.newIntent(this).apply { action = Intent.ACTION_VIEW })
.build()
return listOf(downloadShortcut)
}
fun instantiateUsbTabs(seIds: List<EuiccChannel.SecureElementId>) {
val existingUsbPageIndex = pages.indexOfFirst { it.logicalSlotId == EuiccChannelManager.USB_CHANNEL_ID }
if (existingUsbPageIndex == -1) return
val usbPages =
seIds.map { seId ->
val name = if (seIds.size == 1) {
getString(R.string.channel_name_format_usb)
} else {
getString(R.string.channel_name_format_usb_se, seId.id)
}
newPage(EuiccChannelManager.USB_CHANNEL_ID, name) {
appContainer.uiComponentFactory.createEuiccManagementFragment(
EuiccChannelManager.USB_CHANNEL_ID,
0,
seId
)
}
}
// Add before removing to avoid out-of-bounds problems
pages.addAll(existingUsbPageIndex, usbPages)
// Remove the old USB reader page
pages.removeAt(existingUsbPageIndex + usbPages.size)
if (pages.size > 1) {
tabs.visibility = View.VISIBLE
}
pagerAdapter.notifyDataSetChanged()
}
}

View file

@ -7,7 +7,7 @@ import android.view.ViewGroup
import android.widget.TextView
import androidx.fragment.app.Fragment
import im.angry.openeuicc.common.R
import im.angry.openeuicc.util.*
import im.angry.openeuicc.util.OpenEuiccContextMarker
class NoEuiccPlaceholderFragment : Fragment(), OpenEuiccContextMarker {
override fun onCreateView(
@ -20,4 +20,4 @@ class NoEuiccPlaceholderFragment : Fragment(), OpenEuiccContextMarker {
textView.text = appContainer.customizableTextProvider.noEuiccExplanation
return view
}
}
}

View file

@ -1,6 +1,7 @@
package im.angry.openeuicc.ui
import android.annotation.SuppressLint
import android.os.Build
import android.os.Bundle
import android.text.Html
import android.view.ContextMenu
@ -20,6 +21,7 @@ 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
@ -27,45 +29,54 @@ import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import net.typeblog.lpac_jni.LocalProfileNotification
class NotificationsActivity: BaseEuiccAccessActivity(), OpenEuiccContextMarker {
class NotificationsActivity : BaseEuiccAccessActivity(), OpenEuiccContextMarker {
private lateinit var swipeRefresh: SwipeRefreshLayout
private lateinit var notificationList: RecyclerView
private val notificationAdapter = NotificationAdapter()
private var logicalSlotId = -1
private var seId = EuiccChannel.SecureElementId.DEFAULT
override fun onCreate(savedInstanceState: Bundle?) {
enableEdgeToEdge()
super.onCreate(savedInstanceState)
setContentView(R.layout.activity_notifications)
setSupportActionBar(requireViewById(R.id.toolbar))
setupToolbarInsets()
supportActionBar!!.setDisplayHomeAsUpEnabled(true)
swipeRefresh = requireViewById(R.id.swipe_refresh)
notificationList = requireViewById(R.id.recycler_view)
setupRootViewInsets(notificationList)
setupRootViewSystemBarInsets(
window.decorView.rootView, arrayOf(
this::activityToolbarInsetHandler,
mainViewPaddingInsetHandler(notificationList)
)
)
}
override fun onInit() {
notificationList.layoutManager =
LinearLayoutManager(this, LinearLayoutManager.VERTICAL, false)
notificationList.addItemDecoration(DividerItemDecoration(this, LinearLayoutManager.VERTICAL))
notificationList.adapter = notificationAdapter
registerForContextMenu(notificationList)
logicalSlotId = intent.getIntExtra("logicalSlotId", 0)
// This is slightly different from the MainActivity logic
// due to the length (we don't want to display the full USB product name)
val channelTitle = if (logicalSlotId == EuiccChannelManager.USB_CHANNEL_ID) {
getString(R.string.channel_type_usb)
} else {
appContainer.customizableTextProvider.formatInternalChannelName(logicalSlotId)
notificationList.apply {
val context = this@NotificationsActivity
adapter = notificationAdapter
layoutManager = LinearLayoutManager(context, RecyclerView.VERTICAL, false)
addItemDecoration(DividerItemDecoration(context, LinearLayoutManager.VERTICAL))
registerForContextMenu(this)
}
title = getString(R.string.profile_notifications_detailed_format, channelTitle)
logicalSlotId = intent.getIntExtra("logicalSlotId", 0)
seId = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
intent.getParcelableExtra("seId", EuiccChannel.SecureElementId::class.java)
} else {
@Suppress("DEPRECATION")
intent.getParcelableExtra("seId")
} ?: EuiccChannel.SecureElementId.DEFAULT
setChannelTitle(
if (logicalSlotId == EuiccChannelManager.USB_CHANNEL_ID)
getString(R.string.channel_name_format_usb) else
appContainer.customizableTextProvider.formatNonUsbChannelName(logicalSlotId)
)
swipeRefresh.setOnRefreshListener {
refresh()
@ -86,6 +97,7 @@ class NotificationsActivity: BaseEuiccAccessActivity(), OpenEuiccContextMarker {
finish()
true
}
R.id.help -> {
AlertDialog.Builder(this, R.style.AlertDialogTheme).apply {
setMessage(R.string.profile_notifications_help)
@ -96,9 +108,14 @@ class NotificationsActivity: BaseEuiccAccessActivity(), OpenEuiccContextMarker {
}
true
}
else -> super.onOptionsItemSelected(item)
}
private fun setChannelTitle(title: CharSequence) {
super.setTitle(getString(R.string.profile_notifications_detailed_format, title))
}
private fun launchTask(task: suspend () -> Unit) {
swipeRefresh.isRefreshing = true
@ -114,29 +131,39 @@ class NotificationsActivity: BaseEuiccAccessActivity(), OpenEuiccContextMarker {
}
private fun refresh() {
launchTask {
notificationAdapter.notifications =
euiccChannelManager.withEuiccChannel(logicalSlotId) { channel ->
val nameMap = buildMap {
for (profile in channel.lpa.profiles) {
put(profile.iccid, profile.displayName)
}
}
launchTask {
notificationAdapter.notifications = withEuiccChannel { channel ->
if (channel.hasMultipleSE) {
withContext(Dispatchers.Main) {
val channelTitle = if (logicalSlotId == EuiccChannelManager.USB_CHANNEL_ID) {
getString(R.string.channel_name_format_usb_se, seId.id)
} else {
appContainer.customizableTextProvider.formatNonUsbChannelNameWithSeId(logicalSlotId, seId)
}
setChannelTitle(channelTitle)
}
}
channel.lpa.notifications.map {
LocalProfileNotificationWrapper(it, nameMap[it.iccid] ?: "???")
}
}
}
val nameMap = channel.lpa.profiles
.associate { Pair(it.iccid, it.displayName) }
channel.lpa.notifications.map {
LocalProfileNotificationWrapper(it, nameMap[it.iccid] ?: "???")
}
}
}
}
private suspend fun <R> withEuiccChannel(fn: suspend (EuiccChannel) -> R) =
euiccChannelManager.withEuiccChannel(logicalSlotId, seId, fn)
data class LocalProfileNotificationWrapper(
val inner: LocalProfileNotification,
val profileName: String
)
@SuppressLint("ClickableViewAccessibility")
inner class NotificationViewHolder(private val root: View):
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 =
@ -170,7 +197,8 @@ class NotificationsActivity: BaseEuiccAccessActivity(), OpenEuiccContextMarker {
LocalProfileNotification.Operation.Delete -> R.string.profile_notification_operation_delete
LocalProfileNotification.Operation.Enable -> R.string.profile_notification_operation_enable
LocalProfileNotification.Operation.Disable -> R.string.profile_notification_operation_disable
})
}
)
fun updateNotification(value: LocalProfileNotificationWrapper) {
notification = value
@ -181,10 +209,13 @@ class NotificationsActivity: BaseEuiccAccessActivity(), OpenEuiccContextMarker {
value.inner.seqNumber
)
profileName.text = Html.fromHtml(
root.context.getString(R.string.profile_notification_name_format,
root.context.getString(
R.string.profile_notification_name_format,
operationToLocalizedText(value.inner.profileManagementOperation),
value.profileName, value.inner.iccid),
Html.FROM_HTML_MODE_COMPACT)
value.profileName, value.inner.iccid
),
Html.FROM_HTML_MODE_COMPACT
)
}
override fun onCreateContextMenu(
@ -204,7 +235,7 @@ class NotificationsActivity: BaseEuiccAccessActivity(), OpenEuiccContextMarker {
R.id.notification_process -> {
launchTask {
withContext(Dispatchers.IO) {
euiccChannelManager.withEuiccChannel(logicalSlotId) { channel ->
withEuiccChannel { channel ->
channel.lpa.handleNotification(notification.inner.seqNumber)
}
}
@ -213,10 +244,11 @@ class NotificationsActivity: BaseEuiccAccessActivity(), OpenEuiccContextMarker {
}
true
}
R.id.notification_delete -> {
launchTask {
withContext(Dispatchers.IO) {
euiccChannelManager.withEuiccChannel(logicalSlotId) { channel ->
withEuiccChannel { channel ->
channel.lpa.deleteNotification(notification.inner.seqNumber)
}
}
@ -225,11 +257,12 @@ class NotificationsActivity: BaseEuiccAccessActivity(), OpenEuiccContextMarker {
}
true
}
else -> false
}
}
inner class NotificationAdapter: RecyclerView.Adapter<NotificationViewHolder>() {
inner class NotificationAdapter : RecyclerView.Adapter<NotificationViewHolder>() {
var notifications: List<LocalProfileNotificationWrapper> = listOf()
@SuppressLint("NotifyDataSetChanged")
set(value) {
@ -249,4 +282,4 @@ class NotificationsActivity: BaseEuiccAccessActivity(), OpenEuiccContextMarker {
holder.updateNotification(notifications[position])
}
}
}

View file

@ -9,6 +9,7 @@ import androidx.appcompat.app.AlertDialog
import androidx.fragment.app.DialogFragment
import androidx.lifecycle.lifecycleScope
import im.angry.openeuicc.common.R
import im.angry.openeuicc.core.EuiccChannel
import im.angry.openeuicc.service.EuiccChannelManagerService.Companion.waitDone
import im.angry.openeuicc.util.*
import kotlinx.coroutines.flow.onStart
@ -20,11 +21,11 @@ class ProfileDeleteFragment : DialogFragment(), EuiccChannelFragmentMarker {
private const val FIELD_ICCID = "iccid"
private const val FIELD_NAME = "name"
fun newInstance(slotId: Int, portId: Int, iccid: String, name: String) =
newInstanceEuicc(ProfileDeleteFragment::class.java, slotId, portId) {
fun newInstance(slotId: Int, portId: Int, seId: EuiccChannel.SecureElementId, iccid: String, name: String) =
newInstanceEuicc(ProfileDeleteFragment::class.java, slotId, portId, seId) {
putString(FIELD_ICCID, iccid)
putString(FIELD_NAME, name)
}
}
}
private val iccid by lazy {
@ -88,7 +89,7 @@ class ProfileDeleteFragment : DialogFragment(), EuiccChannelFragmentMarker {
requireParentFragment().lifecycleScope.launch {
ensureEuiccChannelManager()
euiccChannelManagerService.waitForForegroundTask()
euiccChannelManagerService.launchProfileDeleteTask(slotId, portId, iccid)
euiccChannelManagerService.launchProfileDeleteTask(slotId, portId, seId, iccid)
.onStart {
parentFragment?.notifyEuiccProfilesChanged()
runCatching(::dismiss)
@ -96,4 +97,4 @@ class ProfileDeleteFragment : DialogFragment(), EuiccChannelFragmentMarker {
.waitDone()
}
}
}
}

View file

@ -12,6 +12,7 @@ import androidx.appcompat.widget.Toolbar
import androidx.lifecycle.lifecycleScope
import com.google.android.material.textfield.TextInputLayout
import im.angry.openeuicc.common.R
import im.angry.openeuicc.core.EuiccChannel
import im.angry.openeuicc.service.EuiccChannelManagerService.Companion.waitDone
import im.angry.openeuicc.util.*
import kotlinx.coroutines.launch
@ -24,11 +25,13 @@ class ProfileRenameFragment : BaseMaterialDialogFragment(), EuiccChannelFragment
const val TAG = "ProfileRenameFragment"
fun newInstance(slotId: Int, portId: Int, iccid: String, currentName: String) =
newInstanceEuicc(ProfileRenameFragment::class.java, slotId, portId) {
putString(FIELD_ICCID, iccid)
putString(FIELD_CURRENT_NAME, currentName)
}
fun newInstance(
slotId: Int, portId: Int, seId: EuiccChannel.SecureElementId,
iccid: String, currentName: String
) = newInstanceEuicc(ProfileRenameFragment::class.java, slotId, portId, seId) {
putString(FIELD_ICCID, iccid)
putString(FIELD_CURRENT_NAME, currentName)
}
}
private lateinit var toolbar: Toolbar
@ -105,7 +108,7 @@ class ProfileRenameFragment : BaseMaterialDialogFragment(), EuiccChannelFragment
ensureEuiccChannelManager()
euiccChannelManagerService.waitForForegroundTask()
val response = euiccChannelManagerService
.launchProfileRenameTask(slotId, portId, iccid, newName).waitDone()
.launchProfileRenameTask(slotId, portId, seId, iccid, newName).waitDone()
when (response) {
is LocalProfileAssistant.ProfileNameTooLongException -> {
@ -128,4 +131,4 @@ class ProfileRenameFragment : BaseMaterialDialogFragment(), EuiccChannelFragment
}
}
}
}
}

View file

@ -8,7 +8,7 @@ import im.angry.openeuicc.OpenEuiccApplication
import im.angry.openeuicc.common.R
import im.angry.openeuicc.util.*
class SettingsActivity: AppCompatActivity() {
class SettingsActivity : AppCompatActivity() {
private val appContainer
get() = (application as OpenEuiccApplication).appContainer
@ -17,8 +17,14 @@ class SettingsActivity: AppCompatActivity() {
super.onCreate(savedInstanceState)
setContentView(R.layout.activity_settings)
setSupportActionBar(requireViewById(R.id.toolbar))
setupToolbarInsets()
supportActionBar!!.setDisplayHomeAsUpEnabled(true)
setupRootViewSystemBarInsets(
window.decorView.rootView, arrayOf(
this::activityToolbarInsetHandler
), consume = false
)
val settingsFragment = appContainer.uiComponentFactory.createSettingsFragment()
supportFragmentManager.beginTransaction()
.replace(R.id.settings_container, settingsFragment)
@ -31,6 +37,7 @@ class SettingsActivity: AppCompatActivity() {
finish()
true
}
else -> super.onOptionsItemSelected(item)
}
}
}

View file

@ -18,7 +18,7 @@ import kotlinx.coroutines.flow.collect
import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.launch
open class SettingsFragment: PreferenceFragmentCompat() {
open class SettingsFragment : PreferenceFragmentCompat(), OpenEuiccContextMarker {
private lateinit var developerPref: PreferenceCategory
// Hidden developer options switch
@ -47,10 +47,9 @@ open class SettingsFragment: PreferenceFragmentCompat() {
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)
}
val uri = Uri.fromParts("package", requireContext().packageName, null)
intent = Intent(Settings.ACTION_APP_LOCALE_SETTINGS, uri)
isVisible = intent!!.resolveActivity(requireContext().packageManager) != null
}
requirePreference<Preference>("pref_advanced_logs").apply {
@ -81,15 +80,19 @@ open class SettingsFragment: PreferenceFragmentCompat() {
requirePreference<CheckBoxPreference>("pref_developer_refresh_after_switch")
.bindBooleanFlow(preferenceRepository.refreshAfterSwitchFlow)
requirePreference<CheckBoxPreference>("pref_developer_euicc_memory_reset")
.bindBooleanFlow(preferenceRepository.euiccMemoryResetFlow)
requirePreference<ListPreference>("pref_developer_es10x_mss")
.bindIntFlow(preferenceRepository.es10xMssFlow, 63)
requirePreference<Preference>("pref_developer_isdr_aid_list").apply {
intent = Intent(requireContext(), IsdrAidListActivity::class.java)
}
requirePreference<Preference>("pref_info_website").apply {
val uri = appContainer.customizableTextProvider.websiteUri ?: return@apply
isVisible = true
summary = uri.buildUpon().clearQuery().build().toString()
intent = Intent(/* action = */ Intent.ACTION_VIEW, uri)
}
}
protected fun <T : Preference> requirePreference(key: CharSequence) =
@ -97,7 +100,9 @@ open class SettingsFragment: PreferenceFragmentCompat() {
override fun onStart() {
super.onStart()
setupRootViewInsets(requireView().requireViewById(R.id.recycler_view))
setupRootViewSystemBarInsets(requireView(), arrayOf(
mainViewPaddingInsetHandler(requireView().requireViewById(R.id.recycler_view))
))
}
@Suppress("UNUSED_PARAMETER")
@ -167,4 +172,4 @@ open class SettingsFragment: PreferenceFragmentCompat() {
overlayCat.parent?.removePreference(overlayCat)
}
}
}

View file

@ -14,23 +14,22 @@ import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import android.widget.Button
import android.widget.ProgressBar
import android.widget.TextView
import androidx.fragment.app.Fragment
import androidx.fragment.app.commit
import androidx.lifecycle.lifecycleScope
import im.angry.openeuicc.common.R
import im.angry.openeuicc.core.EuiccChannelManager
import im.angry.openeuicc.util.*
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.toList
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
/**
* A wrapper fragment over EuiccManagementFragment where we handle
* logic specific to USB devices. This is mainly USB permission
* requests, and the fact that USB devices may or may not be
* available by the time the user selects it from MainActivity.
* A fragment to handle USB reader-specific permission flow. If/after
* permission is granted, this fragment simply calls back to MainActivity
* to instantiate the corresponding EuiccManagementFragment(s) for the USB
* reader.
*
* Having this fragment allows MainActivity to be (mostly) agnostic
* of the underlying implementation of different types of channels.
@ -40,7 +39,7 @@ import kotlinx.coroutines.withContext
* Note that for now we assume there will only be one USB card reader
* device. This is also an implicit assumption in EuiccChannelManager.
*/
class UsbCcidReaderFragment : Fragment(), OpenEuiccContextMarker {
class UsbCcidReaderPermissionFragment : Fragment(), OpenEuiccContextMarker {
companion object {
const val ACTION_USB_PERMISSION = "im.angry.openeuicc.USB_PERMISSION"
}
@ -69,7 +68,7 @@ class UsbCcidReaderFragment : Fragment(), OpenEuiccContextMarker {
private lateinit var text: TextView
private lateinit var permissionButton: Button
private lateinit var loadingProgress: ProgressBar
private lateinit var loadingProgress: View
private var usbDevice: UsbDevice? = null
@ -142,28 +141,23 @@ class UsbCcidReaderFragment : Fragment(), OpenEuiccContextMarker {
euiccChannelManager.tryOpenUsbEuiccChannel()
}
loadingProgress.visibility = View.GONE
usbDevice = device
if (device != null && !canOpen && !usbManager.hasPermission(device)) {
loadingProgress.visibility = View.GONE
text.text = getString(R.string.usb_permission_needed)
text.visibility = View.VISIBLE
permissionButton.visibility = View.VISIBLE
} else if (device != null && canOpen) {
childFragmentManager.commit {
replace(
R.id.child_container,
appContainer.uiComponentFactory.createEuiccManagementFragment(
slotId = EuiccChannelManager.USB_CHANNEL_ID,
portId = 0
)
)
val seIds = withContext(Dispatchers.IO) {
euiccChannelManager.flowEuiccSecureElements(EuiccChannelManager.USB_CHANNEL_ID, 0).toList()
}
(requireActivity() as MainActivity).instantiateUsbTabs(seIds)
} else {
loadingProgress.visibility = View.GONE
text.text = getString(R.string.usb_failed)
text.visibility = View.VISIBLE
permissionButton.visibility = View.GONE
}
}
}
}

View file

@ -7,10 +7,10 @@ import androidx.preference.PreferenceCategory
import androidx.preference.PreferenceViewHolder
@Suppress("unused")
class LongSummaryPreferenceCategory: PreferenceCategory {
constructor(ctx: Context): super(ctx)
constructor(ctx: Context, attrs: AttributeSet): super(ctx, attrs)
constructor(ctx: Context, attrs: AttributeSet, defStyle: Int): super(ctx, attrs, defStyle)
class LongSummaryPreferenceCategory : PreferenceCategory {
constructor(ctx: Context) : super(ctx)
constructor(ctx: Context, attrs: AttributeSet) : super(ctx, attrs)
constructor(ctx: Context, attrs: AttributeSet, defStyle: Int) : super(ctx, attrs, defStyle)
override fun onBindViewHolder(holder: PreferenceViewHolder) {
super.onBindViewHolder(holder)

View file

@ -0,0 +1,51 @@
package im.angry.openeuicc.ui.widget
import android.content.Context
import android.util.AttributeSet
import android.view.ViewGroup
import com.google.android.material.R
import com.google.android.material.tabs.TabLayout
/**
* A TabLayout that automatically switches to MODE_SCROLLABLE when
* child tabs overflow the full width of the layout.
*/
class DynamicModeTabLayout @JvmOverloads constructor(
context: Context,
attrs: AttributeSet? = null,
defStyleAttr: Int = R.attr.tabStyle
) : TabLayout(context, attrs, defStyleAttr) {
init {
addOnLayoutChangeListener { _, _, _, _, _, _, _, _, _ ->
updateModeIfNecessary()
}
}
private fun updateModeIfNecessary() {
if (width <= 0 || tabCount == 0) return
val tabStrip = getChildAt(0) as? ViewGroup ?: return
val totalTabWidth = (0 until tabStrip.childCount).sumOf { index ->
val tabView = tabStrip.getChildAt(index)
val layoutParams = tabView.layoutParams as? MarginLayoutParams
tabView.measure(
MeasureSpec.makeMeasureSpec(0, MeasureSpec.UNSPECIFIED),
MeasureSpec.makeMeasureSpec(0, MeasureSpec.UNSPECIFIED)
)
tabView.measuredWidth + (layoutParams?.leftMargin ?: 0) + (layoutParams?.rightMargin ?: 0)
}
val availableWidth = width - paddingLeft - paddingRight
val shouldScroll = totalTabWidth > availableWidth
val targetMode = if (shouldScroll) MODE_SCROLLABLE else MODE_FIXED
val targetGravity = if (shouldScroll) GRAVITY_START else GRAVITY_FILL
if (tabMode != targetMode) {
tabMode = targetMode
}
if (tabGravity != targetGravity) {
tabGravity = targetGravity
}
}
}

View file

@ -1,6 +1,8 @@
package im.angry.openeuicc.ui.wizard
import android.app.assist.AssistContent
import android.content.Context
import android.content.Intent
import android.os.Bundle
import android.view.View
import android.view.WindowManager
@ -17,6 +19,7 @@ import androidx.core.view.updatePadding
import androidx.fragment.app.Fragment
import androidx.lifecycle.lifecycleScope
import im.angry.openeuicc.common.R
import im.angry.openeuicc.core.EuiccChannel
import im.angry.openeuicc.core.EuiccChannelManager
import im.angry.openeuicc.ui.BaseEuiccAccessActivity
import im.angry.openeuicc.util.*
@ -24,10 +27,26 @@ import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import net.typeblog.lpac_jni.LocalProfileAssistant
class DownloadWizardActivity: BaseEuiccAccessActivity() {
class DownloadWizardActivity : BaseEuiccAccessActivity() {
companion object {
const val TAG = "DownloadWizardActivity"
private const val FIELD_LOGICAL_SLOT_ID = "selectedLogicalSlot"
fun newIntent(
context: Context,
logicalSlotId: Int = 0,
seId: EuiccChannel.SecureElementId = EuiccChannel.SecureElementId.DEFAULT
) = Intent(context, DownloadWizardActivity::class.java).apply {
val selectedSyntheticSlotId = DownloadWizardSlotSelectFragment
.encodeSyntheticSlotId(logicalSlotId, seId)
putExtra(FIELD_LOGICAL_SLOT_ID, selectedSyntheticSlotId)
}
}
data class DownloadWizardState(
var currentStepFragmentClassName: String?,
var selectedLogicalSlot: Int,
var selectedSyntheticSlotId: Int,
var smdp: String,
var matchingId: String?,
var confirmationCode: String?,
@ -66,7 +85,7 @@ class DownloadWizardActivity: BaseEuiccAccessActivity() {
state = DownloadWizardState(
currentStepFragmentClassName = null,
selectedLogicalSlot = intent.getIntExtra("selectedLogicalSlot", 0),
selectedSyntheticSlotId = intent.getIntExtra(FIELD_LOGICAL_SLOT_ID, 0),
smdp = "",
matchingId = null,
confirmationCode = null,
@ -94,27 +113,20 @@ class DownloadWizardActivity: BaseEuiccAccessActivity() {
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 ->
ViewCompat.setOnApplyWindowInsetsListener(window.decorView.rootView) { _, insets ->
val bars = insets.getInsets(
WindowInsetsCompat.Type.systemBars()
or WindowInsetsCompat.Type.displayCutout()
or WindowInsetsCompat.Type.displayCutout()
or WindowInsetsCompat.Type.ime()
)
v.updatePadding(bars.left, bars.top, bars.right, 0)
fragmentRoot.updatePadding(bars.left, bars.top, bars.right, 0)
navigation.updatePadding(bars.left, 0, bars.right, bars.bottom)
val newNavParams = navigation.layoutParams
newNavParams.height = origHeight + bars.bottom
navigation.layoutParams = newNavParams
WindowInsetsCompat.CONSUMED
}
}
@ -151,7 +163,7 @@ class DownloadWizardActivity: BaseEuiccAccessActivity() {
override fun onSaveInstanceState(outState: Bundle) {
super.onSaveInstanceState(outState)
outState.putString("currentStepFragmentClassName", state.currentStepFragmentClassName)
outState.putInt("selectedLogicalSlot", state.selectedLogicalSlot)
outState.putInt("selectedLogicalSlot", state.selectedSyntheticSlotId)
outState.putString("smdp", state.smdp)
outState.putString("matchingId", state.matchingId)
outState.putString("confirmationCode", state.confirmationCode)
@ -167,16 +179,20 @@ class DownloadWizardActivity: BaseEuiccAccessActivity() {
"currentStepFragmentClassName",
state.currentStepFragmentClassName
)
state.selectedLogicalSlot =
savedInstanceState.getInt("selectedLogicalSlot", state.selectedLogicalSlot)
state.selectedSyntheticSlotId =
savedInstanceState.getInt("selectedSyntheticSlotId", state.selectedSyntheticSlotId)
state.smdp = savedInstanceState.getString("smdp", state.smdp)
state.matchingId = savedInstanceState.getString("matchingId", state.matchingId)
state.imei = savedInstanceState.getString("imei", state.imei)
state.downloadStarted =
savedInstanceState.getBoolean("downloadStarted", state.downloadStarted)
state.downloadTaskID = savedInstanceState.getLong("downloadTaskID", state.downloadTaskID)
state.confirmationCode = savedInstanceState.getString("confirmationCode", state.confirmationCode)
state.confirmationCodeRequired = savedInstanceState.getBoolean("confirmationCodeRequired", state.confirmationCodeRequired)
state.confirmationCode =
savedInstanceState.getString("confirmationCode", state.confirmationCode)
state.confirmationCodeRequired = savedInstanceState.getBoolean(
"confirmationCodeRequired",
state.confirmationCodeRequired
)
}
private fun onPrevPressed() {
@ -200,10 +216,13 @@ class DownloadWizardActivity: BaseEuiccAccessActivity() {
progressBar.isIndeterminate = true
lifecycleScope.launch(Dispatchers.Main) {
if (state.selectedLogicalSlot >= 0) {
if (state.selectedSyntheticSlotId >= 0) {
try {
val (slotId, seId) = DownloadWizardSlotSelectFragment.decodeSyntheticSlotId(
state.selectedSyntheticSlotId
)
// This is run on IO by default
euiccChannelManager.withEuiccChannel(state.selectedLogicalSlot) { channel ->
euiccChannelManager.withEuiccChannel(slotId, seId) { channel ->
// Be _very_ sure that the channel we got is valid
if (!channel.valid) throw EuiccChannelManager.EuiccChannelNotFoundException()
}
@ -327,4 +346,4 @@ class DownloadWizardActivity: BaseEuiccAccessActivity() {
open fun beforeNext() {}
}
}
}

View file

@ -4,9 +4,11 @@ import android.os.Bundle
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import android.widget.EditText
import androidx.core.widget.addTextChangedListener
import com.google.android.material.textfield.TextInputLayout
import im.angry.openeuicc.common.R
import im.angry.openeuicc.util.*
class DownloadWizardDetailsFragment : DownloadWizardActivity.DownloadWizardStepFragment() {
private var inputComplete = false
@ -16,17 +18,25 @@ class DownloadWizardDetailsFragment : DownloadWizardActivity.DownloadWizardStepF
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 val address: EditText by lazy {
requireView().requireViewById<TextInputLayout>(R.id.profile_download_server).editText!!
}
private val matchingId: EditText by lazy {
requireView().requireViewById<TextInputLayout>(R.id.profile_download_code).editText!!
}
private val confirmationCode: EditText by lazy {
requireView().requireViewById<TextInputLayout>(R.id.profile_download_confirmation_code).editText!!
}
private val imei: EditText by lazy {
requireView().requireViewById<TextInputLayout>(R.id.profile_download_imei).editText!!
}
private fun saveState() {
state.smdp = smdp.editText!!.text.toString().trim()
state.smdp = address.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 }
state.matchingId = matchingId.text.toString().trim().ifBlank { null }
state.confirmationCode = confirmationCode.text.toString().trim().ifBlank { null }
state.imei = imei.text.toString().ifBlank { null }
}
override fun beforeNext() = saveState()
@ -41,40 +51,30 @@ class DownloadWizardDetailsFragment : DownloadWizardActivity.DownloadWizardStepF
DownloadWizardMethodSelectFragment()
}
override fun onCreateView(
inflater: LayoutInflater,
container: ViewGroup?,
savedInstanceState: Bundle?
): View? {
val view = inflater.inflate(R.layout.fragment_download_details, container, false)
smdp = view.requireViewById(R.id.profile_download_server)
matchingId = view.requireViewById(R.id.profile_download_code)
confirmationCode = view.requireViewById(R.id.profile_download_confirmation_code)
imei = view.requireViewById(R.id.profile_download_imei)
smdp.editText!!.addTextChangedListener {
updateInputCompleteness()
}
confirmationCode.editText!!.addTextChangedListener {
updateInputCompleteness()
}
return view
override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View =
inflater.inflate(R.layout.fragment_download_details, container, /* attachToRoot = */ false)
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
address.addTextChangedListener(onTextChanged = ::handlePasteLPAString)
address.addTextChangedListener { updateInputCompleteness() }
matchingId.addTextChangedListener(onTextChanged = ::handlePasteLPAString)
confirmationCode.addTextChangedListener { updateInputCompleteness() }
}
override fun onStart() {
super.onStart()
smdp.editText!!.setText(state.smdp)
matchingId.editText!!.setText(state.matchingId)
confirmationCode.editText!!.setText(state.confirmationCode)
imei.editText!!.setText(state.imei)
address.setText(state.smdp)
matchingId.setText(state.matchingId)
confirmationCode.setText(state.confirmationCode)
imei.setText(state.imei)
updateInputCompleteness()
if (state.confirmationCodeRequired) {
confirmationCode.editText!!.requestFocus()
confirmationCode.editText!!.hint =
getString(R.string.profile_download_confirmation_code_required)
confirmationCode.requestFocus()
confirmationCode.setHint(R.string.profile_download_confirmation_code_required)
} else {
confirmationCode.editText!!.hint =
getString(R.string.profile_download_confirmation_code)
confirmationCode.setHint(R.string.profile_download_confirmation_code)
}
}
@ -83,10 +83,19 @@ class DownloadWizardDetailsFragment : DownloadWizardActivity.DownloadWizardStepF
saveState()
}
private fun handlePasteLPAString(text: CharSequence?, start: Int, before: Int, count: Int) {
if (start > 0 || before > 0) return // only handle insertions at the beginning
if (text == null || !text.startsWith("LPA:", ignoreCase = true)) return
val parsed = LPAString.parse(text)
address.setText(parsed.address)
matchingId.setText(parsed.matchingId)
if (parsed.confirmationCodeRequired) confirmationCode.requestFocus()
}
private fun updateInputCompleteness() {
inputComplete = isValidAddress(smdp.editText!!.text)
inputComplete = isValidAddress(address.text)
if (state.confirmationCodeRequired) {
inputComplete = inputComplete && confirmationCode.editText!!.text.isNotEmpty()
inputComplete = inputComplete && confirmationCode.text.isNotEmpty()
}
refreshButtons()
}
@ -98,11 +107,11 @@ private fun isValidAddress(input: CharSequence): Boolean {
var port = 443
if (input.contains(':')) {
val portIndex = input.lastIndexOf(':')
fqdn = input.substring(0, portIndex)
fqdn = input.take(portIndex)
port = input.substring(portIndex + 1, input.length).toIntOrNull(10) ?: 0
}
// see https://en.wikipedia.org/wiki/Port_(computer_networking)
if (port < 1 || port > 0xffff) return false
if (port !in 1..0xffff) return false
// see https://en.wikipedia.org/wiki/Fully_qualified_domain_name
if (fqdn.isEmpty() || fqdn.length > 255) return false
for (part in fqdn.split('.')) {
@ -114,4 +123,4 @@ private fun isValidAddress(input: CharSequence): Boolean {
}
}
return true
}
}

View file

@ -138,4 +138,4 @@ class DownloadWizardDiagnosticsFragment : DownloadWizardActivity.DownloadWizardS
ret.toString()
}
}
}

View file

@ -170,4 +170,4 @@ class DownloadWizardMethodSelectFragment : DownloadWizardActivity.DownloadWizard
}
}
}
}

View file

@ -18,23 +18,11 @@ import im.angry.openeuicc.util.*
import kotlinx.coroutines.flow.collect
import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.launch
import net.typeblog.lpac_jni.ProfileDownloadInput
import net.typeblog.lpac_jni.ProfileDownloadState
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,
@ -138,7 +126,7 @@ class DownloadWizardProgressFragment : DownloadWizardActivity.DownloadWizardStep
}
is EuiccChannelManagerService.ForegroundTaskState.InProgress ->
updateProgress(it.progress)
updateProgress(it.context as? ProfileDownloadState ?: return@onEach)
else -> {}
}
@ -153,7 +141,12 @@ class DownloadWizardProgressFragment : DownloadWizardActivity.DownloadWizardStep
} else {
euiccChannelManagerService.waitForForegroundTask()
val (slotId, portId) = euiccChannelManager.withEuiccChannel(state.selectedLogicalSlot) { channel ->
val (logicalSlotId, seId) = DownloadWizardSlotSelectFragment.decodeSyntheticSlotId(state.selectedSyntheticSlotId)
val (slotId, portId) = euiccChannelManager.withEuiccChannel(
logicalSlotId,
seId
) { channel ->
Pair(channel.slotId, channel.portId)
}
@ -161,12 +154,8 @@ class DownloadWizardProgressFragment : DownloadWizardActivity.DownloadWizardStep
state.downloadStarted = true
val ret = euiccChannelManagerService.launchProfileDownloadTask(
slotId,
portId,
state.smdp,
state.matchingId,
state.confirmationCode,
state.imei
slotId, portId, seId,
ProfileDownloadInput(state.smdp, state.matchingId, state.imei, state.confirmationCode)
)
state.downloadTaskID = ret.taskId
@ -174,11 +163,19 @@ class DownloadWizardProgressFragment : DownloadWizardActivity.DownloadWizardStep
ret
}
private fun updateProgress(progress: Int) {
private fun updateProgress(state: ProfileDownloadState) {
val progress = state.downloadProgress
showProgressBar(progress)
val lpaState = ProfileDownloadCallback.lookupStateFromProgress(progress)
val stateIndex = LPA_PROGRESS_STATES.indexOf(lpaState)
val stateIndex = when (state) {
is ProfileDownloadState.Preparing -> 0
is ProfileDownloadState.Connecting -> 1
is ProfileDownloadState.Authenticating -> 2
// TODO: Actually implement metadata confirmation (a dialog or something else)
is ProfileDownloadState.ConfirmingDownload -> 2
is ProfileDownloadState.Downloading -> 3
is ProfileDownloadState.Finalizing -> 4
}
if (stateIndex > 0) {
for (i in (0..<stateIndex)) {
@ -258,4 +255,4 @@ class DownloadWizardProgressFragment : DownloadWizardActivity.DownloadWizardStep
holder.bind(progressItems[position])
}
}
}
}

View file

@ -7,36 +7,45 @@ 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.EuiccChannel
import im.angry.openeuicc.core.EuiccChannelManager
import im.angry.openeuicc.util.*
import kotlinx.coroutines.flow.flatMapConcat
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.toList
import kotlinx.coroutines.launch
class DownloadWizardSlotSelectFragment : DownloadWizardActivity.DownloadWizardStepFragment() {
companion object {
const val LOW_NVRAM_THRESHOLD =
30 * 1024 // < 30 KiB, alert about potential download failure
internal fun encodeSyntheticSlotId(logicalSlotId: Int, seId: EuiccChannel.SecureElementId): Int =
(logicalSlotId shl 16) + seId.id
internal fun decodeSyntheticSlotId(id: Int): Pair<Int, EuiccChannel.SecureElementId> =
Pair(id shr 16, EuiccChannel.SecureElementId.createFromInt(id and 0xFF))
}
private data class SlotInfo(
val logicalSlotId: Int,
val isRemovable: Boolean,
val hasMultiplePorts: Boolean,
val hasMultipleSEs: Boolean,
val portId: Int,
val seId: EuiccChannel.SecureElementId,
val eID: String,
val freeSpace: Int,
val imei: String,
val enabledProfileName: String?,
val intrinsicChannelName: String?,
)
) {
// A synthetic slot ID used to uniquely identify this slot + SE chip in the download process
// We assume we don't have anywhere near 2^16 ports...
val syntheticSlotId: Int = (logicalSlotId shl 16) + seId.id
}
private var loaded = false
@ -56,25 +65,6 @@ class DownloadWizardSlotSelectFragment : DownloadWizardActivity.DownloadWizardSt
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?,
@ -85,7 +75,12 @@ class DownloadWizardSlotSelectFragment : DownloadWizardActivity.DownloadWizardSt
recyclerView.adapter = adapter
recyclerView.layoutManager =
LinearLayoutManager(view.context, LinearLayoutManager.VERTICAL, false)
recyclerView.addItemDecoration(DividerItemDecoration(requireContext(), LinearLayoutManager.VERTICAL))
recyclerView.addItemDecoration(
DividerItemDecoration(
requireContext(),
LinearLayoutManager.VERTICAL
)
)
return view
}
@ -97,37 +92,41 @@ class DownloadWizardSlotSelectFragment : DownloadWizardActivity.DownloadWizardSt
}
@SuppressLint("NotifyDataSetChanged", "MissingPermission")
@OptIn(kotlinx.coroutines.FlowPreview::class)
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,
)
val slots = euiccChannelManager.flowAllOpenEuiccPorts().flatMapConcat { (slotId, portId) ->
euiccChannelManager.flowEuiccSecureElements(slotId, portId).map { seId ->
euiccChannelManager.withEuiccChannel(slotId, portId, seId) { channel ->
SlotInfo(
channel.logicalSlotId,
channel.port.card.isRemovable,
channel.port.card.ports.size > 1,
channel.hasMultipleSE,
channel.portId,
channel.seId,
channel.lpa.eID,
channel.lpa.euiccInfo2?.freeNvram ?: 0,
try {
telephonyManager.getImei(channel.logicalSlotId) ?: ""
} catch (e: Exception) {
""
},
channel.lpa.profiles.enabled?.displayName,
)
}
}
}.toList().sortedBy { it.logicalSlotId }
}.toList().sortedBy { it.syntheticSlotId }
adapter.slots = slots
// Ensure we always have a selected slot by default
val selectedIdx = slots.indexOfFirst { it.logicalSlotId == state.selectedLogicalSlot }
val selectedIdx = slots.indexOfFirst { it.syntheticSlotId == state.selectedSyntheticSlotId }
adapter.currentSelectedIdx = if (selectedIdx > 0) {
selectedIdx
} else {
if (slots.isNotEmpty()) {
state.selectedLogicalSlot = slots[0].logicalSlotId
state.selectedSyntheticSlotId = slots[0].syntheticSlotId
}
0
}
@ -167,7 +166,8 @@ class DownloadWizardSlotSelectFragment : DownloadWizardActivity.DownloadWizardSt
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.selectedSyntheticSlotId =
adapter.slots[adapter.currentSelectedIdx].syntheticSlotId
state.imei = adapter.slots[adapter.currentSelectedIdx].imei
}
@ -186,12 +186,22 @@ class DownloadWizardSlotSelectFragment : DownloadWizardActivity.DownloadWizardSt
}
title.text = if (item.logicalSlotId == EuiccChannelManager.USB_CHANNEL_ID) {
item.intrinsicChannelName ?: root.context.getString(R.string.channel_type_usb)
if (item.hasMultipleSEs) {
root.context.getString(R.string.channel_name_format_usb_se, item.seId.id)
} else {
root.context.getString(R.string.channel_name_format_usb)
}
} else if (item.hasMultipleSEs) {
appContainer.customizableTextProvider.formatNonUsbChannelNameWithSeId(
item.logicalSlotId,
item.seId
)
} else {
appContainer.customizableTextProvider.formatInternalChannelName(item.logicalSlotId)
appContainer.customizableTextProvider.formatNonUsbChannelName(item.logicalSlotId)
}
eID.text = item.eID
activeProfile.text = item.enabledProfileName ?: root.context.getString(R.string.profile_no_enabled_profile)
activeProfile.text = item.enabledProfileName
?: root.context.getString(R.string.profile_no_enabled_profile)
freeSpace.text = formatFreeSpace(item.freeSpace)
checkBox.isChecked = adapter.currentSelectedIdx == idx
}
@ -205,7 +215,8 @@ class DownloadWizardSlotSelectFragment : DownloadWizardActivity.DownloadWizardSt
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)
val root = LayoutInflater.from(parent.context)
.inflate(R.layout.download_slot_item, parent, false)
return SlotItemHolder(root)
}
@ -215,4 +226,4 @@ class DownloadWizardSlotSelectFragment : DownloadWizardActivity.DownloadWizardSt
holder.bind(slots[position], position)
}
}
}
}

View file

@ -43,6 +43,10 @@ enum class SimplifiedErrorMessages(
R.string.download_wizard_error_profile_unreleased,
R.string.download_wizard_error_suggest_contact_reissue
),
UnavailableProfile(
R.string.download_wizard_error_profile_unavailable,
R.string.download_wizard_error_suggest_contact_carrier
),
MatchingIDRefused(
R.string.download_wizard_error_matching_id_refused,
R.string.download_wizard_error_suggest_contact_carrier
@ -82,21 +86,39 @@ enum class SimplifiedErrorMessages(
companion object {
private val httpErrors = buildMap {
// @formatter:off
// Stage: InitiateAuthentication
put("8.8.1" to "3.8", UnknownHost) // Invalid SM-DP+ Address.
put("8.8.2" to "3.1", UnsupportedProfile) // None of the proposed Public Key Identifiers is supported by the SM-DP+.
put("8.8.3" to "3.1", UnsupportedProfile) // The SVN indicated by the eUICC is not supported by the SM-DP+.
put("8.8.4" to "3.7", UnsupportedProfile) // The SM-DP+ has no CERT.DPAuth.ECDSA signed by one of the GSMA CI Public Key supported by the eUICC.
// Stage: AuthenticateClient
put("8.1" to "4.8", InsufficientMemory)
put("8.1.1" to "2.1", EIDNotSupported)
put("8.1.1" to "3.8", EIDMismatch)
put("8.2" to "1.2", UnreleasedProfile)
put("8.2.6" to "3.8", MatchingIDRefused)
put("8.8.5" to "6.4", ProfileRetriesExceeded)
put("8.1" to "4.8", InsufficientMemory) // eUICC does not have sufficient space for this Profile.
put("8.1.1" to "2.1", EIDNotSupported) // eUICC does not support the EID.
put("8.1.1" to "3.8", EIDMismatch) // EID doesn't match the expected value.
put("8.1.2" to "6.1", UnsupportedProfile) // EUM Certificate is invalid.
put("8.1.2" to "6.3", UnsupportedProfile) // EUM Certificate has expired.
put("8.1.3" to "6.1", UnsupportedProfile) // eUICC Certificate is invalid.
put("8.1.3" to "6.3", UnsupportedProfile) // eUICC Certificate has expired.
put("8.2" to "1.2", UnreleasedProfile) // Profile has not yet been released.
put("8.2.5" to "4.3", UnavailableProfile) // No eligible Profile for this eUICC/Device.
put("8.2.6" to "3.8", MatchingIDRefused) // MatchingID (AC_Token or EventID) is refused.
put("8.8" to "4.2", EIDNotSupported) // eUICC is not supported by the SM-DP+.
put("8.8.5" to "6.4", ProfileRetriesExceeded) // The maximum number of retries for the Profile download order has been exceeded.
put("8.10.1" to "3.9", UnsupportedProfile) // The RSP session identified by the TransactionID is unknown.
put("8.11.1" to "3.9", UnsupportedProfile) // Unknown CI Public Key.
// Stage: GetBoundProfilePackage
put("8.2.7" to "2.2", ConfirmationCodeMissing)
put("8.2.7" to "3.8", ConfirmationCodeRefused)
put("8.2.7" to "6.4", ConfirmationCodeRetriesExceeded)
put("8.2" to "3.7", UnavailableProfile) // BPP is not available for a new binding.
put("8.2.7" to "2.2", ConfirmationCodeMissing) // Confirmation Code is missing.
put("8.2.7" to "3.8", ConfirmationCodeRefused) // Confirmation Code is refused.
put("8.2.7" to "6.4", ConfirmationCodeRetriesExceeded) // The maximum number of retries for the Confirmation Code has been exceeded.
// Stage: AuthenticateClient, GetBoundProfilePackage
put("8.8.5" to "4.10", ProfileExpired)
put("8.1" to "6.1", UnsupportedProfile) // eUICC Signature is invalid.
put("8.8.5" to "4.10", ProfileExpired) // The Download order has expired.
// @formatter:on
}
fun fromDownloadError(exc: LocalProfileAssistant.ProfileDownloadException) = when {
@ -145,8 +167,8 @@ enum class SimplifiedErrorMessages(
private fun fromAPDUResponse(resp: ByteArray): SimplifiedErrorMessages? {
val isSuccess = resp.size >= 2 &&
resp[resp.size - 2] == 0x90.toByte() &&
resp[resp.size - 1] == 0x00.toByte()
resp[resp.size - 2] == 0x90.toByte() &&
resp[resp.size - 1] == 0x00.toByte()
if (isSuccess) return null
return CardInternalError
}

View file

@ -1,5 +1,6 @@
package im.angry.openeuicc.util
import android.os.Build
import android.os.Bundle
import androidx.fragment.app.Fragment
import im.angry.openeuicc.core.EuiccChannel
@ -9,6 +10,7 @@ import im.angry.openeuicc.ui.BaseEuiccAccessActivity
private const val FIELD_SLOT_ID = "slotId"
private const val FIELD_PORT_ID = "portId"
private const val FIELD_SE_ID = "seId"
interface EuiccChannelFragmentMarker : OpenEuiccContextMarker
@ -17,12 +19,19 @@ private typealias BundleSetter = Bundle.() -> Unit
// We must use extension functions because there is no way to add bounds to the type of "self"
// in the definition of an interface, so the only way is to limit where the extension functions
// can be applied.
fun <T> newInstanceEuicc(clazz: Class<T>, slotId: Int, portId: Int, addArguments: BundleSetter = {}): T
where T : Fragment, T : EuiccChannelFragmentMarker =
fun <T> newInstanceEuicc(
clazz: Class<T>,
slotId: Int,
portId: Int,
seId: EuiccChannel.SecureElementId,
addArguments: BundleSetter = {}
): T
where T : Fragment, T : EuiccChannelFragmentMarker =
clazz.getDeclaredConstructor().newInstance().apply {
arguments = Bundle()
arguments!!.putInt(FIELD_SLOT_ID, slotId)
arguments!!.putInt(FIELD_PORT_ID, portId)
arguments!!.putParcelable(FIELD_SE_ID, seId)
arguments!!.addArguments()
}
@ -30,31 +39,43 @@ fun <T> newInstanceEuicc(clazz: Class<T>, slotId: Int, portId: Int, addArguments
// `channel` requires that the channel actually exists in EuiccChannelManager, which is
// not always the case during operations such as switching
val <T> T.slotId: Int
where T : Fragment, T : EuiccChannelFragmentMarker
where T : Fragment, T : EuiccChannelFragmentMarker
get() = requireArguments().getInt(FIELD_SLOT_ID)
val <T> T.portId: Int
where T : Fragment, T : EuiccChannelFragmentMarker
where T : Fragment, T : EuiccChannelFragmentMarker
get() = requireArguments().getInt(FIELD_PORT_ID)
val <T> T.seId: EuiccChannel.SecureElementId
where T : Fragment, T : EuiccChannelFragmentMarker
get() =
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
requireArguments().getParcelable(
FIELD_SE_ID,
EuiccChannel.SecureElementId::class.java
)!!
} else {
@Suppress("DEPRECATION")
requireArguments().getParcelable(FIELD_SE_ID)!!
}
val <T> T.isUsb: Boolean
where T : Fragment, T : EuiccChannelFragmentMarker
where T : Fragment, T : EuiccChannelFragmentMarker
get() = slotId == EuiccChannelManager.USB_CHANNEL_ID
private fun <T> T.requireEuiccActivity(): BaseEuiccAccessActivity
where T : Fragment, T : OpenEuiccContextMarker =
where T : Fragment, T : OpenEuiccContextMarker =
requireActivity() as BaseEuiccAccessActivity
val <T> T.euiccChannelManager: EuiccChannelManager
where T : Fragment, T : OpenEuiccContextMarker
where T : Fragment, T : OpenEuiccContextMarker
get() = requireEuiccActivity().euiccChannelManager
val <T> T.euiccChannelManagerService: EuiccChannelManagerService
where T : Fragment, T : OpenEuiccContextMarker
where T : Fragment, T : OpenEuiccContextMarker
get() = requireEuiccActivity().euiccChannelManagerService
suspend fun <T, R> T.withEuiccChannel(fn: suspend (EuiccChannel) -> R): R
where T : Fragment, T : EuiccChannelFragmentMarker {
where T : Fragment, T : EuiccChannelFragmentMarker {
ensureEuiccChannelManager()
return euiccChannelManager.withEuiccChannel(slotId, portId, fn)
return euiccChannelManager.withEuiccChannel(slotId, portId, seId, fn)
}
suspend fun <T> T.ensureEuiccChannelManager() where T : Fragment, T : OpenEuiccContextMarker =
@ -69,4 +90,4 @@ fun <T> T.notifyEuiccProfilesChanged() where T : Fragment {
interface EuiccProfilesChangedListener {
fun onEuiccProfilesChanged()
}
}

View file

@ -7,7 +7,7 @@ data class LPAString(
val confirmationCodeRequired: Boolean,
) {
companion object {
fun parse(input: String): LPAString {
fun parse(input: CharSequence): LPAString {
var token = input
if (token.startsWith("LPA:", ignoreCase = true)) token = token.drop(4)
val components = token.split('$').map { it.trim().ifBlank { null } }
@ -31,4 +31,4 @@ data class LPAString(
)
return parts.joinToString("$").trimEnd('$')
}
}
}

View file

@ -5,6 +5,8 @@ import im.angry.openeuicc.core.EuiccChannel
import im.angry.openeuicc.core.EuiccChannelManager
import net.typeblog.lpac_jni.LocalProfileAssistant
import net.typeblog.lpac_jni.LocalProfileInfo
import net.typeblog.lpac_jni.ProfileClass
import net.typeblog.lpac_jni.ProfileDownloadState
const val TAG = "LPAUtils"
@ -16,11 +18,21 @@ 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 == ProfileClass.Operational || it.isEnabled }
val List<LocalProfileInfo>.enabled: LocalProfileInfo?
get() = find { it.isEnabled }
val ProfileDownloadState.downloadProgress: Int
get() = when (this) {
is ProfileDownloadState.Preparing -> 0
is ProfileDownloadState.Connecting -> 20
is ProfileDownloadState.Authenticating -> 40
is ProfileDownloadState.ConfirmingDownload -> 50
is ProfileDownloadState.Downloading -> 60
is ProfileDownloadState.Finalizing -> 80
}
val List<EuiccChannel>.hasMultipleChips: Boolean
get() = distinctBy { it.slotId }.size > 1
@ -79,9 +91,10 @@ fun LocalProfileAssistant.disableActiveProfileKeepIccId(refresh: Boolean): Strin
suspend inline fun EuiccChannelManager.beginTrackedOperation(
slotId: Int,
portId: Int,
seId: EuiccChannel.SecureElementId,
op: () -> Boolean
) {
val latestSeq = withEuiccChannel(slotId, portId) { channel ->
val latestSeq = withEuiccChannel(slotId, portId, seId) { channel ->
channel.lpa.notifications.firstOrNull()?.seqNumber
?: 0
}
@ -91,7 +104,7 @@ suspend inline fun EuiccChannelManager.beginTrackedOperation(
try {
// Note that the exact instance of "channel" might have changed here if reconnected;
// this is why we need to use two distinct calls to withEuiccChannel()
withEuiccChannel(slotId, portId) { channel ->
withEuiccChannel(slotId, portId, seId) { channel ->
channel.lpa.notifications.filter { it.seqNumber > latestSeq }.forEach {
Log.d(TAG, "Handling notification $it")
channel.lpa.handleNotification(it.seqNumber)
@ -103,4 +116,4 @@ suspend inline fun EuiccChannelManager.beginTrackedOperation(
}
}
Log.d(TAG, "Operation complete")
}
}

View file

@ -37,7 +37,6 @@ internal object PreferenceKeys {
val REFRESH_AFTER_SWITCH = booleanPreferencesKey("refresh_after_switch")
val UNFILTERED_PROFILE_LIST = booleanPreferencesKey("unfiltered_profile_list")
val IGNORE_TLS_CERTIFICATE = booleanPreferencesKey("ignore_tls_certificate")
val EUICC_MEMORY_RESET = booleanPreferencesKey("euicc_memory_reset")
val ISDR_AID_LIST = stringPreferencesKey("isdr_aid_list")
val ES10X_MSS = intPreferencesKey("es10x_mss")
}
@ -50,14 +49,9 @@ internal object PreferenceConstants {
# Refs: <https://euicc-manual.osmocom.org/docs/lpa/applet-id-oem/>
# eUICC standard
# Even if this AID is deleted here, it will still be attempted as the last resort.
$EUICC_DEFAULT_ISDR_AID
# ESTKme AUX (deprecated, use SE0 instead)
A06573746B6D65FFFFFFFF4953442D52
# ESTKme SE0
A06573746B6D65FFFF4953442D522030
# eSIM.me
A0000005591010000000008900000300
@ -66,6 +60,20 @@ internal object PreferenceConstants {
# Xesim
A0000005591010FFFFFFFF8900000177
# LinksField
A000000559104C696E6B736669656C64
# ESTKme SE0
# For multi-SE eSTK.me products, this will always be attempted even if removed from the list
${ESTKme.ESTK_SE0_AID.encodeHex()}
# ESTKme SE1
# For multi-SE eSTK.me products, this will always be attempted even if removed from the list
${ESTKme.ESTK_SE1_AID.encodeHex()}
# ESTKme AUX (deprecated, use SE0 instead)
A06573746B6D65FFFFFFFF4953442D52
""".trimIndent()
}
@ -85,7 +93,6 @@ open class PreferenceRepository(private val context: Context) {
val developerOptionsEnabledFlow = bindFlow(PreferenceKeys.DEVELOPER_OPTIONS_ENABLED, false)
val unfilteredProfileListFlow = bindFlow(PreferenceKeys.UNFILTERED_PROFILE_LIST, false)
val ignoreTLSCertificateFlow = bindFlow(PreferenceKeys.IGNORE_TLS_CERTIFICATE, false)
val euiccMemoryResetFlow = bindFlow(PreferenceKeys.EUICC_MEMORY_RESET, false)
val isdrAidListFlow = bindFlow(
PreferenceKeys.ISDR_AID_LIST,
PreferenceConstants.DEFAULT_AID_LIST,

View file

@ -31,13 +31,23 @@ fun formatFreeSpace(size: Int): String =
/**
* Decode a list of potential ISDR AIDs, one per line. Lines starting with '#' are ignored.
* If none is found, at least EUICC_DEFAULT_ISDR_AID is returned
* If none is found, at least EUICC_DEFAULT_ISDR_AID is returned.
* If EUICC_DEFAULT_ISDR_AID is not contained in the list, it is always appended as the last
* element.
*/
fun parseIsdrAidList(s: String): List<ByteArray> =
s.split('\n')
fun parseIsdrAidList(s: String): List<ByteArray> {
val ret = s.split('\n')
.asSequence()
.map(String::trim)
.filter { !it.startsWith('#') }
.map(String::trim)
.filter(String::isNotEmpty)
.mapNotNull { runCatching(it::decodeHex).getOrNull() }
.ifEmpty { listOf(EUICC_DEFAULT_ISDR_AID.decodeHex()) }
.toList()
return if (!ret.any { it.contentEquals(EUICC_DEFAULT_ISDR_AID.decodeHex()) }) {
ret + EUICC_DEFAULT_ISDR_AID.decodeHex()
} else {
ret
}
}

View file

@ -1,16 +1,9 @@
package im.angry.openeuicc.util
import android.content.Context
import android.os.Build
import android.se.omapi.Reader
import android.se.omapi.SEService
import android.telephony.TelephonyManager
import kotlinx.coroutines.runBlocking
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
import kotlin.coroutines.resume
import kotlin.coroutines.resumeWithException
import kotlin.coroutines.suspendCoroutine
val TelephonyManager.activeModemCountCompat: Int
get() = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
@ -55,16 +48,11 @@ interface UiccPortInfoCompat {
val logicalSlotIndex: Int
}
data class FakeUiccCardInfoCompat(
override val physicalSlotIndex: Int,
): UiccCardInfoCompat {
override val ports: Collection<UiccPortInfoCompat> =
listOf(FakeUiccPortInfoCompat(this))
data class FakeUiccCardInfoCompat(override val physicalSlotIndex: Int) : UiccCardInfoCompat {
override val ports: Collection<UiccPortInfoCompat> = listOf(FakeUiccPortInfoCompat(this))
}
data class FakeUiccPortInfoCompat(
override val card: UiccCardInfoCompat
): UiccPortInfoCompat {
data class FakeUiccPortInfoCompat(override val card: UiccCardInfoCompat) : UiccPortInfoCompat {
override val portIndex: Int = 0
override val logicalSlotIndex: Int = card.physicalSlotIndex
}
}

View file

@ -11,6 +11,7 @@ import androidx.activity.result.ActivityResultCaller
import androidx.activity.result.contract.ActivityResultContracts
import androidx.appcompat.app.AlertDialog
import androidx.appcompat.app.AppCompatActivity
import androidx.core.graphics.Insets
import androidx.core.view.ViewCompat
import androidx.core.view.WindowInsetsCompat
import androidx.core.view.updateLayoutParams
@ -35,46 +36,71 @@ fun DialogFragment.setWidthPercent(percentage: Int) {
}
/**
* Call this method (in onActivityCreated or later)
* to make the dialog near-full screen.
* A handler function for `setupRootViewSystemBarInsets`, which is intended to set up
* insets for the top toolbar, in the case where the activity contains a toolbar with the default
* ID `R.id.toolbar`, and a spacer `R.id.toolbar_spacer` for status bar background.
*/
fun DialogFragment.setFullScreen() {
dialog?.window?.setLayout(ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.WRAP_CONTENT)
}
fun AppCompatActivity.setupToolbarInsets() {
val spacer = requireViewById<View>(R.id.toolbar_spacer)
ViewCompat.setOnApplyWindowInsetsListener(requireViewById(R.id.toolbar)) { v, insets ->
val bars = insets.getInsets(
WindowInsetsCompat.Type.systemBars()
or WindowInsetsCompat.Type.displayCutout()
)
v.updateLayoutParams<ViewGroup.MarginLayoutParams> {
topMargin = bars.top
fun AppCompatActivity.activityToolbarInsetHandler(insets: Insets) {
val toolbar = requireViewById<View>(R.id.toolbar)
toolbar.apply {
updateLayoutParams<ViewGroup.MarginLayoutParams> {
topMargin = insets.top
}
v.updatePadding(bars.left, v.paddingTop, bars.right, v.paddingBottom)
updatePadding(insets.left, paddingTop, insets.right, paddingBottom)
}
spacer.updateLayoutParams {
height = v.top
}
WindowInsetsCompat.CONSUMED
requireViewById<View>(R.id.toolbar_spacer).updateLayoutParams {
height = toolbar.top
}
}
fun setupRootViewInsets(view: ViewGroup) {
/**
* A handler function for `setupRootViewSystemBarInsets`, which is intended to set up
* left, right, and bottom padding for a "main view", usually a RecyclerView or a ScrollView.
*
* It ignores top paddings because that should be handled by the toolbar handler for the activity.
* See above.
*/
fun mainViewPaddingInsetHandler(v: View): (Insets) -> Unit = { insets ->
// Disable clipToPadding to make sure content actually display
view.clipToPadding = false
ViewCompat.setOnApplyWindowInsetsListener(view) { v, insets ->
if (v is ViewGroup) {
v.clipToPadding = false
}
v.updatePadding(insets.left, v.paddingTop, insets.right, insets.bottom)
}
/**
* A wrapper for `ViewCompat.setOnApplyWindowInsetsListener`, which should only be called
* on a root view of a certain component. For activities, this should usually be `window.decorView.rootView`,
* and for Fragments this should be the outermost layer of view it inflated during creation.
*
* This function takes in an array of handler functions, and is expected to only ever be called
* on views belonging to the same hierarchy. All sibling views should be handled from the array of
* handler functions, rather than a separate call to this function OR `ViewCompat.setOnApplyWindowInsetsListener`.
*
* The reason this function exists is that on some versions of Android, the dispatch of window inset
* events is completely broken. If an inset event is handled by a view, it will never be seen by any of
* its siblings. By wrapping this function and restricting its use to only the "main" view hierarchy and
* handling all sibling views using our own handler functions, we work around that issue.
*
* Note that this function by default returns `WindowInsetCompat.CONSUME`, which will prevent the event from
* being dispatched further to child views. This may be a problem for activities that act as fragment hosts.
* In that case, please set `consume = false` in order for the event to propagate.
*/
fun setupRootViewSystemBarInsets(rootView: View, handlers: Array<(Insets) -> Unit>, consume: Boolean = true) {
ViewCompat.setOnApplyWindowInsetsListener(rootView) { _, insets ->
val bars = insets.getInsets(
WindowInsetsCompat.Type.systemBars()
or WindowInsetsCompat.Type.displayCutout()
or WindowInsetsCompat.Type.displayCutout()
)
v.updatePadding(bars.left, v.paddingTop, bars.right, bars.bottom)
handlers.forEach { it(bars) }
WindowInsetsCompat.CONSUMED
if (consume) {
WindowInsetsCompat.CONSUMED
} else {
insets
}
}
}
@ -121,4 +147,4 @@ fun <T : ActivityResultCaller> T.setupLogSaving(
lastFileName = getLogFileName()
launchSaveIntent.launch(lastFileName)
}
}
}

View file

@ -1,6 +1,7 @@
package im.angry.openeuicc.util
import android.content.Context
import android.content.pm.PackageInfo
import android.content.pm.PackageManager
import android.graphics.Bitmap
import android.se.omapi.SEService
@ -17,19 +18,18 @@ import kotlinx.coroutines.runBlocking
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
import kotlinx.coroutines.withContext
import kotlin.RuntimeException
import kotlin.coroutines.resume
import kotlin.coroutines.resumeWithException
import kotlin.coroutines.suspendCoroutine
val Context.packageInfo: PackageInfo
get() = packageManager.getPackageInfo(packageName, /* flags = */ 0)!!
val Context.selfAppVersion: String
get() =
try {
val pInfo = packageManager.getPackageInfo(packageName, 0)
pInfo.versionName!!
} catch (e: PackageManager.NameNotFoundException) {
throw RuntimeException(e)
}
get() = packageInfo.versionName!!
val Context.selfAppVersionCode: Long
get() = packageInfo.longVersionCode
suspend fun readSelfLog(lines: Int = 2048): String = withContext(Dispatchers.IO) {
try {
@ -96,13 +96,12 @@ inline fun <T> Bitmap.use(f: (Bitmap) -> T): T =
recycle()
}
fun decodeQrFromBitmap(bmp: Bitmap): String? =
runCatching {
val pixels = IntArray(bmp.width * bmp.height)
bmp.getPixels(pixels, 0, bmp.width, 0, 0, bmp.width, bmp.height)
fun decodeQrFromBitmap(bmp: Bitmap): String? = runCatching {
val pixels = IntArray(bmp.width * bmp.height)
bmp.getPixels(pixels, 0, bmp.width, 0, 0, bmp.width, bmp.height)
val luminanceSource = RGBLuminanceSource(bmp.width, bmp.height, pixels)
val binaryBmp = BinaryBitmap(HybridBinarizer(luminanceSource))
val luminanceSource = RGBLuminanceSource(bmp.width, bmp.height, pixels)
val binaryBmp = BinaryBitmap(HybridBinarizer(luminanceSource))
QRCodeReader().decode(binaryBmp).text
}.getOrNull()
QRCodeReader().decode(binaryBmp).text
}.getOrNull()

View file

@ -1,50 +1,59 @@
package im.angry.openeuicc.util
import android.util.Log
import im.angry.openeuicc.core.ApduInterfaceAtrProvider
import im.angry.openeuicc.core.EuiccChannel
import net.typeblog.lpac_jni.Version
data class EuiccVendorInfo(
val skuName: String?,
val serialNumber: String?,
val bootloaderVersion: String?,
val firmwareVersion: String?,
val skuName: String? = null,
val serialNumber: String? = null,
val firmwareVersion: String? = null,
)
private val EUICC_VENDORS: Array<EuiccVendor> = arrayOf(EstkMe(), SimLink())
private val EUICC_VENDORS: Array<EuiccVendor> = arrayOf(ESTKme(), SIMLink())
fun EuiccChannel.tryParseEuiccVendorInfo(): EuiccVendorInfo? {
EUICC_VENDORS.forEach { vendor ->
vendor.tryParseEuiccVendorInfo(this@tryParseEuiccVendorInfo)?.let {
return it
}
}
fun EuiccChannel.tryParseEuiccVendorInfo(): EuiccVendorInfo? =
EUICC_VENDORS.firstNotNullOfOrNull { it.tryParseEuiccVendorInfo(this) }
return null
fun EuiccChannel.queryVendorAidListTransformation(aidList: List<ByteArray>): Pair<List<ByteArray>, VendorAidDecider>? =
EUICC_VENDORS.firstNotNullOfOrNull { it.transformAidListIfNeeded(this, aidList) }
fun interface VendorAidDecider {
/**
* Given a list of already opened AIDs, should we still attempt to open the next?
*/
fun shouldOpenMore(openedAids: List<ByteArray>, nextAid: ByteArray): Boolean
}
interface EuiccVendor {
fun tryParseEuiccVendorInfo(channel: EuiccChannel): EuiccVendorInfo?
/**
* Removable eSIM products from some vendors may prefer a vendor-specific list of AIDs or
* a specific ordering. For example, multi-SE products from eSTK.me might prefer us trying
* SE0 and SE1 AIDs first instead of the generic GSMA ISD-R AID. This method is intended
* to implement these vendor-specific cases.
*
* This method is called on an already opened `EuiccChannel`. If the method returns a non-null
* value, the channel will be closed and the process that attempts to open all channels will
* be restarted from the beginning. The method will not be called again for the same chip,
* but it should still ensure idempotency when called with an already-transformed input.
*
* The second return value of this method is used to decide when we should stop attempting more
* AIDs from the list.
*/
fun transformAidListIfNeeded(
referenceChannel: EuiccChannel,
aidList: List<ByteArray>
): Pair<List<ByteArray>, VendorAidDecider>? = null
}
private class EstkMe : EuiccVendor {
class ESTKme : EuiccVendor {
companion object {
private val PRODUCT_AID = "A06573746B6D65FFFFFFFFFFFF6D6774".decodeHex()
private val PRODUCT_ATR_FPR = "estk.me".encodeToByteArray()
}
private fun checkAtr(channel: EuiccChannel): Boolean {
val iface = channel.apduInterface
if (iface !is ApduInterfaceAtrProvider) return false
val atr = iface.atr ?: return false
for (index in atr.indices) {
if (atr.size - index < PRODUCT_ATR_FPR.size) break
if (atr.sliceArray(index until index + PRODUCT_ATR_FPR.size)
.contentEquals(PRODUCT_ATR_FPR)
) return true
}
return false
val ESTK_SE0_AID = "A06573746B6D65FFFF4953442D522030".decodeHex()
val ESTK_SE1_AID = "A06573746B6D65FFFF4953442D522031".decodeHex()
}
private fun decodeAsn1String(b: ByteArray): String? {
@ -54,8 +63,6 @@ private class EstkMe : EuiccVendor {
}
override fun tryParseEuiccVendorInfo(channel: EuiccChannel): EuiccVendorInfo? {
if (!checkAtr(channel)) return null
val iface = channel.apduInterface
return try {
iface.withLogicalChannel(PRODUCT_AID) { transmit ->
@ -64,8 +71,11 @@ private class EstkMe : EuiccVendor {
EuiccVendorInfo(
skuName = invoke(0x03),
serialNumber = invoke(0x00),
bootloaderVersion = invoke(0x01),
firmwareVersion = invoke(0x02),
firmwareVersion = run {
val bl = invoke(0x01) // bootloader version
val fw = invoke(0x02) // firmware version
if (bl == null || fw == null) null else "$bl-$fw"
},
)
}
} catch (e: Exception) {
@ -73,9 +83,38 @@ private class EstkMe : EuiccVendor {
null
}
}
override fun transformAidListIfNeeded(
referenceChannel: EuiccChannel,
aidList: List<ByteArray>
): Pair<List<ByteArray>, VendorAidDecider>? {
try {
referenceChannel.apduInterface.withLogicalChannel(PRODUCT_AID) {}
} catch (_: Exception) {
// Not eSTK!
return null
}
// If we get here, this is eSTK, and we need to rearrange aidList such that:
// 1. SE0 and SE1 AIDs are _always_ included in the list
// 2. SE0 and SE1 AIDs are always sorted at the beginning of the list
val expected = listOf(ESTK_SE0_AID, ESTK_SE1_AID, *aidList.filter {
!it.contentEquals(ESTK_SE0_AID) && !it.contentEquals(ESTK_SE1_AID)
}.toTypedArray())
return if (expected == aidList) {
null
} else {
Pair(expected, VendorAidDecider { openedAids, nextAid ->
// Don't open any more channels if we have reached the GSMA default AID and at least 1
// eSTK AID has been opened (note that above we re-sorted them to the top of the list)
!(openedAids.isNotEmpty() && nextAid.contentEquals(EUICC_DEFAULT_ISDR_AID.decodeHex()))
})
}
}
}
private class SimLink : EuiccVendor {
class SIMLink : EuiccVendor {
companion object {
private val EID_PATTERN = Regex("^89044045(84|21)67274948")
}
@ -86,6 +125,7 @@ private class SimLink : EuiccVendor {
if (version == null || EID_PATTERN.find(eid, 0) == null) return null
val versionName = when {
// @formatter:off
version >= Version(37, 4, 3) -> "v3.2 (beta 1)"
version >= Version(37, 1, 41) -> "v3.1 (beta 1)"
version >= Version(36, 18, 5) -> "v3 (final)"
version >= Version(36, 17, 39) -> "v3 (beta)"
@ -102,11 +142,6 @@ private class SimLink : EuiccVendor {
"9eSIM $versionName"
}
return EuiccVendorInfo(
skuName = skuName,
serialNumber = null,
bootloaderVersion = null,
firmwareVersion = null
)
return EuiccVendorInfo(skuName = skuName)
}
}
}

View file

@ -1,6 +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:interpolator="@android:anim/decelerate_interpolator"
android:toXDelta="0%" />

View file

@ -1,6 +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:interpolator="@android:anim/decelerate_interpolator"
android:toXDelta="0%" />

View file

@ -1,6 +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:interpolator="@android:anim/decelerate_interpolator"
android:toXDelta="-100%" />

View file

@ -1,6 +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:interpolator="@android:anim/decelerate_interpolator"
android:toXDelta="100%" />

View file

@ -1,7 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<shape xmlns:android="http://schemas.android.com/apk/res/android">
<solid
android:color="?attr/colorSurface"/>
<corners
android:radius="?attr/dialogCornerRadius" />
</shape>
<solid android:color="?attr/colorSurface" />
<corners android:radius="?attr/dialogCornerRadius" />
</shape>

View file

@ -1,5 +1,10 @@
<vector android:height="24dp" android:tint="#FFFFFF"
android:viewportHeight="24" android:viewportWidth="24"
android:width="24dp" xmlns:android="http://schemas.android.com/apk/res/android">
<path android:fillColor="@android:color/white" android:pathData="M19,13h-6v6h-2v-6H5v-2h6V5h2v6h6v2z"/>
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:tint="#FFFFFF"
android:viewportWidth="24"
android:viewportHeight="24">
<path
android:fillColor="@android:color/white"
android:pathData="M19,13h-6v6h-2v-6H5v-2h6V5h2v6h6v2z" />
</vector>

View file

@ -1,10 +1,10 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:tint="?attr/colorControlNormal"
android:viewportWidth="24"
android:viewportHeight="24"
android:tint="?attr/colorControlNormal">
<path
android:fillColor="@android:color/white"
android:pathData="M9,16.17L4.83,12l-1.42,1.41L9,19 21,7l-1.41,-1.41z"/>
android:viewportHeight="24">
<path
android:fillColor="@android:color/white"
android:pathData="M9,16.17L4.83,12l-1.42,1.41L9,19 21,7l-1.41,-1.41z" />
</vector>

View file

@ -1,5 +1,10 @@
<vector android:height="24dp" android:tint="?attr/colorControlNormal"
android:viewportHeight="24" android:viewportWidth="24"
android:width="24dp" xmlns:android="http://schemas.android.com/apk/res/android">
<path android:fillColor="@android:color/white" android:pathData="M16.59,7.58L10,14.17l-3.59,-3.58L5,12l5,5 8,-8zM12,2C6.48,2 2,6.48 2,12s4.48,10 10,10 10,-4.48 10,-10S17.52,2 12,2zM12,20c-4.42,0 -8,-3.58 -8,-8s3.58,-8 8,-8 8,3.58 8,8 -3.58,8 -8,8z"/>
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:tint="?attr/colorControlNormal"
android:viewportWidth="24"
android:viewportHeight="24">
<path
android:fillColor="@android:color/white"
android:pathData="M16.59,7.58L10,14.17l-3.59,-3.58L5,12l5,5 8,-8zM12,2C6.48,2 2,6.48 2,12s4.48,10 10,10 10,-4.48 10,-10S17.52,2 12,2zM12,20c-4.42,0 -8,-3.58 -8,-8s3.58,-8 8,-8 8,3.58 8,8 -3.58,8 -8,8z" />
</vector>

View file

@ -1,5 +1,12 @@
<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 xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:tint="#000000"
android:viewportWidth="24"
android:viewportHeight="24">
<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

@ -1,5 +1,12 @@
<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 xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:tint="#000000"
android:viewportWidth="24"
android:viewportHeight="24">
<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

@ -1,5 +1,12 @@
<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 xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:tint="#000000"
android:viewportWidth="24"
android:viewportHeight="24">
<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

@ -1,5 +1,10 @@
<vector android:height="24dp" android:tint="?attr/colorControlNormal"
android:viewportHeight="24" android:viewportWidth="24"
android:width="24dp" xmlns:android="http://schemas.android.com/apk/res/android">
<path android:fillColor="@android:color/white" android:pathData="M11,15h2v2h-2zM11,7h2v6h-2zM11.99,2C6.47,2 2,6.48 2,12s4.47,10 9.99,10C17.52,22 22,17.52 22,12S17.52,2 11.99,2zM12,20c-4.42,0 -8,-3.58 -8,-8s3.58,-8 8,-8 8,3.58 8,8 -3.58,8 -8,8z"/>
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:tint="?attr/colorControlNormal"
android:viewportWidth="24"
android:viewportHeight="24">
<path
android:fillColor="@android:color/white"
android:pathData="M11,15h2v2h-2zM11,7h2v6h-2zM11.99,2C6.47,2 2,6.48 2,12s4.47,10 9.99,10C17.52,22 22,17.52 22,12S17.52,2 11.99,2zM12,20c-4.42,0 -8,-3.58 -8,-8s3.58,-8 8,-8 8,3.58 8,8 -3.58,8 -8,8z" />
</vector>

View file

@ -1,5 +1,10 @@
<vector android:height="24dp" android:tint="?attr/colorControlNormal"
android:viewportHeight="24" android:viewportWidth="24"
android:width="24dp" xmlns:android="http://schemas.android.com/apk/res/android">
<path android:fillColor="@android:color/white" android:pathData="M22,16L22,4c0,-1.1 -0.9,-2 -2,-2L8,2c-1.1,0 -2,0.9 -2,2v12c0,1.1 0.9,2 2,2h12c1.1,0 2,-0.9 2,-2zM11,12l2.03,2.71L16,11l4,5L8,16l3,-4zM2,6v14c0,1.1 0.9,2 2,2h14v-2L4,20L4,6L2,6z"/>
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:tint="?attr/colorControlNormal"
android:viewportWidth="24"
android:viewportHeight="24">
<path
android:fillColor="@android:color/white"
android:pathData="M22,16L22,4c0,-1.1 -0.9,-2 -2,-2L8,2c-1.1,0 -2,0.9 -2,2v12c0,1.1 0.9,2 2,2h12c1.1,0 2,-0.9 2,-2zM11,12l2.03,2.71L16,11l4,5L8,16l3,-4zM2,6v14c0,1.1 0.9,2 2,2h14v-2L4,20L4,6L2,6z" />
</vector>

View file

@ -1,5 +1,11 @@
<vector android:autoMirrored="true" android:height="24dp"
android:tint="?attr/colorControlNormal" android:viewportHeight="24"
android:viewportWidth="24" android:width="24dp" xmlns:android="http://schemas.android.com/apk/res/android">
<path android:fillColor="@android:color/white" android:pathData="M12,2C6.48,2 2,6.48 2,12s4.48,10 10,10 10,-4.48 10,-10S17.52,2 12,2zM13,19h-2v-2h2v2zM15.07,11.25l-0.9,0.92C13.45,12.9 13,13.5 13,15h-2v-0.5c0,-1.1 0.45,-2.1 1.17,-2.83l1.24,-1.26c0.37,-0.36 0.59,-0.86 0.59,-1.41 0,-1.1 -0.9,-2 -2,-2s-2,0.9 -2,2L8,9c0,-2.21 1.79,-4 4,-4s4,1.79 4,4c0,0.88 -0.36,1.68 -0.93,2.25z"/>
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:autoMirrored="true"
android:tint="?attr/colorControlNormal"
android:viewportWidth="24"
android:viewportHeight="24">
<path
android:fillColor="@android:color/white"
android:pathData="M12,2C6.48,2 2,6.48 2,12s4.48,10 10,10 10,-4.48 10,-10S17.52,2 12,2zM13,19h-2v-2h2v2zM15.07,11.25l-0.9,0.92C13.45,12.9 13,13.5 13,15h-2v-0.5c0,-1.1 0.45,-2.1 1.17,-2.83l1.24,-1.26c0.37,-0.36 0.59,-0.86 0.59,-1.41 0,-1.1 -0.9,-2 -2,-2s-2,0.9 -2,2L8,9c0,-2.21 1.79,-4 4,-4s4,1.79 4,4c0,0.88 -0.36,1.68 -0.93,2.25z" />
</vector>

View file

@ -1,10 +1,10 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:tint="?attr/colorControlNormal"
android:viewportWidth="24"
android:viewportHeight="24"
android:tint="?attr/colorControlNormal">
<path
android:fillColor="@android:color/white"
android:pathData="M12,8c1.1,0 2,-0.9 2,-2s-0.9,-2 -2,-2 -2,0.9 -2,2 0.9,2 2,2zM12,10c-1.1,0 -2,0.9 -2,2s0.9,2 2,2 2,-0.9 2,-2 -0.9,-2 -2,-2zM12,16c-1.1,0 -2,0.9 -2,2s0.9,2 2,2 2,-0.9 2,-2 -0.9,-2 -2,-2z"/>
android:viewportHeight="24">
<path
android:fillColor="@android:color/white"
android:pathData="M12,8c1.1,0 2,-0.9 2,-2s-0.9,-2 -2,-2 -2,0.9 -2,2 0.9,2 2,2zM12,10c-1.1,0 -2,0.9 -2,2s0.9,2 2,2 2,-0.9 2,-2 -0.9,-2 -2,-2zM12,16c-1.1,0 -2,0.9 -2,2s0.9,2 2,2 2,-0.9 2,-2 -0.9,-2 -2,-2z" />
</vector>

View file

@ -1,7 +1,16 @@
<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 xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:tint="?attr/colorControlNormal"
android:viewportWidth="24"
android:viewportHeight="24">
<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

@ -1,5 +1,10 @@
<vector android:height="24dp" android:tint="?attr/colorControlNormal"
android:viewportHeight="24" android:viewportWidth="24"
android:width="24dp" xmlns:android="http://schemas.android.com/apk/res/android">
<path android:fillColor="@android:color/white" android:pathData="M17.65,6.35C16.2,4.9 14.21,4 12,4c-4.42,0 -7.99,3.58 -7.99,8s3.57,8 7.99,8c3.73,0 6.84,-2.55 7.73,-6h-2.08c-0.82,2.33 -3.04,4 -5.65,4 -3.31,0 -6,-2.69 -6,-6s2.69,-6 6,-6c1.66,0 3.14,0.69 4.22,1.78L13,11h7V4l-2.35,2.35z"/>
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:tint="?attr/colorControlNormal"
android:viewportWidth="24"
android:viewportHeight="24">
<path
android:fillColor="@android:color/white"
android:pathData="M17.65,6.35C16.2,4.9 14.21,4 12,4c-4.42,0 -7.99,3.58 -7.99,8s3.57,8 7.99,8c3.73,0 6.84,-2.55 7.73,-6h-2.08c-0.82,2.33 -3.04,4 -5.65,4 -3.31,0 -6,-2.69 -6,-6s2.69,-6 6,-6c1.66,0 3.14,0.69 4.22,1.78L13,11h7V4l-2.35,2.35z" />
</vector>

View file

@ -1,5 +1,10 @@
<vector android:height="24dp" android:tint="?attr/colorControlNormal"
android:viewportHeight="24" android:viewportWidth="24"
android:width="24dp" xmlns:android="http://schemas.android.com/apk/res/android">
<path android:fillColor="@android:color/white" android:pathData="M21,12.4V7l-4,-4H5C3.89,3 3,3.9 3,5v14c0,1.1 0.89,2 2,2h7.4L21,12.4zM15,15c0,1.66 -1.34,3 -3,3s-3,-1.34 -3,-3s1.34,-3 3,-3S15,13.34 15,15zM6,6h9v4H6V6zM19.99,16.25l1.77,1.77L16.77,23H15v-1.77L19.99,16.25zM23.25,16.51l-0.85,0.85l-1.77,-1.77l0.85,-0.85c0.2,-0.2 0.51,-0.2 0.71,0l1.06,1.06C23.45,16 23.45,16.32 23.25,16.51z"/>
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:tint="?attr/colorControlNormal"
android:viewportWidth="24"
android:viewportHeight="24">
<path
android:fillColor="@android:color/white"
android:pathData="M21,12.4V7l-4,-4H5C3.89,3 3,3.9 3,5v14c0,1.1 0.89,2 2,2h7.4L21,12.4zM15,15c0,1.66 -1.34,3 -3,3s-3,-1.34 -3,-3s1.34,-3 3,-3S15,13.34 15,15zM6,6h9v4H6V6zM19.99,16.25l1.77,1.77L16.77,23H15v-1.77L19.99,16.25zM23.25,16.51l-0.85,0.85l-1.77,-1.77l0.85,-0.85c0.2,-0.2 0.51,-0.2 0.71,0l1.06,1.06C23.45,16 23.45,16.32 23.25,16.51z" />
</vector>

View file

@ -1,10 +1,10 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:tint="?attr/colorControlNormal"
android:viewportWidth="24"
android:viewportHeight="24"
android:tint="?attr/colorControlNormal">
<path
android:fillColor="@android:color/white"
android:pathData="M9.5,6.5v3h-3v-3H9.5M11,5H5v6h6V5L11,5zM9.5,14.5v3h-3v-3H9.5M11,13H5v6h6V13L11,13zM17.5,6.5v3h-3v-3H17.5M19,5h-6v6h6V5L19,5zM13,13h1.5v1.5H13V13zM14.5,14.5H16V16h-1.5V14.5zM16,13h1.5v1.5H16V13zM13,16h1.5v1.5H13V16zM14.5,17.5H16V19h-1.5V17.5zM16,16h1.5v1.5H16V16zM17.5,14.5H19V16h-1.5V14.5zM17.5,17.5H19V19h-1.5V17.5zM22,7h-2V4h-3V2h5V7zM22,22v-5h-2v3h-3v2H22zM2,22h5v-2H4v-3H2V22zM2,2v5h2V4h3V2H2z"/>
android:viewportHeight="24">
<path
android:fillColor="@android:color/white"
android:pathData="M9.5,6.5v3h-3v-3H9.5M11,5H5v6h6V5L11,5zM9.5,14.5v3h-3v-3H9.5M11,13H5v6h6V13L11,13zM17.5,6.5v3h-3v-3H17.5M19,5h-6v6h6V5L19,5zM13,13h1.5v1.5H13V13zM14.5,14.5H16V16h-1.5V14.5zM16,13h1.5v1.5H16V13zM13,16h1.5v1.5H13V16zM14.5,17.5H16V19h-1.5V17.5zM16,16h1.5v1.5H16V16zM17.5,14.5H19V16h-1.5V14.5zM17.5,17.5H19V19h-1.5V17.5zM22,7h-2V4h-3V2h5V7zM22,22v-5h-2v3h-3v2H22zM2,22h5v-2H4v-3H2V22zM2,2v5h2V4h3V2H2z" />
</vector>

View file

@ -1,5 +1,12 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android" android:height="24dp" android:tint="#000000" android:viewportHeight="24" android:viewportWidth="24" android:width="24dp">
<path android:fillColor="@android:color/white" android:pathData="M6,19c0,1.1 0.9,2 2,2h8c1.1,0 2,-0.9 2,-2V7H6v12zM19,4h-3.5l-1,-1h-5l-1,1H5v2h14V4z"/>
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:tint="#000000"
android:viewportWidth="24"
android:viewportHeight="24">
<path
android:fillColor="@android:color/white"
android:pathData="M6,19c0,1.1 0.9,2 2,2h8c1.1,0 2,-0.9 2,-2V7H6v12zM19,4h-3.5l-1,-1h-5l-1,1H5v2h14V4z" />
</vector>

View file

@ -1,5 +1,12 @@
<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 xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:tint="#000000"
android:viewportWidth="24"
android:viewportHeight="24">
<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

@ -1,5 +1,12 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android" android:height="24dp" android:tint="#000000" android:viewportHeight="24" android:viewportWidth="24" android:width="24dp">
<path android:fillColor="@android:color/white" android:pathData="M18,2h-8L4,8v12c0,1.1 0.9,2 2,2h12c1.1,0 2,-0.9 2,-2V4C20,2.9 19.1,2 18,2zM12,17l-4,-4h3V9.02L13,9v4h3L12,17z"/>
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:tint="#000000"
android:viewportWidth="24"
android:viewportHeight="24">
<path
android:fillColor="@android:color/white"
android:pathData="M18,2h-8L4,8v12c0,1.1 0.9,2 2,2h12c1.1,0 2,-0.9 2,-2V4C20,2.9 19.1,2 18,2zM12,17l-4,-4h3V9.02L13,9v4h3L12,17z" />
</vector>

View file

@ -1,5 +1,12 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android" android:height="24dp" android:tint="#000000" android:viewportHeight="24" android:viewportWidth="24" android:width="24dp">
<path android:fillColor="@android:color/white" android:pathData="M17.65,6.35C16.2,4.9 14.21,4 12,4c-4.42,0 -7.99,3.58 -7.99,8s3.57,8 7.99,8c3.73,0 6.84,-2.55 7.73,-6h-2.08c-0.82,2.33 -3.04,4 -5.65,4 -3.31,0 -6,-2.69 -6,-6s2.69,-6 6,-6c1.66,0 3.14,0.69 4.22,1.78L13,11h7V4l-2.35,2.35z"/>
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:tint="#000000"
android:viewportWidth="24"
android:viewportHeight="24">
<path
android:fillColor="@android:color/white"
android:pathData="M17.65,6.35C16.2,4.9 14.21,4 12,4c-4.42,0 -7.99,3.58 -7.99,8s3.57,8 7.99,8c3.73,0 6.84,-2.55 7.73,-6h-2.08c-0.82,2.33 -3.04,4 -5.65,4 -3.31,0 -6,-2.69 -6,-6s2.69,-6 6,-6c1.66,0 3.14,0.69 4.22,1.78L13,11h7V4l-2.35,2.35z" />
</vector>

View file

@ -1,5 +1,12 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android" android:height="24dp" android:tint="#000000" android:viewportHeight="24" android:viewportWidth="24" android:width="24dp">
<path android:fillColor="@android:color/white" android:pathData="M19,6.41L17.59,5 12,10.59 6.41,5 5,6.41 10.59,12 5,17.59 6.41,19 12,13.41 17.59,19 19,17.59 13.41,12z"/>
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:tint="#000000"
android:viewportWidth="24"
android:viewportHeight="24">
<path
android:fillColor="@android:color/white"
android:pathData="M19,6.41L17.59,5 12,10.59 6.41,5 5,6.41 10.59,12 5,17.59 6.41,19 12,13.41 17.59,19 19,17.59 13.41,12z" />
</vector>

View file

@ -1,17 +1,17 @@
<?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"
xmlns:app="http://schemas.android.com/apk/res-auto">
android:layout_height="match_parent">
<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_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintEnd_toEndOf="parent" />
app:layout_constraintTop_toTopOf="parent" />
<View
android:id="@+id/guideline"
@ -25,14 +25,14 @@
<ProgressBar
android:id="@+id/progress"
style="@style/Widget.AppCompat.ProgressBar.Horizontal"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:indeterminate="true"
app:layout_constraintBottom_toTopOf="@id/download_wizard_navigation"
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" />
app:layout_constraintTop_toBottomOf="@id/guideline" />
<androidx.constraintlayout.widget.ConstraintLayout
android:id="@+id/download_wizard_navigation"
@ -40,16 +40,16 @@
android:layout_height="48dp"
android:background="?attr/colorSurfaceContainer"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintEnd_toEndOf="parent">
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="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"
android:background="?attr/selectableItemBackground"
android:text="@string/download_wizard_back"
android:textColor="?attr/colorPrimary"
app:icon="@drawable/ic_chevron_left"
app:iconGravity="start"
app:iconTint="?attr/colorPrimary"
@ -58,11 +58,11 @@
<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"
android:background="?attr/selectableItemBackground"
android:text="@string/download_wizard_next"
android:textColor="?attr/colorPrimary"
app:icon="@drawable/ic_chevron_right"
app:iconGravity="end"
app:iconTint="?attr/colorPrimary"
@ -71,4 +71,4 @@
</androidx.constraintlayout.widget.ConstraintLayout>
</androidx.constraintlayout.widget.ConstraintLayout>
</androidx.constraintlayout.widget.ConstraintLayout>

View file

@ -1,8 +1,8 @@
<?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"
xmlns:app="http://schemas.android.com/apk/res-auto">
android:layout_height="match_parent">
<include layout="@layout/toolbar_activity" />
@ -10,10 +10,10 @@
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_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintBottom_toBottomOf="parent">
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@id/toolbar">
<androidx.recyclerview.widget.RecyclerView
android:id="@+id/recycler_view"
@ -22,4 +22,4 @@
</androidx.swiperefreshlayout.widget.SwipeRefreshLayout>
</androidx.constraintlayout.widget.ConstraintLayout>
</androidx.constraintlayout.widget.ConstraintLayout>

View file

@ -1,7 +1,7 @@
<?xml version="1.0" encoding="utf-8"?>
<androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools"
xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent"
android:layout_height="match_parent">
@ -12,13 +12,13 @@
android:layout_width="0dp"
android:layout_height="0dp"
android:fontFamily="monospace"
android:gravity="top|start"
android:importantForAutofill="no"
android:inputType="textMultiLine"
android:gravity="top|start"
app:layout_constraintTop_toBottomOf="@id/toolbar"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@id/toolbar"
tools:ignore="LabelFor" />
</androidx.constraintlayout.widget.ConstraintLayout>
</androidx.constraintlayout.widget.ConstraintLayout>

View file

@ -1,9 +1,9 @@
<?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"
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent"
android:layout_height="match_parent"
xmlns:app="http://schemas.android.com/apk/res-auto">
android:layout_height="match_parent">
<include layout="@layout/toolbar_activity" />
@ -11,10 +11,10 @@
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_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintBottom_toBottomOf="parent">
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@id/toolbar">
<ScrollView
android:id="@+id/scroll_view"
@ -25,17 +25,17 @@
android:id="@+id/log_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"
android:padding="10dp"
android:textIsSelectable="true"
android:textSize="10sp"
tools:ignore="SmallSp" />
</ScrollView>
</androidx.swiperefreshlayout.widget.SwipeRefreshLayout>
</androidx.constraintlayout.widget.ConstraintLayout>
</androidx.constraintlayout.widget.ConstraintLayout>

View file

@ -1,32 +1,31 @@
<?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"
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent"
android:layout_height="match_parent">
<include layout="@layout/toolbar_activity" />
<com.google.android.material.tabs.TabLayout
<im.angry.openeuicc.ui.widget.DynamicModeTabLayout
android:id="@+id/main_tabs"
android:background="?attr/colorSurfaceVariant"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:background="?attr/colorSurfaceVariant"
android:visibility="gone"
app:tabTextColor="?attr/colorOnSurfaceVariant"
app:tabSelectedTextColor="?attr/colorOnSurfaceVariant"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@id/toolbar"
app:layout_constraintStart_toStartOf="parent" />
app:tabSelectedTextColor="?attr/colorOnSurfaceVariant"
app:tabTextColor="?attr/colorOnSurfaceVariant" />
<ProgressBar
android:id="@+id/loading"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:indeterminate="true"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintTop_toBottomOf="@id/main_tabs"
app:layout_constraintBottom_toBottomOf="parent" />
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@id/main_tabs" />
<androidx.viewpager2.widget.ViewPager2
android:id="@+id/view_pager"
@ -36,6 +35,6 @@
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintLeft_toLeftOf="parent"
app:layout_constraintRight_toRightOf="parent"
app:layout_constraintTop_toBottomOf="@id/main_tabs"/>
app:layout_constraintTop_toBottomOf="@id/main_tabs" />
</androidx.constraintlayout.widget.ConstraintLayout>
</androidx.constraintlayout.widget.ConstraintLayout>

View file

@ -1,8 +1,8 @@
<?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"
xmlns:app="http://schemas.android.com/apk/res-auto">
android:layout_height="match_parent">
<include layout="@layout/toolbar_activity" />
@ -10,10 +10,10 @@
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_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintBottom_toBottomOf="parent">
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@id/toolbar">
<androidx.recyclerview.widget.RecyclerView
android:id="@+id/recycler_view"
@ -22,4 +22,4 @@
</androidx.swiperefreshlayout.widget.SwipeRefreshLayout>
</androidx.constraintlayout.widget.ConstraintLayout>
</androidx.constraintlayout.widget.ConstraintLayout>

View file

@ -1,8 +1,8 @@
<?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"
xmlns:app="http://schemas.android.com/apk/res-auto">
android:layout_height="match_parent">
<include layout="@layout/toolbar_activity" />
@ -15,4 +15,4 @@
app:layout_constraintRight_toRightOf="parent"
app:layout_constraintTop_toBottomOf="@id/toolbar" />
</androidx.constraintlayout.widget.ConstraintLayout>
</androidx.constraintlayout.widget.ConstraintLayout>

View file

@ -1,19 +1,19 @@
<?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"
xmlns:app="http://schemas.android.com/apk/res-auto"
android:padding="20dp"
android:background="?attr/selectableItemBackground">
android:background="?attr/selectableItemBackground"
android:padding="20dp">
<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" />
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent"
app:tint="?attr/colorAccent" />
<TextView
android:id="@+id/download_method_title"
@ -21,24 +21,24 @@
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"
android:maxLines="1"
android:textSize="15sp"
app:layout_constrainedWidth="true"
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" />
app:layout_constraintStart_toEndOf="@id/download_method_icon"
app:layout_constraintTop_toTopOf="parent" />
<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"
android:src="@drawable/ic_chevron_right"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="parent" />
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintTop_toTopOf="parent"
app:tint="?attr/colorAccent" />
</androidx.constraintlayout.widget.ConstraintLayout>
</androidx.constraintlayout.widget.ConstraintLayout>

View file

@ -48,8 +48,8 @@
android:id="@+id/download_progress_item_error_title"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginTop="10dp"
android:layout_marginStart="20dp"
android:layout_marginTop="10dp"
android:layout_marginEnd="20dp"
android:layout_marginBottom="10dp"
android:textColor="?attr/colorError"
@ -78,4 +78,4 @@
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@id/download_progress_item_title" />
</androidx.constraintlayout.widget.ConstraintLayout>
</androidx.constraintlayout.widget.ConstraintLayout>

View file

@ -3,11 +3,11 @@
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:background="?attr/selectableItemBackground"
android:paddingStart="20sp"
android:paddingTop="10sp"
android:paddingEnd="20sp"
android:background="?attr/selectableItemBackground">
android:paddingBottom="20sp">
<TextView
android:id="@+id/slot_item_title"
@ -83,7 +83,6 @@
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"
@ -92,6 +91,7 @@
app:flow_verticalBias="0"
app:flow_verticalGap="16sp"
app:flow_verticalStyle="packed"
app:flow_wrapMode="aligned"
app:layout_constraintEnd_toStartOf="@id/slot_checkbox"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@id/slot_item_title" />
@ -105,4 +105,4 @@
app:layout_constraintStart_toEndOf="@id/flow1"
app:layout_constraintTop_toTopOf="parent" />
</androidx.constraintlayout.widget.ConstraintLayout>
</androidx.constraintlayout.widget.ConstraintLayout>

Some files were not shown because too many files have changed in this diff Show more