From 967bd0c3ab3f165a591bc355539ff15c30fa794e Mon Sep 17 00:00:00 2001 From: Melad Raouf Date: Mon, 22 Jun 2026 11:09:50 +0100 Subject: [PATCH 01/14] fix: upload session scopes in FIFO order Change ORDER BY in SessionScopeRoomDao.loadClosed from DESC to ASC so older sessions are prioritised over newer ones during upload. Add SessionScopeRoomDaoTest to verify FIFO ordering is preserved. --- .../events/event/local/SessionScopeRoomDao.kt | 2 +- .../event/local/SessionScopeRoomDaoTest.kt | 73 +++++++++++++++++++ 2 files changed, 74 insertions(+), 1 deletion(-) create mode 100644 infra/events/src/test/java/com/simprints/infra/events/event/local/SessionScopeRoomDaoTest.kt diff --git a/infra/events/src/main/java/com/simprints/infra/events/event/local/SessionScopeRoomDao.kt b/infra/events/src/main/java/com/simprints/infra/events/event/local/SessionScopeRoomDao.kt index 0880135afd..66312d86ec 100644 --- a/infra/events/src/main/java/com/simprints/infra/events/event/local/SessionScopeRoomDao.kt +++ b/infra/events/src/main/java/com/simprints/infra/events/event/local/SessionScopeRoomDao.kt @@ -15,7 +15,7 @@ internal interface SessionScopeRoomDao { @Query("select * from DbEventScope where type = :type AND end_unixMs IS NULL order by start_unixMs desc") suspend fun loadOpen(type: EventScopeType): List - @Query("select * from DbEventScope where type = :type AND end_unixMs IS NOT NULL order by start_unixMs desc limit :limit") + @Query("select * from DbEventScope where type = :type AND end_unixMs IS NOT NULL order by start_unixMs asc limit :limit") suspend fun loadClosed( type: EventScopeType, limit: Int, diff --git a/infra/events/src/test/java/com/simprints/infra/events/event/local/SessionScopeRoomDaoTest.kt b/infra/events/src/test/java/com/simprints/infra/events/event/local/SessionScopeRoomDaoTest.kt new file mode 100644 index 0000000000..49d9ce3e4d --- /dev/null +++ b/infra/events/src/test/java/com/simprints/infra/events/event/local/SessionScopeRoomDaoTest.kt @@ -0,0 +1,73 @@ +package com.simprints.infra.events.event.local + +import android.content.Context +import androidx.room.Room +import androidx.test.core.app.ApplicationProvider +import androidx.test.ext.junit.runners.AndroidJUnit4 +import com.google.common.truth.Truth.assertThat +import com.simprints.core.tools.time.Timestamp +import com.simprints.infra.events.event.domain.models.scope.EventScopeType +import com.simprints.infra.events.event.local.models.fromDomainToDb +import com.simprints.infra.events.sampledata.SampleDefaults.DEFAULT_PROJECT_ID +import com.simprints.infra.events.sampledata.SampleDefaults.GUID1 +import com.simprints.infra.events.sampledata.SampleDefaults.GUID2 +import com.simprints.infra.events.sampledata.SampleDefaults.GUID3 +import com.simprints.infra.events.sampledata.createSessionScope +import kotlinx.coroutines.test.runTest +import org.junit.After +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import java.io.IOException + +@RunWith(AndroidJUnit4::class) +internal class SessionScopeRoomDaoTest { + private lateinit var db: EventRoomDatabase + private lateinit var scopeDao: SessionScopeRoomDao + + @Before + fun setup() { + val context = ApplicationProvider.getApplicationContext() + db = Room + .inMemoryDatabaseBuilder(context, EventRoomDatabase::class.java) + .allowMainThreadQueries() + .build() + scopeDao = db.scopeDao + } + + @After + @Throws(IOException::class) + fun tearDown() { + db.close() + } + + @Test + fun `loadClosed returns scopes in FIFO order`() = runTest { + val oldestScope = createSessionScope(id = GUID1, createdAt = Timestamp(1000L), projectId = DEFAULT_PROJECT_ID, isClosed = true).fromDomainToDb() + val middleScope = createSessionScope(id = GUID2, createdAt = Timestamp(2000L), projectId = DEFAULT_PROJECT_ID, isClosed = true).fromDomainToDb() + val newestScope = createSessionScope(id = GUID3, createdAt = Timestamp(3000L), projectId = DEFAULT_PROJECT_ID, isClosed = true).fromDomainToDb() + // Insert in reverse order to ensure ordering isn't insertion-dependent + scopeDao.insertOrUpdate(newestScope) + scopeDao.insertOrUpdate(middleScope) + scopeDao.insertOrUpdate(oldestScope) + + val result = scopeDao.loadClosed(EventScopeType.SESSION, limit = 10) + + assertThat(result.map { it.id }).isEqualTo(listOf(GUID1, GUID2, GUID3)) + } + + @Test + fun `loadClosed respects the limit parameter`() = runTest { + val oldestScope = createSessionScope(id = GUID1, createdAt = Timestamp(1000L), projectId = DEFAULT_PROJECT_ID, isClosed = true).fromDomainToDb() + val middleScope = createSessionScope(id = GUID2, createdAt = Timestamp(2000L), projectId = DEFAULT_PROJECT_ID, isClosed = true).fromDomainToDb() + val newestScope = createSessionScope(id = GUID3, createdAt = Timestamp(3000L), projectId = DEFAULT_PROJECT_ID, isClosed = true).fromDomainToDb() + scopeDao.insertOrUpdate(oldestScope) + scopeDao.insertOrUpdate(middleScope) + scopeDao.insertOrUpdate(newestScope) + + val result = scopeDao.loadClosed(EventScopeType.SESSION, limit = 2) + + // With FIFO ordering, the oldest 2 are returned + assertThat(result.map { it.id }).isEqualTo(listOf(GUID1, GUID2)) + } +} From 4e9c9c90128d13e85be5a52877247a1cfb920559 Mon Sep 17 00:00:00 2001 From: "transifex-integration[bot]" <43880903+transifex-integration[bot]@users.noreply.github.com> Date: Tue, 23 Jun 2026 08:42:00 +0000 Subject: [PATCH 02/14] Translate strings.xml in am 100% translated source file: 'strings.xml' on 'am'. --- .../src/main/res/values-am/strings.xml | 40 ++++++++++++++----- 1 file changed, 31 insertions(+), 9 deletions(-) diff --git a/infra/resources/src/main/res/values-am/strings.xml b/infra/resources/src/main/res/values-am/strings.xml index 345d1c83eb..9e44cb810f 100644 --- a/infra/resources/src/main/res/values-am/strings.xml +++ b/infra/resources/src/main/res/values-am/strings.xml @@ -471,16 +471,19 @@ የማከማቻ ማመቻቸት በሂደት ላይ ነው። - የመለየት ችግር - የልየታ እጩዎችን በማጣራት ላይ - የተጠቃሚው መለያ እና ሲንከ የተደረገው መረጃ አልተመሳሰለም - ሞጁሉ መለያ ሲንክ ከተደረገው መረጃ ጋር አልተመሳሰለም - መረጃው በቅርቡ አልተላከም - ሲንክ በማድረግ ላይ - ስልኩ ላይ ምንም መረጃ የለም - ዝጋ + በማረጋገጥ ላይ… + መለየት አይሳካም + ለዚህ የመለየት ጥያቄ ተዛማጅ ሊሆኑ የሚችሉ መረጃዎች አልተገኙም። +ይህ የሚጠበቅ ከሆነ ምዝገባውን መቀጠል ይችላሉ። ያለበለዚያ እባክዎን የፕሮጀክት ስራ አስኪያጅዎን ያነጋግሩ። + በጥያቄው ላይ ያለው የአገልጋይ መለያ (Attendant ID) ከሲምፕሪንትስ መለያ (Simprints ID) ጋር አይዛመድም። + በጥያቄው ላይ ያለው የሞጁል መለያ (Module ID) በሲምፕሪንትስ (Simprints ID) ላይ ከተመረጡት ሞጁሎች መካከል ከአንዳቸውም ጋር አይዛመድም። + የሲምፕሪንትስ መረጃዎ ከሰርቨር ጋር ሳይመሳሰል ረጅም ጊዜ ቆይቷል። + ለዚህ ጥያቄ የሚሆን ምንም ዓይነት መዝገብ/መረጃ በዚህ መሣሪያ (ስልክ) ላይ አልተገኘም። + የመጨረሻው የSync ጊዜ 1%1$s + በማመሳሰል ላይ… ሲንከ አድርግና እና እንደገና ሞክር - በመያዝ ይቀጥሉ + ዝጋ + ቀጥል የተጠቃሚውን የእድሜ ክልል ምረጥ @@ -513,6 +516,8 @@ ምንም የQR ኮድ አልተገኘም ይህንን%1$s በመስኮቱ ውስጥ ያስቀምጡት + በቂ ብርሃን የለም + ከፍተኛ ብርሃን አለ ቀጥል ልክ ያልሆነ የQR ኮድ አይነት የተሳሳተ የQR ኮድ ስካን ተደርጓል @@ -536,6 +541,7 @@ ይህንን %1$sይጨምር? ለምን %1$sን ስካን ማድረግ ዘለልከው? + ቁጥር አለው, 1%1$s (Booklet) የለም %1$sየለውም %1$sአላመጣም። በስህተት የመጣ %1$sነው @@ -545,8 +551,24 @@ ሌላ ምክንያት እባክዎ ለመዝለል ምክንያት ያቅርቡ + የደብተሩን ፎቶ/ስካን ለምን ዘለሉት? + ቁጥር አለው፣ ሰነዱ (ደብተሩ) የለም + ሰነድ (ደብተር) የለውም + ሰነድ (ደብተር) አልያዘም / አልያዘችም + የመጣው ሰነድ/ደብተር ትክክል አይደለም + ሰነድ (ደብተር) ለመስጠት ፈቃደኛ አይደለም / አይደለችም + ሰነዱ (ደብተሩ) የተበላሸ ወይም የማይነበብ ነው + የደብተሩን ፎቶ/ስካን ማድረግ አልተቻለም + ሌላ +  + ምክንያት ስካን ማድረግን ዝለል ተመለስ + + የማከማቻ ቦታ እጥረት ማሳሰቢያ + የስልኩ ማህደረ ትውስታ (ቦታ) ሊሞላ ነው + መተግበሪያው በትክክል እንዲሰራ የስልክ ቦታ ያስፈልገዋል። እባክዎን የተወሰኑ ፋይሎችን በማጥፋት የስልክዎን ማከማቻ ቦታ ያጽዱ። + የማከማቻ ቦታ ያጽዱ From 04ccd555c8e78a01777eac95ec771ec92ec24e6b Mon Sep 17 00:00:00 2001 From: Melad Raouf Date: Tue, 23 Jun 2026 09:41:56 +0100 Subject: [PATCH 03/14] [MS-1459] Enhance version code computation and validation in reusable-build-apk.yml - Extract version code generation and validation into a reusable shell script - Add unit tests for the version generation script - Add test-version-generation workflow Cherry-picked from 23a5f42cbf9aab019d1ff8871a908260be8c09e2 and 27741c187bb2d1bb37d4ec22edbdca6e2a2f3b6b --- .github/scripts/generate-version.sh | 52 ++++ .github/scripts/test-generate-version.sh | 253 ++++++++++++++++++ .github/workflows/reusable-build-apk.yml | 35 +-- .github/workflows/test-version-generation.yml | 18 ++ 4 files changed, 330 insertions(+), 28 deletions(-) create mode 100755 .github/scripts/generate-version.sh create mode 100755 .github/scripts/test-generate-version.sh create mode 100644 .github/workflows/test-version-generation.yml diff --git a/.github/scripts/generate-version.sh b/.github/scripts/generate-version.sh new file mode 100755 index 0000000000..d8563b7f53 --- /dev/null +++ b/.github/scripts/generate-version.sh @@ -0,0 +1,52 @@ +#!/usr/bin/env bash +# Validates VERSION_NAME and computes VERSION_CODE and FILE_NAME. +# +# Required environment variables: +# VERSION_NAME e.g. "2026.2.0" +# VERSION_SUFFIX e.g. "dev", "staging", "internal" +# RUN_ATTEMPT github.run_attempt — must be 1 (re-runs are not allowed) +# RUN_NUMBER github.run_number +# +# Outputs are appended to $GITHUB_ENV (or stdout if GITHUB_ENV is unset). + +set -euo pipefail + +# Re-runs are disabled because VERSION_CODE uses only run_number for its sequence slot. +if [ "${RUN_ATTEMPT}" -ne 1 ]; then + echo "ERROR: Workflow re-runs are not allowed for this build; trigger a new run instead" >&2 + exit 1 +fi + +# Validate VERSION_NAME format: YYYY.M.P (e.g. 2026.2.0) +if ! echo "$VERSION_NAME" | grep -qE '^[0-9]{4}\.[0-9]{1,2}\.[0-9]{1,2}$'; then + echo "ERROR: VERSION_NAME '$VERSION_NAME' does not match required format YYYY.M.P" >&2 + exit 1 +fi + +IFS='.' read -r VERSION_YEAR VERSION_MINOR VERSION_PATCH <<< "$VERSION_NAME" +# Force base-10 to avoid octal misinterpretation (e.g. 08, 09) +VERSION_YEAR=$((10#$VERSION_YEAR)) +VERSION_MINOR=$((10#$VERSION_MINOR)) +VERSION_PATCH=$((10#$VERSION_PATCH)) + +if [ "$VERSION_YEAR" -lt 2020 ] || [ "$VERSION_YEAR" -gt 2099 ]; then + echo "ERROR: Version year $VERSION_YEAR is out of supported range (2020-2099)" >&2 + exit 1 +fi + +if [ "$VERSION_MINOR" -gt 99 ] || [ "$VERSION_PATCH" -gt 99 ]; then + echo "ERROR: Minor ($VERSION_MINOR) and patch ($VERSION_PATCH) must each be 0-99" >&2 + exit 1 +fi + +VERSION_CODE_CALCULATED=$(( (VERSION_YEAR - 2000) * 10000000 + VERSION_MINOR * 100000 + VERSION_PATCH * 1000 + RUN_NUMBER )) + +if [ "$VERSION_CODE_CALCULATED" -gt 2100000000 ]; then + echo "ERROR: VERSION_CODE $VERSION_CODE_CALCULATED exceeds Android max 2100000000" >&2 + exit 1 +fi + +DEST="${GITHUB_ENV:-/dev/stdout}" +echo "VERSION_CODE=$VERSION_CODE_CALCULATED" >> "$DEST" +echo "VERSION_BUILD=${RUN_NUMBER}.${RUN_ATTEMPT}" >> "$DEST" +echo "FILE_NAME=${VERSION_NAME}+${VERSION_SUFFIX}.${RUN_NUMBER}" >> "$DEST" diff --git a/.github/scripts/test-generate-version.sh b/.github/scripts/test-generate-version.sh new file mode 100755 index 0000000000..dde14864cd --- /dev/null +++ b/.github/scripts/test-generate-version.sh @@ -0,0 +1,253 @@ +#!/usr/bin/env bash +# Tests for generate-version.sh +# +# Run from any directory: bash .github/scripts/test-generate-version.sh +# Exit code 0 = all tests passed, non-zero = one or more failures. + +set -uo pipefail + +SCRIPT="$(cd "$(dirname "$0")" && pwd)/generate-version.sh" +PASS=0 +FAIL=0 + +# --------------------------------------------------------------------------- +# Test runner helpers +# --------------------------------------------------------------------------- + +_run() { + # _run VERSION_NAME RUN_ATTEMPT RUN_NUMBER VERSION_SUFFIX + local env_file + env_file="$(mktemp "${TMPDIR:-/tmp}/gen_version_test.XXXXXX")" + _OUTPUT=$(VERSION_NAME="$1" RUN_ATTEMPT="$2" RUN_NUMBER="$3" VERSION_SUFFIX="$4" \ + GITHUB_ENV="$env_file" bash "$SCRIPT" 2>&1) + _EXIT=$? + _ENV_CONTENT="$(cat "$env_file" 2>/dev/null || true)" + rm -f "$env_file" +} + +_get_env() { + # Extract a key's value written to GITHUB_ENV + echo "$_ENV_CONTENT" | grep "^${1}=" | cut -d= -f2- +} + +assert_success() { + # assert_success LABEL EXPECTED_CODE EXPECTED_BUILD EXPECTED_FILE VERSION_NAME RUN_ATTEMPT RUN_NUMBER VERSION_SUFFIX + local label="$1" expected_code="$2" expected_build="$3" expected_file="$4" + shift 4 + _run "$@" + + if [ "$_EXIT" -ne 0 ]; then + echo "FAIL [$label]: expected success but got exit $_EXIT — $_OUTPUT" + FAIL=$(( FAIL + 1 )) + return + fi + + local actual_code actual_build actual_file + actual_code="$(_get_env VERSION_CODE)" + actual_build="$(_get_env VERSION_BUILD)" + actual_file="$(_get_env FILE_NAME)" + + local ok=1 + if [ "$actual_code" != "$expected_code" ]; then + echo "FAIL [$label]: VERSION_CODE expected=$expected_code actual=$actual_code" + ok=0 + fi + if [ "$actual_build" != "$expected_build" ]; then + echo "FAIL [$label]: VERSION_BUILD expected=$expected_build actual=$actual_build" + ok=0 + fi + if [ "$actual_file" != "$expected_file" ]; then + echo "FAIL [$label]: FILE_NAME expected=$expected_file actual=$actual_file" + ok=0 + fi + + if [ "$ok" -eq 1 ]; then + echo "PASS [$label]" + PASS=$(( PASS + 1 )) + else + FAIL=$(( FAIL + 1 )) + fi +} + +assert_failure() { + # assert_failure LABEL EXPECTED_ERROR_SUBSTRING VERSION_NAME RUN_ATTEMPT RUN_NUMBER VERSION_SUFFIX + local label="$1" expected_msg="$2" + shift 2 + _run "$@" + + if [ "$_EXIT" -eq 0 ]; then + echo "FAIL [$label]: expected failure but script succeeded (output: $_OUTPUT)" + FAIL=$(( FAIL + 1 )) + return + fi + + if ! echo "$_OUTPUT" | grep -qF "$expected_msg"; then + echo "FAIL [$label]: expected error containing '$expected_msg', got: $_OUTPUT" + FAIL=$(( FAIL + 1 )) + return + fi + + echo "PASS [$label]" + PASS=$(( PASS + 1 )) +} + +# --------------------------------------------------------------------------- +# Valid cases — formula: (year-2000)*10_000_000 + minor*100_000 + patch*1_000 + run_number +# --------------------------------------------------------------------------- + +# Standard release: 2026.2.0, run 345 +# = 26*10_000_000 + 2*100_000 + 0 + 345 = 260_200_345 +assert_success "standard release" \ + "260200345" "345.1" "2026.2.0+dev.345" \ + "2026.2.0" 1 345 "dev" + +# With non-zero patch: 2026.2.1, run 345 +# = 260_000_000 + 200_000 + 1_000 + 345 = 260_201_345 +assert_success "non-zero patch" \ + "260201345" "345.1" "2026.2.1+staging.345" \ + "2026.2.1" 1 345 "staging" + +# Octal edge case — leading zeros in month/patch (08, 09 must not be interpreted as octal) +# 2026.08.09, run 1 → (26)*10M + 8*100K + 9*1K + 1 = 260_809_001 +assert_success "octal edge case 08.09" \ + "260809001" "1.1" "2026.08.09+dev.1" \ + "2026.08.09" 1 1 "dev" + +# Another octal edge: 2026.09.08, run 1 → 260_000_000 + 900_000 + 8_000 + 1 = 260_908_001 +assert_success "octal edge case 09.08" \ + "260908001" "1.1" "2026.09.08+dev.1" \ + "2026.09.08" 1 1 "dev" + +# Double-digit minor: 2026.21.0, run 50 → 260_000_000 + 2_100_000 + 0 + 50 = 262_100_050 +assert_success "double-digit minor" \ + "262100050" "50.1" "2026.21.0+internal.50" \ + "2026.21.0" 1 50 "internal" + +# Double-digit patch: 2026.2.10, run 50 → 260_000_000 + 200_000 + 10_000 + 50 = 260_210_050 +assert_success "double-digit patch" \ + "260210050" "50.1" "2026.2.10+dev.50" \ + "2026.2.10" 1 50 "dev" + +# Double-digit minor and patch: 2026.21.10, run 50 → 260_000_000 + 2_100_000 + 10_000 + 50 = 262_110_050 +assert_success "double-digit minor and patch" \ + "262110050" "50.1" "2026.21.10+dev.50" \ + "2026.21.10" 1 50 "dev" + +# Minimum valid year: 2020.1.0, run 1 → 20*10M + 100_000 + 0 + 1 = 200_100_001 +assert_success "minimum year boundary" \ + "200100001" "1.1" "2020.1.0+dev.1" \ + "2020.1.0" 1 1 "dev" + +# Maximum valid values: 2099.99.99, run 999 → 99*10M + 9_900_000 + 99_000 + 999 = 999_999_999 +assert_success "maximum valid values" \ + "999999999" "999.1" "2099.99.99+dev.999" \ + "2099.99.99" 1 999 "dev" + +# run_number 1000 — no longer wraps, adds directly +# 2026.2.0, run 1000 → 260_200_000 + 1_000 = 260_201_000 +assert_success "run_number 1000 does not wrap" \ + "260201000" "1000.1" "2026.2.0+dev.1000" \ + "2026.2.0" 1 1000 "dev" + +# run_number 2000 — adds directly +# 2026.2.0, run 2000 → 260_200_000 + 2_000 = 260_202_000 +assert_success "run_number 2000 does not wrap" \ + "260202000" "2000.1" "2026.2.0+dev.2000" \ + "2026.2.0" 1 2000 "dev" + +# Large run_number: 5001 → adds directly +# 2026.2.0, run 5001 → 260_200_000 + 5_001 = 260_205_001 +assert_success "large run_number" \ + "260205001" "5001.1" "2026.2.0+dev.5001" \ + "2026.2.0" 1 5001 "dev" + +# run_number at 999 +assert_success "run_number at 999" \ + "260200999" "999.1" "2026.2.0+dev.999" \ + "2026.2.0" 1 999 "dev" + +# Patch at boundary 99: 2026.1.99, run 1 → 260_000_000 + 100_000 + 99_000 + 1 = 260_199_001 +assert_success "patch boundary 99" \ + "260199001" "1.1" "2026.1.99+dev.1" \ + "2026.1.99" 1 1 "dev" + +# Minor at boundary 99: 2026.99.0, run 1 → 260_000_000 + 9_900_000 + 0 + 1 = 269_900_001 +assert_success "minor boundary 99" \ + "269900001" "1.1" "2026.99.0+dev.1" \ + "2026.99.0" 1 1 "dev" + +# All components at single digit minimums: 2020.0.0, run 1 → 200_000_001 +assert_success "minor and patch at 0" \ + "200000001" "1.1" "2020.0.0+dev.1" \ + "2020.0.0" 1 1 "dev" + +# --------------------------------------------------------------------------- +# Invalid cases — re-run detection +# --------------------------------------------------------------------------- + +assert_failure "re-run attempt=2" "re-runs are not allowed" \ + "2026.2.0" 2 345 "dev" + +assert_failure "re-run attempt=3" "re-runs are not allowed" \ + "2026.2.0" 3 1 "dev" + +# --------------------------------------------------------------------------- +# Invalid cases — VERSION_NAME format +# --------------------------------------------------------------------------- + +assert_failure "missing patch component" "does not match required format" \ + "2026.2" 1 1 "dev" + +assert_failure "2-digit year" "does not match required format" \ + "26.2.0" 1 1 "dev" + +assert_failure "3-digit year" "does not match required format" \ + "202.2.0" 1 1 "dev" + +assert_failure "extra 4th component" "does not match required format" \ + "2026.2.0.1" 1 1 "dev" + +assert_failure "alphabetic minor" "does not match required format" \ + "2026.a.0" 1 1 "dev" + +assert_failure "alphabetic patch" "does not match required format" \ + "2026.2.b" 1 1 "dev" + +assert_failure "empty version name" "does not match required format" \ + "" 1 1 "dev" + +assert_failure "only dots" "does not match required format" \ + "..." 1 1 "dev" + +# 3-digit minor (100) is rejected by the regex before the range check +assert_failure "3-digit minor 100 rejected by format" "does not match required format" \ + "2026.100.0" 1 1 "dev" + +# 3-digit patch (100) is rejected by the regex before the range check +assert_failure "3-digit patch 100 rejected by format" "does not match required format" \ + "2026.1.100" 1 1 "dev" + +# --------------------------------------------------------------------------- +# Invalid cases — year out of range +# --------------------------------------------------------------------------- + +assert_failure "year below minimum (2019)" "out of supported range" \ + "2019.1.0" 1 1 "dev" + +assert_failure "year at 2000 (below minimum)" "out of supported range" \ + "2000.1.0" 1 1 "dev" + +assert_failure "year above maximum (2100)" "out of supported range" \ + "2100.1.0" 1 1 "dev" + +# --------------------------------------------------------------------------- +# Summary +# --------------------------------------------------------------------------- + +TOTAL=$(( PASS + FAIL )) +echo "" +echo "Results: $PASS/$TOTAL passed" + +if [ "$FAIL" -gt 0 ]; then + exit 1 +fi diff --git a/.github/workflows/reusable-build-apk.yml b/.github/workflows/reusable-build-apk.yml index b920d9c235..aa06c6a01e 100644 --- a/.github/workflows/reusable-build-apk.yml +++ b/.github/workflows/reusable-build-apk.yml @@ -30,18 +30,12 @@ jobs: environment: ${{ inputs.build-environment }} env: - # VERSION_CODE: Unique version code using the sum of the current timestamp and run number. + # VERSION_CODE formula: (year-2000)*10_000_000 + minor*100_000 + patch*1_000 + run_number + # Re-runs are disabled: github.run_attempt must be 1. VERSION_CODE: "set in lower step" - # VERSION_NAME: Version name derived from the GitHub ref name after the final /. VERSION_NAME: ${{ inputs.version-name }} - # VERSION_SUFFIX: The build environment (e.g., dev, staging, internal). VERSION_SUFFIX: ${{ inputs.build-environment }} - # VERSION_BUILD: A unique build identifier combining the run number and attempt. - VERSION_BUILD: ${{ github.run_number }}.${{ github.run_attempt }} - # The final output file name. FILE_NAME: "set in lower step" - # The base floor version code. Please check the README for more information. - BASE_VERSION_CODE: 10000000 steps: - name: Checkout @@ -53,26 +47,11 @@ jobs: java-version: 21 distribution: 'temurin' - - name: Check workflow validity - if: ${{ github.run_attempt > 99 }} - run: | - echo "Run attempts exceeded 99. Please start a new workflow." - exit 1 - - - name: Check base version code - run: | - if [ $(( ${{ github.run_number }} * 100 )) -ge $BASE_VERSION_CODE ]; then - echo "Github workflows now exceeds the base version code..." - echo "BASE_VERSION_CODE=0" >> $GITHUB_ENV - fi - - - name: Compute version variables - run: | - echo "VERSION_CODE=$(($BASE_VERSION_CODE + ${{ github.run_number }} * 100 + ${{ github.run_attempt }}))" >> $GITHUB_ENV - - - name: Compute file name - run: | - echo "FILE_NAME=${{ env.VERSION_NAME }}+${{ env.VERSION_SUFFIX }}.${{ env.VERSION_BUILD }}" >> $GITHUB_ENV + - name: Validate version name and compute version code + env: + RUN_ATTEMPT: ${{ github.run_attempt }} + RUN_NUMBER: ${{ github.run_number }} + run: bash .github/scripts/generate-version.sh - name: Set up build files uses: ./.github/actions/setup-gradle-build-files diff --git a/.github/workflows/test-version-generation.yml b/.github/workflows/test-version-generation.yml new file mode 100644 index 0000000000..0baf0a3f20 --- /dev/null +++ b/.github/workflows/test-version-generation.yml @@ -0,0 +1,18 @@ +name: Test version generation + +on: + workflow_dispatch: + push: + paths: + - '.github/scripts/generate-version.sh' + - '.github/scripts/test-generate-version.sh' + +jobs: + test-version-generation: + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v6 + + - name: Run version generation tests + run: bash .github/scripts/test-generate-version.sh From 4deb45f418191d9aa993b28eca2f8b7855215bda Mon Sep 17 00:00:00 2001 From: Melad Raouf Date: Tue, 23 Jun 2026 10:00:45 +0100 Subject: [PATCH 04/14] Upload AAB to Google Play Internal and attach universal APK to draft release - Add reusable-upload-to-internal-and-release.yml: uploads AAB via TianchenWei/upload-aab-google-play, downloads the Play-signed universal APK, and creates a draft GitHub release with the APK attached - Update pipeline-deploy-to-internal.yml: replace deploy-internal-build and tag-release jobs with the new upload-to-internal-and-release job --- .../workflows/pipeline-deploy-to-internal.yml | 11 +-- ...eusable-upload-to-internal-and-release.yml | 82 +++++++++++++++++++ 2 files changed, 86 insertions(+), 7 deletions(-) create mode 100644 .github/workflows/reusable-upload-to-internal-and-release.yml diff --git a/.github/workflows/pipeline-deploy-to-internal.yml b/.github/workflows/pipeline-deploy-to-internal.yml index 61abcd8c1c..84251371e7 100644 --- a/.github/workflows/pipeline-deploy-to-internal.yml +++ b/.github/workflows/pipeline-deploy-to-internal.yml @@ -18,16 +18,13 @@ jobs: build-environment: Internal version-name: ${{ needs.get-version.outputs.version-name }} - deploy-internal-build: - uses: ./.github/workflows/reusable-promote-artifact.yml + upload-to-internal-and-release: + uses: ./.github/workflows/reusable-upload-to-internal-and-release.yml secrets: inherit with: - deployment-track: Internal upload-artifact: ${{ needs.build-internal-aab.outputs.build-artifact }} + version-name: ${{ needs.get-version.outputs.version-name }} mapping-file: ${{ needs.build-internal-aab.outputs.optional-mapping-file }} needs: + - get-version - build-internal-aab - tag-release: - uses: ./.github/workflows/reusable-update-release-git-tag.yml - needs: deploy-internal-build - secrets: inherit diff --git a/.github/workflows/reusable-upload-to-internal-and-release.yml b/.github/workflows/reusable-upload-to-internal-and-release.yml new file mode 100644 index 0000000000..b1f0eb8592 --- /dev/null +++ b/.github/workflows/reusable-upload-to-internal-and-release.yml @@ -0,0 +1,82 @@ +name: "[Reusable] Upload to Internal and Create Draft Release" + +on: + workflow_call: + inputs: + upload-artifact: + description: "Artifact name of the AAB file to upload" + type: string + required: true + version-name: + description: "Version name used for the draft release tag (e.g. 2026.2.0)" + type: string + required: true + mapping-file: + description: "Optional artifact name of the ProGuard mapping file" + type: string + required: false + default: "" + +jobs: + upload-and-release: + runs-on: ubuntu-latest + + environment: Internal + + permissions: + contents: write + + steps: + - name: Checkout + uses: actions/checkout@v6 + + - name: Download AAB artifact + uses: actions/download-artifact@v8 + with: + name: ${{ inputs.upload-artifact }} + + - name: Encode service account credentials as base64 + id: encode-creds + env: + GOOGLE_API_KEY_JSON: ${{ secrets.GOOGLE_API_KEY_JSON }} + run: echo "b64=$(echo "$GOOGLE_API_KEY_JSON" | base64 -w 0)" >> "$GITHUB_OUTPUT" + + - name: Upload AAB to Google Play Internal and download universal APK + id: upload-play + uses: TianchenWei/upload-aab-google-play@main + with: + service-account-credential-base64: ${{ steps.encode-creds.outputs.b64 }} + package-name: com.simprints.id + aab-file-path: ${{ inputs.upload-artifact }} + + - name: Rename universal APK + id: rename-apk + run: | + APK_NAME="simprints-id-${{ inputs.version-name }}-universal.apk" + mv "${{ steps.upload-play.outputs.universal-apk-file-path }}" "$APK_NAME" + echo "apk-name=$APK_NAME" >> "$GITHUB_OUTPUT" + + - name: Download mapping file + if: ${{ inputs.mapping-file != '' }} + uses: actions/download-artifact@v8 + with: + name: ${{ inputs.mapping-file }} + + - name: Create draft GitHub release with universal APK + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + TAG="v${{ inputs.version-name }}" + APK_NAME="${{ steps.rename-apk.outputs.apk-name }}" + + ASSETS=("$APK_NAME") + if [[ -n "${{ inputs.mapping-file }}" && -f "${{ inputs.mapping-file }}" ]]; then + ASSETS+=("${{ inputs.mapping-file }}") + fi + + gh release create "$TAG" \ + --repo "$GITHUB_REPOSITORY" \ + --title "Simprints ID ${{ inputs.version-name }}" \ + --draft \ + --generate-notes \ + "${ASSETS[@]}" From d51d6332b9a6d659a81bd7155db8a92f7ba66347 Mon Sep 17 00:00:00 2001 From: Melad Raouf Date: Tue, 23 Jun 2026 13:09:37 +0100 Subject: [PATCH 05/14] Fix supply-chain and secret masking in upload-to-internal-and-release workflow - Mask the derived base64 credential with ::add-mask:: to prevent it leaking in debug logs; use printf instead of echo to avoid content mangling - Pin TianchenWei/upload-aab-google-play to commit SHA 1b070738058f58636b4a5c92ece8bc89fc7e4910 (main) instead of the moving branch ref to prevent unreviewed breaking changes --- .../workflows/reusable-upload-to-internal-and-release.yml | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/.github/workflows/reusable-upload-to-internal-and-release.yml b/.github/workflows/reusable-upload-to-internal-and-release.yml index b1f0eb8592..0906050bfc 100644 --- a/.github/workflows/reusable-upload-to-internal-and-release.yml +++ b/.github/workflows/reusable-upload-to-internal-and-release.yml @@ -39,11 +39,14 @@ jobs: id: encode-creds env: GOOGLE_API_KEY_JSON: ${{ secrets.GOOGLE_API_KEY_JSON }} - run: echo "b64=$(echo "$GOOGLE_API_KEY_JSON" | base64 -w 0)" >> "$GITHUB_OUTPUT" + run: | + b64=$(printf '%s' "$GOOGLE_API_KEY_JSON" | base64 -w 0) + echo "::add-mask::$b64" + echo "b64=$b64" >> "$GITHUB_OUTPUT" - name: Upload AAB to Google Play Internal and download universal APK id: upload-play - uses: TianchenWei/upload-aab-google-play@main + uses: TianchenWei/upload-aab-google-play@1b070738058f58636b4a5c92ece8bc89fc7e4910 # main with: service-account-credential-base64: ${{ steps.encode-creds.outputs.b64 }} package-name: com.simprints.id From 6ba50242e705ae6a92b2639b743fbc67e05587ea Mon Sep 17 00:00:00 2001 From: Melad Raouf Date: Tue, 23 Jun 2026 13:13:02 +0100 Subject: [PATCH 06/14] Fix script injection: pass workflow inputs via env vars in run blocks inputs.version-name and inputs.mapping-file were interpolated directly into run scripts, allowing script injection if the workflow is triggered with crafted values. Move all user-controlled inputs and step outputs to env: so they are referenced as shell variables instead. --- ...eusable-upload-to-internal-and-release.yml | 19 ++++++++++++------- 1 file changed, 12 insertions(+), 7 deletions(-) diff --git a/.github/workflows/reusable-upload-to-internal-and-release.yml b/.github/workflows/reusable-upload-to-internal-and-release.yml index 0906050bfc..4510f4042f 100644 --- a/.github/workflows/reusable-upload-to-internal-and-release.yml +++ b/.github/workflows/reusable-upload-to-internal-and-release.yml @@ -54,9 +54,12 @@ jobs: - name: Rename universal APK id: rename-apk + env: + VERSION_NAME: ${{ inputs.version-name }} + UNIVERSAL_APK_PATH: ${{ steps.upload-play.outputs.universal-apk-file-path }} run: | - APK_NAME="simprints-id-${{ inputs.version-name }}-universal.apk" - mv "${{ steps.upload-play.outputs.universal-apk-file-path }}" "$APK_NAME" + APK_NAME="simprints-id-${VERSION_NAME}-universal.apk" + mv "$UNIVERSAL_APK_PATH" "$APK_NAME" echo "apk-name=$APK_NAME" >> "$GITHUB_OUTPUT" - name: Download mapping file @@ -68,18 +71,20 @@ jobs: - name: Create draft GitHub release with universal APK env: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + VERSION_NAME: ${{ inputs.version-name }} + MAPPING_FILE: ${{ inputs.mapping-file }} + APK_NAME: ${{ steps.rename-apk.outputs.apk-name }} run: | - TAG="v${{ inputs.version-name }}" - APK_NAME="${{ steps.rename-apk.outputs.apk-name }}" + TAG="v${VERSION_NAME}" ASSETS=("$APK_NAME") - if [[ -n "${{ inputs.mapping-file }}" && -f "${{ inputs.mapping-file }}" ]]; then - ASSETS+=("${{ inputs.mapping-file }}") + if [[ -n "$MAPPING_FILE" && -f "$MAPPING_FILE" ]]; then + ASSETS+=("$MAPPING_FILE") fi gh release create "$TAG" \ --repo "$GITHUB_REPOSITORY" \ - --title "Simprints ID ${{ inputs.version-name }}" \ + --title "Simprints ID ${VERSION_NAME}" \ --draft \ --generate-notes \ "${ASSETS[@]}" From c581669fb42456b571c9a8b2f1055637c40c8b09 Mon Sep 17 00:00:00 2001 From: Melad Raouf Date: Tue, 23 Jun 2026 20:36:55 +0100 Subject: [PATCH 07/14] Add tests for run_number above 9999 in version generation script MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Cover the 4-digit/5-digit boundary (9999 → 10000) and a large 5-digit run number (99999) to confirm VERSION_CODE is computed correctly when run_number exceeds four digits. --- .github/scripts/test-generate-version.sh | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) diff --git a/.github/scripts/test-generate-version.sh b/.github/scripts/test-generate-version.sh index dde14864cd..7cf1aeb64c 100755 --- a/.github/scripts/test-generate-version.sh +++ b/.github/scripts/test-generate-version.sh @@ -166,6 +166,24 @@ assert_success "run_number at 999" \ "260200999" "999.1" "2026.2.0+dev.999" \ "2026.2.0" 1 999 "dev" +# run_number at 9999 — last 4-digit run number +# 2026.2.0, run 9999 → 260_200_000 + 9_999 = 260_209_999 +assert_success "run_number at 9999 (last 4-digit)" \ + "260209999" "9999.1" "2026.2.0+dev.9999" \ + "2026.2.0" 1 9999 "dev" + +# run_number at 10000 — first 5-digit run number +# 2026.2.0, run 10000 → 260_200_000 + 10_000 = 260_210_000 +assert_success "run_number at 10000 (first 5-digit)" \ + "260210000" "10000.1" "2026.2.0+dev.10000" \ + "2026.2.0" 1 10000 "dev" + +# run_number at 99999 — last 5-digit run number +# 2026.2.0, run 99999 → 260_200_000 + 99_999 = 260_299_999 +assert_success "run_number at 99999 (last 5-digit)" \ + "260299999" "99999.1" "2026.2.0+dev.99999" \ + "2026.2.0" 1 99999 "dev" + # Patch at boundary 99: 2026.1.99, run 1 → 260_000_000 + 100_000 + 99_000 + 1 = 260_199_001 assert_success "patch boundary 99" \ "260199001" "1.1" "2026.1.99+dev.1" \ From b4753b8561ce4ffb51e1d072151bac7944293d38 Mon Sep 17 00:00:00 2001 From: Melad Raouf Date: Tue, 23 Jun 2026 20:38:07 +0100 Subject: [PATCH 08/14] Remove duplicated VERSION_CODE formula comment from workflow The formula and re-run policy are already documented in generate-version.sh, which is the single source of truth. Keeping a copy in the workflow risks the two drifting apart. --- .github/workflows/reusable-build-apk.yml | 2 -- 1 file changed, 2 deletions(-) diff --git a/.github/workflows/reusable-build-apk.yml b/.github/workflows/reusable-build-apk.yml index aa06c6a01e..0d36b134db 100644 --- a/.github/workflows/reusable-build-apk.yml +++ b/.github/workflows/reusable-build-apk.yml @@ -30,8 +30,6 @@ jobs: environment: ${{ inputs.build-environment }} env: - # VERSION_CODE formula: (year-2000)*10_000_000 + minor*100_000 + patch*1_000 + run_number - # Re-runs are disabled: github.run_attempt must be 1. VERSION_CODE: "set in lower step" VERSION_NAME: ${{ inputs.version-name }} VERSION_SUFFIX: ${{ inputs.build-environment }} From 4d22ac5072ca140cb54fb1fe9ebf31cf38c3090b Mon Sep 17 00:00:00 2001 From: Melad Raouf Date: Tue, 23 Jun 2026 20:42:41 +0100 Subject: [PATCH 09/14] Replace TianchenWei action with r0adkll/upload-google-play + own download script TianchenWei/upload-aab-google-play is an unmaintained personal repo. Replace it with: - r0adkll/upload-google-play@v1 (already used in reusable-promote-artifact) for uploading the AAB to the internal track - download-universal-apk.py (our own script) for fetching the Play-signed universal APK Also expose version-code as an output of reusable-build-apk.yml so the download script can look up the correct APK in the Play generatedApks API. --- .github/scripts/download-universal-apk.py | 105 ++++++++++++++++++ .../workflows/pipeline-deploy-to-internal.yml | 1 + .github/workflows/reusable-build-apk.yml | 8 +- ...eusable-upload-to-internal-and-release.yml | 48 ++++---- 4 files changed, 140 insertions(+), 22 deletions(-) create mode 100644 .github/scripts/download-universal-apk.py diff --git a/.github/scripts/download-universal-apk.py b/.github/scripts/download-universal-apk.py new file mode 100644 index 0000000000..7ae35f97ed --- /dev/null +++ b/.github/scripts/download-universal-apk.py @@ -0,0 +1,105 @@ +#!/usr/bin/env python3 +""" +Download the Play-signed universal APK for a given version code from Google Play. + +Uses the Android Publisher API generatedApks resource. Google Play generates APKs +from an uploaded AAB asynchronously, so this script retries until the APK is ready +or MAX_WAIT_SECONDS is exceeded. + +Required environment variables: + GOOGLE_API_KEY_JSON Service account JSON (as a string, not a file path) + VERSION_CODE Integer Android version code + PACKAGE_NAME Android package name (default: com.simprints.id) + OUTPUT_APK Output file path (default: universal.apk) + MAX_WAIT_SECONDS Maximum seconds to wait for Play to finish processing (default: 600) +""" + +import json +import os +import sys +import time + +from google.oauth2.service_account import Credentials +from googleapiclient.discovery import build +from googleapiclient.http import MediaIoBaseDownload + +RETRY_INTERVAL_SECONDS = 30 + + +def build_service(service_account_json: str): + creds = Credentials.from_service_account_info( + json.loads(service_account_json), + scopes=["https://www.googleapis.com/auth/androidpublisher"], + ) + return build("androidpublisher", "v3", credentials=creds, cache_discovery=False) + + +def find_universal_apk_download_id(service, package_name: str, version_code: int) -> str | None: + result = service.generatedapks().list( + packageName=package_name, + versionCode=version_code, + ).execute() + + for apk in result.get("generatedApks", []): + if apk.get("universalApk"): + return apk["downloadId"] + return None + + +def download_apk(service, package_name: str, version_code: int, download_id: str, output_path: str): + request = service.generatedapks().download_media( + packageName=package_name, + versionCode=version_code, + downloadId=download_id, + ) + with open(output_path, "wb") as f: + downloader = MediaIoBaseDownload(f, request) + done = False + while not done: + status, done = downloader.next_chunk() + if status: + print(f" {int(status.progress() * 100)}%", flush=True) + + size_mb = os.path.getsize(output_path) / 1_048_576 + print(f"Saved to {output_path} ({size_mb:.1f} MB)") + + +def main(): + service_account_json = os.environ["GOOGLE_API_KEY_JSON"] + package_name = os.environ.get("PACKAGE_NAME", "com.simprints.id") + version_code = int(os.environ["VERSION_CODE"]) + output_path = os.environ.get("OUTPUT_APK", "universal.apk") + max_wait = int(os.environ.get("MAX_WAIT_SECONDS", "600")) + + print(f"Looking for universal APK: package={package_name} versionCode={version_code}") + + service = build_service(service_account_json) + deadline = time.monotonic() + max_wait + attempt = 0 + + while True: + attempt += 1 + try: + download_id = find_universal_apk_download_id(service, package_name, version_code) + if download_id: + print(f"Universal APK ready (attempt {attempt}): downloadId={download_id}") + break + print(f"Attempt {attempt}: APKs not generated yet by Play Store.") + except Exception as exc: + print(f"Attempt {attempt}: API error — {exc}") + + remaining = deadline - time.monotonic() + if remaining <= 0: + print(f"ERROR: Universal APK not available after {max_wait}s", file=sys.stderr) + sys.exit(1) + + wait = min(RETRY_INTERVAL_SECONDS, remaining) + print(f"Retrying in {int(wait)}s... ({int(remaining)}s remaining)") + time.sleep(wait) + + print("Downloading...") + download_apk(service, package_name, version_code, download_id, output_path) + + +if __name__ == "__main__": + main() diff --git a/.github/workflows/pipeline-deploy-to-internal.yml b/.github/workflows/pipeline-deploy-to-internal.yml index 84251371e7..e1a34bb6c8 100644 --- a/.github/workflows/pipeline-deploy-to-internal.yml +++ b/.github/workflows/pipeline-deploy-to-internal.yml @@ -24,6 +24,7 @@ jobs: with: upload-artifact: ${{ needs.build-internal-aab.outputs.build-artifact }} version-name: ${{ needs.get-version.outputs.version-name }} + version-code: ${{ needs.build-internal-aab.outputs.version-code }} mapping-file: ${{ needs.build-internal-aab.outputs.optional-mapping-file }} needs: - get-version diff --git a/.github/workflows/reusable-build-apk.yml b/.github/workflows/reusable-build-apk.yml index 0d36b134db..1d0e9a4e05 100644 --- a/.github/workflows/reusable-build-apk.yml +++ b/.github/workflows/reusable-build-apk.yml @@ -17,6 +17,9 @@ on: optional-mapping-file: description: "The mapping file for the built APK" value: ${{ jobs.build-apk.outputs.mapping-text }} + version-code: + description: "The computed Android version code" + value: ${{ jobs.build-apk.outputs.version-code }} jobs: build-apk: @@ -24,6 +27,7 @@ jobs: outputs: build-artifact: ${{ steps.set-build-artifact.outputs.artifact }} mapping-text: ${{ steps.build-internal-apk.outputs.mapping }} + version-code: ${{ steps.set-build-artifact.outputs.version-code }} runs-on: ubuntu-latest @@ -117,4 +121,6 @@ jobs: - name: Set build-artifact output id: set-build-artifact - run: echo "artifact=${{ env.FILE_NAME }}" >> $GITHUB_OUTPUT + run: | + echo "artifact=${{ env.FILE_NAME }}" >> $GITHUB_OUTPUT + echo "version-code=$VERSION_CODE" >> $GITHUB_OUTPUT diff --git a/.github/workflows/reusable-upload-to-internal-and-release.yml b/.github/workflows/reusable-upload-to-internal-and-release.yml index 4510f4042f..a436cefffe 100644 --- a/.github/workflows/reusable-upload-to-internal-and-release.yml +++ b/.github/workflows/reusable-upload-to-internal-and-release.yml @@ -11,6 +11,10 @@ on: description: "Version name used for the draft release tag (e.g. 2026.2.0)" type: string required: true + version-code: + description: "Android version code for the build (used to download the universal APK from Play)" + type: string + required: true mapping-file: description: "Optional artifact name of the ProGuard mapping file" type: string @@ -35,31 +39,33 @@ jobs: with: name: ${{ inputs.upload-artifact }} - - name: Encode service account credentials as base64 - id: encode-creds - env: - GOOGLE_API_KEY_JSON: ${{ secrets.GOOGLE_API_KEY_JSON }} - run: | - b64=$(printf '%s' "$GOOGLE_API_KEY_JSON" | base64 -w 0) - echo "::add-mask::$b64" - echo "b64=$b64" >> "$GITHUB_OUTPUT" + - name: Upload AAB to Google Play Internal track + uses: r0adkll/upload-google-play@eb49699984a39f23558439581660aa6f088acfd6 # v1 + with: + serviceAccountJsonPlainText: ${{ secrets.GOOGLE_API_KEY_JSON }} + packageName: com.simprints.id + releaseFiles: ${{ inputs.upload-artifact }} + track: internal + status: completed - - name: Upload AAB to Google Play Internal and download universal APK - id: upload-play - uses: TianchenWei/upload-aab-google-play@1b070738058f58636b4a5c92ece8bc89fc7e4910 # main + - name: Set up Python + uses: actions/setup-python@v5 with: - service-account-credential-base64: ${{ steps.encode-creds.outputs.b64 }} - package-name: com.simprints.id - aab-file-path: ${{ inputs.upload-artifact }} + python-version: "3.11" - - name: Rename universal APK - id: rename-apk + - name: Install Python dependencies + run: | + pip install --only-binary :all: "google-api-python-client==2.197.0" "google-auth==2.55.0" + - name: Download universal APK from Google Play + id: download-apk env: + GOOGLE_API_KEY_JSON: ${{ secrets.GOOGLE_API_KEY_JSON }} + VERSION_CODE: ${{ inputs.version-code }} VERSION_NAME: ${{ inputs.version-name }} - UNIVERSAL_APK_PATH: ${{ steps.upload-play.outputs.universal-apk-file-path }} + PACKAGE_NAME: com.simprints.id run: | APK_NAME="simprints-id-${VERSION_NAME}-universal.apk" - mv "$UNIVERSAL_APK_PATH" "$APK_NAME" + OUTPUT_APK="$APK_NAME" python3 .github/scripts/download-universal-apk.py echo "apk-name=$APK_NAME" >> "$GITHUB_OUTPUT" - name: Download mapping file @@ -73,13 +79,13 @@ jobs: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} VERSION_NAME: ${{ inputs.version-name }} MAPPING_FILE: ${{ inputs.mapping-file }} - APK_NAME: ${{ steps.rename-apk.outputs.apk-name }} + APK_NAME: ${{ steps.download-apk.outputs.apk-name }} run: | TAG="v${VERSION_NAME}" ASSETS=("$APK_NAME") - if [[ -n "$MAPPING_FILE" && -f "$MAPPING_FILE" ]]; then - ASSETS+=("$MAPPING_FILE") + if [[ -n "$MAPPING_FILE" && -f "$MAPPING_FILE" ]]; then + ASSETS+=("$MAPPING_FILE") fi gh release create "$TAG" \ From 06bd55c767ef852a2e75d78107d715ae548bfa87 Mon Sep 17 00:00:00 2001 From: Melad Raouf Date: Wed, 24 Jun 2026 11:36:47 +0100 Subject: [PATCH 10/14] Update universal APK download ID key to `generatedUniversalApk` --- .github/scripts/download-universal-apk.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/.github/scripts/download-universal-apk.py b/.github/scripts/download-universal-apk.py index 7ae35f97ed..b3d0c059e6 100644 --- a/.github/scripts/download-universal-apk.py +++ b/.github/scripts/download-universal-apk.py @@ -41,8 +41,9 @@ def find_universal_apk_download_id(service, package_name: str, version_code: int ).execute() for apk in result.get("generatedApks", []): - if apk.get("universalApk"): - return apk["downloadId"] + universal = apk.get("generatedUniversalApk") + if universal: + return universal["downloadId"] return None From 411bdcf926220c01bdca11eff2a1aa2fcb82a624 Mon Sep 17 00:00:00 2001 From: Melad Raouf Date: Wed, 24 Jun 2026 13:59:09 +0100 Subject: [PATCH 11/14] Add step to create release git tag in workflow --- .github/workflows/reusable-upload-to-internal-and-release.yml | 3 +++ 1 file changed, 3 insertions(+) diff --git a/.github/workflows/reusable-upload-to-internal-and-release.yml b/.github/workflows/reusable-upload-to-internal-and-release.yml index a436cefffe..6698bc6263 100644 --- a/.github/workflows/reusable-upload-to-internal-and-release.yml +++ b/.github/workflows/reusable-upload-to-internal-and-release.yml @@ -74,6 +74,9 @@ jobs: with: name: ${{ inputs.mapping-file }} + - name: Create release git tag + uses: ./.github/actions/set-release-git-tag + - name: Create draft GitHub release with universal APK env: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} From 2bd239fad40a25fda5ff5e5e51faa959d5445c69 Mon Sep 17 00:00:00 2001 From: Melad Raouf Date: Wed, 24 Jun 2026 15:48:06 +0100 Subject: [PATCH 12/14] Delete existing draft release to avoid conflicts during upload --- .../workflows/reusable-upload-to-internal-and-release.yml | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/.github/workflows/reusable-upload-to-internal-and-release.yml b/.github/workflows/reusable-upload-to-internal-and-release.yml index 6698bc6263..4ca71129c2 100644 --- a/.github/workflows/reusable-upload-to-internal-and-release.yml +++ b/.github/workflows/reusable-upload-to-internal-and-release.yml @@ -86,6 +86,11 @@ jobs: run: | TAG="v${VERSION_NAME}" + # Delete any existing draft release with the same tag to avoid conflicts. + if gh release view "$TAG" --repo "$GITHUB_REPOSITORY" --json isDraft --jq '.isDraft' 2>/dev/null | grep -q true; then + gh release delete "$TAG" --repo "$GITHUB_REPOSITORY" --yes + fi + ASSETS=("$APK_NAME") if [[ -n "$MAPPING_FILE" && -f "$MAPPING_FILE" ]]; then ASSETS+=("$MAPPING_FILE") From 5625cc4873e00d281dc57ff03bc00dfe9fb1bafc Mon Sep 17 00:00:00 2001 From: alexandr-simprints <129499142+alexandr-simprints@users.noreply.github.com> Date: Thu, 25 Jun 2026 20:12:33 +0300 Subject: [PATCH 13/14] [MS-1484] MFID: QR codes are no longer required to be confirmed. QR code readouts cannot be edited. (#1723) --- .../ExternalCredentialSearchFragment.kt | 5 ++-- .../search/model/SearchCredentialState.kt | 29 ++++++++++++++----- 2 files changed, 24 insertions(+), 10 deletions(-) diff --git a/feature/external-credential/src/main/java/com/simprints/feature/externalcredential/screens/search/ExternalCredentialSearchFragment.kt b/feature/external-credential/src/main/java/com/simprints/feature/externalcredential/screens/search/ExternalCredentialSearchFragment.kt index 49887bacf7..8f24e50283 100644 --- a/feature/external-credential/src/main/java/com/simprints/feature/externalcredential/screens/search/ExternalCredentialSearchFragment.kt +++ b/feature/external-credential/src/main/java/com/simprints/feature/externalcredential/screens/search/ExternalCredentialSearchFragment.kt @@ -106,11 +106,12 @@ internal class ExternalCredentialSearchFragment : Fragment(R.layout.fragment_ext credentialEditText.inputType = viewModel.getKeyBoardInputType() credentialEditText.hint = credentialField credentialValue.text = currentEditTextValue - confirmCredentialCheckbox.isVisible = state.searchState != SearchState.Searching + confirmCredentialCheckbox.isVisible = state.isUserConfirmationRequired && state.searchState != SearchState.Searching confirmCredentialCheckbox.text = getString(IDR.string.mfid_confirmation_checkbox_text, credentialField) confirmCredentialCheckbox.isChecked = state.isConfirmed && !state.isEditingCredential confirmCredentialCheckbox.isEnabled = !state.isEditingCredential + iconEditCredential.isVisible = state.isEditAvailable iconEditCredential.setOnClickListener { if (isEditingCredential) { viewModel.confirmCredentialUpdate(updatedCredential = credentialEditText.text.toString().asTokenizableRaw()) @@ -209,7 +210,7 @@ internal class ExternalCredentialSearchFragment : Fragment(R.layout.fragment_ext val isSearching = state.searchState != SearchState.Searching buttonRecapture.isVisible = isSearching buttonConfirm.isVisible = isSearching - buttonConfirm.isEnabled = state.isConfirmed && !state.isEditingCredential + buttonConfirm.isEnabled = !state.isUserConfirmationRequired || (state.isConfirmed && !state.isEditingCredential) viewModel.getButtonTextResource(state.searchState, state.flowType)?.run(buttonConfirm::setText) buttonConfirm.setOnClickListener { viewModel.finish(state) diff --git a/feature/external-credential/src/main/java/com/simprints/feature/externalcredential/screens/search/model/SearchCredentialState.kt b/feature/external-credential/src/main/java/com/simprints/feature/externalcredential/screens/search/model/SearchCredentialState.kt index 0e20510a76..a446b3815d 100644 --- a/feature/external-credential/src/main/java/com/simprints/feature/externalcredential/screens/search/model/SearchCredentialState.kt +++ b/feature/external-credential/src/main/java/com/simprints/feature/externalcredential/screens/search/model/SearchCredentialState.kt @@ -3,6 +3,7 @@ package com.simprints.feature.externalcredential.screens.search.model import androidx.annotation.Keep import com.simprints.core.ExcludedFromGeneratedTestCoverageReports import com.simprints.core.domain.common.FlowType +import com.simprints.core.domain.externalcredential.ExternalCredentialType import com.simprints.core.domain.tokenization.TokenizableString import com.simprints.feature.externalcredential.model.CredentialMatch import kotlin.Boolean @@ -16,19 +17,31 @@ internal data class SearchCredentialState( val searchState: SearchState, val isConfirmed: Boolean, val isEditingCredential: Boolean, + val isUserConfirmationRequired: Boolean, + val isEditAvailable: Boolean, ) { companion object { fun buildInitial( scannedCredentialResult: ScannedCredentialResult, flowType: FlowType, - ) = SearchCredentialState( - scannedCredentialResult = scannedCredentialResult, - displayedCredential = scannedCredentialResult.credential, - flowType = flowType, - searchState = SearchState.Searching, - isConfirmed = false, - isEditingCredential = false, - ) + ): SearchCredentialState { + // [MS-1484] Editing and confirming the readout value should only be available for the OCR readouts (i.e.: Ghana NHIS card) + // QR codes are not readable by humans, hence there is no point of asking the user to confirm the readout, nor to edit the readout + val isOcrReadout = when (scannedCredentialResult.credentialType) { + ExternalCredentialType.NHISCard, ExternalCredentialType.GhanaIdCard -> true + ExternalCredentialType.QRCode -> false + } + return SearchCredentialState( + scannedCredentialResult = scannedCredentialResult, + displayedCredential = scannedCredentialResult.credential, + flowType = flowType, + searchState = SearchState.Searching, + isConfirmed = false, + isEditingCredential = false, + isUserConfirmationRequired = isOcrReadout, + isEditAvailable = isOcrReadout, + ) + } } } From 5826d75322c5ec91a0939b474feb7efc25dee922 Mon Sep 17 00:00:00 2001 From: alexandr-simprints <129499142+alexandr-simprints@users.noreply.github.com> Date: Tue, 30 Jun 2026 16:12:03 +0300 Subject: [PATCH 14/14] [MS-1485] MFID, "Skip reason" screen rework. Its visibility and contents can be controlled with custom config (#1730) * [MS-1485] MFID: "Skip reason" screen visiblity and content is now controlled by 'mfidDefaultSkipReason' and 'mfidSkipReasonsHideHasNumber' flags * [MS-1485] Destroying dialog in 'onDestroyView' instead of 'onDestroy' * [MS-1485] Fixing failing tests --- .../ExternalCredentialControllerFragment.kt | 4 -- .../controller/ExternalCredentialViewModel.kt | 18 ++++++ .../ExternalCredentialSearchFragment.kt | 34 +++++++++++ .../ExternalCredentialSelectFragment.kt | 61 +++++-------------- .../skip/ExternalCredentialSkipFragment.kt | 1 + .../view/SkipScanConfirmationDialog.kt | 38 ++++++++++++ .../ExternalCredentialViewModelTest.kt | 58 +++++++++++++++++- .../ExperimentalProjectConfiguration.kt | 18 ++++++ .../ExperimentalProjectConfigurationTest.kt | 27 ++++++++ 9 files changed, 208 insertions(+), 51 deletions(-) create mode 100644 feature/external-credential/src/main/java/com/simprints/feature/externalcredential/view/SkipScanConfirmationDialog.kt diff --git a/feature/external-credential/src/main/java/com/simprints/feature/externalcredential/screens/controller/ExternalCredentialControllerFragment.kt b/feature/external-credential/src/main/java/com/simprints/feature/externalcredential/screens/controller/ExternalCredentialControllerFragment.kt index beda07975f..348064dd87 100644 --- a/feature/external-credential/src/main/java/com/simprints/feature/externalcredential/screens/controller/ExternalCredentialControllerFragment.kt +++ b/feature/external-credential/src/main/java/com/simprints/feature/externalcredential/screens/controller/ExternalCredentialControllerFragment.kt @@ -77,10 +77,6 @@ internal class ExternalCredentialControllerFragment : Fragment(R.layout.fragment private fun initListeners() { requireActivity().onBackPressedDispatcher.addCallback(viewLifecycleOwner) { when (internalNavController?.currentDestination?.id) { - R.id.externalCredentialSearch -> { - internalNavController?.navigate(R.id.externalCredentialSkip) - } - R.id.externalCredentialSkip, R.id.externalCredentialScanQr, R.id.externalCredentialScanOcr -> { internalNavController?.popBackStack() } diff --git a/feature/external-credential/src/main/java/com/simprints/feature/externalcredential/screens/controller/ExternalCredentialViewModel.kt b/feature/external-credential/src/main/java/com/simprints/feature/externalcredential/screens/controller/ExternalCredentialViewModel.kt index 0d6701efa0..b026c4d15f 100644 --- a/feature/external-credential/src/main/java/com/simprints/feature/externalcredential/screens/controller/ExternalCredentialViewModel.kt +++ b/feature/external-credential/src/main/java/com/simprints/feature/externalcredential/screens/controller/ExternalCredentialViewModel.kt @@ -14,6 +14,7 @@ import com.simprints.feature.externalcredential.ExternalCredentialSearchResult import com.simprints.feature.externalcredential.model.ExternalCredentialParams import com.simprints.feature.externalcredential.usecase.ExternalCredentialEventTrackerUseCase import com.simprints.infra.config.store.ConfigRepository +import com.simprints.infra.config.store.models.experimental import com.simprints.infra.events.event.domain.models.ExternalCredentialSelectionEvent import dagger.hilt.android.lifecycle.HiltViewModel import kotlinx.coroutines.launch @@ -29,6 +30,10 @@ internal class ExternalCredentialViewModel @Inject internal constructor( private var isInitialized = false lateinit var params: ExternalCredentialParams private set + var defaultSkipReason: String? = null + private set + var skipReasonsHideHasNumber: Boolean = false + private set val finishEvent: LiveData> get() = _finishEvent private val _finishEvent = MutableLiveData>() @@ -58,8 +63,11 @@ internal class ExternalCredentialViewModel @Inject internal constructor( viewModelScope.launch { val config = configRepository.getProjectConfiguration() + val experimental = config.experimental() val allowedExternalCredentials = config.multifactorId?.allowedExternalCredentials.orEmpty() _externalCredentialTypes.postValue(allowedExternalCredentials) + defaultSkipReason = experimental.mfidDefaultSkipReason + skipReasonsHideHasNumber = experimental.mfidSkipReasonsHideHasNumber } } @@ -81,6 +89,16 @@ internal class ExternalCredentialViewModel @Inject internal constructor( selectedSkipOtherText = otherText?.ifBlank { null } } + fun bypassSkipScreen() { + selectedSkipOtherText = defaultSkipReason + finish( + ExternalCredentialSearchResult.Skipped( + flowType = params.flowType, + skipReason = ExternalCredentialSelectionEvent.SkipReason.OTHER, + ), + ) + } + private fun updateState(state: (ExternalCredentialState) -> ExternalCredentialState) { this.state = state(this.state) } diff --git a/feature/external-credential/src/main/java/com/simprints/feature/externalcredential/screens/search/ExternalCredentialSearchFragment.kt b/feature/external-credential/src/main/java/com/simprints/feature/externalcredential/screens/search/ExternalCredentialSearchFragment.kt index 8f24e50283..6e263db485 100644 --- a/feature/external-credential/src/main/java/com/simprints/feature/externalcredential/screens/search/ExternalCredentialSearchFragment.kt +++ b/feature/external-credential/src/main/java/com/simprints/feature/externalcredential/screens/search/ExternalCredentialSearchFragment.kt @@ -1,11 +1,13 @@ package com.simprints.feature.externalcredential.screens.search +import android.app.Dialog import android.content.Context import android.graphics.BitmapFactory import android.os.Bundle import android.text.TextWatcher import android.view.View import android.view.inputmethod.InputMethodManager +import androidx.activity.addCallback import androidx.core.content.ContextCompat import androidx.core.view.isVisible import androidx.core.widget.addTextChangedListener @@ -30,6 +32,7 @@ import com.simprints.feature.externalcredential.screens.scanocr.usecase.ZoomOnto import com.simprints.feature.externalcredential.screens.search.model.ScannedCredentialResult import com.simprints.feature.externalcredential.screens.search.model.SearchCredentialState import com.simprints.feature.externalcredential.screens.search.model.SearchState +import com.simprints.feature.externalcredential.view.SkipScanConfirmationDialog import com.simprints.infra.logging.LoggingConstants.CrashReportTag.MULTI_FACTOR_ID import com.simprints.infra.logging.Simber import com.simprints.infra.uibase.navigation.navigateSafely @@ -62,6 +65,7 @@ internal class ExternalCredentialSearchFragment : Fragment(R.layout.fragment_ext @Inject lateinit var zoomOntoCredentialUseCase: ZoomOntoCredentialUseCase private var credentialTextWatcher: TextWatcher? = null + private var dialog: Dialog? = null override fun onViewCreated( view: View, @@ -70,6 +74,18 @@ internal class ExternalCredentialSearchFragment : Fragment(R.layout.fragment_ext super.onViewCreated(view, savedInstanceState) applySystemBarInsets(view) initObservers() + requireActivity().onBackPressedDispatcher.addCallback(viewLifecycleOwner) { + if (mainViewModel.defaultSkipReason != null) { + showSkipConfirmationDialog() + } else { + findNavController().navigate(R.id.externalCredentialSkip) + } + } + } + + override fun onDestroyView() { + dismissDialog() + super.onDestroyView() } override fun onPause() { @@ -77,6 +93,11 @@ internal class ExternalCredentialSearchFragment : Fragment(R.layout.fragment_ext super.onPause() } + private fun dismissDialog() { + dialog?.dismiss() + dialog = null + } + private fun initObservers() { viewModel.stateLiveData.observe(viewLifecycleOwner) { state -> renderCredentialCard(state) @@ -260,6 +281,19 @@ internal class ExternalCredentialSearchFragment : Fragment(R.layout.fragment_ext } } + private fun showSkipConfirmationDialog() { + dismissDialog() + dialog = SkipScanConfirmationDialog( + context = requireContext(), + credentialTypes = mainViewModel.externalCredentialTypes.value.orEmpty(), + onConfirm = { + dismissDialog() + mainViewModel.bypassSkipScreen() + }, + onCancel = ::dismissDialog, + ).also { it.show() } + } + private fun hideKeyboard() { requireActivity().hideKeyboard() } diff --git a/feature/external-credential/src/main/java/com/simprints/feature/externalcredential/screens/select/ExternalCredentialSelectFragment.kt b/feature/external-credential/src/main/java/com/simprints/feature/externalcredential/screens/select/ExternalCredentialSelectFragment.kt index 7e942024e0..31f0040a85 100644 --- a/feature/external-credential/src/main/java/com/simprints/feature/externalcredential/screens/select/ExternalCredentialSelectFragment.kt +++ b/feature/external-credential/src/main/java/com/simprints/feature/externalcredential/screens/select/ExternalCredentialSelectFragment.kt @@ -3,16 +3,11 @@ package com.simprints.feature.externalcredential.screens.select import android.app.Dialog import android.os.Bundle import android.view.View -import android.widget.Button -import android.widget.TextView import androidx.activity.addCallback import androidx.fragment.app.Fragment import androidx.fragment.app.activityViewModels -import androidx.fragment.app.viewModels import androidx.navigation.fragment.findNavController import androidx.recyclerview.widget.LinearLayoutManager -import com.google.android.material.bottomsheet.BottomSheetBehavior -import com.google.android.material.bottomsheet.BottomSheetDialog import com.simprints.core.domain.externalcredential.ExternalCredentialType import com.simprints.feature.externalcredential.R import com.simprints.feature.externalcredential.databinding.FragmentExternalCredentialSelectBinding @@ -22,6 +17,7 @@ import com.simprints.feature.externalcredential.screens.controller.ExternalCrede import com.simprints.feature.externalcredential.screens.scanocr.model.OcrDocumentType import com.simprints.feature.externalcredential.screens.scanocr.model.asOcrDocumentType import com.simprints.feature.externalcredential.screens.select.view.ExternalCredentialTypeAdapter +import com.simprints.feature.externalcredential.view.SkipScanConfirmationDialog import com.simprints.infra.logging.LoggingConstants.CrashReportTag.ORCHESTRATION import com.simprints.infra.logging.Simber import com.simprints.infra.uibase.navigation.navigateSafely @@ -48,9 +44,9 @@ internal class ExternalCredentialSelectFragment : Fragment(R.layout.fragment_ext observeChanges() } - override fun onDestroy() { + override fun onDestroyView() { dismissDialog() - super.onDestroy() + super.onDestroyView() } private fun dismissDialog() { @@ -60,17 +56,23 @@ internal class ExternalCredentialSelectFragment : Fragment(R.layout.fragment_ext private fun initListeners(types: List) { binding.skipScanning.setOnClickListener { - displaySkipScanningConfirmationDialog( + dismissDialog() + dialog = SkipScanConfirmationDialog( + context = requireContext(), credentialTypes = types, onConfirm = { dismissDialog() - findNavController().navigateSafely( - this, - ExternalCredentialSelectFragmentDirections.actionExternalCredentialSelectFragmentToExternalCredentialSkip(), - ) + if (mainViewModel.defaultSkipReason != null) { + mainViewModel.bypassSkipScreen() + } else { + findNavController().navigateSafely( + this, + ExternalCredentialSelectFragmentDirections.actionExternalCredentialSelectFragmentToExternalCredentialSkip(), + ) + } }, onCancel = ::dismissDialog, - ) + ).also { it.show() } } requireActivity().onBackPressedDispatcher.addCallback(viewLifecycleOwner) { binding.skipScanning.performClick() @@ -127,39 +129,6 @@ internal class ExternalCredentialSelectFragment : Fragment(R.layout.fragment_ext ) } - private fun displaySkipScanningConfirmationDialog( - credentialTypes: List, - onConfirm: () -> Unit, - onCancel: () -> Unit, - ) { - dismissDialog() - dialog = BottomSheetDialog(requireContext()).also { - val view = layoutInflater - .inflate(R.layout.dialog_skip_scan_confirm, null) - .also { view -> - val bodyText = view.findViewById(R.id.skipDialogBodyText) - val cancelButton = view.findViewById