diff --git a/.github/workflows/android-ci.yml b/.github/workflows/android-ci.yml
index 9ffc4f0..2b7f7c5 100644
--- a/.github/workflows/android-ci.yml
+++ b/.github/workflows/android-ci.yml
@@ -24,8 +24,21 @@ jobs:
distribution: temurin
java-version: "17"
- - name: Set up Android SDK
- uses: android-actions/setup-android@v3
+ - name: Configure Android SDK
+ run: |
+ ANDROID_HOME="${ANDROID_HOME:-${ANDROID_SDK_ROOT:-/usr/local/lib/android/sdk}}"
+ echo "ANDROID_HOME=$ANDROID_HOME" >> "$GITHUB_ENV"
+ echo "ANDROID_SDK_ROOT=$ANDROID_HOME" >> "$GITHUB_ENV"
+ if [ ! -x "$ANDROID_HOME/cmdline-tools/latest/bin/sdkmanager" ]; then
+ mkdir -p "$ANDROID_HOME/cmdline-tools"
+ curl -fsSL https://dl.google.com/android/repository/commandlinetools-linux-11076708_latest.zip -o "$RUNNER_TEMP/cmdline-tools.zip"
+ unzip -q "$RUNNER_TEMP/cmdline-tools.zip" -d "$RUNNER_TEMP/cmdline-tools"
+ rm -rf "$ANDROID_HOME/cmdline-tools/latest"
+ mv "$RUNNER_TEMP/cmdline-tools/cmdline-tools" "$ANDROID_HOME/cmdline-tools/latest"
+ fi
+ echo "$ANDROID_HOME/cmdline-tools/latest/bin" >> "$GITHUB_PATH"
+ echo "$ANDROID_HOME/platform-tools" >> "$GITHUB_PATH"
+ yes | "$ANDROID_HOME/cmdline-tools/latest/bin/sdkmanager" --licenses >/dev/null
- name: Install Android SDK packages
run: sdkmanager "platforms;android-36" "build-tools;36.0.0" "ndk;30.0.14904198"
diff --git a/.github/workflows/android-release.yml b/.github/workflows/android-release.yml
index edfe91b..5278084 100644
--- a/.github/workflows/android-release.yml
+++ b/.github/workflows/android-release.yml
@@ -1,36 +1,73 @@
-name: Android Release
+name: Publish Tagged Android Release
on:
+ push:
+ tags: ["v*"]
workflow_dispatch:
inputs:
- tag_name:
- description: "Release tag. Defaults to v."
- required: false
+ tag:
+ description: Existing immutable release tag to publish
+ required: true
+ type: string
+ workflow_call:
+ inputs:
+ tag:
+ required: true
type: string
- prerelease:
- description: "Mark GitHub release as a prerelease."
- required: false
- default: false
- type: boolean
permissions:
contents: write
jobs:
- signed-release:
- name: Build signed release artifacts
+ publish:
runs-on: ubuntu-latest
-
env:
ANDROID_KEYSTORE_PASSWORD: ${{ secrets.ANDROID_KEYSTORE_PASSWORD }}
ANDROID_KEY_ALIAS: ${{ secrets.ANDROID_KEY_ALIAS }}
ANDROID_KEY_PASSWORD: ${{ secrets.ANDROID_KEY_PASSWORD }}
-
steps:
- - name: Check out repository
- uses: actions/checkout@v4
+ - name: Resolve requested tag
+ id: requested
+ env:
+ INPUT_TAG: ${{ inputs.tag }}
+ REF_TAG: ${{ github.ref_name }}
+ run: |
+ if [ -n "$INPUT_TAG" ]; then TAG="$INPUT_TAG"; else TAG="$REF_TAG"; fi
+ case "$TAG" in v[0-9]*.[0-9]*.[0-9]*) ;; *) echo "::error::Invalid release tag: $TAG"; exit 2 ;; esac
+ echo "tag=$TAG" >> "$GITHUB_OUTPUT"
+
+ - uses: actions/checkout@v4
+ with:
+ ref: ${{ steps.requested.outputs.tag }}
+ fetch-depth: 0
+
+ - name: Verify tag and release identity
+ env:
+ TAG: ${{ steps.requested.outputs.tag }}
+ run: |
+ git fetch origin --tags
+ test "$(git rev-parse HEAD)" = "$(git rev-list -n 1 "$TAG")"
+ test "$(git cat-file -t "$TAG")" = "tag"
+ test "$(python3 scripts/release_tool.py metadata | python3 -c 'import json,sys; print(json.load(sys.stdin)["tag"])')" = "$TAG"
+ python3 scripts/release_tool.py validate --skip-branch --allow-existing-tag
+
+ - name: Detect an existing complete GitHub Release
+ id: existing
+ env:
+ GH_TOKEN: ${{ github.token }}
+ TAG: ${{ steps.requested.outputs.tag }}
+ run: |
+ mkdir -p release-artifacts
+ if gh release view "$TAG" >/dev/null 2>&1; then
+ gh release download "$TAG" --dir release-artifacts
+ (cd release-artifacts && sha256sum --check SHA256SUMS)
+ echo "complete=true" >> "$GITHUB_OUTPUT"
+ else
+ echo "complete=false" >> "$GITHUB_OUTPUT"
+ fi
- name: Validate signing secrets
+ if: steps.existing.outputs.complete != 'true'
env:
ANDROID_KEYSTORE_BASE64: ${{ secrets.ANDROID_KEYSTORE_BASE64 }}
run: |
@@ -46,23 +83,41 @@ jobs:
fi
- name: Set up JDK
+ if: steps.existing.outputs.complete != 'true'
uses: actions/setup-java@v4
with:
distribution: temurin
java-version: "17"
- - name: Set up Android SDK
- uses: android-actions/setup-android@v3
+ - name: Configure Android SDK
+ if: steps.existing.outputs.complete != 'true'
+ run: |
+ ANDROID_HOME="${ANDROID_HOME:-${ANDROID_SDK_ROOT:-/usr/local/lib/android/sdk}}"
+ echo "ANDROID_HOME=$ANDROID_HOME" >> "$GITHUB_ENV"
+ echo "ANDROID_SDK_ROOT=$ANDROID_HOME" >> "$GITHUB_ENV"
+ if [ ! -x "$ANDROID_HOME/cmdline-tools/latest/bin/sdkmanager" ]; then
+ mkdir -p "$ANDROID_HOME/cmdline-tools"
+ curl -fsSL https://dl.google.com/android/repository/commandlinetools-linux-11076708_latest.zip -o "$RUNNER_TEMP/cmdline-tools.zip"
+ unzip -q "$RUNNER_TEMP/cmdline-tools.zip" -d "$RUNNER_TEMP/cmdline-tools"
+ rm -rf "$ANDROID_HOME/cmdline-tools/latest"
+ mv "$RUNNER_TEMP/cmdline-tools/cmdline-tools" "$ANDROID_HOME/cmdline-tools/latest"
+ fi
+ echo "$ANDROID_HOME/cmdline-tools/latest/bin" >> "$GITHUB_PATH"
+ echo "$ANDROID_HOME/platform-tools" >> "$GITHUB_PATH"
+ yes | "$ANDROID_HOME/cmdline-tools/latest/bin/sdkmanager" --licenses >/dev/null
- name: Install Android SDK packages
+ if: steps.existing.outputs.complete != 'true'
run: sdkmanager "platforms;android-36" "build-tools;36.0.0" "ndk;30.0.14904198"
- name: Set up Rust
+ if: steps.existing.outputs.complete != 'true'
uses: dtolnay/rust-toolchain@stable
with:
targets: aarch64-linux-android
- name: Cache Rust artifacts
+ if: steps.existing.outputs.complete != 'true'
uses: actions/cache@v4
with:
path: |
@@ -74,12 +129,20 @@ jobs:
rust-${{ runner.os }}-
- name: Install cargo-ndk
+ if: steps.existing.outputs.complete != 'true'
run: cargo install cargo-ndk --locked
- name: Set up Gradle
+ if: steps.existing.outputs.complete != 'true'
uses: gradle/actions/setup-gradle@v4
+ - uses: ruby/setup-ruby@v1
+ with:
+ ruby-version: "3.3"
+ bundler-cache: true
+
- name: Decode release keystore
+ if: steps.existing.outputs.complete != 'true'
env:
ANDROID_KEYSTORE_BASE64: ${{ secrets.ANDROID_KEYSTORE_BASE64 }}
run: |
@@ -87,64 +150,45 @@ jobs:
printf '%s' "$ANDROID_KEYSTORE_BASE64" | base64 --decode > "$keystore"
echo "ANDROID_KEYSTORE_FILE=$keystore" >> "$GITHUB_ENV"
- - name: Build signed release APK
- run: ./gradlew :app:assembleRelease
-
- - name: Build signed release AAB
- run: ./gradlew :app:bundleRelease
-
- - name: Stage release files
- id: release_files
+ - name: Build signed APK and AAB once
+ if: steps.existing.outputs.complete != 'true'
run: |
- version=$(grep 'versionName =' app/build.gradle.kts | head -n1 | cut -d '"' -f2)
- if [ -z "$version" ]; then
- echo "::error::Unable to read versionName from app/build.gradle.kts"
- exit 1
- fi
- tag="${{ inputs.tag_name }}"
- if [ -z "$tag" ]; then
- tag="v$version"
- fi
-
- mkdir -p release-dist
- cp app/build/outputs/apk/release/app-release.apk "release-dist/voice-inbox-$version.apk"
- cp app/build/outputs/bundle/release/app-release.aab "release-dist/voice-inbox-$version.aab"
-
- echo "version=$version" >> "$GITHUB_OUTPUT"
- echo "tag=$tag" >> "$GITHUB_OUTPUT"
- echo "apk=release-dist/voice-inbox-$version.apk" >> "$GITHUB_OUTPUT"
- echo "aab=release-dist/voice-inbox-$version.aab" >> "$GITHUB_OUTPUT"
-
- - name: Upload signed release APK
- uses: actions/upload-artifact@v4
- with:
- name: voice-inbox-release-apk
- path: ${{ steps.release_files.outputs.apk }}
- if-no-files-found: error
-
- - name: Upload signed release AAB
- uses: actions/upload-artifact@v4
- with:
- name: voice-inbox-release-aab
- path: ${{ steps.release_files.outputs.aab }}
- if-no-files-found: error
+ ./gradlew :app:assembleRelease :app:bundleRelease --no-daemon
+ version=$(python3 scripts/release_tool.py metadata | python3 -c 'import json,sys; print(json.load(sys.stdin)["versionName"])')
+ cp app/build/outputs/apk/release/app-release.apk "release-artifacts/voice-inbox-$version.apk"
+ cp app/build/outputs/bundle/release/app-release.aab "release-artifacts/voice-inbox-$version.aab"
+ (cd release-artifacts && sha256sum "voice-inbox-$version.apk" "voice-inbox-$version.aab" > SHA256SUMS)
+ (cd release-artifacts && sha256sum --check SHA256SUMS)
+
+ - name: Publish GitHub Release and immutable assets
+ if: steps.existing.outputs.complete != 'true'
+ env:
+ GH_TOKEN: ${{ github.token }}
+ TAG: ${{ steps.requested.outputs.tag }}
+ run: |
+ CODE=$(python3 scripts/release_tool.py metadata | python3 -c 'import json,sys; print(json.load(sys.stdin)["versionCode"])')
+ NOTES="fastlane/metadata/android/en-US/changelogs/$CODE.txt"
+ gh release create "$TAG" release-artifacts/* --verify-tag --title "Voice Inbox $TAG" --notes-file "$NOTES"
- - name: Create GitHub release
+ - name: Decode Google Play service account
env:
- GITHUB_TOKEN: ${{ github.token }}
- RELEASE_TAG: ${{ steps.release_files.outputs.tag }}
- RELEASE_VERSION: ${{ steps.release_files.outputs.version }}
- PRERELEASE: ${{ inputs.prerelease }}
+ SERVICE_ACCOUNT_BASE64: ${{ secrets.GOOGLE_PLAY_SERVICE_ACCOUNT }}
run: |
- prerelease_args=""
- if [ "$PRERELEASE" = "true" ]; then
- prerelease_args="--prerelease"
+ if [ -z "$SERVICE_ACCOUNT_BASE64" ]; then
+ echo "::error::GOOGLE_PLAY_SERVICE_ACCOUNT is required for Google Play upload"
+ exit 1
fi
+ printf '%s' "$SERVICE_ACCOUNT_BASE64" | base64 --decode > service-account.json
- gh release create "$RELEASE_TAG" \
- "${{ steps.release_files.outputs.apk }}" \
- "${{ steps.release_files.outputs.aab }}" \
- --target "$GITHUB_SHA" \
- --title "Voice Inbox $RELEASE_VERSION" \
- --notes "Signed Android release artifacts for Voice Inbox $RELEASE_VERSION." \
- $prerelease_args
+ - name: Upload the published AAB to Play Internal testing
+ run: |
+ TAG="${{ steps.requested.outputs.tag }}"
+ CODE=$(python3 scripts/release_tool.py metadata | python3 -c 'import json,sys; print(json.load(sys.stdin)["versionCode"])')
+ TRACK=$(python3 scripts/release_tool.py metadata | python3 -c 'import json,sys; print(json.load(sys.stdin)["playTrack"])')
+ AAB=$(find release-artifacts -maxdepth 1 -name 'voice-inbox-*.aab' | head -n1)
+ bundle exec fastlane android upload_release \
+ aab:"$AAB" \
+ version_code:"$CODE" \
+ metadata_path:"${{ github.workspace }}/fastlane/metadata/android" \
+ json_key:"${{ github.workspace }}/service-account.json" \
+ track:"$TRACK"
diff --git a/.github/workflows/create-release.yml b/.github/workflows/create-release.yml
new file mode 100644
index 0000000..7460856
--- /dev/null
+++ b/.github/workflows/create-release.yml
@@ -0,0 +1,112 @@
+name: Create Android Release
+
+on:
+ pull_request_target:
+ branches: [master]
+ types: [closed]
+ workflow_dispatch:
+ inputs:
+ head_branch:
+ description: Merged release/* or hotfix/* branch for recovery
+ required: true
+ type: string
+ commit_sha:
+ description: Exact merge commit contained in master
+ required: true
+ type: string
+
+permissions:
+ contents: write
+ checks: read
+ pull-requests: read
+
+concurrency:
+ group: android-release-${{ github.event.pull_request.merge_commit_sha || inputs.commit_sha }}
+ cancel-in-progress: false
+
+jobs:
+ create-tag:
+ if: >-
+ github.event_name == 'workflow_dispatch' ||
+ (github.event.pull_request.merged == true &&
+ (startsWith(github.event.pull_request.head.ref, 'release/') ||
+ startsWith(github.event.pull_request.head.ref, 'hotfix/')))
+ runs-on: ubuntu-latest
+ outputs:
+ tag: ${{ steps.identity.outputs.tag }}
+ steps:
+ - uses: actions/checkout@v4
+ with:
+ ref: ${{ github.event.pull_request.merge_commit_sha || inputs.commit_sha }}
+ fetch-depth: 0
+
+ - uses: ruby/setup-ruby@v1
+ with:
+ ruby-version: "3.3"
+ bundler-cache: true
+
+ - name: Fetch stable history and tags
+ run: git fetch origin master --tags
+
+ - name: Require successful protected pull-request checks
+ if: github.event_name == 'pull_request_target'
+ env:
+ GH_TOKEN: ${{ github.token }}
+ PR_NUMBER: ${{ github.event.pull_request.number }}
+ run: gh pr checks "$PR_NUMBER" --required
+
+ - name: Resolve merged release identity
+ id: identity
+ env:
+ HEAD_BRANCH: ${{ github.event.pull_request.head.ref || inputs.head_branch }}
+ BASE_BRANCH: ${{ github.event.pull_request.base.ref || 'master' }}
+ MERGE_COMMIT: ${{ github.event.pull_request.merge_commit_sha || inputs.commit_sha }}
+ MERGED: ${{ github.event_name == 'workflow_dispatch' || github.event.pull_request.merged }}
+ run: |
+ python3 scripts/release_tool.py candidate \
+ --head-branch "$HEAD_BRANCH" \
+ --base-branch "$BASE_BRANCH" \
+ --merge-commit "$MERGE_COMMIT" \
+ --merged "$MERGED" > release-candidate.json
+ tag=$(python3 -c 'import json; print(json.load(open("release-candidate.json"))["tag"])')
+ echo "tag=$tag" >> "$GITHUB_OUTPUT"
+
+ - name: Decode Google Play service account
+ env:
+ SERVICE_ACCOUNT_BASE64: ${{ secrets.GOOGLE_PLAY_SERVICE_ACCOUNT }}
+ run: |
+ if [ -z "$SERVICE_ACCOUNT_BASE64" ]; then
+ echo "::error::GOOGLE_PLAY_SERVICE_ACCOUNT is required for release publishing"
+ exit 1
+ fi
+ printf '%s' "$SERVICE_ACCOUNT_BASE64" | base64 --decode > service-account.json
+
+ - name: Repeat repository and Google Play validation
+ run: >-
+ python3 scripts/release_tool.py validate --skip-branch --check-play
+ --service-account service-account.json
+
+ - name: Create immutable annotated tag
+ env:
+ TAG: ${{ steps.identity.outputs.tag }}
+ MERGE_COMMIT: ${{ github.event.pull_request.merge_commit_sha || inputs.commit_sha }}
+ run: |
+ test -z "$(git tag --list "$TAG")"
+ git config user.name "github-actions[bot]"
+ git config user.email "41898282+github-actions[bot]@users.noreply.github.com"
+ git tag -a "$TAG" "$MERGE_COMMIT" -m "Release $TAG"
+ git push origin "refs/tags/$TAG"
+
+ publish:
+ needs: create-tag
+ uses: ./.github/workflows/android-release.yml
+ with:
+ tag: ${{ needs.create-tag.outputs.tag }}
+ secrets: inherit
+
+ sync-development:
+ needs: [create-tag, publish]
+ uses: ./.github/workflows/sync-master-to-develop.yml
+ with:
+ tag: ${{ needs.create-tag.outputs.tag }}
+ secrets: inherit
diff --git a/.github/workflows/release-pr.yml b/.github/workflows/release-pr.yml
new file mode 100644
index 0000000..c1e1b01
--- /dev/null
+++ b/.github/workflows/release-pr.yml
@@ -0,0 +1,90 @@
+name: Validate Android Release PR
+
+on:
+ pull_request:
+ branches: [master]
+
+permissions:
+ contents: read
+
+jobs:
+ validate:
+ if: startsWith(github.head_ref, 'release/') || startsWith(github.head_ref, 'hotfix/')
+ runs-on: ubuntu-latest
+ steps:
+ - uses: actions/checkout@v4
+ with:
+ ref: ${{ github.head_ref }}
+ fetch-depth: 0
+
+ - name: Set up JDK
+ uses: actions/setup-java@v4
+ with:
+ distribution: temurin
+ java-version: "17"
+
+ - name: Configure Android SDK
+ run: |
+ ANDROID_HOME="${ANDROID_HOME:-${ANDROID_SDK_ROOT:-/usr/local/lib/android/sdk}}"
+ echo "ANDROID_HOME=$ANDROID_HOME" >> "$GITHUB_ENV"
+ echo "ANDROID_SDK_ROOT=$ANDROID_HOME" >> "$GITHUB_ENV"
+ if [ ! -x "$ANDROID_HOME/cmdline-tools/latest/bin/sdkmanager" ]; then
+ mkdir -p "$ANDROID_HOME/cmdline-tools"
+ curl -fsSL https://dl.google.com/android/repository/commandlinetools-linux-11076708_latest.zip -o "$RUNNER_TEMP/cmdline-tools.zip"
+ unzip -q "$RUNNER_TEMP/cmdline-tools.zip" -d "$RUNNER_TEMP/cmdline-tools"
+ rm -rf "$ANDROID_HOME/cmdline-tools/latest"
+ mv "$RUNNER_TEMP/cmdline-tools/cmdline-tools" "$ANDROID_HOME/cmdline-tools/latest"
+ fi
+ echo "$ANDROID_HOME/cmdline-tools/latest/bin" >> "$GITHUB_PATH"
+ echo "$ANDROID_HOME/platform-tools" >> "$GITHUB_PATH"
+ yes | "$ANDROID_HOME/cmdline-tools/latest/bin/sdkmanager" --licenses >/dev/null
+
+ - name: Install Android SDK packages
+ run: sdkmanager "platforms;android-36" "build-tools;36.0.0" "ndk;30.0.14904198"
+
+ - name: Set up Rust
+ uses: dtolnay/rust-toolchain@stable
+ with:
+ targets: aarch64-linux-android
+
+ - name: Cache Rust artifacts
+ uses: actions/cache@v4
+ with:
+ path: |
+ ~/.cargo/registry
+ ~/.cargo/git
+ target
+ key: rust-${{ runner.os }}-${{ hashFiles('Cargo.lock', 'Cargo.toml', 'transcribe-rs/**/Cargo.toml') }}
+ restore-keys: |
+ rust-${{ runner.os }}-
+
+ - name: Install cargo-ndk
+ run: cargo install cargo-ndk --locked
+
+ - name: Set up Gradle
+ uses: gradle/actions/setup-gradle@v4
+
+ - name: Validate release identity and localized notes
+ run: python3 scripts/release_tool.py validate
+
+ - name: Test release tooling
+ run: python3 -m unittest scripts/test_release_tool.py
+
+ - name: Run Android release validation build
+ run: ./gradlew :shared:jvmTest :app:testDebugUnitTest :app:assembleRelease :app:bundleRelease --no-daemon
+
+ - name: Verify assembled release identity
+ run: >-
+ python3 scripts/release_tool.py verify-build
+ --manifest app/build/intermediates/merged_manifests/release/processReleaseManifest/AndroidManifest.xml
+ --metadata app/build/outputs/apk/release/output-metadata.json
+
+ - uses: actions/upload-artifact@v4
+ if: always()
+ with:
+ name: release-pr-validation
+ path: |
+ app/build/reports/
+ app/build/outputs/apk/release/
+ app/build/outputs/bundle/release/
+ retention-days: 14
diff --git a/.github/workflows/sync-master-to-develop.yml b/.github/workflows/sync-master-to-develop.yml
new file mode 100644
index 0000000..e411227
--- /dev/null
+++ b/.github/workflows/sync-master-to-develop.yml
@@ -0,0 +1,38 @@
+name: Sync Released Master to Develop
+
+on:
+ workflow_dispatch:
+ inputs:
+ tag:
+ description: Released v tag on master
+ required: true
+ type: string
+ workflow_call:
+ inputs:
+ tag:
+ required: true
+ type: string
+
+permissions:
+ contents: write
+
+jobs:
+ sync:
+ runs-on: ubuntu-latest
+ steps:
+ - uses: actions/checkout@v4
+ with:
+ ref: develop
+ fetch-depth: 0
+
+ - name: Merge released master into develop
+ env:
+ TAG: ${{ inputs.tag }}
+ run: |
+ git fetch origin master develop --tags
+ test "$(git cat-file -t "$TAG")" = "tag"
+ git merge-base --is-ancestor "$TAG^{}" origin/master
+ git config user.name github-actions[bot]
+ git config user.email 41898282+github-actions[bot]@users.noreply.github.com
+ git merge --no-ff origin/master -m "Merge released $TAG back into develop"
+ git push origin HEAD:develop
diff --git a/Gemfile b/Gemfile
new file mode 100644
index 0000000..b42dd3c
--- /dev/null
+++ b/Gemfile
@@ -0,0 +1,4 @@
+source "https://rubygems.org"
+
+gem "fastlane", "2.235.0"
+gem "multi_json", "~> 1.15"
diff --git a/Gemfile.lock b/Gemfile.lock
new file mode 100644
index 0000000..001cd65
--- /dev/null
+++ b/Gemfile.lock
@@ -0,0 +1,344 @@
+GEM
+ remote: https://rubygems.org/
+ specs:
+ CFPropertyList (3.0.8)
+ abbrev (0.1.2)
+ addressable (2.9.0)
+ public_suffix (>= 2.0.2, < 8.0)
+ artifactory (3.0.17)
+ atomos (0.1.3)
+ aws-eventstream (1.4.0)
+ aws-partitions (1.1292.0)
+ aws-sdk-core (3.257.0)
+ aws-eventstream (~> 1, >= 1.3.0)
+ aws-partitions (~> 1, >= 1.992.0)
+ aws-sigv4 (~> 1.9)
+ base64
+ bigdecimal
+ jmespath (~> 1, >= 1.6.1)
+ logger
+ rexml (~> 3.4, >= 3.4.2)
+ aws-sdk-kms (1.132.0)
+ aws-sdk-core (~> 3, >= 3.256.0)
+ aws-sigv4 (~> 1.5)
+ aws-sdk-s3 (1.233.1)
+ aws-sdk-core (~> 3, >= 3.256.0)
+ aws-sdk-kms (~> 1)
+ aws-sigv4 (~> 1.5)
+ aws-sigv4 (1.12.1)
+ aws-eventstream (~> 1, >= 1.0.2)
+ babosa (1.0.4)
+ base64 (0.3.0)
+ benchmark (0.5.0)
+ bigdecimal (4.1.3)
+ claide (1.1.0)
+ colored (1.2)
+ colored2 (3.1.2)
+ commander (4.6.0)
+ highline (~> 2.0.0)
+ csv (3.3.6)
+ declarative (0.0.20)
+ digest-crc (0.7.0)
+ rake (>= 12.0.0, < 14.0.0)
+ domain_name (0.6.20260921)
+ dotenv (2.8.1)
+ emoji_regex (3.2.3)
+ excon (0.112.0)
+ faraday (1.10.6)
+ faraday-em_http (~> 1.0)
+ faraday-em_synchrony (~> 1.0)
+ faraday-excon (~> 1.1)
+ faraday-httpclient (~> 1.0)
+ faraday-multipart (~> 1.0)
+ faraday-net_http (~> 1.0)
+ faraday-net_http_persistent (~> 1.0)
+ faraday-patron (~> 1.0)
+ faraday-rack (~> 1.0)
+ faraday-retry (~> 1.0)
+ ruby2_keywords (>= 0.0.4)
+ faraday-cookie_jar (0.0.8)
+ faraday (>= 0.8.0)
+ http-cookie (>= 1.0.0)
+ faraday-em_http (1.0.0)
+ faraday-em_synchrony (1.0.1)
+ faraday-excon (1.1.0)
+ faraday-httpclient (1.0.1)
+ faraday-multipart (1.2.0)
+ multipart-post (~> 2.0)
+ faraday-net_http (1.0.2)
+ faraday-net_http_persistent (1.2.0)
+ faraday-patron (1.0.0)
+ faraday-rack (1.0.0)
+ faraday-retry (1.0.4)
+ faraday_middleware (1.2.1)
+ faraday (~> 1.0)
+ fastimage (2.4.1)
+ fastlane (2.235.0)
+ CFPropertyList (>= 2.3, < 5.0.0)
+ abbrev (~> 0.1)
+ addressable (>= 2.8, < 3.0.0)
+ artifactory (~> 3.0)
+ aws-sdk-s3 (~> 1.197)
+ babosa (>= 1.0.3, < 2.0.0)
+ base64 (~> 0.2)
+ benchmark (>= 0.1.0)
+ bundler (>= 2.4.0, < 5.0.0)
+ colored (~> 1.2)
+ commander (~> 4.6)
+ csv (~> 3.3)
+ dotenv (>= 2.1.1, < 3.0.0)
+ emoji_regex (>= 0.1, < 4.0)
+ excon (>= 0.71.0, < 1.0.0)
+ faraday (~> 1.0)
+ faraday-cookie_jar (~> 0.0.6)
+ faraday_middleware (~> 1.0)
+ fastimage (>= 2.1.0, < 3.0.0)
+ fastlane-sirp (>= 1.1.0)
+ gh_inspector (>= 1.1.2, < 2.0.0)
+ google-apis-androidpublisher_v3 (~> 0.3)
+ google-apis-playcustomapp_v1 (~> 0.1)
+ google-cloud-env (>= 1.6.0, < 2.3.0)
+ google-cloud-storage (~> 1.31)
+ highline (~> 2.0)
+ http-cookie (~> 1.0.5)
+ json (< 3.0.0)
+ jwt (>= 2.1.0, < 4)
+ logger (>= 1.6, < 2.0)
+ mini_magick (>= 4.9.4, < 5.0.0)
+ multipart-post (>= 2.0.0, < 3.0.0)
+ mutex_m (~> 0.3)
+ naturally (~> 2.2)
+ nkf (~> 0.2)
+ optparse (>= 0.1.1, < 1.0.0)
+ ostruct (>= 0.1.0)
+ plist (>= 3.1.0, < 4.0.0)
+ rubyzip (>= 2.0.0, < 3.0.0)
+ security (= 0.1.5)
+ simctl (~> 1.6.3)
+ terminal-notifier (>= 2.0.0, < 3.0.0)
+ terminal-table (~> 3)
+ tty-screen (>= 0.6.3, < 1.0.0)
+ tty-spinner (>= 0.8.0, < 1.0.0)
+ word_wrap (~> 1.0.0)
+ xcodeproj (>= 1.13.0, < 2.0.0)
+ xcpretty (~> 0.4.1)
+ xcpretty-travis-formatter (>= 0.0.3, < 2.0.0)
+ fastlane-sirp (1.1.0)
+ gh_inspector (1.1.3)
+ google-apis-androidpublisher_v3 (0.109.0)
+ google-apis-core (>= 0.15.0, < 2.a)
+ google-apis-core (0.18.0)
+ addressable (~> 2.5, >= 2.5.1)
+ googleauth (~> 1.9)
+ httpclient (>= 2.8.3, < 3.a)
+ mini_mime (~> 1.0)
+ mutex_m
+ representable (~> 3.0)
+ retriable (>= 2.0, < 4.a)
+ google-apis-iamcredentials_v1 (0.28.0)
+ google-apis-core (>= 0.15.0, < 2.a)
+ google-apis-playcustomapp_v1 (0.18.0)
+ google-apis-core (>= 0.15.0, < 2.a)
+ google-apis-storage_v1 (0.68.0)
+ google-apis-core (>= 0.15.0, < 2.a)
+ google-cloud-core (1.9.0)
+ google-cloud-env (>= 1.0, < 3.a)
+ google-cloud-errors (~> 1.0)
+ google-cloud-env (2.2.2)
+ base64 (~> 0.2)
+ faraday (>= 1.0, < 3.a)
+ google-cloud-errors (1.7.0)
+ google-cloud-storage (1.62.1)
+ addressable (~> 2.8)
+ digest-crc (~> 0.4)
+ google-apis-core (>= 0.18, < 2)
+ google-apis-iamcredentials_v1 (~> 0.18)
+ google-apis-storage_v1 (>= 0.42)
+ google-cloud-core (~> 1.6)
+ googleauth (~> 1.9)
+ mini_mime (~> 1.0)
+ google-logging-utils (0.2.0)
+ googleauth (1.17.4)
+ faraday (>= 1.0, < 3.a)
+ google-cloud-env (~> 2.2)
+ google-logging-utils (~> 0.1)
+ jwt (>= 1.4, < 4.0)
+ os (>= 0.9, < 2.0)
+ pstore (~> 0.1)
+ signet (>= 0.16, < 2.a)
+ highline (2.0.3)
+ http-cookie (1.0.8)
+ domain_name (~> 0.5)
+ httpclient (2.9.0)
+ mutex_m
+ jmespath (1.6.2)
+ json (2.21.2)
+ jwt (3.3.0)
+ base64
+ logger (1.7.0)
+ mini_magick (4.13.2)
+ mini_mime (1.1.5)
+ multi_json (1.21.2)
+ multipart-post (2.4.1)
+ mutex_m (0.3.0)
+ nanaimo (0.4.0)
+ naturally (2.3.0)
+ nkf (0.3.0)
+ optparse (0.8.1)
+ os (1.1.4)
+ ostruct (0.6.3)
+ plist (3.7.2)
+ pstore (0.2.1)
+ public_suffix (7.0.5)
+ rake (13.4.2)
+ representable (3.3.1)
+ declarative (< 0.1.0)
+ trailblazer-option (>= 0.1.1, < 0.2.0)
+ uber (< 0.2.0)
+ retriable (3.8.0)
+ rexml (3.4.4)
+ rouge (3.28.0)
+ ruby2_keywords (0.0.5)
+ rubyzip (2.4.1)
+ security (0.1.5)
+ signet (0.22.0)
+ addressable (~> 2.8)
+ faraday (>= 0.17.5, < 3.a)
+ jwt (>= 1.5, < 4.0)
+ simctl (1.6.10)
+ CFPropertyList
+ naturally
+ terminal-notifier (2.0.0)
+ terminal-table (3.0.2)
+ unicode-display_width (>= 1.1.1, < 3)
+ trailblazer-option (0.1.2)
+ tty-cursor (0.7.1)
+ tty-screen (0.8.2)
+ tty-spinner (0.9.3)
+ tty-cursor (~> 0.7)
+ uber (0.1.0)
+ unicode-display_width (2.6.0)
+ word_wrap (1.0.0)
+ xcodeproj (1.28.1)
+ CFPropertyList (>= 2.3.3, < 4.0)
+ atomos (~> 0.1.3)
+ base64
+ claide (>= 1.0.2, < 2.0)
+ colored2 (~> 3.1)
+ nanaimo (~> 0.4.0)
+ nkf
+ rexml (>= 3.3.6, < 4.0)
+ xcpretty (0.4.1)
+ rouge (~> 3.28.0)
+ xcpretty-travis-formatter (1.0.1)
+ xcpretty (~> 0.2, >= 0.0.7)
+
+PLATFORMS
+ arm64-darwin-25
+ ruby
+
+DEPENDENCIES
+ fastlane (= 2.235.0)
+ multi_json (~> 1.15)
+
+CHECKSUMS
+ CFPropertyList (3.0.8) sha256=2c99d0d980536d3d7ab252f7bd59ac8be50fbdd1ff487c98c949bb66bb114261
+ abbrev (0.1.2) sha256=ad1b4eaaaed4cb722d5684d63949e4bde1d34f2a95e20db93aecfe7cbac74242
+ addressable (2.9.0) sha256=7fdf6ac3660f7f4e867a0838be3f6cf722ace541dd97767fa42bc6cfa980c7af
+ artifactory (3.0.17) sha256=3023d5c964c31674090d655a516f38ca75665c15084140c08b7f2841131af263
+ atomos (0.1.3) sha256=7d43b22f2454a36bace5532d30785b06de3711399cb1c6bf932573eda536789f
+ aws-eventstream (1.4.0) sha256=116bf85c436200d1060811e6f5d2d40c88f65448f2125bc77ffce5121e6e183b
+ aws-partitions (1.1292.0) sha256=c7fbe8b878640f12e24c975fd14c30401f4dd112416a2d21f834d3a823c8c4b8
+ aws-sdk-core (3.257.0) sha256=a92ad19c7ff668cec6688ee3200ee562da80647a3bbeed9fda02e8b0ca6492c6
+ aws-sdk-kms (1.132.0) sha256=094b0097bb3be9d5c1ac87e971ca7f5aebb746801dfb5f0f576480bf98dca69c
+ aws-sdk-s3 (1.233.1) sha256=cdfb5051266354ae7e93a1443ae3878cceca531d81a890cf8185458ec62919e9
+ aws-sigv4 (1.12.1) sha256=6973ff95cb0fd0dc58ba26e90e9510a2219525d07620c8babeb70ef831826c00
+ babosa (1.0.4) sha256=18dea450f595462ed7cb80595abd76b2e535db8c91b350f6c4b3d73986c5bc99
+ base64 (0.3.0) sha256=27337aeabad6ffae05c265c450490628ef3ebd4b67be58257393227588f5a97b
+ benchmark (0.5.0) sha256=465df122341aedcb81a2a24b4d3bd19b6c67c1530713fd533f3ff034e419236c
+ bigdecimal (4.1.3) sha256=61ebe1e5e559bdc3cc6f2c0ee7f427321fc838f59611c294356eb04d6e21cf66
+ claide (1.1.0) sha256=6d3c5c089dde904d96aa30e73306d0d4bd444b1accb9b3125ce14a3c0183f82e
+ colored (1.2) sha256=9d82b47ac589ce7f6cab64b1f194a2009e9fd00c326a5357321f44afab2c1d2c
+ colored2 (3.1.2) sha256=b13c2bd7eeae2cf7356a62501d398e72fde78780bd26aec6a979578293c28b4a
+ commander (4.6.0) sha256=7d1ddc3fccae60cc906b4131b916107e2ef0108858f485fdda30610c0f2913d9
+ csv (3.3.6) sha256=aba61e7e507a66f03d45cb1f3c4b6359861c3504038b422962875dce099e4456
+ declarative (0.0.20) sha256=8021dd6cb17ab2b61233c56903d3f5a259c5cf43c80ff332d447d395b17d9ff9
+ digest-crc (0.7.0) sha256=64adc23a26a241044cbe6732477ca1b3c281d79e2240bcff275a37a5a0d78c07
+ domain_name (0.6.20260921) sha256=b335949f69fae603fed0ae88c4e09a51e9d8e3ceef4c73a6cfde44eb5c8dee6d
+ dotenv (2.8.1) sha256=c5944793349ae03c432e1780a2ca929d60b88c7d14d52d630db0508c3a8a17d8
+ emoji_regex (3.2.3) sha256=ecd8be856b7691406c6bf3bb3a5e55d6ed683ffab98b4aa531bb90e1ddcc564b
+ excon (0.112.0) sha256=daf9ac3a4c2fc9aa48383a33da77ecb44fa395111e973084d5c52f6f214ae0f0
+ faraday (1.10.6) sha256=7ff4802a6b312876a2241b3e641ce0d5045e168dd871b422c35b505e5261ad4d
+ faraday-cookie_jar (0.0.8) sha256=0140605823f8cc63c7028fccee486aaed8e54835c360cffc1f7c8c07c4299dbb
+ faraday-em_http (1.0.0) sha256=7a3d4c7079789121054f57e08cd4ef7e40ad1549b63101f38c7093a9d6c59689
+ faraday-em_synchrony (1.0.1) sha256=bf3ce45dcf543088d319ab051f80985ea6d294930635b7a0b966563179f81750
+ faraday-excon (1.1.0) sha256=b055c842376734d7f74350fe8611542ae2000c5387348d9ba9708109d6e40940
+ faraday-httpclient (1.0.1) sha256=4c8ff1f0973ff835be8d043ef16aaf54f47f25b7578f6d916deee8399a04d33b
+ faraday-multipart (1.2.0) sha256=7d89a949693714176f612323ca13746a2ded204031a6ba528adee788694ef757
+ faraday-net_http (1.0.2) sha256=63992efea42c925a20818cf3c0830947948541fdcf345842755510d266e4c682
+ faraday-net_http_persistent (1.2.0) sha256=0b0cbc8f03dab943c3e1cc58d8b7beb142d9df068b39c718cd83e39260348335
+ faraday-patron (1.0.0) sha256=dc2cd7b340bb3cc8e36bcb9e6e7eff43d134b6d526d5f3429c7a7680ddd38fa7
+ faraday-rack (1.0.0) sha256=ef60ec969a2bb95b8dbf24400155aee64a00fc8ba6c6a4d3968562bcc92328c0
+ faraday-retry (1.0.4) sha256=dc659233777fabf96c69c2ffe56c0a5d2c102af90321a42cc6c90157bcd716aa
+ faraday_middleware (1.2.1) sha256=d45b78c8ee864c4783fbc276f845243d4a7918a67301c052647bacabec0529e9
+ fastimage (2.4.1) sha256=c64bebd46b6fd8943ab70c1e6e85ff728f970f2e48f92ecd249b6bc3a540ad20
+ fastlane (2.235.0) sha256=7de12cf4c4e242b68836aef859ba8e6b25bb1db7f6c8e2d705b024fb16a2ed65
+ fastlane-sirp (1.1.0) sha256=10bc94f9682efd8e1badfb31452a76dd8981f1f3a33717c765fde6d75b54d847
+ gh_inspector (1.1.3) sha256=04cca7171b87164e053aa43147971d3b7f500fcb58177698886b48a9fc4a1939
+ google-apis-androidpublisher_v3 (0.109.0) sha256=b4fdbb8af4a0d235e86bd173bd51eccd10effdc5de067ab10c1c0d22048a2a56
+ google-apis-core (0.18.0) sha256=96b057816feeeab448139ed5b5c78eab7fc2a9d8958f0fbc8217dedffad054ee
+ google-apis-iamcredentials_v1 (0.28.0) sha256=0a92ffe6cc39c569554af2a77a25dfc61519ed8bbb64ab04cffdd352dc5ef106
+ google-apis-playcustomapp_v1 (0.18.0) sha256=44b277b9dee4a59ac5e9d98be1485edc5e382d2f9d73c79ae8908a455786a254
+ google-apis-storage_v1 (0.68.0) sha256=43387d0466b4fc569fca05fdef5098ecf135ce173626b5c5f45bfe5e8db959fc
+ google-cloud-core (1.9.0) sha256=ab55409f51488e8deefb6edcc1ce4771dfb5da2fe7b3bc075709a030c2b682a4
+ google-cloud-env (2.2.2) sha256=94bed40e05a67e9468ce1cb38389fba9a90aa8fc62fc9e173204c1dca59e21e7
+ google-cloud-errors (1.7.0) sha256=6e682f42d89aae08689f36f28495de629d756da076bafff0003bac7f8042ebb3
+ google-cloud-storage (1.62.1) sha256=10dbf2ebda3af1b4b841335364cb889827c6cb17bf6c9f8bc5313b6bef7a4d3c
+ google-logging-utils (0.2.0) sha256=675462b4ea5affa825a3442694ca2d75d0069455a1d0956127207498fca3df7b
+ googleauth (1.17.4) sha256=acc2cac2a6011048f149c922cbc6c9675719e165aa5000c7fa3876c480fe9ae3
+ highline (2.0.3) sha256=2ddd5c127d4692721486f91737307236fe005352d12a4202e26c48614f719479
+ http-cookie (1.0.8) sha256=b14fe0445cf24bf9ae098633e9b8d42e4c07c3c1f700672b09fbfe32ffd41aa6
+ httpclient (2.9.0) sha256=4b645958e494b2f86c2f8a2f304c959baa273a310e77a2931ddb986d83e498c8
+ jmespath (1.6.2) sha256=238d774a58723d6c090494c8879b5e9918c19485f7e840f2c1c7532cf84ebcb1
+ json (2.21.2) sha256=1f1d3b7cf2b3ba1a69beca0bb6db13d5438b80bff3cd54cdaaa620b9b07c1c6a
+ jwt (3.3.0) sha256=44cc34fbc341c148233cf66641522b4bce0dfb1ce55e8073f05c56f44f4ee546
+ logger (1.7.0) sha256=196edec7cc44b66cfb40f9755ce11b392f21f7967696af15d274dde7edff0203
+ mini_magick (4.13.2) sha256=71d6258e0e8a3d04a9a0a09784d5d857b403a198a51dd4f882510435eb95ddd9
+ mini_mime (1.1.5) sha256=8681b7e2e4215f2a159f9400b5816d85e9d8c6c6b491e96a12797e798f8bccef
+ multi_json (1.21.2) sha256=245531eaf54bdba57aba515ff08b4503505c5e09911daa89c5a561e2f85bea8e
+ multipart-post (2.4.1) sha256=9872d03a8e552020ca096adadbf5e3cb1cd1cdd6acd3c161136b8a5737cdb4a8
+ mutex_m (0.3.0) sha256=cfcb04ac16b69c4813777022fdceda24e9f798e48092a2b817eb4c0a782b0751
+ nanaimo (0.4.0) sha256=faf069551bab17f15169c1f74a1c73c220657e71b6e900919897a10d991d0723
+ naturally (2.3.0) sha256=459923cf76c2e6613048301742363200c3c7e4904c324097d54a67401e179e01
+ nkf (0.3.0) sha256=357a8dbeba38b727b75930f665146546076a394a1c243faf634ff176e3588895
+ optparse (0.8.1) sha256=42bea10d53907ccff4f080a69991441d611fbf8733b60ed1ce9ee365ce03bd1a
+ os (1.1.4) sha256=57816d6a334e7bd6aed048f4b0308226c5fb027433b67d90a9ab435f35108d3f
+ ostruct (0.6.3) sha256=95a2ed4a4bd1d190784e666b47b2d3f078e4a9efda2fccf18f84ddc6538ed912
+ plist (3.7.2) sha256=d37a4527cc1116064393df4b40e1dbbc94c65fa9ca2eec52edf9a13616718a42
+ pstore (0.2.1) sha256=03904d0f2c66579e96d1e6704cdabc0c88df7ea8ed8782d9f3569f6f6c702c1a
+ public_suffix (7.0.5) sha256=1a8bb08f1bbea19228d3bed6e5ed908d1cb4f7c2726d18bd9cadf60bc676f623
+ rake (13.4.2) sha256=cb825b2bd5f1f8e91ca37bddb4b9aaf345551b4731da62949be002fa89283701
+ representable (3.3.1) sha256=8a5fc8eef99f598147eee0390f606386268b429a840419fcfd3f7a8ccc40f566
+ retriable (3.8.0) sha256=9f2f1b0207594c7817f17f671587b8ec7587387ac6cebda6c941a802bb98a8e5
+ rexml (3.4.4) sha256=19e0a2c3425dfbf2d4fc1189747bdb2f849b6c5e74180401b15734bc97b5d142
+ rouge (3.28.0) sha256=0d6de482c7624000d92697772ab14e48dca35629f8ddf3f4b21c99183fd70e20
+ ruby2_keywords (0.0.5) sha256=ffd13740c573b7301cf7a2e61fc857b2a8e3d3aff32545d6f8300d8bae10e3ef
+ rubyzip (2.4.1) sha256=8577c88edc1fde8935eb91064c5cb1aef9ad5494b940cf19c775ee833e075615
+ security (0.1.5) sha256=3a977a0eca7706e804c96db0dd9619e0a94969fe3aac9680fcfc2bf9b8a833b7
+ signet (0.22.0) sha256=b76d495ccb07ad35dbc89f3e920665a9d8ed717141955034005d7843dcfe4780
+ simctl (1.6.10) sha256=b99077f4d13ad81eace9f86bf5ba4df1b0b893a4d1b368bd3ed59b5b27f9236b
+ terminal-notifier (2.0.0) sha256=7a0d2b2212ab9835c07f4b2e22a94cff64149dba1eed203c04835f7991078cea
+ terminal-table (3.0.2) sha256=f951b6af5f3e00203fb290a669e0a85c5dd5b051b3b023392ccfd67ba5abae91
+ trailblazer-option (0.1.2) sha256=20e4f12ea4e1f718c8007e7944ca21a329eee4eed9e0fa5dde6e8ad8ac4344a3
+ tty-cursor (0.7.1) sha256=79534185e6a777888d88628b14b6a1fdf5154a603f285f80b1753e1908e0bf48
+ tty-screen (0.8.2) sha256=c090652115beae764336c28802d633f204fb84da93c6a968aa5d8e319e819b50
+ tty-spinner (0.9.3) sha256=0e036f047b4ffb61f2aa45f5a770ec00b4d04130531558a94bfc5b192b570542
+ uber (0.1.0) sha256=5beeb407ff807b5db994f82fa9ee07cfceaa561dad8af20be880bc67eba935dc
+ unicode-display_width (2.6.0) sha256=12279874bba6d5e4d2728cef814b19197dbb10d7a7837a869bab65da943b7f5a
+ word_wrap (1.0.0) sha256=f556d4224c812e371000f12a6ee8102e0daa724a314c3f246afaad76d82accc7
+ xcodeproj (1.28.1) sha256=6f12670f00739d9817ca27ac89d6ef01cc86050e22a0bc08a3131487e5b5cddc
+ xcpretty (0.4.1) sha256=b14c50e721f6589ee3d6f5353e2c2cfcd8541fa1ea16d6c602807dd7327f3892
+ xcpretty-travis-formatter (1.0.1) sha256=aacc332f17cb7b2cba222994e2adc74223db88724fe76341483ad3098e232f93
+
+BUNDLED WITH
+ 4.0.11
diff --git a/app/build.gradle.kts b/app/build.gradle.kts
index bac4ee9..740899c 100644
--- a/app/build.gradle.kts
+++ b/app/build.gradle.kts
@@ -27,8 +27,8 @@ android {
applicationId = "me.maxistar.voiceinbox"
minSdk = 24
targetSdk = 36
- versionCode = 7
- versionName = "0.4.0"
+ versionCode = 8
+ versionName = "0.5.0"
ndk {
abiFilters += "arm64-v8a"
}
diff --git a/app/src/androidTest/java/me/maxistar/voiceinbox/AndroidBackupPolicyInstrumentedTest.kt b/app/src/androidTest/java/me/maxistar/voiceinbox/AndroidBackupPolicyInstrumentedTest.kt
new file mode 100644
index 0000000..7d1c6a1
--- /dev/null
+++ b/app/src/androidTest/java/me/maxistar/voiceinbox/AndroidBackupPolicyInstrumentedTest.kt
@@ -0,0 +1,18 @@
+package me.maxistar.voiceinbox
+
+import android.content.pm.ApplicationInfo
+import androidx.test.ext.junit.runners.AndroidJUnit4
+import androidx.test.platform.app.InstrumentationRegistry
+import org.junit.Assert.assertEquals
+import org.junit.Test
+import org.junit.runner.RunWith
+
+@RunWith(AndroidJUnit4::class)
+class AndroidBackupPolicyInstrumentedTest {
+ @Test
+ fun installedApplicationDisablesAndroidSystemBackup() {
+ val appInfo = InstrumentationRegistry.getInstrumentation().targetContext.applicationInfo
+
+ assertEquals(0, appInfo.flags and ApplicationInfo.FLAG_ALLOW_BACKUP)
+ }
+}
diff --git a/app/src/androidTest/java/me/maxistar/voiceinbox/AndroidLocalizationInstrumentedTest.kt b/app/src/androidTest/java/me/maxistar/voiceinbox/AndroidLocalizationInstrumentedTest.kt
new file mode 100644
index 0000000..80f52e1
--- /dev/null
+++ b/app/src/androidTest/java/me/maxistar/voiceinbox/AndroidLocalizationInstrumentedTest.kt
@@ -0,0 +1,42 @@
+package me.maxistar.voiceinbox
+
+import android.content.Context
+import android.content.res.Configuration
+import androidx.test.ext.junit.runners.AndroidJUnit4
+import androidx.test.platform.app.InstrumentationRegistry
+import me.maxistar.voiceinbox.core.TaskText
+import me.maxistar.voiceinbox.core.TaskTextKey
+import org.junit.Assert.assertEquals
+import org.junit.Test
+import org.junit.runner.RunWith
+import java.util.Locale
+
+@RunWith(AndroidJUnit4::class)
+class AndroidLocalizationInstrumentedTest {
+ @Test
+ fun taskTextResolverAndPluralResourcesFollowTheRequestedLocale() {
+ val english = localizedContext(Locale.US)
+ val russian = localizedContext(Locale("ru", "RU"))
+ val german = localizedContext(Locale("de", "DE"))
+ val taskTitle = TaskText(TaskTextKey.INSTALL_SPEECH_MODEL, "Install Speech Model")
+
+ assertEquals("Install Speech Model", AndroidTaskTextResolver.resolve(english.resources, taskTitle))
+ assertEquals("Установить речевую модель", AndroidTaskTextResolver.resolve(russian.resources, taskTitle))
+ assertEquals("Sprachmodell installieren", AndroidTaskTextResolver.resolve(german.resources, taskTitle))
+ assertEquals("Клавиатура Voice Inbox", russian.getString(R.string.voice_keyboard_name))
+ assertEquals("Voice-Inbox-Tastatur", german.getString(R.string.voice_keyboard_name))
+ assertEquals("Sprachmodell aus Ordner installieren", german.getString(R.string.settings_model_import_title))
+ assertEquals("Расшифровать все (1 файл)", russian.resources.getQuantityString(R.plurals.transcribe_all_count, 1, 1))
+ assertEquals("Расшифровать все (2 файла)", russian.resources.getQuantityString(R.plurals.transcribe_all_count, 2, 2))
+ assertEquals("Расшифровать все (5 файлов)", russian.resources.getQuantityString(R.plurals.transcribe_all_count, 5, 5))
+ assertEquals("Alle transkribieren (1 Datei)", german.resources.getQuantityString(R.plurals.transcribe_all_count, 1, 1))
+ assertEquals("Alle transkribieren (2 Dateien)", german.resources.getQuantityString(R.plurals.transcribe_all_count, 2, 2))
+ assertEquals("Alle transkribieren (5 Dateien)", german.resources.getQuantityString(R.plurals.transcribe_all_count, 5, 5))
+ }
+
+ private fun localizedContext(locale: Locale): Context {
+ val context = InstrumentationRegistry.getInstrumentation().targetContext
+ val configuration = Configuration(context.resources.configuration).apply { setLocale(locale) }
+ return context.createConfigurationContext(configuration)
+ }
+}
diff --git a/app/src/androidTest/java/me/maxistar/voiceinbox/MainActivityInstrumentedTest.kt b/app/src/androidTest/java/me/maxistar/voiceinbox/MainActivityInstrumentedTest.kt
index ad40f18..dc321a4 100644
--- a/app/src/androidTest/java/me/maxistar/voiceinbox/MainActivityInstrumentedTest.kt
+++ b/app/src/androidTest/java/me/maxistar/voiceinbox/MainActivityInstrumentedTest.kt
@@ -202,10 +202,10 @@ class MainActivityInstrumentedTest {
ActivityScenario.launch(MainActivity::class.java).use { scenario ->
scenario.onActivity { it.findViewById(R.id.processedTab).performClick() }
awaitActivity(scenario) { activity ->
- val rows = audioRows(activity).associateBy { it.task.title }
+ val rows = audioRows(activity).associateBy { it.task.title.fallback }
rows.keys == setOf("failed.ogg", "silent.ogg", "done.ogg") &&
rows.getValue("failed.ogg").task.actions.any { it.kind == TaskActionKind.RETRY_TRANSCRIPTION } &&
- rows.getValue("silent.ogg").task.badge == "No speech" &&
+ rows.getValue("silent.ogg").task.badge.fallback == "No speech" &&
rows.getValue("done.ogg").task.actions.any { it.kind == TaskActionKind.SHOW_TEXT }
}
}
@@ -269,7 +269,7 @@ class MainActivityInstrumentedTest {
assertEquals(android.view.View.VISIBLE, action.visibility)
assertTrue(action.isEnabled)
assertEquals(
- AndroidFolderSyncPresentation.ACCESSIBILITY_REFRESH,
+ activity.getString(R.string.refresh_state_idle),
action.contentDescription,
)
}
@@ -282,7 +282,7 @@ class MainActivityInstrumentedTest {
awaitActivity(scenario) { activity -> !refreshAction(activity).isEnabled }
scenario.onActivity { activity ->
assertEquals(
- AndroidFolderSyncPresentation.ACCESSIBILITY_REFRESH,
+ activity.getString(R.string.refresh_state_idle),
refreshAction(activity).contentDescription,
)
}
@@ -320,7 +320,7 @@ class MainActivityInstrumentedTest {
val action = refreshAction(activity)
assertFalse(action.isEnabled)
assertEquals(
- AndroidFolderSyncPresentation.ACCESSIBILITY_REFRESHING,
+ activity.getString(R.string.refresh_state_refreshing),
action.contentDescription,
)
val before = stateHost(activity).currentInput.folderSync
@@ -430,7 +430,7 @@ class MainActivityInstrumentedTest {
assertTrue(items.take(hintIndex).all { it is TaskListDisplayItem.Setup })
val hint = items[hintIndex] as TaskListDisplayItem.OnboardingHint
assertEquals(TaskActionKind.DOWNLOAD_MODEL, hint.presentation.action?.kind)
- assertTrue(hint.presentation.downloadDisclosure?.contains("downloads") == true)
+ assertEquals(R.string.onboarding_download_disclosure, hint.presentation.downloadDisclosureRes)
}
scenario.onActivity { activity ->
@@ -454,7 +454,7 @@ class MainActivityInstrumentedTest {
val hint = items.filterIsInstance().single()
assertTrue(model.task.progress != null)
assertTrue(model.task.actions.any { it.kind == TaskActionKind.CANCEL_MODEL_DOWNLOAD })
- assertEquals("Installing speech model…", hint.presentation.action?.label)
+ assertEquals(R.string.onboarding_action_installing, hint.presentation.action?.labelRes)
}
scenario.onActivity { activity ->
@@ -694,7 +694,7 @@ class MainActivityInstrumentedTest {
private fun audioRows(activity: MainActivity): List =
displayItems(activity).filterIsInstance()
- private fun audioTitles(activity: MainActivity): List = audioRows(activity).map { it.task.title }
+ private fun audioTitles(activity: MainActivity): List = audioRows(activity).map { it.task.title.fallback }
private fun descendants(root: android.view.ViewGroup): Sequence = sequence {
for (index in 0 until root.childCount) {
diff --git a/app/src/androidTest/java/me/maxistar/voiceinbox/TranscriptReviewInstrumentedTest.kt b/app/src/androidTest/java/me/maxistar/voiceinbox/TranscriptReviewInstrumentedTest.kt
index 3750080..d6eef90 100644
--- a/app/src/androidTest/java/me/maxistar/voiceinbox/TranscriptReviewInstrumentedTest.kt
+++ b/app/src/androidTest/java/me/maxistar/voiceinbox/TranscriptReviewInstrumentedTest.kt
@@ -41,4 +41,18 @@ class TranscriptReviewInstrumentedTest {
transcript.contentDescription,
)
}
+
+ @Test
+ fun copyActionPreparesExactTranscriptAndHasLocalizedAccessibilityText() {
+ val context = InstrumentationRegistry.getInstrumentation().targetContext
+ val review = AndroidTranscriptReview(9, "voice.ogg", " exact transcript\n")
+
+ val copiedText = transcriptClipboardClip(review)
+ .getItemAt(0)
+ ?.coerceToText(context)
+ ?.toString()
+ assertEquals(review.text, copiedText)
+ assertTrue(context.getString(R.string.transcript_copy_accessibility).isNotBlank())
+ assertTrue(context.getString(R.string.transcript_copied).isNotBlank())
+ }
}
diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml
index 3ec6f3e..eb92350 100644
--- a/app/src/main/AndroidManifest.xml
+++ b/app/src/main/AndroidManifest.xml
@@ -9,9 +9,7 @@
= emptyList(),
val action: AndroidOnboardingHintAction? = null,
) {
@@ -122,24 +122,24 @@ object AndroidOnboardingHintPresenter {
val steps = listOf(
AndroidOnboardingChecklistStep(
kind = AndroidOnboardingStepKind.MODEL,
- label = "Install speech model",
+ labelRes = R.string.onboarding_step_model,
complete = model.state == ModelSetupSnapshotState.READY,
),
AndroidOnboardingChecklistStep(
kind = AndroidOnboardingStepKind.OUTPUT,
- label = "Configure automatic transcript export · Optional",
+ labelRes = R.string.onboarding_step_output,
complete = output.state == OutputSetupSnapshotState.READY,
optional = true,
),
AndroidOnboardingChecklistStep(
kind = AndroidOnboardingStepKind.FOLDER,
- label = "Select audio folder · Optional",
+ labelRes = R.string.onboarding_step_folder,
complete = folder.state == FolderSetupSnapshotState.READY,
optional = true,
),
AndroidOnboardingChecklistStep(
kind = AndroidOnboardingStepKind.KEYBOARD,
- label = "Enable voice keyboard · Optional",
+ labelRes = R.string.onboarding_step_keyboard,
complete = keyboardStatus != AndroidVoiceKeyboardStatus.DISABLED,
optional = true,
),
@@ -148,7 +148,7 @@ object AndroidOnboardingHintPresenter {
return AndroidOnboardingHintPresentation(
visible = true,
steps = steps,
- downloadDisclosure = "Start setup downloads the speech model to this device."
+ downloadDisclosureRes = R.string.onboarding_download_disclosure
.takeIf {
action.enabled && action.kind in setOf(
TaskActionKind.DOWNLOAD_MODEL,
@@ -194,12 +194,12 @@ object AndroidOnboardingHintPresenter {
keyboardStatus: AndroidVoiceKeyboardStatus,
): AndroidOnboardingHintAction = when {
model.state == ModelSetupSnapshotState.INSTALLING -> AndroidOnboardingHintAction(
- label = "Installing speech model…",
+ labelRes = R.string.onboarding_action_installing,
enabled = false,
kind = TaskActionKind.DOWNLOAD_MODEL,
)
model.state != ModelSetupSnapshotState.READY && model.downloadAvailable -> AndroidOnboardingHintAction(
- label = if (model.state == ModelSetupSnapshotState.INVALID) "Retry setup" else "Start setup",
+ labelRes = if (model.state == ModelSetupSnapshotState.INVALID) R.string.onboarding_action_retry else R.string.onboarding_action_start,
enabled = true,
kind = if (model.state == ModelSetupSnapshotState.INVALID) {
TaskActionKind.RETRY_MODEL_DOWNLOAD
@@ -208,27 +208,27 @@ object AndroidOnboardingHintPresenter {
},
)
model.state != ModelSetupSnapshotState.READY -> AndroidOnboardingHintAction(
- label = "Install model from folder",
+ labelRes = R.string.onboarding_action_import_model,
enabled = true,
kind = TaskActionKind.IMPORT_MODEL,
)
output.state != OutputSetupSnapshotState.READY -> AndroidOnboardingHintAction(
- label = "Choose export document",
+ labelRes = R.string.onboarding_action_select_output,
enabled = true,
kind = TaskActionKind.SELECT_OUTPUT,
)
folder.state != FolderSetupSnapshotState.READY -> AndroidOnboardingHintAction(
- label = "Select audio folder",
+ labelRes = R.string.onboarding_action_select_folder,
enabled = true,
kind = TaskActionKind.SELECT_FOLDER,
)
keyboardStatus == AndroidVoiceKeyboardStatus.DISABLED -> AndroidOnboardingHintAction(
- label = "Enable voice keyboard",
+ labelRes = R.string.onboarding_action_enable_keyboard,
enabled = true,
kind = TaskActionKind.ENABLE_VOICE_KEYBOARD,
)
else -> AndroidOnboardingHintAction(
- label = "Ready to transcribe",
+ labelRes = R.string.onboarding_action_ready,
enabled = false,
kind = TaskActionKind.IMPORT_AUDIO,
)
diff --git a/app/src/main/java/me/maxistar/voiceinbox/AndroidTaskTextResolver.kt b/app/src/main/java/me/maxistar/voiceinbox/AndroidTaskTextResolver.kt
new file mode 100644
index 0000000..e5344b7
--- /dev/null
+++ b/app/src/main/java/me/maxistar/voiceinbox/AndroidTaskTextResolver.kt
@@ -0,0 +1,56 @@
+package me.maxistar.voiceinbox
+
+import android.content.res.Resources
+import me.maxistar.voiceinbox.core.TaskText
+import me.maxistar.voiceinbox.core.TaskTextKey
+
+/** Resolves locale-neutral shared task text at the Android boundary. */
+internal object AndroidTaskTextResolver {
+ fun resolve(resources: Resources, value: TaskText): String = when (value.key) {
+ TaskTextKey.OPAQUE -> value.fallback
+ TaskTextKey.INSTALL_SPEECH_MODEL -> resources.getString(R.string.task_install_speech_model)
+ TaskTextKey.AUTOMATIC_TRANSCRIPT_EXPORT -> resources.getString(R.string.task_automatic_transcript_export)
+ TaskTextKey.OUTPUT_OPTIONAL_DETAIL -> resources.getString(R.string.task_output_optional_detail)
+ TaskTextKey.REFRESH_AUDIO_FOLDER -> resources.getString(R.string.task_refresh_audio_folder)
+ TaskTextKey.RESTORE_AUDIO_FOLDER_ACCESS -> resources.getString(R.string.task_restore_audio_folder_access)
+ TaskTextKey.REQUIRED -> resources.getString(R.string.task_required)
+ TaskTextKey.OPTIONAL -> resources.getString(R.string.task_optional)
+ TaskTextKey.INSTALLING -> resources.getString(R.string.task_installing)
+ TaskTextKey.SCANNING -> resources.getString(R.string.task_scanning)
+ TaskTextKey.NEEDS_ATTENTION -> resources.getString(R.string.task_needs_attention)
+ TaskTextKey.NEW -> resources.getString(R.string.new_label)
+ TaskTextKey.PROCESSING -> resources.getString(R.string.task_processing)
+ TaskTextKey.PROCESSED -> resources.getString(R.string.processed)
+ TaskTextKey.FAILED -> resources.getString(R.string.task_failed)
+ TaskTextKey.NO_SPEECH -> resources.getString(R.string.task_no_speech)
+ TaskTextKey.CANCEL -> resources.getString(R.string.voice_keyboard_cancel)
+ TaskTextKey.RETRY_DOWNLOAD -> resources.getString(R.string.task_retry_download)
+ TaskTextKey.DOWNLOAD -> resources.getString(R.string.task_download)
+ TaskTextKey.CREATE_NEW -> resources.getString(R.string.settings_output_create_new)
+ TaskTextKey.CHOOSE_EXISTING -> resources.getString(R.string.settings_output_choose_existing)
+ TaskTextKey.HIDE -> resources.getString(R.string.task_hide)
+ TaskTextKey.SELECT_FOLDER -> resources.getString(R.string.task_select_folder)
+ TaskTextKey.RETRY -> resources.getString(R.string.task_retry)
+ TaskTextKey.TRANSCRIBE -> resources.getString(R.string.task_transcribe)
+ TaskTextKey.SHOW_TEXT -> resources.getString(R.string.task_show_text)
+ TaskTextKey.STOP -> resources.getString(R.string.voice_keyboard_stop)
+ TaskTextKey.PLAY -> resources.getString(R.string.task_play)
+ TaskTextKey.IMPORT_AUDIO_FILES -> resources.getString(R.string.task_import_audio_files)
+ TaskTextKey.SELECT_AUDIO_FOLDER -> resources.getString(R.string.select_audio_folder)
+ TaskTextKey.NO_NEW_TASKS -> resources.getString(R.string.task_no_new_tasks)
+ TaskTextKey.NO_PROCESSED_AUDIO -> resources.getString(R.string.task_no_processed_audio)
+ TaskTextKey.NO_TASKS -> resources.getString(R.string.task_no_tasks)
+ TaskTextKey.INSTALLING_MODEL -> resources.getString(R.string.task_installing_model)
+ TaskTextKey.SCANNING_AUDIO_FOLDER -> resources.getString(R.string.task_scanning_audio_folder)
+ TaskTextKey.PREPARING_SPEECH_MODEL -> resources.getString(R.string.task_preparing_speech_model)
+ TaskTextKey.CHOOSE_MODEL -> resources.getString(R.string.task_choose_model)
+ TaskTextKey.SELECTED_MODEL_DETAIL -> resources.getString(
+ R.string.task_selected_model_detail,
+ value.arguments[0],
+ value.arguments[1],
+ value.arguments[2],
+ value.arguments[3],
+ value.arguments[4],
+ )
+ }
+}
diff --git a/app/src/main/java/me/maxistar/voiceinbox/AndroidTranscriptReview.kt b/app/src/main/java/me/maxistar/voiceinbox/AndroidTranscriptReview.kt
index c3ea3e7..860f39d 100644
--- a/app/src/main/java/me/maxistar/voiceinbox/AndroidTranscriptReview.kt
+++ b/app/src/main/java/me/maxistar/voiceinbox/AndroidTranscriptReview.kt
@@ -1,6 +1,9 @@
package me.maxistar.voiceinbox
import android.content.Intent
+import android.content.ClipData
+import android.content.ClipboardManager
+import android.content.Context
import me.maxistar.voiceinbox.core.AudioCatalogEntry
internal data class AndroidTranscriptReview(
@@ -32,3 +35,11 @@ internal object AndroidTranscriptShareIntent {
fun chooser(review: AndroidTranscriptReview): Intent =
Intent.createChooser(create(review), review.filename)
}
+
+internal fun copyTranscriptToClipboard(context: Context, review: AndroidTranscriptReview) {
+ val clipboard = context.getSystemService(ClipboardManager::class.java)
+ clipboard.setPrimaryClip(transcriptClipboardClip(review))
+}
+
+internal fun transcriptClipboardClip(review: AndroidTranscriptReview): ClipData =
+ ClipData.newPlainText(review.filename, review.text)
diff --git a/app/src/main/java/me/maxistar/voiceinbox/AndroidVoiceKeyboardDiscovery.kt b/app/src/main/java/me/maxistar/voiceinbox/AndroidVoiceKeyboardDiscovery.kt
index cccf3bb..54cc1d0 100644
--- a/app/src/main/java/me/maxistar/voiceinbox/AndroidVoiceKeyboardDiscovery.kt
+++ b/app/src/main/java/me/maxistar/voiceinbox/AndroidVoiceKeyboardDiscovery.kt
@@ -147,11 +147,11 @@ class AndroidVoiceKeyboardDiscoveryStore(
data class AndroidVoiceKeyboardDiscoveryPresentation(
val visible: Boolean = false,
- val title: String = "Dictate in any app",
- val explanation: String = "Use Voice Inbox as a compact voice keyboard. Recognition runs locally after model setup.",
- val setupLabel: String = "Set up keyboard",
+ val titleRes: Int = R.string.voice_keyboard_discovery_title,
+ val explanationRes: Int = R.string.voice_keyboard_discovery_explanation,
+ val setupLabelRes: Int = R.string.voice_keyboard_discovery_setup,
val setupAction: TaskActionKind = TaskActionKind.ENABLE_VOICE_KEYBOARD,
- val documentationLabel: String = "Learn more",
+ val documentationLabelRes: Int = R.string.voice_keyboard_discovery_documentation,
) {
companion object {
val HIDDEN = AndroidVoiceKeyboardDiscoveryPresentation()
diff --git a/app/src/main/java/me/maxistar/voiceinbox/AudioImportCoordinator.kt b/app/src/main/java/me/maxistar/voiceinbox/AudioImportCoordinator.kt
index ea8b828..8a431f4 100644
--- a/app/src/main/java/me/maxistar/voiceinbox/AudioImportCoordinator.kt
+++ b/app/src/main/java/me/maxistar/voiceinbox/AudioImportCoordinator.kt
@@ -1,5 +1,6 @@
package me.maxistar.voiceinbox
+import android.content.res.Resources
import android.content.ContentResolver
import android.content.Context
import android.content.Intent
@@ -61,6 +62,25 @@ data class AudioImportSummary(
}
if (outcomes.isEmpty()) append(" • no audio received")
}
+
+ fun localizedMessage(resources: Resources): String = buildString {
+ append(resources.getString(R.string.audio_import_summary, importedCount))
+ if (duplicateCount > 0) append(" • ${resources.getString(R.string.audio_import_duplicates, duplicateCount)}")
+ if (rejectedCount == 1) {
+ val rejection = outcomes.filterIsInstance().single()
+ append(" • ${resources.getString(R.string.audio_import_rejected_reason, localizedRejectionReason(resources, rejection.reason))}")
+ } else if (rejectedCount > 1) {
+ append(" • ${resources.getString(R.string.audio_import_rejected_count, rejectedCount)}")
+ }
+ if (outcomes.isEmpty()) append(" • ${resources.getString(R.string.audio_import_none_received)}")
+ }
+
+ private fun localizedRejectionReason(resources: Resources, reason: String): String = when (reason) {
+ "Audio file is empty" -> resources.getString(R.string.audio_import_empty_file)
+ "Unsupported audio format" -> resources.getString(R.string.audio_import_unsupported_format)
+ "Audio import failed" -> resources.getString(R.string.audio_import_failed)
+ else -> reason
+ }
}
interface AudioImportStorage {
diff --git a/app/src/main/java/me/maxistar/voiceinbox/MainActivity.kt b/app/src/main/java/me/maxistar/voiceinbox/MainActivity.kt
index dbf75ed..8cc946b 100644
--- a/app/src/main/java/me/maxistar/voiceinbox/MainActivity.kt
+++ b/app/src/main/java/me/maxistar/voiceinbox/MainActivity.kt
@@ -86,7 +86,7 @@ class MainActivity : AppCompatActivity(), StartupProcessingDialogFragment.Listen
private var currentSessionTranscriptionWorkId: UUID? = null
private var currentSessionObservedActiveTranscription = false
private val transcriptionHandoff = AndroidTranscriptionHandoffCoordinator()
- private var modelMessage = "Checking speech model"
+ private var modelMessage = ""
private var modelDownloadAvailable = false
private var modelDownloadProgress: Int? = null
private var modelInstallCanCancel = false
@@ -448,7 +448,7 @@ class MainActivity : AppCompatActivity(), StartupProcessingDialogFragment.Listen
PersistedSelectionAccessState.TEMPORARILY_UNAVAILABLE -> {
outputAccessReady = false
outputAccessError = result.error?.message
- ?: "Output file is temporarily unavailable"
+ ?: getString(R.string.error_output_temporarily_unavailable)
startupCoordinator.setOutputReady(false)
outputDisplayName = null
}
@@ -458,7 +458,7 @@ class MainActivity : AppCompatActivity(), StartupProcessingDialogFragment.Listen
outputAccessReady = false
outputDisplayName = null
outputAccessError = result.error?.message
- ?: "Output file access was revoked; select it again"
+ ?: getString(R.string.error_output_access_revoked)
startupCoordinator.setOutputReady(false)
}
}
@@ -530,9 +530,9 @@ class MainActivity : AppCompatActivity(), StartupProcessingDialogFragment.Listen
}
}
PersistedSelectionAccessState.TEMPORARILY_UNAVAILABLE -> {
- folderAccessReady = false
- folderAccessError = result.error?.message
- ?: "Audio folder is temporarily unavailable"
+ folderAccessReady = false
+ folderAccessError = result.error?.message
+ ?: getString(R.string.error_folder_temporarily_unavailable)
startupCoordinator.setFolderReady(false)
folderDisplayName = null
taskStateHost.folderSyncCoordinator.fail(folderSyncGeneration)
@@ -542,8 +542,8 @@ class MainActivity : AppCompatActivity(), StartupProcessingDialogFragment.Listen
folderUri = null
folderAccessReady = false
folderDisplayName = null
- folderAccessError = result.error?.message
- ?: "Audio folder access was revoked; select it again"
+ folderAccessError = result.error?.message
+ ?: getString(R.string.error_folder_access_revoked)
pendingFolderSyncGeneration = null
startupCoordinator.setFolderReady(false)
taskStateHost.folderSyncCoordinator.fail(folderSyncGeneration)
@@ -614,7 +614,7 @@ class MainActivity : AppCompatActivity(), StartupProcessingDialogFragment.Listen
activityDestroyed ||
validationGeneration != outputValidationGeneration
) return@runOnUiThread
- outputAccessError = it.message ?: "Output file is not writable"
+ outputAccessError = it.message ?: getString(R.string.error_output_not_writable)
outputPresentationKnown = true
publishTaskState()
updateControls()
@@ -677,7 +677,7 @@ class MainActivity : AppCompatActivity(), StartupProcessingDialogFragment.Listen
) return@runOnUiThread
folderChecking = false
folderPresentationKnown = true
- folderAccessError = it.message ?: "Audio folder is not readable"
+ folderAccessError = it.message ?: getString(R.string.error_audio_folder_not_readable)
taskStateHost.folderSyncCoordinator.fail(folderSyncGeneration)
publishTaskState()
updateControls()
@@ -702,7 +702,7 @@ class MainActivity : AppCompatActivity(), StartupProcessingDialogFragment.Listen
modelReady = false
modelSetupState = ModelSetupSnapshotState.INVALID
setModelUi(
- "The selected model folder cannot be accessed after the picker closes",
+ getString(R.string.error_model_folder_access),
canDownload = true,
)
updateControls()
@@ -727,7 +727,7 @@ class MainActivity : AppCompatActivity(), StartupProcessingDialogFragment.Listen
modelReady = false
modelSetupState = ModelSetupSnapshotState.INVALID
setModelUi(
- error.message ?: "The selected model folder is not readable",
+ error.message ?: getString(R.string.error_model_folder_not_readable),
canDownload = true,
)
updateControls()
@@ -748,14 +748,14 @@ class MainActivity : AppCompatActivity(), StartupProcessingDialogFragment.Listen
val size = SpeechModelRepository.formatBytes(descriptor.approximateDownloadBytes)
val maturity = descriptor.maturity.name.lowercase().replaceFirstChar(Char::uppercase)
val dialog = MaterialAlertDialogBuilder(this)
- .setTitle("Install ${descriptor.displayName}?")
- .setMessage("$maturity model · ${descriptor.languages.summary} · approximately $size")
+ .setTitle(getString(R.string.confirm_install_model, descriptor.displayName))
+ .setMessage(getString(R.string.confirm_install_model_detail, maturity, descriptor.languages.summary, size))
.setNegativeButton(android.R.string.cancel) { _, _ ->
pendingModelPackageUri = null
pendingModelPackageCatalogId = null
SpeechModelImportPermission.releaseOwnedIfUnused(this)
}
- .setPositiveButton("Install", null)
+ .setPositiveButton(R.string.model_install, null)
.create()
dialog.setOnShowListener {
val confirm = dialog.getButton(AlertDialog.BUTTON_POSITIVE)
@@ -767,7 +767,7 @@ class MainActivity : AppCompatActivity(), StartupProcessingDialogFragment.Listen
modelPresentationKnown = true
modelReady = false
modelSetupState = ModelSetupSnapshotState.INSTALLING
- setModelUi("Installing ${descriptor.displayName}", canDownload = false)
+ setModelUi(getString(R.string.installing_model_named, descriptor.displayName), canDownload = false)
updateControls()
SpeechModelImportWorker.enqueue(this, uri, descriptor)
dialog.dismiss()
@@ -819,7 +819,7 @@ class MainActivity : AppCompatActivity(), StartupProcessingDialogFragment.Listen
if (activityDestroyed) return@runOnUiThread
folderScanQueued = false
scanning = true
- scanMessage = "Scanning folder"
+ scanMessage = getString(R.string.folder_scanning)
taskStateHost.folderSyncCoordinator.transition(
folderSyncGeneration,
AndroidFolderSyncPhase.SCANNING,
@@ -837,7 +837,7 @@ class MainActivity : AppCompatActivity(), StartupProcessingDialogFragment.Listen
folderScanQueued = false
scanning = false
folderAccessError = null
- scanMessage = "Scan complete: $count audio files"
+ scanMessage = getString(R.string.folder_scan_complete, count)
taskStateHost.folderSyncCoordinator.transition(
folderSyncGeneration,
AndroidFolderSyncPhase.REFRESHING_CATALOG,
@@ -849,7 +849,7 @@ class MainActivity : AppCompatActivity(), StartupProcessingDialogFragment.Listen
if (activityDestroyed) return@runOnUiThread
folderScanQueued = false
scanning = false
- folderAccessError = error.message ?: "Folder scan failed"
+ folderAccessError = error.message ?: getString(R.string.error_folder_scan_failed)
folderPresentationKnown = true
taskStateHost.folderSyncCoordinator.fail(folderSyncGeneration)
startupGeneration?.let(startupCoordinator::onStartupScanFailed)
@@ -924,7 +924,7 @@ class MainActivity : AppCompatActivity(), StartupProcessingDialogFragment.Listen
if (folderSyncGeneration != null &&
taskStateHost.folderSyncCoordinator.fail(folderSyncGeneration)
) {
- folderAccessError = error.message ?: "Audio catalog refresh failed"
+ folderAccessError = error.message ?: getString(R.string.error_audio_catalog_refresh_failed)
folderPresentationKnown = true
startupGeneration?.let(startupCoordinator::onStartupScanFailed)
publishTaskState()
@@ -950,9 +950,17 @@ class MainActivity : AppCompatActivity(), StartupProcessingDialogFragment.Listen
.setTitle(review.filename)
.setView(content)
.setNegativeButton(R.string.transcript_close, null)
+ .setNeutralButton(R.string.transcript_copy_all, null)
.setPositiveButton(R.string.transcript_share, null)
.create()
dialog.setOnShowListener {
+ dialog.getButton(AlertDialog.BUTTON_NEUTRAL).apply {
+ contentDescription = getString(R.string.transcript_copy_accessibility)
+ setOnClickListener {
+ copyTranscriptToClipboard(this@MainActivity, review)
+ Toast.makeText(this@MainActivity, R.string.transcript_copied, Toast.LENGTH_SHORT).show()
+ }
+ }
dialog.getButton(AlertDialog.BUTTON_POSITIVE).setOnClickListener {
startActivity(AndroidTranscriptShareIntent.chooser(review))
}
@@ -975,25 +983,25 @@ class MainActivity : AppCompatActivity(), StartupProcessingDialogFragment.Listen
SpeechModelReadinessState.Checking -> {
modelReady = false
modelPresentationKnown = false
- setModelUi("Checking speech model", canDownload = false)
+ setModelUi(getString(R.string.model_checking), canDownload = false)
}
is SpeechModelReadinessState.Ready -> {
modelPresentationKnown = true
modelReady = true
modelSetupState = ModelSetupSnapshotState.READY
- setModelUi("Speech model ready", canDownload = false)
+ setModelUi(getString(R.string.model_ready), canDownload = false)
}
SpeechModelReadinessState.Missing -> {
modelPresentationKnown = true
modelReady = false
modelSetupState = ModelSetupSnapshotState.REQUIRED
- setModelUi("Speech model is not installed", canDownload = true)
+ setModelUi(getString(R.string.model_missing), canDownload = true)
}
is SpeechModelReadinessState.Invalid -> {
modelPresentationKnown = true
modelReady = false
modelSetupState = ModelSetupSnapshotState.INVALID
- setModelUi("Invalid speech model: ${state.reason}", canDownload = true)
+ setModelUi(getString(R.string.model_invalid, state.reason), canDownload = true)
}
is SpeechModelReadinessState.Failed -> {
modelPresentationKnown = true
@@ -1039,7 +1047,7 @@ class MainActivity : AppCompatActivity(), StartupProcessingDialogFragment.Listen
)
modelMessage =
info.progress.getString(SpeechModelInstallationWork.KEY_MESSAGE)
- ?: "Installing speech model"
+ ?: getString(R.string.model_installing)
modelDownloadAvailable = false
modelInstallCanCancel = info.tags.any { tag ->
tag.endsWith(SpeechModelDownloadWorker::class.java.simpleName)
@@ -1065,7 +1073,7 @@ class MainActivity : AppCompatActivity(), StartupProcessingDialogFragment.Listen
startupCoordinator.setModelReady(false)
setModelUi(
info.outputData.getString(SpeechModelInstallationWork.KEY_ERROR)
- ?: "Speech model installation failed",
+ ?: getString(R.string.model_installation_failed),
canDownload = true,
)
updateControls()
@@ -1142,9 +1150,9 @@ class MainActivity : AppCompatActivity(), StartupProcessingDialogFragment.Listen
observedActive = workActive,
activeEntryId = transcriptionEntryId,
) ?: when (info.state) {
- WorkInfo.State.SUCCEEDED -> "Completed"
+ WorkInfo.State.SUCCEEDED -> getString(R.string.transcription_completed)
WorkInfo.State.FAILED ->
- data.getString(TranscriptionWorker.KEY_ERROR) ?: "Failed"
+ data.getString(TranscriptionWorker.KEY_ERROR) ?: getString(R.string.task_failed)
else -> info.state.name
}
transcriptionIndeterminate =
@@ -1333,14 +1341,14 @@ class MainActivity : AppCompatActivity(), StartupProcessingDialogFragment.Listen
}
player.setOnErrorListener { failed, _, _ ->
if (previewPlayer == failed) {
- showError("Cannot play ${entry.displayName}")
+ showError(getString(R.string.error_cannot_play, entry.displayName))
stopPreviewPlayback(render = true)
}
true
}
player.prepareAsync()
}.onFailure {
- showError("Cannot play ${entry.displayName}")
+ showError(getString(R.string.error_cannot_play, entry.displayName))
stopPreviewPlayback(render = true)
}
}
@@ -1610,7 +1618,7 @@ class MainActivity : AppCompatActivity(), StartupProcessingDialogFragment.Listen
val choices = SpeechModelCatalog.modelsFor(SpeechModelPlatform.ANDROID)
.filter { it.distribution.networkDownloadAvailable }
MaterialAlertDialogBuilder(this)
- .setTitle("Download speech model")
+ .setTitle(R.string.download_speech_model)
.setSingleChoiceItems(
choices.map { it.displayName }.toTypedArray(),
choices.indexOfFirst { it.catalogId == selectedDownloadModel.catalogId },
@@ -1651,7 +1659,9 @@ class MainActivity : AppCompatActivity(), StartupProcessingDialogFragment.Listen
val view = refreshActionView ?: return
view.visibility = if (sync.visible) View.VISIBLE else View.GONE
view.isEnabled = sync.enabled
- view.contentDescription = sync.accessibilityLabel
+ view.contentDescription = getString(
+ if (sync.active) R.string.refresh_state_refreshing else R.string.refresh_state_idle,
+ )
ViewCompat.setStateDescription(
view,
if (sync.active) getString(R.string.refresh_state_refreshing) else getString(R.string.refresh_state_idle),
@@ -1765,8 +1775,8 @@ class MainActivity : AppCompatActivity(), StartupProcessingDialogFragment.Listen
AudioImportSummary(
listOf(
AudioImportItemOutcome.Rejected(
- displayName = "shared audio",
- reason = error.message ?: "Audio import failed",
+ displayName = getString(R.string.audio_import_default_name),
+ reason = error.message ?: getString(R.string.audio_import_failed),
),
),
)
@@ -1777,7 +1787,7 @@ class MainActivity : AppCompatActivity(), StartupProcessingDialogFragment.Listen
runOnUiThread {
if (activityDestroyed) return@runOnUiThread
ingestionActive = false
- Toast.makeText(this, summary.message(), Toast.LENGTH_LONG).show()
+ Toast.makeText(this, summary.localizedMessage(resources), Toast.LENGTH_LONG).show()
refreshCatalog()
if (queuedImportUris.isNotEmpty()) {
val queued = queuedImportUris.toList()
diff --git a/app/src/main/java/me/maxistar/voiceinbox/SettingsActivity.kt b/app/src/main/java/me/maxistar/voiceinbox/SettingsActivity.kt
index 26089d8..0af7961 100644
--- a/app/src/main/java/me/maxistar/voiceinbox/SettingsActivity.kt
+++ b/app/src/main/java/me/maxistar/voiceinbox/SettingsActivity.kt
@@ -207,7 +207,7 @@ class SettingsActivity : AppCompatActivity() {
if (activityDestroyed) return@runOnUiThread
Toast.makeText(
this,
- error.message ?: "Output file is not writable",
+ error.message ?: getString(R.string.error_output_not_writable),
Toast.LENGTH_LONG,
).show()
renderStorage()
@@ -259,7 +259,7 @@ class SettingsActivity : AppCompatActivity() {
if (activityDestroyed) return@runOnUiThread
Toast.makeText(
this,
- error.message ?: "Audio folder is not readable",
+ error.message ?: getString(R.string.error_audio_folder_not_readable),
Toast.LENGTH_LONG,
).show()
renderStorage()
@@ -296,8 +296,8 @@ class SettingsActivity : AppCompatActivity() {
modelDetail.text = when (val state = SpeechModelRepository.forActive(
noBackupFilesDir.resolve("models"),
).inspectLightweight()) {
- is InstalledSpeechModelState.Ready -> "Active: ${state.descriptor.displayName}. Select a supported local model package to replace it."
- else -> "Select a supported local model package."
+ is InstalledSpeechModelState.Ready -> getString(R.string.settings_model_active, state.descriptor.displayName)
+ else -> getString(R.string.settings_model_select)
}
}
@@ -310,7 +310,7 @@ class SettingsActivity : AppCompatActivity() {
outputDetail.text = runCatching {
getString(R.string.output_selected, documentAccess.metadata(stored).displayName)
}.getOrElse {
- getString(R.string.output_selected, stored.lastPathSegment ?: "document")
+ getString(R.string.output_selected, stored.lastPathSegment ?: getString(R.string.output_default_name))
}
}
@@ -323,7 +323,7 @@ class SettingsActivity : AppCompatActivity() {
folderDetail.text = runCatching {
getString(R.string.folder_selected, folderScanner.folderName(stored))
}.getOrElse {
- getString(R.string.folder_selected, stored.lastPathSegment ?: "audio folder")
+ getString(R.string.folder_selected, stored.lastPathSegment ?: getString(R.string.audio_folder_default_name))
}
}
@@ -365,7 +365,7 @@ class SettingsActivity : AppCompatActivity() {
private fun appVersionName(): String =
runCatching {
- packageManager.getPackageInfo(packageName, 0).versionName ?: "Unknown"
- }.getOrDefault("Unknown")
+ packageManager.getPackageInfo(packageName, 0).versionName ?: getString(R.string.unknown_value)
+ }.getOrDefault(getString(R.string.unknown_value))
}
diff --git a/app/src/main/java/me/maxistar/voiceinbox/SpeechModelDownloadWorker.kt b/app/src/main/java/me/maxistar/voiceinbox/SpeechModelDownloadWorker.kt
index cccb464..587e0b0 100644
--- a/app/src/main/java/me/maxistar/voiceinbox/SpeechModelDownloadWorker.kt
+++ b/app/src/main/java/me/maxistar/voiceinbox/SpeechModelDownloadWorker.kt
@@ -29,7 +29,7 @@ class SpeechModelDownloadWorker(
modelVersion = inputData.getString(KEY_MODEL_VERSION).orEmpty(),
),
SpeechModelPlatform.ANDROID,
- ) ?: throw IllegalArgumentException("Selected speech model is unavailable for download")
+ ) ?: throw IllegalArgumentException(applicationContext.getString(R.string.error_model_unavailable_download))
}
private val repository = SpeechModelRepository(
root = applicationContext.noBackupFilesDir.resolve("models"),
@@ -45,9 +45,9 @@ class SpeechModelDownloadWorker(
return try {
installModel()
} catch (error: IllegalArgumentException) {
- failure(error.message ?: "Selected speech model is unavailable for download")
+ failure(error.message ?: applicationContext.getString(R.string.error_model_unavailable_download))
} catch (error: ForegroundPromotionException) {
- failure(error.userMessage)
+ failure(applicationContext.getString(R.string.error_foreground_work))
}
}
@@ -56,17 +56,17 @@ class SpeechModelDownloadWorker(
worker = this,
context = applicationContext,
progress = 0,
- message = "Preparing model download",
+ message = applicationContext.getString(R.string.model_download_preparing),
source = SpeechModelInstallationWork.Source.NETWORK_DOWNLOAD,
)
repository.prepareForInstall().getOrElse {
- return failure(it.message ?: "Model installation preflight failed")
+ return failure(it.message ?: applicationContext.getString(R.string.error_model_preflight))
}
var completedBytes = repository.manifest.files
.filter(repository::isValidStagingFile)
.sumOf { it.sizeBytes }
- publishProgress(completedBytes, "Downloading speech model")
+ publishProgress(completedBytes, applicationContext.getString(R.string.model_downloading))
for (entry in repository.manifest.files) {
currentCoroutineContext().ensureActive()
@@ -84,7 +84,7 @@ class SpeechModelDownloadWorker(
"Failed to accept ${entry.name}"
}
completedBytes += entry.sizeBytes
- publishProgress(completedBytes, "Verified ${entry.name}")
+ publishProgress(completedBytes, applicationContext.getString(R.string.model_file_verified, entry.name))
lastFailure = null
break
} catch (error: ForegroundPromotionException) {
@@ -101,14 +101,14 @@ class SpeechModelDownloadWorker(
}
if (lastFailure != null) {
return failure(
- "Failed to download ${entry.name}: ${lastFailure.message ?: "unknown error"}",
+ applicationContext.getString(R.string.error_model_download, entry.name, lastFailure.message ?: applicationContext.getString(R.string.unknown_error)),
)
}
}
- publishProgress(repository.manifest.totalSizeBytes, "Activating speech model")
+ publishProgress(repository.manifest.totalSizeBytes, applicationContext.getString(R.string.model_activating))
val installedDirectory = repository.activate().getOrElse {
- return failure(it.message ?: "Failed to activate speech model")
+ return failure(it.message ?: applicationContext.getString(R.string.error_model_activate))
}
SpeechModelWarmup.invalidate()
return Result.success(workDataOf(KEY_MODEL_PATH to installedDirectory.absolutePath))
@@ -139,7 +139,7 @@ class SpeechModelDownloadWorker(
if (fileBytes - lastReported >= PROGRESS_STEP_BYTES) {
publishProgress(
completedBytes + fileBytes,
- "Downloading ${entry.name}",
+ applicationContext.getString(R.string.model_file_downloading, entry.name),
)
lastReported = fileBytes
}
@@ -187,7 +187,7 @@ class SpeechModelDownloadWorker(
private const val PROGRESS_STEP_BYTES = 2L * 1024L * 1024L
fun enqueue(context: Context, descriptor: SpeechModelDescriptor = SpeechModelCatalog.defaultModel) {
require(descriptor.distribution.networkDownloadAvailable) {
- "Selected speech model is unavailable for download"
+ context.getString(R.string.error_model_unavailable_download)
}
val request = OneTimeWorkRequestBuilder()
.setInputData(
diff --git a/app/src/main/java/me/maxistar/voiceinbox/SpeechModelImportWorker.kt b/app/src/main/java/me/maxistar/voiceinbox/SpeechModelImportWorker.kt
index ca6adb4..27ec2e6 100644
--- a/app/src/main/java/me/maxistar/voiceinbox/SpeechModelImportWorker.kt
+++ b/app/src/main/java/me/maxistar/voiceinbox/SpeechModelImportWorker.kt
@@ -16,14 +16,14 @@ class SpeechModelImportWorker(
) : CoroutineWorker(appContext, params) {
override suspend fun doWork(): Result {
val treeUri = inputData.getString(KEY_TREE_URI)?.let(Uri::parse)
- ?: return failure("No model folder was selected")
+ ?: return failure(applicationContext.getString(R.string.error_model_folder_missing))
val catalogId = inputData.getString(KEY_CATALOG_ID)
- ?: return failure("No speech model was selected")
+ ?: return failure(applicationContext.getString(R.string.error_model_missing))
val modelVersion = inputData.getString(KEY_MODEL_VERSION)
- ?: return failure("No speech model version was selected")
+ ?: return failure(applicationContext.getString(R.string.error_model_version_missing))
val descriptor = SpeechModelCatalog.resolveInstallation(catalogId, modelVersion)
?.takeIf { it.distribution.localImportAvailable }
- ?: return failure("The selected speech model is no longer supported")
+ ?: return failure(applicationContext.getString(R.string.error_model_unsupported))
val repository = SpeechModelRepository(
root = applicationContext.noBackupFilesDir.resolve("models"),
descriptor = descriptor,
@@ -33,21 +33,21 @@ class SpeechModelImportWorker(
worker = this,
context = applicationContext,
progress = 0,
- message = "Preparing local speech model",
+ message = applicationContext.getString(R.string.model_import_preparing),
source = SpeechModelInstallationWork.Source.LOCAL_IMPORT,
)
val installed = SpeechModelLocalImporter(
resolver = applicationContext.contentResolver,
repository = repository,
).import(treeUri.toString()) { progress -> publishProgress(progress, repository) }.getOrElse {
- return failure(it.message ?: "Could not import speech model")
+ return failure(it.message ?: applicationContext.getString(R.string.error_model_import))
}
SpeechModelWarmup.invalidate()
Result.success(
workDataOf(SpeechModelInstallationWork.KEY_MODEL_PATH to installed.absolutePath),
)
} catch (error: ForegroundPromotionException) {
- failure(error.userMessage)
+ failure(applicationContext.getString(R.string.error_foreground_work))
} finally {
SpeechModelImportPermission.releaseOwnedIfUnused(applicationContext)
}
@@ -63,18 +63,32 @@ class SpeechModelImportWorker(
workDataOf(
SpeechModelInstallationWork.KEY_BYTES_DOWNLOADED to progress.bytesCopied,
SpeechModelInstallationWork.KEY_TOTAL_BYTES to total,
- SpeechModelInstallationWork.KEY_MESSAGE to progress.message,
+ SpeechModelInstallationWork.KEY_MESSAGE to localizedProgressMessage(progress.message),
),
)
SpeechModelInstallationWork.promote(
worker = this,
context = applicationContext,
progress = percent,
- message = progress.message,
+ message = localizedProgressMessage(progress.message),
source = SpeechModelInstallationWork.Source.LOCAL_IMPORT,
)
}
+ private fun localizedProgressMessage(message: String): String = when {
+ message == "Preparing local speech model" -> applicationContext.getString(R.string.model_import_preparing)
+ message == "Activating speech model" -> applicationContext.getString(R.string.model_activating)
+ message.startsWith("Copying ") -> applicationContext.getString(
+ R.string.model_file_downloading,
+ message.removePrefix("Copying "),
+ )
+ message.startsWith("Verified ") -> applicationContext.getString(
+ R.string.model_file_verified,
+ message.removePrefix("Verified "),
+ )
+ else -> message
+ }
+
private fun failure(message: String): Result = Result.failure(
SpeechModelInstallationWork.failureData(message),
)
diff --git a/app/src/main/java/me/maxistar/voiceinbox/SpeechModelInstallationWork.kt b/app/src/main/java/me/maxistar/voiceinbox/SpeechModelInstallationWork.kt
index f926901..8a078a3 100644
--- a/app/src/main/java/me/maxistar/voiceinbox/SpeechModelInstallationWork.kt
+++ b/app/src/main/java/me/maxistar/voiceinbox/SpeechModelInstallationWork.kt
@@ -28,11 +28,10 @@ object SpeechModelInstallationWork {
enum class Source(
val diagnosticName: String,
- val userFacingName: String,
) {
- NETWORK_DOWNLOAD("network-download", "model download"),
- LOCAL_IMPORT("local-import", "local model import"),
- TRANSCRIPTION("transcription", "transcription"),
+ NETWORK_DOWNLOAD("network-download"),
+ LOCAL_IMPORT("local-import"),
+ TRANSCRIPTION("transcription"),
}
fun foregroundInfo(context: Context, progress: Int, message: String): ForegroundInfo {
@@ -41,14 +40,14 @@ object SpeechModelInstallationWork {
manager.createNotificationChannel(
NotificationChannel(
NOTIFICATION_CHANNEL,
- "Speech model installation",
+ context.getString(R.string.notification_model_installation),
NotificationManager.IMPORTANCE_LOW,
),
)
}
val notification = NotificationCompat.Builder(context, NOTIFICATION_CHANNEL)
.setSmallIcon(android.R.drawable.stat_sys_download)
- .setContentTitle("Voice Inbox")
+ .setContentTitle(context.getString(R.string.app_name))
.setContentText(message)
.setOnlyAlertOnce(true)
.setOngoing(true)
@@ -146,13 +145,13 @@ object SpeechModelInstallationWork {
error,
)
return ForegroundPromotionException(
- userMessage = "Could not keep ${source.userFacingName} running in the foreground. Please try again.",
+ source = source,
cause = error,
)
}
}
class ForegroundPromotionException(
- val userMessage: String,
+ val source: SpeechModelInstallationWork.Source,
cause: Throwable,
-) : RuntimeException(userMessage, cause)
+) : RuntimeException(cause)
diff --git a/app/src/main/java/me/maxistar/voiceinbox/TaskListAdapter.kt b/app/src/main/java/me/maxistar/voiceinbox/TaskListAdapter.kt
index d4f734a..d08b2ef 100644
--- a/app/src/main/java/me/maxistar/voiceinbox/TaskListAdapter.kt
+++ b/app/src/main/java/me/maxistar/voiceinbox/TaskListAdapter.kt
@@ -19,6 +19,7 @@ import me.maxistar.voiceinbox.core.TaskActionPresentation
import me.maxistar.voiceinbox.core.TaskListState
import me.maxistar.voiceinbox.core.TaskPresentation
import me.maxistar.voiceinbox.core.TaskProgressPresentation
+import me.maxistar.voiceinbox.core.TaskText
sealed class TaskListDisplayItem {
abstract val stableKey: String
@@ -67,7 +68,7 @@ sealed class TaskListDisplayItem {
}
data class Empty(
- val message: String,
+ val message: TaskText,
val actions: List,
) : TaskListDisplayItem() {
override val stableKey: String = STABLE_KEY
@@ -248,12 +249,12 @@ class TaskListAdapter(
private var boundActions: List? = null
protected fun bindTask(task: TaskPresentation, entryId: Long?) {
- itemView.contentDescription = task.title
- title.text = task.title
- badge.text = task.badge
- bindOptional(detail, task.detail)
+ itemView.contentDescription = AndroidTaskTextResolver.resolve(itemView.resources, task.title)
+ title.text = AndroidTaskTextResolver.resolve(itemView.resources, task.title)
+ badge.text = AndroidTaskTextResolver.resolve(itemView.resources, task.badge)
+ bindOptional(detail, task.detail?.let { AndroidTaskTextResolver.resolve(itemView.resources, it) })
bindProgress(task.progress)
- bindOptional(error, task.errorMessage)
+ bindOptional(error, task.errorMessage?.let { AndroidTaskTextResolver.resolve(itemView.resources, it) })
val dismissAction = task.actions.firstOrNull { it.kind == TaskActionKind.HIDE_OUTPUT }
dismiss.isVisible = dismissAction != null
dismiss.setOnClickListener {
@@ -271,7 +272,7 @@ class TaskListAdapter(
progressMeta.isVisible = false
return
}
- progressPhase.text = value.phase
+ progressPhase.text = AndroidTaskTextResolver.resolve(itemView.resources, value.phase)
progress.isIndeterminate = value.percent == null
progress.progress = value.percent ?: 0
bindOptional(progressMeta, progressMetadata(value))
@@ -290,7 +291,7 @@ class TaskListAdapter(
visibleActions.forEachIndexed { index, action ->
actions.addView(
MaterialButton(actions.context).apply {
- text = action.label
+ text = AndroidTaskTextResolver.resolve(actions.resources, action.text)
isEnabled = action.enabled
layoutParams = LinearLayout.LayoutParams(
ViewGroup.LayoutParams.WRAP_CONTENT,
@@ -354,15 +355,15 @@ class TaskListAdapter(
fun bind(item: TaskListDisplayItem.OnboardingHint) {
val presentation = item.presentation
- title.text = presentation.title
- explanation.text = presentation.explanation
- disclosure.text = presentation.downloadDisclosure.orEmpty()
- disclosure.isVisible = presentation.downloadDisclosure != null
+ title.setText(presentation.titleRes)
+ explanation.setText(presentation.explanationRes)
+ presentation.downloadDisclosureRes?.let(disclosure::setText)
+ disclosure.isVisible = presentation.downloadDisclosureRes != null
bindStep(modelStep, presentation.steps.firstOrNull { it.kind == AndroidOnboardingStepKind.MODEL })
bindStep(outputStep, presentation.steps.firstOrNull { it.kind == AndroidOnboardingStepKind.OUTPUT })
bindStep(folderStep, presentation.steps.firstOrNull { it.kind == AndroidOnboardingStepKind.FOLDER })
bindStep(keyboardStep, presentation.steps.firstOrNull { it.kind == AndroidOnboardingStepKind.KEYBOARD })
- action.text = presentation.action?.label.orEmpty()
+ presentation.action?.labelRes?.let(action::setText)
action.isEnabled = presentation.action?.enabled == true
action.isVisible = presentation.action != null
action.setOnClickListener {
@@ -377,11 +378,11 @@ class TaskListAdapter(
step ?: return
view.text = itemView.resources.getString(
if (step.complete) R.string.onboarding_step_complete else R.string.onboarding_step_incomplete,
- step.label,
+ itemView.resources.getString(step.labelRes),
)
view.contentDescription = itemView.resources.getString(
if (step.complete) R.string.onboarding_step_complete_accessibility else R.string.onboarding_step_incomplete_accessibility,
- step.label,
+ itemView.resources.getString(step.labelRes),
)
}
}
@@ -402,10 +403,10 @@ class TaskListAdapter(
fun bind(item: TaskListDisplayItem.KeyboardDiscovery) {
val presentation = item.presentation
- title.text = presentation.title
- explanation.text = presentation.explanation
- setup.text = presentation.setupLabel
- documentation.text = presentation.documentationLabel
+ title.setText(presentation.titleRes)
+ explanation.setText(presentation.explanationRes)
+ setup.setText(presentation.setupLabelRes)
+ documentation.setText(presentation.documentationLabelRes)
setup.setOnClickListener {
onAction(AndroidTaskActionRequest(item.stableKey, null, presentation.setupAction))
}
@@ -429,12 +430,12 @@ class TaskListAdapter(
private val actions: LinearLayout = itemView.findViewById(R.id.emptyActions)
fun bind(item: TaskListDisplayItem.Empty) {
- message.text = item.message
+ message.text = AndroidTaskTextResolver.resolve(itemView.resources, item.message)
actions.removeAllViews()
item.actions.forEachIndexed { index, action ->
actions.addView(
MaterialButton(actions.context).apply {
- text = action.label
+ text = AndroidTaskTextResolver.resolve(actions.resources, action.text)
isEnabled = action.enabled
layoutParams = LinearLayout.LayoutParams(
ViewGroup.LayoutParams.WRAP_CONTENT,
diff --git a/app/src/main/java/me/maxistar/voiceinbox/TranscriptionWorker.kt b/app/src/main/java/me/maxistar/voiceinbox/TranscriptionWorker.kt
index 95836fe..1feb526 100644
--- a/app/src/main/java/me/maxistar/voiceinbox/TranscriptionWorker.kt
+++ b/app/src/main/java/me/maxistar/voiceinbox/TranscriptionWorker.kt
@@ -34,15 +34,15 @@ class TranscriptionWorker(
try {
SpeechModelInstallationWork.promote(
worker = this@TranscriptionWorker,
- foregroundInfo = foreground("Preparing transcription", 0, true),
+ foregroundInfo = foreground(applicationContext.getString(R.string.transcription_preparing), 0, true),
source = SpeechModelInstallationWork.Source.TRANSCRIPTION,
)
val modelRepository = SpeechModelRepository.forActive(
applicationContext.noBackupFilesDir.resolve("models"),
)
- publish("Preparing speech model", null, null, 0, 0, null, null)
+ publish(applicationContext.getString(R.string.transcription_preparing_model), null, null, 0, 0, null, null)
SpeechModelWarmup.prepare(modelRepository, retryFailed = true).get()
- .getOrElse { return@withContext failure(it.message ?: "Speech model preparation failed") }
+ .getOrElse { return@withContext failure(it.message ?: applicationContext.getString(R.string.error_speech_model_preparation_failed)) }
val batch = BatchTranscriptionUseCase(
catalog = catalog,
@@ -78,7 +78,7 @@ class TranscriptionWorker(
} catch (cancelled: CancellationException) {
throw cancelled
} catch (error: Throwable) {
- failure(error.message ?: "Transcription failed")
+ failure(error.message ?: applicationContext.getString(R.string.error_transcription_failed))
}
}
@@ -117,7 +117,7 @@ class TranscriptionWorker(
private fun publishAsync(progress: BatchTranscriptionProgress) {
publishAsync(
- phase = progress.phase,
+ phase = localizedPhase(progress),
activeEntryId = progress.activeEntryId,
filename = progress.filename,
completed = progress.completed,
@@ -129,6 +129,22 @@ class TranscriptionWorker(
)
}
+ private fun localizedPhase(progress: BatchTranscriptionProgress): String = when {
+ progress.filename == null && progress.failed > 0 -> applicationContext.getString(
+ R.string.transcription_summary_failed,
+ progress.completed,
+ progress.total,
+ progress.failed,
+ )
+ progress.filename == null -> applicationContext.getString(
+ R.string.transcription_summary,
+ progress.completed,
+ progress.total,
+ )
+ progress.phase == "Transcribing" -> applicationContext.getString(R.string.transcription_progress)
+ else -> progress.phase
+ }
+
private fun publishAsync(
phase: String,
activeEntryId: Long?,
@@ -192,13 +208,13 @@ class TranscriptionWorker(
manager.createNotificationChannel(
NotificationChannel(
NOTIFICATION_CHANNEL,
- "Audio transcription",
+ applicationContext.getString(R.string.notification_audio_transcription),
NotificationManager.IMPORTANCE_LOW,
),
)
val notification = NotificationCompat.Builder(applicationContext, NOTIFICATION_CHANNEL)
.setSmallIcon(android.R.drawable.ic_btn_speak_now)
- .setContentTitle("Voice Inbox")
+ .setContentTitle(applicationContext.getString(R.string.app_name))
.setContentText(message)
.setOnlyAlertOnce(true)
.setOngoing(true)
@@ -219,7 +235,7 @@ class TranscriptionWorker(
}
private fun failure(message: String): Result =
- Result.failure(workDataOf(KEY_ERROR to message, KEY_PHASE to "Failed"))
+ Result.failure(workDataOf(KEY_ERROR to message, KEY_PHASE to applicationContext.getString(R.string.task_failed)))
private class AndroidBatchEntryTranscriber(
private val transcriber: SingleFileTranscriber,
diff --git a/app/src/main/res/layout/activity_settings.xml b/app/src/main/res/layout/activity_settings.xml
index a8cf443..330f2c7 100644
--- a/app/src/main/res/layout/activity_settings.xml
+++ b/app/src/main/res/layout/activity_settings.xml
@@ -95,14 +95,14 @@
android:id="@+id/settingsModelDetail"
android:layout_width="match_parent"
android:layout_height="wrap_content"
- android:text="Install speech model from folder"
+ android:text="@string/settings_model_import_title"
android:textStyle="bold" />
+ android:text="@string/settings_model_select" />
+ Voice Inbox
+ Voice-Inbox-Tastatur
+ Offline-Diktat
+ Zum Aufnehmen tippen oder während des Sprechens gedrückt halten
+ Sprachmodell wird vorbereitet…
+ Höre zu… Zum Transkribieren loslassen.
+ Höre zu… Nach dem Ende auf Stopp tippen.
+ Höre zu… Zum Transkribieren loslassen. Noch %1$s.
+ Höre zu… Nach dem Ende auf Stopp tippen. Noch %1$s.
+ Lokale Transkription…
+ Der Text ist bereit. Zum Einfügen ein Textfeld auswählen oder verwerfen.
+ Keine Sprache erkannt. Bitte erneut versuchen.
+ Mikrofonzugriff erforderlich. Voice Inbox öffnen, um ihn zu erlauben.
+ Mikrofon ist nicht verfügbar. Bitte erneut versuchen.
+ Ein verfügbares Sprachmodell ist erforderlich. Voice Inbox öffnen, um es zu installieren oder zu reparieren.
+ Transkription fehlgeschlagen. Bitte erneut versuchen.
+ Aufnehmen
+ Stopp
+ Abbrechen
+ Schließen
+ Voice-Inbox-Einrichtung öffnen
+ Löschen
+ Leerzeichen
+ Eingabe
+ Weiter
+ Zur vorherigen Tastatur zurückkehren
+ Mikrofonzugriff gewährt. Zur Voice-Inbox-Tastatur zurückkehren.
+ Mikrofonzugriff wurde nicht gewährt.
+ Tastaturvorschlag schließen
+ Die Android-Tastatureinstellungen sind auf diesem Gerät nicht verfügbar. Die Tastaturanleitung enthält manuelle Schritte.
+ Ausgabedatei auswählen
+ Ausgabedatei ändern
+ Audioordner auswählen
+ Audioordner ändern
+ Aktualisieren
+ Audioordner wird nicht aktualisiert
+ Audioordner wird aktualisiert
+ Einstellungen
+ Dokumentation
+ Einstellungen
+ Speicher
+ Audioordner
+ Automatischer Transkript-Export
+ Automatischen Transkript-Export konfigurieren
+ Neu erstellen
+ Vorhandene auswählen
+ Nicht exportieren
+ Über
+ Version: %1$s
+ Webseite
+ Rechtliche Hinweise
+ Keine App kann diesen Link öffnen.
+ Aktiv: %1$s. Ein unterstütztes lokales Modellpaket zum Ersetzen auswählen.
+ Sprachmodell aus Ordner installieren
+ Ein unterstütztes lokales Modellpaket auswählen.
+ Ausgabedatei ist nicht beschreibbar
+ Audioordner ist nicht lesbar
+ Sprachmodell wird geprüft
+ Sprachmodell ist bereit
+ Sprachmodell ist nicht installiert
+ Ungültiges Sprachmodell: %1$s
+ Sprachmodell wird installiert
+ Installation des Sprachmodells fehlgeschlagen
+ Installation des Sprachmodells
+ Audiotranskription
+ Modelldownload wird vorbereitet
+ Sprachmodell wird heruntergeladen
+ Sprachmodell wird aktiviert
+ Lokales Sprachmodell wird vorbereitet
+ Transkription wird vorbereitet
+ Sprachmodell wird vorbereitet
+ Transkription fehlgeschlagen
+ Vorbereitung des Sprachmodells fehlgeschlagen
+ %1$s wird heruntergeladen
+ %1$s verifiziert
+ Das ausgewählte Sprachmodell steht nicht zum Download bereit
+ Vorprüfung der Modellinstallation fehlgeschlagen
+ Download von %1$s fehlgeschlagen: %2$s
+ Aktivierung des Sprachmodells fehlgeschlagen
+ Kein Modellordner ausgewählt
+ Kein Sprachmodell ausgewählt
+ Keine Version des Sprachmodells ausgewählt
+ Das ausgewählte Sprachmodell wird nicht mehr unterstützt
+ Sprachmodell konnte nicht importiert werden
+ unbekannter Fehler
+ Voice-Inbox-Tastatur
+ Kompaktes Android-Diktat für jedes bearbeitbare Feld. Die Erkennung läuft nach der Einrichtung des Modells lokal; dies ist keine vollständige Tastatur.
+ Nicht aktiviert
+ Aktiviert · In der Android-Tastaturauswahl auswählen
+ Bereit · Derzeit ausgewählt
+ Aktivieren
+ Tastatur auswählen
+ Tastatur ändern
+ Tastaturanleitung
+ Nächtliche Transkription
+ Neue Notizen zur ausgewählten Zeit automatisch suchen und transkribieren.
+ Geplante Zeit: %1$02d:%2$02d
+ Geplante Zeit ändern
+ Android kann Hintergrundarbeit zur Akkuschonung verzögern. Diese Funktion ist standardmäßig deaktiviert.
+ Verarbeitung beim Start
+ Auswählen, was beim Öffnen von Voice Inbox mit wartenden Notizen geschieht.
+ Jedes Mal fragen
+ Automatisch transkribieren
+ Dateien in der Warteschlange lassen
+ Die Startverarbeitung läuft nur beim Öffnen der App. Die nächtliche Transkription ist eine separate Hintergrundoption.
+ Wartende Notizen verarbeiten?
+
+ - %1$d Datei ist zum Transkribieren bereit.
+ - %1$d Dateien sind zum Transkribieren bereit.
+
+ Beim Start von Voice Inbox immer so verfahren
+ Jetzt verarbeiten
+ Dateien in der Warteschlange lassen
+ Ausgabe: nicht ausgewählt
+ Ausgabe: %1$s
+ Ausgabe: Auswahl wird wiederhergestellt…
+ Ausgabe: vorübergehend nicht verfügbar
+ Ausgabedatei auswählen
+ Audioordner: nicht ausgewählt
+ Audioordner: %1$s
+ Audioordner: Auswahl wird wiederhergestellt…
+ Audioordner: vorübergehend nicht verfügbar
+ Audioordner auswählen
+ Audio importieren
+ Audio wird importiert…
+ Es wurde kein lesbares Audio geteilt.
+ Alles kopieren
+ Vollständiges Transkript kopieren
+ Transkript kopiert
+ Teilen
+ Schließen
+ Auswählbarer Transkripttext
+ Sprachmodell wird geprüft
+ Sprachmodell herunterladen
+ Aus Ordner installieren
+ Neu
+ Verarbeitet
+ Alle
+ Keine neuen Audiodateien
+ Alle transkribieren
+ Sprachmodell installieren
+ Automatischer Transkript-Export
+ Optional. Transkripte werden in Voice Inbox gespeichert.
+ %1$s · %2$s · %3$s · %4$s Download · %5$s frei
+ Modell auswählen
+ Audioordner aktualisieren
+ Zugriff auf Audioordner wiederherstellen
+ Erforderlich
+ Optional
+ Wird installiert
+ Wird durchsucht
+ Aufmerksamkeit erforderlich
+ Wird verarbeitet
+ Fehlgeschlagen
+ Keine Sprache
+ Download wiederholen
+ Herunterladen
+ Ausblenden
+ Ordner auswählen
+ Erneut versuchen
+ Transkribieren
+ Text anzeigen
+ Abspielen
+ Audiodateien importieren
+ Keine neuen Aufgaben
+ Keine verarbeiteten Audiodateien
+ Keine Aufgaben
+ Modell wird installiert
+ Audioordner wird durchsucht
+ Sprachmodell wird vorbereitet
+ Einrichtungsanleitung schließen
+ Voice Inbox einrichten
+ Diesen Schritten folgen oder die Einrichtungsaufgaben oben in beliebiger Reihenfolge verwenden.
+ Beim Start der Einrichtung wird das Sprachmodell auf dieses Gerät heruntergeladen.
+ Sprachmodell installieren
+ Automatischen Transkript-Export konfigurieren · Optional
+ Audioordner auswählen · Optional
+ Voice-Inbox-Tastatur aktivieren · Optional
+ Sprachmodell wird installiert…
+ Einrichtung wiederholen
+ Einrichtung starten
+ Modell aus Ordner installieren
+ Exportdokument auswählen
+ Audioordner auswählen
+ Voice-Inbox-Tastatur aktivieren
+ Bereit zum Transkribieren
+ In jeder App diktieren
+ Voice Inbox als kompakte Sprachtastatur verwenden. Die Erkennung läuft nach der Einrichtung lokal.
+ Tastatur einrichten
+ Mehr erfahren
+ Automatischen Transkript-Export ausblenden
+ Ausgabedatei ist vorübergehend nicht verfügbar
+ Zugriff auf Ausgabedatei wurde entzogen; erneut auswählen
+ Audioordner ist vorübergehend nicht verfügbar
+ Zugriff auf Audioordner wurde entzogen; erneut auswählen
+ Auf den ausgewählten Modellordner kann nach dem Schließen der Auswahl nicht zugegriffen werden
+ Der ausgewählte Modellordner ist nicht lesbar
+ %1$s installieren?
+ %1$s-Modell · %2$s · ungefähr %3$s
+ %1$s wird installiert
+ Ordner wird durchsucht
+ Suche abgeschlossen: %1$d Audiodateien
+ Ordnersuche fehlgeschlagen
+ Aktualisierung des Audiokatalogs fehlgeschlagen
+ Hintergrundarbeit konnte nicht fortgesetzt werden. Bitte erneut versuchen.
+ Abgeschlossen
+ Transkribieren
+ %1$d von %2$d abgeschlossen
+ %1$d von %2$d abgeschlossen, %3$d fehlgeschlagen
+ %1$s kann nicht abgespielt werden
+ geteiltes Audio
+ Audioimport fehlgeschlagen
+ Audiodatei ist leer
+ Nicht unterstütztes Audioformat
+ %1$d importiert
+ %1$d bereits hinzugefügt
+ abgelehnt: %1$s
+ %1$d abgelehnt
+ kein Audio empfangen
+ Installieren
+ Unbekannt
+ Dokument
+ Audioordner
+ ✓ %1$s
+ ○ %1$s
+ Abgeschlossen: %1$s
+ Nicht abgeschlossen: %1$s
+
+ - Alle transkribieren (%1$d Datei)
+ - Alle transkribieren (%1$d Dateien)
+
+
diff --git a/app/src/main/res/values-ru/strings.xml b/app/src/main/res/values-ru/strings.xml
new file mode 100644
index 0000000..569f8e4
--- /dev/null
+++ b/app/src/main/res/values-ru/strings.xml
@@ -0,0 +1,237 @@
+
+ Voice Inbox
+ Клавиатура Voice Inbox
+ Офлайн-диктовка
+ Нажмите для записи или удерживайте во время речи
+ Подготовка речевой модели…
+ Слушаю… Отпустите, чтобы расшифровать.
+ Слушаю… Нажмите «Стоп», когда закончите.
+ Слушаю… Отпустите, чтобы расшифровать. Осталось: %1$s.
+ Слушаю… Нажмите «Стоп», когда закончите. Осталось: %1$s.
+ Расшифровка на устройстве…
+ Текст готов. Выберите поле для вставки или закройте результат.
+ Речь не обнаружена. Попробуйте ещё раз.
+ Требуется доступ к микрофону. Откройте Voice Inbox и предоставьте его.
+ Микрофон недоступен. Попробуйте ещё раз.
+ Требуется готовая речевая модель. Откройте Voice Inbox, чтобы установить или восстановить её.
+ Не удалось расшифровать запись. Попробуйте ещё раз.
+ Запись
+ Стоп
+ Отмена
+ Закрыть
+ Открыть настройку Voice Inbox
+ Удалить
+ Пробел
+ Ввод
+ Далее
+ Вернуться к предыдущей клавиатуре
+ Доступ к микрофону предоставлен. Вернитесь к клавиатуре Voice Inbox.
+ Доступ к микрофону не предоставлен.
+ Скрыть предложение клавиатуры
+ Настройки клавиатуры Android недоступны на этом устройстве. Откройте руководство по клавиатуре, чтобы настроить её вручную.
+ Выбрать файл вывода
+ Изменить файл вывода
+ Выбрать папку с аудио
+ Изменить папку с аудио
+ Обновить
+ Папка с аудио не обновляется
+ Обновление папки с аудио
+ Настройки
+ Документация
+ Настройки
+ Хранилище
+ Папка с аудио
+ Автоматический экспорт расшифровок
+ Настроить автоматический экспорт расшифровок
+ Создать новый
+ Выбрать существующий
+ Не экспортировать
+ О приложении
+ Версия: %1$s
+ Сайт
+ Правовая информация
+ Нет приложения для открытия этой ссылки.
+ Активна: %1$s. Выберите поддерживаемый локальный пакет модели, чтобы заменить её.
+ Установить речевую модель из папки
+ Выберите поддерживаемый локальный пакет модели.
+ В файл вывода нельзя записать
+ Папка с аудио недоступна для чтения
+ Проверка речевой модели
+ Речевая модель готова
+ Речевая модель не установлена
+ Недопустимая речевая модель: %1$s
+ Установка речевой модели
+ Не удалось установить речевую модель
+ Установка речевой модели
+ Расшифровка аудио
+ Подготовка загрузки модели
+ Загрузка речевой модели
+ Активация речевой модели
+ Подготовка локальной речевой модели
+ Подготовка расшифровки
+ Подготовка речевой модели
+ Не удалось расшифровать аудио
+ Не удалось подготовить речевую модель
+ Загрузка %1$s
+ Проверен файл %1$s
+ Выбранная речевая модель недоступна для загрузки
+ Не удалось подготовить установку модели
+ Не удалось загрузить %1$s: %2$s
+ Не удалось активировать речевую модель
+ Папка модели не выбрана
+ Речевая модель не выбрана
+ Версия речевой модели не выбрана
+ Выбранная речевая модель больше не поддерживается
+ Не удалось импортировать речевую модель
+ неизвестная ошибка
+ Голосовая клавиатура
+ Компактная Android-клавиатура для диктовки в любом редактируемом поле. Распознавание работает локально после настройки модели; это не полноценная клавиатура для набора текста.
+ Не включена
+ Включена · Выберите её в переключателе клавиатур Android
+ Готова · Выбрана сейчас
+ Включить
+ Выбрать клавиатуру
+ Изменить клавиатуру
+ Руководство по клавиатуре
+ Ночная расшифровка
+ Автоматически искать и расшифровывать новые заметки около выбранного времени.
+ Время запуска: %1$02d:%2$02d
+ Изменить время запуска
+ Android может отложить фоновую работу для экономии заряда. По умолчанию функция выключена.
+ Обработка при запуске
+ Выберите, что делать, когда Voice Inbox находит ожидающие заметки при открытии.
+ Спрашивать каждый раз
+ Расшифровывать автоматически
+ Оставлять файлы в очереди
+ Обработка при запуске работает только при открытии приложения. Ночная расшифровка выполняется отдельно в фоне.
+ Обработать ожидающие заметки?
+
+ - %1$d файл готов к расшифровке.
+ - %1$d файла готовы к расшифровке.
+ - %1$d файлов готовы к расшифровке.
+ - %1$d файла готовы к расшифровке.
+
+ Всегда делать так при запуске Voice Inbox
+ Обработать
+ Оставить в очереди
+ Вывод: не выбран
+ Вывод: %1$s
+ Вывод: восстановление доступа…
+ Вывод: временно недоступен
+ Выбрать файл вывода
+ Папка с аудио: не выбрана
+ Папка с аудио: %1$s
+ Папка с аудио: восстановление доступа…
+ Папка с аудио: временно недоступна
+ Выбрать папку с аудио
+ Импортировать аудио
+ Импорт аудио…
+ Не удалось прочитать переданное аудио.
+ Скопировать всё
+ Скопировать всю расшифровку
+ Расшифровка скопирована
+ Поделиться
+ Закрыть
+ Текст расшифровки с возможностью выделения
+ Проверка речевой модели
+ Скачать речевую модель
+ Установить из папки
+ Новые
+ Готовые
+ Все
+ Нет новых аудиофайлов
+ Расшифровать все
+ Установить речевую модель
+ Автоматический экспорт расшифровок
+ Необязательно. Расшифровки хранятся в Voice Inbox.
+ %1$s · %2$s · %3$s · загрузка %4$s · свободно %5$s
+ Выбрать модель
+ Обновить папку с аудио
+ Восстановить доступ к папке с аудио
+ Обязательно
+ Необязательно
+ Установка
+ Сканирование
+ Требуется внимание
+ Обработка
+ Ошибка
+ Речь не обнаружена
+ Повторить загрузку
+ Скачать
+ Скрыть
+ Выбрать папку
+ Повторить
+ Расшифровать
+ Показать текст
+ Воспроизвести
+ Импортировать аудиофайлы
+ Нет новых задач
+ Нет обработанных аудиофайлов
+ Нет задач
+ Установка модели
+ Сканирование папки с аудио
+ Подготовка речевой модели
+ Скрыть руководство по настройке
+ Настройте Voice Inbox
+ Выполните эти шаги или задачи настройки выше в любом порядке.
+ Настройка загрузит речевую модель на это устройство.
+ Установить речевую модель
+ Экспорт · необязательно
+ Папка с аудио · необязательно
+ Клавиатура · необязательно
+ Установка речевой модели…
+ Повторить настройку
+ Начать настройку
+ Установить модель из папки
+ Выбрать файл экспорта
+ Выбрать папку с аудио
+ Включить голосовую клавиатуру
+ Готово к расшифровке
+ Диктуйте в любом приложении
+ Используйте Voice Inbox как компактную голосовую клавиатуру. Распознавание работает локально после настройки модели.
+ Настроить клавиатуру
+ Подробнее
+ Скрыть автоматический экспорт расшифровок
+ Файл экспорта временно недоступен
+ Доступ к файлу экспорта отозван; выберите его снова
+ Папка с аудио временно недоступна
+ Доступ к папке с аудио отозван; выберите ее снова
+ После закрытия выбора папки доступ к модели недоступен
+ Не удалось прочитать выбранную папку с моделью
+ Установить %1$s?
+ Модель %1$s · %2$s · примерно %3$s
+ Установка %1$s
+ Сканирование папки
+ Сканирование завершено: аудиофайлов %1$d
+ Не удалось просканировать папку
+ Не удалось обновить каталог аудио
+ Не удалось продолжить фоновую работу. Попробуйте ещё раз.
+ Готово
+ Расшифровка
+ Готово: %1$d из %2$d
+ Готово: %1$d из %2$d, ошибок: %3$d
+ Не удалось воспроизвести %1$s
+ переданное аудио
+ Не удалось импортировать аудио
+ Аудиофайл пуст
+ Неподдерживаемый формат аудио
+ Импортировано: %1$d
+ Уже добавлено: %1$d
+ Отклонено: %1$s
+ Отклонено: %1$d
+ Аудио не получено
+ Установить
+ Неизвестно
+ документ
+ папка с аудио
+ ✓ %1$s
+ ○ %1$s
+ Выполнено: %1$s
+ Не выполнено: %1$s
+
+ - Расшифровать все (%1$d файл)
+ - Расшифровать все (%1$d файла)
+ - Расшифровать все (%1$d файлов)
+ - Расшифровать все (%1$d файла)
+
+
diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml
index 488ad68..c5c2a35 100644
--- a/app/src/main/res/values/strings.xml
+++ b/app/src/main/res/values/strings.xml
@@ -51,6 +51,39 @@
Website
Legal information
No app can open this link.
+ Active: %1$s. Select a supported local model package to replace it.
+ Install speech model from folder
+ Select a supported local model package.
+ Output file is not writable
+ Audio folder is not readable
+ Checking speech model
+ Speech model ready
+ Speech model is not installed
+ Invalid speech model: %1$s
+ Installing speech model
+ Speech model installation failed
+ Speech model installation
+ Audio transcription
+ Preparing model download
+ Downloading speech model
+ Activating speech model
+ Preparing local speech model
+ Preparing transcription
+ Preparing speech model
+ Transcription failed
+ Speech model preparation failed
+ Downloading %1$s
+ Verified %1$s
+ Selected speech model is unavailable for download
+ Model installation preflight failed
+ Failed to download %1$s: %2$s
+ Failed to activate speech model
+ No model folder was selected
+ No speech model was selected
+ No speech model version was selected
+ The selected speech model is no longer supported
+ Could not import speech model
+ unknown error
Voice keyboard
Compact Android dictation for any editable field. Recognition runs locally after model setup; this is not a full typing keyboard.
Not enabled
@@ -92,6 +125,9 @@
Import audio
Importing audio…
No readable audio was shared.
+ Copy all
+ Copy full transcript
+ Transcript copied
Share
Close
Selectable transcript text
@@ -103,8 +139,89 @@
All
No new audio files
Transcribe all
+ Install Speech Model
+ Automatic Transcript Export
+ Optional. Transcripts are stored in Voice Inbox.
+ %1$s · %2$s · %3$s · %4$s download · %5$s free
+ Choose Model
+ Refresh Audio Folder
+ Restore Audio Folder Access
+ Required
+ Optional
+ Installing
+ Scanning
+ Needs attention
+ Processing
+ Failed
+ No speech
+ Retry Download
+ Download
+ Hide
+ Select Folder
+ Retry
+ Transcribe
+ Show Text
+ Play
+ Import Audio Files
+ No new tasks
+ No processed audio files
+ No tasks
+ Installing model
+ Scanning audio folder
+ Preparing speech model
Dismiss setup guide
+ Set up Voice Inbox
+ Follow these steps, or use the setup tasks above in any order.
+ Start setup downloads the speech model to this device.
+ Install speech model
+ Configure automatic transcript export · Optional
+ Select audio folder · Optional
+ Enable voice keyboard · Optional
+ Installing speech model…
+ Retry setup
+ Start setup
+ Install model from folder
+ Choose export document
+ Select audio folder
+ Enable voice keyboard
+ Ready to transcribe
+ Dictate in any app
+ Use Voice Inbox as a compact voice keyboard. Recognition runs locally after model setup.
+ Set up keyboard
+ Learn more
Hide automatic transcript export
+ Output file is temporarily unavailable
+ Output file access was revoked; select it again
+ Audio folder is temporarily unavailable
+ Audio folder access was revoked; select it again
+ The selected model folder cannot be accessed after the picker closes
+ The selected model folder is not readable
+ Install %1$s?
+ %1$s model · %2$s · approximately %3$s
+ Installing %1$s
+ Scanning folder
+ Scan complete: %1$d audio files
+ Folder scan failed
+ Audio catalog refresh failed
+ Could not continue background work. Please try again.
+ Completed
+ Transcribing
+ Completed %1$d of %2$d
+ Completed %1$d of %2$d, %3$d failed
+ Cannot play %1$s
+ shared audio
+ Audio import failed
+ Audio file is empty
+ Unsupported audio format
+ Imported %1$d
+ %1$d already added
+ rejected: %1$s
+ %1$d rejected
+ no audio received
+ Install
+ Unknown
+ document
+ audio folder
✓ %1$s
○ %1$s
Completed: %1$s
diff --git a/app/src/main/res/xml/backup_rules.xml b/app/src/main/res/xml/backup_rules.xml
deleted file mode 100644
index 4df9255..0000000
--- a/app/src/main/res/xml/backup_rules.xml
+++ /dev/null
@@ -1,13 +0,0 @@
-
-
-
-
\ No newline at end of file
diff --git a/app/src/main/res/xml/data_extraction_rules.xml b/app/src/main/res/xml/data_extraction_rules.xml
deleted file mode 100644
index 9ee9997..0000000
--- a/app/src/main/res/xml/data_extraction_rules.xml
+++ /dev/null
@@ -1,19 +0,0 @@
-
-
-
-
-
-
-
\ No newline at end of file
diff --git a/app/src/test/java/me/maxistar/voiceinbox/AndroidAudioMetadataFormatterTest.kt b/app/src/test/java/me/maxistar/voiceinbox/AndroidAudioMetadataFormatterTest.kt
index 7fe85d9..adc2b6a 100644
--- a/app/src/test/java/me/maxistar/voiceinbox/AndroidAudioMetadataFormatterTest.kt
+++ b/app/src/test/java/me/maxistar/voiceinbox/AndroidAudioMetadataFormatterTest.kt
@@ -70,6 +70,6 @@ class AndroidAudioMetadataFormatterTest {
).taskList.tasks.single().detail
assertEquals(processed, all)
- assertTrue(requireNotNull(processed).endsWith(" • 1 KiB • 1:05"))
+ assertTrue(requireNotNull(processed).fallback.endsWith(" • 1 KiB • 1:05"))
}
}
diff --git a/app/src/test/java/me/maxistar/voiceinbox/AndroidInlineOnboardingTest.kt b/app/src/test/java/me/maxistar/voiceinbox/AndroidInlineOnboardingTest.kt
index 44d438f..f63f989 100644
--- a/app/src/test/java/me/maxistar/voiceinbox/AndroidInlineOnboardingTest.kt
+++ b/app/src/test/java/me/maxistar/voiceinbox/AndroidInlineOnboardingTest.kt
@@ -40,7 +40,7 @@ class AndroidInlineOnboardingTest {
@Test
fun modelActionUsesDownloadRetryManualImportAndNeutralInstallingState() {
assertEquals(TaskActionKind.DOWNLOAD_MODEL, present().action?.kind)
- assertEquals("Start setup", present().action?.label)
+ assertEquals(R.string.onboarding_action_start, present().action?.labelRes)
val invalid = present(
model = ModelSetupSnapshot(ModelSetupSnapshotState.INVALID, downloadAvailable = true),
@@ -56,7 +56,7 @@ class AndroidInlineOnboardingTest {
model = ModelSetupSnapshot(ModelSetupSnapshotState.INSTALLING, downloadAvailable = true),
)
assertFalse(installing.action!!.enabled)
- assertEquals("Installing speech model…", installing.action?.label)
+ assertEquals(R.string.onboarding_action_installing, installing.action?.labelRes)
}
@Test
diff --git a/app/src/test/java/me/maxistar/voiceinbox/AndroidMainScreenStateHostTest.kt b/app/src/test/java/me/maxistar/voiceinbox/AndroidMainScreenStateHostTest.kt
index c146ff3..ca031dc 100644
--- a/app/src/test/java/me/maxistar/voiceinbox/AndroidMainScreenStateHostTest.kt
+++ b/app/src/test/java/me/maxistar/voiceinbox/AndroidMainScreenStateHostTest.kt
@@ -17,6 +17,7 @@ import me.maxistar.voiceinbox.core.PreviewTaskSnapshot
import me.maxistar.voiceinbox.core.SetupTaskPresentation
import me.maxistar.voiceinbox.core.TaskActionKind
import me.maxistar.voiceinbox.core.TaskListFilter
+import me.maxistar.voiceinbox.core.TaskTextKey
import me.maxistar.voiceinbox.core.TranscriptionTaskSnapshot
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
@@ -43,10 +44,10 @@ class AndroidMainScreenStateHostTest {
val tasks = state.taskList.tasks.filterIsInstance()
assertEquals(listOf("setup:model", "setup:output", "setup:folder"), tasks.map { it.stableId })
- assertEquals("Verifying local model", tasks.first().progress?.phase)
+ assertEquals("Verifying local model", tasks.first().progress?.phase?.fallback)
assertEquals(63, tasks.first().progress?.percent)
- assertEquals("Output unavailable", tasks[1].errorMessage)
- assertEquals("Scanning audio folder", tasks[2].progress?.phase)
+ assertEquals("Output unavailable", tasks[1].errorMessage?.fallback)
+ assertEquals(TaskTextKey.SCANNING_AUDIO_FOLDER, tasks[2].progress?.phase?.key)
}
@Test
@@ -70,7 +71,7 @@ class AndroidMainScreenStateHostTest {
.single { it.stableId == "setup:model" }
assertNull(task.detail)
- assertEquals(message, task.progress?.phase)
+ assertEquals(message, task.progress?.phase?.fallback)
}
@Test
@@ -91,7 +92,7 @@ class AndroidMainScreenStateHostTest {
assertEquals(listOf(5L, 4L, 3L, 2L, 1L), tasks.map { it.entryId })
assertEquals(AudioTaskState.NO_SPEECH, tasks.single { it.entryId == 4L }.state)
assertEquals(TaskActionKind.SHOW_TEXT, tasks.single { it.entryId == 3L }.actions.first().kind)
- assertTrue(requireNotNull(tasks.single { it.entryId == 1L }.detail).endsWith(" • 1 KiB"))
+ assertTrue(requireNotNull(tasks.single { it.entryId == 1L }.detail).fallback.endsWith(" • 1 KiB"))
}
@Test
@@ -117,7 +118,7 @@ class AndroidMainScreenStateHostTest {
val tasks = state.taskList.tasks.filterIsInstance()
val active = tasks.single { it.entryId == 1L }
assertEquals(AudioTaskState.PROCESSING, active.state)
- assertEquals("Transcribing", active.progress?.phase)
+ assertEquals("Transcribing", active.progress?.phase?.fallback)
assertEquals(25, active.progress?.percent)
val preview = tasks.single { it.entryId == 2L }
assertEquals(TaskActionKind.STOP, preview.actions.last().kind)
@@ -163,7 +164,7 @@ class AndroidMainScreenStateHostTest {
assertNull(tasks.single { it.entryId == 1L }.progress)
val owner = tasks.single { it.entryId == 2L }
assertEquals(AudioTaskState.PROCESSING, owner.state)
- assertEquals("Preparing speech model", owner.progress?.phase)
+ assertEquals("Preparing speech model", owner.progress?.phase?.fallback)
}
@Test
@@ -318,7 +319,7 @@ class AndroidMainScreenStateHostTest {
@Test
fun hydratedCatalogPublishesEmptyStateAndRetainedEntriesRemainVisibleWhileRevalidating() {
val empty = AndroidTaskListSnapshotMapper.state(readyInput())
- assertEquals("No new tasks", empty.taskList.emptyMessage)
+ assertEquals(TaskTextKey.NO_NEW_TASKS, empty.taskList.emptyMessage?.key)
val retained = AndroidTaskListSnapshotMapper.state(
readyInput(entries = listOf(entry(1, AudioFileState.PENDING))).copy(
diff --git a/app/src/test/java/me/maxistar/voiceinbox/GermanResourceParityTest.kt b/app/src/test/java/me/maxistar/voiceinbox/GermanResourceParityTest.kt
new file mode 100644
index 0000000..e9e596e
--- /dev/null
+++ b/app/src/test/java/me/maxistar/voiceinbox/GermanResourceParityTest.kt
@@ -0,0 +1,56 @@
+package me.maxistar.voiceinbox
+
+import org.junit.Assert.assertEquals
+import org.junit.Assert.assertTrue
+import org.junit.Test
+import org.w3c.dom.Element
+import java.io.File
+import javax.xml.parsers.DocumentBuilderFactory
+
+class GermanResourceParityTest {
+ @Test
+ fun germanResourcesMatchDefaultNamesAndFormatArguments() {
+ val defaultDocument = parse("src/main/res/values/strings.xml")
+ val germanDocument = parse("src/main/res/values-de/strings.xml")
+ val defaultEntries = entries(defaultDocument)
+ val germanEntries = entries(germanDocument)
+
+ assertEquals(defaultEntries.keys, germanEntries.keys)
+ defaultEntries.forEach { (name, value) ->
+ assertEquals("format arguments for $name", placeholders(value), placeholders(germanEntries.getValue(name)))
+ }
+ }
+
+ @Test
+ fun germanLocaleContainsTranslatedVoiceInboxCopy() {
+ val document = parse("src/main/res/values-de/strings.xml")
+ val entries = entries(document)
+ assertEquals("Sprachmodell installieren", entries.getValue("task_install_speech_model"))
+ assertEquals("Voice-Inbox-Tastatur", entries.getValue("voice_keyboard_name"))
+ assertTrue(entries.getValue("onboarding_title").contains("einrichten", ignoreCase = true))
+ }
+
+ private fun parse(path: String) = DocumentBuilderFactory.newInstance()
+ .newDocumentBuilder()
+ .parse(File(path))
+ .documentElement
+
+ private fun entries(root: Element): Map = buildMap {
+ val nodes = root.childNodes
+ for (index in 0 until nodes.length) {
+ val node = nodes.item(index)
+ if (node is Element && node.tagName == "string") {
+ put(node.getAttribute("name"), node.textContent)
+ } else if (node is Element && node.tagName == "plurals") {
+ for (childIndex in 0 until node.childNodes.length) {
+ val child = node.childNodes.item(childIndex)
+ if (child is Element && child.tagName == "item") {
+ put("${node.getAttribute("name")}:${child.getAttribute("quantity")}", child.textContent)
+ }
+ }
+ }
+ }
+ }
+
+ private fun placeholders(value: String): List = "%\\d+\\$[sd]".toRegex().findAll(value).map { it.value }.toList()
+}
diff --git a/app/src/test/java/me/maxistar/voiceinbox/TaskListDisplayItemsTest.kt b/app/src/test/java/me/maxistar/voiceinbox/TaskListDisplayItemsTest.kt
index bbabdde..f2df141 100644
--- a/app/src/test/java/me/maxistar/voiceinbox/TaskListDisplayItemsTest.kt
+++ b/app/src/test/java/me/maxistar/voiceinbox/TaskListDisplayItemsTest.kt
@@ -13,6 +13,8 @@ import me.maxistar.voiceinbox.core.TaskListFilter
import me.maxistar.voiceinbox.core.TaskListInput
import me.maxistar.voiceinbox.core.TaskListPresentationController
import me.maxistar.voiceinbox.core.TaskProgressPresentation
+import me.maxistar.voiceinbox.core.TaskText
+import me.maxistar.voiceinbox.core.TaskTextKey
import me.maxistar.voiceinbox.core.TranscriptionTaskSnapshot
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
@@ -59,7 +61,7 @@ class TaskListDisplayItemsTest {
assertTrue(TaskListDisplayItemDiff.areItemsTheSame(idle, active))
assertFalse(TaskListDisplayItemDiff.areContentsTheSame(idle, active))
- assertEquals("Transcribing", (active as TaskListDisplayItem.Audio).task.progress?.phase)
+ assertEquals("Transcribing", (active as TaskListDisplayItem.Audio).task.progress?.phase?.fallback)
assertEquals(20, active.task.progress?.percent)
val processed = items(filter = TaskListFilter.PROCESSED, audio = listOf(pending(7)))
@@ -73,7 +75,7 @@ class TaskListDisplayItemsTest {
.filterIsInstance()
.single()
val progress = TaskProgressPresentation(
- phase = "Transcribing",
+ phase = TaskText(TaskTextKey.OPAQUE, "Transcribing", listOf("Transcribing")),
percent = 35,
processedUs = 3_000_000,
durationUs = 10_000_000,
@@ -81,7 +83,9 @@ class TaskListDisplayItemsTest {
totalFiles = 3,
)
val progressUpdate = original.copy(task = original.task.copy(progress = progress))
- val badgeUpdate = progressUpdate.copy(task = progressUpdate.task.copy(badge = "Changed"))
+ val badgeUpdate = progressUpdate.copy(
+ task = progressUpdate.task.copy(badge = TaskText(TaskTextKey.OPAQUE, "Changed", listOf("Changed"))),
+ )
val actionsUpdate = progressUpdate.copy(task = progressUpdate.task.copy(actions = emptyList()))
assertEquals(
@@ -100,7 +104,7 @@ class TaskListDisplayItemsTest {
fun emptyStateCarriesOnlyPresentedTypedActions() {
val item = items().single() as TaskListDisplayItem.Empty
- assertEquals("No new tasks", item.message)
+ assertEquals(TaskTextKey.NO_NEW_TASKS, item.message.key)
assertEquals(
listOf(TaskActionKind.IMPORT_AUDIO),
item.actions.map { it.kind },
@@ -135,7 +139,7 @@ class TaskListDisplayItemsTest {
val changedHint = hint.copy(
steps = hint.steps.mapIndexed { index, step -> step.copy(complete = index == 0) },
- action = AndroidOnboardingHintAction("Select output file", true, TaskActionKind.SELECT_OUTPUT),
+ action = AndroidOnboardingHintAction(R.string.onboarding_action_select_output, true, TaskActionKind.SELECT_OUTPUT),
)
val oldItem = empty.first()
val newItem = items(onboardingHint = changedHint).first()
@@ -164,7 +168,7 @@ class TaskListDisplayItemsTest {
),
withBoth.map { it.stableKey },
)
- val changed = discovery.copy(setupLabel = "Choose keyboard")
+ val changed = discovery.copy(setupLabelRes = R.string.settings_voice_keyboard_choose)
assertTrue(
TaskListDisplayItemDiff.areItemsTheSame(
withBoth[1],
@@ -205,12 +209,12 @@ class TaskListDisplayItemsTest {
private fun onboardingHint() = AndroidOnboardingHintPresentation(
visible = true,
steps = listOf(
- AndroidOnboardingChecklistStep(AndroidOnboardingStepKind.MODEL, "Install speech model", false),
- AndroidOnboardingChecklistStep(AndroidOnboardingStepKind.OUTPUT, "Select transcript output", false),
- AndroidOnboardingChecklistStep(AndroidOnboardingStepKind.FOLDER, "Select audio folder · Optional", false, true),
- AndroidOnboardingChecklistStep(AndroidOnboardingStepKind.KEYBOARD, "Enable voice keyboard · Optional", false, true),
+ AndroidOnboardingChecklistStep(AndroidOnboardingStepKind.MODEL, R.string.onboarding_step_model, false),
+ AndroidOnboardingChecklistStep(AndroidOnboardingStepKind.OUTPUT, R.string.onboarding_step_output, false),
+ AndroidOnboardingChecklistStep(AndroidOnboardingStepKind.FOLDER, R.string.onboarding_step_folder, false, true),
+ AndroidOnboardingChecklistStep(AndroidOnboardingStepKind.KEYBOARD, R.string.onboarding_step_keyboard, false, true),
),
- action = AndroidOnboardingHintAction("Start setup", true, TaskActionKind.DOWNLOAD_MODEL),
+ action = AndroidOnboardingHintAction(R.string.onboarding_action_start, true, TaskActionKind.DOWNLOAD_MODEL),
)
private fun pending(id: Long) = AudioTaskSnapshot(
diff --git a/fastlane/Fastfile b/fastlane/Fastfile
new file mode 100644
index 0000000..9187d5f
--- /dev/null
+++ b/fastlane/Fastfile
@@ -0,0 +1,62 @@
+default_platform(:android)
+
+platform :android do
+ desc "Fail when a version code already exists on a Google Play track"
+ lane :validate_play_version_code do |options|
+ version_code = Integer(options.fetch(:version_code))
+ json_key = File.expand_path(options.fetch(:json_key))
+ tracks = %w[internal alpha beta production]
+ existing = tracks.to_h do |track|
+ codes = google_play_track_version_codes(
+ package_name: "me.maxistar.voiceinbox",
+ json_key: json_key,
+ track: track
+ ).map(&:to_i)
+ [track, codes]
+ end
+ duplicates = existing.select { |_track, codes| codes.include?(version_code) }.keys
+ UI.user_error!("versionCode #{version_code} already exists on: #{duplicates.join(', ')}") unless duplicates.empty?
+ end
+
+ desc "Require a version code to exist on one explicit Google Play track"
+ lane :assert_track_version_code do |options|
+ version_code = Integer(options.fetch(:version_code))
+ track = options.fetch(:track)
+ codes = google_play_track_version_codes(
+ package_name: "me.maxistar.voiceinbox",
+ json_key: File.expand_path(options.fetch(:json_key)),
+ track: track
+ ).map(&:to_i)
+ UI.user_error!("versionCode #{version_code} does not exist on #{track}") unless codes.include?(version_code)
+ end
+
+ desc "Upload one already-built AAB and localized metadata to an explicit Play track"
+ lane :upload_release do |options|
+ aab = File.expand_path(options.fetch(:aab))
+ version_code = Integer(options.fetch(:version_code))
+ json_key = File.expand_path(options.fetch(:json_key))
+ metadata_path = File.expand_path(options.fetch(:metadata_path))
+ track = options.fetch(:track)
+ UI.user_error!("AAB does not exist: #{aab}") unless File.file?(aab)
+ existing = google_play_track_version_codes(
+ package_name: "me.maxistar.voiceinbox",
+ json_key: json_key,
+ track: track
+ ).map(&:to_i)
+ if existing.include?(version_code)
+ UI.message("versionCode #{version_code} already exists on #{track}; upload is complete")
+ next
+ end
+ upload_to_play_store(
+ package_name: "me.maxistar.voiceinbox",
+ aab: aab,
+ json_key: json_key,
+ metadata_path: metadata_path,
+ track: track,
+ release_status: "completed",
+ skip_upload_apk: true,
+ skip_upload_images: true,
+ skip_upload_screenshots: true
+ )
+ end
+end
diff --git a/fastlane/metadata/android/en-US/changelogs/8.txt b/fastlane/metadata/android/en-US/changelogs/8.txt
new file mode 100644
index 0000000..1845d8f
--- /dev/null
+++ b/fastlane/metadata/android/en-US/changelogs/8.txt
@@ -0,0 +1,3 @@
+RELEASE NOTES (0.5.0)
+
+Improved voice keyboard experience, added Copy All action, expanded localization, and updated onboarding.
\ No newline at end of file
diff --git a/fastlane/metadata/android/en-US/changelogs/default.txt b/fastlane/metadata/android/en-US/changelogs/default.txt
index 53b4f2b..43057e9 100644
--- a/fastlane/metadata/android/en-US/changelogs/default.txt
+++ b/fastlane/metadata/android/en-US/changelogs/default.txt
@@ -1,6 +1 @@
-Initial Google Play release.
-
-• Import individual audio files or select a recording folder
-• Transcribe recordings locally after installing the speech model
-• Append results to a Markdown or plain-text document
-• Review, play, retry, and process recordings from the task list
+Bug fixes and improvements.
diff --git a/fastlane/metadata/android/ru-RU/changelogs/8.txt b/fastlane/metadata/android/ru-RU/changelogs/8.txt
new file mode 100644
index 0000000..aea4248
--- /dev/null
+++ b/fastlane/metadata/android/ru-RU/changelogs/8.txt
@@ -0,0 +1,3 @@
+RELEASE NOTES (0.5.0)
+
+Улучшена работа голосовой клавиатуры, добавлена кнопка «Копировать всё», расширены переводы и обновлён онбординг.
\ No newline at end of file
diff --git a/fastlane/metadata/android/ru-RU/changelogs/default.txt b/fastlane/metadata/android/ru-RU/changelogs/default.txt
index dd32797..62cca67 100644
--- a/fastlane/metadata/android/ru-RU/changelogs/default.txt
+++ b/fastlane/metadata/android/ru-RU/changelogs/default.txt
@@ -1,6 +1 @@
-Первый выпуск в Google Play.
-
-• Импорт отдельных аудиофайлов или выбор папки с записями
-• Локальная расшифровка после установки речевой модели
-• Добавление результатов в документ Markdown или обычного текста
-• Просмотр, прослушивание, повторная и пакетная обработка записей
+Исправления ошибок и улучшения.
diff --git a/iosApp/VoiceInbox.xcodeproj/project.pbxproj b/iosApp/VoiceInbox.xcodeproj/project.pbxproj
index f8f2280..413e0e0 100644
--- a/iosApp/VoiceInbox.xcodeproj/project.pbxproj
+++ b/iosApp/VoiceInbox.xcodeproj/project.pbxproj
@@ -7,6 +7,11 @@
objects = {
/* Begin PBXBuildFile section */
+ E60000000000000000000001 /* ShareExtensionLocalization.swift in Sources */ = {isa = PBXBuildFile; fileRef = E60000000000000000000002 /* ShareExtensionLocalization.swift */; };
+ E60000000000000000000005 /* ShareExtensionLocalization.swift in Sources */ = {isa = PBXBuildFile; fileRef = E60000000000000000000002 /* ShareExtensionLocalization.swift */; };
+ E60000000000000000000003 /* Localizable.xcstrings in Resources */ = {isa = PBXBuildFile; fileRef = E60000000000000000000004 /* Localizable.xcstrings */; };
+ E50000000000000000000001 /* IosLocalization.swift in Sources */ = {isa = PBXBuildFile; fileRef = E50000000000000000000002 /* IosLocalization.swift */; };
+ E50000000000000000000003 /* Localizable.xcstrings in Resources */ = {isa = PBXBuildFile; fileRef = E50000000000000000000004 /* Localizable.xcstrings */; };
101010101010101010101001 /* VoiceInboxApp.swift in Sources */ = {isa = PBXBuildFile; fileRef = 101010101010101010101011 /* VoiceInboxApp.swift */; };
101010101010101010101002 /* ContentView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 101010101010101010101012 /* ContentView.swift */; };
101010101010101010101021 /* IosScheduledTranscriptionSettingsStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = 101010101010101010101023 /* IosScheduledTranscriptionSettingsStore.swift */; };
@@ -64,6 +69,10 @@
/* End PBXCopyFilesBuildPhase section */
/* Begin PBXFileReference section */
+ E60000000000000000000002 /* ShareExtensionLocalization.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ShareExtensionLocalization.swift; sourceTree = ""; };
+ E60000000000000000000004 /* Localizable.xcstrings */ = {isa = PBXFileReference; lastKnownFileType = text.json.xcstrings; path = Localizable.xcstrings; sourceTree = ""; };
+ E50000000000000000000002 /* IosLocalization.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = IosLocalization.swift; sourceTree = ""; };
+ E50000000000000000000004 /* Localizable.xcstrings */ = {isa = PBXFileReference; lastKnownFileType = text.json.xcstrings; path = Localizable.xcstrings; sourceTree = ""; };
101010101010101010101003 /* VoiceInbox.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = VoiceInbox.app; sourceTree = BUILT_PRODUCTS_DIR; };
101010101010101010101011 /* VoiceInboxApp.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VoiceInboxApp.swift; sourceTree = ""; };
101010101010101010101012 /* ContentView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContentView.swift; sourceTree = ""; };
@@ -144,6 +153,8 @@
children = (
101010101010101010101011 /* VoiceInboxApp.swift */,
101010101010101010101012 /* ContentView.swift */,
+ E50000000000000000000002 /* IosLocalization.swift */,
+ E50000000000000000000004 /* Localizable.xcstrings */,
101010101010101010101030 /* IosAudioDocumentPicker.swift */,
101010101010101010101029 /* IosAudioImportStore.swift */,
C30000000000000000000002 /* IosAudioMetadataFormatter.swift */,
@@ -167,6 +178,8 @@
isa = PBXGroup;
children = (
A2A88E9CFF2023E878C6EC22 /* ShareViewController.swift */,
+ E60000000000000000000002 /* ShareExtensionLocalization.swift */,
+ E60000000000000000000004 /* Localizable.xcstrings */,
CD030301C0E14C9932DCBFE6 /* IosSharedImportStaging.swift */,
F63411837D138D0E90F5BA1D /* Info.plist */,
A3B22C5C663FBCBBFAB0A81F /* VoiceInboxShareExtension.entitlements */,
@@ -268,6 +281,7 @@
hasScannedForEncodings = 0;
knownRegions = (
en,
+ ru,
Base,
);
mainGroup = 101010101010101010101005;
@@ -288,6 +302,7 @@
buildActionMask = 2147483647;
files = (
101010101010101010101033 /* Assets.xcassets in Resources */,
+ E50000000000000000000003 /* Localizable.xcstrings in Resources */,
);
runOnlyForDeploymentPostprocessing = 0;
};
@@ -295,6 +310,7 @@
isa = PBXResourcesBuildPhase;
buildActionMask = 2147483647;
files = (
+ E60000000000000000000003 /* Localizable.xcstrings in Resources */,
);
runOnlyForDeploymentPostprocessing = 0;
};
@@ -359,6 +375,8 @@
files = (
101010101010101010101001 /* VoiceInboxApp.swift in Sources */,
101010101010101010101002 /* ContentView.swift in Sources */,
+ E50000000000000000000001 /* IosLocalization.swift in Sources */,
+ E60000000000000000000005 /* ShareExtensionLocalization.swift in Sources */,
101010101010101010101028 /* IosAudioDocumentPicker.swift in Sources */,
101010101010101010101027 /* IosAudioImportStore.swift in Sources */,
101010101010101010101031 /* IosAudioPreviewPlayer.swift in Sources */,
@@ -380,6 +398,7 @@
buildActionMask = 2147483647;
files = (
361EE03677F4524C001986C0 /* ShareViewController.swift in Sources */,
+ E60000000000000000000001 /* ShareExtensionLocalization.swift in Sources */,
9FAC76B26E6BD4116D968475 /* IosSharedImportStaging.swift in Sources */,
);
runOnlyForDeploymentPostprocessing = 0;
diff --git a/iosApp/VoiceInbox/ContentView.swift b/iosApp/VoiceInbox/ContentView.swift
index 44b3836..ff3365c 100644
--- a/iosApp/VoiceInbox/ContentView.swift
+++ b/iosApp/VoiceInbox/ContentView.swift
@@ -58,13 +58,13 @@ struct ContentView: View {
)
.font(.caption)
.foregroundStyle(.secondary)
- Text("Installing this model replaces the currently installed model.")
+ Text(L10n.text("model.replacementWarning", fallback: "Installing this model replaces the currently installed model."))
.font(.caption)
.foregroundStyle(.secondary)
}
HStack {
- Button("Install") {
+ Button(L10n.text("common.install", fallback: "Install")) {
speechModelStore.confirmPendingInstallation(
candidate: candidate,
replacementAllowed: !transcriber.isActive
@@ -73,7 +73,7 @@ struct ContentView: View {
.buttonStyle(.borderedProminent)
.disabled(speechModelStore.isBusy || transcriber.isActive)
- Button("Cancel", role: .cancel) {
+ Button(L10n.text("common.cancel", fallback: "Cancel"), role: .cancel) {
speechModelStore.cancelPendingInstallation()
}
.buttonStyle(.bordered)
@@ -96,22 +96,20 @@ struct ContentView: View {
.font(.caption)
.foregroundStyle(.secondary)
if speechModelStore.activeDescriptor != nil {
- Text("Downloading this model replaces the currently installed model.")
+ Text(L10n.text("model.downloadReplacementWarning", fallback: "Downloading this model replaces the currently installed model."))
.font(.caption)
.foregroundStyle(.secondary)
}
}
- Button("Download") {
+ Button(L10n.text("common.download", fallback: "Download")) {
speechModelStore.downloadModel(speechModelStore.selectedDownloadModel)
}
.buttonStyle(.borderedProminent)
.disabled(speechModelStore.isBusy)
- let modelTask = screen.state.tasks
- .compactMap({ $0 as? SetupTaskPresentation })
- .first(where: { $0.kind == .model })
- if let error = modelTask?.errorMessage, !error.isEmpty {
- Text(error)
+ let modelTask = modelSetupTask(in: screen)
+ if let error = modelTask?.errorMessage, !error.fallback.isEmpty {
+ Text(IosTaskTextResolver.resolve(error))
.font(.footnote)
.foregroundStyle(.red)
}
@@ -151,7 +149,7 @@ struct ContentView: View {
transcribeAll()
} label: {
Label(
- "Transcribe All (\(screen.state.batchAction.eligibleCount))",
+ L10n.plural("catalog.transcribeAll", count: Int(screen.state.batchAction.eligibleCount), fallback: "Transcribe All (%d)"),
systemImage: "text.badge.checkmark"
)
}
@@ -169,10 +167,10 @@ struct ContentView: View {
if let emptyMessage = screen.state.emptyMessage {
VStack(alignment: .leading, spacing: 8) {
- Text(emptyMessage)
+ Text(IosTaskTextResolver.resolve(emptyMessage))
.foregroundStyle(.secondary)
ForEach(Array(screen.state.emptyActions.enumerated()), id: \.offset) { _, action in
- Button(action.label) {
+ Button(IosTaskTextResolver.resolve(action.text)) {
perform(action: action, task: nil, screen: screen)
}
.buttonStyle(.borderless)
@@ -186,20 +184,20 @@ struct ContentView: View {
Button {
presentPicker(.audioFiles)
} label: {
- Label("Import Audio Files", systemImage: "square.and.arrow.down")
+ Label(L10n.text("catalog.importAudio", fallback: "Import Audio Files"), systemImage: "square.and.arrow.down")
}
if !importStore.inboxFolderStatus.needsSelection {
Button {
importStore.refreshInboxFolder()
selectedTab = .new
} label: {
- Label("Refresh Audio Folder", systemImage: "arrow.clockwise")
+ Label(L10n.text("catalog.refreshFolder", fallback: "Refresh Audio Folder"), systemImage: "arrow.clockwise")
}
.disabled(importStore.isScanningFolder)
}
}
}
- .navigationTitle("Voice Inbox")
+ .navigationTitle(L10n.text("app.title", fallback: "Voice Inbox"))
.navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItem(placement: .navigationBarTrailing) {
@@ -220,7 +218,7 @@ struct ContentView: View {
},
installModelPackage: {
guard !transcriber.isActive else {
- speechModelStore.message = "Wait for transcription to finish before replacing the speech model."
+ speechModelStore.message = L10n.text("model.waitForTranscription", fallback: "Wait for transcription to finish before replacing the speech model.")
return
}
presentPicker(.speechModelFolder)
@@ -229,7 +227,7 @@ struct ContentView: View {
} label: {
Image(systemName: "gearshape")
}
- .accessibilityLabel("Settings")
+ .accessibilityLabel(L10n.text("settings.title", fallback: "Settings"))
}
}
.sheet(item: $presentedPicker) { picker in
@@ -314,7 +312,7 @@ struct ContentView: View {
Alert(
title: Text(message.title),
message: Text(message.text),
- dismissButton: .default(Text("OK"))
+ dismissButton: .default(Text(L10n.text("common.ok", fallback: "OK")))
)
}
}
@@ -562,15 +560,15 @@ struct ContentView: View {
private func startStartupProcessing() {
guard importStore.pendingCount > 0 else { return }
guard !transcriber.isActive else {
- importStore.importMessage = "Found files to process, but transcription is already running."
+ importStore.importMessage = L10n.text("startup.alreadyRunning", fallback: "Found files to process, but transcription is already running.")
return
}
guard transcriber.backendConfigured else {
- importStore.importMessage = "Found files to process, but the iOS transcription backend is not configured."
+ importStore.importMessage = L10n.text("startup.backendUnavailable", fallback: "Found files to process, but the iOS transcription backend is not configured.")
return
}
guard speechModelStore.isReady, !speechModelStore.isBusy else {
- importStore.importMessage = "Found files to process, but the speech model is not ready."
+ importStore.importMessage = L10n.text("startup.modelNotReady", fallback: "Found files to process, but the speech model is not ready.")
return
}
let outputDocument = outputStore.currentDocument()
@@ -588,6 +586,16 @@ struct ContentView: View {
)
}
+ private func modelSetupTask(in screen: IosTaskListScreen) -> SetupTaskPresentation? {
+ for task in screen.state.tasks {
+ guard let setupTask = task as? SetupTaskPresentation else { continue }
+ if setupTask.kind == .model {
+ return setupTask
+ }
+ }
+ return nil
+ }
+
}
private enum IosPresentedPicker: String, Identifiable {
@@ -610,16 +618,16 @@ private struct TaskListRow: View {
VStack(alignment: .leading, spacing: 10) {
HStack(alignment: .top) {
VStack(alignment: .leading, spacing: 4) {
- Text(task.title)
+ Text(IosTaskTextResolver.resolve(task.title))
.font(.headline)
- if let detail = task.detail, !detail.isEmpty {
- Text(detail)
+ if let detail = task.detail, !detail.fallback.isEmpty {
+ Text(IosTaskTextResolver.resolve(detail))
.font(.subheadline)
.foregroundStyle(.secondary)
}
}
Spacer()
- Text(task.badge)
+ Text(IosTaskTextResolver.resolve(task.badge))
.font(.caption)
.padding(.horizontal, 8)
.padding(.vertical, 4)
@@ -632,13 +640,13 @@ private struct TaskListRow: View {
Image(systemName: "xmark")
}
.buttonStyle(.borderless)
- .accessibilityLabel("Hide automatic transcript export")
+ .accessibilityLabel(L10n.text("catalog.hideExport", fallback: "Hide automatic transcript export"))
.accessibilityIdentifier("task-dismiss-\(task.stableId)")
}
}
- if let error = task.errorMessage, !error.isEmpty {
- Text(error)
+ if let error = task.errorMessage, !error.fallback.isEmpty {
+ Text(IosTaskTextResolver.resolve(error))
.font(.footnote)
.foregroundStyle(.red)
}
@@ -661,7 +669,7 @@ private struct TaskListRow: View {
if !visibleActions.isEmpty {
HStack {
ForEach(Array(visibleActions.enumerated()), id: \.offset) { _, action in
- Button(action.label) { onAction(action) }
+ Button(IosTaskTextResolver.resolve(action.text)) { onAction(action) }
.disabled(!action.enabled)
.accessibilityIdentifier("task-action-\(task.stableId)-\(action.kind.name.lowercased())")
}
@@ -674,17 +682,18 @@ private struct TaskListRow: View {
}
private func progressLabel(_ progress: TaskProgressPresentation) -> String {
- var parts = [progress.phase]
+ var parts = [IosTaskTextResolver.resolve(progress.phase)]
if let completed = progress.completedFiles?.int32Value,
let total = progress.totalFiles?.int32Value,
total > 0 {
- parts.append("\(completed) / \(total) files")
+ parts.append(L10n.format("task.progressFiles", fallback: "%d / %d files", completed, total))
}
if let failed = progress.failedFiles?.int32Value, failed > 0 {
- parts.append("\(failed) failed")
+ parts.append(L10n.format("task.progressFailed", fallback: "%d failed", failed))
}
return parts.joined(separator: " • ")
}
+
}
private struct IosGlobalMessage: Identifiable {
@@ -709,7 +718,7 @@ private struct StartupProcessingPromptView: View {
NavigationStack {
VStack(alignment: .leading, spacing: 20) {
VStack(alignment: .leading, spacing: 8) {
- Text("Found files to process. Process now?")
+ Text(L10n.text("startup.prompt", fallback: "Found files to process. Process now?"))
.font(.title3)
.fontWeight(.semibold)
Text(summary)
@@ -721,15 +730,15 @@ private struct StartupProcessingPromptView: View {
Spacer()
}
.padding()
- .navigationTitle("Process Files")
+ .navigationTitle(L10n.text("startup.processFiles", fallback: "Process Files"))
.toolbar {
ToolbarItem(placement: .cancellationAction) {
- Button("No") {
+ Button(L10n.text("settings.startup.no", fallback: "No")) {
onNo(alwaysAtStartup)
}
}
ToolbarItem(placement: .confirmationAction) {
- Button("Yes") {
+ Button(L10n.text("settings.startup.yes", fallback: "Yes")) {
onYes(alwaysAtStartup)
}
.buttonStyle(.borderedProminent)
@@ -743,6 +752,6 @@ private struct StartupProcessingPromptView: View {
if pendingCount == 1 {
return "1 file is waiting in New."
}
- return "\(pendingCount) files are waiting in New."
+ return L10n.plural("startup.filesWaiting", count: pendingCount, fallback: "%d files are waiting in New.")
}
}
diff --git a/iosApp/VoiceInbox/IosAudioDocumentPicker.swift b/iosApp/VoiceInbox/IosAudioDocumentPicker.swift
index e61a509..6cac4b2 100644
--- a/iosApp/VoiceInbox/IosAudioDocumentPicker.swift
+++ b/iosApp/VoiceInbox/IosAudioDocumentPicker.swift
@@ -156,7 +156,8 @@ struct IosOutputDocumentStatus {
let ready: Bool
var title: String {
- displayName.map { "Output: \($0)" } ?? "Output file not selected"
+ displayName.map { L10n.format("output.selected", fallback: "Output: %@", $0) }
+ ?? L10n.text("output.noneSelected", fallback: "Output file not selected")
}
}
@@ -164,7 +165,7 @@ struct IosOutputDocumentStatus {
final class IosOutputDocumentStore: ObservableObject {
@Published private(set) var status = IosOutputDocumentStatus(
displayName: nil,
- message: "Choose a text or Markdown file for transcripts.",
+ message: L10n.text("output.choose", fallback: "Choose a text or Markdown file for transcripts."),
ready: false
)
@@ -193,7 +194,7 @@ final class IosOutputDocumentStore: ObservableObject {
guard Self.isSupportedOutput(url) else {
status = IosOutputDocumentStatus(
displayName: url.lastPathComponent,
- message: "Choose a .txt, .md, or .markdown file.",
+ message: L10n.text("output.unsupported", fallback: "Choose a .txt, .md, or .markdown file."),
ready: false
)
return
@@ -223,14 +224,14 @@ final class IosOutputDocumentStore: ObservableObject {
)
status = IosOutputDocumentStatus(
displayName: url.lastPathComponent,
- message: "Ready to append transcripts.",
+ message: L10n.text("output.ready", fallback: "Ready to append transcripts."),
ready: true
)
} catch {
selectedDocument = nil
status = IosOutputDocumentStatus(
displayName: url.lastPathComponent,
- message: "Output file is not writable. Choose another file.",
+ message: L10n.text("output.notWritable", fallback: "Output file is not writable. Choose another file."),
ready: false
)
}
@@ -247,7 +248,7 @@ final class IosOutputDocumentStore: ObservableObject {
selectedDocument = nil
status = IosOutputDocumentStatus(
displayName: nil,
- message: "Automatic export is off. Transcripts stay in Voice Inbox.",
+ message: L10n.text("output.disabled", fallback: "Automatic export is off. Transcripts stay in Voice Inbox."),
ready: false
)
}
@@ -262,7 +263,7 @@ final class IosOutputDocumentStore: ObservableObject {
selectedDocument = nil
status = IosOutputDocumentStatus(
displayName: nil,
- message: "Choose a text or Markdown file for transcripts.",
+ message: L10n.text("output.choose", fallback: "Choose a text or Markdown file for transcripts."),
ready: false
)
return
@@ -281,7 +282,7 @@ final class IosOutputDocumentStore: ObservableObject {
selectedDocument = nil
status = IosOutputDocumentStatus(
displayName: savedName,
- message: "Output access needs to be refreshed. Choose the file again.",
+ message: L10n.text("output.refreshAccess", fallback: "Output access needs to be refreshed. Choose the file again."),
ready: false
)
return
@@ -294,14 +295,14 @@ final class IosOutputDocumentStore: ObservableObject {
)
status = IosOutputDocumentStatus(
displayName: url.lastPathComponent,
- message: "Ready to append transcripts.",
+ message: L10n.text("output.ready", fallback: "Ready to append transcripts."),
ready: true
)
} catch {
selectedDocument = nil
status = IosOutputDocumentStatus(
displayName: savedName,
- message: "Could not write to the selected output file. Choose it again.",
+ message: L10n.text("output.writeFailed", fallback: "Could not write to the selected output file. Choose it again."),
ready: false
)
}
@@ -335,12 +336,12 @@ final class IosOutputDocumentStore: ObservableObject {
}
try handle.seekToEnd()
guard let data = text.data(using: .utf8) else {
- return "Transcript text could not be encoded as UTF-8."
+ return L10n.text("output.encodingFailed", fallback: "Transcript text could not be encoded as UTF-8.")
}
try handle.write(contentsOf: data)
return nil
} catch {
- return "Could not append to the selected output file."
+ return L10n.text("output.appendFailed", fallback: "Could not append to the selected output file.")
}
}
diff --git a/iosApp/VoiceInbox/IosAudioImportStore.swift b/iosApp/VoiceInbox/IosAudioImportStore.swift
index f1d6409..2d82fce 100644
--- a/iosApp/VoiceInbox/IosAudioImportStore.swift
+++ b/iosApp/VoiceInbox/IosAudioImportStore.swift
@@ -43,13 +43,13 @@ enum IosImportedAudioStatus: String, Codable {
var badge: String {
switch self {
case .pending:
- "New"
+ L10n.text("catalog.new", fallback: "New")
case .processing:
- "Processing"
+ L10n.text("task.processing", fallback: "Processing")
case .processed:
- "Processed"
+ L10n.text("catalog.processed", fallback: "Processed")
case .failed:
- "Failed"
+ L10n.text("task.failed", fallback: "Failed")
}
}
}
@@ -130,7 +130,7 @@ struct IosAudioImportSummary {
if imported > 0 { parts.append("\(imported) imported") }
if skipped > 0 { parts.append("\(skipped) skipped") }
if failed > 0 { parts.append("\(failed) failed") }
- return parts.isEmpty ? "No files imported" : parts.joined(separator: ", ")
+ return parts.isEmpty ? L10n.text("import.none", fallback: "No files imported") : parts.joined(separator: ", ")
}
var alertMessage: String? {
@@ -161,7 +161,7 @@ struct IosInboxFolderStatus {
}
var title: String {
- displayName ?? "No audio folder selected"
+ displayName ?? L10n.text("folder.noneSelected", fallback: "No audio folder selected")
}
}
@@ -170,7 +170,7 @@ final class IosAudioImportStore: ObservableObject {
@Published private(set) var files: [IosImportedAudioFile] = []
@Published private(set) var inboxFolderStatus = IosInboxFolderStatus(
displayName: nil,
- message: "Choose a folder to use it as your audio inbox.",
+ message: L10n.text("folder.choose", fallback: "Choose a folder to use it as your audio inbox."),
needsSelection: true
)
@Published private(set) var isScanningFolder = false
diff --git a/iosApp/VoiceInbox/IosAudioPreviewPlayer.swift b/iosApp/VoiceInbox/IosAudioPreviewPlayer.swift
index 3d5d8ab..3f7878a 100644
--- a/iosApp/VoiceInbox/IosAudioPreviewPlayer.swift
+++ b/iosApp/VoiceInbox/IosAudioPreviewPlayer.swift
@@ -37,14 +37,14 @@ final class IosAudioPreviewPlayer: NSObject, ObservableObject, AVAudioPlayerDele
player.delegate = self
player.prepareToPlay()
guard player.play() else {
- errorMessage = "Could not start audio playback."
+ errorMessage = L10n.text("playback.couldNotStart", fallback: "Could not start audio playback.")
return
}
self.player = player
playingFileId = fileId
errorMessage = nil
} catch {
- errorMessage = "Could not play \(url.lastPathComponent): \(error.localizedDescription)"
+ errorMessage = L10n.format("playback.couldNotPlay", fallback: "Could not play %@: %@", url.lastPathComponent, error.localizedDescription)
}
}
@@ -73,7 +73,7 @@ final class IosAudioPreviewPlayer: NSObject, ObservableObject, AVAudioPlayerDele
self.deactivateAudioSession()
}
if !flag {
- self.errorMessage = "Playback stopped before the file finished."
+ self.errorMessage = L10n.text("playback.stoppedEarly", fallback: "Playback stopped before the file finished.")
}
}
}
@@ -85,7 +85,7 @@ final class IosAudioPreviewPlayer: NSObject, ObservableObject, AVAudioPlayerDele
self.playingFileId = nil
self.deactivateAudioSession()
}
- self.errorMessage = error?.localizedDescription ?? "Could not decode audio for playback."
+ self.errorMessage = error?.localizedDescription ?? L10n.text("playback.decodeFailed", fallback: "Could not decode audio for playback.")
}
}
}
diff --git a/iosApp/VoiceInbox/IosInlineOnboarding.swift b/iosApp/VoiceInbox/IosInlineOnboarding.swift
index 518009f..bce6cfb 100644
--- a/iosApp/VoiceInbox/IosInlineOnboarding.swift
+++ b/iosApp/VoiceInbox/IosInlineOnboarding.swift
@@ -66,8 +66,10 @@ struct IosOnboardingChecklistStep: Identifiable {
var id: String { kind.rawValue }
var accessibilityLabel: String {
- let state = complete ? "Completed" : "Not completed"
- let optionalSuffix = optional ? ", optional" : ""
+ let state = complete
+ ? L10n.text("onboarding.completed", fallback: "Completed")
+ : L10n.text("onboarding.notCompleted", fallback: "Not completed")
+ let optionalSuffix = optional ? ", \(L10n.text("onboarding.optional", fallback: "optional"))" : ""
return "\(state): \(label)\(optionalSuffix)"
}
}
@@ -91,8 +93,8 @@ struct IosOnboardingHintPresentation {
init(
visible: Bool = false,
- title: String = "Set up Voice Inbox",
- explanation: String = "Follow these steps, or use the setup tasks above in any order.",
+ title: String = L10n.text("onboarding.title", fallback: "Set up Voice Inbox"),
+ explanation: String = L10n.text("onboarding.explanation", fallback: "Follow these steps, or use the setup tasks above in any order."),
downloadDisclosure: String? = nil,
steps: [IosOnboardingChecklistStep] = [],
action: IosOnboardingHintAction? = nil
@@ -127,24 +129,24 @@ enum IosOnboardingHintPresenter {
return IosOnboardingHintPresentation(
visible: true,
downloadDisclosure: networkActions.contains { $0 == action.kind } && action.enabled
- ? "This action downloads the speech model to this device."
+ ? L10n.text("onboarding.downloadDisclosure", fallback: "This action downloads the speech model to this device.")
: nil,
steps: [
IosOnboardingChecklistStep(
kind: .model,
- label: "Install speech model",
+ label: L10n.text("onboarding.installModel", fallback: "Install speech model"),
complete: model.state == .ready,
optional: false
),
IosOnboardingChecklistStep(
kind: .output,
- label: "Configure automatic transcript export",
+ label: L10n.text("onboarding.configureExport", fallback: "Configure automatic transcript export"),
complete: output.state == .ready,
optional: true
),
IosOnboardingChecklistStep(
kind: .folder,
- label: "Select audio folder",
+ label: L10n.text("onboarding.selectFolder", fallback: "Select audio folder"),
complete: folder.state == .ready,
optional: true
),
@@ -180,7 +182,7 @@ enum IosOnboardingHintPresenter {
) -> IosOnboardingHintAction {
if model.state == .installing {
return IosOnboardingHintAction(
- label: "Installing speech model…",
+ label: L10n.text("onboarding.installingModel", fallback: "Installing speech model…"),
enabled: false,
kind: .downloadModel
)
@@ -188,27 +190,29 @@ enum IosOnboardingHintPresenter {
if model.state != .ready, model.downloadAvailable {
let retry = model.state == .invalid
return IosOnboardingHintAction(
- label: retry ? "Retry setup" : "Start setup",
+ label: retry
+ ? L10n.text("onboarding.retrySetup", fallback: "Retry setup")
+ : L10n.text("onboarding.startSetup", fallback: "Start setup"),
enabled: true,
kind: retry ? .retryModelDownload : .downloadModel
)
}
if model.state != .ready {
return IosOnboardingHintAction(
- label: "Install model from folder",
+ label: L10n.text("onboarding.installModelFromFolder", fallback: "Install model from folder"),
enabled: true,
kind: .importModel
)
}
if output.state != .ready {
return IosOnboardingHintAction(
- label: "Select Output File",
+ label: L10n.text("onboarding.selectOutput", fallback: "Select Output File"),
enabled: true,
kind: .selectOutput
)
}
return IosOnboardingHintAction(
- label: "Select audio folder (optional)",
+ label: L10n.text("onboarding.selectOptionalFolder", fallback: "Select audio folder (optional)"),
enabled: folder.state != .scanning,
kind: .selectFolder
)
@@ -251,7 +255,7 @@ struct IosInlineOnboardingRow: View {
Image(systemName: "xmark")
}
.buttonStyle(.plain)
- .accessibilityLabel("Dismiss setup guide")
+ .accessibilityLabel(L10n.text("onboarding.dismiss", fallback: "Dismiss setup guide"))
.accessibilityIdentifier("onboarding-dismiss")
}
@@ -271,7 +275,7 @@ struct IosInlineOnboardingRow: View {
HStack(alignment: .firstTextBaseline, spacing: 8) {
Image(systemName: step.complete ? "checkmark.circle.fill" : "circle")
.foregroundStyle(step.complete ? .green : .secondary)
- Text(step.optional ? "\(step.label) · Optional" : step.label)
+ Text(step.optional ? "\(step.label) · \(L10n.text("onboarding.optionalLabel", fallback: "Optional"))" : step.label)
}
.accessibilityElement(children: .ignore)
.accessibilityLabel(step.accessibilityLabel)
diff --git a/iosApp/VoiceInbox/IosLocalization.swift b/iosApp/VoiceInbox/IosLocalization.swift
new file mode 100644
index 0000000..4754ad4
--- /dev/null
+++ b/iosApp/VoiceInbox/IosLocalization.swift
@@ -0,0 +1,70 @@
+import Foundation
+import Shared
+
+enum L10n {
+ static func text(_ key: String, fallback: String) -> String {
+ NSLocalizedString(key, value: fallback, comment: "")
+ }
+
+ static func format(_ key: String, fallback: String, _ arguments: CVarArg...) -> String {
+ String(format: text(key, fallback: fallback), locale: .current, arguments: arguments)
+ }
+
+ static func plural(_ key: String, count: Int, fallback: String) -> String {
+ format("\(key).\(pluralForm(for: count, languageCode: Locale.current.language.languageCode?.identifier))", fallback: fallback, count)
+ }
+
+ static func pluralForm(for count: Int, languageCode: String?) -> String {
+ if languageCode == "ru" {
+ let lastTwo = count % 100
+ let last = count % 10
+ return (last == 1 && lastTwo != 11) ? "one" :
+ ((2...4).contains(last) && !(12...14).contains(lastTwo) ? "few" : "many")
+ }
+ return count == 1 ? "one" : "other"
+ }
+}
+
+enum IosTaskTextResolver {
+ static func resolve(_ text: TaskText) -> String {
+ switch text.key.name {
+ case "INSTALL_SPEECH_MODEL": return L10n.text("task.installSpeechModel", fallback: text.fallback)
+ case "AUTOMATIC_TRANSCRIPT_EXPORT": return L10n.text("task.automaticTranscriptExport", fallback: text.fallback)
+ case "OUTPUT_OPTIONAL_DETAIL": return L10n.text("task.outputOptionalDetail", fallback: text.fallback)
+ case "REFRESH_AUDIO_FOLDER": return L10n.text("task.refreshAudioFolder", fallback: text.fallback)
+ case "RESTORE_AUDIO_FOLDER_ACCESS": return L10n.text("task.restoreAudioFolderAccess", fallback: text.fallback)
+ case "REQUIRED": return L10n.text("task.required", fallback: text.fallback)
+ case "OPTIONAL": return L10n.text("task.optional", fallback: text.fallback)
+ case "INSTALLING": return L10n.text("task.installing", fallback: text.fallback)
+ case "SCANNING": return L10n.text("task.scanning", fallback: text.fallback)
+ case "NEEDS_ATTENTION": return L10n.text("task.needsAttention", fallback: text.fallback)
+ case "NEW": return L10n.text("task.new", fallback: text.fallback)
+ case "PROCESSING": return L10n.text("task.processing", fallback: text.fallback)
+ case "PROCESSED": return L10n.text("task.processed", fallback: text.fallback)
+ case "FAILED": return L10n.text("task.failed", fallback: text.fallback)
+ case "NO_SPEECH": return L10n.text("task.noSpeech", fallback: text.fallback)
+ case "CANCEL": return L10n.text("task.cancel", fallback: text.fallback)
+ case "RETRY_DOWNLOAD": return L10n.text("task.retryDownload", fallback: text.fallback)
+ case "DOWNLOAD": return L10n.text("task.download", fallback: text.fallback)
+ case "CREATE_NEW": return L10n.text("task.createNew", fallback: text.fallback)
+ case "CHOOSE_EXISTING": return L10n.text("task.chooseExisting", fallback: text.fallback)
+ case "HIDE": return L10n.text("task.hide", fallback: text.fallback)
+ case "SELECT_FOLDER": return L10n.text("task.selectFolder", fallback: text.fallback)
+ case "RETRY": return L10n.text("task.retry", fallback: text.fallback)
+ case "TRANSCRIBE": return L10n.text("task.transcribe", fallback: text.fallback)
+ case "SHOW_TEXT": return L10n.text("task.showText", fallback: text.fallback)
+ case "STOP": return L10n.text("task.stop", fallback: text.fallback)
+ case "PLAY": return L10n.text("task.play", fallback: text.fallback)
+ case "IMPORT_AUDIO_FILES": return L10n.text("task.importAudioFiles", fallback: text.fallback)
+ case "SELECT_AUDIO_FOLDER": return L10n.text("task.selectAudioFolder", fallback: text.fallback)
+ case "NO_NEW_TASKS": return L10n.text("task.noNewTasks", fallback: text.fallback)
+ case "NO_PROCESSED_AUDIO": return L10n.text("task.noProcessedAudio", fallback: text.fallback)
+ case "NO_TASKS": return L10n.text("task.noTasks", fallback: text.fallback)
+ case "INSTALLING_MODEL": return L10n.text("task.installingModel", fallback: text.fallback)
+ case "SCANNING_AUDIO_FOLDER": return L10n.text("task.scanningAudioFolder", fallback: text.fallback)
+ case "PREPARING_SPEECH_MODEL": return L10n.text("task.preparingSpeechModel", fallback: text.fallback)
+ case "CHOOSE_MODEL": return L10n.text("task.chooseModel", fallback: text.fallback)
+ default: return text.fallback
+ }
+ }
+}
diff --git a/iosApp/VoiceInbox/IosMainScreenShellState.swift b/iosApp/VoiceInbox/IosMainScreenShellState.swift
index 900c1ea..08c5e5e 100644
--- a/iosApp/VoiceInbox/IosMainScreenShellState.swift
+++ b/iosApp/VoiceInbox/IosMainScreenShellState.swift
@@ -10,9 +10,9 @@ enum IosShellCatalogSelection: String, CaseIterable, Identifiable {
var title: String {
switch self {
- case .new: "New"
- case .processed: "Processed"
- case .all: "All"
+ case .new: L10n.text("catalog.new", fallback: "New")
+ case .processed: L10n.text("catalog.processed", fallback: "Processed")
+ case .all: L10n.text("catalog.all", fallback: "All")
}
}
diff --git a/iosApp/VoiceInbox/IosSingleFileTranscriptionController.swift b/iosApp/VoiceInbox/IosSingleFileTranscriptionController.swift
index 3192427..34cd573 100644
--- a/iosApp/VoiceInbox/IosSingleFileTranscriptionController.swift
+++ b/iosApp/VoiceInbox/IosSingleFileTranscriptionController.swift
@@ -87,7 +87,7 @@ final class IosSingleFileTranscriptionController: ObservableObject {
@Published var message: String?
private var task: Task?
- static let backendUnavailableMessage = "iOS transcription backend is not configured yet."
+ static let backendUnavailableMessage = L10n.text("transcription.backendUnavailable", fallback: "iOS transcription backend is not configured yet.")
var activeFileId: Int64? {
state.active ? state.fileId : nil
@@ -117,7 +117,7 @@ final class IosSingleFileTranscriptionController: ObservableObject {
active: true,
fileId: file.id,
fileName: file.displayName,
- phase: "Preparing speech model",
+ phase: L10n.text("transcription.preparingModel", fallback: "Preparing speech model"),
processedUs: 0,
durationUs: 0,
completedFiles: 0,
@@ -134,7 +134,7 @@ final class IosSingleFileTranscriptionController: ObservableObject {
claim: { store.markProcessing(fileId: file.id) }
)
guard prepared else {
- let error = modelStore.message ?? "Speech model preparation failed."
+ let error = modelStore.message ?? L10n.text("transcription.modelPreparationFailed", fallback: "Speech model preparation failed.")
prerequisiteError = error
message = error
state = .idle
@@ -183,10 +183,10 @@ final class IosSingleFileTranscriptionController: ObservableObject {
transcriptText: result.transcriptText,
durationUs: result.durationUs?.int64Value
)
- message = "Transcribed \(file.displayName)"
+ message = L10n.format("transcription.transcribed", fallback: "Transcribed %@", file.displayName)
onSuccess?(result.transcriptText)
} else {
- let error = outcome.errorMessage ?? "iOS transcription failed."
+ let error = outcome.errorMessage ?? L10n.text("transcription.failed", fallback: "iOS transcription failed.")
store.markFailed(fileId: file.id, error: error)
message = error
}
@@ -217,7 +217,7 @@ final class IosSingleFileTranscriptionController: ObservableObject {
active: true,
fileId: nil,
fileName: nil,
- phase: "Preparing speech model",
+ phase: L10n.text("transcription.preparingModel", fallback: "Preparing speech model"),
processedUs: 0,
durationUs: 0,
completedFiles: 0,
@@ -230,7 +230,7 @@ final class IosSingleFileTranscriptionController: ObservableObject {
task = Task {
guard await modelStore.prepareForTranscription() != nil else {
- let error = modelStore.message ?? "Speech model preparation failed."
+ let error = modelStore.message ?? L10n.text("transcription.modelPreparationFailed", fallback: "Speech model preparation failed.")
prerequisiteError = error
message = error
state = .idle
diff --git a/iosApp/VoiceInbox/IosSpeechModelStore.swift b/iosApp/VoiceInbox/IosSpeechModelStore.swift
index cadedf3..b677a82 100644
--- a/iosApp/VoiceInbox/IosSpeechModelStore.swift
+++ b/iosApp/VoiceInbox/IosSpeechModelStore.swift
@@ -111,9 +111,9 @@ struct IosSpeechModelStatus {
var summary: String {
if isReady {
- return "Speech model installed"
+ return L10n.text("model.installed", fallback: "Speech model installed")
}
- return "Speech model is not installed"
+ return L10n.text("model.notInstalled", fallback: "Speech model is not installed")
}
var detail: String? {
@@ -123,7 +123,7 @@ struct IosSpeechModelStatus {
guard !missingFiles.isEmpty else {
return nil
}
- return "Missing: \(missingFiles.joined(separator: ", "))"
+ return L10n.format("model.missing", fallback: "Missing: %@", missingFiles.joined(separator: ", "))
}
}
@@ -321,7 +321,7 @@ final class IosSpeechModelStore: ObservableObject {
replacementAllowed: Bool = true
) {
guard replacementAllowed else {
- message = "Wait for the current transcription to finish before replacing the speech model."
+ message = L10n.text("model.waitForCurrentTranscription", fallback: "Wait for the current transcription to finish before replacing the speech model.")
return
}
guard let candidate = confirmedCandidate ?? pendingCandidate, !isBusy else { return }
@@ -333,7 +333,7 @@ final class IosSpeechModelStore: ObservableObject {
isInstalling = true
installationError = nil
downloadProgress = nil
- message = "Installing \(candidate.descriptor.displayName)..."
+ message = L10n.format("model.installing", fallback: "Installing %@...", candidate.descriptor.displayName)
Task {
let result = await Task.detached(priority: .userInitiated) {
@@ -378,18 +378,18 @@ final class IosSpeechModelStore: ObservableObject {
catalogId: descriptor.catalogId,
modelVersion: descriptor.modelVersion
)?.networkDownloadAvailable == true else {
- message = "Selected speech model is not available for download."
+ message = L10n.text("model.notAvailableForDownload", fallback: "Selected speech model is not available for download.")
return
}
guard descriptor.networkDownloadAvailable else {
- message = "Network download is not available for this model."
+ message = L10n.text("model.networkUnavailable", fallback: "Network download is not available for this model.")
return
}
isInstalling = true
- message = "Preparing speech model download..."
+ message = L10n.text("model.preparingDownload", fallback: "Preparing speech model download...")
downloadProgress = IosSpeechModelDownloadProgress(
- message: "Preparing speech model download...",
+ message: L10n.text("model.preparingDownload", fallback: "Preparing speech model download..."),
bytesDownloaded: 0,
totalBytes: descriptor.totalSizeBytes
)
@@ -422,7 +422,7 @@ final class IosSpeechModelStore: ObservableObject {
downloadTask = nil
isInstalling = false
downloadProgress = nil
- message = "Speech model download cancelled."
+ message = L10n.text("model.downloadCancelled", fallback: "Speech model download cancelled.")
}
func prepareForTranscription() async -> URL? {
@@ -432,7 +432,7 @@ final class IosSpeechModelStore: ObservableObject {
task = existing
} else {
runtimeState = .loading
- message = "Preparing speech model..."
+ message = L10n.text("model.preparing", fallback: "Preparing speech model...")
let directory = modelDirectory
let validateInstallation = validateInstallation
let prepareNative = prepareNative
@@ -455,7 +455,7 @@ final class IosSpeechModelStore: ObservableObject {
directory: nil,
invalidInstallation: false,
message: nativeError()
- ?? "Speech model failed to load."
+ ?? L10n.text("model.loadFailed", fallback: "Speech model failed to load.")
)
}
recordVerified()
diff --git a/iosApp/VoiceInbox/IosTranscriptReview.swift b/iosApp/VoiceInbox/IosTranscriptReview.swift
index 4226ad5..57d7a9f 100644
--- a/iosApp/VoiceInbox/IosTranscriptReview.swift
+++ b/iosApp/VoiceInbox/IosTranscriptReview.swift
@@ -7,6 +7,7 @@ struct IosDisplayedTranscript: Identifiable, Equatable {
let text: String
var id: Int64 { entryId }
+ var clipboardText: String { text }
var shareText: String { text }
var shareSubject: String { filename }
@@ -38,30 +39,53 @@ enum IosTranscriptReview {
struct IosTranscriptViewer: View {
let transcript: IosDisplayedTranscript
let onDismiss: () -> Void
+ @State private var copyConfirmationVisible = false
var body: some View {
NavigationStack {
IosSelectableTranscriptTextView(
text: transcript.text,
- accessibilityLabel: "Transcript text for \(transcript.filename)"
+ accessibilityLabel: L10n.format("transcript.textAccessibility", fallback: "Transcript text for %@", transcript.filename)
)
.accessibilityIdentifier("transcript-text")
.navigationTitle(transcript.filename)
.navigationBarTitleDisplayMode(.inline)
+ .overlay(alignment: .bottom) {
+ if copyConfirmationVisible {
+ Text(L10n.text("transcript.copied", fallback: "Transcript copied"))
+ .padding(.horizontal, 16)
+ .padding(.vertical, 10)
+ .background(.regularMaterial, in: Capsule())
+ .padding(.bottom, 20)
+ .accessibilityIdentifier("transcript-copy-confirmation")
+ }
+ }
.toolbar {
ToolbarItem(placement: .cancellationAction) {
- Button("Done", action: onDismiss)
- .accessibilityLabel("Close transcript")
+ Button(L10n.text("transcript.done", fallback: "Done"), action: onDismiss)
+ .accessibilityLabel(L10n.text("transcript.close", fallback: "Close transcript"))
.accessibilityIdentifier("transcript-close")
}
- ToolbarItem(placement: .primaryAction) {
+ ToolbarItemGroup(placement: .primaryAction) {
+ Button {
+ IosTranscriptClipboard.copy(transcript.clipboardText)
+ copyConfirmationVisible = true
+ DispatchQueue.main.asyncAfter(deadline: .now() + 2) {
+ copyConfirmationVisible = false
+ }
+ } label: {
+ Label(L10n.text("transcript.copyAll", fallback: "Copy All"), systemImage: "doc.on.doc")
+ }
+ .accessibilityLabel(L10n.text("transcript.copyAccessibility", fallback: "Copy full transcript"))
+ .accessibilityIdentifier("transcript-copy")
+
ShareLink(
item: transcript.shareText,
subject: Text(transcript.shareSubject)
) {
- Label("Share", systemImage: "square.and.arrow.up")
+ Label(L10n.text("transcript.share", fallback: "Share"), systemImage: "square.and.arrow.up")
}
- .accessibilityLabel("Share transcript")
+ .accessibilityLabel(L10n.text("transcript.shareAccessibility", fallback: "Share transcript"))
.accessibilityIdentifier("transcript-share")
}
}
@@ -69,6 +93,12 @@ struct IosTranscriptViewer: View {
}
}
+enum IosTranscriptClipboard {
+ static func copy(_ text: String, to pasteboard: UIPasteboard = .general) {
+ pasteboard.string = text
+ }
+}
+
struct IosSelectableTranscriptTextView: UIViewRepresentable {
let text: String
let accessibilityLabel: String
diff --git a/iosApp/VoiceInbox/Localizable.xcstrings b/iosApp/VoiceInbox/Localizable.xcstrings
new file mode 100644
index 0000000..4cdafe7
--- /dev/null
+++ b/iosApp/VoiceInbox/Localizable.xcstrings
@@ -0,0 +1,2551 @@
+{
+ "sourceLanguage" : "en",
+ "strings" : {
+ "%@ · %@" : {
+ "comment" : "A description of a speech model, including the language and maturity level.",
+ "isCommentAutoGenerated" : true,
+ "localizations" : {
+ "en" : {
+ "stringUnit" : {
+ "state" : "new",
+ "value" : "%1$@ · %2$@"
+ }
+ }
+ }
+ },
+ "%@ · %@ download · %@ free" : {
+ "localizations" : {
+ "en" : {
+ "stringUnit" : {
+ "state" : "new",
+ "value" : "%1$@ · %2$@ download · %3$@ free"
+ }
+ }
+ }
+ },
+ "Always do this at startup" : {
+ "comment" : "A toggle that allows the user to choose whether to always process files at startup.",
+ "isCommentAutoGenerated" : true
+ },
+ "app.title" : {
+ "extractionState" : "stale",
+ "localizations" : {
+ "en" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Voice Inbox"
+ }
+ },
+ "ru" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Voice Inbox"
+ }
+ }
+ }
+ },
+ "Available" : {
+ "comment" : "A label describing an action that is currently available.",
+ "isCommentAutoGenerated" : true
+ },
+ "Catalog" : {
+
+ },
+ "catalog.all" : {
+ "extractionState" : "stale",
+ "localizations" : {
+ "en" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "All"
+ }
+ },
+ "ru" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Все"
+ }
+ }
+ }
+ },
+ "catalog.hideExport" : {
+ "extractionState" : "stale",
+ "localizations" : {
+ "en" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Hide automatic transcript export"
+ }
+ },
+ "ru" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Скрыть автоматический экспорт расшифровок"
+ }
+ }
+ }
+ },
+ "catalog.importAudio" : {
+ "extractionState" : "stale",
+ "localizations" : {
+ "en" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Import Audio Files"
+ }
+ },
+ "ru" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Импортировать аудиофайлы"
+ }
+ }
+ }
+ },
+ "catalog.new" : {
+ "extractionState" : "stale",
+ "localizations" : {
+ "en" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "New"
+ }
+ },
+ "ru" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Новые"
+ }
+ }
+ }
+ },
+ "catalog.processed" : {
+ "extractionState" : "stale",
+ "localizations" : {
+ "en" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Processed"
+ }
+ },
+ "ru" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Обработанные"
+ }
+ }
+ }
+ },
+ "catalog.refreshFolder" : {
+ "extractionState" : "stale",
+ "localizations" : {
+ "en" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Refresh Audio Folder"
+ }
+ },
+ "ru" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Обновить папку с аудио"
+ }
+ }
+ }
+ },
+ "catalog.transcribeAll.few" : {
+ "extractionState" : "stale",
+ "localizations" : {
+ "en" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Transcribe All (%d)"
+ }
+ },
+ "ru" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Расшифровать (%d файла)"
+ }
+ }
+ }
+ },
+ "catalog.transcribeAll.many" : {
+ "extractionState" : "stale",
+ "localizations" : {
+ "en" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Transcribe All (%d)"
+ }
+ },
+ "ru" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Расшифровать (%d файлов)"
+ }
+ }
+ }
+ },
+ "catalog.transcribeAll.one" : {
+ "extractionState" : "stale",
+ "localizations" : {
+ "en" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Transcribe All (%d)"
+ }
+ },
+ "ru" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Расшифровать (%d файл)"
+ }
+ }
+ }
+ },
+ "catalog.transcribeAll.other" : {
+ "extractionState" : "stale",
+ "localizations" : {
+ "en" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Transcribe All (%d)"
+ }
+ },
+ "ru" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Расшифровать (%d файлов)"
+ }
+ }
+ }
+ },
+ "common.cancel" : {
+ "extractionState" : "stale",
+ "localizations" : {
+ "en" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Cancel"
+ }
+ },
+ "ru" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Отмена"
+ }
+ }
+ }
+ },
+ "common.download" : {
+ "extractionState" : "stale",
+ "localizations" : {
+ "en" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Download"
+ }
+ },
+ "ru" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Скачать"
+ }
+ }
+ }
+ },
+ "common.install" : {
+ "extractionState" : "stale",
+ "localizations" : {
+ "en" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Install"
+ }
+ },
+ "ru" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Установить"
+ }
+ }
+ }
+ },
+ "common.ok" : {
+ "extractionState" : "stale",
+ "localizations" : {
+ "en" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "OK"
+ }
+ },
+ "ru" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "ОК"
+ }
+ }
+ }
+ },
+ "Detected Speech Model" : {
+
+ },
+ "Download Speech Model" : {
+
+ },
+ "folder.choose" : {
+ "extractionState" : "stale",
+ "localizations" : {
+ "en" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Choose a folder to use it as your audio inbox."
+ }
+ },
+ "ru" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Выберите папку, которая будет использоваться для входящих аудио."
+ }
+ }
+ }
+ },
+ "folder.noneSelected" : {
+ "extractionState" : "stale",
+ "localizations" : {
+ "en" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "No audio folder selected"
+ }
+ },
+ "ru" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Папка с аудио не выбрана"
+ }
+ }
+ }
+ },
+ "import.none" : {
+ "extractionState" : "stale",
+ "localizations" : {
+ "en" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "No files imported"
+ }
+ },
+ "ru" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Файлы не импортированы"
+ }
+ }
+ }
+ },
+ "Model" : {
+
+ },
+ "model.downloadCancelled" : {
+ "extractionState" : "stale",
+ "localizations" : {
+ "en" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Speech model download cancelled."
+ }
+ },
+ "ru" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Загрузка речевой модели отменена."
+ }
+ }
+ }
+ },
+ "model.downloadReplacementWarning" : {
+ "extractionState" : "stale",
+ "localizations" : {
+ "en" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Downloading this model replaces the currently installed model."
+ }
+ },
+ "ru" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Загрузка этой модели заменит текущую модель."
+ }
+ }
+ }
+ },
+ "model.installed" : {
+ "extractionState" : "stale",
+ "localizations" : {
+ "en" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Speech model installed"
+ }
+ },
+ "ru" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Речевая модель установлена"
+ }
+ }
+ }
+ },
+ "model.installing" : {
+ "extractionState" : "stale",
+ "localizations" : {
+ "en" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Installing %@..."
+ }
+ },
+ "ru" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Установка %@..."
+ }
+ }
+ }
+ },
+ "model.loadFailed" : {
+ "extractionState" : "stale",
+ "localizations" : {
+ "en" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Speech model failed to load."
+ }
+ },
+ "ru" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Не удалось загрузить речевую модель."
+ }
+ }
+ }
+ },
+ "model.missing" : {
+ "extractionState" : "stale",
+ "localizations" : {
+ "en" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Missing: %@"
+ }
+ },
+ "ru" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Отсутствует: %@"
+ }
+ }
+ }
+ },
+ "model.networkUnavailable" : {
+ "extractionState" : "stale",
+ "localizations" : {
+ "en" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Network download is not available for this model."
+ }
+ },
+ "ru" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Сетевая загрузка недоступна для этой модели."
+ }
+ }
+ }
+ },
+ "model.notAvailableForDownload" : {
+ "extractionState" : "stale",
+ "localizations" : {
+ "en" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Selected speech model is not available for download."
+ }
+ },
+ "ru" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Выбранная речевая модель недоступна для загрузки."
+ }
+ }
+ }
+ },
+ "model.notInstalled" : {
+ "extractionState" : "stale",
+ "localizations" : {
+ "en" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Speech model is not installed"
+ }
+ },
+ "ru" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Речевая модель не установлена"
+ }
+ }
+ }
+ },
+ "model.preparing" : {
+ "extractionState" : "stale",
+ "localizations" : {
+ "en" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Preparing speech model..."
+ }
+ },
+ "ru" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Подготовка речевой модели..."
+ }
+ }
+ }
+ },
+ "model.preparingDownload" : {
+ "extractionState" : "stale",
+ "localizations" : {
+ "en" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Preparing speech model download..."
+ }
+ },
+ "ru" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Подготовка загрузки речевой модели..."
+ }
+ }
+ }
+ },
+ "model.replacementWarning" : {
+ "extractionState" : "stale",
+ "localizations" : {
+ "en" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Installing this model replaces the currently installed model."
+ }
+ },
+ "ru" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Установка этой модели заменит текущую модель."
+ }
+ }
+ }
+ },
+ "model.waitForCurrentTranscription" : {
+ "extractionState" : "stale",
+ "localizations" : {
+ "en" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Wait for the current transcription to finish before replacing the speech model."
+ }
+ },
+ "ru" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Дождитесь окончания текущей расшифровки перед заменой речевой модели."
+ }
+ }
+ }
+ },
+ "model.waitForTranscription" : {
+ "extractionState" : "stale",
+ "localizations" : {
+ "en" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Wait for transcription to finish before replacing the speech model."
+ }
+ },
+ "ru" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Дождитесь окончания расшифровки перед заменой речевой модели."
+ }
+ }
+ }
+ },
+ "onboarding.completed" : {
+ "extractionState" : "stale",
+ "localizations" : {
+ "en" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Completed"
+ }
+ },
+ "ru" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Выполнено"
+ }
+ }
+ }
+ },
+ "onboarding.configureExport" : {
+ "extractionState" : "stale",
+ "localizations" : {
+ "en" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Configure automatic transcript export"
+ }
+ },
+ "ru" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Настройте автоматический экспорт расшифровок"
+ }
+ }
+ }
+ },
+ "onboarding.dismiss" : {
+ "extractionState" : "stale",
+ "localizations" : {
+ "en" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Dismiss setup guide"
+ }
+ },
+ "ru" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Скрыть подсказку по настройке"
+ }
+ }
+ }
+ },
+ "onboarding.downloadDisclosure" : {
+ "extractionState" : "stale",
+ "localizations" : {
+ "en" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "This action downloads the speech model to this device."
+ }
+ },
+ "ru" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Это действие загрузит речевую модель на устройство."
+ }
+ }
+ }
+ },
+ "onboarding.explanation" : {
+ "extractionState" : "stale",
+ "localizations" : {
+ "en" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Follow these steps, or use the setup tasks above in any order."
+ }
+ },
+ "ru" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Пройдите эти шаги или выполните задачи настройки выше в любом порядке."
+ }
+ }
+ }
+ },
+ "onboarding.installingModel" : {
+ "extractionState" : "stale",
+ "localizations" : {
+ "en" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Installing speech model…"
+ }
+ },
+ "ru" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Установка речевой модели…"
+ }
+ }
+ }
+ },
+ "onboarding.installModel" : {
+ "extractionState" : "stale",
+ "localizations" : {
+ "en" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Install speech model"
+ }
+ },
+ "ru" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Установите речевую модель"
+ }
+ }
+ }
+ },
+ "onboarding.installModelFromFolder" : {
+ "extractionState" : "stale",
+ "localizations" : {
+ "en" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Install model from folder"
+ }
+ },
+ "ru" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Установить модель из папки"
+ }
+ }
+ }
+ },
+ "onboarding.notCompleted" : {
+ "extractionState" : "stale",
+ "localizations" : {
+ "en" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Not completed"
+ }
+ },
+ "ru" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Не выполнено"
+ }
+ }
+ }
+ },
+ "onboarding.optional" : {
+ "extractionState" : "stale",
+ "localizations" : {
+ "en" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "optional"
+ }
+ },
+ "ru" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "необязательно"
+ }
+ }
+ }
+ },
+ "onboarding.optionalLabel" : {
+ "extractionState" : "stale",
+ "localizations" : {
+ "en" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Optional"
+ }
+ },
+ "ru" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Необязательно"
+ }
+ }
+ }
+ },
+ "onboarding.retrySetup" : {
+ "extractionState" : "stale",
+ "localizations" : {
+ "en" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Retry setup"
+ }
+ },
+ "ru" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Повторить настройку"
+ }
+ }
+ }
+ },
+ "onboarding.selectFolder" : {
+ "extractionState" : "stale",
+ "localizations" : {
+ "en" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Select audio folder"
+ }
+ },
+ "ru" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Выберите папку с аудио"
+ }
+ }
+ }
+ },
+ "onboarding.selectOptionalFolder" : {
+ "extractionState" : "stale",
+ "localizations" : {
+ "en" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Select audio folder (optional)"
+ }
+ },
+ "ru" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Выбрать папку с аудио (необязательно)"
+ }
+ }
+ }
+ },
+ "onboarding.selectOutput" : {
+ "extractionState" : "stale",
+ "localizations" : {
+ "en" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Select Output File"
+ }
+ },
+ "ru" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Выбрать файл вывода"
+ }
+ }
+ }
+ },
+ "onboarding.startSetup" : {
+ "extractionState" : "stale",
+ "localizations" : {
+ "en" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Start setup"
+ }
+ },
+ "ru" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Начать настройку"
+ }
+ }
+ }
+ },
+ "onboarding.title" : {
+ "extractionState" : "stale",
+ "localizations" : {
+ "en" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Set up Voice Inbox"
+ }
+ },
+ "ru" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Настройте Voice Inbox"
+ }
+ }
+ }
+ },
+ "output.appendFailed" : {
+ "extractionState" : "stale",
+ "localizations" : {
+ "en" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Could not append to the selected output file."
+ }
+ },
+ "ru" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Не удалось добавить текст в выбранный файл вывода."
+ }
+ }
+ }
+ },
+ "output.choose" : {
+ "extractionState" : "stale",
+ "localizations" : {
+ "en" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Choose a text or Markdown file for transcripts."
+ }
+ },
+ "ru" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Выберите текстовый или Markdown-файл для расшифровок."
+ }
+ }
+ }
+ },
+ "output.disabled" : {
+ "extractionState" : "stale",
+ "localizations" : {
+ "en" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Automatic export is off. Transcripts stay in Voice Inbox."
+ }
+ },
+ "ru" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Автоматический экспорт выключен. Расшифровки остаются в Voice Inbox."
+ }
+ }
+ }
+ },
+ "output.encodingFailed" : {
+ "extractionState" : "stale",
+ "localizations" : {
+ "en" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Transcript text could not be encoded as UTF-8."
+ }
+ },
+ "ru" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Не удалось закодировать текст расшифровки в UTF-8."
+ }
+ }
+ }
+ },
+ "output.noneSelected" : {
+ "extractionState" : "stale",
+ "localizations" : {
+ "en" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Output file not selected"
+ }
+ },
+ "ru" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Файл вывода не выбран"
+ }
+ }
+ }
+ },
+ "output.notWritable" : {
+ "extractionState" : "stale",
+ "localizations" : {
+ "en" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Output file is not writable. Choose another file."
+ }
+ },
+ "ru" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "В файл вывода нельзя записывать. Выберите другой файл."
+ }
+ }
+ }
+ },
+ "output.ready" : {
+ "extractionState" : "stale",
+ "localizations" : {
+ "en" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Ready to append transcripts."
+ }
+ },
+ "ru" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Готово к добавлению расшифровок."
+ }
+ }
+ }
+ },
+ "output.refreshAccess" : {
+ "extractionState" : "stale",
+ "localizations" : {
+ "en" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Output access needs to be refreshed. Choose the file again."
+ }
+ },
+ "ru" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Доступ к файлу вывода нужно обновить. Выберите файл снова."
+ }
+ }
+ }
+ },
+ "output.selected" : {
+ "extractionState" : "stale",
+ "localizations" : {
+ "en" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Output: %@"
+ }
+ },
+ "ru" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Вывод: %@"
+ }
+ }
+ }
+ },
+ "output.unsupported" : {
+ "extractionState" : "stale",
+ "localizations" : {
+ "en" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Choose a .txt, .md, or .markdown file."
+ }
+ },
+ "ru" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Выберите файл .txt, .md или .markdown."
+ }
+ }
+ }
+ },
+ "output.writeFailed" : {
+ "extractionState" : "stale",
+ "localizations" : {
+ "en" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Could not write to the selected output file. Choose it again."
+ }
+ },
+ "ru" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Не удалось записать в выбранный файл вывода. Выберите его снова."
+ }
+ }
+ }
+ },
+ "playback.couldNotPlay" : {
+ "extractionState" : "stale",
+ "localizations" : {
+ "en" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Could not play %@: %@"
+ }
+ },
+ "ru" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Не удалось воспроизвести %@: %@"
+ }
+ }
+ }
+ },
+ "playback.couldNotStart" : {
+ "extractionState" : "stale",
+ "localizations" : {
+ "en" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Could not start audio playback."
+ }
+ },
+ "ru" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Не удалось начать воспроизведение аудио."
+ }
+ }
+ }
+ },
+ "playback.decodeFailed" : {
+ "extractionState" : "stale",
+ "localizations" : {
+ "en" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Could not decode audio for playback."
+ }
+ },
+ "ru" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Не удалось декодировать аудио для воспроизведения."
+ }
+ }
+ }
+ },
+ "playback.stoppedEarly" : {
+ "extractionState" : "stale",
+ "localizations" : {
+ "en" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Playback stopped before the file finished."
+ }
+ },
+ "ru" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Воспроизведение остановилось до окончания файла."
+ }
+ }
+ }
+ },
+ "settings.about" : {
+ "extractionState" : "stale",
+ "localizations" : {
+ "en" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "About"
+ }
+ },
+ "ru" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "О приложении"
+ }
+ }
+ }
+ },
+ "settings.audioInbox" : {
+ "extractionState" : "stale",
+ "localizations" : {
+ "en" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Audio inbox folder"
+ }
+ },
+ "ru" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Папка входящих аудио"
+ }
+ }
+ }
+ },
+ "settings.changeAudioFolder" : {
+ "extractionState" : "stale",
+ "localizations" : {
+ "en" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Change Audio Folder"
+ }
+ },
+ "ru" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Изменить папку с аудио"
+ }
+ }
+ }
+ },
+ "settings.changeOutput" : {
+ "extractionState" : "stale",
+ "localizations" : {
+ "en" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Change Output File"
+ }
+ },
+ "ru" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Изменить файл вывода"
+ }
+ }
+ }
+ },
+ "settings.documentation" : {
+ "extractionState" : "stale",
+ "localizations" : {
+ "en" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Documentation"
+ }
+ },
+ "ru" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Документация"
+ }
+ }
+ }
+ },
+ "settings.doNotExport" : {
+ "extractionState" : "stale",
+ "localizations" : {
+ "en" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Do not export"
+ }
+ },
+ "ru" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Не экспортировать"
+ }
+ }
+ }
+ },
+ "settings.installModel" : {
+ "extractionState" : "stale",
+ "localizations" : {
+ "en" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Install model package from folder"
+ }
+ },
+ "ru" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Установить пакет модели из папки"
+ }
+ }
+ }
+ },
+ "settings.legal" : {
+ "extractionState" : "stale",
+ "localizations" : {
+ "en" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Legal information"
+ }
+ },
+ "ru" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Правовая информация"
+ }
+ }
+ }
+ },
+ "settings.noModel" : {
+ "extractionState" : "stale",
+ "localizations" : {
+ "en" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "No speech model installed"
+ }
+ },
+ "ru" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Речевая модель не установлена"
+ }
+ }
+ }
+ },
+ "settings.outputFile" : {
+ "extractionState" : "stale",
+ "localizations" : {
+ "en" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Transcript output file"
+ }
+ },
+ "ru" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Файл вывода расшифровок"
+ }
+ }
+ }
+ },
+ "settings.selectAudioFolder" : {
+ "extractionState" : "stale",
+ "localizations" : {
+ "en" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Select Audio Folder"
+ }
+ },
+ "ru" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Выбрать папку с аудио"
+ }
+ }
+ }
+ },
+ "settings.selectOutput" : {
+ "extractionState" : "stale",
+ "localizations" : {
+ "en" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Select Output File"
+ }
+ },
+ "ru" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Выбрать файл вывода"
+ }
+ }
+ }
+ },
+ "settings.speechModel" : {
+ "extractionState" : "stale",
+ "localizations" : {
+ "en" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Speech Model"
+ }
+ },
+ "ru" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Речевая модель"
+ }
+ }
+ }
+ },
+ "settings.startup" : {
+ "extractionState" : "stale",
+ "localizations" : {
+ "en" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Startup Processing"
+ }
+ },
+ "ru" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Обработка при запуске"
+ }
+ }
+ }
+ },
+ "settings.startup.ask" : {
+ "extractionState" : "stale",
+ "localizations" : {
+ "en" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Ask"
+ }
+ },
+ "ru" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Спрашивать"
+ }
+ }
+ }
+ },
+ "settings.startup.no" : {
+ "extractionState" : "stale",
+ "localizations" : {
+ "en" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "No"
+ }
+ },
+ "ru" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Нет"
+ }
+ }
+ }
+ },
+ "settings.startup.yes" : {
+ "extractionState" : "stale",
+ "localizations" : {
+ "en" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Yes"
+ }
+ },
+ "ru" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Да"
+ }
+ }
+ }
+ },
+ "settings.storage" : {
+ "extractionState" : "stale",
+ "localizations" : {
+ "en" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Storage"
+ }
+ },
+ "ru" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Хранилище"
+ }
+ }
+ }
+ },
+ "settings.title" : {
+ "extractionState" : "stale",
+ "localizations" : {
+ "en" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Settings"
+ }
+ },
+ "ru" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Настройки"
+ }
+ }
+ }
+ },
+ "settings.unknown" : {
+ "extractionState" : "stale",
+ "localizations" : {
+ "en" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Unknown"
+ }
+ },
+ "ru" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Неизвестно"
+ }
+ }
+ }
+ },
+ "settings.version" : {
+ "extractionState" : "stale",
+ "localizations" : {
+ "en" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Version"
+ }
+ },
+ "ru" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Версия"
+ }
+ }
+ }
+ },
+ "settings.website" : {
+ "extractionState" : "stale",
+ "localizations" : {
+ "en" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Website"
+ }
+ },
+ "ru" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Сайт"
+ }
+ }
+ }
+ },
+ "settings.whenQueued" : {
+ "extractionState" : "stale",
+ "localizations" : {
+ "en" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "When queued files are found"
+ }
+ },
+ "ru" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Когда найдены файлы в очереди"
+ }
+ }
+ }
+ },
+ "startup.alreadyRunning" : {
+ "extractionState" : "stale",
+ "localizations" : {
+ "en" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Found files to process, but transcription is already running."
+ }
+ },
+ "ru" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Найдены файлы для обработки, но расшифровка уже выполняется."
+ }
+ }
+ }
+ },
+ "startup.backendUnavailable" : {
+ "extractionState" : "stale",
+ "localizations" : {
+ "en" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Found files to process, but the iOS transcription backend is not configured."
+ }
+ },
+ "ru" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Найдены файлы для обработки, но механизм расшифровки iOS не настроен."
+ }
+ }
+ }
+ },
+ "startup.filesWaiting.few" : {
+ "extractionState" : "stale",
+ "localizations" : {
+ "en" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "%d files are waiting in New."
+ }
+ },
+ "ru" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "В разделе «Новые» ожидают %d файла."
+ }
+ }
+ }
+ },
+ "startup.filesWaiting.many" : {
+ "extractionState" : "stale",
+ "localizations" : {
+ "en" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "%d files are waiting in New."
+ }
+ },
+ "ru" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "В разделе «Новые» ожидают %d файлов."
+ }
+ }
+ }
+ },
+ "startup.filesWaiting.one" : {
+ "extractionState" : "stale",
+ "localizations" : {
+ "en" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "%d file is waiting in New."
+ }
+ },
+ "ru" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "В разделе «Новые» ожидает %d файл."
+ }
+ }
+ }
+ },
+ "startup.filesWaiting.other" : {
+ "extractionState" : "stale",
+ "localizations" : {
+ "en" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "%d files are waiting in New."
+ }
+ },
+ "ru" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "В разделе «Новые» ожидают %d файлов."
+ }
+ }
+ }
+ },
+ "startup.modelNotReady" : {
+ "extractionState" : "stale",
+ "localizations" : {
+ "en" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Found files to process, but the speech model is not ready."
+ }
+ },
+ "ru" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Найдены файлы для обработки, но речевая модель не готова."
+ }
+ }
+ }
+ },
+ "startup.processFiles" : {
+ "extractionState" : "stale",
+ "localizations" : {
+ "en" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Process Files"
+ }
+ },
+ "ru" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Обработать файлы"
+ }
+ }
+ }
+ },
+ "startup.prompt" : {
+ "extractionState" : "stale",
+ "localizations" : {
+ "en" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Found files to process. Process now?"
+ }
+ },
+ "ru" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Найдены файлы для обработки. Обработать сейчас?"
+ }
+ }
+ }
+ },
+ "task.automaticTranscriptExport" : {
+ "extractionState" : "stale",
+ "localizations" : {
+ "en" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Automatic Transcript Export"
+ }
+ },
+ "ru" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Автоматический экспорт расшифровок"
+ }
+ }
+ }
+ },
+ "task.cancel" : {
+ "extractionState" : "stale",
+ "localizations" : {
+ "en" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Cancel"
+ }
+ },
+ "ru" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Отмена"
+ }
+ }
+ }
+ },
+ "task.chooseExisting" : {
+ "extractionState" : "stale",
+ "localizations" : {
+ "en" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Choose Existing"
+ }
+ },
+ "ru" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Выбрать существующий"
+ }
+ }
+ }
+ },
+ "task.chooseModel" : {
+ "extractionState" : "stale",
+ "localizations" : {
+ "en" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Choose Model"
+ }
+ },
+ "ru" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Выбрать модель"
+ }
+ }
+ }
+ },
+ "task.createNew" : {
+ "extractionState" : "stale",
+ "localizations" : {
+ "en" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Create New"
+ }
+ },
+ "ru" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Создать новый"
+ }
+ }
+ }
+ },
+ "task.download" : {
+ "extractionState" : "stale",
+ "localizations" : {
+ "en" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Download"
+ }
+ },
+ "ru" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Скачать"
+ }
+ }
+ }
+ },
+ "task.failed" : {
+ "extractionState" : "stale",
+ "localizations" : {
+ "en" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Failed"
+ }
+ },
+ "ru" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Ошибка"
+ }
+ }
+ }
+ },
+ "task.hide" : {
+ "extractionState" : "stale",
+ "localizations" : {
+ "en" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Hide"
+ }
+ },
+ "ru" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Скрыть"
+ }
+ }
+ }
+ },
+ "task.importAudioFiles" : {
+ "extractionState" : "stale",
+ "localizations" : {
+ "en" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Import Audio Files"
+ }
+ },
+ "ru" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Импортировать аудиофайлы"
+ }
+ }
+ }
+ },
+ "task.installing" : {
+ "extractionState" : "stale",
+ "localizations" : {
+ "en" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Installing"
+ }
+ },
+ "ru" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Установка"
+ }
+ }
+ }
+ },
+ "task.installingModel" : {
+ "extractionState" : "stale",
+ "localizations" : {
+ "en" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Installing model"
+ }
+ },
+ "ru" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Установка модели"
+ }
+ }
+ }
+ },
+ "task.installSpeechModel" : {
+ "extractionState" : "stale",
+ "localizations" : {
+ "en" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Install Speech Model"
+ }
+ },
+ "ru" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Установить речевую модель"
+ }
+ }
+ }
+ },
+ "task.needsAttention" : {
+ "extractionState" : "stale",
+ "localizations" : {
+ "en" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Needs attention"
+ }
+ },
+ "ru" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Требуется внимание"
+ }
+ }
+ }
+ },
+ "task.new" : {
+ "extractionState" : "stale",
+ "localizations" : {
+ "en" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "New"
+ }
+ },
+ "ru" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Новые"
+ }
+ }
+ }
+ },
+ "task.noNewTasks" : {
+ "extractionState" : "stale",
+ "localizations" : {
+ "en" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "No new tasks"
+ }
+ },
+ "ru" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Нет новых задач"
+ }
+ }
+ }
+ },
+ "task.noProcessedAudio" : {
+ "extractionState" : "stale",
+ "localizations" : {
+ "en" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "No processed audio files"
+ }
+ },
+ "ru" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Нет обработанных аудиофайлов"
+ }
+ }
+ }
+ },
+ "task.noSpeech" : {
+ "extractionState" : "stale",
+ "localizations" : {
+ "en" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "No speech"
+ }
+ },
+ "ru" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Речь не обнаружена"
+ }
+ }
+ }
+ },
+ "task.noTasks" : {
+ "extractionState" : "stale",
+ "localizations" : {
+ "en" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "No tasks"
+ }
+ },
+ "ru" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Нет задач"
+ }
+ }
+ }
+ },
+ "task.optional" : {
+ "extractionState" : "stale",
+ "localizations" : {
+ "en" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Optional"
+ }
+ },
+ "ru" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Необязательно"
+ }
+ }
+ }
+ },
+ "task.outputOptionalDetail" : {
+ "extractionState" : "stale",
+ "localizations" : {
+ "en" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Optional. Transcripts are stored in Voice Inbox."
+ }
+ },
+ "ru" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Необязательно. Расшифровки хранятся в Voice Inbox."
+ }
+ }
+ }
+ },
+ "task.play" : {
+ "extractionState" : "stale",
+ "localizations" : {
+ "en" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Play"
+ }
+ },
+ "ru" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Воспроизвести"
+ }
+ }
+ }
+ },
+ "task.preparingSpeechModel" : {
+ "extractionState" : "stale",
+ "localizations" : {
+ "en" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Preparing speech model"
+ }
+ },
+ "ru" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Подготовка речевой модели"
+ }
+ }
+ }
+ },
+ "task.processed" : {
+ "extractionState" : "stale",
+ "localizations" : {
+ "en" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Processed"
+ }
+ },
+ "ru" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Готовые"
+ }
+ }
+ }
+ },
+ "task.processing" : {
+ "extractionState" : "stale",
+ "localizations" : {
+ "en" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Processing"
+ }
+ },
+ "ru" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Обработка"
+ }
+ }
+ }
+ },
+ "task.progressFailed" : {
+ "extractionState" : "stale",
+ "localizations" : {
+ "en" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "%d failed"
+ }
+ },
+ "ru" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Ошибок: %d"
+ }
+ }
+ }
+ },
+ "task.progressFiles" : {
+ "extractionState" : "stale",
+ "localizations" : {
+ "en" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "%d / %d files"
+ }
+ },
+ "ru" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "%d из %d файлов"
+ }
+ }
+ }
+ },
+ "task.refreshAudioFolder" : {
+ "extractionState" : "stale",
+ "localizations" : {
+ "en" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Refresh Audio Folder"
+ }
+ },
+ "ru" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Обновить папку с аудио"
+ }
+ }
+ }
+ },
+ "task.required" : {
+ "extractionState" : "stale",
+ "localizations" : {
+ "en" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Required"
+ }
+ },
+ "ru" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Обязательно"
+ }
+ }
+ }
+ },
+ "task.restoreAudioFolderAccess" : {
+ "extractionState" : "stale",
+ "localizations" : {
+ "en" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Restore Audio Folder Access"
+ }
+ },
+ "ru" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Восстановить доступ к папке с аудио"
+ }
+ }
+ }
+ },
+ "task.retry" : {
+ "extractionState" : "stale",
+ "localizations" : {
+ "en" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Retry"
+ }
+ },
+ "ru" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Повторить"
+ }
+ }
+ }
+ },
+ "task.retryDownload" : {
+ "extractionState" : "stale",
+ "localizations" : {
+ "en" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Retry download"
+ }
+ },
+ "ru" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Повторить загрузку"
+ }
+ }
+ }
+ },
+ "task.scanning" : {
+ "extractionState" : "stale",
+ "localizations" : {
+ "en" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Scanning"
+ }
+ },
+ "ru" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Сканирование"
+ }
+ }
+ }
+ },
+ "task.scanningAudioFolder" : {
+ "extractionState" : "stale",
+ "localizations" : {
+ "en" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Scanning audio folder"
+ }
+ },
+ "ru" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Сканирование папки с аудио"
+ }
+ }
+ }
+ },
+ "task.selectAudioFolder" : {
+ "extractionState" : "stale",
+ "localizations" : {
+ "en" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Select Audio Folder"
+ }
+ },
+ "ru" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Выбрать папку с аудио"
+ }
+ }
+ }
+ },
+ "task.selectFolder" : {
+ "extractionState" : "stale",
+ "localizations" : {
+ "en" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Select folder"
+ }
+ },
+ "ru" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Выбрать папку"
+ }
+ }
+ }
+ },
+ "task.showText" : {
+ "extractionState" : "stale",
+ "localizations" : {
+ "en" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Show Text"
+ }
+ },
+ "ru" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Показать текст"
+ }
+ }
+ }
+ },
+ "task.stop" : {
+ "extractionState" : "stale",
+ "localizations" : {
+ "en" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Stop"
+ }
+ },
+ "ru" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Остановить"
+ }
+ }
+ }
+ },
+ "task.transcribe" : {
+ "extractionState" : "stale",
+ "localizations" : {
+ "en" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Transcribe"
+ }
+ },
+ "ru" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Расшифровать"
+ }
+ }
+ }
+ },
+ "transcript.close" : {
+ "extractionState" : "stale",
+ "localizations" : {
+ "en" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Close transcript"
+ }
+ },
+ "ru" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Закрыть расшифровку"
+ }
+ }
+ }
+ },
+ "transcript.copyAccessibility" : {
+ "extractionState" : "stale",
+ "localizations" : {
+ "en" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Copy full transcript"
+ }
+ },
+ "ru" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Скопировать всю расшифровку"
+ }
+ }
+ }
+ },
+ "transcript.copyAll" : {
+ "extractionState" : "stale",
+ "localizations" : {
+ "en" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Copy All"
+ }
+ },
+ "ru" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Скопировать всё"
+ }
+ }
+ }
+ },
+ "transcript.copied" : {
+ "extractionState" : "stale",
+ "localizations" : {
+ "en" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Transcript copied"
+ }
+ },
+ "ru" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Расшифровка скопирована"
+ }
+ }
+ }
+ },
+ "transcript.done" : {
+ "extractionState" : "stale",
+ "localizations" : {
+ "en" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Done"
+ }
+ },
+ "ru" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Готово"
+ }
+ }
+ }
+ },
+ "transcript.share" : {
+ "extractionState" : "stale",
+ "localizations" : {
+ "en" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Share"
+ }
+ },
+ "ru" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Поделиться"
+ }
+ }
+ }
+ },
+ "transcript.shareAccessibility" : {
+ "extractionState" : "stale",
+ "localizations" : {
+ "en" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Share transcript"
+ }
+ },
+ "ru" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Поделиться расшифровкой"
+ }
+ }
+ }
+ },
+ "transcript.textAccessibility" : {
+ "extractionState" : "stale",
+ "localizations" : {
+ "en" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Transcript text for %@"
+ }
+ },
+ "ru" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Текст расшифровки для %@"
+ }
+ }
+ }
+ },
+ "transcription.backendUnavailable" : {
+ "extractionState" : "stale",
+ "localizations" : {
+ "en" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "iOS transcription backend is not configured yet."
+ }
+ },
+ "ru" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Механизм расшифровки iOS еще не настроен."
+ }
+ }
+ }
+ },
+ "transcription.failed" : {
+ "extractionState" : "stale",
+ "localizations" : {
+ "en" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "iOS transcription failed."
+ }
+ },
+ "ru" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Сбой расшифровки iOS."
+ }
+ }
+ }
+ },
+ "transcription.modelPreparationFailed" : {
+ "extractionState" : "stale",
+ "localizations" : {
+ "en" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Speech model preparation failed."
+ }
+ },
+ "ru" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Не удалось подготовить речевую модель."
+ }
+ }
+ }
+ },
+ "transcription.preparingModel" : {
+ "extractionState" : "stale",
+ "localizations" : {
+ "en" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Preparing speech model"
+ }
+ },
+ "ru" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Подготовка речевой модели"
+ }
+ }
+ }
+ },
+ "transcription.transcribed" : {
+ "extractionState" : "stale",
+ "localizations" : {
+ "en" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Transcribed %@"
+ }
+ },
+ "ru" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Расшифрован файл %@"
+ }
+ }
+ }
+ },
+ "Unavailable" : {
+ "comment" : "A label describing an action that is currently unavailable.",
+ "isCommentAutoGenerated" : true
+ }
+ },
+ "version" : "1.1"
+}
diff --git a/iosApp/VoiceInbox/SettingsView.swift b/iosApp/VoiceInbox/SettingsView.swift
index 43e72e6..b972278 100644
--- a/iosApp/VoiceInbox/SettingsView.swift
+++ b/iosApp/VoiceInbox/SettingsView.swift
@@ -11,22 +11,22 @@ enum IosStartupProcessingPolicy: String, CaseIterable, Identifiable {
var title: String {
switch self {
case .ask:
- "Ask"
+ L10n.text("settings.startup.ask", fallback: "Ask")
case .yes:
- "Yes"
+ L10n.text("settings.startup.yes", fallback: "Yes")
case .no:
- "No"
+ L10n.text("settings.startup.no", fallback: "No")
}
}
var detail: String {
switch self {
case .ask:
- "Ask before processing queued files when Voice Inbox starts."
+ L10n.text("settings.startup.askDetail", fallback: "Ask before processing queued files when Voice Inbox starts.")
case .yes:
- "Automatically process queued files when Voice Inbox starts."
+ L10n.text("settings.startup.yesDetail", fallback: "Automatically process queued files when Voice Inbox starts.")
case .no:
- "Do not process queued files automatically at startup."
+ L10n.text("settings.startup.noDetail", fallback: "Do not process queued files automatically at startup.")
}
}
}
@@ -70,9 +70,9 @@ struct SettingsView: View {
var body: some View {
Form {
- Section("Storage") {
+ Section(L10n.text("settings.storage", fallback: "Storage")) {
VStack(alignment: .leading, spacing: 6) {
- Text("Audio inbox folder")
+ Text(L10n.text("settings.audioInbox", fallback: "Audio inbox folder"))
.font(.headline)
Text(importStore.inboxFolderStatus.title)
if let message = importStore.inboxFolderStatus.message {
@@ -89,14 +89,16 @@ struct SettingsView: View {
selectInboxFolder()
} label: {
Label(
- importStore.inboxFolderStatus.needsSelection ? "Select Audio Folder" : "Change Audio Folder",
+ importStore.inboxFolderStatus.needsSelection
+ ? L10n.text("settings.selectAudioFolder", fallback: "Select Audio Folder")
+ : L10n.text("settings.changeAudioFolder", fallback: "Change Audio Folder"),
systemImage: "folder"
)
}
.disabled(importStore.isScanningFolder)
VStack(alignment: .leading, spacing: 6) {
- Text("Transcript output file")
+ Text(L10n.text("settings.outputFile", fallback: "Transcript output file"))
.font(.headline)
Text(outputStore.status.title)
Text(outputStore.status.message)
@@ -108,18 +110,20 @@ struct SettingsView: View {
selectOutputFile()
} label: {
Label(
- outputStore.isReady ? "Change Output File" : "Select Output File",
+ outputStore.isReady
+ ? L10n.text("settings.changeOutput", fallback: "Change Output File")
+ : L10n.text("settings.selectOutput", fallback: "Select Output File"),
systemImage: "doc.badge.plus"
)
}
- Button("Do not export", role: .destructive) {
+ Button(L10n.text("settings.doNotExport", fallback: "Do not export"), role: .destructive) {
disableExport()
}
}
- Section("Startup Processing") {
- Picker("When queued files are found", selection: $startupPolicyStore.policy) {
+ Section(L10n.text("settings.startup", fallback: "Startup Processing")) {
+ Picker(L10n.text("settings.whenQueued", fallback: "When queued files are found"), selection: $startupPolicyStore.policy) {
ForEach(IosStartupProcessingPolicy.allCases) { policy in
Text(policy.title).tag(policy)
}
@@ -130,7 +134,7 @@ struct SettingsView: View {
.foregroundStyle(.secondary)
}
- Section("Speech Model") {
+ Section(L10n.text("settings.speechModel", fallback: "Speech Model")) {
if let active = speechModelStore.activeDescriptor {
VStack(alignment: .leading, spacing: 4) {
Text(active.displayName).font(.headline)
@@ -139,24 +143,24 @@ struct SettingsView: View {
.foregroundStyle(.secondary)
}
} else {
- Text("No speech model installed")
+ Text(L10n.text("settings.noModel", fallback: "No speech model installed"))
}
Button {
installModelPackage()
} label: {
- Label("Install model package from folder", systemImage: "folder.badge.plus")
+ Label(L10n.text("settings.installModel", fallback: "Install model package from folder"), systemImage: "folder.badge.plus")
}
.disabled(speechModelStore.isBusy)
}
- Section("About") {
- LabeledContent("Version", value: appVersion)
- Link("Website", destination: websiteURL)
- Link("Documentation", destination: documentationURL)
- Link("Legal information", destination: legalURL)
+ Section(L10n.text("settings.about", fallback: "About")) {
+ LabeledContent(L10n.text("settings.version", fallback: "Version"), value: appVersion)
+ Link(L10n.text("settings.website", fallback: "Website"), destination: websiteURL)
+ Link(L10n.text("settings.documentation", fallback: "Documentation"), destination: documentationURL)
+ Link(L10n.text("settings.legal", fallback: "Legal information"), destination: legalURL)
}
}
- .navigationTitle("Settings")
+ .navigationTitle(L10n.text("settings.title", fallback: "Settings"))
}
private var appVersion: String {
@@ -168,6 +172,6 @@ struct SettingsView: View {
if let build, !build.isEmpty {
return build
}
- return "Unknown"
+ return L10n.text("settings.unknown", fallback: "Unknown")
}
}
diff --git a/iosApp/VoiceInboxShareExtension/Localizable.xcstrings b/iosApp/VoiceInboxShareExtension/Localizable.xcstrings
new file mode 100644
index 0000000..239a7de
--- /dev/null
+++ b/iosApp/VoiceInboxShareExtension/Localizable.xcstrings
@@ -0,0 +1,261 @@
+{
+ "sourceLanguage" : "en",
+ "strings" : {
+ "share.done" : {
+ "extractionState" : "stale",
+ "localizations" : {
+ "en" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Done"
+ }
+ },
+ "ru" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Готово"
+ }
+ }
+ }
+ },
+ "share.failed.few" : {
+ "extractionState" : "stale",
+ "localizations" : {
+ "en" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "%d files failed"
+ }
+ },
+ "ru" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Ошибка при добавлении %d файлов"
+ }
+ }
+ }
+ },
+ "share.failed.many" : {
+ "extractionState" : "stale",
+ "localizations" : {
+ "en" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "%d files failed"
+ }
+ },
+ "ru" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Ошибка при добавлении %d файлов"
+ }
+ }
+ }
+ },
+ "share.failed.one" : {
+ "extractionState" : "stale",
+ "localizations" : {
+ "en" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "%d file failed"
+ }
+ },
+ "ru" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Ошибка при добавлении %d файла"
+ }
+ }
+ }
+ },
+ "share.failed.other" : {
+ "extractionState" : "stale",
+ "localizations" : {
+ "en" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "%d files failed"
+ }
+ },
+ "ru" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Ошибка при добавлении %d файлов"
+ }
+ }
+ }
+ },
+ "share.imported.few" : {
+ "extractionState" : "stale",
+ "localizations" : {
+ "en" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "%d files added to Voice Inbox"
+ }
+ },
+ "ru" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "%d файла добавлено в Voice Inbox"
+ }
+ }
+ }
+ },
+ "share.imported.many" : {
+ "extractionState" : "stale",
+ "localizations" : {
+ "en" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "%d files added to Voice Inbox"
+ }
+ },
+ "ru" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "%d файлов добавлено в Voice Inbox"
+ }
+ }
+ }
+ },
+ "share.imported.one" : {
+ "extractionState" : "stale",
+ "localizations" : {
+ "en" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "%d file added to Voice Inbox"
+ }
+ },
+ "ru" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "%d файл добавлен в Voice Inbox"
+ }
+ }
+ }
+ },
+ "share.imported.other" : {
+ "extractionState" : "stale",
+ "localizations" : {
+ "en" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "%d files added to Voice Inbox"
+ }
+ },
+ "ru" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "%d файлов добавлено в Voice Inbox"
+ }
+ }
+ }
+ },
+ "share.importing" : {
+ "extractionState" : "stale",
+ "localizations" : {
+ "en" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Importing audio..."
+ }
+ },
+ "ru" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Импорт аудио..."
+ }
+ }
+ }
+ },
+ "share.noSupportedAudio" : {
+ "extractionState" : "stale",
+ "localizations" : {
+ "en" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "No supported audio files found."
+ }
+ },
+ "ru" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Поддерживаемые аудиофайлы не найдены."
+ }
+ }
+ }
+ },
+ "share.skipped.few" : {
+ "extractionState" : "stale",
+ "localizations" : {
+ "en" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "%d files skipped"
+ }
+ },
+ "ru" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "%d файла пропущено"
+ }
+ }
+ }
+ },
+ "share.skipped.many" : {
+ "extractionState" : "stale",
+ "localizations" : {
+ "en" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "%d files skipped"
+ }
+ },
+ "ru" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "%d файлов пропущено"
+ }
+ }
+ }
+ },
+ "share.skipped.one" : {
+ "extractionState" : "stale",
+ "localizations" : {
+ "en" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "%d file skipped"
+ }
+ },
+ "ru" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "%d файл пропущен"
+ }
+ }
+ }
+ },
+ "share.skipped.other" : {
+ "extractionState" : "stale",
+ "localizations" : {
+ "en" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "%d files skipped"
+ }
+ },
+ "ru" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "%d файлов пропущено"
+ }
+ }
+ }
+ }
+ },
+ "version" : "1.0"
+}
\ No newline at end of file
diff --git a/iosApp/VoiceInboxShareExtension/ShareExtensionLocalization.swift b/iosApp/VoiceInboxShareExtension/ShareExtensionLocalization.swift
new file mode 100644
index 0000000..9f71c20
--- /dev/null
+++ b/iosApp/VoiceInboxShareExtension/ShareExtensionLocalization.swift
@@ -0,0 +1,31 @@
+import Foundation
+
+enum ShareExtensionL10n {
+ static func text(_ key: String, fallback: String) -> String {
+ NSLocalizedString(key, bundle: .main, value: fallback, comment: "")
+ }
+
+ static func format(_ key: String, fallback: String, _ arguments: CVarArg...) -> String {
+ String(format: text(key, fallback: fallback), locale: .current, arguments: arguments)
+ }
+
+ static func plural(_ key: String, count: Int, fallback: String) -> String {
+ format("\(key).\(pluralForm(for: count, languageCode: Locale.current.language.languageCode?.identifier))", fallback: fallback, count)
+ }
+
+ static func pluralForm(for count: Int, languageCode: String?) -> String {
+ guard languageCode == "ru" else {
+ return count == 1 ? "one" : "other"
+ }
+
+ let lastTwo = count % 100
+ let last = count % 10
+ if last == 1 && lastTwo != 11 {
+ return "one"
+ }
+ if (2...4).contains(last) && !(12...14).contains(lastTwo) {
+ return "few"
+ }
+ return "many"
+ }
+}
diff --git a/iosApp/VoiceInboxShareExtension/ShareViewController.swift b/iosApp/VoiceInboxShareExtension/ShareViewController.swift
index 8e93de4..8024c04 100644
--- a/iosApp/VoiceInboxShareExtension/ShareViewController.swift
+++ b/iosApp/VoiceInboxShareExtension/ShareViewController.swift
@@ -17,10 +17,10 @@ final class ShareViewController: UIViewController {
statusLabel.translatesAutoresizingMaskIntoConstraints = false
statusLabel.numberOfLines = 0
statusLabel.textAlignment = .center
- statusLabel.text = "Importing audio..."
+ statusLabel.text = ShareExtensionL10n.text("share.importing", fallback: "Importing audio...")
doneButton.translatesAutoresizingMaskIntoConstraints = false
- doneButton.setTitle("Done", for: .normal)
+ doneButton.setTitle(ShareExtensionL10n.text("share.done", fallback: "Done"), for: .normal)
doneButton.addTarget(self, action: #selector(closeExtension), for: .touchUpInside)
doneButton.isHidden = true
@@ -164,9 +164,29 @@ private struct ShareImportResult {
var message: String {
var parts: [String] = []
- if imported > 0 { parts.append("\(imported) staged for Voice Inbox") }
- if skipped > 0 { parts.append("\(skipped) skipped") }
- if failed > 0 { parts.append("\(failed) failed") }
- return parts.isEmpty ? "No supported audio files found." : parts.joined(separator: ", ")
+ if imported > 0 {
+ parts.append(ShareExtensionL10n.plural(
+ "share.imported",
+ count: imported,
+ fallback: "%d file added to Voice Inbox"
+ ))
+ }
+ if skipped > 0 {
+ parts.append(ShareExtensionL10n.plural(
+ "share.skipped",
+ count: skipped,
+ fallback: "%d file skipped"
+ ))
+ }
+ if failed > 0 {
+ parts.append(ShareExtensionL10n.plural(
+ "share.failed",
+ count: failed,
+ fallback: "%d file failed"
+ ))
+ }
+ return parts.isEmpty
+ ? ShareExtensionL10n.text("share.noSupportedAudio", fallback: "No supported audio files found.")
+ : parts.joined(separator: ", ")
}
}
diff --git a/iosApp/VoiceInboxTests/DeferredSpeechModelLoadingTests.swift b/iosApp/VoiceInboxTests/DeferredSpeechModelLoadingTests.swift
index 02b24c7..87a5128 100644
--- a/iosApp/VoiceInboxTests/DeferredSpeechModelLoadingTests.swift
+++ b/iosApp/VoiceInboxTests/DeferredSpeechModelLoadingTests.swift
@@ -369,7 +369,7 @@ final class DeferredSpeechModelLoadingTests: XCTestCase {
return XCTFail("Expected an audio task")
}
XCTAssertEqual(task.state, .pending)
- XCTAssertEqual(task.errorMessage, "Model could not be loaded")
+ XCTAssertEqual(task.errorMessage?.fallback, "Model could not be loaded")
}
func testTypedActionsHaveExplicitIosRoutes() {
diff --git a/iosApp/VoiceInboxTests/IosInlineOnboardingTests.swift b/iosApp/VoiceInboxTests/IosInlineOnboardingTests.swift
index 0f46a05..c22b7b5 100644
--- a/iosApp/VoiceInboxTests/IosInlineOnboardingTests.swift
+++ b/iosApp/VoiceInboxTests/IosInlineOnboardingTests.swift
@@ -4,6 +4,24 @@ import XCTest
@testable import VoiceInbox
final class IosInlineOnboardingTests: XCTestCase {
+ func testShareExtensionRussianPluralSelectionCoversOneFewAndMany() {
+ XCTAssertEqual(ShareExtensionL10n.pluralForm(for: 1, languageCode: "ru"), "one")
+ XCTAssertEqual(ShareExtensionL10n.pluralForm(for: 2, languageCode: "ru"), "few")
+ XCTAssertEqual(ShareExtensionL10n.pluralForm(for: 5, languageCode: "ru"), "many")
+ XCTAssertEqual(ShareExtensionL10n.pluralForm(for: 11, languageCode: "ru"), "many")
+ XCTAssertEqual(ShareExtensionL10n.pluralForm(for: 21, languageCode: "ru"), "one")
+ XCTAssertEqual(ShareExtensionL10n.pluralForm(for: 2, languageCode: "en"), "other")
+ }
+
+ func testRussianPluralSelectionCoversOneFewAndMany() {
+ XCTAssertEqual(L10n.pluralForm(for: 1, languageCode: "ru"), "one")
+ XCTAssertEqual(L10n.pluralForm(for: 2, languageCode: "ru"), "few")
+ XCTAssertEqual(L10n.pluralForm(for: 5, languageCode: "ru"), "many")
+ XCTAssertEqual(L10n.pluralForm(for: 11, languageCode: "ru"), "many")
+ XCTAssertEqual(L10n.pluralForm(for: 21, languageCode: "ru"), "one")
+ XCTAssertEqual(L10n.pluralForm(for: 2, languageCode: "en"), "other")
+ }
+
func testLifecycleStoreDefaultsUnknownValuesToActiveAndPersistsTerminalStates() throws {
let suiteName = "IosInlineOnboardingTests.\(UUID().uuidString)"
let defaults = try XCTUnwrap(UserDefaults(suiteName: suiteName))
diff --git a/iosApp/VoiceInboxTests/IosTranscriptReviewTests.swift b/iosApp/VoiceInboxTests/IosTranscriptReviewTests.swift
index d4059d6..ec5d198 100644
--- a/iosApp/VoiceInboxTests/IosTranscriptReviewTests.swift
+++ b/iosApp/VoiceInboxTests/IosTranscriptReviewTests.swift
@@ -1,5 +1,6 @@
import Foundation
import Shared
+import UIKit
import XCTest
@testable import VoiceInbox
@@ -51,7 +52,7 @@ final class IosTranscriptReviewTests: XCTestCase {
)
}
- func testTranscriptPresentationPreservesExactShareTextAndFilenameContext() throws {
+ func testTranscriptPresentationPreservesExactShareAndClipboardTextAndFilenameContext() throws {
let text = " recognized text\n"
let presentation = try XCTUnwrap(IosDisplayedTranscript(
entryId: 7,
@@ -60,12 +61,24 @@ final class IosTranscriptReviewTests: XCTestCase {
))
XCTAssertEqual(presentation.id, 7)
+ XCTAssertEqual(presentation.clipboardText, text)
XCTAssertEqual(presentation.shareText, text)
XCTAssertEqual(presentation.shareSubject, "voice.m4a")
XCTAssertFalse(presentation.shareText.contains(presentation.filename))
XCTAssertNil(IosDisplayedTranscript(entryId: 8, filename: "empty.m4a", text: " \n "))
}
+ func testTranscriptClipboardWritesExactText() throws {
+ let pasteboardName = UIPasteboard.Name("VoiceInboxTests.TranscriptClipboard.\(UUID().uuidString)")
+ let pasteboard = try XCTUnwrap(UIPasteboard(name: pasteboardName, create: true))
+ defer { UIPasteboard.remove(withName: pasteboardName) }
+ let text = " exact transcript\n"
+
+ IosTranscriptClipboard.copy(text, to: pasteboard)
+
+ XCTAssertEqual(pasteboard.string, text)
+ }
+
func testTranscriptReviewRejectsMissingOrStaleCatalogText() throws {
let files = [
file(id: 1, status: .processed, transcript: "stored transcript"),
@@ -91,9 +104,9 @@ final class IosTranscriptReviewTests: XCTestCase {
)
let allScreen = screen(selection: .all, files: [pending, processed])
- XCTAssertEqual(newDetail, audioTask(in: allScreen, id: 3).detail)
- XCTAssertEqual(processedDetail, audioTask(in: allScreen, id: 4).detail)
- XCTAssertTrue(newDetail.contains(" • "))
+ XCTAssertEqual(newDetail.fallback, audioTask(in: allScreen, id: 3).detail?.fallback)
+ XCTAssertEqual(processedDetail.fallback, audioTask(in: allScreen, id: 4).detail?.fallback)
+ XCTAssertTrue(newDetail.fallback.contains(" • "))
}
private func file(
diff --git a/release-config.json b/release-config.json
new file mode 100644
index 0000000..9f71244
--- /dev/null
+++ b/release-config.json
@@ -0,0 +1,15 @@
+{
+ "branches": {
+ "development": "develop",
+ "stable": "master",
+ "releasePrefix": "release/",
+ "hotfixPrefix": "hotfix/"
+ },
+ "tagPrefix": "v",
+ "metadataRoot": "fastlane/metadata/android",
+ "requiredLocales": ["en-US", "ru-RU"],
+ "ignoredLocales": [],
+ "englishLocale": "en-US",
+ "placeholderMarkers": ["TODO: RELEASE NOTES", "TODO_RELEASE_NOTES"],
+ "playTrack": "internal"
+}
diff --git a/scripts/release_tool.py b/scripts/release_tool.py
new file mode 100755
index 0000000..edbd82e
--- /dev/null
+++ b/scripts/release_tool.py
@@ -0,0 +1,448 @@
+#!/usr/bin/env python3
+"""Repository-owned Android release preparation and validation tool."""
+
+from __future__ import annotations
+
+import argparse
+import json
+import re
+import shutil
+import subprocess
+import sys
+import xml.etree.ElementTree as ET
+from dataclasses import dataclass
+from pathlib import Path
+from typing import Sequence
+
+
+ROOT = Path(__file__).resolve().parents[1]
+CONFIG_PATH = ROOT / "release-config.json"
+GRADLE_PATH = ROOT / "app" / "build.gradle.kts"
+MANIFEST_PATH = ROOT / "app" / "src" / "main" / "AndroidManifest.xml"
+SEMVER_RE = re.compile(r"^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)$")
+VERSION_CODE_RE = re.compile(r"(?m)^([ \t]*)versionCode[ \t]*=[ \t]*(\d+)[ \t]*$")
+VERSION_NAME_RE = re.compile(r'(?m)^([ \t]*)versionName[ \t]*=[ \t]*["\']([^"\']+)["\'][ \t]*$')
+
+
+class ReleaseError(RuntimeError):
+ pass
+
+
+@dataclass(frozen=True)
+class Version:
+ name: str
+ code: int
+
+ @property
+ def semver(self) -> tuple[int, int, int]:
+ return parse_semver(self.name)
+
+
+@dataclass(frozen=True)
+class ReleaseCandidate:
+ mode: str
+ head_branch: str
+ base_branch: str
+ merge_commit: str
+ version: Version
+ tag: str
+
+
+def load_config(path: Path = CONFIG_PATH) -> dict:
+ with path.open(encoding="utf-8") as stream:
+ config = json.load(stream)
+ required = {"branches", "tagPrefix", "metadataRoot", "requiredLocales", "placeholderMarkers", "playTrack"}
+ missing = required.difference(config)
+ if missing:
+ raise ReleaseError(f"release-config.json is missing: {', '.join(sorted(missing))}")
+ return config
+
+
+def parse_semver(value: str) -> tuple[int, int, int]:
+ match = SEMVER_RE.fullmatch(value)
+ if not match:
+ raise ReleaseError(f"Invalid release SemVer: {value}")
+ return tuple(int(part) for part in match.groups())
+
+
+def parse_version_text(text: str) -> Version:
+ codes = VERSION_CODE_RE.findall(text)
+ names = VERSION_NAME_RE.findall(text)
+ if len(codes) != 1 or len(names) != 1:
+ raise ReleaseError("Gradle must contain exactly one versionCode and versionName")
+ parse_semver(names[0][1])
+ return Version(names[0][1], int(codes[0][1]))
+
+
+def read_version(path: Path = GRADLE_PATH) -> Version:
+ return parse_version_text(path.read_text(encoding="utf-8"))
+
+
+def read_version_from_ref(ref: str) -> Version:
+ gradle = run(["git", "show", f"{ref}:app/build.gradle.kts"], check=False)
+ if not gradle:
+ raise ReleaseError(f"Candidate {ref} does not contain app/build.gradle.kts")
+ return parse_version_text(gradle)
+
+
+def write_version(version: Version, path: Path = GRADLE_PATH) -> None:
+ text = path.read_text(encoding="utf-8")
+
+ def code_repl(match: re.Match[str]) -> str:
+ return f"{match.group(1)}versionCode = {version.code}"
+
+ def name_repl(match: re.Match[str]) -> str:
+ return f'{match.group(1)}versionName = "{version.name}"'
+
+ text, code_count = VERSION_CODE_RE.subn(code_repl, text)
+ text, name_count = VERSION_NAME_RE.subn(name_repl, text)
+ if code_count != 1 or name_count != 1:
+ raise ReleaseError("Refusing to update ambiguous Gradle version declarations")
+ path.write_text(text, encoding="utf-8")
+
+
+def next_version(current: Version, bump: str | None, explicit: str | None) -> Version:
+ if bool(bump) == bool(explicit):
+ raise ReleaseError("Specify exactly one of --bump or --version")
+ major, minor, patch = current.semver
+ if explicit:
+ candidate = parse_semver(explicit)
+ if candidate <= current.semver:
+ raise ReleaseError("Explicit version must be greater than the current version")
+ name = explicit
+ elif bump == "major":
+ name = f"{major + 1}.0.0"
+ elif bump == "minor":
+ name = f"{major}.{minor + 1}.0"
+ else:
+ name = f"{major}.{minor}.{patch + 1}"
+ return Version(name, current.code + 1)
+
+
+def run(command: Sequence[str], *, check: bool = True) -> str:
+ result = subprocess.run(command, cwd=ROOT, check=False, text=True, capture_output=True)
+ if check and result.returncode:
+ message = result.stderr.strip() or result.stdout.strip() or "command failed"
+ raise ReleaseError(f"{' '.join(command)}: {message}")
+ return result.stdout.strip()
+
+
+def git_ref_exists(ref: str) -> bool:
+ return subprocess.run(["git", "show-ref", "--verify", "--quiet", ref], cwd=ROOT, check=False).returncode == 0
+
+
+def git_is_ancestor(ancestor: str, descendant: str) -> bool:
+ return subprocess.run(["git", "merge-base", "--is-ancestor", ancestor, descendant], cwd=ROOT, check=False).returncode == 0
+
+
+def current_branch() -> str:
+ branch = run(["git", "branch", "--show-current"])
+ if not branch:
+ raise ReleaseError("A branch checkout is required")
+ return branch
+
+
+def discover_locales(config: dict, root: Path = ROOT) -> list[str]:
+ metadata_root = root / config["metadataRoot"]
+ ignored = set(config.get("ignoredLocales", []))
+ if not metadata_root.is_dir():
+ raise ReleaseError(f"Metadata root does not exist: {metadata_root.relative_to(root)}")
+ locales = sorted(
+ item.name for item in metadata_root.iterdir()
+ if item.is_dir() and item.name not in ignored and (item / "title.txt").is_file()
+ )
+ missing = sorted(set(config["requiredLocales"]) - set(locales))
+ if missing:
+ raise ReleaseError(f"Required Fastlane locales are missing: {', '.join(missing)}")
+ if not locales:
+ raise ReleaseError("No Fastlane metadata locales discovered")
+ return locales
+
+
+def changelog_paths(version: Version, config: dict, root: Path = ROOT) -> list[Path]:
+ return [
+ root / config["metadataRoot"] / locale / "changelogs" / f"{version.code}.txt"
+ for locale in discover_locales(config, root)
+ ]
+
+
+def validate_changelogs(version: Version, config: dict, root: Path = ROOT) -> None:
+ markers = tuple(config["placeholderMarkers"])
+ errors = []
+ for path in changelog_paths(version, config, root):
+ if not path.is_file():
+ errors.append(f"missing {path.relative_to(root)}")
+ continue
+ content = path.read_text(encoding="utf-8").strip()
+ if not content:
+ errors.append(f"empty {path.relative_to(root)}")
+ elif any(marker in content for marker in markers):
+ errors.append(f"placeholder remains in {path.relative_to(root)}")
+ if errors:
+ raise ReleaseError("Invalid localized release notes:\n- " + "\n- ".join(errors))
+
+
+def create_placeholders(version: Version, config: dict) -> None:
+ marker = config["placeholderMarkers"][0]
+ for path in changelog_paths(version, config):
+ path.parent.mkdir(parents=True, exist_ok=True)
+ path.write_text(f"{marker} ({version.name})\n", encoding="utf-8")
+
+
+def version_from_tag(tag: str, config: dict) -> Version | None:
+ gradle = run(["git", "show", f"{tag}:app/build.gradle.kts"], check=False)
+ if not gradle:
+ return None
+ try:
+ return parse_version_text(gradle)
+ except ReleaseError:
+ return None
+
+
+def validate_history(version: Version, config: dict, *, require_absent_tag: bool = True) -> None:
+ expected_tag = config["tagPrefix"] + version.name
+ if require_absent_tag and git_ref_exists(f"refs/tags/{expected_tag}"):
+ raise ReleaseError(f"Tag already exists: {expected_tag}")
+ historical = [
+ parsed for tag in run(["git", "tag", "--list", f'{config["tagPrefix"]}*']).splitlines()
+ if require_absent_tag or tag != expected_tag
+ if (parsed := version_from_tag(tag, config)) is not None
+ ]
+ if historical and version.code <= max(item.code for item in historical):
+ raise ReleaseError("versionCode must be greater than all tagged releases")
+ same_code = [item.name for item in historical if item.code == version.code]
+ if same_code:
+ raise ReleaseError(f"versionCode {version.code} already belongs to {same_code[0]}")
+
+
+def validate_branch(version: Version, config: dict, *, mode: str | None = None) -> str:
+ branch = current_branch()
+ branches = config["branches"]
+ inferred = mode
+ if inferred is None:
+ if branch == f'{branches["releasePrefix"]}{version.name}':
+ inferred = "release"
+ elif branch == f'{branches["hotfixPrefix"]}{version.name}':
+ inferred = "hotfix"
+ else:
+ raise ReleaseError(f"Unexpected release branch: {branch}")
+ base = branches["development"] if inferred == "release" else branches["stable"]
+ expected = f'{branches[inferred + "Prefix"]}{version.name}'
+ if branch != expected:
+ raise ReleaseError(f"Expected branch {expected}, found {branch}")
+ base_ref = base if git_ref_exists(f"refs/heads/{base}") else f"origin/{base}"
+ if subprocess.run(["git", "merge-base", "--is-ancestor", base_ref, "HEAD"], cwd=ROOT).returncode:
+ raise ReleaseError(f"{branch} is not based on {base}")
+ return inferred
+
+
+def resolve_release_candidate(*, head_branch: str, base_branch: str, merge_commit: str, merged: bool, config: dict, stable_ref: str | None = None) -> ReleaseCandidate:
+ branches = config["branches"]
+ stable = branches["stable"]
+ if not merged:
+ raise ReleaseError("Release pull request was closed without being merged")
+ if base_branch != stable:
+ raise ReleaseError(f"Release pull request must target {stable}, found {base_branch}")
+ version = read_version_from_ref(merge_commit)
+ expected_release = f'{branches["releasePrefix"]}{version.name}'
+ expected_hotfix = f'{branches["hotfixPrefix"]}{version.name}'
+ if head_branch == expected_release:
+ mode = "release"
+ elif head_branch == expected_hotfix:
+ mode = "hotfix"
+ else:
+ raise ReleaseError(f"Candidate branch {head_branch} does not match version {version.name}")
+ commit_type = run(["git", "cat-file", "-t", f"{merge_commit}^{{commit}}"], check=False)
+ if commit_type != "commit":
+ raise ReleaseError(f"Candidate is not a commit: {merge_commit}")
+ parents = run(["git", "rev-list", "--parents", "-n", "1", merge_commit], check=False).split()
+ if len(parents) < 3 or parents[0] != merge_commit:
+ raise ReleaseError(f"Candidate is not a merge commit: {merge_commit}")
+ if stable_ref is None:
+ remote_ref = f"origin/{stable}"
+ stable_ref = remote_ref if git_ref_exists(f"refs/remotes/{remote_ref}") else stable
+ if not git_is_ancestor(merge_commit, stable_ref):
+ raise ReleaseError(f"Candidate {merge_commit} is not contained in {stable_ref}")
+ tag = config["tagPrefix"] + version.name
+ if git_ref_exists(f"refs/tags/{tag}"):
+ raise ReleaseError(f"Tag already exists: {tag}")
+ return ReleaseCandidate(mode, head_branch, base_branch, merge_commit, version, tag)
+
+
+def candidate_conflicts(version: Version, config: dict, mode: str) -> list[str]:
+ branches = config["branches"]
+ branch = f'{branches[mode + "Prefix"]}{version.name}'
+ tag = config["tagPrefix"] + version.name
+ conflicts = []
+ for ref in (f"refs/heads/{branch}", f"refs/remotes/origin/{branch}", f"refs/tags/{tag}"):
+ if git_ref_exists(ref):
+ conflicts.append(ref)
+ if shutil.which("gh"):
+ gh = subprocess.run(["gh", "pr", "list", "--state", "open", "--head", branch, "--json", "number"], cwd=ROOT, text=True, capture_output=True, check=False)
+ if gh.returncode == 0 and json.loads(gh.stdout or "[]"):
+ conflicts.append(f"open pull request for {branch}")
+ return conflicts
+
+
+def ensure_clean_current_base(config: dict, mode: str) -> str:
+ if run(["git", "status", "--porcelain"]):
+ raise ReleaseError("Working tree must be clean")
+ base = config["branches"]["development" if mode == "release" else "stable"]
+ if current_branch() != base:
+ raise ReleaseError(f"Preparation must start on {base}")
+ run(["git", "fetch", "origin", "--prune", "--tags"])
+ local = run(["git", "rev-parse", base])
+ remote = run(["git", "rev-parse", f"origin/{base}"])
+ if local != remote:
+ raise ReleaseError(f"{base} must exactly match origin/{base}")
+ return base
+
+
+def verify_build_identity(version: Version, manifest_path: Path, metadata_path: Path) -> None:
+ android = "{http://schemas.android.com/apk/res/android}"
+ root = ET.parse(manifest_path).getroot()
+ manifest_code = int(root.attrib[android + "versionCode"])
+ manifest_name = root.attrib[android + "versionName"]
+ metadata = json.loads(metadata_path.read_text(encoding="utf-8"))
+ element = metadata["elements"][0]
+ identities = {(version.code, version.name), (manifest_code, manifest_name), (int(element["versionCode"]), element["versionName"])}
+ if len(identities) != 1:
+ raise ReleaseError(f"Build output identity does not match Gradle: {sorted(identities)}")
+
+
+def prepare(args: argparse.Namespace) -> None:
+ config = load_config()
+ current = read_version()
+ candidate = next_version(current, args.bump, args.version)
+ mode = "hotfix" if args.hotfix else "release"
+ branches = config["branches"]
+ branch = f'{branches[mode + "Prefix"]}{candidate.name}'
+ base = branches["stable" if mode == "hotfix" else "development"]
+ summary = {"base": base, "branch": branch, "versionName": candidate.name, "versionCode": candidate.code}
+ if args.dry_run:
+ conflicts = candidate_conflicts(candidate, config, mode)
+ validate_history(candidate, config)
+ if conflicts:
+ raise ReleaseError("Release candidate already exists: " + ", ".join(conflicts))
+ print(json.dumps(summary, indent=2))
+ return
+ if not shutil.which("gh"):
+ raise ReleaseError("GitHub CLI (gh) is required before preparation can mutate the repository")
+ ensure_clean_current_base(config, mode)
+ conflicts = candidate_conflicts(candidate, config, mode)
+ validate_history(candidate, config)
+ if conflicts:
+ raise ReleaseError("Release candidate already exists: " + ", ".join(conflicts))
+ run(["git", "switch", "-c", branch])
+ write_version(candidate)
+ create_placeholders(candidate, config)
+ run(["git", "add", "app/build.gradle.kts", config["metadataRoot"]])
+ run(["git", "commit", "-m", f"Prepare release {candidate.name}"])
+ run(["git", "push", "-u", "origin", branch])
+ checklist = "\n".join(["## Release checklist", "", "- [ ] Localized release notes completed", "- [ ] Release validation passes", "- [ ] Release APK and AAB assemble successfully"])
+ run(["gh", "pr", "create", "--base", branches["stable"], "--head", branch, "--title", f"Release {candidate.name}", "--body", checklist])
+ print(json.dumps(summary, indent=2))
+
+
+def metadata(args: argparse.Namespace) -> None:
+ config = load_config()
+ version = read_version()
+ print(json.dumps({
+ "versionName": version.name,
+ "versionCode": version.code,
+ "tag": config["tagPrefix"] + version.name,
+ "playTrack": config["playTrack"],
+ "locales": discover_locales(config),
+ "changelogs": [str(path.relative_to(ROOT)) for path in changelog_paths(version, config)],
+ }, indent=2))
+
+
+def candidate(args: argparse.Namespace) -> None:
+ resolved = resolve_release_candidate(
+ head_branch=args.head_branch,
+ base_branch=args.base_branch,
+ merge_commit=args.merge_commit,
+ merged=args.merged == "true",
+ config=load_config(),
+ )
+ print(json.dumps({
+ "mode": resolved.mode,
+ "headBranch": resolved.head_branch,
+ "baseBranch": resolved.base_branch,
+ "mergeCommit": resolved.merge_commit,
+ "versionName": resolved.version.name,
+ "versionCode": resolved.version.code,
+ "tag": resolved.tag,
+ }, indent=2))
+
+
+def validate(args: argparse.Namespace) -> None:
+ config = load_config()
+ version = read_version()
+ manifest = MANIFEST_PATH.read_text(encoding="utf-8")
+ if "android:versionCode" in manifest or "android:versionName" in manifest:
+ raise ReleaseError("Manifest contains competing Android version declarations")
+ validate_history(version, config, require_absent_tag=not args.allow_existing_tag)
+ validate_changelogs(version, config)
+ if not args.skip_branch:
+ validate_branch(version, config, mode=args.mode)
+ if args.check_play:
+ if not args.service_account:
+ raise ReleaseError("--check-play requires --service-account")
+ run([
+ "bundle", "exec", "fastlane", "android", "validate_play_version_code",
+ f"version_code:{version.code}", f"json_key:{Path(args.service_account).resolve()}",
+ ])
+ print(json.dumps({"valid": True, "versionName": version.name, "versionCode": version.code}))
+
+
+def verify_build(args: argparse.Namespace) -> None:
+ version = read_version()
+ verify_build_identity(version, Path(args.manifest), Path(args.metadata))
+ print(json.dumps({"valid": True, "versionName": version.name, "versionCode": version.code}))
+
+
+def build_parser() -> argparse.ArgumentParser:
+ parser = argparse.ArgumentParser()
+ commands = parser.add_subparsers(dest="command", required=True)
+ metadata_parser = commands.add_parser("metadata")
+ metadata_parser.set_defaults(handler=metadata)
+ candidate_parser = commands.add_parser("candidate")
+ candidate_parser.add_argument("--head-branch", required=True)
+ candidate_parser.add_argument("--base-branch", required=True)
+ candidate_parser.add_argument("--merge-commit", required=True)
+ candidate_parser.add_argument("--merged", required=True, choices=("true", "false"))
+ candidate_parser.set_defaults(handler=candidate)
+ validate_parser = commands.add_parser("validate")
+ validate_parser.add_argument("--mode", choices=("release", "hotfix"))
+ validate_parser.add_argument("--skip-branch", action="store_true")
+ validate_parser.add_argument("--allow-existing-tag", action="store_true")
+ validate_parser.add_argument("--check-play", action="store_true")
+ validate_parser.add_argument("--service-account")
+ validate_parser.set_defaults(handler=validate)
+ verify_parser = commands.add_parser("verify-build")
+ verify_parser.add_argument("--manifest", required=True)
+ verify_parser.add_argument("--metadata", required=True)
+ verify_parser.set_defaults(handler=verify_build)
+ prepare_parser = commands.add_parser("prepare")
+ group = prepare_parser.add_mutually_exclusive_group(required=True)
+ group.add_argument("--bump", choices=("patch", "minor", "major"))
+ group.add_argument("--version")
+ prepare_parser.add_argument("--hotfix", action="store_true")
+ prepare_parser.add_argument("--dry-run", action="store_true")
+ prepare_parser.set_defaults(handler=prepare)
+ return parser
+
+
+def main(argv: Sequence[str] | None = None) -> int:
+ try:
+ args = build_parser().parse_args(argv)
+ args.handler(args)
+ return 0
+ except (ReleaseError, OSError, json.JSONDecodeError, ET.ParseError) as error:
+ print(f"release-tool: {error}", file=sys.stderr)
+ return 2
+
+
+if __name__ == "__main__":
+ raise SystemExit(main())
diff --git a/scripts/test_release_tool.py b/scripts/test_release_tool.py
new file mode 100644
index 0000000..b8af6af
--- /dev/null
+++ b/scripts/test_release_tool.py
@@ -0,0 +1,117 @@
+import json
+import sys
+import tempfile
+import unittest
+from pathlib import Path
+from unittest import mock
+
+sys.path.insert(0, str(Path(__file__).resolve().parent))
+import release_tool
+
+
+class ReleaseToolTests(unittest.TestCase):
+ def test_parse_kotlin_gradle_version(self):
+ version = release_tool.parse_version_text('''
+ defaultConfig {
+ versionCode = 7
+ versionName = "0.4.0"
+ }
+ ''')
+ self.assertEqual(version.code, 7)
+ self.assertEqual(version.name, "0.4.0")
+
+ def test_write_kotlin_gradle_version_preserves_indentation(self):
+ with tempfile.TemporaryDirectory() as tmp:
+ path = Path(tmp) / "build.gradle.kts"
+ path.write_text(' versionCode = 7\n versionName = "0.4.0"\n', encoding="utf-8")
+ release_tool.write_version(release_tool.Version("0.5.0", 8), path)
+ self.assertEqual(path.read_text(encoding="utf-8"), ' versionCode = 8\n versionName = "0.5.0"\n')
+
+ def test_next_version_requires_greater_explicit_version(self):
+ with self.assertRaises(release_tool.ReleaseError):
+ release_tool.next_version(release_tool.Version("0.4.0", 7), None, "0.4.0")
+ self.assertEqual(release_tool.next_version(release_tool.Version("0.4.0", 7), "minor", None), release_tool.Version("0.5.0", 8))
+
+ def test_discover_locales_and_changelog_paths(self):
+ with tempfile.TemporaryDirectory() as tmp:
+ root = Path(tmp)
+ for locale in ("en-US", "ru-RU"):
+ locale_dir = root / "fastlane" / "metadata" / "android" / locale
+ locale_dir.mkdir(parents=True)
+ (locale_dir / "title.txt").write_text("Voice Inbox", encoding="utf-8")
+ config = {
+ "metadataRoot": "fastlane/metadata/android",
+ "requiredLocales": ["en-US", "ru-RU"],
+ "ignoredLocales": [],
+ }
+ self.assertEqual(release_tool.discover_locales(config, root), ["en-US", "ru-RU"])
+ paths = release_tool.changelog_paths(release_tool.Version("0.5.0", 8), config, root)
+ self.assertEqual([p.name for p in paths], ["8.txt", "8.txt"])
+
+ def test_validate_changelogs_rejects_placeholders(self):
+ with tempfile.TemporaryDirectory() as tmp:
+ root = Path(tmp)
+ for locale in ("en-US", "ru-RU"):
+ locale_dir = root / "fastlane" / "metadata" / "android" / locale
+ changelog_dir = locale_dir / "changelogs"
+ changelog_dir.mkdir(parents=True)
+ (locale_dir / "title.txt").write_text("Voice Inbox", encoding="utf-8")
+ (changelog_dir / "8.txt").write_text("TODO: RELEASE NOTES", encoding="utf-8")
+ config = {
+ "metadataRoot": "fastlane/metadata/android",
+ "requiredLocales": ["en-US", "ru-RU"],
+ "ignoredLocales": [],
+ "placeholderMarkers": ["TODO: RELEASE NOTES"],
+ }
+ with self.assertRaises(release_tool.ReleaseError):
+ release_tool.validate_changelogs(release_tool.Version("0.5.0", 8), config, root)
+
+ def test_validate_branch_requires_release_branch_name(self):
+ config = {
+ "branches": {
+ "development": "develop",
+ "stable": "master",
+ "releasePrefix": "release/",
+ "hotfixPrefix": "hotfix/",
+ }
+ }
+ with mock.patch.object(release_tool, "current_branch", return_value="release/0.5.0"), \
+ mock.patch.object(release_tool, "git_ref_exists", return_value=True), \
+ mock.patch("subprocess.run") as subprocess_run:
+ subprocess_run.return_value.returncode = 0
+ self.assertEqual(release_tool.validate_branch(release_tool.Version("0.5.0", 8), config), "release")
+
+ def test_resolve_candidate_rejects_non_matching_branch(self):
+ config = {
+ "branches": {
+ "development": "develop",
+ "stable": "master",
+ "releasePrefix": "release/",
+ "hotfixPrefix": "hotfix/",
+ },
+ "tagPrefix": "v",
+ }
+ with mock.patch.object(release_tool, "read_version_from_ref", return_value=release_tool.Version("0.5.0", 8)):
+ with self.assertRaises(release_tool.ReleaseError):
+ release_tool.resolve_release_candidate(
+ head_branch="feature/not-release",
+ base_branch="master",
+ merge_commit="abc123",
+ merged=True,
+ config=config,
+ stable_ref="master",
+ )
+
+ def test_verify_build_identity_detects_mismatch(self):
+ with tempfile.TemporaryDirectory() as tmp:
+ root = Path(tmp)
+ manifest = root / "AndroidManifest.xml"
+ metadata = root / "output-metadata.json"
+ manifest.write_text('', encoding="utf-8")
+ metadata.write_text(json.dumps({"elements": [{"versionCode": 9, "versionName": "0.5.0"}]}), encoding="utf-8")
+ with self.assertRaises(release_tool.ReleaseError):
+ release_tool.verify_build_identity(release_tool.Version("0.5.0", 8), manifest, metadata)
+
+
+if __name__ == "__main__":
+ unittest.main()
diff --git a/shared/src/commonMain/kotlin/me/maxistar/voiceinbox/core/TaskListPresentationController.kt b/shared/src/commonMain/kotlin/me/maxistar/voiceinbox/core/TaskListPresentationController.kt
index 5e0e6f8..c0bcdfa 100644
--- a/shared/src/commonMain/kotlin/me/maxistar/voiceinbox/core/TaskListPresentationController.kt
+++ b/shared/src/commonMain/kotlin/me/maxistar/voiceinbox/core/TaskListPresentationController.kt
@@ -54,14 +54,72 @@ enum class TaskActionKind {
OPEN_VOICE_KEYBOARD_DOCUMENTATION,
}
+/**
+ * Stable, locale-neutral identifiers for copy owned by the task-list presentation.
+ *
+ * [fallback] keeps iOS English-only until it adopts a native localization layer;
+ * Android resolves [TaskText.key] through its resources instead.
+ */
+enum class TaskTextKey {
+ OPAQUE,
+ INSTALL_SPEECH_MODEL,
+ AUTOMATIC_TRANSCRIPT_EXPORT,
+ OUTPUT_OPTIONAL_DETAIL,
+ REFRESH_AUDIO_FOLDER,
+ RESTORE_AUDIO_FOLDER_ACCESS,
+ REQUIRED,
+ OPTIONAL,
+ INSTALLING,
+ SCANNING,
+ NEEDS_ATTENTION,
+ NEW,
+ PROCESSING,
+ PROCESSED,
+ FAILED,
+ NO_SPEECH,
+ CANCEL,
+ RETRY_DOWNLOAD,
+ DOWNLOAD,
+ CREATE_NEW,
+ CHOOSE_EXISTING,
+ HIDE,
+ SELECT_FOLDER,
+ RETRY,
+ TRANSCRIBE,
+ SHOW_TEXT,
+ STOP,
+ PLAY,
+ IMPORT_AUDIO_FILES,
+ SELECT_AUDIO_FOLDER,
+ NO_NEW_TASKS,
+ NO_PROCESSED_AUDIO,
+ NO_TASKS,
+ INSTALLING_MODEL,
+ SCANNING_AUDIO_FOLDER,
+ PREPARING_SPEECH_MODEL,
+ CHOOSE_MODEL,
+ SELECTED_MODEL_DETAIL,
+}
+
+data class TaskText(
+ val key: TaskTextKey,
+ val fallback: String,
+ val arguments: List = emptyList(),
+)
+
+private fun taskText(key: TaskTextKey, fallback: String, vararg arguments: String) =
+ TaskText(key, fallback, arguments.toList())
+
+private fun opaqueTaskText(value: String) = taskText(TaskTextKey.OPAQUE, value, value)
+
data class TaskActionPresentation(
val kind: TaskActionKind,
- val label: String,
+ val text: TaskText,
val enabled: Boolean = true,
)
data class TaskProgressPresentation(
- val phase: String,
+ val phase: TaskText,
val percent: Int? = null,
val processedUs: Long? = null,
val durationUs: Long? = null,
@@ -72,11 +130,11 @@ data class TaskProgressPresentation(
sealed class TaskPresentation {
abstract val stableId: String
- abstract val title: String
- abstract val detail: String?
- abstract val badge: String
+ abstract val title: TaskText
+ abstract val detail: TaskText?
+ abstract val badge: TaskText
abstract val progress: TaskProgressPresentation?
- abstract val errorMessage: String?
+ abstract val errorMessage: TaskText?
abstract val actions: List
abstract val retention: TaskRetention
}
@@ -85,11 +143,11 @@ data class SetupTaskPresentation(
override val stableId: String,
val kind: SetupTaskKind,
val state: SetupTaskState,
- override val title: String,
- override val detail: String?,
- override val badge: String,
+ override val title: TaskText,
+ override val detail: TaskText?,
+ override val badge: TaskText,
override val progress: TaskProgressPresentation?,
- override val errorMessage: String?,
+ override val errorMessage: TaskText?,
override val actions: List,
override val retention: TaskRetention = TaskRetention.UNTIL_COMPLETED,
) : TaskPresentation()
@@ -98,11 +156,11 @@ data class AudioTaskPresentation(
override val stableId: String,
val entryId: Long,
val state: AudioTaskState,
- override val title: String,
- override val detail: String?,
- override val badge: String,
+ override val title: TaskText,
+ override val detail: TaskText?,
+ override val badge: TaskText,
override val progress: TaskProgressPresentation?,
- override val errorMessage: String?,
+ override val errorMessage: TaskText?,
override val actions: List,
override val retention: TaskRetention = TaskRetention.RETAINED,
) : TaskPresentation()
@@ -210,7 +268,7 @@ data class TaskListBatchActionState(
data class TaskListState(
val filter: TaskListFilter,
val tasks: List,
- val emptyMessage: String?,
+ val emptyMessage: TaskText?,
val emptyActions: List,
val batchAction: TaskListBatchActionState,
)
@@ -234,9 +292,9 @@ object TaskListPresentationController {
emptyMessage = if (tasks.isEmpty()) emptyMessage(input.filter) else null,
emptyActions = if (tasks.isEmpty() && input.filter != TaskListFilter.PROCESSED) {
buildList {
- add(TaskActionPresentation(TaskActionKind.IMPORT_AUDIO, "Import Audio Files"))
+ add(TaskActionPresentation(TaskActionKind.IMPORT_AUDIO, taskText(TaskTextKey.IMPORT_AUDIO_FILES, "Import Audio Files")))
if (input.folder.state == FolderSetupSnapshotState.UNSELECTED) {
- add(TaskActionPresentation(TaskActionKind.SELECT_FOLDER, "Select Audio Folder"))
+ add(TaskActionPresentation(TaskActionKind.SELECT_FOLDER, taskText(TaskTextKey.SELECT_AUDIO_FOLDER, "Select Audio Folder")))
}
}
} else {
@@ -271,7 +329,7 @@ object TaskListPresentationController {
val error = snapshot.state == ModelSetupSnapshotState.INVALID
val actions = when {
active && snapshot.canCancel -> listOf(
- TaskActionPresentation(TaskActionKind.CANCEL_MODEL_DOWNLOAD, "Cancel"),
+ TaskActionPresentation(TaskActionKind.CANCEL_MODEL_DOWNLOAD, taskText(TaskTextKey.CANCEL, "Cancel")),
)
active -> emptyList()
error -> listOf(
@@ -280,14 +338,14 @@ object TaskListPresentationController {
selectedModelLabel(snapshot),
snapshot.downloadChoices.isNotEmpty(),
),
- TaskActionPresentation(TaskActionKind.RETRY_MODEL_DOWNLOAD, "Retry Download", snapshot.downloadAvailable),
+ TaskActionPresentation(TaskActionKind.RETRY_MODEL_DOWNLOAD, taskText(TaskTextKey.RETRY_DOWNLOAD, "Retry Download"), snapshot.downloadAvailable),
)
else -> buildList {
if (snapshot.downloadChoices.size > 1) {
add(TaskActionPresentation(TaskActionKind.SELECT_DOWNLOAD_MODEL, selectedModelLabel(snapshot)))
}
if (snapshot.downloadAvailable) {
- add(TaskActionPresentation(TaskActionKind.DOWNLOAD_MODEL, "Download"))
+ add(TaskActionPresentation(TaskActionKind.DOWNLOAD_MODEL, taskText(TaskTextKey.DOWNLOAD, "Download")))
}
}
}
@@ -299,35 +357,45 @@ object TaskListPresentationController {
error -> SetupTaskState.ERROR
else -> SetupTaskState.REQUIRED
},
- title = "Install Speech Model",
- detail = selectedModelDetail(snapshot) ?: snapshot.detail.takeUnless { active },
- badge = if (active) "Installing" else if (error) "Needs attention" else "Required",
+ title = taskText(TaskTextKey.INSTALL_SPEECH_MODEL, "Install Speech Model"),
+ detail = selectedModelDetail(snapshot) ?: snapshot.detail.takeUnless { active }?.let(::opaqueTaskText),
+ badge = if (active) taskText(TaskTextKey.INSTALLING, "Installing") else if (error) taskText(TaskTextKey.NEEDS_ATTENTION, "Needs attention") else taskText(TaskTextKey.REQUIRED, "Required"),
progress = if (active) {
TaskProgressPresentation(
- snapshot.installationPhase?.takeIf(String::isNotBlank) ?: "Installing model",
+ snapshot.installationPhase?.takeIf(String::isNotBlank)?.let(::opaqueTaskText)
+ ?: taskText(TaskTextKey.INSTALLING_MODEL, "Installing model"),
snapshot.progressPercent,
)
} else {
null
},
- errorMessage = snapshot.detail.takeIf { error },
+ errorMessage = snapshot.detail.takeIf { error }?.let(::opaqueTaskText),
actions = actions,
)
}
- private fun selectedModelDetail(snapshot: ModelSetupSnapshot): String? {
+ private fun selectedModelDetail(snapshot: ModelSetupSnapshot): TaskText? {
val selected = snapshot.selectedModel ?: return null
val choice = snapshot.downloadChoices.firstOrNull { it.identity == selected } ?: return null
val download = formatBytes(choice.downloadBytes)
val storage = formatBytes(choice.requiredStorageBytes)
- return "${choice.displayName} · ${choice.languageSummary} · ${choice.maturity} · $download download · $storage free"
+ return taskText(
+ TaskTextKey.SELECTED_MODEL_DETAIL,
+ "${choice.displayName} · ${choice.languageSummary} · ${choice.maturity} · $download download · $storage free",
+ choice.displayName,
+ choice.languageSummary,
+ choice.maturity,
+ download,
+ storage,
+ )
}
- private fun selectedModelLabel(snapshot: ModelSetupSnapshot): String =
+ private fun selectedModelLabel(snapshot: ModelSetupSnapshot): TaskText =
snapshot.selectedModel
?.let { selected -> snapshot.downloadChoices.firstOrNull { it.identity == selected } }
?.displayName
- ?: "Choose Model"
+ ?.let(::opaqueTaskText)
+ ?: taskText(TaskTextKey.CHOOSE_MODEL, "Choose Model")
private fun formatBytes(bytes: Long): String = when {
bytes >= 1024L * 1024L * 1024L -> "${bytes / (1024L * 1024L * 1024L)} GB"
@@ -341,15 +409,16 @@ object TaskListPresentationController {
stableId = "setup:output",
kind = SetupTaskKind.OUTPUT,
state = if (error) SetupTaskState.ERROR else SetupTaskState.REQUIRED,
- title = "Automatic Transcript Export",
- detail = snapshot.detail ?: "Optional. Transcripts are stored in Voice Inbox.",
- badge = if (error) "Needs attention" else "Optional",
+ title = taskText(TaskTextKey.AUTOMATIC_TRANSCRIPT_EXPORT, "Automatic Transcript Export"),
+ detail = snapshot.detail?.let(::opaqueTaskText)
+ ?: taskText(TaskTextKey.OUTPUT_OPTIONAL_DETAIL, "Optional. Transcripts are stored in Voice Inbox."),
+ badge = if (error) taskText(TaskTextKey.NEEDS_ATTENTION, "Needs attention") else taskText(TaskTextKey.OPTIONAL, "Optional"),
progress = null,
- errorMessage = snapshot.detail.takeIf { error },
+ errorMessage = snapshot.detail.takeIf { error }?.let(::opaqueTaskText),
actions = buildList {
- add(TaskActionPresentation(TaskActionKind.CREATE_OUTPUT, "Create New"))
- add(TaskActionPresentation(TaskActionKind.SELECT_OUTPUT, "Choose Existing"))
- if (!error) add(TaskActionPresentation(TaskActionKind.HIDE_OUTPUT, "Hide"))
+ add(TaskActionPresentation(TaskActionKind.CREATE_OUTPUT, taskText(TaskTextKey.CREATE_NEW, "Create New")))
+ add(TaskActionPresentation(TaskActionKind.SELECT_OUTPUT, taskText(TaskTextKey.CHOOSE_EXISTING, "Choose Existing")))
+ if (!error) add(TaskActionPresentation(TaskActionKind.HIDE_OUTPUT, taskText(TaskTextKey.HIDE, "Hide")))
},
)
}
@@ -362,10 +431,10 @@ object TaskListPresentationController {
stableId = "setup:folder",
kind = SetupTaskKind.FOLDER,
state = SetupTaskState.ACTIVE,
- title = "Refresh Audio Folder",
- detail = snapshot.detail,
- badge = "Scanning",
- progress = TaskProgressPresentation("Scanning audio folder"),
+ title = taskText(TaskTextKey.REFRESH_AUDIO_FOLDER, "Refresh Audio Folder"),
+ detail = snapshot.detail?.let(::opaqueTaskText),
+ badge = taskText(TaskTextKey.SCANNING, "Scanning"),
+ progress = TaskProgressPresentation(taskText(TaskTextKey.SCANNING_AUDIO_FOLDER, "Scanning audio folder")),
errorMessage = null,
actions = emptyList(),
)
@@ -373,14 +442,14 @@ object TaskListPresentationController {
stableId = "setup:folder",
kind = SetupTaskKind.FOLDER,
state = SetupTaskState.ERROR,
- title = "Restore Audio Folder Access",
- detail = snapshot.detail,
- badge = "Needs attention",
+ title = taskText(TaskTextKey.RESTORE_AUDIO_FOLDER_ACCESS, "Restore Audio Folder Access"),
+ detail = snapshot.detail?.let(::opaqueTaskText),
+ badge = taskText(TaskTextKey.NEEDS_ATTENTION, "Needs attention"),
progress = null,
- errorMessage = snapshot.detail,
+ errorMessage = snapshot.detail?.let(::opaqueTaskText),
actions = listOf(
- TaskActionPresentation(TaskActionKind.SELECT_FOLDER, "Select Folder"),
- TaskActionPresentation(TaskActionKind.REFRESH_FOLDER, "Retry"),
+ TaskActionPresentation(TaskActionKind.SELECT_FOLDER, taskText(TaskTextKey.SELECT_FOLDER, "Select Folder")),
+ TaskActionPresentation(TaskActionKind.REFRESH_FOLDER, taskText(TaskTextKey.RETRY, "Retry")),
),
)
}
@@ -408,7 +477,7 @@ object TaskListPresentationController {
AudioTaskState.PENDING -> add(
TaskActionPresentation(
TaskActionKind.TRANSCRIBE,
- "Transcribe",
+ taskText(TaskTextKey.TRANSCRIBE, "Transcribe"),
snapshot.eligibleForTranscription && !input.transcription.active,
),
)
@@ -417,19 +486,19 @@ object TaskListPresentationController {
-> add(
TaskActionPresentation(
TaskActionKind.RETRY_TRANSCRIPTION,
- "Retry",
+ taskText(TaskTextKey.RETRY, "Retry"),
snapshot.eligibleForTranscription && !input.transcription.active,
),
)
AudioTaskState.SUCCEEDED -> if (snapshot.hasTranscriptText) {
- add(TaskActionPresentation(TaskActionKind.SHOW_TEXT, "Show Text"))
+ add(TaskActionPresentation(TaskActionKind.SHOW_TEXT, taskText(TaskTextKey.SHOW_TEXT, "Show Text")))
}
AudioTaskState.PROCESSING -> Unit
}
add(
TaskActionPresentation(
if (isPreviewing) TaskActionKind.STOP else TaskActionKind.PLAY,
- if (isPreviewing) "Stop" else "Play",
+ if (isPreviewing) taskText(TaskTextKey.STOP, "Stop") else taskText(TaskTextKey.PLAY, "Play"),
isPreviewing || (snapshot.eligibleForPreview && !input.transcription.active),
),
)
@@ -443,24 +512,24 @@ object TaskListPresentationController {
stableId = "audio:${snapshot.entryId}",
entryId = snapshot.entryId,
state = presentationState,
- title = snapshot.title,
- detail = snapshot.detail,
+ title = opaqueTaskText(snapshot.title),
+ detail = snapshot.detail?.let(::opaqueTaskText),
badge = when (presentationState) {
- AudioTaskState.PENDING -> "New"
- AudioTaskState.PROCESSING -> "Processing"
- AudioTaskState.SUCCEEDED -> "Processed"
- AudioTaskState.FAILED -> "Failed"
- AudioTaskState.NO_SPEECH -> "No speech"
+ AudioTaskState.PENDING -> taskText(TaskTextKey.NEW, "New")
+ AudioTaskState.PROCESSING -> taskText(TaskTextKey.PROCESSING, "Processing")
+ AudioTaskState.SUCCEEDED -> taskText(TaskTextKey.PROCESSED, "Processed")
+ AudioTaskState.FAILED -> taskText(TaskTextKey.FAILED, "Failed")
+ AudioTaskState.NO_SPEECH -> taskText(TaskTextKey.NO_SPEECH, "No speech")
},
progress = if (ownsProgress) input.transcription.toProgress() else null,
- errorMessage = prerequisiteError ?: snapshot.lastError,
+ errorMessage = (prerequisiteError ?: snapshot.lastError)?.let(::opaqueTaskText),
actions = actions,
)
}
private fun TranscriptionTaskSnapshot.toProgress(): TaskProgressPresentation =
TaskProgressPresentation(
- phase = phase ?: "Processing",
+ phase = phase?.let(::opaqueTaskText) ?: taskText(TaskTextKey.PROCESSING, "Processing"),
percent = percent,
processedUs = processedUs,
durationUs = durationUs,
@@ -485,9 +554,9 @@ object TaskListPresentationController {
.thenByDescending { it.entryId }
}
- private fun emptyMessage(filter: TaskListFilter): String = when (filter) {
- TaskListFilter.NEW -> "No new tasks"
- TaskListFilter.PROCESSED -> "No processed audio files"
- TaskListFilter.ALL -> "No audio tasks"
+ private fun emptyMessage(filter: TaskListFilter): TaskText = when (filter) {
+ TaskListFilter.NEW -> taskText(TaskTextKey.NO_NEW_TASKS, "No new tasks")
+ TaskListFilter.PROCESSED -> taskText(TaskTextKey.NO_PROCESSED_AUDIO, "No processed audio files")
+ TaskListFilter.ALL -> taskText(TaskTextKey.NO_TASKS, "No audio tasks")
}
}
diff --git a/shared/src/commonTest/kotlin/me/maxistar/voiceinbox/core/TaskListPresentationControllerTest.kt b/shared/src/commonTest/kotlin/me/maxistar/voiceinbox/core/TaskListPresentationControllerTest.kt
index d7fc2f5..2ac7da0 100644
--- a/shared/src/commonTest/kotlin/me/maxistar/voiceinbox/core/TaskListPresentationControllerTest.kt
+++ b/shared/src/commonTest/kotlin/me/maxistar/voiceinbox/core/TaskListPresentationControllerTest.kt
@@ -22,7 +22,8 @@ class TaskListPresentationControllerTest {
),
).tasks.single(),
)
- assertTrue(required.detail?.contains("Whisper Tiny Multilingual") == true)
+ assertEquals(TaskTextKey.SELECTED_MODEL_DETAIL, required.detail?.key)
+ assertEquals("Whisper Tiny Multilingual", required.detail?.arguments?.first())
assertTrue(required.actions.any { it.kind == TaskActionKind.SELECT_DOWNLOAD_MODEL })
val installing = assertIs(
@@ -73,7 +74,7 @@ class TaskListPresentationControllerTest {
state(output = OutputSetupSnapshot(OutputSetupSnapshotState.REQUIRED)).tasks.single(),
)
- assertEquals("Automatic Transcript Export", task.title)
+ assertEquals(TaskTextKey.AUTOMATIC_TRANSCRIPT_EXPORT, task.title.key)
assertEquals(
listOf(
TaskActionKind.CREATE_OUTPUT,
@@ -82,12 +83,16 @@ class TaskListPresentationControllerTest {
),
task.actions.map { it.kind },
)
- assertEquals(listOf("Create New", "Choose Existing", "Hide"), task.actions.map { it.label })
+ assertEquals(
+ listOf(TaskTextKey.CREATE_NEW, TaskTextKey.CHOOSE_EXISTING, TaskTextKey.HIDE),
+ task.actions.map { it.text.key },
+ )
val invalid = assertIs(
state(output = OutputSetupSnapshot(OutputSetupSnapshotState.INVALID, "Access expired")).tasks.single(),
)
- assertEquals("Access expired", invalid.errorMessage)
+ assertEquals(TaskTextKey.OPAQUE, invalid.errorMessage?.key)
+ assertEquals("Access expired", invalid.errorMessage?.arguments?.single())
assertEquals(
listOf(TaskActionKind.CREATE_OUTPUT, TaskActionKind.SELECT_OUTPUT),
invalid.actions.map { it.kind },
@@ -117,8 +122,9 @@ class TaskListPresentationControllerTest {
).tasks.single(),
)
- assertEquals("Installing", download.badge)
- assertEquals("Downloading encoder.onnx", download.progress?.phase)
+ assertEquals(TaskTextKey.INSTALLING, download.badge.key)
+ assertEquals(TaskTextKey.OPAQUE, download.progress?.phase?.key)
+ assertEquals("Downloading encoder.onnx", download.progress?.phase?.arguments?.single())
assertEquals(42, download.progress?.percent)
assertEquals(listOf(TaskActionKind.CANCEL_MODEL_DOWNLOAD), download.actions.map { it.kind })
@@ -133,13 +139,14 @@ class TaskListPresentationControllerTest {
).tasks.single(),
)
assertNull(localImport.detail)
- assertEquals("Verifying local model", localImport.progress?.phase)
+ assertEquals(TaskTextKey.OPAQUE, localImport.progress?.phase?.key)
+ assertEquals("Verifying local model", localImport.progress?.phase?.arguments?.single())
assertTrue(localImport.actions.isEmpty())
val fallback = assertIs(
state(model = ModelSetupSnapshot(ModelSetupSnapshotState.INSTALLING)).tasks.single(),
)
- assertEquals("Installing model", fallback.progress?.phase)
+ assertEquals(TaskTextKey.INSTALLING_MODEL, fallback.progress?.phase?.key)
assertNull(fallback.progress?.percent)
}
@@ -155,7 +162,8 @@ class TaskListPresentationControllerTest {
).tasks.single(),
)
- assertEquals("Verification failed", task.errorMessage)
+ assertEquals(TaskTextKey.OPAQUE, task.errorMessage?.key)
+ assertEquals("Verification failed", task.errorMessage?.arguments?.single())
assertFalse(task.actions.single { it.kind == TaskActionKind.RETRY_MODEL_DOWNLOAD }.enabled)
assertFalse(task.actions.any { it.kind == TaskActionKind.IMPORT_MODEL })
}
@@ -201,7 +209,8 @@ class TaskListPresentationControllerTest {
val task = assertIs(state.tasks.single())
assertEquals(AudioTaskState.PENDING, task.state)
- assertEquals("Model could not be loaded", task.errorMessage)
+ assertEquals(TaskTextKey.OPAQUE, task.errorMessage?.key)
+ assertEquals("Model could not be loaded", task.errorMessage?.arguments?.single())
assertTrue(state(TaskListFilter.PROCESSED, audio = listOf(audio(7, AudioFileState.PENDING))).tasks.isEmpty())
}
@@ -219,7 +228,8 @@ class TaskListPresentationControllerTest {
val byId = state.tasks.associateBy(TaskPresentation::stableId)
val active = assertIs(byId.getValue("audio:1"))
assertEquals(AudioTaskState.PROCESSING, active.state)
- assertEquals("Preparing speech model", active.progress?.phase)
+ assertEquals(TaskTextKey.OPAQUE, active.progress?.phase?.key)
+ assertEquals("Preparing speech model", active.progress?.phase?.arguments?.single())
assertNull(assertIs(byId.getValue("audio:2")).progress)
assertEquals("audio:1", active.stableId)
}
@@ -298,13 +308,13 @@ class TaskListPresentationControllerTest {
)
val processed = state(TaskListFilter.PROCESSED)
- assertEquals("No new tasks", newState.emptyMessage)
+ assertEquals(TaskTextKey.NO_NEW_TASKS, newState.emptyMessage?.key)
assertEquals(listOf(TaskActionKind.IMPORT_AUDIO), newState.emptyActions.map { it.kind })
assertEquals(
listOf(TaskActionKind.IMPORT_AUDIO, TaskActionKind.SELECT_FOLDER),
withoutFolder.emptyActions.map { it.kind },
)
- assertEquals("No processed audio files", processed.emptyMessage)
+ assertEquals(TaskTextKey.NO_PROCESSED_AUDIO, processed.emptyMessage?.key)
assertTrue(processed.emptyActions.isEmpty())
}
diff --git a/website/src/pages/legal.astro b/website/src/pages/legal.astro
index 7e03a78..29446c7 100644
--- a/website/src/pages/legal.astro
+++ b/website/src/pages/legal.astro
@@ -48,7 +48,6 @@ const base = import.meta.env.BASE_URL.replace(/\/$/, '');
App and publication details
This page collects information useful for users and app store publication preparation.
- Fields marked as placeholders must be reviewed and replaced by the maintainer before store submission.
@@ -57,7 +56,7 @@ const base = import.meta.env.BASE_URL.replace(/\/$/, '');
App nameVoice Inbox
Android packageme.maxistar.voiceinbox
iOS bundle statusiOS MVP in development; final bundle identifier and store listing details must be confirmed before release.
- Developer / publisherTODO: maintainer legal or publisher name
+ Project maintainerMaxim Starikov
diff --git a/website/src/pages/privacy.astro b/website/src/pages/privacy.astro
index 96d3fd1..c1e0657 100644
--- a/website/src/pages/privacy.astro
+++ b/website/src/pages/privacy.astro
@@ -55,6 +55,14 @@ import Layout from '../layouts/Layout.astro';
+
+ Android backup
+
+ Voice Inbox disables Android system backup for app-private data, including its local catalog, imported audio, settings, and installed model files.
+ This does not restrict files you explicitly share, export, or save to a document or folder you select outside the app.
+
+
+
External providers and platform services