Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
21 commits
Select commit Hold shift + click to select a range
967bd0c
fix: upload session scopes in FIFO order
meladRaouf Jun 22, 2026
4e9c9c9
Translate strings.xml in am
transifex-integration[bot] Jun 23, 2026
531babc
Merge pull request #1718 from Simprints/translations-update
meladRaouf Jun 23, 2026
f81842b
Merge pull request #1716 from Simprints/bugfix/fifo-upsync-release
meladRaouf Jun 23, 2026
04ccd55
[MS-1459] Enhance version code computation and validation in reusable…
meladRaouf Jun 23, 2026
4deb45f
Upload AAB to Google Play Internal and attach universal APK to draft …
meladRaouf Jun 23, 2026
d51d633
Fix supply-chain and secret masking in upload-to-internal-and-release…
meladRaouf Jun 23, 2026
6ba5024
Fix script injection: pass workflow inputs via env vars in run blocks
meladRaouf Jun 23, 2026
c581669
Add tests for run_number above 9999 in version generation script
meladRaouf Jun 23, 2026
b4753b8
Remove duplicated VERSION_CODE formula comment from workflow
meladRaouf Jun 23, 2026
4d22ac5
Replace TianchenWei action with r0adkll/upload-google-play + own down…
meladRaouf Jun 23, 2026
9fa1336
Merge pull request #1719 from Simprints/apk-versiong
meladRaouf Jun 24, 2026
06bd55c
Update universal APK download ID key to `generatedUniversalApk`
meladRaouf Jun 24, 2026
de739c5
Merge pull request #1720 from Simprints/apk-versiong
meladRaouf Jun 24, 2026
411bdcf
Add step to create release git tag in workflow
meladRaouf Jun 24, 2026
f531678
Merge pull request #1721 from Simprints/add-release-tag
meladRaouf Jun 24, 2026
2bd239f
Delete existing draft release to avoid conflicts during upload
meladRaouf Jun 24, 2026
9b81ac4
Merge pull request #1722 from Simprints/delete-existing-draft-release
meladRaouf Jun 25, 2026
5625cc4
[MS-1484] MFID: QR codes are no longer required to be confirmed. QR c…
alexandr-simprints Jun 25, 2026
5826d75
[MS-1485] MFID, "Skip reason" screen rework. Its visibility and conte…
alexandr-simprints Jun 30, 2026
5a64784
Merge origin/release/2026.2.1 into main
meladRaouf Jul 5, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
106 changes: 106 additions & 0 deletions .github/scripts/download-universal-apk.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,106 @@
#!/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", []):
universal = apk.get("generatedUniversalApk")
if universal:
return universal["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()
18 changes: 18 additions & 0 deletions .github/scripts/test-generate-version.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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" \
Expand Down
12 changes: 5 additions & 7 deletions .github/workflows/pipeline-deploy-to-internal.yml
Original file line number Diff line number Diff line change
Expand Up @@ -18,16 +18,14 @@ 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 }}
version-code: ${{ needs.build-internal-aab.outputs.version-code }}
mapping-file: ${{ needs.build-internal-aab.outputs.optional-mapping-file }}
Comment on lines 25 to 28
needs:
- get-version
- build-internal-aab
tag-release:
uses: ./.github/workflows/reusable-update-release-git-tag.yml
needs: deploy-internal-build
secrets: inherit
104 changes: 104 additions & 0 deletions .github/workflows/reusable-upload-to-internal-and-release.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,104 @@
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
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
required: false
default: ""

jobs:
upload-and-release:
runs-on: ubuntu-latest

environment: Internal

permissions:
contents: write

steps:
- name: Checkout
uses: actions/checkout@v6

Comment on lines +34 to +36
- name: Download AAB artifact
uses: actions/download-artifact@v8
with:
name: ${{ inputs.upload-artifact }}

- 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: Set up Python
uses: actions/setup-python@v5
with:
python-version: "3.11"

- 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 }}
PACKAGE_NAME: com.simprints.id
run: |
APK_NAME="simprints-id-${VERSION_NAME}-universal.apk"
OUTPUT_APK="$APK_NAME" python3 .github/scripts/download-universal-apk.py
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 release git tag
uses: ./.github/actions/set-release-git-tag

- 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.download-apk.outputs.apk-name }}
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")
fi

gh release create "$TAG" \
--repo "$GITHUB_REPOSITORY" \
--title "Simprints ID ${VERSION_NAME}" \
--draft \
--generate-notes \
"${ASSETS[@]}"
Original file line number Diff line number Diff line change
Expand Up @@ -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()
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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<LiveDataEventWithContent<ExternalCredentialSearchResult>>
get() = _finishEvent
private val _finishEvent = MutableLiveData<LiveDataEventWithContent<ExternalCredentialSearchResult>>()
Expand Down Expand Up @@ -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
}
}

Expand All @@ -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)
}
Expand Down
Loading