Rewrite SimImport to Kotlin + Compose - #33
Conversation
RankoR
left a comment
There was a problem hiding this comment.
Please also check for unused resources and imports.
| loadSimContacts = { flowOf(SimContactsResult(contacts = persistentListOf(contact))) }, | ||
| startSimImport = { a, b, c -> startSimImportCall = Triple(a, b, c) }, | ||
| ) | ||
|
|
There was a problem hiding this comment.
advanceUntilIdle seems to be required here, otherwise init collectors will not run, currentAccount will be null and startImport will early-return
| state.copy( | ||
| isLoading = false, | ||
| accounts = accounts, | ||
| currentAccount = if (accounts.contains(state.currentAccount)) { |
There was a problem hiding this comment.
AccountInfo doesn't have equals and hashCode, so selected account silently resets to default on every accounts reload.
Also, not strictly required, but when would look better here (and in many similar cases)
| selectedContacts.update { oldSelectedContactsMap -> | ||
| accounts.associate { account -> | ||
| val oldSelectedContacts = oldSelectedContactsMap[account.account] | ||
| val selectedContacts = if (oldSelectedContacts.isNullOrEmpty()) { |
There was a problem hiding this comment.
isNullOrEmpty() conflates "account never initialized" (null) with "user deselected everything" (empty set). After Deselect All, the next re-emission - guaranteed by broken distinctUntilChanged on any accounts broadcast - re-selects every contact. A partial selection survives correctly; only the user's most explicit "import nothing" intent is destroyed. The old Java distinguished ids == null from an empty long[].
There was a problem hiding this comment.
Well caught. I introduced nullable states in SimImportUiState and simContacts to ensure we can distinguish between the not having received any value yet, and empty lists. The code get's slightly more complex with nullability checks, but it should help avoiding making this issues.
|
|
||
| override operator fun invoke(subscriptionId: Int): Flow<SimContactsResult> = | ||
| buildBroadcastReceiverFlow(IntentFilter(AccountTypeManager.BROADCAST_ACCOUNTS_CHANGED)) | ||
| .onStart { emit(Unit) } |
There was a problem hiding this comment.
Make sure to have catch downstream, otherwise any exception will crash the app.
| /** | ||
| * Holds an {@link AccountWithDataSet} and the corresponding {@link AccountType} for an account. | ||
| */ | ||
| @Immutable |
There was a problem hiding this comment.
It's not immutable. We should probably implement a separate UI-layer model (and a mapper, see examples in Messaging), that will be truly immutable.
There was a problem hiding this comment.
Went with 2 domain models (AndroidModel and AccountDisplayModel) and a AndroidUIModel.
| } | ||
|
|
||
| @VisibleForTesting | ||
| const val TEST_TAG_SIM_IMPORT_CONTACTS_TO_IMPORT_TITLE = "sim_import_contacts_to_import_title" |
There was a problem hiding this comment.
Test tags should be in a separate file and in most cases with internal visibility
| import com.android.contacts.ui.core.ContactsPreviewColumn | ||
|
|
||
| @Composable | ||
| internal fun SimContactCell( |
There was a problem hiding this comment.
modifier: Modifier = Modifier is missing. Check other Composables too.
| title = { | ||
| Text( | ||
| text = if (uiState.selectedContactsCount > 0) { | ||
| uiState.selectedContactsCount.toString() |
There was a problem hiding this comment.
While it mirrors old behavior, it's not a good UX to display just a number. Should be a plural string res (English-only is enough for now)
|
|
||
| override fun handle(effect: Effect) { | ||
| when (effect) { | ||
| Effect.Close -> activity.finish() |
There was a problem hiding this comment.
setResult(RESULT_OK) / setResult(RESULT_CANCELED) are lost here
| androidx-compose-ui-test-manifest = { module = "androidx.compose.ui:ui-test-manifest" } | ||
| androidx-compose-ui-tooling = { module = "androidx.compose.ui:ui-tooling" } | ||
| androidx-compose-ui-tooling-preview = { module = "androidx.compose.ui:ui-tooling-preview" } | ||
| androidx-lifecycle-viewmodel-compose = { module = "androidx.lifecycle:lifecycle-viewmodel-compose" } |
There was a problem hiding this comment.
Version isn't covered by the Compose BOM, so version must be set
5f67695 to
733eda4
Compare
m4pl
left a comment
There was a problem hiding this comment.
Only had time to test this on a device so far, the comments above are what I ran into. Will go through the code properly in the next pass.
| package com.android.contacts.domain.accounts.mapper | ||
|
|
||
| import com.android.contacts.domain.accounts.model.AccountDisplayModel | ||
| import com.android.contacts.domain.accounts.model.AccountModel |
| import com.android.contacts.model.account.AccountInfo; | ||
| import com.android.contacts.model.account.AccountWithDataSet; | ||
| import com.android.contacts.ui.UIIntents; | ||
| import com.android.contacts.ui.simimport.SimImportActivity; |
| } | ||
|
|
||
| @Composable | ||
| private fun itemClipShape( |
There was a problem hiding this comment.
I followed the Material spec and the latest Google Contacts app: https://m3.material.io/components/lists/specs
But I also feel that the notches were a bit too sharp, so in the next PR, I made all corners have at least 2.dp of corner size. I'll update this PR with the same.
| isExpanded: Boolean, | ||
| ) { | ||
| val currentAccountLabel = current.name.orEmpty() | ||
| OutlinedTextField( |
There was a problem hiding this comment.
The account name behaves like editable text: long pressing it selects the text and opens the copy/share toolbar. I think it shouldn't be selectable.
There was a problem hiding this comment.
That's the default behavior of readOnly TextFields. Adding a DisableSelection around it doesn't fix it.
If we really don't want text selection there, we need to design the custom Button ourselves. Possible, just a bit more work. Is it preferable?
There was a problem hiding this comment.
Makes sense, not worth a custom component. Let's leave it.
| } | ||
| }, | ||
| title = { | ||
| Text( |
There was a problem hiding this comment.
Title wraps to two lines on small screens.
| ) | ||
| }, | ||
| actions = { | ||
| IconButton( |
There was a problem hiding this comment.
Two separate buttons where only one is ever enabled. Could this be a single control?
There was a problem hiding this comment.
Both can be enabled, if you just selected some but not all contacts.
There was a problem hiding this comment.
Got it. Two icons plus the Import button still feels like a lot for the app bar, but it's not a blocker. Fine as is.
| android:layout_width="match_parent" | ||
| android:layout_height="?attr/actionBarSize" | ||
| android:elevation="3dp" | ||
| android:theme="@style/LightToolbarThemeOverlay" |
There was a problem hiding this comment.
These are no longer referenced anywhere after the old SIM import layouts were deleted: LightToolbarNavigationButtonStyle, LightToolbarThemeOverlay, LightToolbarStyle, FullScreenDialogAnimationStyle, PeopleThemeAppCompat.FullScreenDialog, PeopleThemeAppCompat.FullScreenDialog.SimImportActivity, and res/anim/slide_and_fade_out.xml.
There was a problem hiding this comment.
Once contacts are imported there's no way to undo it from this screen, you have to delete them one by one in the contacts list. I'd like them to be undoable, but maybe that's not this screen's job.
@RankoR what do you think, is undo in scope here?
There was a problem hiding this comment.
I think it's out of scope for this PR
|
|
||
| internal class AccountDisplayModelMapperImpl @Inject constructor() : AccountDisplayModelMapper { | ||
| override fun map(accountInfo: AccountInfo): AccountDisplayModel { | ||
| return AccountDisplayModel( |
| private fun load(subscriptionId: Int): SimContactsResult { | ||
| val sim = simContactDao.getSimBySubscriptionId(subscriptionId) ?: return SimContactsResult() | ||
| val contacts = simContactDao.loadContactsForSim(sim).orEmpty() | ||
| val accountsMap = simContactDao.findAccountsOfExistingSimContacts(contacts).orEmpty() |
There was a problem hiding this comment.
Importing the same SIM contact twice creates a duplicate. Import succeeds, but reopening the screen lists the contact under "Contacts to import" again.
The check matches raw contacts by phone and name. We insert only StructuredName.DISPLAY_NAME and the provider re-derives it from the parsed name parts, so 01.Balance is stored as 01 Balance and the name comparison in SimContact.findByPhoneAndName fails. Verified on device, the query returns the raw contacts and every one comes back as no match. Names without punctuation are fine.
Not caused by this PR, the matching code is untouched. Up to you whether to fix it here or create a separate issue.
There was a problem hiding this comment.
Got it. I can reproduce the issue as well.
I feel it should be a separate ticket. For example, importing from a VCard also allows duplicates, and I'm not sure we want that as well there.
7092d5e to
0ef831a
Compare
cbaca32 to
c9ac9d9
Compare
0ef831a to
8eb4e48
Compare
|
@RankoR rebased to the latest |
|
|
||
| init { | ||
| if (subscriptionId != SimCard.NO_SUBSCRIPTION_ID) { | ||
| loadSimCards() |
There was a problem hiding this comment.
There's no catch, so it can crash the app
There was a problem hiding this comment.
Done. But the previous and existing code did not catch any exception from the Dao method, and the only mention I could find of a throw was for a UnsupportedOperationException, so I added a catch for that one.
ad16541 to
fedb8b7
Compare
|
Rebased to the latest |
| @@ -1,5 +1,4 @@ | |||
| <?xml version="1.0" encoding="utf-8"?> | |||
| <!-- Copyright (C) 2006 The Android Open Source Project | |||
| <?xml version="1.0" encoding="utf-8"?><!-- Copyright (C) 2006 The Android Open Source Project | |||
There was a problem hiding this comment.
Looks like the line break should be restored.
| val account: AccountModel, | ||
| val name: String?, | ||
| val type: String? = null, | ||
| val icon: Drawable? = null, |
There was a problem hiding this comment.
Drawable can change and is tied to Resources, so this model isn't really immutable. It also ends up in AccountUiModel, which is marked @Immutable, and that's no longer true. Could we keep an id here and build the icon in the UI?
There was a problem hiding this comment.
It comes as a Drawable from inside the AccounType. Usually it starts from an icon resource, but sometimes it gets mutated before reaching here.
Maybe we can agree to mark the AccountUiModel as @Stable, knowning it's unlikely it's actually going to change, without the type changing as well?
The alternative is ignoring the icon we get from the AccountType, and attributing our own icon resources for every known AccountType (external accounts are the most complex scenario).
There was a problem hiding this comment.
getDisplayIcon() returns a new Drawable on every call, so accounts.contains(it) in findCurrentAccount never matches. @Stable wouldn't change that.
Own icon set isn't needed though. AccountInfo.getType() gives you the AccountType, where syncAdapterPackageName and iconRes are public. Those plus the grey tint from FallbackAccountType / SimAccountType are enough for the UI to build it.
| ktlint_class_signature_rule_force_multiline_when_parameter_count_greater_or_equal_than = 1 | ||
| ij_kotlin_line_break_after_multiline_when_entry = false | ||
| ktlint_code_style = android_studio | ||
| ktlint_class_signature_rule_force_multiline_when_parameter_count_greater_or_equal_than = 1 |
There was a problem hiding this comment.
Copied from GrapheneOS/app-docs#2. Already fixed here and left a comment there.
| ktlint_standard_trailing-comma-on-call-site = disabled | ||
| ktlint_standard_blank-line-between-when-conditions = disabled | ||
| max_line_length = 100 | ||
| max_line_length = 100 No newline at end of file |
There was a problem hiding this comment.
Copied from GrapheneOS/app-docs#2. Already fixed here and left a comment there.
| val contact: SimContactUiModel, | ||
| val isSelected: Boolean, | ||
| ) : SimImportAction | ||
|
|
|
|
||
| internal object AccountModelFactory { | ||
| fun build( | ||
| name: String = "Account ${Random.nextInt().toString().take(4)}", |
There was a problem hiding this comment.
take(4) on a signed int leaves only ~9900 names, so two accounts can come out identical and some tests need them to differ. Flaky. A counter instead? Same at AccountDisplayModelFactory and AccountUiModelFactory.
There was a problem hiding this comment.
You're right. And I feel it's a bad idea to rely on the Factory for uniqueness. Better leave that responsibility to the test, if it really needs multiple unique objects.
There was a problem hiding this comment.
SimContactFactory.kt:8 and SimContactUiModelFactory.kt:10 still default recordNumber to Random.nextInt().
| val effectHandler by rememberUpdatedState(effectHandler) | ||
|
|
||
| LaunchedEffect(screenModel) { | ||
| screenModel.effects.collect(effectHandler::handle) |
There was a problem hiding this comment.
effectHandler::handle is a bound reference, so rememberUpdatedState has no effect.
| .onStart { emit(Unit) } | ||
| .map { load(subscriptionId) } | ||
| .catch { | ||
| if (it is CancellationException) { |
There was a problem hiding this comment.
This swallows the exception instead of rethrowing it. Throw it, or drop the branch?
| import kotlinx.collections.immutable.ImmutableList | ||
|
|
||
| internal fun interface StartSimImport { | ||
| operator fun invoke( |
There was a problem hiding this comment.
operator is here but not in LoadSimContactsImpl / BuildBroadcastReceiverFlowImpl.
There was a problem hiding this comment.
But operator is present in LoadSimContactsImpl and BuildBroadcastReceiverFlowImpl 🤔
Or am I missing something?
| ) { | ||
| ContactsPreviewTheme(modifier = modifier) { | ||
| Column( | ||
| verticalArrangement = Arrangement.spacedBy(16.dp), |
There was a problem hiding this comment.
Real list uses spacedBy(1.dp), so cell previews don't match.
5753783 to
1846520
Compare
1846520 to
f193e07
Compare
m4pl
left a comment
There was a problem hiding this comment.
Added a few comments in the existing threads. Also worth another pass on unused imports: kotlin.random.Random in AccountModelFactory.kt:4, AccountDisplayModelFactory.kt:6, AccountUiModelFactory.kt:6, AccountModel in AccountDisplayModelFactory.kt:5, ImmutableList in StartSimImport.kt:10. ktlint doesn't flag them here.
| end = contentPadding.calculateEndPadding(layoutDirection), | ||
| ), | ||
| ) { | ||
| if (uiState is State.Ready) { |
There was a problem hiding this comment.
Should the picker still show on the "no contacts" screen? It's only rendered for State.Ready now.
There was a problem hiding this comment.
We could show it yeah, we have the info.
|
|
||
| private fun selectAll() { | ||
| val account = currentAccount.value ?: return | ||
| val state = (uiState.value as? State.Ready) ?: return |
There was a problem hiding this comment.
selectAll() and getSelectedContacts() read uiState.value, so the handlers depend on the state they produce. Could they use simContacts + existingContacts + currentAccount directly?
|
|
||
| private fun restoreSelectedContacts(): Map<AccountModel, Set<Int>> { | ||
| val entries = savedStateHandle.get<List<AccountContactsEntry>>(KEY_SELECTED_CONTACTS) | ||
| return entries |
There was a problem hiding this comment.
Returns Map<AccountModel, Set<Int>> but still builds it with toImmutableSet() / toImmutableMap() / persistentMapOf().
| } | ||
| } | ||
|
|
||
| @Suppress("detekt:ReturnCount") |
There was a problem hiding this comment.
I've split the method in two steps.


Closes #34
Feature Changes:
Video of the final result:
sim_import.mp4
Implementation Notes:
verification-metadata.xmlmy changes should be committed.OptInwere kept at the class and statement level, and not applied globally.