From 9f564beb691943c8f5ef7e4e6999091ca9cef224 Mon Sep 17 00:00:00 2001 From: pasichdev Date: Sun, 18 Oct 2026 23:56:58 +0300 Subject: [PATCH 1/4] fix(clipboard): stop the crash when text is selected Selecting text (for example double-tapping a word) crashed the app on 2.2.0. The text toolbar of Compose 1.12 reads the system clipboard through Clipboard.nativeClipboardManager, which throws for any Clipboard that is not an AndroidClipboard, and SensitiveClipboard was a plain Clipboard. It now implements AndroidClipboard; copies are still marked sensitive and cleared after a minute. --- .../presentation/components/SensitiveClip.kt | 14 ++++++++++---- .../presentation/components/SensitiveClipTest.kt | 15 +++++++++++++++ 2 files changed, 25 insertions(+), 4 deletions(-) diff --git a/app/src/main/java/com/pasich/encly/presentation/components/SensitiveClip.kt b/app/src/main/java/com/pasich/encly/presentation/components/SensitiveClip.kt index 9f76a824..99c765a3 100644 --- a/app/src/main/java/com/pasich/encly/presentation/components/SensitiveClip.kt +++ b/app/src/main/java/com/pasich/encly/presentation/components/SensitiveClip.kt @@ -10,6 +10,7 @@ import android.os.PersistableBundle import androidx.compose.runtime.Composable import androidx.compose.runtime.CompositionLocalProvider import androidx.compose.runtime.remember +import androidx.compose.ui.platform.AndroidClipboard import androidx.compose.ui.platform.ClipEntry import androidx.compose.ui.platform.Clipboard import androidx.compose.ui.platform.LocalClipboard @@ -92,16 +93,21 @@ object SensitiveClip { } } -/** A [Clipboard] that applies [SensitiveClip] to everything set through it. */ -private class SensitiveClipboard(private val delegate: Clipboard) : Clipboard { - override val nativeClipboard: ClipboardManager get() = delegate.nativeClipboard +/** + * A [Clipboard] that applies [SensitiveClip] to everything set through it. It must be an + * [AndroidClipboard]: Compose's text toolbar reads the system clipboard through + * `Clipboard.nativeClipboardManager`, which throws for any other [Clipboard], so a plain + * wrapper crashed the app on every text selection. + */ +private class SensitiveClipboard(private val delegate: Clipboard) : AndroidClipboard { + override val clipboardManager: ClipboardManager get() = delegate.nativeClipboard override suspend fun getClipEntry(): ClipEntry? = delegate.getClipEntry() override suspend fun setClipEntry(clipEntry: ClipEntry?) { clipEntry?.clipData?.let(SensitiveClip::markSensitive) delegate.setClipEntry(clipEntry) - if (clipEntry != null) SensitiveClip.scheduleClear(nativeClipboard) + if (clipEntry != null) SensitiveClip.scheduleClear(clipboardManager) } } diff --git a/app/src/test/java/com/pasich/encly/presentation/components/SensitiveClipTest.kt b/app/src/test/java/com/pasich/encly/presentation/components/SensitiveClipTest.kt index 52bc5e45..89032990 100644 --- a/app/src/test/java/com/pasich/encly/presentation/components/SensitiveClipTest.kt +++ b/app/src/test/java/com/pasich/encly/presentation/components/SensitiveClipTest.kt @@ -10,6 +10,7 @@ import androidx.compose.runtime.Composable import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.ui.platform.ClipEntry import androidx.compose.ui.platform.LocalClipboard +import androidx.compose.ui.platform.nativeClipboardManager import androidx.compose.ui.test.junit4.createComposeRule import androidx.test.core.app.ApplicationProvider import kotlinx.coroutines.launch @@ -114,6 +115,20 @@ class SensitiveClipTest { assertTrue(clipboard.primaryClip == null || clipboard.primaryClip?.getItemAt(0)?.text.isNullOrEmpty()) } + /** The text toolbar (shown on every selection) reads this; any other Clipboard type throws. */ + @Test + fun theBoundaryStillExposesTheSystemClipboardToTheTextToolbar() { + var native: ClipboardManager? = null + rule.setContent { + SensitiveClipboardBoundary { + native = LocalClipboard.current.nativeClipboardManager + } + } + rule.waitForIdle() + + assertEquals(clipboard, native) + } + @Composable private fun Probe() = Unit } From 86405e044a5397c581cb7404f621d1940aecfb2a Mon Sep 17 00:00:00 2001 From: pasichdev Date: Sun, 18 Oct 2026 23:56:58 +0300 Subject: [PATCH 2/4] fix(tasks): keep the shown data when a read fails after the vault closed The drawer counts and the Tasks list collected database flows with no error handling. When the vault locked, the session closed before the lock event cancelled them, Room re-queried, getDatabase threw "Database session expired" and the app crashed. Such a failure is now logged and the last values stay until the lock screen takes over. --- .../viewmodel/StatisticViewModel.kt | 10 ++++ .../presentation/viewmodel/TasksViewModel.kt | 8 +++ .../viewmodel/StatisticViewModelTest.kt | 51 +++++++++++++++++++ .../viewmodel/TasksViewModelTest.kt | 11 ++++ .../encly/testutil/TestTagsRepository.kt | 8 ++- .../encly/testutil/TestTasksRepository.kt | 11 +++- 6 files changed, 96 insertions(+), 3 deletions(-) create mode 100644 app/src/test/java/com/pasich/encly/presentation/viewmodel/StatisticViewModelTest.kt diff --git a/app/src/main/java/com/pasich/encly/presentation/viewmodel/StatisticViewModel.kt b/app/src/main/java/com/pasich/encly/presentation/viewmodel/StatisticViewModel.kt index fa205ccc..372c6d1e 100644 --- a/app/src/main/java/com/pasich/encly/presentation/viewmodel/StatisticViewModel.kt +++ b/app/src/main/java/com/pasich/encly/presentation/viewmodel/StatisticViewModel.kt @@ -2,21 +2,30 @@ package com.pasich.encly.presentation.viewmodel import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope +import com.pasich.encly.core.AppLogger import com.pasich.encly.domain.repository.TagsRepository import com.pasich.encly.domain.repository.TasksRepository import dagger.hilt.android.lifecycle.HiltViewModel import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.catch import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.stateIn import javax.inject.Inject +private const val TAG = "StatisticViewModel" + @HiltViewModel class StatisticViewModel @Inject constructor(tagsRepository: TagsRepository, tasksRepository: TasksRepository) : ViewModel() { + /** + * The counts below read the vault while the drawer is open. A read that fails (the vault was + * just locked and its session closed) keeps the last count instead of crashing the app. + */ val totalTagsCreated: StateFlow = tagsRepository.getTags() .map { tags -> tags.size } + .catch { AppLogger.e(TAG, "Tags count read failed") } .stateIn( scope = viewModelScope, started = SharingStarted.WhileSubscribed(5000), @@ -25,6 +34,7 @@ class StatisticViewModel @Inject constructor(tagsRepository: TagsRepository, tas /** Open (not completed) tasks: the drawer's Tasks badge. */ val openTasksCount: StateFlow = tasksRepository.getActiveTasksCount() + .catch { AppLogger.e(TAG, "Open tasks count read failed") } .stateIn( scope = viewModelScope, started = SharingStarted.WhileSubscribed(5000), diff --git a/app/src/main/java/com/pasich/encly/presentation/viewmodel/TasksViewModel.kt b/app/src/main/java/com/pasich/encly/presentation/viewmodel/TasksViewModel.kt index a8742de6..6f60dc4f 100644 --- a/app/src/main/java/com/pasich/encly/presentation/viewmodel/TasksViewModel.kt +++ b/app/src/main/java/com/pasich/encly/presentation/viewmodel/TasksViewModel.kt @@ -7,6 +7,7 @@ import androidx.compose.runtime.setValue import androidx.lifecycle.SavedStateHandle import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope +import com.pasich.encly.core.AppLogger import com.pasich.encly.core.security.NeverLocked import com.pasich.encly.core.security.VaultLockEvents import com.pasich.encly.data.model.Subtask @@ -23,12 +24,15 @@ import kotlinx.coroutines.flow.SharedFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asSharedFlow import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.catch import kotlinx.coroutines.flow.combine import kotlinx.coroutines.launch import kotlinx.coroutines.sync.Mutex import kotlinx.coroutines.sync.withLock import javax.inject.Inject +private const val TAG = "TasksViewModel" + // Filters for tasks data class TaskFilter( val id: String, @@ -205,6 +209,10 @@ class TasksViewModel @Inject constructor( ) { activeTasks, completedTasks, activeCount, completedCount, subtasks -> TaskFilterEngine.reduce(_uiState.value, activeTasks, completedTasks, activeCount, completedCount) .copy(subtasks = subtasks.groupBy { it.taskId }) + }.catch { + // The vault closed under the list (locked; [clearOnLock] resets the screen right + // after). Keep what is shown rather than crash or blank it as an empty list. + AppLogger.e(TAG, "Tasks read failed") }.collect { newState -> _uiState.value = newState if (!restoredTreesChecked) { diff --git a/app/src/test/java/com/pasich/encly/presentation/viewmodel/StatisticViewModelTest.kt b/app/src/test/java/com/pasich/encly/presentation/viewmodel/StatisticViewModelTest.kt new file mode 100644 index 00000000..58a845ba --- /dev/null +++ b/app/src/test/java/com/pasich/encly/presentation/viewmodel/StatisticViewModelTest.kt @@ -0,0 +1,51 @@ +package com.pasich.encly.presentation.viewmodel + +import com.pasich.encly.data.model.Tag +import com.pasich.encly.data.model.Task +import com.pasich.encly.testutil.TestTagsRepository +import com.pasich.encly.testutil.TestTasksRepository +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.launch +import kotlinx.coroutines.test.UnconfinedTestDispatcher +import kotlinx.coroutines.test.advanceUntilIdle +import kotlinx.coroutines.test.resetMain +import kotlinx.coroutines.test.runTest +import kotlinx.coroutines.test.setMain +import org.junit.After +import org.junit.Assert.assertEquals +import org.junit.Before +import org.junit.Test + +/** The drawer's counts: a read that fails once the vault closed keeps the last count. */ +@OptIn(ExperimentalCoroutinesApi::class) +class StatisticViewModelTest { + private val dispatcher = UnconfinedTestDispatcher() + private val tags = TestTagsRepository(listOf(Tag(id = 1, nameTag = "a"), Tag(id = 2, nameTag = "b"))) + private val tasks = TestTasksRepository(listOf(Task(id = 1, title = "Open"))) + + @Before + fun setUp() = Dispatchers.setMain(dispatcher) + + @After + fun tearDown() = Dispatchers.resetMain() + + @Test + fun aFailedReadKeepsTheLastCountsInsteadOfCrashing() = runTest(dispatcher) { + val viewModel = StatisticViewModel(tags, tasks) + backgroundScope.launch { viewModel.totalTagsCreated.collect {} } + backgroundScope.launch { viewModel.openTasksCount.collect {} } + advanceUntilIdle() + assertEquals(2, viewModel.totalTagsCreated.value) + assertEquals(1, viewModel.openTasksCount.value) + + tags.failReads = true + tasks.failReads = true + tags.tags.value = tags.tags.value + Tag(id = 3, nameTag = "c") + tasks.tasks.value = tasks.tasks.value + Task(id = 2, title = "Another") + advanceUntilIdle() + + assertEquals(2, viewModel.totalTagsCreated.value) + assertEquals(1, viewModel.openTasksCount.value) + } +} diff --git a/app/src/test/java/com/pasich/encly/presentation/viewmodel/TasksViewModelTest.kt b/app/src/test/java/com/pasich/encly/presentation/viewmodel/TasksViewModelTest.kt index a909bee2..2aefaacf 100644 --- a/app/src/test/java/com/pasich/encly/presentation/viewmodel/TasksViewModelTest.kt +++ b/app/src/test/java/com/pasich/encly/presentation/viewmodel/TasksViewModelTest.kt @@ -64,6 +64,17 @@ class TasksViewModelTest { assertEquals(TaskFilterEngine.ALL_FILTER_ID, state.selectedActiveFilter?.id) } + @Test + fun aReadFailingAfterTheVaultClosedKeepsTheListInsteadOfCrashing() = runTest(dispatcher) { + advanceUntilIdle() + + repository.failReads = true + repository.tasks.value = repository.tasks.value + Task(id = 4, title = "New") + advanceUntilIdle() + + assertEquals(listOf("High", "Low"), viewModel.uiState.value.activeTasks.map { it.title }) + } + @Test fun anEmptyListIsLoadedNotLoading() = runTest(dispatcher) { repository.tasks.value = emptyList() diff --git a/app/src/test/java/com/pasich/encly/testutil/TestTagsRepository.kt b/app/src/test/java/com/pasich/encly/testutil/TestTagsRepository.kt index 3edb049d..78bc64c3 100644 --- a/app/src/test/java/com/pasich/encly/testutil/TestTagsRepository.kt +++ b/app/src/test/java/com/pasich/encly/testutil/TestTagsRepository.kt @@ -11,12 +11,18 @@ internal class TestTagsRepository(initial: List = emptyList()) : TagsReposi val tags = MutableStateFlow(initial) var failWrites = false + /** While set, reading the tags fails, as it does once the vault's session has closed. */ + var failReads = false + /** Every list passed to [updateTags], in call order. */ val savedOrders = mutableListOf>() private var nextId = (initial.maxOfOrNull { it.id } ?: 0L) + 1 - override fun getTags(): Flow> = tags.map { list -> list.sortedBy { it.position } } + override fun getTags(): Flow> = tags.map { list -> + if (failReads) error("Database session expired") + list.sortedBy { it.position } + } override suspend fun addTag(tag: Tag): Result { if (failWrites) return Result.failure(IllegalStateException("add")) diff --git a/app/src/test/java/com/pasich/encly/testutil/TestTasksRepository.kt b/app/src/test/java/com/pasich/encly/testutil/TestTasksRepository.kt index 159a70f0..7479c187 100644 --- a/app/src/test/java/com/pasich/encly/testutil/TestTasksRepository.kt +++ b/app/src/test/java/com/pasich/encly/testutil/TestTasksRepository.kt @@ -25,11 +25,18 @@ internal class TestTasksRepository(initial: List = emptyList(), initialSub var failClear = false var failSubtasks = false + /** While set, every list read fails, as it does once the vault's session has closed. */ + var failReads = false + /** Every (id, isCompleted, completedDate) passed to [updateTaskStatus]. */ val statusUpdates = mutableListOf>() - override fun getAllActiveTasks(): Flow> = tasks.map { list -> list.filterNot { it.isCompleted } } - override fun getAllCompletedTasks(): Flow> = tasks.map { list -> list.filter { it.isCompleted } } + override fun getAllActiveTasks(): Flow> = + tasks.map { list -> read { list.filterNot { it.isCompleted } } } + override fun getAllCompletedTasks(): Flow> = + tasks.map { list -> read { list.filter { it.isCompleted } } } + + private fun read(block: () -> T): T = if (failReads) error("Database session expired") else block() override fun getAllTasks(): Flow> = tasks override fun getActiveTasksCount(): Flow = getAllActiveTasks().map { it.size } override fun getCompletedTasksCount(): Flow = getAllCompletedTasks().map { it.size } From 7faf177a4be0849e0cbc706879d3a689b7b3d74a Mon Sep 17 00:00:00 2001 From: pasichdev Date: Sun, 18 Oct 2026 23:56:59 +0300 Subject: [PATCH 3/4] ci(play): publish release tags to Google Play production with the R8 mapping A vX.Y.Z tag now builds, signs and uploads the play bundle to the production track at 100%, with mapping.txt and the store release notes. Google access uses Workload Identity Federation, so no Google key is stored in GitHub. The build job holds the signing key without an OIDC token; the upload job runs in the google-play environment with the OIDC token and only the signed bundle. PLAY_PUBLISH_ENABLED stays the kill switch, and the store test now checks the triggers, the guard and that no job holds both secrets. --- .github/workflows/publish-play.yml | 138 +++++++++++------- CONTRIBUTING.md | 8 +- .../pasich/encly/release/StoreMetadataTest.kt | 54 +++++-- 3 files changed, 129 insertions(+), 71 deletions(-) diff --git a/.github/workflows/publish-play.yml b/.github/workflows/publish-play.yml index 14042139..bc24f976 100644 --- a/.github/workflows/publish-play.yml +++ b/.github/workflows/publish-play.yml @@ -1,97 +1,97 @@ name: Publish to Google Play -# Uploads the signed `play` bundle of a release tag to the Google Play INTERNAL track. +# A release tag vX.Y.Z publishes the signed `play` bundle to the Google Play PRODUCTION track, +# rolled out to 100% of users, with the R8 mapping file and the release notes. +# It can be re-run by hand (Actions -> "Publish to Google Play" -> Run workflow, choosing the tag +# under "Use workflow from"); a run from a branch fails. # -# DISABLED BY DEFAULT. It never runs on push, pull request or tag events: it can only be started -# by hand (Actions -> "Publish to Google Play" -> Run workflow), and even then every job is -# skipped unless the repository VARIABLE PLAY_PUBLISH_ENABLED is exactly 'true'. -# To enable it: add the secrets below, then set the repository variable PLAY_PUBLISH_ENABLED=true. +# Kill switch: every job is skipped unless the repository VARIABLE PLAY_PUBLISH_ENABLED is +# exactly 'true'. # -# Repository secrets (all required once enabled): -# PLAY_SERVICE_ACCOUNT_JSON JSON key of a Play Console service account limited to this app -# ENCLY_KEYSTORE_BASE64 base64 of the release (upload) keystore -# ENCLY_KEYSTORE_PASSWORD keystore password -# ENCLY_KEY_ALIAS key alias -# ENCLY_KEY_PASSWORD key password -# Repository variable (required): -# ENCLY_CERT_SHA256 expected SHA-256 of the signing certificate (as in release.yml) +# No Google credential is stored in GitHub. The `upload` job exchanges the run's GitHub OIDC +# token for a short-lived Google token (Workload Identity Federation); the Google side only +# accepts tokens of this repository's `google-play` environment for a v* tag, and the +# environment only deploys v* tags. The service account has no Google Cloud roles, only +# release rights for this app in Play Console. +# +# The jobs are split so that no job holds both secrets: `build` has the signing key and no +# OIDC token, `upload` has the OIDC token and only sees the signed bundle. +# +# Repository secrets (build): +# ENCLY_KEYSTORE_BASE64, ENCLY_KEYSTORE_PASSWORD, ENCLY_KEY_ALIAS, ENCLY_KEY_PASSWORD +# Repository variables: +# ENCLY_CERT_SHA256 expected SHA-256 of the signing certificate (as in release.yml) +# PLAY_PUBLISH_ENABLED 'true' to publish +# PLAY_WIF_PROVIDER projects//locations/global/workloadIdentityPools//providers/ +# PLAY_SERVICE_ACCOUNT the service account's e-mail, invited in Play Console # # Gradle builds the bundle UNSIGNED and never sees the keystore; a separate step signs it with # jarsigner using only the signing secrets. Third-party actions are pinned by commit SHA. on: + push: + tags: [ 'v*' ] workflow_dispatch: - inputs: - ref: - description: 'Release tag to publish (vX.Y.Z, must match version.properties)' - required: true - type: string - status: - description: 'Release status on the internal track (draft until the app passed its first review)' - required: true - default: draft - type: choice - options: [ draft, completed ] - -permissions: - contents: read + +permissions: {} concurrency: group: publish-play cancel-in-progress: false jobs: - publish: + build: if: vars.PLAY_PUBLISH_ENABLED == 'true' runs-on: ubuntu-latest timeout-minutes: 60 + permissions: + contents: read + outputs: + name: ${{ steps.version.outputs.name }} steps: - name: Only release tags - env: - REF: ${{ inputs.ref }} run: | set -euo pipefail - if ! [[ "$REF" =~ ^v[0-9]+\.[0-9]+\.[0-9]+$ ]]; then - echo "::error::ref must be a release tag vX.Y.Z, got '$REF'" + if ! [[ "$GITHUB_REF" =~ ^refs/tags/v[0-9]+\.[0-9]+\.[0-9]+$ ]]; then + echo "::error::run this workflow from a release tag vX.Y.Z, got '$GITHUB_REF'" exit 1 fi - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: - ref: refs/tags/${{ inputs.ref }} + persist-credentials: false - name: Validate Gradle wrapper uses: gradle/actions/wrapper-validation@3f5f9adaf7d9fecd50b5935e54106014257a94e6 # v6.4.0 - name: Tag must match version.properties id: version - env: - REF: ${{ inputs.ref }} run: | set -euo pipefail prop() { grep -E "^$1=" version.properties | cut -d= -f2 | tr -d ' \r'; } major="$(prop VERSION_MAJOR)"; minor="$(prop VERSION_MINOR)"; patch="$(prop VERSION_PATCH)" name="$major.$minor.$patch" - if [ "v$name" != "$REF" ]; then - echo "::error::Tag $REF does not match version.properties (v$name)" + if [ "v$name" != "$GITHUB_REF_NAME" ]; then + echo "::error::Tag $GITHUB_REF_NAME does not match version.properties (v$name)" exit 1 fi echo "name=$name" >> "$GITHUB_OUTPUT" echo "code=$((major * 10000 + minor * 100 + patch))" >> "$GITHUB_OUTPUT" - - name: Required secrets are set + - name: Required secrets and variables are set env: - PLAY_JSON: ${{ secrets.PLAY_SERVICE_ACCOUNT_JSON }} KEYSTORE_BASE64: ${{ secrets.ENCLY_KEYSTORE_BASE64 }} KEYSTORE_PASSWORD: ${{ secrets.ENCLY_KEYSTORE_PASSWORD }} KEY_ALIAS: ${{ secrets.ENCLY_KEY_ALIAS }} KEY_PASSWORD: ${{ secrets.ENCLY_KEY_PASSWORD }} CERT_SHA256: ${{ vars.ENCLY_CERT_SHA256 }} + WIF_PROVIDER: ${{ vars.PLAY_WIF_PROVIDER }} + SERVICE_ACCOUNT: ${{ vars.PLAY_SERVICE_ACCOUNT }} run: | set -euo pipefail missing=0 - for v in PLAY_JSON KEYSTORE_BASE64 KEYSTORE_PASSWORD KEY_ALIAS KEY_PASSWORD CERT_SHA256; do - if [ -z "${!v}" ]; then echo "::error::secret for $v is not set"; missing=1; fi + for v in KEYSTORE_BASE64 KEYSTORE_PASSWORD KEY_ALIAS KEY_PASSWORD CERT_SHA256 WIF_PROVIDER SERVICE_ACCOUNT; do + if [ -z "${!v}" ]; then echo "::error::$v is not set"; missing=1; fi done # Play rejects unsigned bundles; fail here instead of after a long build. exit $missing @@ -141,28 +141,58 @@ jobs: exit 1 fi - - name: Release notes from fastlane changelogs + - name: Collect the bundle, mapping and release notes env: CODE: ${{ steps.version.outputs.code }} run: | set -euo pipefail - mkdir -p whatsnew + mkdir -p play/whatsnew + cp app/build/outputs/bundle/playRelease/app-play-release.aab play/ + cp app/build/outputs/mapping/playRelease/mapping.txt play/ for dir in fastlane/metadata/android/*/; do locale="$(basename "$dir")" notes="$dir/changelogs/$CODE.txt" - [ -f "$notes" ] && cp "$notes" "whatsnew/whatsnew-$locale" + [ -f "$notes" ] && cp "$notes" "play/whatsnew/whatsnew-$locale" done - test -f whatsnew/whatsnew-en-US || { echo "::error::no en-US changelog for $CODE"; exit 1; } - ls whatsnew + test -f play/whatsnew/whatsnew-en-US || { echo "::error::no en-US changelog for $CODE"; exit 1; } + ls -lR play + + - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: play-release + path: play/ + retention-days: 1 + if-no-files-found: error + + upload: + if: vars.PLAY_PUBLISH_ENABLED == 'true' + needs: build + runs-on: ubuntu-latest + timeout-minutes: 30 + environment: google-play + permissions: + id-token: write + steps: + - uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 + with: + name: play-release + path: play + + - name: Authenticate to Google (Workload Identity Federation) + id: auth + uses: google-github-actions/auth@7c6bc770dae815cd3e89ee6cdf493a5fab2cc093 # v3.0.0 + with: + workload_identity_provider: ${{ vars.PLAY_WIF_PROVIDER }} + service_account: ${{ vars.PLAY_SERVICE_ACCOUNT }} - - name: Upload to Google Play (internal track) + - name: Upload to Google Play (production, 100%) uses: r0adkll/upload-google-play@e738b9dd8f2476ea806d921b64aacd24f34515a5 # v1.1.5 with: - serviceAccountJsonPlainText: ${{ secrets.PLAY_SERVICE_ACCOUNT_JSON }} + serviceAccountJson: ${{ steps.auth.outputs.credentials_file_path }} packageName: com.pasich.encly - releaseFiles: app/build/outputs/bundle/playRelease/app-play-release.aab - releaseName: ${{ steps.version.outputs.name }} - track: internal - status: ${{ inputs.status }} - mappingFile: app/build/outputs/mapping/playRelease/mapping.txt - whatsNewDirectory: whatsnew + releaseFiles: play/app-play-release.aab + releaseName: ${{ needs.build.outputs.name }} + track: production + status: completed + mappingFile: play/mapping.txt + whatsNewDirectory: play/whatsnew diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index cde2db64..52b33673 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -212,10 +212,10 @@ upload this key instead of letting Google generate one). the keystore), checks the certificate against `ENCLY_CERT_SHA256`, and publishes the APKs with `SHA256SUMS` (signed as `SHA256SUMS.asc` when the GPG secrets are set) and the R8 mapping files. -6. Google Play: either upload `app-play-release.aab` by hand - (`./gradlew :app:bundlePlayRelease` with the `ENCLY_*` variables set), or, once enabled, run - the [Publish to Google Play](.github/workflows/publish-play.yml) workflow with the tag; it - uploads to the internal track, from where you promote it in Play Console. +6. Google Play: the same tag runs [Publish to Google Play](.github/workflows/publish-play.yml), + which builds and signs the `play` bundle and publishes it to production for 100% of users, + with the R8 mapping file (so Play Console shows readable crash stack traces) and the release + notes from step 3. To re-run it, start the workflow by hand from the tag. 7. F-Droid picks the new tag up by itself (`AutoUpdateMode: Version`), usually within a few days. ### F-Droid reproducibility diff --git a/app/src/test/java/com/pasich/encly/release/StoreMetadataTest.kt b/app/src/test/java/com/pasich/encly/release/StoreMetadataTest.kt index d611ea58..87ecd95d 100644 --- a/app/src/test/java/com/pasich/encly/release/StoreMetadataTest.kt +++ b/app/src/test/java/com/pasich/encly/release/StoreMetadataTest.kt @@ -13,7 +13,8 @@ import java.util.Properties * F-Droid because of a missing file or an over-long text: * - fastlane/metadata/android: one listing per shipped language, Play's length limits, release * notes for the current versionCode; - * - .github/workflows/publish-play.yml: can only be started by hand and only when enabled. + * - .github/workflows/publish-play.yml: runs only for release tags and only when enabled, and no + * job holds both the signing key and the Google token. */ class StoreMetadataTest { @@ -83,21 +84,48 @@ class StoreMetadataTest { } @Test - fun playPublishingRunsOnlyByHandAndOnlyWhenEnabled() { - val lines = File(root, PUBLISH_WORKFLOW).readLines() - .map { it.substringBefore(" #").trimEnd() } - .filter { it.isNotBlank() && !it.trimStart().startsWith("#") } + fun playPublishingRunsOnlyForReleaseTagsAndOnlyWhenEnabled() { + val lines = publishWorkflowLines() + + val trigger = section(lines, "on:") + assertEquals( + listOf("push:", "tags: [ 'v*' ]", "workflow_dispatch:"), + trigger.map { it.trim() }, + ) + + val jobs = publishJobs(lines) + assertFalse("no jobs found", jobs.isEmpty()) + jobs.forEach { (job, body) -> + val guard = body.filter { it.indentation() == 4 }.firstOrNull { it.trim().startsWith("if:") } + assertEquals("$job must be guarded", "if: $PUBLISH_GUARD", guard?.trim()) + } + } - val triggers = section(lines, "on:").filter { it.indentation() == 2 }.map { it.trim() } - assertEquals(listOf("workflow_dispatch:"), triggers) + @Test + fun noPlayPublishingJobHoldsBothTheSigningKeyAndTheGoogleToken() { + val lines = publishWorkflowLines() + assertEquals("permissions: {}", lines.firstOrNull { it.startsWith("permissions:") }) + + publishJobs(lines).forEach { (job, body) -> + val oidc = body.any { it.trim() == "id-token: write" } + val secrets = body.any { "secrets." in it } + assertFalse("$job has the signing secrets and an OIDC token", oidc && secrets) + if (oidc) { + val environment = body.any { it.trim() == "environment: google-play" } + assertTrue("$job must deploy through the google-play environment", environment) + } + } + } + private fun publishWorkflowLines(): List = File(root, PUBLISH_WORKFLOW).readLines() + .map { it.substringBefore(" #").trimEnd() } + .filter { it.isNotBlank() && !it.trimStart().startsWith("#") } + + /** Each job's name with its body. */ + private fun publishJobs(lines: List): List>> { val jobs = section(lines, "jobs:") - val jobNames = jobs.filter { it.indentation() == 2 } - assertFalse("no jobs found", jobNames.isEmpty()) - jobNames.forEach { job -> - val body = jobs.dropWhile { it != job }.drop(1).takeWhile { it.indentation() > 2 } - val guard = body.filter { it.indentation() == 4 }.firstOrNull { it.trim().startsWith("if:") } - assertEquals("${job.trim()} must be guarded", "if: $PUBLISH_GUARD", guard?.trim()) + return jobs.filter { it.indentation() == 2 }.map { job -> + job.trim().removeSuffix(":") to jobs.dropWhile { it != job }.drop(1).takeWhile { it.indentation() > 2 } } } From 9d8570d2147657f8bc8efc1cfa05ed496f558808 Mon Sep 17 00:00:00 2001 From: pasichdev Date: Sun, 18 Oct 2026 23:56:59 +0300 Subject: [PATCH 4/4] release: 2.2.1, fix crashes on text selection and on vault lock Bumps the version to 2.2.1 (versionCode 20201), adds the 2.2.1 section to the changelog and the store release notes in all nine store languages. --- CHANGELOG.md | 21 ++++++++++++++++++- .../android/de-DE/changelogs/20201.txt | 1 + .../android/en-US/changelogs/20201.txt | 1 + .../android/es-ES/changelogs/20201.txt | 1 + .../android/fr-FR/changelogs/20201.txt | 1 + .../android/it-IT/changelogs/20201.txt | 1 + .../android/nl-NL/changelogs/20201.txt | 1 + .../android/pl-PL/changelogs/20201.txt | 1 + .../android/pt-PT/changelogs/20201.txt | 1 + .../metadata/android/uk/changelogs/20201.txt | 1 + version.properties | 2 +- 11 files changed, 30 insertions(+), 2 deletions(-) create mode 100644 fastlane/metadata/android/de-DE/changelogs/20201.txt create mode 100644 fastlane/metadata/android/en-US/changelogs/20201.txt create mode 100644 fastlane/metadata/android/es-ES/changelogs/20201.txt create mode 100644 fastlane/metadata/android/fr-FR/changelogs/20201.txt create mode 100644 fastlane/metadata/android/it-IT/changelogs/20201.txt create mode 100644 fastlane/metadata/android/nl-NL/changelogs/20201.txt create mode 100644 fastlane/metadata/android/pl-PL/changelogs/20201.txt create mode 100644 fastlane/metadata/android/pt-PT/changelogs/20201.txt create mode 100644 fastlane/metadata/android/uk/changelogs/20201.txt diff --git a/CHANGELOG.md b/CHANGELOG.md index 80632d8f..e535fcea 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,6 +11,24 @@ IzzyOnDroid) and used as the GitHub Release notes. ## [Unreleased] +## [2.2.1] - 2026-10-18 + +versionCode 20201. + +### Fixed + +- The app crashed whenever text was selected in a note (for example by double-tapping a word). + Encly's sensitive-clipboard wrapper was not an `AndroidClipboard`, which the text selection + toolbar of Compose 1.12 requires. Copies stay marked sensitive and still clear after a minute. +- The app could crash when the vault locked while the Tasks list or the drawer counts were + being read. Such a read now keeps what is shown; the lock screen replaces it right after. + +### Changed + +- Google Play releases are published from CI on the release tag, together with the R8 mapping + file, so Play Console shows readable crash reports. No Google credential is stored in the + repository settings (Workload Identity Federation). + ## [2.2.0] - 2026-10-15 versionCode 20200. @@ -245,7 +263,8 @@ The last release of the old storage format (versionCode 30): the SQLCipher key w Keystore-sealed seed hash, with an optional 4-digit PIN. It had no public users; superseded by 2.0.0. -[Unreleased]: https://github.com/pasichDev/Encly/compare/v2.2.0...HEAD +[Unreleased]: https://github.com/pasichDev/Encly/compare/v2.2.1...HEAD +[2.2.1]: https://github.com/pasichDev/Encly/compare/v2.2.0...v2.2.1 [2.2.0]: https://github.com/pasichDev/Encly/releases/tag/v2.2.0 [2.1.0]: https://github.com/pasichDev/Encly/releases/tag/v2.1.0 [2.0.1]: https://github.com/pasichDev/Encly/releases/tag/v2.0.1 diff --git a/fastlane/metadata/android/de-DE/changelogs/20201.txt b/fastlane/metadata/android/de-DE/changelogs/20201.txt new file mode 100644 index 00000000..77c99c96 --- /dev/null +++ b/fastlane/metadata/android/de-DE/changelogs/20201.txt @@ -0,0 +1 @@ +Encly 2.2.1: behebt einen Absturz beim Markieren von Text in einer Notiz (zum Beispiel durch Doppeltippen auf ein Wort) und einen Absturz, wenn sich die App beim Anzeigen von Aufgaben sperrte. diff --git a/fastlane/metadata/android/en-US/changelogs/20201.txt b/fastlane/metadata/android/en-US/changelogs/20201.txt new file mode 100644 index 00000000..5cd2189b --- /dev/null +++ b/fastlane/metadata/android/en-US/changelogs/20201.txt @@ -0,0 +1 @@ +Encly 2.2.1: fixes a crash when selecting text in a note (for example by double-tapping a word) and a crash when the app locked while showing tasks. diff --git a/fastlane/metadata/android/es-ES/changelogs/20201.txt b/fastlane/metadata/android/es-ES/changelogs/20201.txt new file mode 100644 index 00000000..6b446233 --- /dev/null +++ b/fastlane/metadata/android/es-ES/changelogs/20201.txt @@ -0,0 +1 @@ +Encly 2.2.1: corrige un cierre inesperado al seleccionar texto en una nota (por ejemplo, al tocar dos veces una palabra) y otro cuando la app se bloqueaba mientras mostraba tareas. diff --git a/fastlane/metadata/android/fr-FR/changelogs/20201.txt b/fastlane/metadata/android/fr-FR/changelogs/20201.txt new file mode 100644 index 00000000..41a6aa86 --- /dev/null +++ b/fastlane/metadata/android/fr-FR/changelogs/20201.txt @@ -0,0 +1 @@ +Encly 2.2.1 : corrige un plantage lors de la sélection de texte dans une note (par exemple en touchant deux fois un mot) et un plantage quand l’application se verrouillait en affichant les tâches. diff --git a/fastlane/metadata/android/it-IT/changelogs/20201.txt b/fastlane/metadata/android/it-IT/changelogs/20201.txt new file mode 100644 index 00000000..64c08c11 --- /dev/null +++ b/fastlane/metadata/android/it-IT/changelogs/20201.txt @@ -0,0 +1 @@ +Encly 2.2.1: corregge un arresto anomalo durante la selezione del testo in una nota (ad esempio toccando due volte una parola) e uno quando l’app si bloccava mentre mostrava le attività. diff --git a/fastlane/metadata/android/nl-NL/changelogs/20201.txt b/fastlane/metadata/android/nl-NL/changelogs/20201.txt new file mode 100644 index 00000000..7584b357 --- /dev/null +++ b/fastlane/metadata/android/nl-NL/changelogs/20201.txt @@ -0,0 +1 @@ +Encly 2.2.1: lost een crash op bij het selecteren van tekst in een notitie (bijvoorbeeld door twee keer op een woord te tikken) en een crash wanneer de app vergrendelde terwijl taken werden getoond. diff --git a/fastlane/metadata/android/pl-PL/changelogs/20201.txt b/fastlane/metadata/android/pl-PL/changelogs/20201.txt new file mode 100644 index 00000000..7226013b --- /dev/null +++ b/fastlane/metadata/android/pl-PL/changelogs/20201.txt @@ -0,0 +1 @@ +Encly 2.2.1: naprawia awarię podczas zaznaczania tekstu w notatce (na przykład przez dwukrotne stuknięcie słowa) oraz awarię, gdy aplikacja blokowała się podczas wyświetlania zadań. diff --git a/fastlane/metadata/android/pt-PT/changelogs/20201.txt b/fastlane/metadata/android/pt-PT/changelogs/20201.txt new file mode 100644 index 00000000..4473b692 --- /dev/null +++ b/fastlane/metadata/android/pt-PT/changelogs/20201.txt @@ -0,0 +1 @@ +Encly 2.2.1: corrige uma falha ao selecionar texto numa nota (por exemplo, ao tocar duas vezes numa palavra) e uma falha quando a aplicação bloqueava enquanto mostrava tarefas. diff --git a/fastlane/metadata/android/uk/changelogs/20201.txt b/fastlane/metadata/android/uk/changelogs/20201.txt new file mode 100644 index 00000000..4c81abd7 --- /dev/null +++ b/fastlane/metadata/android/uk/changelogs/20201.txt @@ -0,0 +1 @@ +Encly 2.2.1: виправлено збій під час виділення тексту в нотатці (наприклад, подвійним дотиком до слова) і збій, коли застосунок блокувався під час показу завдань. diff --git a/version.properties b/version.properties index 2a732220..5ff43af2 100644 --- a/version.properties +++ b/version.properties @@ -8,4 +8,4 @@ # refuses a tag that does not match this file. See CONTRIBUTING.md > Releasing. VERSION_MAJOR=2 VERSION_MINOR=2 -VERSION_PATCH=0 +VERSION_PATCH=1