mirror of
https://github.com/Darkrock-Studios/hammer-editor.git
synced 2026-10-24 07:30:31 +00:00
Compare commits
52 commits
develop
...
fdroid-307
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c8f7d92bf3 | ||
|
|
0829dc1bd2 | ||
|
|
a432599c69 | ||
|
|
8c04d9f28a | ||
|
|
5c18153ff0 | ||
|
|
3168bc4527 | ||
|
|
d397836c20 | ||
|
|
6af84d4ca2 | ||
|
|
a926e71783 | ||
|
|
58aa8c9f87 | ||
|
|
e7862f8a61 | ||
|
|
85a733221c | ||
|
|
a6a94d5517 | ||
|
|
cd5d016820 | ||
|
|
4fe5344ce0 | ||
|
|
1047a65365 | ||
|
|
82723313c7 | ||
|
|
11a6f6307a | ||
|
|
6cbaf3357b | ||
|
|
9e66f60136 | ||
|
|
babf1a74ac | ||
|
|
5281b2a6f0 | ||
|
|
492be1c4eb | ||
|
|
14fbb67a6e | ||
|
|
4310688098 | ||
|
|
d3221e8747 | ||
|
|
7ad0244092 | ||
|
|
cd03bea8e0 | ||
|
|
504f2a10a8 | ||
|
|
69ed22d0dc | ||
|
|
eb1b36394b | ||
|
|
6142df4311 | ||
|
|
a3ee323f7d | ||
|
|
894aa7d01a | ||
|
|
754d43befd | ||
|
|
99cfd47d44 | ||
|
|
81f4c46f77 | ||
|
|
807df5f607 | ||
|
|
0705a11a45 | ||
|
|
84e5325858 | ||
|
|
b0a0b81b70 | ||
|
|
dddf55f32b | ||
|
|
f3405c2c51 | ||
|
|
b66b7f07f1 | ||
|
|
72110a3c3c | ||
|
|
840c2619e8 | ||
|
|
502d6f4173 | ||
|
|
dc7f071186 | ||
|
|
2cfaadbbf7 | ||
|
|
80a3dbe0bb | ||
|
|
d623a5ace6 | ||
|
|
202552b68b |
1188 changed files with 10248 additions and 61110 deletions
64
.github/actions/setup-jbr/action.yml
vendored
Normal file
64
.github/actions/setup-jbr/action.yml
vendored
Normal file
|
|
@ -0,0 +1,64 @@
|
|||
name: 'Set up JBR'
|
||||
description: 'Set up the JetBrains Runtime, retrying through transient GitHub rate limits on the JBR download.'
|
||||
|
||||
inputs:
|
||||
token:
|
||||
description: 'GitHub token used for authenticated JBR downloads.'
|
||||
required: true
|
||||
cache:
|
||||
description: "Build tool to cache dependencies for (passed through to setup-java's cache input). Empty disables caching."
|
||||
required: false
|
||||
default: ''
|
||||
|
||||
runs:
|
||||
using: 'composite'
|
||||
steps:
|
||||
- name: Set up JBR (attempt 1)
|
||||
id: attempt1
|
||||
uses: actions/setup-java@v5
|
||||
continue-on-error: true
|
||||
# The jetbrains distribution authenticates its release-listing API call via
|
||||
# the GITHUB_TOKEN env var, not the `token` input — without it the request is
|
||||
# unauthenticated and hits GitHub's per-IP rate limit.
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ inputs.token }}
|
||||
with:
|
||||
distribution: 'jetbrains'
|
||||
java-version: 21
|
||||
token: ${{ inputs.token }}
|
||||
cache: ${{ inputs.cache }}
|
||||
|
||||
- name: Back off before retry
|
||||
if: steps.attempt1.outcome == 'failure'
|
||||
shell: bash
|
||||
run: sleep 30
|
||||
|
||||
- name: Set up JBR (attempt 2)
|
||||
id: attempt2
|
||||
if: steps.attempt1.outcome == 'failure'
|
||||
uses: actions/setup-java@v5
|
||||
continue-on-error: true
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ inputs.token }}
|
||||
with:
|
||||
distribution: 'jetbrains'
|
||||
java-version: 21
|
||||
token: ${{ inputs.token }}
|
||||
cache: ${{ inputs.cache }}
|
||||
|
||||
- name: Back off before final retry
|
||||
if: steps.attempt1.outcome == 'failure' && steps.attempt2.outcome == 'failure'
|
||||
shell: bash
|
||||
run: sleep 60
|
||||
|
||||
# Final attempt: no continue-on-error, so a persistent failure still fails the job.
|
||||
- name: Set up JBR (attempt 3)
|
||||
if: steps.attempt1.outcome == 'failure' && steps.attempt2.outcome == 'failure'
|
||||
uses: actions/setup-java@v5
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ inputs.token }}
|
||||
with:
|
||||
distribution: 'jetbrains'
|
||||
java-version: 21
|
||||
token: ${{ inputs.token }}
|
||||
cache: ${{ inputs.cache }}
|
||||
10
.github/workflows/build.yml
vendored
10
.github/workflows/build.yml
vendored
|
|
@ -21,9 +21,9 @@ jobs:
|
|||
cache: gradle
|
||||
|
||||
- name: Set up Node
|
||||
uses: actions/setup-node@v6
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: '24'
|
||||
node-version: '22'
|
||||
|
||||
- name: Grant execute permission for gradlew
|
||||
run: chmod +x gradlew
|
||||
|
|
@ -33,12 +33,6 @@ jobs:
|
|||
# buildDebug does not touch androidTest, so stale instrumented tests rot silently otherwise.
|
||||
- name: Compile Android instrumented tests
|
||||
run: ./gradlew :android:assembleDebugAndroidTest
|
||||
# buildDebug abbreviates to buildDebugPreBundle, which never runs unit tests, and Kover
|
||||
# aggregates only :common and :server, so the phone's unit tests need their own step.
|
||||
- name: Unit test the Android app
|
||||
run: ./gradlew :android:testDebugUnitTest
|
||||
- name: Build and unit test the Wear OS app
|
||||
run: ./gradlew :wear:assembleDebug :wear:testDebugUnitTest
|
||||
- name: Run Kover
|
||||
run: ./gradlew koverXmlReport
|
||||
- name: Upload coverage reports to Codecov
|
||||
|
|
|
|||
166
.github/workflows/prepare-release.yml
vendored
166
.github/workflows/prepare-release.yml
vendored
|
|
@ -15,8 +15,8 @@ jobs:
|
|||
- name: Set up JDK
|
||||
uses: actions/setup-java@v5
|
||||
with:
|
||||
distribution: 'temurin'
|
||||
java-version: '21'
|
||||
distribution: 'adopt'
|
||||
java-version: 21
|
||||
cache: gradle
|
||||
|
||||
- name: Run Unit Tests
|
||||
|
|
@ -50,11 +50,10 @@ jobs:
|
|||
- name: Checkout code
|
||||
uses: actions/checkout@v7
|
||||
|
||||
- name: Set up JDK
|
||||
uses: actions/setup-java@v5
|
||||
- name: Set up JBR
|
||||
uses: ./.github/actions/setup-jbr
|
||||
with:
|
||||
distribution: 'temurin'
|
||||
java-version: '21'
|
||||
token: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Gradle caching
|
||||
uses: actions/cache@v6
|
||||
|
|
@ -72,7 +71,7 @@ jobs:
|
|||
timeout_minutes: 30
|
||||
max_attempts: 3
|
||||
shell: pwsh
|
||||
command: ./gradlew :desktop:packageReleaseMsi -Pchannel=github -Pformat=msi
|
||||
command: ./gradlew :desktop:packageReleaseMsi
|
||||
|
||||
- name: Rename MSI Artifact
|
||||
shell: cmd
|
||||
|
|
@ -104,7 +103,7 @@ jobs:
|
|||
timeout_minutes: 30
|
||||
max_attempts: 3
|
||||
shell: pwsh
|
||||
command: ./gradlew :desktop:packageReleaseExe -Pchannel=github -Pformat=exe
|
||||
command: ./gradlew :desktop:packageReleaseExe
|
||||
|
||||
- name: Rename EXE Artifact
|
||||
shell: cmd
|
||||
|
|
@ -130,17 +129,6 @@ jobs:
|
|||
|
||||
echo Successfully renamed file to "hammer.exe"
|
||||
|
||||
- name: Write update digests
|
||||
shell: pwsh
|
||||
run: ./gradlew writeAssetDigests "-PassetFiles=desktop/build/installers/main-release/msi/hammer.msi,desktop/build/installers/main-release/exe/hammer.exe"
|
||||
|
||||
- name: Upload update digests
|
||||
uses: actions/upload-artifact@v7
|
||||
with:
|
||||
name: update-digests-windows
|
||||
path: desktop/build/installers/main-release/*/hammer.*.sha512
|
||||
if-no-files-found: error
|
||||
|
||||
- name: Create Release
|
||||
uses: ncipollo/release-action@339a81892b84b4eeb0f6e744e4574d79d0d9b8dd # v1.21.0
|
||||
env:
|
||||
|
|
@ -161,11 +149,10 @@ jobs:
|
|||
- name: Checkout code
|
||||
uses: actions/checkout@v7
|
||||
|
||||
- name: Set up JDK
|
||||
uses: actions/setup-java@v5
|
||||
- name: Set up JBR
|
||||
uses: ./.github/actions/setup-jbr
|
||||
with:
|
||||
distribution: 'temurin'
|
||||
java-version: '21'
|
||||
token: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Gradle caching
|
||||
uses: actions/cache@v6
|
||||
|
|
@ -203,7 +190,7 @@ jobs:
|
|||
with:
|
||||
timeout_minutes: 30
|
||||
max_attempts: 3
|
||||
command: ./gradlew :desktop:packageReleaseDeb -Pchannel=github -Pformat=deb
|
||||
command: ./gradlew :desktop:packageReleaseDeb
|
||||
|
||||
- name: Rename DEB Artifact
|
||||
run: |
|
||||
|
|
@ -227,7 +214,7 @@ jobs:
|
|||
with:
|
||||
timeout_minutes: 30
|
||||
max_attempts: 3
|
||||
command: ./gradlew :desktop:packageReleaseRpm -Pchannel=github -Pformat=rpm
|
||||
command: ./gradlew :desktop:packageReleaseRpm
|
||||
|
||||
- name: Rename RPM Artifact
|
||||
run: |
|
||||
|
|
@ -251,17 +238,7 @@ jobs:
|
|||
with:
|
||||
timeout_minutes: 30
|
||||
max_attempts: 3
|
||||
command: ./gradlew buildDistAppImage -Pchannel=appimage -Pformat=appimage
|
||||
|
||||
- name: Write update digests
|
||||
run: ./gradlew writeAssetDigests "-PassetFiles=desktop/build/installers/main-release/deb/hammer.deb,desktop/build/installers/main-release/rpm/hammer.rpm,desktop/build/installers/main-release/appimage/hammer.AppImage"
|
||||
|
||||
- name: Upload update digests
|
||||
uses: actions/upload-artifact@v7
|
||||
with:
|
||||
name: update-digests-linux
|
||||
path: desktop/build/installers/main-release/*/hammer.*.sha512
|
||||
if-no-files-found: error
|
||||
command: ./gradlew buildDistAppImage
|
||||
|
||||
- name: Create Release
|
||||
uses: ncipollo/release-action@339a81892b84b4eeb0f6e744e4574d79d0d9b8dd # v1.21.0
|
||||
|
|
@ -283,11 +260,10 @@ jobs:
|
|||
- name: Checkout code
|
||||
uses: actions/checkout@v7
|
||||
|
||||
- name: Set up JDK
|
||||
uses: actions/setup-java@v5
|
||||
- name: Set up JBR
|
||||
uses: ./.github/actions/setup-jbr
|
||||
with:
|
||||
distribution: 'temurin'
|
||||
java-version: '21'
|
||||
token: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Gradle caching
|
||||
uses: actions/cache@v6
|
||||
|
|
@ -304,7 +280,7 @@ jobs:
|
|||
with:
|
||||
timeout_minutes: 30
|
||||
max_attempts: 3
|
||||
command: ./gradlew :desktop:packageReleaseDmg -Pchannel=github -Pformat=dmg
|
||||
command: ./gradlew :desktop:packageReleaseDmg
|
||||
|
||||
- name: Rename DMG Artifact
|
||||
run: |
|
||||
|
|
@ -328,7 +304,7 @@ jobs:
|
|||
with:
|
||||
timeout_minutes: 30
|
||||
max_attempts: 3
|
||||
command: ./gradlew :desktop:packageReleasePkg -Pchannel=github -Pformat=pkg
|
||||
command: ./gradlew :desktop:packageReleasePkg
|
||||
|
||||
- name: Rename PKG Artifact
|
||||
run: |
|
||||
|
|
@ -349,16 +325,6 @@ jobs:
|
|||
echo "Contents of pkg directory:"
|
||||
ls -la
|
||||
|
||||
- name: Write update digests
|
||||
run: ./gradlew writeAssetDigests "-PassetFiles=desktop/build/installers/main-release/dmg/hammer.dmg,desktop/build/installers/main-release/pkg/hammer.pkg"
|
||||
|
||||
- name: Upload update digests
|
||||
uses: actions/upload-artifact@v7
|
||||
with:
|
||||
name: update-digests-macos
|
||||
path: desktop/build/installers/main-release/*/hammer.*.sha512
|
||||
if-no-files-found: error
|
||||
|
||||
- name: Create Release
|
||||
uses: ncipollo/release-action@339a81892b84b4eeb0f6e744e4574d79d0d9b8dd # v1.21.0
|
||||
env:
|
||||
|
|
@ -382,8 +348,8 @@ jobs:
|
|||
- name: Set up JDK
|
||||
uses: actions/setup-java@v5
|
||||
with:
|
||||
distribution: 'temurin'
|
||||
java-version: '21'
|
||||
distribution: 'adopt'
|
||||
java-version: 21
|
||||
|
||||
- name: Gradle caching
|
||||
uses: actions/cache@v6
|
||||
|
|
@ -444,8 +410,8 @@ jobs:
|
|||
- name: Set up JDK
|
||||
uses: actions/setup-java@v5
|
||||
with:
|
||||
distribution: 'temurin'
|
||||
java-version: '21'
|
||||
distribution: 'adopt'
|
||||
java-version: 21
|
||||
|
||||
- name: Gradle caching
|
||||
uses: actions/cache@v6
|
||||
|
|
@ -496,9 +462,6 @@ jobs:
|
|||
|
||||
echo "File renamed successfully."
|
||||
|
||||
- name: Rename R8 Mapping
|
||||
run: cp "$GITHUB_WORKSPACE/android/build/outputs/mapping/release/mapping.txt" "$GITHUB_WORKSPACE/android/build/outputs/apk/release/hammer-apk-mapping.txt"
|
||||
|
||||
- name: Create Release
|
||||
uses: ncipollo/release-action@339a81892b84b4eeb0f6e744e4574d79d0d9b8dd # v1.21.0
|
||||
env:
|
||||
|
|
@ -508,7 +471,7 @@ jobs:
|
|||
prerelease: true
|
||||
artifactErrorsFailBuild: true
|
||||
allowUpdates: true
|
||||
artifacts: "${{ github.workspace }}/android/build/outputs/apk/release/hammer.apk,${{ github.workspace }}/android/build/outputs/apk/release/hammer-apk-mapping.txt"
|
||||
artifacts: "${{ github.workspace }}/android/build/outputs/apk/release/hammer.apk"
|
||||
|
||||
snap:
|
||||
needs: tests
|
||||
|
|
@ -519,11 +482,10 @@ jobs:
|
|||
- name: Checkout code
|
||||
uses: actions/checkout@v7
|
||||
|
||||
- name: Set up JDK
|
||||
uses: actions/setup-java@v5
|
||||
- name: Set up JBR
|
||||
uses: ./.github/actions/setup-jbr
|
||||
with:
|
||||
distribution: 'temurin'
|
||||
java-version: '21'
|
||||
token: ${{ secrets.GITHUB_TOKEN }}
|
||||
cache: gradle
|
||||
|
||||
- name: Grant Permission to Execute
|
||||
|
|
@ -558,11 +520,10 @@ jobs:
|
|||
- name: Checkout code
|
||||
uses: actions/checkout@v7
|
||||
|
||||
- name: Set up JDK
|
||||
uses: actions/setup-java@v5
|
||||
- name: Set up JBR
|
||||
uses: ./.github/actions/setup-jbr
|
||||
with:
|
||||
distribution: 'temurin'
|
||||
java-version: '21'
|
||||
token: ${{ secrets.GITHUB_TOKEN }}
|
||||
cache: gradle
|
||||
|
||||
- name: Install Flatpak and flatpak-builder
|
||||
|
|
@ -598,7 +559,7 @@ jobs:
|
|||
with:
|
||||
timeout_minutes: 30
|
||||
max_attempts: 3
|
||||
command: ./gradlew buildDistFlatpak -Pchannel=flathub
|
||||
command: ./gradlew buildDistFlatpak
|
||||
|
||||
- name: Create Release
|
||||
uses: ncipollo/release-action@339a81892b84b4eeb0f6e744e4574d79d0d9b8dd # v1.21.0
|
||||
|
|
@ -625,61 +586,10 @@ jobs:
|
|||
- name: Fetch tags
|
||||
run: git fetch --depth=1 origin +refs/tags/*:refs/tags/*
|
||||
|
||||
- name: Set up JDK
|
||||
uses: actions/setup-java@v5
|
||||
with:
|
||||
distribution: 'temurin'
|
||||
java-version: '21'
|
||||
|
||||
- name: Gradle caching
|
||||
uses: actions/cache@v6
|
||||
with:
|
||||
path: |
|
||||
~/.gradle/caches
|
||||
~/.gradle/wrapper
|
||||
key: ${{ runner.os }}-gradle-${{ hashFiles('**/*.gradle*', '**/gradle-wrapper.properties') }}
|
||||
restore-keys: |
|
||||
${{ runner.os }}-gradle-
|
||||
|
||||
# The desktop updater reads latest*.yml from the release. Each platform job hashed
|
||||
# the installer it attached and handed the digest over as a workflow artifact, so
|
||||
# nothing is downloaded back and no release lookup is needed. Attached before the
|
||||
# draft flips, so a published release never exists without them.
|
||||
- name: Collect update digests
|
||||
uses: actions/download-artifact@v7
|
||||
with:
|
||||
pattern: update-digests-*
|
||||
merge-multiple: true
|
||||
path: ${{ runner.temp }}/update-digests
|
||||
|
||||
- name: Write update metadata
|
||||
run: ./gradlew writeUpdateMetadata "-PreleaseDigestsDir=${{ runner.temp }}/update-digests" --no-daemon
|
||||
|
||||
- name: Attach update metadata
|
||||
uses: ncipollo/release-action@339a81892b84b4eeb0f6e744e4574d79d0d9b8dd # v1.21.0
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
with:
|
||||
draft: true
|
||||
prerelease: true
|
||||
artifactErrorsFailBuild: true
|
||||
allowUpdates: true
|
||||
artifacts: "${{ github.workspace }}/build/update-metadata/latest.yml,${{ github.workspace }}/build/update-metadata/latest-mac.yml,${{ github.workspace }}/build/update-metadata/latest-linux.yml"
|
||||
|
||||
- name: Get last tag message
|
||||
run: |
|
||||
TAG_NAME=${GITHUB_REF#refs/tags/}
|
||||
TAG_MESSAGE=$(git tag -n90 --format='%(contents)' "$TAG_NAME" )
|
||||
|
||||
# prepareForRelease appends this trailer when the release dialog's
|
||||
# auto-publish box is checked. Strip it so it stays out of the body.
|
||||
AUTO_PUBLISH=false
|
||||
if printf '%s\n' "$TAG_MESSAGE" | grep -qxF 'Auto-Publish: true'; then
|
||||
AUTO_PUBLISH=true
|
||||
fi
|
||||
TAG_MESSAGE=$(printf '%s\n' "$TAG_MESSAGE" | grep -vxF 'Auto-Publish: true' || true)
|
||||
echo "auto_publish=$AUTO_PUBLISH" >> $GITHUB_ENV
|
||||
echo "Auto-publish: $AUTO_PUBLISH"
|
||||
|
||||
echo "Tag: $TAG_NAME"
|
||||
echo -e "Tag message:\n$TAG_MESSAGE"
|
||||
|
|
@ -697,19 +607,3 @@ jobs:
|
|||
prerelease: true
|
||||
allowUpdates: true
|
||||
body: ${{ env.tag_message }}
|
||||
|
||||
# Flipping the draft fires `release: published`, which starts
|
||||
# publish-release.yml. The token must not be GITHUB_TOKEN: GitHub suppresses
|
||||
# workflow runs triggered by it, so no store would ever publish.
|
||||
- name: Publish release
|
||||
if: env.auto_publish == 'true'
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.RELEASE_PAT }}
|
||||
REPO: ${{ github.repository }}
|
||||
TAG_NAME: ${{ github.ref_name }}
|
||||
run: |
|
||||
if [ -z "$GH_TOKEN" ]; then
|
||||
echo "::error::Auto-publish was requested but the RELEASE_PAT secret is not set."
|
||||
exit 1
|
||||
fi
|
||||
gh release edit "$TAG_NAME" --repo "$REPO" --draft=false --prerelease=false --latest
|
||||
|
|
|
|||
16
.github/workflows/publish-google-play.yml
vendored
16
.github/workflows/publish-google-play.yml
vendored
|
|
@ -36,9 +36,9 @@ jobs:
|
|||
distribution: 'temurin'
|
||||
cache: gradle
|
||||
|
||||
- uses: ruby/setup-ruby@d45b1a4e94b71acab930e56e79c6aa188764e7f9 # v1.316.0
|
||||
- uses: ruby/setup-ruby@6aaa311d81eba98ae12eaffbcb63296ace0efcde # v1.307.0
|
||||
with:
|
||||
ruby-version: '3.4.10'
|
||||
ruby-version: '3.4.8'
|
||||
bundler-cache: true
|
||||
|
||||
- name: Cache Ruby - Bundler
|
||||
|
|
@ -89,15 +89,3 @@ jobs:
|
|||
esac
|
||||
echo "Running fastlane $LANE for track=$TRACK"
|
||||
bundle exec fastlane "$LANE"
|
||||
|
||||
# Keeps the mappings for the exact Play builds next to the release, so prod
|
||||
# crash traces can be retraced without the Play Console.
|
||||
- name: Attach R8 mappings to the GitHub release
|
||||
if: github.event_name == 'release'
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
TAG: ${{ github.event.release.tag_name }}
|
||||
run: |
|
||||
cp android/build/outputs/mapping/release/mapping.txt hammer-google-play-mapping.txt
|
||||
cp wear/build/outputs/mapping/release/mapping.txt hammer-wear-mapping.txt
|
||||
gh release upload "$TAG" hammer-google-play-mapping.txt hammer-wear-mapping.txt --clobber
|
||||
|
|
|
|||
4
.github/workflows/publish-internal.yml
vendored
4
.github/workflows/publish-internal.yml
vendored
|
|
@ -21,9 +21,9 @@ jobs:
|
|||
distribution: 'temurin'
|
||||
cache: gradle
|
||||
|
||||
- uses: ruby/setup-ruby@d45b1a4e94b71acab930e56e79c6aa188764e7f9 # v1.316.0
|
||||
- uses: ruby/setup-ruby@6aaa311d81eba98ae12eaffbcb63296ace0efcde # v1.307.0
|
||||
with:
|
||||
ruby-version: '3.4.10'
|
||||
ruby-version: '3.4.8'
|
||||
bundler-cache: true
|
||||
|
||||
- name: Cache Ruby - Bundler
|
||||
|
|
|
|||
16
.github/workflows/publish-ios-app-store.yml
vendored
16
.github/workflows/publish-ios-app-store.yml
vendored
|
|
@ -15,18 +15,6 @@ on:
|
|||
required: false
|
||||
workflow_call:
|
||||
|
||||
# Serialized against the Mac App Store publish: since iOS moved onto the macOS
|
||||
# app record for Universal Purchase, both publishes target the SAME App Store
|
||||
# Connect record, and deliver edits record-level App Information (categories,
|
||||
# copyright) as well as the per-platform version. A full release tag — one with
|
||||
# no `+platform` suffix — starts both in parallel, so they must queue instead.
|
||||
# The group name is deliberately constant, not per-tag: two overlapping releases
|
||||
# would collide the same way. cancel-in-progress stays false; aborting a publish
|
||||
# part-way through a review submission is far worse than waiting for a slot.
|
||||
concurrency:
|
||||
group: app-store-connect
|
||||
cancel-in-progress: false
|
||||
|
||||
jobs:
|
||||
publish-ios-app-store:
|
||||
runs-on: macos-latest
|
||||
|
|
@ -50,9 +38,9 @@ jobs:
|
|||
sudo xcode-select -s "$XCODE_APP/Contents/Developer"
|
||||
xcodebuild -version
|
||||
|
||||
- uses: ruby/setup-ruby@d45b1a4e94b71acab930e56e79c6aa188764e7f9 # v1.316.0
|
||||
- uses: ruby/setup-ruby@6aaa311d81eba98ae12eaffbcb63296ace0efcde # v1.307.0
|
||||
with:
|
||||
ruby-version: '3.4.10'
|
||||
ruby-version: '3.4.8'
|
||||
bundler-cache: true
|
||||
|
||||
- name: Install bundle
|
||||
|
|
|
|||
16
.github/workflows/publish-mac-app-store.yml
vendored
16
.github/workflows/publish-mac-app-store.yml
vendored
|
|
@ -15,18 +15,6 @@ on:
|
|||
required: false
|
||||
workflow_call:
|
||||
|
||||
# Serialized against the Mac App Store publish: since iOS moved onto the macOS
|
||||
# app record for Universal Purchase, both publishes target the SAME App Store
|
||||
# Connect record, and deliver edits record-level App Information (categories,
|
||||
# copyright) as well as the per-platform version. A full release tag — one with
|
||||
# no `+platform` suffix — starts both in parallel, so they must queue instead.
|
||||
# The group name is deliberately constant, not per-tag: two overlapping releases
|
||||
# would collide the same way. cancel-in-progress stays false; aborting a publish
|
||||
# part-way through a review submission is far worse than waiting for a slot.
|
||||
concurrency:
|
||||
group: app-store-connect
|
||||
cancel-in-progress: false
|
||||
|
||||
jobs:
|
||||
publish-mac-app-store:
|
||||
runs-on: macos-latest
|
||||
|
|
@ -46,9 +34,9 @@ jobs:
|
|||
distribution: 'temurin'
|
||||
cache: gradle
|
||||
|
||||
- uses: ruby/setup-ruby@d45b1a4e94b71acab930e56e79c6aa188764e7f9 # v1.316.0
|
||||
- uses: ruby/setup-ruby@6aaa311d81eba98ae12eaffbcb63296ace0efcde # v1.307.0
|
||||
with:
|
||||
ruby-version: '3.4.10'
|
||||
ruby-version: '3.4.8'
|
||||
bundler-cache: true
|
||||
|
||||
- name: Grant Permission to Execute
|
||||
|
|
|
|||
|
|
@ -27,7 +27,7 @@ jobs:
|
|||
cache: gradle
|
||||
|
||||
- name: Build MSIX package
|
||||
run: ./gradlew :desktop:packageMsix -Pchannel=ms-store -Pformat=msix
|
||||
run: ./gradlew :desktop:packageMsix
|
||||
|
||||
- name: Upload Gradle problems report on failure
|
||||
if: failure()
|
||||
|
|
|
|||
2
.github/workflows/publish-release.yml
vendored
2
.github/workflows/publish-release.yml
vendored
|
|
@ -11,8 +11,6 @@ jobs:
|
|||
publish-google-play:
|
||||
if: ${{ !contains(github.event.release.tag_name, '+') || contains(github.event.release.tag_name, '+google-play') }}
|
||||
uses: ./.github/workflows/publish-google-play.yml
|
||||
permissions:
|
||||
contents: write
|
||||
secrets: inherit
|
||||
with:
|
||||
track: production
|
||||
|
|
|
|||
3
.gitignore
vendored
3
.gitignore
vendored
|
|
@ -93,6 +93,3 @@ fastlane/README.md
|
|||
# server/src/main/resources/assets/images/ are committed, their sources are not
|
||||
/scripts/build-home-screenshots.py
|
||||
/scripts/home-screenshot-sources/
|
||||
|
||||
# Local working area: marketing renders, one-off scripts, anything not shipped
|
||||
/scratch/
|
||||
|
|
|
|||
239
CHANGELOG.md
239
CHANGELOG.md
|
|
@ -1,244 +1,5 @@
|
|||
# Changelog
|
||||
|
||||
## [3.12.0] - 2026-10-5
|
||||
|
||||
New features
|
||||
- Reader Mode: read your story as prose, one chapter at a time, instead of through the scene editor. Open it from the Read button under the scene list, the book icon in the scene editor, or the editor menu. The scene list becomes a table of contents, and the reader has its own font size
|
||||
- Export: three new options in the Export Story dialog. Number chapter titles, pick a font (Word, RTF and EPUB), and choose whether to keep blank lines between paragraphs
|
||||
- Android and iOS: an OLED theme, the dark theme on a pure black background
|
||||
- Desktop (Windows and Linux installs from GitHub): Hammer tells you when a newer release is out and can update itself from the project list or the About screen. macOS DMG installs are told and sent to the release page. Automatic checks can be turned off in Settings
|
||||
- Text editor 3.0: Huge improvements in input and performance
|
||||
- Text editor: change the text size with Ctrl/Cmd and Plus, Minus or 0, or from the format bar's overflow menu
|
||||
- Text editor: Delete in the right-click menu
|
||||
- Turkish translation
|
||||
- Web: a fifth reader reaction on public stories, "Surprised me"
|
||||
|
||||
Improvements
|
||||
- Syncing with unsaved work now asks Save, Discard or Cancel first, where before it silently saved every unsaved scene
|
||||
- Open notes, timeline events, encyclopedia entries and the scene title, outline and notes refresh when a sync or conflict resolution changes them. An edit in progress keeps its draft
|
||||
- The Backup Manager lists backups of projects that are no longer on disk, so a deleted project can be restored
|
||||
- Exports no longer number chapter titles unless you turn the new option on. Word, RTF, PDF and Markdown used to number them always
|
||||
- Text editor: the heading button writes real Markdown headings, and Hammer's shortcuts land on the same keys on non-QWERTY layouts
|
||||
- Android: a launch splash screen on every supported version
|
||||
- Translations and dependencies updated
|
||||
|
||||
Fixes
|
||||
- Android: unsaved scene edits could be lost when the app was closed in the background. They now come back as unsaved scenes the next time the project opens [#983]
|
||||
- Export: unsaved scene edits were left out of the exported file [#983]
|
||||
- Restoring a backup from the Backup Manager made the project disappear from the project list
|
||||
- Choosing Discard on close with automatic sync on still saved and uploaded the discarded scenes
|
||||
- PDF export: long paragraphs ran off the bottom of the page or left large blank gaps
|
||||
- Android: a crash on launch when the saved login could not be decrypted, for example after restoring the device from a backup. You are asked to sign in again
|
||||
- iOS: a crash when tapping into a text editor
|
||||
- Server URL, number, tag and word-list fields no longer autocorrect or capitalize, which put a space after each "." in a server URL
|
||||
- Encyclopedia references no longer match a name inside a longer word with accented or non-Latin letters ("Ana" in "Anaïs")
|
||||
|
||||
|
||||
## [3.11.0] - 2026-9-24
|
||||
|
||||
New features
|
||||
- Web: signed-in readers can leave kudos at the end of a publicly published story, picking up to four craft chips and one reaction. Kudos are anonymous: authors see per-chip totals on their story page, and a chip appears on the public page once three readers have picked it. Authors can turn kudos off for any story
|
||||
|
||||
Improvements
|
||||
- Word counts no longer count standalone Markdown markers such as horizontal rules, heading marks, bullets and blockquote markers as words. The server now counts the same way, so both give the same number
|
||||
- Android and Wear OS builds are now shrunk and obfuscated with R8
|
||||
- Translations and dependencies updated
|
||||
|
||||
Fixes
|
||||
- Text editor: numpad navigation keys and numpad Enter now work
|
||||
- Text editor: keyboard (IME) edits are applied right away, and the keyboard is told about each one only once
|
||||
- Text editor: spelling underlines stay on the right words while you keep typing
|
||||
- Text editor: the formatting you picked for new text is kept after unrelated updates
|
||||
- Text editor: fixed crashes and misplaced carets when drawing the cursor or moving it during a layout change
|
||||
- Wear OS: pairing with the phone app works in release builds
|
||||
|
||||
|
||||
## [3.10.1] - 2026-9-23
|
||||
|
||||
- Fix Android Wear release
|
||||
|
||||
|
||||
## [3.10.0] - 2026-9-20
|
||||
|
||||
New features
|
||||
- A Wear OS watch app: dictate notes into your projects and capture story ideas from your wrist, then sync them on their own. Pair it from your phone, or sign in on the watch directly
|
||||
- Add words to a project's own spelling dictionary, either from "Add to dictionary" on a flagged word in the editor or from the word list in Project Settings under Spell checking. The list syncs with the project
|
||||
- Encyclopedia entry names and draft names accept the same characters project and scene names already did: punctuation like . , ! ? : ( ) & - " and non-Latin letters
|
||||
- Copy Diagnostics on the About screen puts your version, system details, recent log, and newest crash report on the clipboard, so a bug report can carry its own diagnostics
|
||||
- Desktop: Export Logs saves a zip wherever you choose, which works even where opening the log folder does not
|
||||
|
||||
Improvements
|
||||
- A failed login says what actually went wrong instead of answering with a bare 401, and a self-hosted server logs which failure occurred
|
||||
- Account creation reports policy problems, real conflicts, and Allowed Users rejections distinctly
|
||||
- Builds record which store or channel they came from, which shows up in diagnostics
|
||||
- Translations updated for German, Spanish, French, Italian, Portuguese (Brazil), and Ukrainian
|
||||
- Web: the home page offers the iOS app, which now shares one App Store listing with macOS
|
||||
|
||||
Fixes
|
||||
- Desktop (Microsoft Store): the app crashed on its first network call and could not sync at all
|
||||
- Client: email and password fields no longer capitalize the first character as you type, which could create an account with a password you could not reproduce elsewhere
|
||||
- Client: a network failure occurring before any request was sent showed a raw Java error instead of a real message
|
||||
- Text editor: the spelling suggestions menu could stick on "Loading" when the editor re-anchored it
|
||||
- Sync: clients on different versions no longer fail an entire sync over a field one side does not recognize
|
||||
- Sync: a failed sync no longer ends by logging "Sync complete!"
|
||||
- Web: previous and next page links on the community feed and authors pages
|
||||
|
||||
|
||||
## [3.9.8] - 2026-9-12
|
||||
|
||||
iOS Release fix
|
||||
|
||||
|
||||
## [3.9.7] - 2026-9-4
|
||||
|
||||
iOS Release fix
|
||||
|
||||
|
||||
## [3.9.6] - 2026-9-3
|
||||
|
||||
Release fix
|
||||
|
||||
|
||||
## [3.9.5] - 2026-9-1
|
||||
|
||||
Release fix
|
||||
|
||||
|
||||
## [3.9.4] - 2026-8-22
|
||||
|
||||
Fix widgets not doing anything
|
||||
|
||||
|
||||
## [3.9.3] - 2026-8-22
|
||||
|
||||
Bug fixes
|
||||
|
||||
|
||||
## [3.9.2] - 2026-8-21
|
||||
|
||||
- Fix MacOS Crash
|
||||
- Fix Android widgets not working
|
||||
|
||||
|
||||
## [3.9.1] - 2026-8-18
|
||||
|
||||
- Fix sharing scene order
|
||||
- Fix PDF export crash
|
||||
- Fix MacOS crash on start
|
||||
- Added configurable timezone for server admins
|
||||
|
||||
|
||||
## [3.9.0] - 2026-8-16
|
||||
|
||||
New features
|
||||
- Export only the scenes you choose, in every export format
|
||||
- Private web shares can be limited to a selection of scenes
|
||||
- Encyclopedia entry names and aliases feed the spell check dictionary, so your invented names stop being flagged as misspelled. Controllable globally, per project, and per entry
|
||||
- Scenes now connect to every kind of Encyclopedia entry, not just People and Places
|
||||
- Drag the editor's edge to set how wide it gets on large screens
|
||||
- Server: search the Allowed Users list by email
|
||||
|
||||
Improvements
|
||||
- Logging in to a new server asks whether to merge or replace, and only asks when local work is actually at stake
|
||||
- Published stories and every export now lay prose out the way the editor shows it, line for line
|
||||
- A scene opens for typing immediately instead of waiting for spell check to finish
|
||||
- Scene tree nesting is easier to read
|
||||
- Save all moved from Ctrl+Alt+S to Ctrl+Shift+S, so AltGr layouts keep their characters
|
||||
- Desktop (Linux): runs natively on Wayland instead of being forced through XWayland
|
||||
- Web: the editorial review dialog's scene list uses the full height of the window
|
||||
- Italian translation added, and translations updated for German, Spanish, French, Italian, Portuguese (Brazil), and Ukrainian
|
||||
- Server: Argon2 password hashing no longer loads a native library, which fixes signup and login on hosts that mount the data volume noexec
|
||||
|
||||
Fixes
|
||||
- Client: scenes in a folder holding exactly ten items could go blank and export empty
|
||||
- Client: sync could fail when a folder crossed a ten-item boundary
|
||||
- Client: unsaved edits survive a device rotation on Android
|
||||
- Client: AltGr chords no longer close the app on Turkish, German, and Polish layouts
|
||||
- Client: crash when closing a project with the scene metadata panel open
|
||||
- Sync: renaming a project onto the name of one you had deleted
|
||||
- Sync: project settings could be wiped when a project moved to a different server
|
||||
- Sync: a local project sharing a name with a server project could leave a duplicate behind
|
||||
- Server: sync could hang indefinitely on hosts with a shallow entropy pool
|
||||
- Server: concurrent sync sessions could be handed colliding sync ids
|
||||
- Server: API errors returned a 500 instead of falling back to English when a translation was missing
|
||||
- Web: Allowed Users paging could repeat and skip entries
|
||||
|
||||
|
||||
## [3.8.2] - 2026-8-11
|
||||
|
||||
New features
|
||||
- Web: public sign-up page, so an allowed user can create an account without installing the app
|
||||
- Server: the Allowed Users list is now always enforced; the old whitelist toggle is gone
|
||||
|
||||
Improvements
|
||||
- Server: web sign-ins are now recorded in the security audit trail, alongside app sign-ins
|
||||
- Server: admin instance messages render as Markdown, so links, emphasis, and lists work in them
|
||||
- About: Dark Rock Studios attribution moved into its own "Studio" section with a full-screen Cairn overlay
|
||||
- Desktop: dropped the JetBrains Runtime requirement; builds run on stock Temurin OpenJDK now
|
||||
- Translations updated for German, Spanish, French, Italian, Portuguese (Brazil), and Ukrainian
|
||||
|
||||
Fixes
|
||||
- Desktop (macOS): fixed a crash on launch
|
||||
- Desktop (Linux/Snap): fixed a crash on launch
|
||||
|
||||
|
||||
## [3.8.1] - 2026-8-8
|
||||
|
||||
Improvements
|
||||
- Desktop window decoration migrated to the Nucleus TAO backend
|
||||
- Server: trustProxyForwarding option reads real client IPs from X-Forwarded-* headers when running behind a reverse proxy, so the login rate limiter, login audit trail, and story reader counts reflect actual clients instead of the proxy
|
||||
- Translations updated for German, Spanish, French, Italian, Portuguese (Brazil), and Ukrainian
|
||||
- Rendered stories now use real book typography: indented lists, a centered scene-break ornament, tinted blockquotes, and deliberate blank-line spacing between paragraphs
|
||||
|
||||
Fixes
|
||||
- Client: text editor no longer drops block structure (horizontal rules, images, code fences) when a scene loads
|
||||
- Client: font size changes no longer flatten list and blockquote indents
|
||||
- Client: typed text after a size change could lose the paragraph's body style
|
||||
- Client: F3 and Ctrl+Alt+S sync shortcuts work from anywhere in the project window
|
||||
- Client: tag suggestion chips could add the typed prefix instead of the selected tag
|
||||
- Sync: projects with a stale cached server id could get stuck failing "410 Gone" instead of recreating
|
||||
- Web: ~~strikethrough~~ rendered as literal tildes instead of struck-through text
|
||||
|
||||
|
||||
## [3.8.0] - 2026-8-4
|
||||
|
||||
https://hammer.ink/blog/news/hammer-3-8-0
|
||||
|
||||
### New features
|
||||
|
||||
- Set the language a project is written in, and have spell check follow it
|
||||
- Self-service account deletion from the web dashboard, with an admin restore window
|
||||
- Import and export documents as HTML, alongside Markdown
|
||||
- "What's New" release notes shown in-app with no network request, and reopenable from About
|
||||
- Import preview warns when a scene comes in unusually large, so a failed chapter split doesn't quietly collapse a manuscript into one scene
|
||||
|
||||
### Improvements
|
||||
|
||||
- Text editor: faster typing and relayout, word-wise undo with a much deeper history, and rich copy and paste that preserves formatting between apps
|
||||
- Text editor: platform-correct key bindings on macOS, and AltGr chords type their character instead of firing shortcuts
|
||||
- Large Markdown imports are dramatically faster: 1600 scenes went from 37 seconds to about 3
|
||||
- Search matches the rendered prose, resolving Markdown escapes and formatting
|
||||
- Tag search matches accented and non-latin characters, and searches entry names as well as bodies
|
||||
- Scene drag and drop stays accurate while the list autoscrolls
|
||||
- Chapter detection on Markdown import understands Setext-style and bold titles
|
||||
- Visible scrollbars on scrolling screens, and scroll-away footers float over their lists
|
||||
- Translations updated for German, Spanish, French, Italian, Portuguese (Brazil), and Ukrainian
|
||||
- Server: the setup page can create the initial admin account
|
||||
- Server: a self-hosted server not serving HTTPS now reports a clear error instead of "Network error"
|
||||
- Server: smaller Docker image
|
||||
- Web: error responses show a toast instead of failing silently
|
||||
|
||||
### Fixes
|
||||
|
||||
- Client: importing a very large story no longer freezes the app
|
||||
- Client: scene drag could grab the wrong row
|
||||
- Client: literal `**` and `_` left around emphasis imported from RTF
|
||||
- Client: sync server URLs are normalized consistently when saved
|
||||
- Client: a number of text editor crashes around undo, paste, and joined paragraphs
|
||||
- Web: htmx requests denied access landed on a blank page instead of being redirected
|
||||
- Web: broken download link on the home page
|
||||
|
||||
|
||||
## [3.7.2] - 2026-7-27
|
||||
|
||||
[Improve]
|
||||
|
|
|
|||
|
|
@ -146,58 +146,8 @@ flowchart TD
|
|||
|
||||
## Build Variants
|
||||
|
||||
### The distribution channel
|
||||
|
||||
Every build is stamped with the distribution vehicle it was produced for, via
|
||||
`-Pchannel=<token>`:
|
||||
|
||||
```
|
||||
./gradlew :desktop:packageMsix -Pchannel=ms-store
|
||||
```
|
||||
|
||||
Omitting it produces a `dev` build. An unknown token fails the build rather than
|
||||
silently falling back, so a typo in a release workflow cannot ship a store binary as
|
||||
`dev`. Each release pipeline passes its own channel; `prepare-release.yml` uses `github`
|
||||
for the installers it attaches to the GitHub release.
|
||||
|
||||
The token list lives in two places that must stay in step, pinned by a test on each side:
|
||||
|
||||
| Location | Role |
|
||||
| --- | --- |
|
||||
| `buildSrc/src/main/java/distributionChannel.kt` | Build-side enum and the `-Pchannel` resolver. |
|
||||
| `base/src/commonMain/.../DistributionChannel.kt` | Runtime enum. `DistributionChannel.current` reads the baked-in `BuildMetadata.CHANNEL`. |
|
||||
|
||||
Branch on `DistributionChannel.current` for behaviour that varies per vehicle: self-update
|
||||
(forbidden in the app stores, expected for the GitHub and AppImage builds), where "get the
|
||||
app" links point, external-payment policy, and sandbox-aware path resolution. It is a flat
|
||||
enum on purpose: every channel-conditional branch is a greppable `when` on the type.
|
||||
Capability flags (`canSelfUpdate`, `isSandboxed`, …) can be introduced later, from real
|
||||
duplication rather than a guess at the axes.
|
||||
|
||||
The build side reads the same token through `Project.distributionChannel()` in buildSrc:
|
||||
`desktop/build.gradle.kts` derives its Mac App Store packaging mode (`isAppStoreRelease`:
|
||||
sandbox entitlements, provisioning profiles, signing) from `-Pchannel=mac-app-store`. Together
|
||||
with `-PbuildNumber=N`, that is everything `build-appstore.sh` passes.
|
||||
|
||||
Packaging runs carry a second stamp, `-Pformat=<token>` (`msi`, `exe`, `deb`, `rpm`, `dmg`,
|
||||
`pkg`, `appimage`, `msix`, `snap`, `flatpak`), read the same way into
|
||||
`BuildMetadata.PACKAGE_FORMAT` and `PackageFormat.current`. The channel says which vehicle a
|
||||
build is for; the format says which installer it came out of, which is what the desktop
|
||||
updater needs to pick its download. Omitting it gives `none`, an unknown token fails the
|
||||
build, and each release step passes its own. Two packaging tasks in one Gradle invocation
|
||||
would both carry the first task's format, so the release workflow runs one per step.
|
||||
|
||||
Note that every branch compiles into every build, so a store-forbidden code path is
|
||||
physically present in a store binary even when unreachable. If a store ever objects to
|
||||
presence rather than behaviour, promote that one case to a real source set rather than
|
||||
restructuring everything.
|
||||
|
||||
### The F-Droid build flag
|
||||
|
||||
Predates the channel constant and is still the switch for the two things that must happen
|
||||
before any Kotlin runs (see the table below). A build with the F-Droid flag set and no
|
||||
`-Pchannel` reports the `fdroid` channel.
|
||||
|
||||
F-Droid builds are produced by the same modules as the Google Play build, but with a
|
||||
single build flag toggled. There are no Gradle product flavors; instead the flag is read
|
||||
directly from a Gradle property (or an environment variable) wherever it's needed:
|
||||
|
|
@ -234,10 +184,9 @@ Access), which Google Play does not allow, so the feature is gated to F-Droid bu
|
|||
- `HammerApplication` forces internal storage on non-F-Droid builds, so a leftover
|
||||
preference can never point a Google Play build at a directory it has no permission for.
|
||||
|
||||
For behaviour that is specifically about F-Droid's manifest and storage permissions,
|
||||
branch on `com.darkrockstudios.apps.hammer.common.BuildConfig.FDROID` rather than
|
||||
re-reading the Gradle property. For anything else that varies per vehicle, use
|
||||
`DistributionChannel.current`.
|
||||
When adding new runtime behaviour that should differ between channels, branch on
|
||||
`com.darkrockstudios.apps.hammer.common.BuildConfig.FDROID` rather than re-reading the
|
||||
Gradle property.
|
||||
|
||||
## Client Development
|
||||
|
||||
|
|
|
|||
10
PRIVACY.md
10
PRIVACY.md
|
|
@ -1,6 +1,6 @@
|
|||
# Privacy Policy
|
||||
|
||||
_Last updated: 15 September 2026_
|
||||
_Last updated: 21 July 2026_
|
||||
|
||||
This is a plain-language explanation of how Hammer handles your data, from Dark Rock Studios, the
|
||||
makers of Hammer. The short version: Hammer is offline-first, your writing lives on your own
|
||||
|
|
@ -35,12 +35,8 @@ leaves your device unless you choose to send it.
|
|||
## 5. Security
|
||||
|
||||
For anything that does leave your device through a feature you turned on, we do our best to protect
|
||||
it. Hammer connects to a sync server over an encrypted HTTPS connection unless you deliberately
|
||||
enter an address beginning with `http://`, which some people need for a server on their own home
|
||||
network. A plain `http://` connection is not encrypted, so your account details and the writing you
|
||||
sync can be read by anyone who can see that network. The app warns you when you enter such an
|
||||
address. That said, no method of sending data over the internet is ever completely secure, and we
|
||||
won't pretend otherwise.
|
||||
it. That said, no method of sending data over the internet is ever completely secure, and we won't
|
||||
pretend otherwise.
|
||||
|
||||
## 6. Links to other places
|
||||
|
||||
|
|
|
|||
10
README.md
10
README.md
|
|
@ -7,7 +7,7 @@ A simple tool for building stories.
|
|||
|
||||
![badge-platform-android] ![badge-platform-ios] ![badge-platform-windows] ![badge-platform-linux] ![badge-platform-macos]
|
||||
|
||||
[](https://discord.gg/49Kj5mMj6d)
|
||||
[](https://discord.gg/GTmgjZcupk)
|
||||
[](https://crowdin.com/project/hammer-editor)
|
||||
|
||||
### Available on:
|
||||
|
|
@ -15,15 +15,13 @@ A simple tool for building stories.
|
|||
| Platform | App Store | Direct download |
|
||||
|-------------|--------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|-------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|
|
||||
| **Android** | [](https://play.google.com/store/apps/details?id=com.darkrockstudios.apps.hammer.android) [](https://f-droid.org/en/packages/com.darkrockstudios.apps.hammer.android/) | [](https://github.com/Darkrock-Studios/hammer-editor/releases/latest/download/hammer.apk) |
|
||||
| **iOS** | [](https://apps.apple.com/us/app/hammer-editor/id6770841038) | — |
|
||||
| **iOS** | [](https://apps.apple.com/us/app/hammer-editor/id6771650681) | — |
|
||||
| **Windows** | [](https://apps.microsoft.com/detail/9p84lm8qv5x6) | [](https://github.com/Darkrock-Studios/hammer-editor/releases/latest/download/hammer.msi) [](https://github.com/Darkrock-Studios/hammer-editor/releases/latest/download/hammer.exe) |
|
||||
| **macOS** | [](https://apps.apple.com/us/app/hammer-editor/id6770841038) | [](https://github.com/Darkrock-Studios/hammer-editor/releases/latest/download/hammer.dmg) |
|
||||
| **Linux** | [](https://snapcraft.io/hammer-editor) | [](https://github.com/Darkrock-Studios/hammer-editor/releases/latest/download/hammer.deb) [](https://github.com/Darkrock-Studios/hammer-editor/releases/latest/download/hammer.rpm) [](https://github.com/Darkrock-Studios/hammer-editor/releases/latest/download/hammer.AppImage) [](https://github.com/Darkrock-Studios/hammer-editor/releases/latest/download/hammer.flatpak) |
|
||||
|
||||
### Community
|
||||
|
||||
Join our [Discord](https://discord.gg/49Kj5mMj6d) and help us by reporting bugs, making feature
|
||||
requests, and discussing
|
||||
Join our [Discord](https://discord.gg/GTmgjZcupk) and help us by reporting bugs, making feature requests, and discussing
|
||||
the future of Hammer.
|
||||
|
||||
Or visit us on Reddit at [r/hammereditor](https://www.reddit.com/r/hammereditor/)
|
||||
|
|
@ -97,7 +95,7 @@ latest commit.~~
|
|||
💡 Have suggestions?
|
||||
📚 Want to help translate?
|
||||
🎮 Interested in our other apps?
|
||||
👉 Join our community of Open Source enthusiasts on [**Discord**](https://discord.gg/49Kj5mMj6d)!
|
||||
👉 Join our community of Open Source enthusiasts on [**Discord**](https://discord.gg/ju2RQa5x8W)!
|
||||
|
||||
# Development
|
||||
|
||||
|
|
|
|||
|
|
@ -5,7 +5,7 @@ Thanks for using Hammer! If you need help, have feedback, or want to request a f
|
|||
## Get in touch
|
||||
|
||||
- **Email:** [darkrockstudios@gmail.com](mailto:darkrockstudios@gmail.com)
|
||||
- **Chat with us on Discord:** [join discord](https://discord.gg/49Kj5mMj6d)
|
||||
- **Chat with us on Discord:** [discord.gg/GTmgjZcupk](https://discord.gg/GTmgjZcupk)
|
||||
- **Report a bug or request a feature:** [open an issue on GitHub](https://github.com/Darkrock-Studios/hammer-editor/issues)
|
||||
|
||||
We aim to reply to support emails within a few business days.
|
||||
|
|
@ -50,12 +50,7 @@ config directory).
|
|||
|
||||
**macOS:**
|
||||
|
||||
`~/Library/Preferences/hammer/0/logs/`
|
||||
|
||||
If you installed Hammer from the Mac App Store, it runs sandboxed and that
|
||||
path is relocated into the app's container:
|
||||
|
||||
`~/Library/Containers/com.darkrockstudios.apps.hammer/Data/Library/Preferences/hammer/0/logs/`
|
||||
`~/Library/Application Support/hammer/0/logs/`
|
||||
|
||||
**Linux:**
|
||||
|
||||
|
|
|
|||
|
|
@ -40,7 +40,6 @@ dependencies {
|
|||
implementation(libs.work.runtime.ktx)
|
||||
implementation(libs.material)
|
||||
implementation(libs.appcompat)
|
||||
implementation(libs.core.splashscreen)
|
||||
implementation(libs.multiplatform.settings)
|
||||
|
||||
androidTestImplementation(libs.androidx.junit)
|
||||
|
|
@ -52,15 +51,7 @@ dependencies {
|
|||
androidTestUtil(libs.orchestrator)
|
||||
|
||||
implementation(libs.aboutlibraries.core)
|
||||
if (!isFDroidBuild) {
|
||||
implementation(libs.play.services.wearable)
|
||||
implementation(libs.coroutines.play.services)
|
||||
implementation(libs.lifecycle.process)
|
||||
}
|
||||
testImplementation(platform(libs.junit.bom))
|
||||
testImplementation(libs.bundles.junit.jupiter)
|
||||
testRuntimeOnly(libs.junit.platform.launcher)
|
||||
testImplementation(libs.coroutines.test)
|
||||
androidTestImplementation(libs.bundles.junit.jupiter)
|
||||
}
|
||||
|
||||
|
|
@ -79,14 +70,6 @@ android {
|
|||
useSupportLibrary = true
|
||||
}
|
||||
}
|
||||
// Wear OS pairing needs Google Play services, which F-Droid builds may not ship.
|
||||
if (isFDroidBuild) {
|
||||
sourceSets.getByName("main").kotlin.srcDir("src/nogms/kotlin")
|
||||
} else {
|
||||
sourceSets.getByName("main").kotlin.srcDir("src/gms/kotlin")
|
||||
sourceSets.getByName("main").res.srcDir("src/gms/res")
|
||||
sourceSets.getByName("test").kotlin.srcDir("src/testGms/kotlin")
|
||||
}
|
||||
if (isFDroidBuild) {
|
||||
// Swap in the manifest that also declares the public-storage permissions.
|
||||
sourceSets.getByName("main").manifest.srcFile("src/fdroid/AndroidManifest.xml")
|
||||
|
|
@ -128,8 +111,7 @@ android {
|
|||
|
||||
proguardFiles(
|
||||
getDefaultProguardFile("proguard-android-optimize.txt"),
|
||||
rootProject.file("proguard-common.pro"),
|
||||
file("proguard-rules.pro")
|
||||
File("proguard-rules.pro")
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
61
android/proguard-rules.pro
vendored
61
android/proguard-rules.pro
vendored
|
|
@ -1,8 +1,57 @@
|
|||
# Phone-only rules. Rules shared with the wear app live in /proguard-common.pro.
|
||||
-dontobfuscate
|
||||
|
||||
# Glance instantiates ActionCallback implementations reflectively via their no-arg
|
||||
# constructor. Glance's own consumer rule keeps the class but not its members, so
|
||||
# R8 strips the constructor and every widget tap throws NoSuchMethodException.
|
||||
-keepclassmembers class * implements androidx.glance.appwidget.action.ActionCallback {
|
||||
<init>();
|
||||
# Keep exception class names and their constructors for proper stack traces
|
||||
-keep class * extends java.lang.Exception { *; }
|
||||
-keep class * extends java.lang.RuntimeException { *; }
|
||||
-keep class * extends java.lang.Throwable { *; }
|
||||
|
||||
# Keep `Companion` object fields of serializable classes.
|
||||
# This avoids serializer lookup through `getDeclaredClasses` as done for named companion objects.
|
||||
-if @kotlinx.serialization.Serializable class **
|
||||
-keepclassmembers class <1> {
|
||||
static <1>$Companion Companion;
|
||||
}
|
||||
|
||||
# Keep `serializer()` on companion objects (both default and named) of serializable classes.
|
||||
-if @kotlinx.serialization.Serializable class ** {
|
||||
static **$* *;
|
||||
}
|
||||
-keepclassmembers class <2>$<3> {
|
||||
kotlinx.serialization.KSerializer serializer(...);
|
||||
}
|
||||
|
||||
# Keep `INSTANCE.serializer()` of serializable objects.
|
||||
-if @kotlinx.serialization.Serializable class ** {
|
||||
public static ** INSTANCE;
|
||||
}
|
||||
-keepclassmembers class <1> {
|
||||
public static <1> INSTANCE;
|
||||
kotlinx.serialization.KSerializer serializer(...);
|
||||
}
|
||||
|
||||
# @Serializable and @Polymorphic are used at runtime for polymorphic serialization.
|
||||
-keepattributes RuntimeVisibleAnnotations,AnnotationDefault
|
||||
|
||||
# Serializer for classes with named companion objects are retrieved using `getDeclaredClasses`.
|
||||
# If you have any, uncomment and replace classes with those containing named companion objects.
|
||||
#-keepattributes InnerClasses # Needed for `getDeclaredClasses`.
|
||||
#-if @kotlinx.serialization.Serializable class
|
||||
#com.example.myapplication.HasNamedCompanion, # <-- List serializable classes with named companions.
|
||||
#com.example.myapplication.HasNamedCompanion2
|
||||
#{
|
||||
# static **$* *;
|
||||
#}
|
||||
#-keepnames class <1>$$serializer { # -keepnames suffices; class is kept when serializer() is kept.
|
||||
# static <1>$$serializer INSTANCE;
|
||||
#}
|
||||
|
||||
# Animal Sniffer compileOnly dependency to ensure APIs are compatible with older versions of Java.
|
||||
-dontwarn org.codehaus.mojo.animal_sniffer.*
|
||||
|
||||
# Logging
|
||||
-keep class io.github.aakira.napier.** { *; }
|
||||
-dontwarn org.slf4j.**
|
||||
-assumenosideeffects class org.slf4j.Logger {
|
||||
public void trace(...);
|
||||
public void debug(...);
|
||||
}
|
||||
|
|
@ -6,7 +6,6 @@ import androidx.compose.ui.test.ComposeTimeoutException
|
|||
import androidx.compose.ui.test.SemanticsMatcher
|
||||
import androidx.compose.ui.test.junit4.ComposeTestRule
|
||||
import androidx.compose.ui.test.onAllNodesWithTag
|
||||
import androidx.compose.ui.test.onFirst
|
||||
import androidx.compose.ui.test.onNodeWithTag
|
||||
import androidx.compose.ui.test.performClick
|
||||
import androidx.test.core.app.ActivityScenario
|
||||
|
|
@ -16,7 +15,6 @@ import com.darkrockstudios.apps.hammer.common.data.ClientResult
|
|||
import com.darkrockstudios.apps.hammer.common.data.ProjectDef
|
||||
import com.darkrockstudios.apps.hammer.common.data.projectsrepository.ProjectsRepository
|
||||
import org.koin.java.KoinJavaComponent.getKoin
|
||||
import java.io.IOException
|
||||
|
||||
/**
|
||||
* Shared helpers for editor instrumented tests: seed a project through the real Koin graph,
|
||||
|
|
@ -32,10 +30,7 @@ object EditorTestHarness {
|
|||
|
||||
/** Create a uniquely-named project so re-runs don't collide; returns its def. */
|
||||
fun seedProject(baseName: String): ProjectDef {
|
||||
val result = repository().createProject(
|
||||
"$baseName ${System.currentTimeMillis()}",
|
||||
seedDefaultLanguage = true,
|
||||
)
|
||||
val result = repository().createProject("$baseName ${System.currentTimeMillis()}")
|
||||
check(result is ClientResult.Success) { "Failed to seed project: $result" }
|
||||
return result.data
|
||||
}
|
||||
|
|
@ -48,37 +43,8 @@ object EditorTestHarness {
|
|||
scenario.onActivity { it.finish() }
|
||||
InstrumentationRegistry.getInstrumentation().waitForIdleSync()
|
||||
scenario.close()
|
||||
deleteProjectWhenSettled(projectDef)
|
||||
repository().deleteProject(projectDef)
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete the project directory, tolerating writes that are still landing.
|
||||
*
|
||||
* finish() and waitForIdleSync() only drain the main thread, but the project-scope close
|
||||
* flushes scene buffers on IO coroutines after that. A straggler write can land between
|
||||
* deleteRecursively emptying the directory and removing it, so the final rmdir fails with
|
||||
* "failed to delete <project dir>". Each retry deletes whatever reappeared, so this
|
||||
* converges as soon as the flushes stop.
|
||||
*/
|
||||
fun deleteProjectWhenSettled(projectDef: ProjectDef, timeoutMillis: Long = 15_000L) {
|
||||
val deadline = SystemClock.uptimeMillis() + timeoutMillis
|
||||
var lastFailure: IOException? = null
|
||||
while (SystemClock.uptimeMillis() < deadline) {
|
||||
try {
|
||||
repository().deleteProject(projectDef)
|
||||
return
|
||||
} catch (e: IOException) {
|
||||
lastFailure = e
|
||||
SystemClock.sleep(DELETE_RETRY_INTERVAL_MS)
|
||||
}
|
||||
}
|
||||
throw AssertionError(
|
||||
"Project directory was still being written to ${timeoutMillis}ms after teardown began",
|
||||
lastFailure,
|
||||
)
|
||||
}
|
||||
|
||||
private const val DELETE_RETRY_INTERVAL_MS = 100L
|
||||
}
|
||||
|
||||
/** Matches nodes whose testTag starts with [prefix] - for list items keyed by an unknown id. */
|
||||
|
|
@ -101,57 +67,6 @@ fun ComposeTestRule.navigateTo(navTag: String) {
|
|||
onNodeWithTag(navTag).performClick()
|
||||
}
|
||||
|
||||
/**
|
||||
* Click the first node matching [matcher] and wait for [landed] to confirm the click took effect.
|
||||
*
|
||||
* A single injected tap is occasionally dropped before `clickable` resolves it into an onClick,
|
||||
* which leaves the screen unchanged and reports no error. Re-inject until the expected outcome
|
||||
* appears rather than trusting one injection.
|
||||
*
|
||||
* Fails if [matcher] never resolves, and never reports success without having clicked: a [landed]
|
||||
* that is already true on entry would otherwise pass the step without touching the target.
|
||||
*/
|
||||
fun ComposeTestRule.clickUntil(
|
||||
matcher: SemanticsMatcher,
|
||||
timeoutMillis: Long = 10_000L,
|
||||
landed: () -> Boolean,
|
||||
) {
|
||||
val deadline = SystemClock.uptimeMillis() + timeoutMillis
|
||||
var clicked = false
|
||||
while (true) {
|
||||
// Only re-click while the target is still on screen; once it's gone the click did land and
|
||||
// the screen is mid-change, so clicking again would hit whatever replaced it.
|
||||
if (onAllNodes(matcher).fetchSemanticsNodes().isNotEmpty()) {
|
||||
onAllNodes(matcher).onFirst().performClick()
|
||||
clicked = true
|
||||
}
|
||||
|
||||
if (!clicked) {
|
||||
if (SystemClock.uptimeMillis() >= deadline) {
|
||||
throw AssertionError(
|
||||
"'${matcher.description}' never appeared within ${timeoutMillis}ms, so it was never clicked",
|
||||
)
|
||||
}
|
||||
SystemClock.sleep(CLICK_RETRY_INTERVAL_MS)
|
||||
continue
|
||||
}
|
||||
|
||||
try {
|
||||
waitUntil(timeoutMillis = 1_000L) { landed() }
|
||||
return
|
||||
} catch (e: ComposeTimeoutException) {
|
||||
if (SystemClock.uptimeMillis() >= deadline) {
|
||||
throw AssertionError(
|
||||
"Click on '${matcher.description}' never took effect within ${timeoutMillis}ms",
|
||||
e,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private const val CLICK_RETRY_INTERVAL_MS = 100L
|
||||
|
||||
/**
|
||||
* Type into the tagged markdown/scene editor and wait for the edit to land.
|
||||
*
|
||||
|
|
|
|||
|
|
@ -66,7 +66,8 @@ class EncyclopediaWorkflowTest {
|
|||
composeRule.onAllNodes(hasTestTagPrefix("encyclopedia-entry-")).onFirst().assertIsDisplayed()
|
||||
|
||||
// Opening it leaves the browse list - the create FAB only shows there.
|
||||
composeRule.clickUntil(hasTestTagPrefix("encyclopedia-entry-")) {
|
||||
composeRule.onAllNodes(hasTestTagPrefix("encyclopedia-entry-")).onFirst().performClick()
|
||||
composeRule.waitUntil(10_000) {
|
||||
composeRule.onAllNodesWithTag(ENCYCLOPEDIA_CREATE_FAB_TAG).fetchSemanticsNodes().isEmpty()
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -3,10 +3,10 @@ import androidx.compose.runtime.ExperimentalComposeApi
|
|||
import androidx.compose.ui.test.assertIsDisplayed
|
||||
import androidx.compose.ui.test.hasClickAction
|
||||
import androidx.compose.ui.test.hasSetTextAction
|
||||
import androidx.compose.ui.test.hasTestTag
|
||||
import androidx.compose.ui.test.hasText
|
||||
import androidx.compose.ui.test.junit4.createAndroidComposeRule
|
||||
import androidx.compose.ui.test.onAllNodesWithText
|
||||
import androidx.compose.ui.test.onNodeWithTag
|
||||
import androidx.compose.ui.test.onNodeWithText
|
||||
import androidx.compose.ui.test.performClick
|
||||
import androidx.compose.ui.test.performTextInput
|
||||
|
|
@ -44,11 +44,12 @@ class ProjectLifecycleTest {
|
|||
@Test
|
||||
fun createProjectThenOpenIt() {
|
||||
// 1. Open the create dialog (masthead button when wide, bottom bar when narrow).
|
||||
composeRule.clickUntil(hasTestTag(CreateProjectButtonTestTag)) {
|
||||
composeRule.onAllNodes(hasSetTextAction()).fetchSemanticsNodes().isNotEmpty()
|
||||
}
|
||||
composeRule.onNodeWithTag(CreateProjectButtonTestTag).performClick()
|
||||
|
||||
// 2. Type the project name into the dialog's only editable field, then confirm.
|
||||
composeRule.waitUntil(timeoutMillis = 5_000) {
|
||||
composeRule.onAllNodes(hasSetTextAction()).fetchSemanticsNodes().isNotEmpty()
|
||||
}
|
||||
composeRule.onNode(hasSetTextAction()).performTextInput(projectName)
|
||||
|
||||
// The confirm button and the dialog title both read "Create Project";
|
||||
|
|
@ -88,6 +89,6 @@ class ProjectLifecycleTest {
|
|||
@After
|
||||
fun deleteCreatedProject() {
|
||||
val repository = getKoin().get<ProjectsRepository>()
|
||||
EditorTestHarness.deleteProjectWhenSettled(repository.getProjectDefinition(projectName))
|
||||
repository.deleteProject(repository.getProjectDefinition(projectName))
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
import androidx.compose.ui.test.hasTestTag
|
||||
import androidx.compose.ui.test.junit4.createEmptyComposeRule
|
||||
import androidx.compose.ui.test.onAllNodesWithTag
|
||||
import androidx.compose.ui.test.onNodeWithTag
|
||||
import androidx.compose.ui.test.performClick
|
||||
import androidx.compose.ui.test.performImeAction
|
||||
import androidx.compose.ui.test.performTextInput
|
||||
import androidx.test.core.app.ActivityScenario
|
||||
|
|
@ -44,10 +44,12 @@ class SceneEditorWorkflowTest {
|
|||
composeRule.navigateTo(NAV_EDITOR_TAG)
|
||||
|
||||
// Create a scene and open it.
|
||||
composeRule.clickUntil(hasTestTag(SCENE_LIST_ADD_BUTTON_TAG)) {
|
||||
composeRule.onAllNodesWithTag(SCENE_LIST_ADD_SCENE_TAG).fetchSemanticsNodes().isNotEmpty()
|
||||
composeRule.waitUntil(10_000) {
|
||||
composeRule.onAllNodesWithTag(SCENE_LIST_ADD_BUTTON_TAG).fetchSemanticsNodes().isNotEmpty()
|
||||
}
|
||||
composeRule.clickUntil(hasTestTag(SCENE_LIST_ADD_SCENE_TAG)) {
|
||||
composeRule.onNodeWithTag(SCENE_LIST_ADD_BUTTON_TAG).performClick()
|
||||
composeRule.onNodeWithTag(SCENE_LIST_ADD_SCENE_TAG).performClick()
|
||||
composeRule.waitUntil(10_000) {
|
||||
composeRule.onAllNodesWithTag(CREATE_ITEM_NAME_FIELD_TAG).fetchSemanticsNodes().isNotEmpty()
|
||||
}
|
||||
composeRule.onNodeWithTag(CREATE_ITEM_NAME_FIELD_TAG).performTextInput("Opening")
|
||||
|
|
@ -63,7 +65,8 @@ class SceneEditorWorkflowTest {
|
|||
}
|
||||
|
||||
// Saving clears the dirty buffer, so the save action disappears.
|
||||
composeRule.clickUntil(hasTestTag(SCENE_EDITOR_SAVE_TAG)) {
|
||||
composeRule.onNodeWithTag(SCENE_EDITOR_SAVE_TAG).performClick()
|
||||
composeRule.waitUntil(10_000) {
|
||||
composeRule.onAllNodesWithTag(SCENE_EDITOR_SAVE_TAG).fetchSemanticsNodes().isEmpty()
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,9 +1,9 @@
|
|||
import androidx.compose.ui.test.assertIsDisplayed
|
||||
import androidx.compose.ui.test.hasTestTag
|
||||
import androidx.compose.ui.test.junit4.createEmptyComposeRule
|
||||
import androidx.compose.ui.test.onAllNodesWithTag
|
||||
import androidx.compose.ui.test.onFirst
|
||||
import androidx.compose.ui.test.onNodeWithTag
|
||||
import androidx.compose.ui.test.performClick
|
||||
import androidx.compose.ui.test.performImeAction
|
||||
import androidx.compose.ui.test.performTextInput
|
||||
import androidx.test.core.app.ActivityScenario
|
||||
|
|
@ -62,10 +62,12 @@ class SceneListWorkflowTest {
|
|||
|
||||
// Open the add menu, pick scene/group, type a name, submit via the IME action.
|
||||
private fun addItem(menuItemTag: String, name: String) {
|
||||
composeRule.clickUntil(hasTestTag(SCENE_LIST_ADD_BUTTON_TAG)) {
|
||||
composeRule.onAllNodesWithTag(menuItemTag).fetchSemanticsNodes().isNotEmpty()
|
||||
composeRule.waitUntil(10_000) {
|
||||
composeRule.onAllNodesWithTag(SCENE_LIST_ADD_BUTTON_TAG).fetchSemanticsNodes().isNotEmpty()
|
||||
}
|
||||
composeRule.clickUntil(hasTestTag(menuItemTag)) {
|
||||
composeRule.onNodeWithTag(SCENE_LIST_ADD_BUTTON_TAG).performClick()
|
||||
composeRule.onNodeWithTag(menuItemTag).performClick()
|
||||
composeRule.waitUntil(10_000) {
|
||||
composeRule.onAllNodesWithTag(CREATE_ITEM_NAME_FIELD_TAG).fetchSemanticsNodes().isNotEmpty()
|
||||
}
|
||||
composeRule.onNodeWithTag(CREATE_ITEM_NAME_FIELD_TAG).performTextInput(name)
|
||||
|
|
|
|||
|
|
@ -1,5 +1,4 @@
|
|||
import androidx.compose.ui.test.assertIsDisplayed
|
||||
import androidx.compose.ui.test.hasTestTag
|
||||
import androidx.compose.ui.test.junit4.createEmptyComposeRule
|
||||
import androidx.compose.ui.test.onAllNodesWithTag
|
||||
import androidx.compose.ui.test.onNodeWithTag
|
||||
|
|
@ -43,8 +42,14 @@ class TimelineWorkflowTest {
|
|||
fun createEventThenOpenIt() {
|
||||
composeRule.navigateTo(NAV_TIMELINE_TAG)
|
||||
|
||||
// Open the create screen via the FAB. Content is optional, so a date alone persists an event.
|
||||
composeRule.clickUntil(hasTestTag(TIME_LINE_CREATE_TAG)) {
|
||||
// Open the create screen via the FAB.
|
||||
composeRule.waitUntil(10_000) {
|
||||
composeRule.onAllNodesWithTag(TIME_LINE_CREATE_TAG).fetchSemanticsNodes().isNotEmpty()
|
||||
}
|
||||
composeRule.onNodeWithTag(TIME_LINE_CREATE_TAG).performClick()
|
||||
|
||||
// Content is optional, so a date alone persists an event.
|
||||
composeRule.waitUntil(10_000) {
|
||||
composeRule.onAllNodesWithTag(TIME_LINE_CREATE_DATE_TAG).fetchSemanticsNodes().isNotEmpty()
|
||||
}
|
||||
composeRule.onNodeWithTag(TIME_LINE_CREATE_DATE_TAG).performTextInput("Year One")
|
||||
|
|
@ -57,7 +62,8 @@ class TimelineWorkflowTest {
|
|||
composeRule.onAllNodesWithTag(EVENT_CARD_TAG)[0].assertIsDisplayed()
|
||||
|
||||
// Opening the event leaves the overview - the create FAB only shows there.
|
||||
composeRule.clickUntil(hasTestTag(EVENT_CARD_TAG)) {
|
||||
composeRule.onAllNodesWithTag(EVENT_CARD_TAG)[0].performClick()
|
||||
composeRule.waitUntil(10_000) {
|
||||
composeRule.onAllNodesWithTag(TIME_LINE_CREATE_TAG).fetchSemanticsNodes().isEmpty()
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -30,7 +30,6 @@
|
|||
<application
|
||||
android:name=".HammerApplication"
|
||||
android:allowBackup="true"
|
||||
android:networkSecurityConfig="@xml/network_security_config"
|
||||
android:enableOnBackInvokedCallback="true"
|
||||
android:dataExtractionRules="@xml/data_extraction_rules"
|
||||
android:fullBackupContent="@xml/backup_rules"
|
||||
|
|
|
|||
|
|
@ -1,6 +0,0 @@
|
|||
package com.darkrockstudios.apps.hammer.android
|
||||
|
||||
import com.darkrockstudios.apps.hammer.android.wear.wearPairingModule
|
||||
import org.koin.core.module.Module
|
||||
|
||||
val playServicesModules: List<Module> = listOf(wearPairingModule)
|
||||
|
|
@ -1,148 +0,0 @@
|
|||
package com.darkrockstudios.apps.hammer.android.wear
|
||||
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.os.Bundle
|
||||
import android.widget.Toast
|
||||
import androidx.activity.ComponentActivity
|
||||
import androidx.activity.compose.BackHandler
|
||||
import androidx.activity.compose.setContent
|
||||
import androidx.activity.enableEdgeToEdge
|
||||
import androidx.compose.foundation.isSystemInDarkTheme
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.systemBarsPadding
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.core.app.NotificationManagerCompat
|
||||
import com.darkrockstudios.apps.hammer.android.R
|
||||
import com.darkrockstudios.apps.hammer.common.compose.Ui
|
||||
import com.darkrockstudios.apps.hammer.common.compose.theme.AppTheme
|
||||
import com.darkrockstudios.apps.hammer.common.data.globalsettings.GlobalSettingsStore
|
||||
import com.darkrockstudios.apps.hammer.common.data.globalsettings.UiTheme
|
||||
import com.darkrockstudios.apps.hammer.common.data.pairing.PairRequest
|
||||
import com.darkrockstudios.apps.hammer.common.data.pairing.PairResponse
|
||||
import com.darkrockstudios.apps.hammer.common.data.pairing.PairingProtocol
|
||||
import com.darkrockstudios.apps.hammer.common.dependencyinjection.APP_SCOPE
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
import org.koin.core.component.KoinComponent
|
||||
import org.koin.core.component.inject
|
||||
import org.koin.core.qualifier.named
|
||||
|
||||
class WearPairingActivity : ComponentActivity(), KoinComponent {
|
||||
|
||||
private val handler: WearPairingHandler by inject()
|
||||
private val globalSettingsStore: GlobalSettingsStore by inject()
|
||||
private val appScope: CoroutineScope by inject(named(APP_SCOPE))
|
||||
|
||||
private var nodeId: String? = null
|
||||
private var request: PairRequest? = null
|
||||
private var answered = false
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
enableEdgeToEdge()
|
||||
|
||||
// Drop touches delivered while another app's window overlays this dialog, so a tapjacking
|
||||
// overlay can't approve a pairing on the user's behalf.
|
||||
window.decorView.filterTouchesWhenObscured = true
|
||||
setFinishOnTouchOutside(false)
|
||||
window.setBackgroundDrawableResource(android.R.color.transparent)
|
||||
|
||||
val sourceNode = intent.getStringExtra(EXTRA_NODE_ID)
|
||||
val pairRequest = intent.getByteArrayExtra(EXTRA_REQUEST)?.let(PairingProtocol::decodeRequest)
|
||||
if (sourceNode == null || pairRequest == null) {
|
||||
finish()
|
||||
return
|
||||
}
|
||||
nodeId = sourceNode
|
||||
request = pairRequest
|
||||
NotificationManagerCompat.from(this).cancel(notificationId(pairRequest.requestId))
|
||||
|
||||
val accountEmail = if (handler.isSignedIn()) globalSettingsStore.serverSettings?.email else null
|
||||
val settings = globalSettingsStore.globalSettings
|
||||
|
||||
setContent {
|
||||
val isDark = when (settings.uiTheme) {
|
||||
UiTheme.Light -> false
|
||||
UiTheme.Dark, UiTheme.OledBlack -> true
|
||||
UiTheme.FollowSystem -> isSystemInDarkTheme()
|
||||
}
|
||||
|
||||
BackHandler { decline() }
|
||||
|
||||
AppTheme(settings = settings, useDarkTheme = isDark) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.systemBarsPadding()
|
||||
.padding(Ui.Padding.M),
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
WearPairingDialog(
|
||||
deviceLabel = pairRequest.deviceLabel,
|
||||
accountEmail = accountEmail,
|
||||
onApprove = ::approve,
|
||||
onDecline = ::decline,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// The reply runs in the app scope so it still reaches the watch after this dialog closes.
|
||||
private fun approve() {
|
||||
val sourceNode = nodeId ?: return
|
||||
val pairRequest = request ?: return
|
||||
answered = true
|
||||
val appContext = applicationContext
|
||||
appScope.launch {
|
||||
val response = handler.approve(sourceNode, pairRequest)
|
||||
val message = when (response) {
|
||||
is PairResponse.Success -> R.string.wear_pairing_toast_success
|
||||
is PairResponse.Error -> R.string.wear_pairing_toast_failure
|
||||
}
|
||||
if (handler.isSignedIn()) {
|
||||
withContext(Dispatchers.Main) {
|
||||
Toast.makeText(appContext, message, Toast.LENGTH_SHORT).show()
|
||||
}
|
||||
}
|
||||
}
|
||||
finish()
|
||||
}
|
||||
|
||||
private fun decline() {
|
||||
val sourceNode = nodeId ?: return
|
||||
val pairRequest = request ?: return
|
||||
answered = true
|
||||
appScope.launch { handler.decline(sourceNode, pairRequest) }
|
||||
finish()
|
||||
}
|
||||
|
||||
override fun onDestroy() {
|
||||
super.onDestroy()
|
||||
// Dismissed without an answer: tell the watch rather than leave it waiting. Only when finishing:
|
||||
// a backgrounded instance the system reclaims comes back with the prompt still to answer.
|
||||
if (!answered && isFinishing) {
|
||||
decline()
|
||||
}
|
||||
}
|
||||
|
||||
companion object {
|
||||
private const val NOTIFICATION_ID_BASE = 7301
|
||||
private const val EXTRA_NODE_ID = "node_id"
|
||||
private const val EXTRA_REQUEST = "request"
|
||||
|
||||
/** Per request, so two watches asking at once do not overwrite each other's prompt. */
|
||||
fun notificationId(requestId: String): Int = NOTIFICATION_ID_BASE + (requestId.hashCode() and 0xFFFF)
|
||||
|
||||
fun createIntent(context: Context, nodeId: String, requestPayload: ByteArray): Intent =
|
||||
Intent(context, WearPairingActivity::class.java)
|
||||
.putExtra(EXTRA_NODE_ID, nodeId)
|
||||
.putExtra(EXTRA_REQUEST, requestPayload)
|
||||
}
|
||||
}
|
||||
|
|
@ -1,102 +0,0 @@
|
|||
package com.darkrockstudios.apps.hammer.android.wear
|
||||
|
||||
import androidx.compose.foundation.BorderStroke
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.widthIn
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.RectangleShape
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.unit.Dp
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.darkrockstudios.apps.hammer.android.R
|
||||
import com.darkrockstudios.apps.hammer.common.compose.Ui
|
||||
import com.darkrockstudios.apps.hammer.common.compose.designsystem.HdFolioDivider
|
||||
import com.darkrockstudios.apps.hammer.common.compose.designsystem.HdHairlineButton
|
||||
import com.darkrockstudios.apps.hammer.common.compose.designsystem.HdMasthead
|
||||
|
||||
private val DialogMaxWidth = 480.dp
|
||||
|
||||
/** [accountEmail] is null when this phone is not signed in to a sync server. */
|
||||
@Composable
|
||||
fun WearPairingDialog(
|
||||
deviceLabel: String,
|
||||
accountEmail: String?,
|
||||
onApprove: () -> Unit,
|
||||
onDecline: () -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
Surface(
|
||||
modifier = modifier
|
||||
.widthIn(max = DialogMaxWidth)
|
||||
.fillMaxWidth(),
|
||||
shape = RectangleShape,
|
||||
color = MaterialTheme.colorScheme.surface,
|
||||
contentColor = MaterialTheme.colorScheme.onSurface,
|
||||
border = BorderStroke(Dp.Hairline, MaterialTheme.colorScheme.outlineVariant),
|
||||
) {
|
||||
Column {
|
||||
HdMasthead(
|
||||
section = stringResource(R.string.wear_pairing_masthead),
|
||||
leadingMeta = listOf(deviceLabel),
|
||||
)
|
||||
HdFolioDivider()
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(Ui.Padding.XL),
|
||||
verticalArrangement = Arrangement.spacedBy(Ui.Padding.L),
|
||||
) {
|
||||
if (accountEmail != null) {
|
||||
Text(
|
||||
text = stringResource(R.string.wear_pairing_title, deviceLabel),
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
)
|
||||
Text(
|
||||
text = stringResource(R.string.wear_pairing_body, accountEmail),
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
HdHairlineButton(
|
||||
label = stringResource(R.string.wear_pairing_decline),
|
||||
onClick = onDecline,
|
||||
)
|
||||
HdHairlineButton(
|
||||
label = stringResource(R.string.wear_pairing_approve),
|
||||
emphasised = true,
|
||||
onClick = onApprove,
|
||||
)
|
||||
}
|
||||
} else {
|
||||
Text(
|
||||
text = stringResource(R.string.wear_pairing_not_signed_in),
|
||||
style = MaterialTheme.typography.bodyLarge,
|
||||
)
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.End,
|
||||
) {
|
||||
// Approving here answers the watch with NotSignedIn rather than a bare decline.
|
||||
HdHairlineButton(
|
||||
label = stringResource(R.string.wear_pairing_close),
|
||||
onClick = onApprove,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -1,75 +0,0 @@
|
|||
package com.darkrockstudios.apps.hammer.android.wear
|
||||
|
||||
import com.darkrockstudios.apps.hammer.common.data.CResult
|
||||
import com.darkrockstudios.apps.hammer.common.data.globalsettings.ServerSettings
|
||||
import com.darkrockstudios.apps.hammer.common.data.isSuccess
|
||||
import com.darkrockstudios.apps.hammer.common.data.pairing.PairErrorCode
|
||||
import com.darkrockstudios.apps.hammer.common.data.pairing.PairRequest
|
||||
import com.darkrockstudios.apps.hammer.common.data.pairing.PairResponse
|
||||
import com.darkrockstudios.apps.hammer.common.data.pairing.PairingProtocol
|
||||
import io.github.aakira.napier.Napier
|
||||
import kotlinx.coroutines.CancellationException
|
||||
|
||||
/** Answers a watch's pairing request once the user has approved or declined it on the phone. */
|
||||
class WearPairingHandler(
|
||||
private val serverSettings: () -> ServerSettings?,
|
||||
private val pairInstall: suspend (installId: String) -> CResult<ServerSettings>,
|
||||
private val responder: PairingResponder,
|
||||
) {
|
||||
fun isSignedIn(): Boolean {
|
||||
val settings = serverSettings() ?: return false
|
||||
return settings.userId >= 0 && !settings.bearerToken.isNullOrBlank()
|
||||
}
|
||||
|
||||
suspend fun approve(nodeId: String, request: PairRequest): PairResponse {
|
||||
val response = when {
|
||||
request.version != PairingProtocol.VERSION ->
|
||||
PairResponse.Error(request.requestId, PairErrorCode.Unsupported)
|
||||
|
||||
!isSignedIn() ->
|
||||
PairResponse.Error(request.requestId, PairErrorCode.NotSignedIn)
|
||||
|
||||
else -> {
|
||||
val result = pairInstall(request.installId)
|
||||
if (isSuccess(result)) {
|
||||
PairResponse.Success(request.requestId, result.data)
|
||||
} else {
|
||||
PairResponse.Error(request.requestId, PairErrorCode.ServerRejected)
|
||||
}
|
||||
}
|
||||
}
|
||||
send(nodeId, response)
|
||||
return response
|
||||
}
|
||||
|
||||
suspend fun decline(nodeId: String, request: PairRequest): PairResponse {
|
||||
val response = PairResponse.Error(request.requestId, PairErrorCode.Declined)
|
||||
send(nodeId, response)
|
||||
return response
|
||||
}
|
||||
|
||||
/**
|
||||
* Replies to a request this phone could not read, so the watch stops waiting. The request id is
|
||||
* unknown precisely because the payload could not be decoded, so this answers with a blank one,
|
||||
* which the watch matches against whatever it has pending.
|
||||
*/
|
||||
suspend fun rejectUnreadable(nodeId: String) {
|
||||
send(nodeId, PairResponse.Error(requestId = "", code = PairErrorCode.Unsupported))
|
||||
}
|
||||
|
||||
/** Replies when the confirmation cannot be shown, so the watch can tell the user what to do. */
|
||||
suspend fun reportUnavailable(nodeId: String, request: PairRequest) {
|
||||
send(nodeId, PairResponse.Error(request.requestId, PairErrorCode.PhoneUnavailable))
|
||||
}
|
||||
|
||||
private suspend fun send(nodeId: String, response: PairResponse) {
|
||||
try {
|
||||
responder.respond(nodeId, response)
|
||||
} catch (e: CancellationException) {
|
||||
throw e
|
||||
} catch (@Suppress("TooGenericExceptionCaught") e: Exception) {
|
||||
// The watch may have gone out of range; it can retry from its own screen.
|
||||
Napier.w("Failed to reply to the watch", e)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -1,100 +0,0 @@
|
|||
package com.darkrockstudios.apps.hammer.android.wear
|
||||
|
||||
import android.Manifest
|
||||
import android.annotation.SuppressLint
|
||||
import android.app.PendingIntent
|
||||
import android.content.Intent
|
||||
import android.content.pm.PackageManager
|
||||
import android.os.Build
|
||||
import androidx.core.app.NotificationChannelCompat
|
||||
import androidx.core.app.NotificationCompat
|
||||
import androidx.core.app.NotificationManagerCompat
|
||||
import androidx.lifecycle.Lifecycle
|
||||
import androidx.lifecycle.ProcessLifecycleOwner
|
||||
import com.darkrockstudios.apps.hammer.android.R
|
||||
import com.darkrockstudios.apps.hammer.common.data.pairing.PairingProtocol
|
||||
import com.darkrockstudios.apps.hammer.common.dependencyinjection.APP_SCOPE
|
||||
import com.google.android.gms.wearable.MessageEvent
|
||||
import com.google.android.gms.wearable.WearableListenerService
|
||||
import io.github.aakira.napier.Napier
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.launch
|
||||
import org.koin.core.component.KoinComponent
|
||||
import org.koin.core.component.inject
|
||||
import org.koin.core.qualifier.named
|
||||
|
||||
/**
|
||||
* Receives a watch's pairing request. The user must approve it on the phone, so this only brings
|
||||
* up [WearPairingActivity]: directly when Hammer is on screen, through a notification otherwise,
|
||||
* since Android blocks activity starts from the background.
|
||||
*/
|
||||
class WearPairingListenerService : WearableListenerService(), KoinComponent {
|
||||
|
||||
private val handler: WearPairingHandler by inject()
|
||||
private val appScope: CoroutineScope by inject(named(APP_SCOPE))
|
||||
|
||||
override fun onMessageReceived(event: MessageEvent) {
|
||||
if (event.path != PairingProtocol.REQUEST_PATH) return
|
||||
|
||||
val request = PairingProtocol.decodeRequest(event.data)
|
||||
if (request == null) {
|
||||
appScope.launch { handler.rejectUnreadable(event.sourceNodeId) }
|
||||
return
|
||||
}
|
||||
|
||||
val intent = WearPairingActivity.createIntent(this, event.sourceNodeId, event.data)
|
||||
val appVisible = ProcessLifecycleOwner.get().lifecycle.currentState.isAtLeast(Lifecycle.State.STARTED)
|
||||
if (appVisible) {
|
||||
startActivity(intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK))
|
||||
} else if (!showNotification(intent, request.requestId)) {
|
||||
// Nothing can put the prompt in front of the user, so answer instead of leaving the
|
||||
// watch waiting on a confirmation that will never appear.
|
||||
appScope.launch { handler.reportUnavailable(event.sourceNodeId, request) }
|
||||
}
|
||||
}
|
||||
|
||||
/** False when the prompt could not be raised, so the caller can answer the watch instead. */
|
||||
@SuppressLint("MissingPermission")
|
||||
private fun showNotification(intent: Intent, requestId: String): Boolean {
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU &&
|
||||
checkSelfPermission(Manifest.permission.POST_NOTIFICATIONS) != PackageManager.PERMISSION_GRANTED
|
||||
) {
|
||||
Napier.w("Cannot show the watch pairing prompt: notification permission not granted")
|
||||
return false
|
||||
}
|
||||
|
||||
val notificationId = WearPairingActivity.notificationId(requestId)
|
||||
val manager = NotificationManagerCompat.from(this)
|
||||
manager.createNotificationChannel(
|
||||
NotificationChannelCompat.Builder(CHANNEL_ID, NotificationManagerCompat.IMPORTANCE_HIGH)
|
||||
.setName(getString(R.string.wear_pairing_channel_name))
|
||||
.build()
|
||||
)
|
||||
|
||||
val pendingIntent = PendingIntent.getActivity(
|
||||
this,
|
||||
notificationId,
|
||||
intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK),
|
||||
PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT,
|
||||
)
|
||||
val notification = NotificationCompat.Builder(this, CHANNEL_ID)
|
||||
.setSmallIcon(R.drawable.ic_launcher_monochrome)
|
||||
.setContentTitle(getString(R.string.wear_pairing_notification_title))
|
||||
.setContentText(getString(R.string.wear_pairing_notification_text))
|
||||
.setContentIntent(pendingIntent)
|
||||
.setAutoCancel(true)
|
||||
.setPriority(NotificationCompat.PRIORITY_HIGH)
|
||||
.build()
|
||||
return try {
|
||||
manager.notify(notificationId, notification)
|
||||
true
|
||||
} catch (@Suppress("SwallowedException") e: SecurityException) {
|
||||
Napier.w("Cannot show the watch pairing prompt", e)
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
private companion object {
|
||||
const val CHANNEL_ID = "wear_pairing"
|
||||
}
|
||||
}
|
||||
|
|
@ -1,21 +0,0 @@
|
|||
package com.darkrockstudios.apps.hammer.android.wear
|
||||
|
||||
import android.content.Context
|
||||
import com.darkrockstudios.apps.hammer.common.data.pairing.PairResponse
|
||||
import com.darkrockstudios.apps.hammer.common.data.pairing.PairingProtocol
|
||||
import com.google.android.gms.wearable.Wearable
|
||||
import kotlinx.coroutines.tasks.await
|
||||
|
||||
interface PairingResponder {
|
||||
suspend fun respond(nodeId: String, response: PairResponse)
|
||||
}
|
||||
|
||||
class WearPairingResponder(context: Context) : PairingResponder {
|
||||
private val messageClient = Wearable.getMessageClient(context)
|
||||
|
||||
override suspend fun respond(nodeId: String, response: PairResponse) {
|
||||
messageClient
|
||||
.sendMessage(nodeId, PairingProtocol.RESPONSE_PATH, PairingProtocol.encodeResponse(response))
|
||||
.await()
|
||||
}
|
||||
}
|
||||
|
|
@ -1,40 +0,0 @@
|
|||
package com.darkrockstudios.apps.hammer.android.wear.preview
|
||||
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.tooling.preview.Preview
|
||||
import com.darkrockstudios.apps.hammer.android.wear.WearPairingDialog
|
||||
import com.darkrockstudios.apps.hammer.common.compose.Ui
|
||||
import com.darkrockstudios.apps.hammer.common.compose.theme.AppTheme
|
||||
import com.darkrockstudios.apps.hammer.common.data.globalsettings.GlobalSettings
|
||||
|
||||
private val previewSettings = GlobalSettings(projectsDirectory = "/projects")
|
||||
|
||||
@Preview
|
||||
@Composable
|
||||
private fun WearPairingDialogSignedInPreview() {
|
||||
AppTheme(settings = previewSettings, useDarkTheme = false) {
|
||||
WearPairingDialog(
|
||||
deviceLabel = "Pixel Watch 3",
|
||||
accountEmail = "writer@example.com",
|
||||
onApprove = {},
|
||||
onDecline = {},
|
||||
modifier = Modifier.padding(Ui.Padding.M),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Preview
|
||||
@Composable
|
||||
private fun WearPairingDialogNotSignedInDarkPreview() {
|
||||
AppTheme(settings = previewSettings, useDarkTheme = true) {
|
||||
WearPairingDialog(
|
||||
deviceLabel = "Pixel Watch 3",
|
||||
accountEmail = null,
|
||||
onApprove = {},
|
||||
onDecline = {},
|
||||
modifier = Modifier.padding(Ui.Padding.M),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -1,19 +0,0 @@
|
|||
package com.darkrockstudios.apps.hammer.android.wear
|
||||
|
||||
import com.darkrockstudios.apps.hammer.common.data.account.AccountUseCase
|
||||
import com.darkrockstudios.apps.hammer.common.data.globalsettings.GlobalSettingsStore
|
||||
import org.koin.core.module.Module
|
||||
import org.koin.dsl.module
|
||||
|
||||
val wearPairingModule: Module = module {
|
||||
single<PairingResponder> { WearPairingResponder(context = get()) }
|
||||
single {
|
||||
val globalSettingsStore: GlobalSettingsStore = get()
|
||||
val accountUseCase: AccountUseCase = get()
|
||||
WearPairingHandler(
|
||||
serverSettings = { globalSettingsStore.serverSettings },
|
||||
pairInstall = accountUseCase::pairInstall,
|
||||
responder = get(),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -1,9 +0,0 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<!--
|
||||
Thar be dragons: only the Wearable Data Layer reads this array, by name, at runtime, so
|
||||
resource shrinking drops it unless it is kept here. A keep attribute is honoured only in a
|
||||
res/raw keep file; one on the values file itself is silently ignored, and the watch then
|
||||
never finds the phone.
|
||||
-->
|
||||
<resources xmlns:tools="http://schemas.android.com/tools"
|
||||
tools:keep="@array/android_wear_capabilities" />
|
||||
|
|
@ -1,8 +0,0 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources>
|
||||
<!-- Advertised to the Wearable Data Layer so the watch app can find this phone. Kept from
|
||||
resource shrinking by res/raw/keep.xml. -->
|
||||
<string-array name="android_wear_capabilities" translatable="false">
|
||||
<item>hammer_phone_pairing</item>
|
||||
</string-array>
|
||||
</resources>
|
||||
|
|
@ -5,13 +5,10 @@
|
|||
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE"/>
|
||||
<uses-permission android:name="android.permission.ACCESS_NOTIFICATION_POLICY"/>
|
||||
<uses-permission android:name="android.permission.ACCESS_LOCAL_NETWORK" />
|
||||
<!-- Watch pairing prompt. Play services only: not mirrored in src/fdroid/AndroidManifest.xml. -->
|
||||
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
|
||||
|
||||
<application
|
||||
android:name=".HammerApplication"
|
||||
android:allowBackup="true"
|
||||
android:networkSecurityConfig="@xml/network_security_config"
|
||||
android:enableOnBackInvokedCallback="true"
|
||||
android:dataExtractionRules="@xml/data_extraction_rules"
|
||||
android:fullBackupContent="@xml/backup_rules"
|
||||
|
|
@ -23,7 +20,6 @@
|
|||
|
||||
<activity
|
||||
android:name=".ProjectSelectActivity"
|
||||
android:theme="@style/Theme.Hammer.Starting"
|
||||
android:exported="true"
|
||||
android:windowSoftInputMode="adjustResize">
|
||||
<intent-filter>
|
||||
|
|
@ -104,26 +100,6 @@
|
|||
android:resource="@xml/story_info_widget_info"/>
|
||||
</receiver>
|
||||
|
||||
<!-- Wear OS pairing. Play services only: not mirrored in src/fdroid/AndroidManifest.xml. -->
|
||||
<activity
|
||||
android:name=".wear.WearPairingActivity"
|
||||
android:excludeFromRecents="true"
|
||||
android:exported="false"
|
||||
android:theme="@style/Theme.AppCompat.DayNight.Dialog"
|
||||
android:windowSoftInputMode="adjustResize"/>
|
||||
<service
|
||||
android:name=".wear.WearPairingListenerService"
|
||||
android:exported="true">
|
||||
<intent-filter>
|
||||
<action android:name="com.google.android.gms.wearable.MESSAGE_RECEIVED"/>
|
||||
|
||||
<data
|
||||
android:host="*"
|
||||
android:pathPrefix="/hammer/pair/request"
|
||||
android:scheme="wear"/>
|
||||
</intent-filter>
|
||||
</service>
|
||||
|
||||
<provider
|
||||
android:name="androidx.core.content.FileProvider"
|
||||
android:authorities="${applicationId}.fileprovider"
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
package com.darkrockstudios.apps.hammer.common
|
||||
package com.darkrockstudios.apps.hammer.android
|
||||
|
||||
import com.darkrockstudios.apps.hammer.common.getConfigDirectory
|
||||
import com.darkrockstudios.apps.hammer.common.getInDevelopmentMode
|
||||
|
|
@ -16,15 +16,16 @@ import com.darkrockstudios.apps.hammer.android.shortcuts.shortcutsModule
|
|||
import com.darkrockstudios.apps.hammer.android.widgets.AddNoteWidgetReceiver
|
||||
import com.darkrockstudios.apps.hammer.android.widgets.StoriesListWidgetReceiver
|
||||
import com.darkrockstudios.apps.hammer.android.widgets.StoryInfoWidgetReceiver
|
||||
import com.darkrockstudios.apps.hammer.base.BuildMetadata
|
||||
import com.darkrockstudios.apps.hammer.common.BuildConfig
|
||||
import com.darkrockstudios.apps.hammer.common.FileLogger
|
||||
import com.darkrockstudios.apps.hammer.common.data.migrator.DataMigrator
|
||||
import com.darkrockstudios.apps.hammer.common.dependencyinjection.NapierLogger
|
||||
import com.darkrockstudios.apps.hammer.common.dependencyinjection.appModule
|
||||
import com.darkrockstudios.apps.hammer.common.dependencyinjection.imageLoadingModule
|
||||
import com.darkrockstudios.apps.hammer.common.dependencyinjection.mainModule
|
||||
import com.darkrockstudios.apps.hammer.common.installGlobalExceptionHandler
|
||||
import com.darkrockstudios.apps.hammer.common.getConfigDirectory
|
||||
import com.darkrockstudios.apps.hammer.common.logStartupBanner
|
||||
import com.darkrockstudios.apps.hammer.common.platformStartupInfo
|
||||
import com.darkrockstudios.apps.hammer.common.setExternalDirectories
|
||||
import com.darkrockstudios.apps.hammer.common.setInternalDirectories
|
||||
import com.darkrockstudios.apps.hammer.common.util.AndroidSettingsKeys
|
||||
|
|
@ -40,6 +41,7 @@ import org.koin.android.ext.koin.androidContext
|
|||
import org.koin.core.context.GlobalContext.startKoin
|
||||
import org.koin.java.KoinJavaComponent
|
||||
import org.koin.java.KoinJavaComponent.getKoin
|
||||
import java.io.File
|
||||
|
||||
class HammerApplication : Application(), SingletonImageLoader.Factory {
|
||||
|
||||
|
|
@ -57,13 +59,11 @@ class HammerApplication : Application(), SingletonImageLoader.Factory {
|
|||
logger(NapierLogger())
|
||||
androidContext(this@HammerApplication)
|
||||
modules(
|
||||
listOf(
|
||||
mainModule,
|
||||
imageLoadingModule,
|
||||
aboutLibrariesModule,
|
||||
shortcutsModule,
|
||||
appModule(applicationScope),
|
||||
) + playServicesModules
|
||||
mainModule,
|
||||
imageLoadingModule,
|
||||
aboutLibrariesModule,
|
||||
shortcutsModule,
|
||||
appModule(applicationScope)
|
||||
)
|
||||
}
|
||||
|
||||
|
|
@ -126,6 +126,38 @@ class HammerApplication : Application(), SingletonImageLoader.Factory {
|
|||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Catch, log, and hand off any otherwise-unhandled exception. The async [FileLogger] can't be
|
||||
* relied on to flush before the process dies, so we also write a synchronous crash dump straight
|
||||
* to disk, then chain to the platform default handler so Android still terminates and reports.
|
||||
*/
|
||||
private fun installGlobalExceptionHandler() {
|
||||
val previous = Thread.getDefaultUncaughtExceptionHandler()
|
||||
Thread.setDefaultUncaughtExceptionHandler { thread, throwable ->
|
||||
runCatching {
|
||||
Napier.e(
|
||||
"Uncaught exception on thread '${thread.name}', terminating",
|
||||
throwable
|
||||
)
|
||||
}
|
||||
runCatching { writeCrashDump(thread, throwable) }
|
||||
previous?.uncaughtException(thread, throwable)
|
||||
}
|
||||
}
|
||||
|
||||
/** Synchronous, self-contained crash record in the logs dir — the guaranteed artifact when the app dies. */
|
||||
private fun writeCrashDump(thread: Thread, throwable: Throwable) {
|
||||
val dir = File(getConfigDirectory(), "logs")
|
||||
dir.mkdirs()
|
||||
File(dir, "crash-${System.currentTimeMillis()}.txt").writeText(
|
||||
buildString {
|
||||
append("Hammer v${BuildMetadata.APP_VERSION} | ${platformStartupInfo()}\n")
|
||||
append("Uncaught exception on thread '${thread.name}'\n\n")
|
||||
append(throwable.stackTraceToString())
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
override fun onTerminate() {
|
||||
super.onTerminate()
|
||||
applicationScope.cancel("Application onTerminate")
|
||||
|
|
|
|||
|
|
@ -30,7 +30,6 @@ import com.darkrockstudios.apps.hammer.android.shortcuts.ProjectShortcutsManager
|
|||
import com.darkrockstudios.apps.hammer.common.components.projectroot.ProjectDeepLink
|
||||
import com.darkrockstudios.apps.hammer.common.components.projectroot.ProjectRoot
|
||||
import com.darkrockstudios.apps.hammer.common.components.projectroot.ProjectRootComponent
|
||||
import com.darkrockstudios.apps.hammer.common.compose.ProjectShortcutHost
|
||||
import com.darkrockstudios.apps.hammer.common.compose.theme.AppTheme
|
||||
import com.darkrockstudios.apps.hammer.common.data.ProjectDef
|
||||
import com.darkrockstudios.apps.hammer.common.data.closeProjectScope
|
||||
|
|
@ -71,7 +70,6 @@ class ProjectRootActivity : AppCompatActivity() {
|
|||
private val viewModel: ProjectRootViewModel by viewModels()
|
||||
|
||||
private var projectRoot: ProjectRoot? = null
|
||||
private val shortcutHost = ProjectShortcutHost()
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
|
|
@ -109,7 +107,7 @@ class ProjectRootActivity : AppCompatActivity() {
|
|||
val settingsState by globalSettings.subscribeAsState()
|
||||
val isDark = when (settingsState.uiTheme) {
|
||||
UiTheme.Light -> false
|
||||
UiTheme.Dark, UiTheme.OledBlack -> true
|
||||
UiTheme.Dark -> true
|
||||
UiTheme.FollowSystem -> isSystemInDarkTheme()
|
||||
}
|
||||
|
||||
|
|
@ -187,22 +185,14 @@ class ProjectRootActivity : AppCompatActivity() {
|
|||
}
|
||||
|
||||
override fun dispatchKeyEvent(event: KeyEvent): Boolean {
|
||||
if (event.action == KeyEvent.ACTION_DOWN) {
|
||||
// hasModifiers matches exactly, so AltGr chords fall through and type their character.
|
||||
val ctrlShift = KeyEvent.META_CTRL_ON or KeyEvent.META_SHIFT_ON
|
||||
if (event.keyCode == KeyEvent.KEYCODE_F && event.hasModifiers(ctrlShift)) {
|
||||
projectRoot?.let {
|
||||
it.showGlobalSearch()
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
if (event.keyCode == KeyEvent.KEYCODE_F3 && event.hasNoModifiers()) {
|
||||
if (shortcutHost.startProjectSync()) return true
|
||||
}
|
||||
|
||||
if (event.keyCode == KeyEvent.KEYCODE_S && event.hasModifiers(ctrlShift)) {
|
||||
if (shortcutHost.saveAllBuffers()) return true
|
||||
if (event.action == KeyEvent.ACTION_DOWN &&
|
||||
event.keyCode == KeyEvent.KEYCODE_F &&
|
||||
event.isShiftPressed &&
|
||||
event.isCtrlPressed
|
||||
) {
|
||||
projectRoot?.let {
|
||||
it.showGlobalSearch()
|
||||
return true
|
||||
}
|
||||
}
|
||||
return super.dispatchKeyEvent(event)
|
||||
|
|
@ -220,12 +210,11 @@ class ProjectRootActivity : AppCompatActivity() {
|
|||
|
||||
// Only intercept back when at Home AND there's potential unsaved work to confirm
|
||||
// Otherwise, let Android's default back behavior close the activity
|
||||
val unsavedBuffers by component.unsavedBuffers.subscribeAsState()
|
||||
BackHandler(enabled = backEnabled && (unsavedBuffers || shouldConfirmClose.isNotEmpty())) {
|
||||
BackHandler(enabled = backEnabled && (component.hasUnsavedBuffers() || shouldConfirmClose.isNotEmpty())) {
|
||||
component.requestClose()
|
||||
}
|
||||
|
||||
ProjectRootScaffold(component, onCloseRequest = ::finish, shortcutHost = shortcutHost)
|
||||
ProjectRootScaffold(component, onCloseRequest = ::finish)
|
||||
}
|
||||
|
||||
companion object {
|
||||
|
|
|
|||
|
|
@ -14,7 +14,6 @@ import androidx.compose.material3.dynamicLightColorScheme
|
|||
import androidx.compose.runtime.ExperimentalComposeApi
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.core.splashscreen.SplashScreen.Companion.installSplashScreen
|
||||
import androidx.lifecycle.lifecycleScope
|
||||
import com.arkivanov.decompose.extensions.compose.subscribeAsState
|
||||
import com.arkivanov.decompose.retainedComponent
|
||||
|
|
@ -44,7 +43,6 @@ class ProjectSelectActivity : AppCompatActivity() {
|
|||
private var settingsUpdateJob: Job? = null
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
installSplashScreen()
|
||||
super.onCreate(savedInstanceState)
|
||||
enableEdgeToEdge()
|
||||
|
||||
|
|
@ -63,7 +61,7 @@ class ProjectSelectActivity : AppCompatActivity() {
|
|||
val settingsState by globalSettings.subscribeAsState()
|
||||
val isDark = when (settingsState.uiTheme) {
|
||||
UiTheme.Light -> false
|
||||
UiTheme.Dark, UiTheme.OledBlack -> true
|
||||
UiTheme.Dark -> true
|
||||
UiTheme.FollowSystem -> isSystemInDarkTheme()
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -18,7 +18,6 @@ import com.darkrockstudios.apps.hammer.common.components.projectselection.projec
|
|||
import com.darkrockstudios.apps.hammer.common.components.storyeditor.metadata.ProjectMetadata
|
||||
import com.darkrockstudios.apps.hammer.base.http.storyideas.StoryIdea
|
||||
import com.darkrockstudios.apps.hammer.common.data.ImportOptions
|
||||
import com.darkrockstudios.apps.hammer.common.data.appupdate.AppUpdateState
|
||||
import com.darkrockstudios.apps.hammer.common.data.Msg
|
||||
import com.darkrockstudios.apps.hammer.common.data.ProjectDef
|
||||
import com.darkrockstudios.apps.hammer.common.fileio.HPath
|
||||
|
|
@ -93,20 +92,16 @@ val component = object : ProjectSelection {
|
|||
override val navRailState: Value<ProjectSelection.NavRailState> =
|
||||
MutableValue(ProjectSelection.NavRailState(expanded = false))
|
||||
|
||||
override val changelog: Value<ProjectSelection.ChangelogState> =
|
||||
MutableValue(ProjectSelection.ChangelogState())
|
||||
|
||||
override val appUpdate: Value<AppUpdateState> = MutableValue(AppUpdateState.Unsupported)
|
||||
override val updateNotification: Value<ProjectSelection.UpdateNotificationState> =
|
||||
MutableValue(ProjectSelection.UpdateNotificationState())
|
||||
|
||||
override fun isAtRoot() = false
|
||||
override fun onBack() {}
|
||||
override fun showLocation(location: ProjectSelection.Locations) {}
|
||||
override fun toggleNavRailExpanded() {}
|
||||
override fun openLatestRelease() {}
|
||||
override fun dismissChangelog() {}
|
||||
override fun showChangelog() {}
|
||||
override fun updateApp() {}
|
||||
override fun dismissUpdate() {}
|
||||
override fun openReleaseUrl() {}
|
||||
override fun dismissUpdateNotification(remember: Boolean) {}
|
||||
override fun showCurrentReleaseDetails() {}
|
||||
override val backHandler = dummyBackHandler
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -97,7 +97,7 @@ class AddNoteActivity : ComponentActivity(), KoinComponent {
|
|||
val settingsState by globalSettings.subscribeAsState()
|
||||
val isDark = when (settingsState.uiTheme) {
|
||||
UiTheme.Light -> false
|
||||
UiTheme.Dark, UiTheme.OledBlack -> true
|
||||
UiTheme.Dark -> true
|
||||
UiTheme.FollowSystem -> isSystemInDarkTheme()
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -90,7 +90,7 @@ class AddNoteWidgetConfigActivity : ComponentActivity() {
|
|||
val settingsState by globalSettings.subscribeAsState()
|
||||
val isDark = when (settingsState.uiTheme) {
|
||||
UiTheme.Light -> false
|
||||
UiTheme.Dark, UiTheme.OledBlack -> true
|
||||
UiTheme.Dark -> true
|
||||
UiTheme.FollowSystem -> isSystemInDarkTheme()
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -88,7 +88,7 @@ class StoryInfoWidgetConfigActivity : ComponentActivity() {
|
|||
val settingsState by globalSettings.subscribeAsState()
|
||||
val isDark = when (settingsState.uiTheme) {
|
||||
UiTheme.Light -> false
|
||||
UiTheme.Dark, UiTheme.OledBlack -> true
|
||||
UiTheme.Dark -> true
|
||||
UiTheme.FollowSystem -> isSystemInDarkTheme()
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -30,7 +30,7 @@
|
|||
<string name="title_activity_story_info_widget_config">Configure Story Info Widget</string>
|
||||
<string name="story_info_widget_unconfigured">Tap to configure</string>
|
||||
<string name="story_info_widget_folio_header">§ %1$s · STORY</string>
|
||||
<string name="story_info_widget_byline">von %1$s</string>
|
||||
<string name="story_info_widget_byline">by %1$s</string>
|
||||
<string name="story_info_widget_words_label">WORDS</string>
|
||||
<string name="story_info_widget_goal_summary">%1$d%% OF %2$s %3$s</string>
|
||||
<string name="story_info_widget_cadence_today">TODAY</string>
|
||||
|
|
|
|||
|
|
@ -1,15 +0,0 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources>
|
||||
<string name="wear_pairing_masthead">PAIR WATCH</string>
|
||||
<string name="wear_pairing_title">Sign in %1$s to Hammer?</string>
|
||||
<string name="wear_pairing_body">The watch will sync as %1$s with a session of its own. You can sign it out from the watch at any time.</string>
|
||||
<string name="wear_pairing_not_signed_in">Sign in to a sync server on this phone first, then pair the watch again.</string>
|
||||
<string name="wear_pairing_approve">Pair</string>
|
||||
<string name="wear_pairing_decline">Ablehnen</string>
|
||||
<string name="wear_pairing_close">Schließen</string>
|
||||
<string name="wear_pairing_toast_success">Watch paired</string>
|
||||
<string name="wear_pairing_toast_failure">The watch could not be paired</string>
|
||||
<string name="wear_pairing_channel_name">Watch pairing</string>
|
||||
<string name="wear_pairing_notification_title">Pair your watch</string>
|
||||
<string name="wear_pairing_notification_text">Tap to let your watch sync with your Hammer account</string>
|
||||
</resources>
|
||||
|
|
@ -30,7 +30,7 @@
|
|||
<string name="title_activity_story_info_widget_config">Configure Story Info Widget</string>
|
||||
<string name="story_info_widget_unconfigured">Tap to configure</string>
|
||||
<string name="story_info_widget_folio_header">§ %1$s · STORY</string>
|
||||
<string name="story_info_widget_byline">por %1$s</string>
|
||||
<string name="story_info_widget_byline">by %1$s</string>
|
||||
<string name="story_info_widget_words_label">WORDS</string>
|
||||
<string name="story_info_widget_goal_summary">%1$d%% OF %2$s %3$s</string>
|
||||
<string name="story_info_widget_cadence_today">TODAY</string>
|
||||
|
|
|
|||
|
|
@ -1,15 +0,0 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources>
|
||||
<string name="wear_pairing_masthead">PAIR WATCH</string>
|
||||
<string name="wear_pairing_title">Sign in %1$s to Hammer?</string>
|
||||
<string name="wear_pairing_body">The watch will sync as %1$s with a session of its own. You can sign it out from the watch at any time.</string>
|
||||
<string name="wear_pairing_not_signed_in">Sign in to a sync server on this phone first, then pair the watch again.</string>
|
||||
<string name="wear_pairing_approve">Pair</string>
|
||||
<string name="wear_pairing_decline">Rechazar</string>
|
||||
<string name="wear_pairing_close">Cerrar</string>
|
||||
<string name="wear_pairing_toast_success">Watch paired</string>
|
||||
<string name="wear_pairing_toast_failure">The watch could not be paired</string>
|
||||
<string name="wear_pairing_channel_name">Watch pairing</string>
|
||||
<string name="wear_pairing_notification_title">Pair your watch</string>
|
||||
<string name="wear_pairing_notification_text">Tap to let your watch sync with your Hammer account</string>
|
||||
</resources>
|
||||
|
|
@ -25,24 +25,24 @@
|
|||
<string name="note_widget_config_project_checkbox">Ajouter la Note au projet</string>
|
||||
<string name="note_widget_config_project_checkbox_explained">Si aucun projet n\'est sélectionné, on vous demandera de choisir un projet à chaque fois.</string>
|
||||
<string name="stories_list_widget_description">Hammer · Bibliothèque -- Ouvrir une histoire</string>
|
||||
<string name="story_info_widget_description">Hammer · Histoire — Aperçu des statistiques pour un projet</string>
|
||||
<string name="story_info_widget_description">Hammer · Histoire — aperçu des statistiques pour un projet</string>
|
||||
<string name="story_info_widget_config_title">Widget d\'info pour une histoire :</string>
|
||||
<string name="title_activity_story_info_widget_config">Configurer le Widget d\'info pour une histoire</string>
|
||||
<string name="story_info_widget_unconfigured">Appuyez pour configurer</string>
|
||||
<string name="story_info_widget_folio_header">§ %1$s · HISTOIRE</string>
|
||||
<string name="story_info_widget_byline">de %1$s</string>
|
||||
<string name="story_info_widget_words_label">MOTS</string>
|
||||
<string name="story_info_widget_goal_summary">%1$d%% SUR %2$s %3$s</string>
|
||||
<string name="story_info_widget_goal_summary">%1$d%% / %2$s %3$s</string>
|
||||
<string name="story_info_widget_cadence_today">AUJOURD\'HUI</string>
|
||||
<string name="story_info_widget_cadence_this_week">CETTE SEMAINE</string>
|
||||
<string name="story_info_widget_today_words">+%1$d AUJOURD\'HUI</string>
|
||||
<string name="story_info_widget_no_session_today">AUCUNE SESSION AUJOURD\'HUI</string>
|
||||
<string name="story_info_widget_streak">SERIE DE %1$dJ</string>
|
||||
<string name="story_info_widget_sessions_header">SESSIONS · 7J</string>
|
||||
<string name="story_info_widget_week_total">%1$s S</string>
|
||||
<string name="story_info_widget_week_total">%1$s SEM.</string>
|
||||
<string name="story_info_widget_action_open">OUVRIR</string>
|
||||
<string name="story_info_widget_action_note">NOTE</string>
|
||||
<string name="story_info_widget_strip_folio">§ %1$s</string>
|
||||
<string name="story_info_widget_strip_words_unit">M</string>
|
||||
<string name="story_info_widget_continue_label">CONTINUER ·</string>
|
||||
<string name="story_info_widget_continue_label">CONTINUER</string>
|
||||
</resources>
|
||||
|
|
|
|||
|
|
@ -1,15 +0,0 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources>
|
||||
<string name="wear_pairing_masthead">APPAIRER LA MONTRE</string>
|
||||
<string name="wear_pairing_title">Se connecter à %1$s sur Hammer ?</string>
|
||||
<string name="wear_pairing_body">La montre sera synchronisée en tant que %1$s avec sa propre session. Vous pouvez la déconnecter depuis la montre à tout moment.</string>
|
||||
<string name="wear_pairing_not_signed_in">Connectez-vous au serveur sur ce smartphone en premier, puis appairez la montre de nouveau.</string>
|
||||
<string name="wear_pairing_approve">Appairer</string>
|
||||
<string name="wear_pairing_decline">Refuser</string>
|
||||
<string name="wear_pairing_close">Fermer</string>
|
||||
<string name="wear_pairing_toast_success">Montre appairée</string>
|
||||
<string name="wear_pairing_toast_failure">La montre n\'a pas pu être appairée</string>
|
||||
<string name="wear_pairing_channel_name">Appairage de la montre</string>
|
||||
<string name="wear_pairing_notification_title">Appairez votre montre</string>
|
||||
<string name="wear_pairing_notification_text">Tapoter pour que votre montre se synchronise avec votre compte Hammer</string>
|
||||
</resources>
|
||||
|
|
@ -1,48 +1,48 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources>
|
||||
<string name="app_name">Hammer</string>
|
||||
<string name="title_activity_add_note">Aggiungi nota</string>
|
||||
<string name="loading">Caricamento…</string>
|
||||
<string name="title_activity_configure_note_widget">Configura widget</string>
|
||||
<string name="overflow_menu_button">Menu Altro</string>
|
||||
<string name="navdrawer_button">Cassetto di navigazione</string>
|
||||
<string name="note_widget_dialog_title">Aggiungi nota a:</string>
|
||||
<string name="note_widget_dialog_save_button">Salva</string>
|
||||
<string name="note_widget_dialog_cancel_button">Annulla</string>
|
||||
<string name="note_widget_dialog_projects_dropdown">Progetti</string>
|
||||
<string name="note_widget_dialog_failure_bad_project">Progetto non trovato: %1$s</string>
|
||||
<string name="note_widget_confirm_cancel_title">Scartare la nota?</string>
|
||||
<string name="note_widget_confirm_cancel_positive">Scarta</string>
|
||||
<string name="note_widget_confirm_cancel_negative">Annulla</string>
|
||||
<string name="note_widget_button_description">Aggiungi nota</string>
|
||||
<string name="note_widget_toast_no_projects">Nessun progetto</string>
|
||||
<string name="note_widget_toast_success">Nota aggiunta</string>
|
||||
<string name="action_add_note_short_label">Aggiungi nota</string>
|
||||
<string name="action_add_note_long_label">Aggiungi nota alla storia</string>
|
||||
<string name="action_add_note_disabled">L\'aggiunta di note è disabilitata al momento</string>
|
||||
<string name="title_activity_add_note_widget_config">Configura widget</string>
|
||||
<string name="note_widget_config_title">Configura widget note:</string>
|
||||
<string name="note_widget_config_project_checkbox">Aggiungi note al progetto specificato</string>
|
||||
<string name="note_widget_config_project_checkbox_explained">Se non viene selezionato alcun progetto, ti verrà chiesto di sceglierne uno ogni volta.</string>
|
||||
<string name="stories_list_widget_description">Hammer · Libreria — apri una storia</string>
|
||||
<string name="story_info_widget_description">Hammer · Storia — statistiche rapide per un progetto</string>
|
||||
<string name="story_info_widget_config_title">Widget info storia:</string>
|
||||
<string name="title_activity_story_info_widget_config">Configura widget info storia</string>
|
||||
<string name="story_info_widget_unconfigured">Tocca per configurare</string>
|
||||
<string name="story_info_widget_folio_header">§ %1$s · STORIA</string>
|
||||
<string name="story_info_widget_byline">di %1$s</string>
|
||||
<string name="story_info_widget_words_label">PAROLE</string>
|
||||
<string name="story_info_widget_goal_summary">%1$d%% DI %2$s %3$s</string>
|
||||
<string name="story_info_widget_cadence_today">OGGI</string>
|
||||
<string name="story_info_widget_cadence_this_week">QUESTA SETTIMANA</string>
|
||||
<string name="story_info_widget_today_words">+%1$d OGGI</string>
|
||||
<string name="story_info_widget_no_session_today">NESSUNA SESSIONE OGGI</string>
|
||||
<string name="story_info_widget_streak">SERIE DI %1$dG</string>
|
||||
<string name="story_info_widget_sessions_header">SESSIONI · 7G</string>
|
||||
<string name="story_info_widget_week_total">%1$s P</string>
|
||||
<string name="story_info_widget_action_open">APRI</string>
|
||||
<string name="story_info_widget_action_note">NOTA</string>
|
||||
<string name="title_activity_add_note">Add Note</string>
|
||||
<string name="loading">Loading…</string>
|
||||
<string name="title_activity_configure_note_widget">Configure Widget</string>
|
||||
<string name="overflow_menu_button">More Menu</string>
|
||||
<string name="navdrawer_button">Navigation Drawer</string>
|
||||
<string name="note_widget_dialog_title">Add Note to:</string>
|
||||
<string name="note_widget_dialog_save_button">Save</string>
|
||||
<string name="note_widget_dialog_cancel_button">Cancel</string>
|
||||
<string name="note_widget_dialog_projects_dropdown">Projects</string>
|
||||
<string name="note_widget_dialog_failure_bad_project">Project not found: %1$s</string>
|
||||
<string name="note_widget_confirm_cancel_title">Discard Note?</string>
|
||||
<string name="note_widget_confirm_cancel_positive">Discard</string>
|
||||
<string name="note_widget_confirm_cancel_negative">Cancel</string>
|
||||
<string name="note_widget_button_description">Add Note</string>
|
||||
<string name="note_widget_toast_no_projects">No projects</string>
|
||||
<string name="note_widget_toast_success">Note Added</string>
|
||||
<string name="action_add_note_short_label">Add Note</string>
|
||||
<string name="action_add_note_long_label">Add Story Note</string>
|
||||
<string name="action_add_note_disabled">Adding notes is disabled right now</string>
|
||||
<string name="title_activity_add_note_widget_config">Configure Widget</string>
|
||||
<string name="note_widget_config_title">Configure Note Widget:</string>
|
||||
<string name="note_widget_config_project_checkbox">Add notes to specified project</string>
|
||||
<string name="note_widget_config_project_checkbox_explained">If no project is selected, you will be asked to choose a project each time.</string>
|
||||
<string name="stories_list_widget_description">Hammer · Library — open a story</string>
|
||||
<string name="story_info_widget_description">Hammer · Story — at-a-glance stats for one project</string>
|
||||
<string name="story_info_widget_config_title">Story Info Widget:</string>
|
||||
<string name="title_activity_story_info_widget_config">Configure Story Info Widget</string>
|
||||
<string name="story_info_widget_unconfigured">Tap to configure</string>
|
||||
<string name="story_info_widget_folio_header">§ %1$s · STORY</string>
|
||||
<string name="story_info_widget_byline">by %1$s</string>
|
||||
<string name="story_info_widget_words_label">WORDS</string>
|
||||
<string name="story_info_widget_goal_summary">%1$d%% OF %2$s %3$s</string>
|
||||
<string name="story_info_widget_cadence_today">TODAY</string>
|
||||
<string name="story_info_widget_cadence_this_week">THIS WEEK</string>
|
||||
<string name="story_info_widget_today_words">+%1$d TODAY</string>
|
||||
<string name="story_info_widget_no_session_today">NO SESSION TODAY</string>
|
||||
<string name="story_info_widget_streak">%1$dD STREAK</string>
|
||||
<string name="story_info_widget_sessions_header">SESSIONS · 7D</string>
|
||||
<string name="story_info_widget_week_total">%1$s W</string>
|
||||
<string name="story_info_widget_action_open">OPEN</string>
|
||||
<string name="story_info_widget_action_note">NOTE</string>
|
||||
<string name="story_info_widget_strip_folio">§ %1$s</string>
|
||||
<string name="story_info_widget_strip_words_unit">P</string>
|
||||
<string name="story_info_widget_continue_label">CONTINUA ·</string>
|
||||
<string name="story_info_widget_strip_words_unit">W</string>
|
||||
<string name="story_info_widget_continue_label">CONTINUE ·</string>
|
||||
</resources>
|
||||
|
|
|
|||
|
|
@ -1,15 +0,0 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources>
|
||||
<string name="wear_pairing_masthead">ASSOCIA OROLOGIO</string>
|
||||
<string name="wear_pairing_title">Accedere come %1$s su Hammer?</string>
|
||||
<string name="wear_pairing_body">L’orologio si sincronizzerà come %1$s con una propria sessione. Puoi disconnetterlo dall’orologio in qualsiasi momento.</string>
|
||||
<string name="wear_pairing_not_signed_in">Accedi prima a un server di sincronizzazione su questo telefono, poi associa nuovamente l’orologio.</string>
|
||||
<string name="wear_pairing_approve">Associa</string>
|
||||
<string name="wear_pairing_decline">Rifiuta</string>
|
||||
<string name="wear_pairing_close">Chiudi</string>
|
||||
<string name="wear_pairing_toast_success">Orologio associato</string>
|
||||
<string name="wear_pairing_toast_failure">Impossibile associare l’orologio</string>
|
||||
<string name="wear_pairing_channel_name">Associazione orologio</string>
|
||||
<string name="wear_pairing_notification_title">Associa il tuo orologio</string>
|
||||
<string name="wear_pairing_notification_text">Tocca per consentire all’orologio di sincronizzarsi con il tuo account Hammer</string>
|
||||
</resources>
|
||||
|
|
@ -30,7 +30,7 @@
|
|||
<string name="title_activity_story_info_widget_config">Configure Story Info Widget</string>
|
||||
<string name="story_info_widget_unconfigured">Tap to configure</string>
|
||||
<string name="story_info_widget_folio_header">§ %1$s · STORY</string>
|
||||
<string name="story_info_widget_byline">por %1$s</string>
|
||||
<string name="story_info_widget_byline">by %1$s</string>
|
||||
<string name="story_info_widget_words_label">WORDS</string>
|
||||
<string name="story_info_widget_goal_summary">%1$d%% OF %2$s %3$s</string>
|
||||
<string name="story_info_widget_cadence_today">TODAY</string>
|
||||
|
|
|
|||
|
|
@ -1,15 +0,0 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources>
|
||||
<string name="wear_pairing_masthead">PAIR WATCH</string>
|
||||
<string name="wear_pairing_title">Sign in %1$s to Hammer?</string>
|
||||
<string name="wear_pairing_body">The watch will sync as %1$s with a session of its own. You can sign it out from the watch at any time.</string>
|
||||
<string name="wear_pairing_not_signed_in">Sign in to a sync server on this phone first, then pair the watch again.</string>
|
||||
<string name="wear_pairing_approve">Pair</string>
|
||||
<string name="wear_pairing_decline">Recusar</string>
|
||||
<string name="wear_pairing_close">Fechar</string>
|
||||
<string name="wear_pairing_toast_success">Watch paired</string>
|
||||
<string name="wear_pairing_toast_failure">The watch could not be paired</string>
|
||||
<string name="wear_pairing_channel_name">Watch pairing</string>
|
||||
<string name="wear_pairing_notification_title">Pair your watch</string>
|
||||
<string name="wear_pairing_notification_text">Tap to let your watch sync with your Hammer account</string>
|
||||
</resources>
|
||||
|
|
@ -1,48 +0,0 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources>
|
||||
<string name="app_name">Hammer</string>
|
||||
<string name="title_activity_add_note">Not Ekle</string>
|
||||
<string name="loading">Yükleniyor…</string>
|
||||
<string name="title_activity_configure_note_widget">Widget\'ı Yapılandır</string>
|
||||
<string name="overflow_menu_button">Diğer Menüler</string>
|
||||
<string name="navdrawer_button">Gezinti Çekmecesi</string>
|
||||
<string name="note_widget_dialog_title">Notu Şuraya Ekle:</string>
|
||||
<string name="note_widget_dialog_save_button">Kaydet</string>
|
||||
<string name="note_widget_dialog_cancel_button">İptal</string>
|
||||
<string name="note_widget_dialog_projects_dropdown">Projeler</string>
|
||||
<string name="note_widget_dialog_failure_bad_project">Proje bulunamadı: %1$s</string>
|
||||
<string name="note_widget_confirm_cancel_title">Nottan Vazgeçilsin mi?</string>
|
||||
<string name="note_widget_confirm_cancel_positive">Vazgeç</string>
|
||||
<string name="note_widget_confirm_cancel_negative">İptal</string>
|
||||
<string name="note_widget_button_description">Not Ekle</string>
|
||||
<string name="note_widget_toast_no_projects">Proje yok</string>
|
||||
<string name="note_widget_toast_success">Not Eklendi</string>
|
||||
<string name="action_add_note_short_label">Not Ekle</string>
|
||||
<string name="action_add_note_long_label">Hikâye Notu Ekle</string>
|
||||
<string name="action_add_note_disabled">Not ekleme şu anda devre dışı</string>
|
||||
<string name="title_activity_add_note_widget_config">Widget\'ı Yapılandır</string>
|
||||
<string name="note_widget_config_title">Not Widget\'ını yapılandır:</string>
|
||||
<string name="note_widget_config_project_checkbox">Notları belirtilen projeye ekle</string>
|
||||
<string name="note_widget_config_project_checkbox_explained">Eğer seçili bir proje yoksa her seferinde bir proje seçmen istenir.</string>
|
||||
<string name="stories_list_widget_description">Hammer · Kütüphane — bir hikâyeyi aç</string>
|
||||
<string name="story_info_widget_description">Hammer · Hikâye — bir proje için özet istatistikler</string>
|
||||
<string name="story_info_widget_config_title">Hikâye Bilgisi Widget\'ı:</string>
|
||||
<string name="title_activity_story_info_widget_config">Hikâye Bilgisi Widget\'ını Yapılandır</string>
|
||||
<string name="story_info_widget_unconfigured">Yapılandırmak için dokun</string>
|
||||
<string name="story_info_widget_folio_header">§ %1$s · HİKÂYE</string>
|
||||
<string name="story_info_widget_byline">- %1$s</string>
|
||||
<string name="story_info_widget_words_label">WORDS</string>
|
||||
<string name="story_info_widget_goal_summary">%1$d%% OF %2$s %3$s</string>
|
||||
<string name="story_info_widget_cadence_today">TODAY</string>
|
||||
<string name="story_info_widget_cadence_this_week">THIS WEEK</string>
|
||||
<string name="story_info_widget_today_words">+%1$d TODAY</string>
|
||||
<string name="story_info_widget_no_session_today">NO SESSION TODAY</string>
|
||||
<string name="story_info_widget_streak">%1$dD STREAK</string>
|
||||
<string name="story_info_widget_sessions_header">SESSIONS · 7D</string>
|
||||
<string name="story_info_widget_week_total">%1$s W</string>
|
||||
<string name="story_info_widget_action_open">OPEN</string>
|
||||
<string name="story_info_widget_action_note">NOTE</string>
|
||||
<string name="story_info_widget_strip_folio">§ %1$s</string>
|
||||
<string name="story_info_widget_strip_words_unit">W</string>
|
||||
<string name="story_info_widget_continue_label">CONTINUE ·</string>
|
||||
</resources>
|
||||
|
|
@ -1,15 +0,0 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources>
|
||||
<string name="wear_pairing_masthead">PAIR WATCH</string>
|
||||
<string name="wear_pairing_title">Sign in %1$s to Hammer?</string>
|
||||
<string name="wear_pairing_body">The watch will sync as %1$s with a session of its own. You can sign it out from the watch at any time.</string>
|
||||
<string name="wear_pairing_not_signed_in">Sign in to a sync server on this phone first, then pair the watch again.</string>
|
||||
<string name="wear_pairing_approve">Pair</string>
|
||||
<string name="wear_pairing_decline">Decline</string>
|
||||
<string name="wear_pairing_close">Close</string>
|
||||
<string name="wear_pairing_toast_success">Watch paired</string>
|
||||
<string name="wear_pairing_toast_failure">The watch could not be paired</string>
|
||||
<string name="wear_pairing_channel_name">Watch pairing</string>
|
||||
<string name="wear_pairing_notification_title">Pair your watch</string>
|
||||
<string name="wear_pairing_notification_text">Tap to let your watch sync with your Hammer account</string>
|
||||
</resources>
|
||||
|
|
@ -7,13 +7,13 @@
|
|||
<string name="overflow_menu_button">More Menu</string>
|
||||
<string name="navdrawer_button">Navigation Drawer</string>
|
||||
<string name="note_widget_dialog_title">Add Note to:</string>
|
||||
<string name="note_widget_dialog_save_button">Зберегти</string>
|
||||
<string name="note_widget_dialog_cancel_button">Скасувати</string>
|
||||
<string name="note_widget_dialog_projects_dropdown">Проєкти</string>
|
||||
<string name="note_widget_dialog_save_button">Save</string>
|
||||
<string name="note_widget_dialog_cancel_button">Cancel</string>
|
||||
<string name="note_widget_dialog_projects_dropdown">Projects</string>
|
||||
<string name="note_widget_dialog_failure_bad_project">Project not found: %1$s</string>
|
||||
<string name="note_widget_confirm_cancel_title">Discard Note?</string>
|
||||
<string name="note_widget_confirm_cancel_positive">Відхилити</string>
|
||||
<string name="note_widget_confirm_cancel_negative">Скасувати</string>
|
||||
<string name="note_widget_confirm_cancel_positive">Discard</string>
|
||||
<string name="note_widget_confirm_cancel_negative">Cancel</string>
|
||||
<string name="note_widget_button_description">Add Note</string>
|
||||
<string name="note_widget_toast_no_projects">No projects</string>
|
||||
<string name="note_widget_toast_success">Note Added</string>
|
||||
|
|
@ -30,7 +30,7 @@
|
|||
<string name="title_activity_story_info_widget_config">Configure Story Info Widget</string>
|
||||
<string name="story_info_widget_unconfigured">Tap to configure</string>
|
||||
<string name="story_info_widget_folio_header">§ %1$s · STORY</string>
|
||||
<string name="story_info_widget_byline">автор: %1$s</string>
|
||||
<string name="story_info_widget_byline">by %1$s</string>
|
||||
<string name="story_info_widget_words_label">WORDS</string>
|
||||
<string name="story_info_widget_goal_summary">%1$d%% OF %2$s %3$s</string>
|
||||
<string name="story_info_widget_cadence_today">TODAY</string>
|
||||
|
|
|
|||
|
|
@ -1,15 +0,0 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources>
|
||||
<string name="wear_pairing_masthead">PAIR WATCH</string>
|
||||
<string name="wear_pairing_title">Sign in %1$s to Hammer?</string>
|
||||
<string name="wear_pairing_body">The watch will sync as %1$s with a session of its own. You can sign it out from the watch at any time.</string>
|
||||
<string name="wear_pairing_not_signed_in">Sign in to a sync server on this phone first, then pair the watch again.</string>
|
||||
<string name="wear_pairing_approve">Pair</string>
|
||||
<string name="wear_pairing_decline">Відхилити</string>
|
||||
<string name="wear_pairing_close">Закрити</string>
|
||||
<string name="wear_pairing_toast_success">Watch paired</string>
|
||||
<string name="wear_pairing_toast_failure">The watch could not be paired</string>
|
||||
<string name="wear_pairing_channel_name">Watch pairing</string>
|
||||
<string name="wear_pairing_notification_title">Pair your watch</string>
|
||||
<string name="wear_pairing_notification_text">Tap to let your watch sync with your Hammer account</string>
|
||||
</resources>
|
||||
|
|
@ -1,15 +0,0 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources>
|
||||
<string name="wear_pairing_masthead">PAIR WATCH</string>
|
||||
<string name="wear_pairing_title">Sign in %1$s to Hammer?</string>
|
||||
<string name="wear_pairing_body">The watch will sync as %1$s with a session of its own. You can sign it out from the watch at any time.</string>
|
||||
<string name="wear_pairing_not_signed_in">Sign in to a sync server on this phone first, then pair the watch again.</string>
|
||||
<string name="wear_pairing_approve">Pair</string>
|
||||
<string name="wear_pairing_decline">Decline</string>
|
||||
<string name="wear_pairing_close">Close</string>
|
||||
<string name="wear_pairing_toast_success">Watch paired</string>
|
||||
<string name="wear_pairing_toast_failure">The watch could not be paired</string>
|
||||
<string name="wear_pairing_channel_name">Watch pairing</string>
|
||||
<string name="wear_pairing_notification_title">Pair your watch</string>
|
||||
<string name="wear_pairing_notification_text">Tap to let your watch sync with your Hammer account</string>
|
||||
</resources>
|
||||
|
|
@ -2,10 +2,4 @@
|
|||
<resources>
|
||||
<style name="Theme.Hammer" parent="Theme.AppCompat.DayNight.NoActionBar">
|
||||
</style>
|
||||
|
||||
<style name="Theme.Hammer.Starting" parent="Theme.SplashScreen">
|
||||
<item name="windowSplashScreenBackground">@color/ic_launcher_background</item>
|
||||
<item name="windowSplashScreenAnimatedIcon">@mipmap/ic_launcher_foreground</item>
|
||||
<item name="postSplashScreenTheme">@style/Theme.Hammer</item>
|
||||
</style>
|
||||
</resources>
|
||||
</resources>
|
||||
|
|
@ -3,5 +3,4 @@
|
|||
<exclude domain="file" path="HammerProjects/server.json"/>
|
||||
<exclude domain="file" path="HammerProjects/sync.json"/>
|
||||
<exclude domain="file" path="HammerProjects/.backups"/>
|
||||
<exclude domain="sharedpref" path="hammer_auth_tokens.xml"/>
|
||||
</full-backup-content>
|
||||
|
|
|
|||
|
|
@ -4,12 +4,10 @@
|
|||
<exclude domain="file" path="HammerProjects/server.json"/>
|
||||
<exclude domain="file" path="HammerProjects/sync.json"/>
|
||||
<exclude domain="file" path="HammerProjects/.backups"/>
|
||||
<exclude domain="sharedpref" path="hammer_auth_tokens.xml"/>
|
||||
</cloud-backup>
|
||||
<device-transfer>
|
||||
<exclude domain="file" path="HammerProjects/server.json"/>
|
||||
<exclude domain="file" path="HammerProjects/sync.json"/>
|
||||
<exclude domain="file" path="HammerProjects/.backups"/>
|
||||
<exclude domain="sharedpref" path="hammer_auth_tokens.xml"/>
|
||||
</device-transfer>
|
||||
</data-extraction-rules>
|
||||
|
|
|
|||
|
|
@ -1,5 +0,0 @@
|
|||
package com.darkrockstudios.apps.hammer.android
|
||||
|
||||
import org.koin.core.module.Module
|
||||
|
||||
val playServicesModules: List<Module> = emptyList()
|
||||
|
|
@ -1,100 +0,0 @@
|
|||
package com.darkrockstudios.apps.hammer.android.wear
|
||||
|
||||
import com.darkrockstudios.apps.hammer.common.data.CResult
|
||||
import com.darkrockstudios.apps.hammer.common.data.globalsettings.ServerSettings
|
||||
import com.darkrockstudios.apps.hammer.common.data.pairing.PairErrorCode
|
||||
import com.darkrockstudios.apps.hammer.common.data.pairing.PairRequest
|
||||
import com.darkrockstudios.apps.hammer.common.data.pairing.PairResponse
|
||||
import kotlinx.coroutines.test.runTest
|
||||
import org.junit.jupiter.api.Assertions.assertEquals
|
||||
import org.junit.jupiter.api.Test
|
||||
|
||||
class WearPairingHandlerTest {
|
||||
|
||||
private class RecordingResponder : PairingResponder {
|
||||
val sent = mutableListOf<Pair<String, PairResponse>>()
|
||||
|
||||
override suspend fun respond(nodeId: String, response: PairResponse) {
|
||||
sent += nodeId to response
|
||||
}
|
||||
}
|
||||
|
||||
private val phoneSettings = ServerSettings(
|
||||
url = "hammer.ink",
|
||||
email = "writer@example.com",
|
||||
userId = 7,
|
||||
bearerToken = "phone-auth",
|
||||
refreshToken = "phone-refresh",
|
||||
)
|
||||
private val watchSettings = phoneSettings.copy(bearerToken = "watch-auth", refreshToken = "watch-refresh")
|
||||
private val request = PairRequest(requestId = "req-1", installId = "watch-install", deviceLabel = "Pixel Watch")
|
||||
|
||||
private val responder = RecordingResponder()
|
||||
private val pairedInstalls = mutableListOf<String>()
|
||||
|
||||
private fun handler(
|
||||
settings: ServerSettings? = phoneSettings,
|
||||
pairResult: CResult<ServerSettings> = CResult.success(watchSettings),
|
||||
) = WearPairingHandler(
|
||||
serverSettings = { settings },
|
||||
pairInstall = { installId ->
|
||||
pairedInstalls += installId
|
||||
pairResult
|
||||
},
|
||||
responder = responder,
|
||||
)
|
||||
|
||||
@Test
|
||||
fun `approving mints a session for the watch's install and sends it back`() = runTest {
|
||||
val response = handler().approve("watch-node", request)
|
||||
|
||||
assertEquals(PairResponse.Success("req-1", watchSettings), response)
|
||||
assertEquals(listOf("watch-install"), pairedInstalls)
|
||||
assertEquals(listOf("watch-node" to response), responder.sent)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a phone that is not signed in tells the watch so without calling the server`() = runTest {
|
||||
val response = handler(settings = null).approve("watch-node", request)
|
||||
|
||||
assertEquals(PairResponse.Error("req-1", PairErrorCode.NotSignedIn), response)
|
||||
assertEquals(emptyList<String>(), pairedInstalls)
|
||||
assertEquals(listOf("watch-node" to response), responder.sent)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a server rejection is reported to the watch`() = runTest {
|
||||
val response = handler(pairResult = CResult.failure(error = "nope")).approve("watch-node", request)
|
||||
|
||||
assertEquals(PairResponse.Error("req-1", PairErrorCode.ServerRejected), response)
|
||||
assertEquals(listOf("watch-node" to response), responder.sent)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `declining tells the watch without minting anything`() = runTest {
|
||||
val response = handler().decline("watch-node", request)
|
||||
|
||||
assertEquals(PairResponse.Error("req-1", PairErrorCode.Declined), response)
|
||||
assertEquals(emptyList<String>(), pairedInstalls)
|
||||
assertEquals(listOf("watch-node" to response), responder.sent)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a phone that cannot raise the prompt tells the watch rather than going quiet`() = runTest {
|
||||
handler().reportUnavailable("watch-node", request)
|
||||
|
||||
assertEquals(
|
||||
listOf("watch-node" to PairResponse.Error("req-1", PairErrorCode.PhoneUnavailable)),
|
||||
responder.sent,
|
||||
)
|
||||
assertEquals(emptyList<String>(), pairedInstalls)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a request from a newer protocol version is refused`() = runTest {
|
||||
val response = handler().approve("watch-node", request.copy(version = 99))
|
||||
|
||||
assertEquals(PairResponse.Error("req-1", PairErrorCode.Unsupported), response)
|
||||
assertEquals(emptyList<String>(), pairedInstalls)
|
||||
}
|
||||
}
|
||||
|
|
@ -1,5 +1,3 @@
|
|||
import com.darkrockstudios.build.distributionChannel
|
||||
import com.darkrockstudios.build.packageFormat
|
||||
import org.jetbrains.kotlin.gradle.dsl.JvmTarget
|
||||
|
||||
plugins {
|
||||
|
|
@ -49,6 +47,7 @@ kotlin {
|
|||
api(libs.koin.core)
|
||||
implementation(libs.serialization.core)
|
||||
implementation(libs.coroutines.core)
|
||||
implementation(libs.kotlinx.datetime)
|
||||
implementation(libs.serialization.json)
|
||||
//implementation("org.kotlincrypto.endians:endians:0.1.0")
|
||||
//api("io.getstream:stream-result:1.1.0")
|
||||
|
|
@ -78,9 +77,6 @@ buildConfig {
|
|||
|
||||
buildConfigField("String", "APP_VERSION", "\"${libs.versions.app.get()}\"")
|
||||
buildConfigField("String", "DATA_VERSION", "\"${libs.versions.data.version.get()}\"")
|
||||
|
||||
buildConfigField("String", "CHANNEL", "\"${project.distributionChannel().token}\"")
|
||||
buildConfigField("String", "PACKAGE_FORMAT", "\"${project.packageFormat().token}\"")
|
||||
}
|
||||
|
||||
val GIT_TASK_NAME = "install-git-hooks"
|
||||
|
|
|
|||
|
|
@ -1,45 +0,0 @@
|
|||
package com.darkrockstudios.apps.hammer.base
|
||||
|
||||
/**
|
||||
* The distribution vehicle this build was produced for, baked in at build time from
|
||||
* `-Pchannel=<token>`. Branch on [current] for the rules that vary per vehicle: self-update
|
||||
* (forbidden in the app stores, expected for the GitHub and AppImage builds), where "get the app"
|
||||
* links point, external-payment policy, and sandbox-aware path resolution.
|
||||
*
|
||||
* Deliberately a flat enum rather than a set of capability flags. Every branch is a greppable
|
||||
* `when` on this type, so each channel-conditional behaviour is findable in one search; flags can
|
||||
* be added later if two channels start sharing a trait and branches begin to duplicate.
|
||||
*
|
||||
* Because every branch compiles into every build, a store-forbidden code path is physically
|
||||
* present in a store binary even when unreachable. If a store ever objects to presence rather
|
||||
* than behaviour, that is the trigger to promote that one case to a real source set.
|
||||
*
|
||||
* Kept in step with the build-side enum in `buildSrc/src/main/java/distributionChannel.kt`.
|
||||
*/
|
||||
enum class DistributionChannel(val token: String, val displayName: String) {
|
||||
DEV("dev", "Development"),
|
||||
GITHUB("github", "GitHub"),
|
||||
PLAY("google-play", "Google Play"),
|
||||
FDROID("fdroid", "F-Droid"),
|
||||
SNAP("snap", "Snap"),
|
||||
APPIMAGE("appimage", "AppImage"),
|
||||
FLATHUB("flathub", "Flathub"),
|
||||
MICROSOFT_STORE("ms-store", "Microsoft Store"),
|
||||
MAC_APP_STORE("mac-app-store", "Mac App Store"),
|
||||
IOS_APP_STORE("ios-app-store", "iOS App Store"),
|
||||
;
|
||||
|
||||
companion object {
|
||||
/**
|
||||
* An unrecognised token falls back to [DEV] rather than throwing: the build validates the
|
||||
* token, so reaching here with an unknown one means something is already wrong and taking
|
||||
* down the app over a log-header string would be worse.
|
||||
*/
|
||||
val current: DistributionChannel =
|
||||
entries.firstOrNull { it.token == BuildMetadata.CHANNEL } ?: DEV
|
||||
|
||||
/** The sandboxed, App Review facing desktop build. */
|
||||
val isMacAppStore: Boolean
|
||||
get() = current == MAC_APP_STORE
|
||||
}
|
||||
}
|
||||
|
|
@ -1,30 +0,0 @@
|
|||
package com.darkrockstudios.apps.hammer.base
|
||||
|
||||
/**
|
||||
* The installer format this build was packaged as, baked in at build time from `-Pformat=<token>`.
|
||||
* The second axis next to [DistributionChannel]: the channel says which vehicle the build is for
|
||||
* and whether it may update itself, the format says which installer artifact it came from. Dev
|
||||
* runs and anything built without a packaging task report [NONE].
|
||||
*
|
||||
* Kept in step with the build-side enum in `buildSrc/src/main/java/packageFormat.kt`.
|
||||
*/
|
||||
enum class PackageFormat(val token: String) {
|
||||
NONE("none"),
|
||||
MSI("msi"),
|
||||
EXE("exe"),
|
||||
DEB("deb"),
|
||||
RPM("rpm"),
|
||||
DMG("dmg"),
|
||||
PKG("pkg"),
|
||||
APPIMAGE("appimage"),
|
||||
MSIX("msix"),
|
||||
SNAP("snap"),
|
||||
FLATPAK("flatpak"),
|
||||
;
|
||||
|
||||
companion object {
|
||||
/** An unrecognised token falls back to [NONE] for the same reason [DistributionChannel.current] falls back to DEV. */
|
||||
val current: PackageFormat =
|
||||
entries.firstOrNull { it.token == BuildMetadata.PACKAGE_FORMAT } ?: NONE
|
||||
}
|
||||
}
|
||||
|
|
@ -1,8 +1,7 @@
|
|||
package com.darkrockstudios.apps.hammer.base
|
||||
|
||||
const val GITHUB_URL = "https://github.com/Darkrock-Studios/hammer-editor/"
|
||||
const val RELEASES_LATEST_URL = "${GITHUB_URL}releases/latest"
|
||||
const val DISCORD_URL = "https://discord.gg/49Kj5mMj6d"
|
||||
const val DISCORD_URL = "https://discord.gg/GTmgjZcupk"
|
||||
const val REDDIT_URL = "https://www.reddit.com/r/hammereditor/"
|
||||
const val VERSION_CHECK_URL = "https://api.github.com/repos/Darkrock-Studios/hammer-editor/releases/latest"
|
||||
const val HAMMER_INK_URL = "https://hammer.ink/"
|
||||
|
|
|
|||
|
|
@ -99,7 +99,6 @@ sealed interface ApiProjectEntity {
|
|||
val tags: Set<String>,
|
||||
val image: Image?,
|
||||
val aliases: List<String> = emptyList(),
|
||||
val excludeFromDictionary: Boolean = false,
|
||||
) : ApiProjectEntity {
|
||||
@Serializable
|
||||
data class Image(
|
||||
|
|
@ -115,7 +114,6 @@ sealed interface ApiProjectEntity {
|
|||
tags = tags,
|
||||
image = image,
|
||||
aliases = aliases,
|
||||
excludeFromDictionary = excludeFromDictionary,
|
||||
)
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -8,30 +8,6 @@ data class HttpResponseError(
|
|||
val error: String,
|
||||
/** A translated message to display to the user */
|
||||
val displayMessage: String,
|
||||
/**
|
||||
* Stable, machine-readable reason from [ApiErrorCode]. Null when the server predates
|
||||
* error codes, or when the failure has no code worth branching on.
|
||||
*/
|
||||
val errorCode: String? = null,
|
||||
) {
|
||||
override fun toString() = displayMessage
|
||||
}
|
||||
|
||||
/**
|
||||
* Reasons a request failed, stable across releases and independent of the translated
|
||||
* [HttpResponseError.displayMessage]. Plain strings rather than an enum so an unknown
|
||||
* value from a newer server deserializes instead of throwing.
|
||||
*/
|
||||
object ApiErrorCode {
|
||||
/** Wrong password, or no such account. Deliberately one code: see AccountsRepository.login. */
|
||||
const val INVALID_CREDENTIALS = "invalid_credentials"
|
||||
const val ACCOUNT_EXISTS = "account_exists"
|
||||
const val ACCOUNT_PENDING_DELETION = "account_pending_deletion"
|
||||
const val INVALID_EMAIL = "invalid_email"
|
||||
// Wire error codes, not secrets.
|
||||
const val PASSWORD_TOO_SHORT = "password_too_short" // nosemgrep: generic.secrets.gitleaks.hashicorp-tf-password.hashicorp-tf-password
|
||||
const val PASSWORD_TOO_LONG = "password_too_long" // nosemgrep: generic.secrets.gitleaks.hashicorp-tf-password.hashicorp-tf-password
|
||||
const val PASSWORD_INVALID = "password_invalid" // nosemgrep: generic.secrets.gitleaks.hashicorp-tf-password.hashicorp-tf-password
|
||||
const val NOT_WHITELISTED = "not_whitelisted"
|
||||
const val TOKEN_INVALID = "token_invalid"
|
||||
}
|
||||
|
|
@ -2,9 +2,7 @@ package com.darkrockstudios.apps.hammer.base.http
|
|||
|
||||
import kotlinx.serialization.ExperimentalSerializationApi
|
||||
import kotlinx.serialization.json.Json
|
||||
import org.koin.core.qualifier.named
|
||||
|
||||
/** For human-facing files on disk: indented, and forgiving of hand edits. */
|
||||
@OptIn(ExperimentalSerializationApi::class)
|
||||
fun createJsonSerializer(): Json {
|
||||
return Json {
|
||||
|
|
@ -15,22 +13,4 @@ fun createJsonSerializer(): Json {
|
|||
allowTrailingComma = true
|
||||
ignoreUnknownKeys = true
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* For machine-facing content on the wire. Matches Ktor's `DefaultJson` plus `ignoreUnknownKeys`,
|
||||
* which the sync protocol depends on: synced models gain fields without a protocol bump, so a peer
|
||||
* must drop fields a newer build wrote rather than failing the decode. See `SYNCING-PROTOCOL.md`.
|
||||
*/
|
||||
fun createNetworkJsonSerializer(): Json {
|
||||
return Json {
|
||||
encodeDefaults = true
|
||||
isLenient = true
|
||||
allowSpecialFloatingPointValues = true
|
||||
allowStructuredMapKeys = true
|
||||
ignoreUnknownKeys = true
|
||||
}
|
||||
}
|
||||
|
||||
/** Resolves [createNetworkJsonSerializer]; an unqualified `Json` is the file serializer. */
|
||||
val NetworkJsonQualifier = named("networkJson")
|
||||
}
|
||||
|
|
@ -9,12 +9,6 @@ data class ProjectData(
|
|||
val wordCountGoal: WordCountGoal? = null,
|
||||
/** Picked as a unit during conflict resolution — the whole set comes from one side, never a merge. */
|
||||
val tags: Set<String> = emptySet(),
|
||||
/** BCP-47 tag of the language the project is written in (e.g. "en", "pt-BR"); null when unset. */
|
||||
val language: String? = null,
|
||||
/** Whether Encyclopedia entry names feed the spell-check session dictionary for this project. */
|
||||
val encyclopediaDictionary: Boolean = true,
|
||||
/** User-added spell-check words. Unlike [tags], a conflict merges both sides by union. */
|
||||
val dictionaryWords: Set<String> = emptySet(),
|
||||
)
|
||||
|
||||
/** Picked as a unit during conflict resolution — one device's primary paired with another's secondary is undesigned. */
|
||||
|
|
|
|||
|
|
@ -99,7 +99,6 @@ object EntityHasher {
|
|||
tags: Set<String>,
|
||||
image: ApiProjectEntity.EncyclopediaEntryEntity.Image?,
|
||||
aliases: List<String>,
|
||||
excludeFromDictionary: Boolean = false,
|
||||
): String {
|
||||
val buf = buff()
|
||||
val d = Algorithm.MurmurHash3_X64_128().createDigest()
|
||||
|
|
@ -122,11 +121,6 @@ object EntityHasher {
|
|||
d.update(image.fileExtension, buf)
|
||||
}
|
||||
|
||||
// Zero bytes at the default (false): pre-existing hashes must stay identical.
|
||||
if (excludeFromDictionary) {
|
||||
d.update(1, buf)
|
||||
}
|
||||
|
||||
return d.digest().base64Url
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -46,34 +46,6 @@ object ProjectDataHasher {
|
|||
data.tags.sorted().forEach { tag -> d.update(tag, buf) }
|
||||
}
|
||||
|
||||
// Zero bytes when null, same rule as tags: pre-language hashes must stay identical.
|
||||
// The -1 marker can never be a tags size and the length prefix bounds the string,
|
||||
// so a language block cannot be read as a continuation of the tags block
|
||||
// (tags=["x"] and language="x" must not collide).
|
||||
if (data.language != null) {
|
||||
d.update(-1, buf)
|
||||
d.update(data.language.length, buf)
|
||||
d.update(data.language, buf)
|
||||
}
|
||||
|
||||
// Zero bytes at the default (true): pre-existing hashes must stay identical.
|
||||
// -2 marker: -1 is taken by the language block.
|
||||
if (!data.encyclopediaDictionary) {
|
||||
d.update(-2, buf)
|
||||
}
|
||||
|
||||
// Zero bytes when empty, same rule as tags. -3 marker: -1 and -2 are taken. Each word
|
||||
// carries its length: update(String) writes bare char codes, so without it
|
||||
// {"a","bc"} and {"ab","c"} would collide.
|
||||
if (data.dictionaryWords.isNotEmpty()) {
|
||||
d.update(-3, buf)
|
||||
d.update(data.dictionaryWords.size, buf)
|
||||
data.dictionaryWords.sorted().forEach { word ->
|
||||
d.update(word.length, buf)
|
||||
d.update(word, buf)
|
||||
}
|
||||
}
|
||||
|
||||
return d.digest().base64Url
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,148 +0,0 @@
|
|||
package com.darkrockstudios.apps.hammer.base.markdown
|
||||
|
||||
import org.intellij.markdown.IElementType
|
||||
import org.intellij.markdown.MarkdownElementTypes
|
||||
import org.intellij.markdown.MarkdownTokenTypes
|
||||
import org.intellij.markdown.ast.ASTNode
|
||||
import org.intellij.markdown.ast.LeafASTNode
|
||||
import org.intellij.markdown.ast.accept
|
||||
import org.intellij.markdown.flavours.MarkdownFlavourDescriptor
|
||||
import org.intellij.markdown.html.GeneratingProvider
|
||||
import org.intellij.markdown.html.HtmlGenerator
|
||||
import org.intellij.markdown.html.SimpleTagProvider
|
||||
import org.intellij.markdown.html.TrimmingInlineHolderProvider
|
||||
import org.intellij.markdown.parser.LinkMap
|
||||
|
||||
/**
|
||||
* How a story's markdown becomes HTML, shared by every surface that renders prose for a reader: the
|
||||
* web pages the server serves and the EPUB the client exports.
|
||||
*
|
||||
* CommonMark reflows prose. A run of single-newline lines collapses into one block and any run of
|
||||
* blank lines collapses to one paragraph break, so a page of dialogue arrives as a wall of text that
|
||||
* looks nothing like what the writer sees in the editor. These providers lay the text out as it was
|
||||
* typed instead: every newline starts a new line, and every blank line is a blank line.
|
||||
*
|
||||
* Only prose is touched. Lists, tables, code and headings keep markdown's own layout, and blank
|
||||
* lines next to them add nothing — those blocks carry their own spacing already.
|
||||
*/
|
||||
object ProseHtml {
|
||||
|
||||
/** Upper bound on the breaks one run of blank lines can produce. */
|
||||
const val MAX_CONSECUTIVE_BREAKS = 6
|
||||
|
||||
/**
|
||||
* The generating providers [flavour] would use, with prose layout applied. Pass the result to
|
||||
* [HtmlGenerator]. With [keepBlankLines] off, a lone blank line between paragraphs produces
|
||||
* nothing and a run of two or more produces a single break.
|
||||
*/
|
||||
fun providers(
|
||||
flavour: MarkdownFlavourDescriptor,
|
||||
linkMap: LinkMap,
|
||||
keepBlankLines: Boolean = true,
|
||||
): Map<IElementType, GeneratingProvider> =
|
||||
flavour.createHtmlGeneratingProviders(linkMap, null) + mapOf(
|
||||
MarkdownElementTypes.PARAGRAPH to ProseParagraphProvider,
|
||||
MarkdownElementTypes.MARKDOWN_FILE to ProseContainerProvider("body", keepBlankLines),
|
||||
MarkdownElementTypes.BLOCK_QUOTE to ProseContainerProvider("blockquote", keepBlankLines),
|
||||
)
|
||||
|
||||
/**
|
||||
* The parser only ends a line on `\n`. Content written on Windows, or imported from a file that
|
||||
* was, arrives with `\r\n` and would otherwise parse as one run-on paragraph.
|
||||
*/
|
||||
fun normalizeLineEndings(markdown: String): String = markdown.replace(CARRIAGE_RETURN, "\n")
|
||||
|
||||
private val CARRIAGE_RETURN = Regex("\r\n?")
|
||||
}
|
||||
|
||||
/**
|
||||
* A paragraph of prose keeps the lines the author typed: each becomes its own paragraph.
|
||||
*
|
||||
* This relies on the reader's stylesheet giving paragraph siblings no vertical margin, so
|
||||
* consecutive lines sit directly under one another and each picks up the first-line indent, exactly
|
||||
* as the editor draws them.
|
||||
*/
|
||||
private object ProseParagraphProvider : TrimmingInlineHolderProvider() {
|
||||
override fun openTag(visitor: HtmlGenerator.HtmlGeneratingVisitor, text: String, node: ASTNode) {
|
||||
visitor.consumeTagOpen(node, "p")
|
||||
}
|
||||
|
||||
override fun closeTag(visitor: HtmlGenerator.HtmlGeneratingVisitor, text: String, node: ASTNode) {
|
||||
visitor.consumeTagClose("p")
|
||||
}
|
||||
|
||||
override fun processNode(
|
||||
visitor: HtmlGenerator.HtmlGeneratingVisitor,
|
||||
text: String,
|
||||
node: ASTNode,
|
||||
) {
|
||||
val children = childrenToRender(node)
|
||||
openTag(visitor, text, node)
|
||||
|
||||
children.forEachIndexed { index, child ->
|
||||
when {
|
||||
// An edge newline would only open or close an empty paragraph.
|
||||
child.type == MarkdownTokenTypes.EOL -> {
|
||||
if (index != 0 && index != children.lastIndex) {
|
||||
closeTag(visitor, text, node)
|
||||
openTag(visitor, text, node)
|
||||
}
|
||||
}
|
||||
|
||||
// The trailing spaces of a hard break are redundant: the newline itself breaks here.
|
||||
child.type == MarkdownTokenTypes.HARD_LINE_BREAK -> Unit
|
||||
|
||||
child is LeafASTNode -> visitor.visitLeaf(child)
|
||||
else -> child.accept(visitor)
|
||||
}
|
||||
}
|
||||
|
||||
closeTag(visitor, text, node)
|
||||
}
|
||||
}
|
||||
|
||||
/** Emits a break for each blank line the author left between two paragraphs. */
|
||||
private class ProseContainerProvider(
|
||||
tag: String,
|
||||
private val keepBlankLines: Boolean,
|
||||
) : SimpleTagProvider(tag) {
|
||||
override fun processNode(
|
||||
visitor: HtmlGenerator.HtmlGeneratingVisitor,
|
||||
text: String,
|
||||
node: ASTNode,
|
||||
) {
|
||||
openTag(visitor, text, node)
|
||||
|
||||
var newlines = 0
|
||||
var afterParagraph = false
|
||||
for (child in node.children) {
|
||||
when (child.type) {
|
||||
MarkdownTokenTypes.EOL -> newlines++
|
||||
// A quote's own '>' markers sit between the lines they mark, and neither they nor
|
||||
// stray indentation say anything about how far apart two passages are.
|
||||
MarkdownTokenTypes.WHITE_SPACE, MarkdownTokenTypes.BLOCK_QUOTE -> Unit
|
||||
else -> {
|
||||
val isParagraph = child.type == MarkdownElementTypes.PARAGRAPH
|
||||
if (afterParagraph && isParagraph) {
|
||||
val blanks = (newlines - 1).coerceIn(0, ProseHtml.MAX_CONSECUTIVE_BREAKS)
|
||||
val breaks = when {
|
||||
keepBlankLines -> blanks
|
||||
blanks >= 2 -> 1
|
||||
else -> 0
|
||||
}
|
||||
repeat(breaks) { visitor.consumeHtml(BREAK) }
|
||||
}
|
||||
child.accept(visitor)
|
||||
afterParagraph = isParagraph
|
||||
newlines = 0
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
closeTag(visitor, text, node)
|
||||
}
|
||||
|
||||
private companion object {
|
||||
const val BREAK = "<br />"
|
||||
}
|
||||
}
|
||||
|
|
@ -1,20 +0,0 @@
|
|||
package com.darkrockstudios.apps.hammer.base.markdown
|
||||
|
||||
/**
|
||||
* Counts runs of non-whitespace that contain at least one letter or digit, so standalone
|
||||
* Markdown markers (`---`, `#`, `-`, `>`) are not words. Single pass: a whole manuscript is
|
||||
* one string, not a word list.
|
||||
*/
|
||||
fun countWords(text: String): Int {
|
||||
var count = 0
|
||||
var counted = false
|
||||
for (char in text) {
|
||||
if (char.isWhitespace()) {
|
||||
counted = false
|
||||
} else if (!counted && char.isLetterOrDigit()) {
|
||||
counted = true
|
||||
count++
|
||||
}
|
||||
}
|
||||
return count
|
||||
}
|
||||
|
|
@ -1,35 +0,0 @@
|
|||
package com.darkrockstudios.apps.hammer.base
|
||||
|
||||
import kotlin.test.Test
|
||||
import kotlin.test.assertEquals
|
||||
|
||||
class DistributionChannelTest {
|
||||
|
||||
/**
|
||||
* Pins the tokens against the build-side enum in
|
||||
* `buildSrc/src/main/java/distributionChannel.kt`. A channel added or renamed on one side and
|
||||
* not the other would otherwise ship a build whose reported channel silently falls back to
|
||||
* [DistributionChannel.DEV].
|
||||
*/
|
||||
@Test
|
||||
fun `tokens match the build side enum`() {
|
||||
val expected = listOf(
|
||||
"dev",
|
||||
"github",
|
||||
"google-play",
|
||||
"fdroid",
|
||||
"snap",
|
||||
"appimage",
|
||||
"flathub",
|
||||
"ms-store",
|
||||
"mac-app-store",
|
||||
"ios-app-store",
|
||||
)
|
||||
assertEquals(expected, DistributionChannel.entries.map { it.token })
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `current resolves the baked in token`() {
|
||||
assertEquals(BuildMetadata.CHANNEL, DistributionChannel.current.token)
|
||||
}
|
||||
}
|
||||
|
|
@ -1,34 +0,0 @@
|
|||
package com.darkrockstudios.apps.hammer.base
|
||||
|
||||
import kotlin.test.Test
|
||||
import kotlin.test.assertEquals
|
||||
|
||||
class PackageFormatTest {
|
||||
|
||||
/**
|
||||
* Pins the tokens against the build-side enum in `buildSrc/src/main/java/packageFormat.kt`,
|
||||
* the same way [DistributionChannelTest] pins the channel tokens.
|
||||
*/
|
||||
@Test
|
||||
fun `tokens match the build side enum`() {
|
||||
val expected = listOf(
|
||||
"none",
|
||||
"msi",
|
||||
"exe",
|
||||
"deb",
|
||||
"rpm",
|
||||
"dmg",
|
||||
"pkg",
|
||||
"appimage",
|
||||
"msix",
|
||||
"snap",
|
||||
"flatpak",
|
||||
)
|
||||
assertEquals(expected, PackageFormat.entries.map { it.token })
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `current resolves the baked in token`() {
|
||||
assertEquals(BuildMetadata.PACKAGE_FORMAT, PackageFormat.current.token)
|
||||
}
|
||||
}
|
||||
|
|
@ -136,23 +136,6 @@ class EntityHasherTest {
|
|||
assertNotEquals(base, different, "hashEncyclopediaEntry must include aliases in the digest")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `hashEncyclopediaEntry differs when excludeFromDictionary differs`() {
|
||||
val included = EntityHasher.hashEncyclopediaEntry(
|
||||
id = 2, name = "Robert", entryType = "person",
|
||||
text = "text", tags = emptySet(), image = null,
|
||||
aliases = emptyList(),
|
||||
excludeFromDictionary = false,
|
||||
)
|
||||
val excluded = EntityHasher.hashEncyclopediaEntry(
|
||||
id = 2, name = "Robert", entryType = "person",
|
||||
text = "text", tags = emptySet(), image = null,
|
||||
aliases = emptyList(),
|
||||
excludeFromDictionary = true,
|
||||
)
|
||||
assertNotEquals(included, excluded, "hashEncyclopediaEntry must include excludeFromDictionary in the digest")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `hashEncyclopediaEntry alias order is significant`() {
|
||||
// aliases is a List, not a Set - reordering is a meaningful change that should
|
||||
|
|
|
|||
|
|
@ -56,82 +56,6 @@ class ProjectDataHasherTest {
|
|||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `language affects hash`() {
|
||||
val unset = ProjectData(authorName = "Pat")
|
||||
val set = unset.copy(language = "en-US")
|
||||
assertNotEquals(ProjectDataHasher.hash(unset), ProjectDataHasher.hash(set))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `language region affects hash`() {
|
||||
val en = ProjectData(language = "en")
|
||||
val enUs = ProjectData(language = "en-US")
|
||||
assertNotEquals(ProjectDataHasher.hash(en), ProjectDataHasher.hash(enUs))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `language block does not collide with a one-element tags block`() {
|
||||
val tagged = ProjectData(tags = setOf("en"))
|
||||
val languaged = ProjectData(language = "en")
|
||||
assertNotEquals(
|
||||
ProjectDataHasher.hash(tagged),
|
||||
ProjectDataHasher.hash(languaged),
|
||||
"tags=[x] and language=x must hash differently",
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `encyclopedia dictionary affects hash`() {
|
||||
val on = ProjectData(authorName = "Pat")
|
||||
val off = on.copy(encyclopediaDictionary = false)
|
||||
assertNotEquals(ProjectDataHasher.hash(on), ProjectDataHasher.hash(off))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `dictionary words affect hash`() {
|
||||
val without = ProjectData(authorName = "Pat")
|
||||
val with = without.copy(dictionaryWords = setOf("kvothe"))
|
||||
assertNotEquals(ProjectDataHasher.hash(without), ProjectDataHasher.hash(with))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `dictionary word insertion order does not affect hash`() {
|
||||
val ab = ProjectData(dictionaryWords = setOf("alpha", "beta"))
|
||||
val ba = ProjectData(dictionaryWords = setOf("beta", "alpha"))
|
||||
assertEquals(ProjectDataHasher.hash(ab), ProjectDataHasher.hash(ba))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `dictionary word boundaries affect hash`() {
|
||||
val joined = ProjectData(dictionaryWords = setOf("ab"))
|
||||
val split = ProjectData(dictionaryWords = setOf("a", "b"))
|
||||
assertNotEquals(ProjectDataHasher.hash(joined), ProjectDataHasher.hash(split))
|
||||
|
||||
// Same size and same concatenation: only a per-word length keeps these apart.
|
||||
val left = ProjectData(dictionaryWords = setOf("a", "bc"))
|
||||
val right = ProjectData(dictionaryWords = setOf("ab", "c"))
|
||||
assertNotEquals(ProjectDataHasher.hash(left), ProjectDataHasher.hash(right))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `dictionary block does not collide with tags or language`() {
|
||||
val words = ProjectData(dictionaryWords = setOf("en"))
|
||||
assertNotEquals(ProjectDataHasher.hash(ProjectData(tags = setOf("en"))), ProjectDataHasher.hash(words))
|
||||
assertNotEquals(ProjectDataHasher.hash(ProjectData(language = "en")), ProjectDataHasher.hash(words))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `null vs empty language distinguishable`() {
|
||||
val nullLanguage = ProjectData(language = null)
|
||||
val emptyLanguage = ProjectData(language = "")
|
||||
assertNotEquals(
|
||||
ProjectDataHasher.hash(nullLanguage),
|
||||
ProjectDataHasher.hash(emptyLanguage),
|
||||
"null and empty-string language must hash differently",
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `same input produces same hash`() {
|
||||
val data = ProjectData(
|
||||
|
|
|
|||
111
build.gradle.kts
111
build.gradle.kts
|
|
@ -1,20 +1,16 @@
|
|||
import com.darkrockstudios.build.APPLE_STORE_LIMIT
|
||||
import com.darkrockstudios.build.PLAY_STORE_LIMIT
|
||||
import com.darkrockstudios.build.Platform
|
||||
import com.darkrockstudios.build.configureRelease
|
||||
import com.darkrockstudios.build.extractLatestChangelog
|
||||
import com.darkrockstudios.build.formatStoreNotes
|
||||
import com.darkrockstudios.build.isPlatformReleaseTag
|
||||
import com.darkrockstudios.build.registerLinuxDistributionTasks
|
||||
import com.darkrockstudios.build.registerPublishTasks
|
||||
import com.darkrockstudios.build.registerUpdateMetadataTasks
|
||||
import com.darkrockstudios.build.releaseNotesUrl
|
||||
import com.darkrockstudios.build.storeNotesLength
|
||||
import com.darkrockstudios.build.updateFlatpakFiles
|
||||
import com.darkrockstudios.build.updateIosShortVersion
|
||||
import com.darkrockstudios.build.updateSnapcraftYaml
|
||||
import com.darkrockstudios.build.wearVersionCode
|
||||
import com.darkrockstudios.build.writeBakedChangelog
|
||||
import com.darkrockstudios.build.writeChangelogMarkdown
|
||||
import com.darkrockstudios.build.writeSemvar
|
||||
import org.jetbrains.kotlin.gradle.tasks.KotlinCompilationTask
|
||||
|
|
@ -46,9 +42,6 @@ allprojects {
|
|||
|
||||
tasks.withType<Test> {
|
||||
useJUnitPlatform()
|
||||
// The Koin compiler plugin's compile-safety hints are not tests, and as of plugin
|
||||
// 1.1.0 they carry duplicate method signatures that JUnit's scan chokes on.
|
||||
exclude("org/koin/plugin/hints/**")
|
||||
}
|
||||
|
||||
// Compiler flags applied globally
|
||||
|
|
@ -129,19 +122,11 @@ kover {
|
|||
|
||||
registerPublishTasks()
|
||||
registerLinuxDistributionTasks(libs.versions.app.get())
|
||||
registerUpdateMetadataTasks(libs.versions.app.get())
|
||||
|
||||
// The pre-commit hook runs Gradle, so git's hook env leaks into the daemon and Gradle
|
||||
// blanks rather than unsets it later; a blank GIT_INDEX_FILE reads as an empty index.
|
||||
fun ProcessBuilder.scrubGitEnv(): ProcessBuilder = apply {
|
||||
environment().keys.removeAll { it.startsWith("GIT_") }
|
||||
}
|
||||
|
||||
val releasePreFlightChecks = tasks.register("releasePreFlightChecks") {
|
||||
doLast {
|
||||
fun runGit(vararg args: String): String {
|
||||
val process = ProcessBuilder(*args)
|
||||
.scrubGitEnv()
|
||||
.directory(project.rootDir)
|
||||
.start()
|
||||
val stdout = process.inputStream.bufferedReader().readText().trim()
|
||||
|
|
@ -150,7 +135,7 @@ val releasePreFlightChecks = tasks.register("releasePreFlightChecks") {
|
|||
}
|
||||
|
||||
println("Fetching origin...")
|
||||
ProcessBuilder("git", "fetch", "origin").scrubGitEnv().directory(project.rootDir).inheritIO().start().waitFor()
|
||||
ProcessBuilder("git", "fetch", "origin").directory(project.rootDir).inheritIO().start().waitFor()
|
||||
|
||||
// Check for unstaged/uncommitted changes
|
||||
val statusText = runGit("git", "status", "--porcelain")
|
||||
|
|
@ -186,43 +171,34 @@ tasks.register("prepareForRelease") {
|
|||
val versionsFile = project.rootDir.resolve(versionsPath)
|
||||
writeSemvar(libs.versions.app.get(), releaseInfo.semVar, versionsFile)
|
||||
|
||||
// Store listings carry the app-only notes, written per sink in the release
|
||||
// dialog. Google Play also gets a link to the GitHub release, which holds the
|
||||
// full text including the web and server changes; the Apple stores must not
|
||||
// (see releaseNotesUrl) and get the notes alone. A sink with no notes is one
|
||||
// this release does not reach: leave its metadata alone rather than blanking
|
||||
// the notes the last client release published.
|
||||
// Store listings carry the app-only notes plus a link to the GitHub release,
|
||||
// which holds the full text including the web and server changes. A release
|
||||
// that reaches no store has no store notes; leave the existing metadata alone
|
||||
// rather than blanking the notes the last client release published.
|
||||
val fullNotesUrl = releaseNotesUrl(releaseInfo.tag)
|
||||
val storeNotes = releaseInfo.storeNotes
|
||||
val playNotes = storeNotes.notesFor(Platform.GOOGLE_PLAY)
|
||||
val appleNotes = storeNotes.notesFor(Platform.IOS_APP_STORE)
|
||||
val writePlayNotes = playNotes.isNotBlank()
|
||||
val writeAppleNotes = appleNotes.isNotBlank()
|
||||
val writeSharedNotes = storeNotes.shared.isNotBlank()
|
||||
val playNotesLength = storeNotesLength(playNotes, fullNotesUrl)
|
||||
val playChangelog = formatStoreNotes(playNotes, PLAY_STORE_LIMIT, fullNotesUrl)
|
||||
val storeNotes = releaseInfo.storeChangeLog
|
||||
val writeStoreNotes = storeNotes.isNotBlank()
|
||||
val notesLength = storeNotesLength(storeNotes, fullNotesUrl)
|
||||
val playChangelog = formatStoreNotes(storeNotes, PLAY_STORE_LIMIT, fullNotesUrl)
|
||||
|
||||
// Write the Fastlane changelog file
|
||||
val rootDir: File = project.rootDir
|
||||
val changelogsPath =
|
||||
"fastlane/metadata/android/en-US/changelogs".replace("/", File.separator)
|
||||
val changeLogsDir = rootDir.resolve(changelogsPath)
|
||||
// The watch app ships in the same listing under its own version code.
|
||||
val changeLogFiles = listOf(versionCode, wearVersionCode(versionCode)).map { File(changeLogsDir, "$it.txt") }
|
||||
if (writePlayNotes) {
|
||||
changeLogFiles.forEach { file ->
|
||||
file.writeText(playChangelog)
|
||||
println("Changelog for version ${releaseInfo.semVar} written to $changelogsPath/${file.name}")
|
||||
}
|
||||
if (playNotesLength > PLAY_STORE_LIMIT) {
|
||||
val changeLogFile = File(changeLogsDir, "$versionCode.txt")
|
||||
if (writeStoreNotes) {
|
||||
changeLogFile.writeText(playChangelog)
|
||||
println("Changelog for version ${releaseInfo.semVar} written to $changelogsPath/$versionCode.txt")
|
||||
if (notesLength > PLAY_STORE_LIMIT) {
|
||||
println(" Google Play notes truncated to $PLAY_STORE_LIMIT characters; full text at $fullNotesUrl")
|
||||
}
|
||||
} else {
|
||||
println("No Google Play notes for this release; store metadata left untouched")
|
||||
println("No store notes for this release; store metadata left untouched")
|
||||
}
|
||||
|
||||
val appleChangelog = formatStoreNotes(appleNotes, APPLE_STORE_LIMIT, null)
|
||||
if (writeAppleNotes && storeNotesLength(appleNotes, null) > APPLE_STORE_LIMIT) {
|
||||
val appleChangelog = formatStoreNotes(storeNotes, APPLE_STORE_LIMIT, fullNotesUrl)
|
||||
if (writeStoreNotes && notesLength > APPLE_STORE_LIMIT) {
|
||||
println("App Store notes truncated to $APPLE_STORE_LIMIT characters; full text at $fullNotesUrl")
|
||||
}
|
||||
|
||||
|
|
@ -231,11 +207,7 @@ tasks.register("prepareForRelease") {
|
|||
val macReleaseNotesDir = rootDir.resolve(macReleaseNotesPath)
|
||||
macReleaseNotesDir.mkdirs()
|
||||
val macReleaseNotesFile = File(macReleaseNotesDir, "release_notes.txt")
|
||||
// Both Apple listings share one set of notes but each has its own file, so a
|
||||
// release to one leaves the other's file holding what it last published.
|
||||
val writeMacNotes = writeAppleNotes && Platform.MAC_APP_STORE in releaseInfo.platforms
|
||||
val writeIosNotes = writeAppleNotes && Platform.IOS_APP_STORE in releaseInfo.platforms
|
||||
if (writeMacNotes) {
|
||||
if (writeStoreNotes) {
|
||||
macReleaseNotesFile.writeText(appleChangelog)
|
||||
println("macOS release notes written to $macReleaseNotesPath/release_notes.txt")
|
||||
}
|
||||
|
|
@ -245,21 +217,14 @@ tasks.register("prepareForRelease") {
|
|||
val iosReleaseNotesDir = rootDir.resolve(iosReleaseNotesPath)
|
||||
iosReleaseNotesDir.mkdirs()
|
||||
val iosReleaseNotesFile = File(iosReleaseNotesDir, "release_notes.txt")
|
||||
if (writeIosNotes) {
|
||||
if (writeStoreNotes) {
|
||||
iosReleaseNotesFile.writeText(appleChangelog)
|
||||
println("iOS release notes written to $iosReleaseNotesPath/release_notes.txt")
|
||||
}
|
||||
|
||||
// Write the Global changelog file
|
||||
val globalChangelogFile = File("${project.rootDir}/CHANGELOG.md")
|
||||
val changelogEntry = writeChangelogMarkdown(releaseInfo, globalChangelogFile)
|
||||
|
||||
// Bake the same entry into the app. Written for every release, including server-only
|
||||
// ones, so the resource never drifts from the version in libs.versions.toml.
|
||||
val bakedChangelogPath =
|
||||
"common/src/commonMain/composeResources/files/changelog.md".replace("/", File.separator)
|
||||
val bakedChangelogFile = project.rootDir.resolve(bakedChangelogPath)
|
||||
writeBakedChangelog(changelogEntry, bakedChangelogFile)
|
||||
writeChangelogMarkdown(releaseInfo, globalChangelogFile)
|
||||
|
||||
// Update snapcraft.yaml with new version and JVM version
|
||||
val snapcraftPath = "snap/snapcraft.yaml".replace("/", File.separator)
|
||||
|
|
@ -272,17 +237,10 @@ tasks.register("prepareForRelease") {
|
|||
val flatpakManifestFile = project.rootDir.resolve(flatpakManifestPath)
|
||||
val flatpakMetainfoPath = "flatpak/studio.darkrock.hammer.metainfo.xml".replace("/", File.separator)
|
||||
val flatpakMetainfoFile = project.rootDir.resolve(flatpakMetainfoPath)
|
||||
// Flathub is a store listing with no tab of its own, so it gets the shared
|
||||
// app-only notes. They are blank unless the release reaches Flathub (see
|
||||
// reachesFlathub), and then it gets no release entry either.
|
||||
if (writeSharedNotes) {
|
||||
updateFlatpakFiles(
|
||||
releaseInfo.semVar,
|
||||
jvmVersion,
|
||||
flatpakManifestFile,
|
||||
flatpakMetainfoFile,
|
||||
storeNotes.shared,
|
||||
)
|
||||
// Flathub is a store listing, so it gets the app-only notes. A release with
|
||||
// none of them is not going to Flathub, so it gets no release entry either.
|
||||
if (writeStoreNotes) {
|
||||
updateFlatpakFiles(releaseInfo.semVar, jvmVersion, flatpakManifestFile, flatpakMetainfoFile, storeNotes)
|
||||
}
|
||||
|
||||
// Keep the iOS marketing version in sync with the semver. macOS pulls this
|
||||
|
|
@ -295,7 +253,6 @@ tasks.register("prepareForRelease") {
|
|||
val cmd = listOf("git") + args.toList()
|
||||
println("> ${cmd.joinToString(" ")}")
|
||||
val process = ProcessBuilder(cmd)
|
||||
.scrubGitEnv()
|
||||
.directory(project.rootDir)
|
||||
.redirectErrorStream(true)
|
||||
.start()
|
||||
|
|
@ -307,22 +264,15 @@ tasks.register("prepareForRelease") {
|
|||
}
|
||||
|
||||
// Commit the changes to the repo
|
||||
if (writePlayNotes) {
|
||||
changeLogFiles.forEach { git("add", it.absolutePath) }
|
||||
}
|
||||
if (writeMacNotes) {
|
||||
if (writeStoreNotes) {
|
||||
git("add", changeLogFile.absolutePath)
|
||||
git("add", macReleaseNotesFile.absolutePath)
|
||||
}
|
||||
if (writeIosNotes) {
|
||||
git("add", iosReleaseNotesFile.absolutePath)
|
||||
}
|
||||
if (writeSharedNotes) {
|
||||
git("add", flatpakManifestFile.absolutePath)
|
||||
git("add", flatpakMetainfoFile.absolutePath)
|
||||
}
|
||||
git("add", versionsFile.absolutePath)
|
||||
git("add", globalChangelogFile.absolutePath)
|
||||
git("add", bakedChangelogFile.absolutePath)
|
||||
git("add", snapcraftFile.absolutePath)
|
||||
git("add", iosInfoPlistFile.absolutePath)
|
||||
git("commit", "-m", "Prepared for release: v${releaseInfo.semVar}")
|
||||
|
|
@ -334,7 +284,6 @@ tasks.register("prepareForRelease") {
|
|||
val cmd = listOf("git") + args.toList()
|
||||
println("> (${dir.name}) ${cmd.joinToString(" ")}")
|
||||
val process = ProcessBuilder(cmd)
|
||||
.scrubGitEnv()
|
||||
.directory(dir)
|
||||
.redirectErrorStream(true)
|
||||
.start()
|
||||
|
|
@ -363,7 +312,7 @@ tasks.register("prepareForRelease") {
|
|||
// Tag the merge commit explicitly; the main tree stays on develop.
|
||||
val tagMessageFile = File(project.rootDir, "build/release-tag-message.txt")
|
||||
tagMessageFile.parentFile.mkdirs()
|
||||
tagMessageFile.writeText(releaseInfo.tagMessage)
|
||||
tagMessageFile.writeText(releaseInfo.changeLog)
|
||||
git("tag", "-a", releaseInfo.tag, "-F", tagMessageFile.absolutePath, "release")
|
||||
|
||||
// Push the branches and only this release's tag. Pushing --tags would try
|
||||
|
|
@ -384,7 +333,6 @@ tasks.register("backoutLastRelease") {
|
|||
val cmd = listOf("git") + args.toList()
|
||||
println("> ${cmd.joinToString(" ")}")
|
||||
val process = ProcessBuilder(cmd)
|
||||
.scrubGitEnv()
|
||||
.directory(project.rootDir)
|
||||
.redirectErrorStream(true)
|
||||
.start()
|
||||
|
|
@ -402,7 +350,6 @@ tasks.register("backoutLastRelease") {
|
|||
// the prefix (`vX.Y.Z+rc1`, `vX.Y.Z+sbom`) aren't included.
|
||||
fun findReleaseTags(): List<String> {
|
||||
val proc = ProcessBuilder("git", "tag", "-l", tagName, "$tagName+*")
|
||||
.scrubGitEnv()
|
||||
.directory(project.rootDir)
|
||||
.start()
|
||||
val tags = proc.inputStream.bufferedReader().readLines().filter { it.isNotBlank() }
|
||||
|
|
@ -415,7 +362,6 @@ tasks.register("backoutLastRelease") {
|
|||
|
||||
// Check if HEAD commit is the release commit
|
||||
val headProcess = ProcessBuilder("git", "log", "-1", "--format=%s")
|
||||
.scrubGitEnv()
|
||||
.directory(project.rootDir).start()
|
||||
val headMessage = headProcess.inputStream.bufferedReader().readText().trim()
|
||||
headProcess.waitFor()
|
||||
|
|
@ -463,7 +409,6 @@ tasks.register("revertLastRelease") {
|
|||
val cmd = listOf("git") + args.toList()
|
||||
println("> ${cmd.joinToString(" ")}")
|
||||
val process = ProcessBuilder(cmd)
|
||||
.scrubGitEnv()
|
||||
.directory(project.rootDir)
|
||||
.redirectErrorStream(true)
|
||||
.start()
|
||||
|
|
@ -476,7 +421,6 @@ tasks.register("revertLastRelease") {
|
|||
val cmd = listOf("git") + args.toList()
|
||||
println("> ${cmd.joinToString(" ")}")
|
||||
val process = ProcessBuilder(cmd)
|
||||
.scrubGitEnv()
|
||||
.directory(project.rootDir)
|
||||
.redirectErrorStream(true)
|
||||
.start()
|
||||
|
|
@ -489,7 +433,6 @@ tasks.register("revertLastRelease") {
|
|||
fun gitOutput(vararg args: String): String {
|
||||
val cmd = listOf("git") + args.toList()
|
||||
val process = ProcessBuilder(cmd)
|
||||
.scrubGitEnv()
|
||||
.directory(project.rootDir)
|
||||
.redirectErrorStream(true)
|
||||
.start()
|
||||
|
|
|
|||
|
|
@ -8,6 +8,8 @@ repositories {
|
|||
|
||||
dependencies {
|
||||
implementation(kotlin("stdlib"))
|
||||
//implementation(libs.markdown)
|
||||
implementation(libs.kotlinx.datetime)
|
||||
// for the prepareForRelease dialog.
|
||||
implementation(libs.flatlaf)
|
||||
testImplementation(kotlin("test"))
|
||||
|
|
|
|||
|
|
@ -57,43 +57,28 @@ fun extractLatestChangelog(changelogFile: File): String? {
|
|||
return body.trim().ifEmpty { null }
|
||||
}
|
||||
|
||||
/** Prepends this release's entry to CHANGELOG.md and returns the entry text. */
|
||||
fun writeChangelogMarkdown(releaseInfo: ReleaseInfo, changelogFile: File): String {
|
||||
fun writeChangelogMarkdown(releaseInfo: ReleaseInfo, changelogFile: File) {
|
||||
val currentChangelog = changelogFile.readText()
|
||||
val withoutHeader = currentChangelog.substring(currentChangelog.indexOf('\n') + 1)
|
||||
|
||||
val headerDate = LocalDate.now().format(DateTimeFormatter.ofPattern("yyyy-M-d"))
|
||||
val entry = "## [${releaseInfo.semVar}] - $headerDate\n\n" + releaseInfo.changeLog + "\n"
|
||||
val newEntry = "## [${releaseInfo.semVar}] - $headerDate\n\n" + releaseInfo.changeLog + "\n\n"
|
||||
|
||||
val newChangeLog = "# Changelog\n\n" + entry + "\n" + withoutHeader
|
||||
val newChangeLog = "# Changelog\n\n" + newEntry + withoutHeader
|
||||
|
||||
changelogFile.writeText(newChangeLog)
|
||||
println("CHANGELOG.md written")
|
||||
|
||||
return entry
|
||||
}
|
||||
|
||||
/** The same entry, baked into the app so the client can show it without calling GitHub. */
|
||||
fun writeBakedChangelog(entry: String, bakedFile: File) {
|
||||
bakedFile.parentFile.mkdirs()
|
||||
bakedFile.writeText(entry)
|
||||
println("Baked changelog written to ${bakedFile.path}")
|
||||
}
|
||||
|
||||
/** Tag-message trailer that tells CI to publish the release instead of leaving it a draft. */
|
||||
const val AUTO_PUBLISH_TRAILER = "Auto-Publish: true"
|
||||
|
||||
/**
|
||||
* @param changeLog The full release notes: CHANGELOG.md and the GitHub release.
|
||||
* @param storeNotes The app-only text each store listing carries.
|
||||
* @param autoPublish Publish the GitHub release once every CI build job passes.
|
||||
* @param storeChangeLog The app-only subset every store listing carries.
|
||||
*/
|
||||
data class ReleaseInfo(
|
||||
val semVar: SemVar,
|
||||
val changeLog: String,
|
||||
val storeNotes: StoreChangelogs,
|
||||
val storeChangeLog: String,
|
||||
val platforms: Set<Platform>,
|
||||
val autoPublish: Boolean = false,
|
||||
) {
|
||||
init {
|
||||
// Fail fast at construction so we never get halfway through prepareForRelease
|
||||
|
|
@ -104,11 +89,6 @@ data class ReleaseInfo(
|
|||
|
||||
/** The git tag for this release: `vX.Y.Z` for full, `vX.Y.Z+token+token` for partial. */
|
||||
val tag: String get() = "v$semVar${tagSuffix(platforms)}"
|
||||
|
||||
// The trailer is the only channel that reaches CI per-release: the tag name already
|
||||
// encodes platform scope, and a `+` suffix there would read as a partial release.
|
||||
/** The annotated-tag message: the notes, plus the auto-publish trailer when asked for. */
|
||||
val tagMessage: String get() = if (autoPublish) "$changeLog\n\n$AUTO_PUBLISH_TRAILER" else changeLog
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -132,10 +112,6 @@ private fun droppedSummary(dropped: List<String>): String {
|
|||
return if (rest > 0) "$shown · +$rest more" else shown
|
||||
}
|
||||
|
||||
/** A store editor's character count against its limit, and by how much it is over. */
|
||||
private fun counterText(length: Int, limit: Int): String =
|
||||
if (length > limit) "$length/$limit · over by ${length - limit}" else "$length/$limit"
|
||||
|
||||
/** Small muted caption above a changelog editor. */
|
||||
private fun tabHint(text: String): JLabel = JLabel(wrapped(text)).apply {
|
||||
font = font.deriveFont(Font.ITALIC, font.size - 1f)
|
||||
|
|
@ -143,64 +119,6 @@ private fun tabHint(text: String): JLabel = JLabel(wrapped(text)).apply {
|
|||
border = BorderFactory.createEmptyBorder(0, 2, 2, 2)
|
||||
}
|
||||
|
||||
/** Tab positions in the changelog tab strip. */
|
||||
private const val TAB_FULL = 0
|
||||
private const val TAB_STORE = 1
|
||||
private const val TAB_PLAY = 2
|
||||
private const val TAB_APPLE = 3
|
||||
|
||||
/**
|
||||
* A changelog editor that mirrors [derive] until it is hand-edited, after which it
|
||||
* keeps its own text until [resync] is called. Chaining these gives the dialog its
|
||||
* default: the full notes are the only thing anyone has to write, and a store tab
|
||||
* is touched only when that store needs something different.
|
||||
*/
|
||||
private class MirroredEditor(private val derive: () -> String) {
|
||||
val editor = JTextArea().apply {
|
||||
lineWrap = true
|
||||
wrapStyleWord = true
|
||||
rows = 12
|
||||
}
|
||||
|
||||
var edited = false
|
||||
private set
|
||||
|
||||
private var syncing = false
|
||||
|
||||
val text: String get() = editor.text
|
||||
|
||||
fun onChange(listener: () -> Unit) {
|
||||
editor.document.addDocumentListener(OnChangeListener {
|
||||
if (!syncing) edited = true
|
||||
listener()
|
||||
})
|
||||
}
|
||||
|
||||
/** Pulls the source text in, unless this editor has been hand-edited. */
|
||||
fun sync() {
|
||||
if (!edited) write(derive())
|
||||
}
|
||||
|
||||
/** Discards hand edits and follows the source again. */
|
||||
fun resync() {
|
||||
edited = false
|
||||
sync()
|
||||
}
|
||||
|
||||
/** Replaces the text with a hand edit the dialog made, such as trim-to-fit. */
|
||||
fun overwrite(value: String) {
|
||||
write(value)
|
||||
edited = true
|
||||
}
|
||||
|
||||
private fun write(value: String) {
|
||||
syncing = true
|
||||
editor.text = value
|
||||
editor.caretPosition = 0
|
||||
syncing = false
|
||||
}
|
||||
}
|
||||
|
||||
class OnChangeListener(
|
||||
val onChange: (e: DocumentEvent?) -> Unit
|
||||
) : DocumentListener {
|
||||
|
|
@ -223,8 +141,7 @@ fun configureRelease(currentSemVarStr: String, lastReleaseChangelog: String? = n
|
|||
SwingUtilities.invokeAndWait {
|
||||
val frame = JFrame()
|
||||
frame.defaultCloseOperation = JFrame.DISPOSE_ON_CLOSE
|
||||
// The fixed sections take the height they need; this leaves the changelog editor ~250px.
|
||||
frame.setSize(620, 1080)
|
||||
frame.setSize(620, 900)
|
||||
|
||||
fun refreshTitle() {
|
||||
frame.title = "Prepare Release — $curSemVar → $newSemVar"
|
||||
|
|
@ -246,8 +163,7 @@ fun configureRelease(currentSemVarStr: String, lastReleaseChangelog: String? = n
|
|||
// --- Tag preview label + commit button declared early so refresh can close over them ---
|
||||
val warningColor: Color = UIManager.getColor("Label.disabledForeground") ?: Color.GRAY
|
||||
val normalColor: Color = UIManager.getColor("Label.foreground") ?: Color.LIGHT_GRAY
|
||||
val editedColor = Color(0xE0, 0x9B, 0x2B)
|
||||
val errorColor = Color(0xD0, 0x4A, 0x4A)
|
||||
val truncateColor = Color(0xE0, 0x9B, 0x2B)
|
||||
val tagFontBig = Font(Font.MONOSPACED, Font.BOLD, 18)
|
||||
val tagFontWarn = Font(Font.MONOSPACED, Font.ITALIC, 14)
|
||||
|
||||
|
|
@ -260,21 +176,18 @@ fun configureRelease(currentSemVarStr: String, lastReleaseChangelog: String? = n
|
|||
font = font.deriveFont(Font.BOLD, 14f)
|
||||
}
|
||||
|
||||
// Editors, previews and tabs are declared up here so the refresh below can
|
||||
// close over them; the Changelog section further down only places them.
|
||||
// Editors and store preview are declared up here so the refresh below can close
|
||||
// over them; they are laid out in the Changelog section further down.
|
||||
val changeLog = JTextArea().apply {
|
||||
lineWrap = true
|
||||
wrapStyleWord = true
|
||||
rows = 12
|
||||
}
|
||||
|
||||
// Each store editor mirrors its source until hand-edited: the shared notes
|
||||
// follow a filtered copy of the full changelog, and each store follows the
|
||||
// shared notes. Stores diverge only when someone makes them.
|
||||
val storeNotes = MirroredEditor { deriveStoreNotes(changeLog.text).notes }
|
||||
val playNotes = MirroredEditor { storeNotes.text }
|
||||
val appleNotes = MirroredEditor { storeNotes.text }
|
||||
|
||||
val storeNotes = JTextArea().apply {
|
||||
lineWrap = true
|
||||
wrapStyleWord = true
|
||||
rows = 12
|
||||
}
|
||||
val playPreview = JTextArea().apply {
|
||||
isEditable = false
|
||||
lineWrap = true
|
||||
|
|
@ -282,100 +195,15 @@ fun configureRelease(currentSemVarStr: String, lastReleaseChangelog: String? = n
|
|||
rows = 6
|
||||
font = Font(Font.MONOSPACED, Font.PLAIN, 12)
|
||||
}
|
||||
fun counterLabel() = JLabel().apply {
|
||||
val characterCount = JLabel().apply {
|
||||
font = font.deriveFont(font.size - 1f)
|
||||
foreground = warningColor
|
||||
}
|
||||
val playCount = counterLabel()
|
||||
val appleCount = counterLabel()
|
||||
|
||||
// What the filter took out, and whether a store still follows its source, so
|
||||
// nothing leaves a listing unnoticed.
|
||||
// What the filter took out, so nothing leaves the store listing unnoticed.
|
||||
val storeStatus = tabHint("")
|
||||
val playStatus = tabHint("")
|
||||
val appleStatus = tabHint("")
|
||||
|
||||
fun smallButton(label: String) = JButton(label).apply {
|
||||
val resyncButton = JButton("Re-sync from full notes").apply {
|
||||
font = font.deriveFont(font.size - 1f)
|
||||
}
|
||||
val storeResync = smallButton("Re-sync from full notes")
|
||||
val playResync = smallButton("Re-sync from store notes")
|
||||
val appleResync = smallButton("Re-sync from store notes")
|
||||
val trimButton = smallButton("Trim to fit")
|
||||
|
||||
fun tabHeader(hint: String, status: JLabel, buttons: List<JButton>): JPanel = JPanel().apply {
|
||||
layout = BoxLayout(this, BoxLayout.Y_AXIS)
|
||||
add(tabHint(hint).apply { alignmentX = Component.LEFT_ALIGNMENT })
|
||||
add(status.apply { alignmentX = Component.LEFT_ALIGNMENT })
|
||||
add(
|
||||
JPanel(FlowLayout(FlowLayout.LEFT, 6, 2)).apply {
|
||||
alignmentX = Component.LEFT_ALIGNMENT
|
||||
buttons.forEach { add(it) }
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
fun editorTab(header: JComponent, center: JComponent, counter: JLabel?): JPanel =
|
||||
JPanel(BorderLayout(0, 4)).apply {
|
||||
border = BorderFactory.createEmptyBorder(8, 0, 0, 0)
|
||||
add(header, BorderLayout.NORTH)
|
||||
add(center, BorderLayout.CENTER)
|
||||
if (counter != null) {
|
||||
add(
|
||||
JPanel(FlowLayout(FlowLayout.RIGHT, 0, 4)).apply { add(counter) },
|
||||
BorderLayout.SOUTH,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
val fullTab = editorTab(
|
||||
tabHint("Goes to CHANGELOG.md and the GitHub release. Write everything here."),
|
||||
JScrollPane(changeLog),
|
||||
null,
|
||||
)
|
||||
val storeTab = editorTab(
|
||||
tabHeader(
|
||||
"The app-only base every listing starts from. Entries tagged for the web or server are filtered out, because stores reject notes about anything but the app. Google Play and Apple follow this text on their own tabs; Flathub publishes it as it stands, on releases to every store.",
|
||||
storeStatus,
|
||||
listOf(storeResync),
|
||||
),
|
||||
JScrollPane(storeNotes.editor),
|
||||
null,
|
||||
)
|
||||
val playTab = editorTab(
|
||||
tabHeader(
|
||||
"Goes to Google Play and F-Droid, which read the same file. 500 characters, including the link to the full notes on GitHub.",
|
||||
playStatus,
|
||||
listOf(playResync, trimButton),
|
||||
),
|
||||
JSplitPane(
|
||||
JSplitPane.VERTICAL_SPLIT,
|
||||
JScrollPane(playNotes.editor),
|
||||
JScrollPane(playPreview).apply {
|
||||
border = BorderFactory.createTitledBorder("Google Play preview")
|
||||
},
|
||||
).apply {
|
||||
resizeWeight = 0.6
|
||||
border = null
|
||||
},
|
||||
playCount,
|
||||
)
|
||||
val appleTab = editorTab(
|
||||
tabHeader(
|
||||
"Goes to the iOS and Mac App Stores. 4000 characters, and no link: App Store review rejects notes pointing at the GitHub releases page.",
|
||||
appleStatus,
|
||||
listOf(appleResync),
|
||||
),
|
||||
JScrollPane(appleNotes.editor),
|
||||
appleCount,
|
||||
)
|
||||
|
||||
val changelogTabs = JTabbedPane().apply {
|
||||
addTab("Full", fullTab)
|
||||
addTab("App stores", storeTab)
|
||||
addTab("Google Play", playTab)
|
||||
addTab("Apple", appleTab)
|
||||
}
|
||||
|
||||
/** The platforms the current scope selection targets, empty if the selection is incomplete. */
|
||||
fun currentPlatforms(): Set<Platform> = when (scope) {
|
||||
|
|
@ -384,20 +212,30 @@ fun configureRelease(currentSemVarStr: String, lastReleaseChangelog: String? = n
|
|||
ReleaseScope.SERVER_ONLY -> setOf(Platform.SERVER)
|
||||
}
|
||||
|
||||
// Without a full selection there is no tag yet, so the notes preview falls back
|
||||
// to the bare version tag.
|
||||
fun currentTag(): String {
|
||||
val platforms = currentPlatforms()
|
||||
return if (platforms.isEmpty()) "v$newSemVar" else "v$newSemVar${tagSuffix(platforms)}"
|
||||
// The store editor mirrors a filtered copy of the full notes until it is
|
||||
// edited by hand, after which it is left alone.
|
||||
var storeNotesEdited = false
|
||||
var syncingStoreNotes = false
|
||||
|
||||
fun syncStoreNotes() {
|
||||
if (storeNotesEdited) return
|
||||
syncingStoreNotes = true
|
||||
storeNotes.text = deriveStoreNotes(changeLog.text).notes
|
||||
storeNotes.caretPosition = 0
|
||||
syncingStoreNotes = false
|
||||
}
|
||||
|
||||
fun followStatus(edited: Boolean) =
|
||||
if (edited) "Hand-edited, so it no longer follows the store notes."
|
||||
else "Following the store notes."
|
||||
fun resyncStoreNotes() {
|
||||
storeNotesEdited = false
|
||||
syncStoreNotes()
|
||||
}
|
||||
|
||||
fun refresh() {
|
||||
val platforms = currentPlatforms()
|
||||
val tag = currentTag()
|
||||
// Without a full selection there is no tag yet, so the notes preview falls
|
||||
// back to the bare version tag.
|
||||
val tag =
|
||||
if (platforms.isEmpty()) "v$newSemVar" else "v$newSemVar${tagSuffix(platforms)}"
|
||||
|
||||
if (platforms.isNotEmpty()) {
|
||||
tagLabel.text = tag
|
||||
|
|
@ -409,80 +247,42 @@ fun configureRelease(currentSemVarStr: String, lastReleaseChangelog: String? = n
|
|||
tagLabel.foreground = warningColor
|
||||
}
|
||||
|
||||
// A sink only matters when the release reaches it, and a store outside the
|
||||
// scope has its tab disabled so nobody writes notes that go nowhere.
|
||||
// The shared tab stays enabled for any store release because the store tabs
|
||||
// follow it, but it is only required when it is published itself.
|
||||
val needsShared = reachesFlathub(platforms)
|
||||
val needsPlay = reachesPlay(platforms)
|
||||
val needsApple = reachesApple(platforms)
|
||||
|
||||
changelogTabs.setEnabledAt(TAB_STORE, reachesAnyStore(platforms))
|
||||
changelogTabs.setEnabledAt(TAB_PLAY, needsPlay)
|
||||
changelogTabs.setEnabledAt(TAB_APPLE, needsApple)
|
||||
if (!changelogTabs.isEnabledAt(changelogTabs.selectedIndex)) {
|
||||
changelogTabs.selectedIndex = TAB_FULL
|
||||
}
|
||||
// Empty notes would publish a "What's new" that describes nothing, which
|
||||
// App Store review rejects — after the tag has already been pushed. A
|
||||
// server-only release reaches no store, so it needs no store notes.
|
||||
val needsStoreNotes = platforms.any { it in Platform.CLIENT_STORES }
|
||||
commitButton.isEnabled = platforms.isNotEmpty() &&
|
||||
changeLog.text.isNotBlank() &&
|
||||
(!needsStoreNotes || storeNotes.text.isNotBlank())
|
||||
|
||||
val dropped = deriveStoreNotes(changeLog.text).dropped
|
||||
storeStatus.text = wrapped(
|
||||
when {
|
||||
storeNotes.edited -> "Hand-edited, so it no longer follows the full notes."
|
||||
storeNotesEdited -> "Hand-edited, so it no longer follows the full notes."
|
||||
dropped.isEmpty() -> "Nothing was removed."
|
||||
else -> "Removed ${dropped.size}: " + droppedSummary(dropped)
|
||||
}
|
||||
)
|
||||
storeStatus.foreground = if (storeNotes.edited) editedColor else warningColor
|
||||
storeResync.isEnabled = storeNotes.edited
|
||||
storeStatus.foreground = if (storeNotesEdited) truncateColor else warningColor
|
||||
resyncButton.isEnabled = storeNotesEdited
|
||||
|
||||
playStatus.text = wrapped(followStatus(playNotes.edited))
|
||||
playStatus.foreground = if (playNotes.edited) editedColor else warningColor
|
||||
playResync.isEnabled = playNotes.edited
|
||||
|
||||
appleStatus.text = wrapped(followStatus(appleNotes.edited))
|
||||
appleStatus.foreground = if (appleNotes.edited) editedColor else warningColor
|
||||
appleResync.isEnabled = appleNotes.edited
|
||||
|
||||
// Over-limit blocks the commit instead of truncating quietly: the Play tab
|
||||
// exists so the 500 characters are chosen, not cut off at a bullet boundary.
|
||||
val url = releaseNotesUrl(tag)
|
||||
val playLength = storeNotesLength(playNotes.text, url)
|
||||
val playOver = playLength > PLAY_STORE_LIMIT
|
||||
playPreview.text = formatStoreNotes(playNotes.text, PLAY_STORE_LIMIT, url)
|
||||
val needed = storeNotesLength(storeNotes.text, url)
|
||||
playPreview.text = formatStoreNotes(storeNotes.text, PLAY_STORE_LIMIT, url)
|
||||
playPreview.caretPosition = 0
|
||||
playCount.text = counterText(playLength, PLAY_STORE_LIMIT)
|
||||
playCount.foreground = if (playOver) errorColor else warningColor
|
||||
trimButton.isEnabled = playOver
|
||||
|
||||
val appleLength = storeNotesLength(appleNotes.text, null)
|
||||
val appleOver = appleLength > APPLE_STORE_LIMIT
|
||||
appleCount.text = counterText(appleLength, APPLE_STORE_LIMIT)
|
||||
appleCount.foreground = if (appleOver) errorColor else warningColor
|
||||
|
||||
// Empty notes would publish a "What's new" that describes nothing, which App
|
||||
// Store review rejects, after the tag has already been pushed.
|
||||
commitButton.isEnabled = platforms.isNotEmpty() &&
|
||||
changeLog.text.isNotBlank() &&
|
||||
(!needsShared || storeNotes.text.isNotBlank()) &&
|
||||
(!needsPlay || (playNotes.text.isNotBlank() && !playOver)) &&
|
||||
(!needsApple || (appleNotes.text.isNotBlank() && !appleOver))
|
||||
characterCount.text = if (needed > PLAY_STORE_LIMIT) {
|
||||
"Store notes: ${storeNotes.document.length} · Play: $needed/$PLAY_STORE_LIMIT — will truncate"
|
||||
} else {
|
||||
"Store notes: ${storeNotes.document.length} · Play: $needed/$PLAY_STORE_LIMIT"
|
||||
}
|
||||
characterCount.foreground =
|
||||
if (needed > PLAY_STORE_LIMIT) truncateColor else warningColor
|
||||
}
|
||||
|
||||
changeLog.document.addDocumentListener(OnChangeListener { storeNotes.sync(); refresh() })
|
||||
storeNotes.onChange { playNotes.sync(); appleNotes.sync(); refresh() }
|
||||
playNotes.onChange { refresh() }
|
||||
appleNotes.onChange { refresh() }
|
||||
|
||||
storeResync.addActionListener { storeNotes.resync(); refresh() }
|
||||
playResync.addActionListener { playNotes.resync(); refresh() }
|
||||
appleResync.addActionListener { appleNotes.resync(); refresh() }
|
||||
// Trim puts the fitted text in the editor instead of letting the write path cut
|
||||
// it later, so what the store gets is text someone looked at.
|
||||
trimButton.addActionListener {
|
||||
val budget = storeNotesBudget(PLAY_STORE_LIMIT, releaseNotesUrl(currentTag()))
|
||||
playNotes.overwrite(fitStoreNotes(playNotes.text, budget))
|
||||
changeLog.document.addDocumentListener(OnChangeListener { syncStoreNotes(); refresh() })
|
||||
storeNotes.document.addDocumentListener(OnChangeListener {
|
||||
if (!syncingStoreNotes) storeNotesEdited = true
|
||||
refresh()
|
||||
}
|
||||
})
|
||||
|
||||
// --- Label-value row helper for the Version section ---
|
||||
fun labelPair(label: String, value: JComponent): JPanel = JPanel().apply {
|
||||
|
|
@ -564,22 +364,58 @@ fun configureRelease(currentSemVarStr: String, lastReleaseChangelog: String? = n
|
|||
// ============= Section: Will push tag =============
|
||||
val tagSection = section("Will push tag").apply { add(tagLabel) }
|
||||
|
||||
// ============= Section: After the build =============
|
||||
val autoPublishBox = JCheckBox("Auto-publish the GitHub release").apply {
|
||||
alignmentX = Component.LEFT_ALIGNMENT
|
||||
}
|
||||
val autoPublishSection = section("After the build").apply {
|
||||
add(autoPublishBox)
|
||||
add(
|
||||
tabHint("Off leaves a draft. Publishing starts the store workflow.")
|
||||
.apply { alignmentX = Component.LEFT_ALIGNMENT },
|
||||
)
|
||||
}
|
||||
|
||||
// ============= Section: Changelog =============
|
||||
val changelogSection = section("Changelog")
|
||||
|
||||
changelogSection.add(changelogTabs)
|
||||
val fullTab = JPanel(BorderLayout(0, 4)).apply {
|
||||
border = BorderFactory.createEmptyBorder(8, 0, 0, 0)
|
||||
add(tabHint("Goes to CHANGELOG.md and the GitHub release. Write everything here."), BorderLayout.NORTH)
|
||||
add(JScrollPane(changeLog), BorderLayout.CENTER)
|
||||
}
|
||||
resyncButton.addActionListener { resyncStoreNotes(); refresh() }
|
||||
val storeHeader = JPanel().apply {
|
||||
layout = BoxLayout(this, BoxLayout.Y_AXIS)
|
||||
add(
|
||||
tabHint("Goes to every store listing. Entries tagged for the web or server are filtered out — stores reject notes about anything but the app.").apply {
|
||||
alignmentX = Component.LEFT_ALIGNMENT
|
||||
},
|
||||
)
|
||||
add(storeStatus.apply { alignmentX = Component.LEFT_ALIGNMENT })
|
||||
add(
|
||||
JPanel(FlowLayout(FlowLayout.LEFT, 0, 2)).apply {
|
||||
alignmentX = Component.LEFT_ALIGNMENT
|
||||
add(resyncButton)
|
||||
},
|
||||
)
|
||||
}
|
||||
val storeTab = JPanel(BorderLayout(0, 4)).apply {
|
||||
border = BorderFactory.createEmptyBorder(8, 0, 0, 0)
|
||||
add(storeHeader, BorderLayout.NORTH)
|
||||
add(JScrollPane(storeNotes), BorderLayout.CENTER)
|
||||
}
|
||||
val changelogTabs = JTabbedPane().apply {
|
||||
addTab("Full", fullTab)
|
||||
addTab("App stores", storeTab)
|
||||
}
|
||||
|
||||
val counterRow = JPanel(FlowLayout(FlowLayout.RIGHT, 0, 4)).apply {
|
||||
alignmentX = Component.LEFT_ALIGNMENT
|
||||
add(characterCount)
|
||||
}
|
||||
val editorPane = JPanel(BorderLayout()).apply {
|
||||
add(changelogTabs, BorderLayout.CENTER)
|
||||
add(counterRow, BorderLayout.SOUTH)
|
||||
}
|
||||
val previewPane = JScrollPane(playPreview).apply {
|
||||
border = BorderFactory.createTitledBorder("Google Play preview")
|
||||
}
|
||||
|
||||
val splitPane = JSplitPane(JSplitPane.VERTICAL_SPLIT, editorPane, previewPane).apply {
|
||||
resizeWeight = 0.6
|
||||
border = null
|
||||
alignmentX = Component.LEFT_ALIGNMENT
|
||||
}
|
||||
changelogSection.add(splitPane)
|
||||
|
||||
// ============= Release-type change listeners (after newVersionLabel exists) =============
|
||||
// A patch carries the same notes as the release it patches, so pre-fill the
|
||||
|
|
@ -619,15 +455,8 @@ fun configureRelease(currentSemVarStr: String, lastReleaseChangelog: String? = n
|
|||
result = ReleaseInfo(
|
||||
semVar = newSemVar,
|
||||
changeLog = changeLog.text,
|
||||
// Mirroring fills every editor whatever the scope, so the sinks this
|
||||
// release does not reach are dropped here rather than published.
|
||||
storeNotes = StoreChangelogs(
|
||||
shared = storeNotes.text,
|
||||
play = playNotes.text,
|
||||
apple = appleNotes.text,
|
||||
).restrictedTo(currentPlatforms()),
|
||||
storeChangeLog = storeNotes.text,
|
||||
platforms = currentPlatforms(),
|
||||
autoPublish = autoPublishBox.isSelected,
|
||||
)
|
||||
frame.dispose()
|
||||
}
|
||||
|
|
@ -647,8 +476,6 @@ fun configureRelease(currentSemVarStr: String, lastReleaseChangelog: String? = n
|
|||
add(Box.createRigidArea(Dimension(0, 8)))
|
||||
add(tagSection)
|
||||
add(Box.createRigidArea(Dimension(0, 8)))
|
||||
add(autoPublishSection)
|
||||
add(Box.createRigidArea(Dimension(0, 8)))
|
||||
}
|
||||
val root = JPanel(BorderLayout()).apply {
|
||||
border = BorderFactory.createEmptyBorder(16, 20, 16, 20)
|
||||
|
|
|
|||
|
|
@ -1,57 +0,0 @@
|
|||
package com.darkrockstudios.build
|
||||
|
||||
import org.gradle.api.Project
|
||||
|
||||
/**
|
||||
* Build-side mirror of the runtime `DistributionChannel` enum in `:base`. The two lists have to
|
||||
* stay in step; `DistributionChannelTest` in `:base` pins the token strings so a change to one
|
||||
* without the other fails a test rather than shipping a build that reports the wrong channel.
|
||||
*
|
||||
* Tokens match [Platform.tagToken] wherever the two overlap.
|
||||
*/
|
||||
enum class DistributionChannel(val token: String) {
|
||||
DEV("dev"),
|
||||
GITHUB("github"),
|
||||
PLAY("google-play"),
|
||||
FDROID("fdroid"),
|
||||
SNAP("snap"),
|
||||
APPIMAGE("appimage"),
|
||||
FLATHUB("flathub"),
|
||||
MICROSOFT_STORE("ms-store"),
|
||||
MAC_APP_STORE("mac-app-store"),
|
||||
IOS_APP_STORE("ios-app-store"),
|
||||
;
|
||||
|
||||
companion object {
|
||||
fun fromToken(token: String): DistributionChannel? = entries.firstOrNull { it.token == token }
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolves the channel for this build from `-Pchannel=<token>`, defaulting to [FDROID] when the
|
||||
* F-Droid build flags are set and [DEV] otherwise. Throws on an unknown token, so a typo in a
|
||||
* release workflow fails the build instead of quietly shipping a store binary as [DEV].
|
||||
*/
|
||||
fun resolveDistributionChannel(
|
||||
channelProperty: String?,
|
||||
isFDroid: Boolean,
|
||||
): DistributionChannel {
|
||||
val requested = channelProperty?.trim()?.takeIf { it.isNotEmpty() }
|
||||
?: return if (isFDroid) DistributionChannel.FDROID else DistributionChannel.DEV
|
||||
|
||||
return DistributionChannel.fromToken(requested)
|
||||
?: error(
|
||||
"Unknown -Pchannel=$requested. Valid channels: " +
|
||||
DistributionChannel.entries.joinToString(", ") { it.token }
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* The channel this build is for. The F-Droid detection mirrors settings.gradle.kts, which cannot
|
||||
* see buildSrc, so a plain F-Droid build reports its channel without passing -Pchannel too.
|
||||
*/
|
||||
fun Project.distributionChannel(): DistributionChannel {
|
||||
val isFDroid = findProperty("fdroid")?.toString()?.isNotEmpty() == true ||
|
||||
System.getenv("FDROID_BUILD") != null
|
||||
return resolveDistributionChannel(findProperty("channel")?.toString(), isFDroid)
|
||||
}
|
||||
|
|
@ -1,45 +0,0 @@
|
|||
package com.darkrockstudios.build
|
||||
|
||||
import org.gradle.api.Project
|
||||
|
||||
/**
|
||||
* Build-side mirror of the runtime `PackageFormat` enum in `:base`. The two lists have to stay
|
||||
* in step; `PackageFormatTest` in `:base` pins the token strings so a change to one without the
|
||||
* other fails a test rather than shipping a build that reports the wrong format.
|
||||
*/
|
||||
enum class PackageFormat(val token: String) {
|
||||
NONE("none"),
|
||||
MSI("msi"),
|
||||
EXE("exe"),
|
||||
DEB("deb"),
|
||||
RPM("rpm"),
|
||||
DMG("dmg"),
|
||||
PKG("pkg"),
|
||||
APPIMAGE("appimage"),
|
||||
MSIX("msix"),
|
||||
SNAP("snap"),
|
||||
FLATPAK("flatpak"),
|
||||
;
|
||||
|
||||
companion object {
|
||||
fun fromToken(token: String): PackageFormat? = entries.firstOrNull { it.token == token }
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolves the installer format for this build from `-Pformat=<token>`, defaulting to [NONE]
|
||||
* for anything that is not a packaging run. Throws on an unknown token, so a typo in a release
|
||||
* workflow fails the build instead of quietly shipping an installer that cannot name itself.
|
||||
*/
|
||||
fun resolvePackageFormat(formatProperty: String?): PackageFormat {
|
||||
val requested = formatProperty?.trim()?.takeIf { it.isNotEmpty() } ?: return PackageFormat.NONE
|
||||
|
||||
return PackageFormat.fromToken(requested)
|
||||
?: error(
|
||||
"Unknown -Pformat=$requested. Valid formats: " +
|
||||
PackageFormat.entries.joinToString(", ") { it.token }
|
||||
)
|
||||
}
|
||||
|
||||
/** The installer format this build is packaged as, from `-Pformat`. */
|
||||
fun Project.packageFormat(): PackageFormat = resolvePackageFormat(findProperty("format")?.toString())
|
||||
|
|
@ -25,14 +25,7 @@ private val SECTION_HEADER = Regex("""^\[[^]]+]$""")
|
|||
/** The longest `Label:` prefix treated as an audience label rather than prose. */
|
||||
private const val MAX_LABEL_LENGTH = 20
|
||||
|
||||
/**
|
||||
* The GitHub release page for a release tag, where the untruncated notes live.
|
||||
*
|
||||
* Only Google Play is given this link. App Store review rejects release notes that
|
||||
* link to the project's GitHub page — it offers the app outside the App Store — so
|
||||
* the Apple stores pass `null` as the footer URL. The link shipped to iOS and macOS
|
||||
* from v3.7.0 onward and drew "invalid metadata" rejections until it was removed.
|
||||
*/
|
||||
/** The GitHub release page for a release tag, where the untruncated notes live. */
|
||||
fun releaseNotesUrl(tag: String): String = "$GITHUB_REPO/releases/tag/$tag"
|
||||
|
||||
/**
|
||||
|
|
@ -163,9 +156,8 @@ fun deriveStoreNotes(fullChangelog: String): StoreNotesDerivation {
|
|||
}
|
||||
|
||||
/**
|
||||
* Fits [changelog] into a store's character limit, leaving room for a footer pointing
|
||||
* at the full notes when [fullNotesUrl] is given — see [releaseNotesUrl] for why the
|
||||
* Apple stores pass null. Whole entries are kept so the text does not end
|
||||
* Fits [changelog] into a store's character limit, always leaving room for a footer
|
||||
* pointing at the full notes. Whole entries are kept so the text does not end
|
||||
* mid-bullet, and an entry too long for the remaining budget is skipped rather than
|
||||
* ending the notes. The `…` mark is appended only when an entry was actually left
|
||||
* out; text that fits once its blank lines are collapsed is not marked truncated.
|
||||
|
|
@ -177,34 +169,18 @@ fun deriveStoreNotes(fullChangelog: String): StoreNotesDerivation {
|
|||
* A blank changelog yields an empty string: store metadata that is only a link
|
||||
* describes nothing, and App Store review rejects it.
|
||||
*/
|
||||
fun formatStoreNotes(changelog: String, limit: Int, fullNotesUrl: String?): String {
|
||||
fun formatStoreNotes(changelog: String, limit: Int, fullNotesUrl: String): String {
|
||||
val body = normalizeNotes(changelog)
|
||||
if (body.isEmpty()) return ""
|
||||
|
||||
val footer = footerFor(fullNotesUrl)
|
||||
val budget = storeNotesBudget(limit, fullNotesUrl)
|
||||
val budget = limit - footer.length
|
||||
require(budget > 0) { "Limit $limit is too small to hold the changelog footer" }
|
||||
|
||||
return fitStoreNotes(body, budget) + footer
|
||||
}
|
||||
|
||||
/** How much of a store's limit is left for the notes once its footer is subtracted. */
|
||||
fun storeNotesBudget(limit: Int, fullNotesUrl: String?): Int = limit - footerFor(fullNotesUrl).length
|
||||
|
||||
/**
|
||||
* [changelog] cut down to [limit] characters, footer excluded. Returns the body on
|
||||
* its own so the release dialog can put the fitted text back in the Google Play
|
||||
* editor for the user to improve, rather than appending a footer that the next
|
||||
* [formatStoreNotes] call would append again.
|
||||
*/
|
||||
fun fitStoreNotes(changelog: String, limit: Int): String {
|
||||
val body = normalizeNotes(changelog)
|
||||
if (body.isEmpty()) return ""
|
||||
require(limit > 0) { "Limit $limit leaves no room for notes" }
|
||||
if (body.length <= limit) return body
|
||||
if (body.length <= budget) return body + footer
|
||||
|
||||
val entries = parseEntries(body)
|
||||
val truncatedBudget = limit - TRUNCATION_MARK.length
|
||||
val truncatedBudget = budget - TRUNCATION_MARK.length
|
||||
val kept = mutableListOf<Entry>()
|
||||
for (entry in entries) {
|
||||
val candidate = pruneEmptySections(kept + entry)
|
||||
|
|
@ -218,27 +194,21 @@ fun fitStoreNotes(changelog: String, limit: Int): String {
|
|||
val hardCut = body.take(truncatedBudget).trimEnd()
|
||||
val lastSpace = hardCut.lastIndexOf(' ')
|
||||
val cut = if (lastSpace > 0) hardCut.take(lastSpace) else hardCut
|
||||
return cut.trimEnd() + TRUNCATION_MARK
|
||||
return cut.trimEnd() + TRUNCATION_MARK + footer
|
||||
}
|
||||
|
||||
val fitted = render(pruneEmptySections(kept)).trimEnd()
|
||||
val mark = if (kept.size == entries.size) "" else TRUNCATION_MARK
|
||||
return fitted + mark
|
||||
return fitted + mark + footer
|
||||
}
|
||||
|
||||
/**
|
||||
* How many characters [changelog] needs at a store, including the footer when
|
||||
* [fullNotesUrl] is given. Pass the same URL the store's [formatStoreNotes] call
|
||||
* gets — null for the Apple stores — or the "will it truncate?" comparison is off
|
||||
* by the footer's length.
|
||||
*/
|
||||
fun storeNotesLength(changelog: String, fullNotesUrl: String?): Int {
|
||||
/** How many characters [changelog] needs at a store, footer included. */
|
||||
fun storeNotesLength(changelog: String, fullNotesUrl: String): Int {
|
||||
val body = normalizeNotes(changelog)
|
||||
return if (body.isEmpty()) 0 else body.length + footerFor(fullNotesUrl).length
|
||||
}
|
||||
|
||||
private fun footerFor(fullNotesUrl: String?) =
|
||||
if (fullNotesUrl == null) "" else "\n\n$FOOTER_LABEL\n$fullNotesUrl"
|
||||
private fun footerFor(fullNotesUrl: String) = "\n\n$FOOTER_LABEL\n$fullNotesUrl"
|
||||
|
||||
/**
|
||||
* CHANGELOG.md is checked out with CRLF on Windows and seeds the release dialog, so
|
||||
|
|
@ -246,55 +216,3 @@ private fun footerFor(fullNotesUrl: String?) =
|
|||
* lines with LF) and the verbatim path would disagree with the counted length.
|
||||
*/
|
||||
private fun normalizeNotes(changelog: String) = changelog.replace("\r\n", "\n").trim()
|
||||
|
||||
/**
|
||||
* The release notes each store listing carries.
|
||||
*
|
||||
* [shared] is the app-only text produced by filtering the full changelog; [play]
|
||||
* and [apple] begin as copies of it and diverge only when hand-edited in the
|
||||
* release dialog. The split exists because the two sinks have incompatible
|
||||
* budgets (500 characters with a footer link at Google Play against 4000 with no
|
||||
* link at Apple), so text written to fill one is machine-mangled at the other.
|
||||
*
|
||||
* Stores with no tab of their own take [shared]: F-Droid and Google Play read the
|
||||
* same fastlane file, so F-Droid is covered by [play].
|
||||
*/
|
||||
data class StoreChangelogs(
|
||||
val shared: String,
|
||||
val play: String = shared,
|
||||
val apple: String = shared,
|
||||
) {
|
||||
fun notesFor(platform: Platform): String = when (platform) {
|
||||
Platform.GOOGLE_PLAY, Platform.FDROID -> play
|
||||
Platform.IOS_APP_STORE, Platform.MAC_APP_STORE -> apple
|
||||
Platform.SNAP, Platform.MS_STORE, Platform.SERVER -> shared
|
||||
}
|
||||
|
||||
/** The same notes with every sink this release does not reach blanked out. */
|
||||
fun restrictedTo(platforms: Set<Platform>) = StoreChangelogs(
|
||||
shared = if (reachesFlathub(platforms)) shared else "",
|
||||
play = if (reachesPlay(platforms)) play else "",
|
||||
apple = if (reachesApple(platforms)) apple else "",
|
||||
)
|
||||
|
||||
/** True when no listing would carry any text at all. */
|
||||
val isBlank: Boolean get() = shared.isBlank() && play.isBlank() && apple.isBlank()
|
||||
}
|
||||
|
||||
/** Whether a release to [platforms] reaches the Google Play file, which F-Droid also reads. */
|
||||
fun reachesPlay(platforms: Set<Platform>) =
|
||||
Platform.GOOGLE_PLAY in platforms || Platform.FDROID in platforms
|
||||
|
||||
/** Whether a release to [platforms] reaches either Apple listing. */
|
||||
fun reachesApple(platforms: Set<Platform>) =
|
||||
Platform.IOS_APP_STORE in platforms || Platform.MAC_APP_STORE in platforms
|
||||
|
||||
/** Whether a release to [platforms] reaches any store listing at all. */
|
||||
fun reachesAnyStore(platforms: Set<Platform>) = platforms.any { it in Platform.CLIENT_STORES }
|
||||
|
||||
/**
|
||||
* Whether a release to [platforms] is a Flathub release. Flathub is published by hand
|
||||
* with `publishFlathub`, outside the tag's store scope, so only a release that ships
|
||||
* every client store counts; a targeted hotfix names its stores and Flathub is not one.
|
||||
*/
|
||||
fun reachesFlathub(platforms: Set<Platform>) = platforms.containsAll(Platform.CLIENT_STORES)
|
||||
|
|
|
|||
|
|
@ -1,145 +0,0 @@
|
|||
package com.darkrockstudios.build
|
||||
|
||||
import org.gradle.api.Project
|
||||
import java.io.File
|
||||
import java.security.MessageDigest
|
||||
import java.time.Instant
|
||||
import java.time.temporal.ChronoUnit
|
||||
import java.util.Base64
|
||||
|
||||
/** One attached installer as the update metadata describes it. */
|
||||
data class UpdateAsset(val name: String, val size: Long, val sha512: String)
|
||||
|
||||
/**
|
||||
* The metadata files the desktop updater reads, each listing its platform's installers in
|
||||
* preference order. The format is electron-builder's `latest.yml`, which Nucleus parses; the
|
||||
* `url` is the bare asset name and the client prefixes the release URL.
|
||||
*/
|
||||
private val UPDATE_METADATA_FILES: Map<String, List<String>> = mapOf(
|
||||
"latest.yml" to listOf("hammer.msi", "hammer.exe"),
|
||||
"latest-mac.yml" to listOf("hammer.zip", "hammer.dmg", "hammer.pkg"),
|
||||
"latest-linux.yml" to listOf("hammer.deb", "hammer.rpm", "hammer.AppImage"),
|
||||
)
|
||||
|
||||
/** Assets a release may legitimately lack; every other listed name must have a digest. */
|
||||
private val OPTIONAL_ASSETS: Set<String> = setOf("hammer.zip")
|
||||
|
||||
/** Suffix of the sidecar each platform job writes next to its installer: `<base64 sha512> <bytes>`. */
|
||||
const val DIGEST_SUFFIX = ".sha512"
|
||||
|
||||
private const val SHA512_BUFFER_SIZE = 1 shl 16
|
||||
|
||||
fun renderUpdateMetadata(version: String, releaseDate: String, assets: List<UpdateAsset>): String =
|
||||
buildString {
|
||||
appendLine("version: $version")
|
||||
appendLine("releaseDate: $releaseDate")
|
||||
appendLine("files:")
|
||||
for (asset in assets) {
|
||||
appendLine(" - url: ${asset.name}")
|
||||
appendLine(" sha512: ${asset.sha512}")
|
||||
appendLine(" size: ${asset.size}")
|
||||
}
|
||||
}
|
||||
|
||||
/** Base64 of the SHA-512 digest, which is how Nucleus's checksum verifier compares it. */
|
||||
fun sha512Base64(file: File): String {
|
||||
val digest = MessageDigest.getInstance("SHA-512")
|
||||
file.inputStream().use { input ->
|
||||
val buffer = ByteArray(SHA512_BUFFER_SIZE)
|
||||
while (true) {
|
||||
val read = input.read(buffer)
|
||||
if (read < 0) break
|
||||
digest.update(buffer, 0, read)
|
||||
}
|
||||
}
|
||||
return Base64.getEncoder().encodeToString(digest.digest())
|
||||
}
|
||||
|
||||
/** The one-line sidecar content for an installer. */
|
||||
fun assetDigestLine(file: File): String = "${sha512Base64(file)} ${file.length()}"
|
||||
|
||||
fun parseAssetDigest(name: String, line: String): UpdateAsset {
|
||||
val parts = line.trim().split(' ')
|
||||
require(parts.size == 2) { "Digest for $name should be '<base64 sha512> <bytes>', got '$line'" }
|
||||
val size = parts[1].toLongOrNull() ?: error("Digest for $name has a non-numeric size: '$line'")
|
||||
return UpdateAsset(name = name, size = size, sha512 = parts[0])
|
||||
}
|
||||
|
||||
/**
|
||||
* Builds every metadata file from the digest sidecars anywhere under [digestsDir], keyed by file
|
||||
* name: upload-artifact keeps each installer's format subdirectory. A required installer with no
|
||||
* sidecar fails loudly: a yml that omits it would silently leave that format with no update path.
|
||||
*/
|
||||
fun updateMetadataFiles(digestsDir: File, version: String, releaseDate: String): Map<String, String> {
|
||||
val sidecars = digestsDir.walk()
|
||||
.filter { it.isFile && it.name.endsWith(DIGEST_SUFFIX) }
|
||||
.groupBy { it.name }
|
||||
.mapValues { (name, found) ->
|
||||
found.singleOrNull() ?: error("$digestsDir has more than one $name: $found")
|
||||
}
|
||||
return UPDATE_METADATA_FILES.mapValues { (metadataFile, names) ->
|
||||
val assets = names.mapNotNull { name ->
|
||||
val sidecar = sidecars[name + DIGEST_SUFFIX]
|
||||
when {
|
||||
sidecar != null -> parseAssetDigest(name, sidecar.readText())
|
||||
name in OPTIONAL_ASSETS -> null
|
||||
else -> error("$metadataFile needs $name$DIGEST_SUFFIX, but $digestsDir has no such file")
|
||||
}
|
||||
}
|
||||
renderUpdateMetadata(version, releaseDate, assets)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Registers the two halves of the update metadata:
|
||||
*
|
||||
* - `writeAssetDigests -PassetFiles=<path,path>` runs in each platform job right after it
|
||||
* packages and renames its installers, writing `<installer>.sha512` beside each one. The
|
||||
* sidecars travel to the final job as a workflow artifact, so no installer is downloaded
|
||||
* twice and no release lookup is needed.
|
||||
* - `writeUpdateMetadata -PreleaseDigestsDir=<dir>` runs once in the final job over the
|
||||
* collected sidecars and writes the yml files to `build/update-metadata/`.
|
||||
*/
|
||||
fun Project.registerUpdateMetadataTasks(appVersion: String) {
|
||||
tasks.register("writeAssetDigests") {
|
||||
group = "distribution"
|
||||
description = "Writes a <installer>.sha512 sidecar beside each installer in -PassetFiles (comma separated)."
|
||||
|
||||
doLast {
|
||||
val files = findProperty("assetFiles")?.toString()
|
||||
?.split(',')
|
||||
?.map { it.trim() }
|
||||
?.filter { it.isNotEmpty() }
|
||||
?.takeIf { it.isNotEmpty() }
|
||||
?: error("Pass -PassetFiles=<installer>[,<installer>...]")
|
||||
for (path in files) {
|
||||
val file = File(path)
|
||||
check(file.isFile) { "No installer at $path" }
|
||||
val sidecar = File(file.path + DIGEST_SUFFIX)
|
||||
sidecar.writeText(assetDigestLine(file))
|
||||
println("Wrote $sidecar")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
tasks.register("writeUpdateMetadata") {
|
||||
group = "distribution"
|
||||
description = "Writes the latest*.yml files the desktop updater reads, from the digest sidecars in -PreleaseDigestsDir."
|
||||
|
||||
doLast {
|
||||
val digestsDir = File(
|
||||
findProperty("releaseDigestsDir")?.toString()
|
||||
?: error("Pass -PreleaseDigestsDir=<directory holding the *$DIGEST_SUFFIX sidecars>")
|
||||
)
|
||||
val outputDir = layout.buildDirectory.dir("update-metadata").get().asFile
|
||||
outputDir.mkdirs()
|
||||
|
||||
val releaseDate = Instant.now().truncatedTo(ChronoUnit.SECONDS).toString()
|
||||
for ((name, content) in updateMetadataFiles(digestsDir, appVersion, releaseDate)) {
|
||||
val target = outputDir.resolve(name)
|
||||
target.writeText(content)
|
||||
println("Wrote $target")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -8,29 +8,9 @@ fun getVersionCode(semVarStr: String): Int {
|
|||
val semVar = parseSemVar(semVarStr)
|
||||
val versionCode = semVar.createVersionCode(isRelease, buildNumber)
|
||||
|
||||
require(versionCode in 0 until WEAR_VERSION_CODE_OFFSET) {
|
||||
"Version $semVarStr produces version code $versionCode, which runs into the Wear OS band at " +
|
||||
"$WEAR_VERSION_CODE_OFFSET. Rework the version code scheme before releasing 10.0.0."
|
||||
}
|
||||
|
||||
return versionCode
|
||||
}
|
||||
|
||||
/**
|
||||
* Play needs unique version codes across every bundle in a listing. Phone codes stay below this
|
||||
* while the major version is below 10, and phone plus offset stays under Play's 2,100,000,000 cap.
|
||||
*/
|
||||
private const val WEAR_VERSION_CODE_OFFSET = 1_000_000_000
|
||||
|
||||
fun getWearVersionCode(semVarStr: String): Int = wearVersionCode(getVersionCode(semVarStr))
|
||||
|
||||
fun wearVersionCode(phoneVersionCode: Int): Int {
|
||||
require(phoneVersionCode in 0 until WEAR_VERSION_CODE_OFFSET) {
|
||||
"Phone version code $phoneVersionCode leaves no room for the wear offset"
|
||||
}
|
||||
return phoneVersionCode + WEAR_VERSION_CODE_OFFSET
|
||||
}
|
||||
|
||||
data class SemVar(
|
||||
val major: Int,
|
||||
val minor: Int,
|
||||
|
|
|
|||
|
|
@ -1,50 +0,0 @@
|
|||
import com.darkrockstudios.build.DistributionChannel
|
||||
import com.darkrockstudios.build.resolveDistributionChannel
|
||||
import kotlin.test.Test
|
||||
import kotlin.test.assertEquals
|
||||
import kotlin.test.assertFailsWith
|
||||
|
||||
class DistributionChannelTest {
|
||||
|
||||
@Test
|
||||
fun `no channel property is a dev build`() {
|
||||
assertEquals(DistributionChannel.DEV, resolveDistributionChannel(null, isFDroid = false))
|
||||
assertEquals(DistributionChannel.DEV, resolveDistributionChannel("", isFDroid = false))
|
||||
assertEquals(DistributionChannel.DEV, resolveDistributionChannel(" ", isFDroid = false))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `the fdroid build flag stands in for the channel property`() {
|
||||
assertEquals(DistributionChannel.FDROID, resolveDistributionChannel(null, isFDroid = true))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `an explicit channel wins over the fdroid flag`() {
|
||||
assertEquals(
|
||||
DistributionChannel.GITHUB,
|
||||
resolveDistributionChannel("github", isFDroid = true)
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `every token resolves to its own channel`() {
|
||||
for (channel in DistributionChannel.entries) {
|
||||
assertEquals(channel, resolveDistributionChannel(channel.token, isFDroid = false))
|
||||
}
|
||||
}
|
||||
|
||||
/** A typo in a release workflow must fail the build, not ship a store binary as DEV. */
|
||||
@Test
|
||||
fun `an unknown token fails the build`() {
|
||||
val error = assertFailsWith<IllegalStateException> {
|
||||
resolveDistributionChannel("microsoftStore", isFDroid = false)
|
||||
}
|
||||
assertEquals(true, error.message?.contains("ms-store"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `tokens are unique`() {
|
||||
val tokens = DistributionChannel.entries.map { it.token }
|
||||
assertEquals(tokens.size, tokens.toSet().size)
|
||||
}
|
||||
}
|
||||
|
|
@ -1,34 +0,0 @@
|
|||
import com.darkrockstudios.build.PackageFormat
|
||||
import com.darkrockstudios.build.resolvePackageFormat
|
||||
import kotlin.test.Test
|
||||
import kotlin.test.assertEquals
|
||||
import kotlin.test.assertFailsWith
|
||||
|
||||
class PackageFormatTest {
|
||||
|
||||
@Test
|
||||
fun `no format property means not packaged`() {
|
||||
assertEquals(PackageFormat.NONE, resolvePackageFormat(null))
|
||||
assertEquals(PackageFormat.NONE, resolvePackageFormat(""))
|
||||
assertEquals(PackageFormat.NONE, resolvePackageFormat(" "))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `every token resolves to its own format`() {
|
||||
for (format in PackageFormat.entries) {
|
||||
assertEquals(format, resolvePackageFormat(format.token))
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `an unknown token fails the build`() {
|
||||
val error = assertFailsWith<IllegalStateException> { resolvePackageFormat("windows") }
|
||||
assertEquals(true, error.message?.contains("Unknown -Pformat=windows"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `tokens are unique`() {
|
||||
val tokens = PackageFormat.entries.map { it.token }
|
||||
assertEquals(tokens.toSet().size, tokens.size)
|
||||
}
|
||||
}
|
||||
|
|
@ -1,65 +0,0 @@
|
|||
package com.darkrockstudios.build
|
||||
|
||||
import kotlin.test.Test
|
||||
import kotlin.test.assertEquals
|
||||
import kotlin.test.assertFalse
|
||||
import kotlin.test.assertTrue
|
||||
|
||||
class ReleaseInfoTest {
|
||||
|
||||
private fun releaseInfo(autoPublish: Boolean) = ReleaseInfo(
|
||||
semVar = SemVar(1, 2, 3),
|
||||
changeLog = "Added a thing\n- detail one",
|
||||
storeNotes = StoreChangelogs("Added a thing"),
|
||||
platforms = Platform.ALL,
|
||||
autoPublish = autoPublish,
|
||||
)
|
||||
|
||||
@Test
|
||||
fun `Auto-publish is off by default`() {
|
||||
assertFalse(
|
||||
ReleaseInfo(
|
||||
semVar = SemVar(1, 2, 3),
|
||||
changeLog = "notes",
|
||||
storeNotes = StoreChangelogs("notes"),
|
||||
platforms = Platform.ALL,
|
||||
).autoPublish
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `Tag message is the changelog alone when auto-publish is off`() {
|
||||
assertEquals("Added a thing\n- detail one", releaseInfo(autoPublish = false).tagMessage)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `Tag message appends the trailer as its own last line when auto-publish is on`() {
|
||||
val lines = releaseInfo(autoPublish = true).tagMessage.lines()
|
||||
assertEquals(AUTO_PUBLISH_TRAILER, lines.last())
|
||||
// The blank line keeps the trailer out of the last changelog paragraph, so
|
||||
// set-release-body strips it without leaving a dangling line in the body.
|
||||
assertEquals("", lines[lines.size - 2])
|
||||
}
|
||||
|
||||
// set-release-body greps for this exact whole line; a reword here silently stops
|
||||
// CI from publishing.
|
||||
@Test
|
||||
fun `Trailer wire format is stable`() {
|
||||
assertEquals("Auto-Publish: true", AUTO_PUBLISH_TRAILER)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `Dropping the trailer restores the plain changelog`() {
|
||||
val stripped = releaseInfo(autoPublish = true).tagMessage
|
||||
.lines()
|
||||
.filterNot { it == AUTO_PUBLISH_TRAILER }
|
||||
.joinToString("\n")
|
||||
.trimEnd()
|
||||
assertEquals(releaseInfo(autoPublish = false).tagMessage, stripped)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `Trailer never leaks into a release that did not ask for it`() {
|
||||
assertTrue(AUTO_PUBLISH_TRAILER !in releaseInfo(autoPublish = false).tagMessage)
|
||||
}
|
||||
}
|
||||
|
|
@ -1,151 +0,0 @@
|
|||
package com.darkrockstudios.build
|
||||
|
||||
import kotlin.test.Test
|
||||
import kotlin.test.assertEquals
|
||||
import kotlin.test.assertFalse
|
||||
import kotlin.test.assertTrue
|
||||
|
||||
class StoreChangelogsTest {
|
||||
|
||||
private val url = releaseNotesUrl("v3.7.0")
|
||||
|
||||
@Test
|
||||
fun `A store with no tab of its own takes the shared notes`() {
|
||||
val notes = StoreChangelogs(shared = "shared", play = "play", apple = "apple")
|
||||
|
||||
assertEquals("shared", notes.notesFor(Platform.SNAP))
|
||||
assertEquals("shared", notes.notesFor(Platform.MS_STORE))
|
||||
assertEquals("shared", notes.notesFor(Platform.SERVER))
|
||||
}
|
||||
|
||||
// F-Droid and Google Play read the same fastlane file, so they cannot disagree.
|
||||
@Test
|
||||
fun `F-Droid takes the Google Play notes`() {
|
||||
val notes = StoreChangelogs(shared = "shared", play = "play", apple = "apple")
|
||||
|
||||
assertEquals(notes.notesFor(Platform.GOOGLE_PLAY), notes.notesFor(Platform.FDROID))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `Both Apple stores take the Apple notes`() {
|
||||
val notes = StoreChangelogs(shared = "shared", play = "play", apple = "apple")
|
||||
|
||||
assertEquals("apple", notes.notesFor(Platform.IOS_APP_STORE))
|
||||
assertEquals("apple", notes.notesFor(Platform.MAC_APP_STORE))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `Stores follow the shared notes until they are given their own`() {
|
||||
val notes = StoreChangelogs(shared = "shared")
|
||||
|
||||
Platform.values().forEach { platform ->
|
||||
assertEquals("shared", notes.notesFor(platform), "$platform diverged")
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `Blank is only blank when every sink is`() {
|
||||
assertTrue(StoreChangelogs(shared = "").isBlank)
|
||||
assertFalse(StoreChangelogs(shared = "", play = "play", apple = "").isBlank)
|
||||
assertFalse(StoreChangelogs(shared = "shared").isBlank)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `Fitted notes carry no footer`() {
|
||||
val changelog = (1..40).joinToString("\n") { "- [New] Feature number $it in this release" }
|
||||
|
||||
val fitted = fitStoreNotes(changelog, storeNotesBudget(PLAY_STORE_LIMIT, url))
|
||||
|
||||
assertFalse(url in fitted, "The footer url leaked into the fitted body")
|
||||
assertTrue(fitted.length <= storeNotesBudget(PLAY_STORE_LIMIT, url))
|
||||
}
|
||||
|
||||
// The dialog's Trim to fit puts fitted text back in the editor, which is then
|
||||
// formatted again on the way to the store. A footer in the fitted body would be
|
||||
// published twice and eat the budget a second time.
|
||||
@Test
|
||||
fun `Trimming then publishing stays within the limit with one footer`() {
|
||||
val changelog = (1..40).joinToString("\n") { "- [New] Feature number $it in this release" }
|
||||
|
||||
val trimmed = fitStoreNotes(changelog, storeNotesBudget(PLAY_STORE_LIMIT, url))
|
||||
val published = formatStoreNotes(trimmed, PLAY_STORE_LIMIT, url)
|
||||
|
||||
assertTrue(published.length <= PLAY_STORE_LIMIT, "Notes were ${published.length} characters")
|
||||
assertEquals(1, published.split(url).size - 1, "The footer was published more than once")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `Trimming already fitted notes changes nothing`() {
|
||||
val changelog = (1..40).joinToString("\n") { "- [New] Feature number $it in this release" }
|
||||
val budget = storeNotesBudget(PLAY_STORE_LIMIT, url)
|
||||
|
||||
val once = fitStoreNotes(changelog, budget)
|
||||
|
||||
assertEquals(once, fitStoreNotes(once, budget))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `Publishing is fitting plus the footer`() {
|
||||
val changelog = (1..40).joinToString("\n") { "- [New] Feature number $it in this release" }
|
||||
|
||||
assertEquals(
|
||||
fitStoreNotes(changelog, storeNotesBudget(PLAY_STORE_LIMIT, url)) + "\n\nFull changelog:\n$url",
|
||||
formatStoreNotes(changelog, PLAY_STORE_LIMIT, url),
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `The Apple budget is the whole limit because it carries no footer`() {
|
||||
assertEquals(APPLE_STORE_LIMIT, storeNotesBudget(APPLE_STORE_LIMIT, null))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `A server-only release publishes no store notes`() {
|
||||
val notes = StoreChangelogs(shared = "shared", play = "play", apple = "apple")
|
||||
|
||||
assertTrue(notes.restrictedTo(setOf(Platform.SERVER)).isBlank)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `A release skips the sinks it does not reach`() {
|
||||
val notes = StoreChangelogs(shared = "shared", play = "play", apple = "apple")
|
||||
|
||||
val playOnly = notes.restrictedTo(setOf(Platform.GOOGLE_PLAY))
|
||||
|
||||
assertEquals("play", playOnly.play)
|
||||
assertEquals("", playOnly.apple)
|
||||
assertEquals("", playOnly.shared)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `Only a release to every client store reaches Flathub`() {
|
||||
val notes = StoreChangelogs(shared = "shared", play = "play", apple = "apple")
|
||||
|
||||
assertEquals("shared", notes.restrictedTo(Platform.CLIENT_STORES).shared)
|
||||
assertEquals("", notes.restrictedTo(Platform.CLIENT_STORES - Platform.SNAP).shared)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `An F-Droid release still fills the Google Play file they share`() {
|
||||
val notes = StoreChangelogs(shared = "shared", play = "play", apple = "apple")
|
||||
|
||||
assertEquals("play", notes.restrictedTo(setOf(Platform.FDROID)).play)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `A mac-only release keeps the Apple notes both listings share`() {
|
||||
val notes = StoreChangelogs(shared = "shared", play = "play", apple = "apple")
|
||||
|
||||
val macOnly = notes.restrictedTo(setOf(Platform.MAC_APP_STORE))
|
||||
|
||||
assertEquals("apple", macOnly.apple)
|
||||
assertEquals("", macOnly.play)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `A full release keeps every sink`() {
|
||||
val notes = StoreChangelogs(shared = "shared", play = "play", apple = "apple")
|
||||
|
||||
assertEquals(notes, notes.restrictedTo(Platform.ALL))
|
||||
}
|
||||
}
|
||||
|
|
@ -213,47 +213,6 @@ class StoreNotesTest {
|
|||
assertEquals(shortNotes.length, storeNotesLength(short, url))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `A null url publishes the notes with no footer and no link`() {
|
||||
val changelog = "- [Fix] A bug\n- [New] A feature"
|
||||
|
||||
val notes = formatStoreNotes(changelog, APPLE_STORE_LIMIT, null)
|
||||
|
||||
assertEquals(changelog, notes)
|
||||
assertEquals(changelog.length, storeNotesLength(changelog, null))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `Apple notes never carry a link that App Store review would reject`() {
|
||||
// The regression that broke every iOS and macOS submission from v3.7.0: the
|
||||
// footer was appended for Apple too, so the notes ended in a github.com URL.
|
||||
val changelog = (1..400).joinToString("\n") { "- [New] Feature number $it in this release" }
|
||||
|
||||
listOf(
|
||||
formatStoreNotes("- [Fix] A bug", APPLE_STORE_LIMIT, null),
|
||||
formatStoreNotes(changelog, APPLE_STORE_LIMIT, null),
|
||||
formatStoreNotes("word ".repeat(2000).trim(), APPLE_STORE_LIMIT, null),
|
||||
).forEach { notes ->
|
||||
assertTrue(!notes.contains("github.com"), "Apple notes carry a GitHub link:\n$notes")
|
||||
assertTrue(!notes.contains("Full changelog:"), "Apple notes carry the footer:\n$notes")
|
||||
assertTrue(notes.length <= APPLE_STORE_LIMIT, "Notes were ${notes.length} characters")
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `Dropping the footer gives the body more of the budget`() {
|
||||
val changelog = (1..40).joinToString("\n") { "- [New] Feature number $it in this release" }
|
||||
|
||||
val withFooter = formatStoreNotes(changelog, PLAY_STORE_LIMIT, url)
|
||||
val without = formatStoreNotes(changelog, PLAY_STORE_LIMIT, null)
|
||||
|
||||
assertTrue(
|
||||
publishedBody(without).length > publishedBody(withFooter).length,
|
||||
"The footer's characters were not returned to the body",
|
||||
)
|
||||
assertTrue(without.length <= PLAY_STORE_LIMIT, "Notes were ${without.length} characters")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `Release url points at the tag`() {
|
||||
assertEquals(
|
||||
|
|
|
|||
|
|
@ -1,140 +0,0 @@
|
|||
import com.darkrockstudios.build.DIGEST_SUFFIX
|
||||
import com.darkrockstudios.build.UpdateAsset
|
||||
import com.darkrockstudios.build.assetDigestLine
|
||||
import com.darkrockstudios.build.parseAssetDigest
|
||||
import com.darkrockstudios.build.renderUpdateMetadata
|
||||
import com.darkrockstudios.build.sha512Base64
|
||||
import com.darkrockstudios.build.updateMetadataFiles
|
||||
import java.io.File
|
||||
import java.nio.file.Files
|
||||
import java.security.MessageDigest
|
||||
import java.util.Base64
|
||||
import kotlin.test.Test
|
||||
import kotlin.test.assertEquals
|
||||
import kotlin.test.assertFailsWith
|
||||
import kotlin.test.assertNull
|
||||
|
||||
class UpdateMetadataTest {
|
||||
|
||||
private val installers = listOf(
|
||||
"hammer.msi", "hammer.exe", "hammer.dmg", "hammer.pkg", "hammer.deb", "hammer.rpm", "hammer.AppImage",
|
||||
)
|
||||
|
||||
@Test
|
||||
fun `renders the electron-builder layout Nucleus parses`() {
|
||||
val yml = renderUpdateMetadata(
|
||||
version = "3.12.0",
|
||||
releaseDate = "2026-10-20T18:04:11Z",
|
||||
assets = listOf(
|
||||
UpdateAsset("hammer.msi", 190034708, "abc="),
|
||||
UpdateAsset("hammer.exe", 190100000, "def="),
|
||||
),
|
||||
)
|
||||
|
||||
val expected = """
|
||||
|version: 3.12.0
|
||||
|releaseDate: 2026-10-20T18:04:11Z
|
||||
|files:
|
||||
| - url: hammer.msi
|
||||
| sha512: abc=
|
||||
| size: 190034708
|
||||
| - url: hammer.exe
|
||||
| sha512: def=
|
||||
| size: 190100000
|
||||
|""".trimMargin()
|
||||
assertEquals(expected, yml)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `sha512 is base64 of the digest`() {
|
||||
val file = tempDir().resolve("asset.bin").apply { writeBytes(ByteArray(100_000) { it.toByte() }) }
|
||||
|
||||
val expected = Base64.getEncoder().encodeToString(
|
||||
MessageDigest.getInstance("SHA-512").digest(file.readBytes())
|
||||
)
|
||||
assertEquals(expected, sha512Base64(file))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a digest line round-trips through the sidecar`() {
|
||||
val file = tempDir().resolve("hammer.msi").apply { writeBytes(ByteArray(12_345) { (it * 3).toByte() }) }
|
||||
|
||||
val asset = parseAssetDigest("hammer.msi", assetDigestLine(file) + "\n")
|
||||
|
||||
assertEquals(UpdateAsset("hammer.msi", 12_345, sha512Base64(file)), asset)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a malformed sidecar is rejected`() {
|
||||
assertFailsWith<IllegalArgumentException> { parseAssetDigest("hammer.msi", "abc=") }
|
||||
assertFailsWith<IllegalStateException> { parseAssetDigest("hammer.msi", "abc= many") }
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `every platform file lists its installers from the sidecars`() {
|
||||
val dir = tempDir()
|
||||
installers.forEachIndexed { i, name -> dir.sidecar(name, "digest$i= ${100 + i}") }
|
||||
|
||||
val files = updateMetadataFiles(dir, "3.12.0", "2026-10-20T18:04:11Z")
|
||||
|
||||
assertEquals(setOf("latest.yml", "latest-mac.yml", "latest-linux.yml"), files.keys)
|
||||
assertEquals(listOf("hammer.msi", "hammer.exe"), urlsIn(files.getValue("latest.yml")))
|
||||
assertEquals(listOf("hammer.dmg", "hammer.pkg"), urlsIn(files.getValue("latest-mac.yml")))
|
||||
assertEquals(
|
||||
listOf("hammer.deb", "hammer.rpm", "hammer.AppImage"),
|
||||
urlsIn(files.getValue("latest-linux.yml")),
|
||||
)
|
||||
assertEquals(" sha512: digest0=", files.getValue("latest.yml").lines()[4])
|
||||
assertEquals(" size: 100", files.getValue("latest.yml").lines()[5])
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `sidecars are found inside the per-format subdirectories the artifacts keep`() {
|
||||
val dir = tempDir()
|
||||
installers.forEach { dir.resolve(it.substringAfter('.').lowercase()).apply { mkdirs() }.sidecar(it, "x= 1") }
|
||||
|
||||
val files = updateMetadataFiles(dir, "1", "d")
|
||||
|
||||
assertEquals(listOf("hammer.msi", "hammer.exe"), urlsIn(files.getValue("latest.yml")))
|
||||
assertEquals(listOf("hammer.deb", "hammer.rpm", "hammer.AppImage"), urlsIn(files.getValue("latest-linux.yml")))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a duplicated sidecar fails instead of picking one`() {
|
||||
val dir = tempDir()
|
||||
installers.forEach { dir.sidecar(it, "x= 1") }
|
||||
dir.resolve("msi").apply { mkdirs() }.sidecar("hammer.msi", "y= 2")
|
||||
|
||||
assertFailsWith<IllegalStateException> { updateMetadataFiles(dir, "1", "d") }
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `the mac zip is listed first when present and skipped when absent`() {
|
||||
val dir = tempDir()
|
||||
installers.forEach { dir.sidecar(it, "x= 1") }
|
||||
|
||||
assertNull(urlsIn(updateMetadataFiles(dir, "1", "d").getValue("latest-mac.yml")).firstOrNull { it == "hammer.zip" })
|
||||
|
||||
dir.sidecar("hammer.zip", "z= 2")
|
||||
assertEquals(
|
||||
listOf("hammer.zip", "hammer.dmg", "hammer.pkg"),
|
||||
urlsIn(updateMetadataFiles(dir, "1", "d").getValue("latest-mac.yml")),
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a missing required installer fails instead of publishing a gap`() {
|
||||
val dir = tempDir()
|
||||
installers.filter { it != "hammer.exe" }.forEach { dir.sidecar(it, "x= 1") }
|
||||
|
||||
val error = assertFailsWith<IllegalStateException> { updateMetadataFiles(dir, "1", "d") }
|
||||
assertEquals(true, error.message?.startsWith("latest.yml needs hammer.exe$DIGEST_SUFFIX"))
|
||||
}
|
||||
|
||||
private fun File.sidecar(name: String, line: String) = resolve(name + DIGEST_SUFFIX).writeText(line)
|
||||
|
||||
private fun urlsIn(yml: String): List<String> =
|
||||
yml.lines().filter { it.trim().startsWith("- url:") }.map { it.substringAfter("- url:").trim() }
|
||||
|
||||
private fun tempDir(): File = Files.createTempDirectory("update-metadata-test").toFile().apply { deleteOnExit() }
|
||||
}
|
||||
|
|
@ -28,27 +28,4 @@ class VersionCodeTest {
|
|||
versionCode = semVar.createVersionCode(true, 1)
|
||||
assertEquals(102040000, versionCode, "Release: True Build: 1")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `Wear version code sits above every phone version code`() {
|
||||
val phoneCode = parseSemVar("9.99.99").createVersionCode(false, 9_999)
|
||||
|
||||
assertEquals(1_999_999_999, wearVersionCode(phoneCode))
|
||||
assertEquals(1_102_030_001, wearVersionCode(102_030_001))
|
||||
}
|
||||
|
||||
@Test(expected = IllegalArgumentException::class)
|
||||
fun `Wear version code rejects a phone code that reaches the offset`() {
|
||||
wearVersionCode(1_000_000_000)
|
||||
}
|
||||
|
||||
@Test(expected = IllegalArgumentException::class)
|
||||
fun `A version that collides with the wear band is refused for every target`() {
|
||||
getVersionCode("10.0.0")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `The last version below the wear band is still allowed`() {
|
||||
assertEquals(999_990_000, getVersionCode("9.99.99"))
|
||||
}
|
||||
}
|
||||
|
|
@ -155,7 +155,7 @@ kotlin {
|
|||
api(libs.serialization.jvm)
|
||||
api(libs.coroutines.swing)
|
||||
implementation(libs.appdirs)
|
||||
implementation(libs.ktor.client.okhttp)
|
||||
implementation(libs.ktor.client.java)
|
||||
implementation(libs.turbine)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,150 +0,0 @@
|
|||
package com.darkrockstudios.apps.hammer.common.data.globalsettings.datasource
|
||||
|
||||
import android.content.SharedPreferences
|
||||
import kotlinx.serialization.json.Json
|
||||
import javax.crypto.AEADBadTagException
|
||||
import kotlin.test.Test
|
||||
import kotlin.test.assertEquals
|
||||
import kotlin.test.assertNull
|
||||
|
||||
class EncryptedSharedPrefsAuthTokenStoreTest {
|
||||
|
||||
private val disk = FakeEncryptedPrefsDisk()
|
||||
|
||||
private fun createStore() = EncryptedSharedPrefsAuthTokenStore(
|
||||
json = Json,
|
||||
openPrefs = disk::open,
|
||||
resetPrefs = disk::reset,
|
||||
)
|
||||
|
||||
@Test
|
||||
fun `tokens round trip`() {
|
||||
val store = createStore()
|
||||
|
||||
store.put(URL, USER_ID, TOKENS)
|
||||
|
||||
assertEquals(TOKENS, createStore().get(URL, USER_ID))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `keyset the master key cannot decrypt is treated as no tokens`() {
|
||||
disk.keysetDecryptable = false
|
||||
val store = createStore()
|
||||
|
||||
val tokens = store.get(URL, USER_ID)
|
||||
|
||||
assertNull(tokens)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `store is usable again after an undecryptable keyset`() {
|
||||
disk.keysetDecryptable = false
|
||||
createStore().get(URL, USER_ID)
|
||||
|
||||
createStore().put(URL, USER_ID, TOKENS)
|
||||
|
||||
assertEquals(TOKENS, createStore().get(URL, USER_ID))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `value that cannot be decrypted is treated as no tokens`() {
|
||||
createStore().put(URL, USER_ID, TOKENS)
|
||||
disk.valuesDecryptable = false
|
||||
|
||||
val tokens = createStore().get(URL, USER_ID)
|
||||
|
||||
assertNull(tokens)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `put replaces a value that cannot be decrypted`() {
|
||||
createStore().put(URL, USER_ID, TOKENS)
|
||||
disk.valuesDecryptable = false
|
||||
val replacement = AuthTokens(bearerToken = "new-bearer", refreshToken = "new-refresh")
|
||||
|
||||
createStore().put(URL, USER_ID, replacement)
|
||||
|
||||
assertEquals(replacement, createStore().get(URL, USER_ID))
|
||||
}
|
||||
|
||||
companion object {
|
||||
private const val URL = "https://hammer.example"
|
||||
private const val USER_ID = 7L
|
||||
private val TOKENS = AuthTokens(bearerToken = "bearer", refreshToken = "refresh")
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Models the encrypted prefs file: [open] fails like EncryptedSharedPreferences.create
|
||||
* when the Keystore master key cannot decrypt the stored keyset, and [reset] removes
|
||||
* the file (keyset included) so the next open generates a fresh one.
|
||||
*/
|
||||
private class FakeEncryptedPrefsDisk {
|
||||
val values = mutableMapOf<String, String>()
|
||||
var keysetDecryptable = true
|
||||
var valuesDecryptable = true
|
||||
|
||||
fun open(): SharedPreferences {
|
||||
if (!keysetDecryptable) throw AEADBadTagException()
|
||||
return FakePrefs()
|
||||
}
|
||||
|
||||
fun reset() {
|
||||
values.clear()
|
||||
keysetDecryptable = true
|
||||
valuesDecryptable = true
|
||||
}
|
||||
|
||||
private inner class FakePrefs : SharedPreferences {
|
||||
override fun getString(key: String, defValue: String?): String? {
|
||||
val stored = values[key] ?: return defValue
|
||||
if (!valuesDecryptable) throw SecurityException("Could not decrypt value")
|
||||
return stored
|
||||
}
|
||||
|
||||
override fun contains(key: String): Boolean = key in values
|
||||
override fun getAll(): MutableMap<String, *> = values.toMutableMap()
|
||||
override fun edit(): SharedPreferences.Editor = FakeEditor()
|
||||
|
||||
override fun getStringSet(key: String, defValues: MutableSet<String>?) = unsupported()
|
||||
override fun getInt(key: String, defValue: Int): Int = unsupported()
|
||||
override fun getLong(key: String, defValue: Long): Long = unsupported()
|
||||
override fun getFloat(key: String, defValue: Float): Float = unsupported()
|
||||
override fun getBoolean(key: String, defValue: Boolean): Boolean = unsupported()
|
||||
override fun registerOnSharedPreferenceChangeListener(
|
||||
listener: SharedPreferences.OnSharedPreferenceChangeListener
|
||||
) = unsupported()
|
||||
|
||||
override fun unregisterOnSharedPreferenceChangeListener(
|
||||
listener: SharedPreferences.OnSharedPreferenceChangeListener
|
||||
) = unsupported()
|
||||
}
|
||||
|
||||
private inner class FakeEditor : SharedPreferences.Editor {
|
||||
private val pending = mutableMapOf<String, String?>()
|
||||
private var clearAll = false
|
||||
|
||||
override fun putString(key: String, value: String?) = apply { pending[key] = value }
|
||||
override fun remove(key: String) = apply { pending[key] = null }
|
||||
override fun clear() = apply { clearAll = true }
|
||||
|
||||
override fun commit(): Boolean {
|
||||
if (clearAll) values.clear()
|
||||
pending.forEach { (key, value) -> if (value == null) values.remove(key) else values[key] = value }
|
||||
valuesDecryptable = true
|
||||
return true
|
||||
}
|
||||
|
||||
override fun apply() {
|
||||
commit()
|
||||
}
|
||||
|
||||
override fun putStringSet(key: String, values: MutableSet<String>?) = unsupported()
|
||||
override fun putInt(key: String, value: Int) = unsupported()
|
||||
override fun putLong(key: String, value: Long) = unsupported()
|
||||
override fun putFloat(key: String, value: Float) = unsupported()
|
||||
override fun putBoolean(key: String, value: Boolean) = unsupported()
|
||||
}
|
||||
}
|
||||
|
||||
private fun unsupported(): Nothing = throw UnsupportedOperationException()
|
||||
|
|
@ -1,36 +0,0 @@
|
|||
package com.darkrockstudios.apps.hammer.common
|
||||
|
||||
import io.github.aakira.napier.Napier
|
||||
import java.io.File
|
||||
|
||||
/**
|
||||
* Catch, log, and hand off any otherwise-unhandled exception. The async [FileLogger] can't be
|
||||
* relied on to flush before the process dies, so we also write a synchronous crash dump straight
|
||||
* to disk, then chain to the platform default handler so Android still terminates and reports.
|
||||
*/
|
||||
fun installGlobalExceptionHandler() {
|
||||
val previous = Thread.getDefaultUncaughtExceptionHandler()
|
||||
Thread.setDefaultUncaughtExceptionHandler { thread, throwable ->
|
||||
runCatching {
|
||||
Napier.e(
|
||||
"Uncaught exception on thread '${thread.name}', terminating",
|
||||
throwable
|
||||
)
|
||||
}
|
||||
runCatching { writeCrashDump(thread, throwable) }
|
||||
previous?.uncaughtException(thread, throwable)
|
||||
}
|
||||
}
|
||||
|
||||
/** Synchronous, self-contained crash record in the logs dir: the guaranteed artifact when the app dies. */
|
||||
private fun writeCrashDump(thread: Thread, throwable: Throwable) {
|
||||
val dir = File(getConfigDirectory(), "logs")
|
||||
dir.mkdirs()
|
||||
File(dir, "crash-${System.currentTimeMillis()}.txt").writeText(
|
||||
buildString {
|
||||
append(startupBanner() + "\n")
|
||||
append("Uncaught exception on thread '${thread.name}'\n\n")
|
||||
append(throwable.stackTraceToString())
|
||||
}
|
||||
)
|
||||
}
|
||||
|
|
@ -1,3 +0,0 @@
|
|||
package com.darkrockstudios.apps.hammer.common.data.changelog
|
||||
|
||||
actual val supportsInAppChangelog: Boolean = true
|
||||
|
|
@ -9,46 +9,31 @@ import io.github.aakira.napier.Napier
|
|||
import kotlinx.atomicfu.locks.reentrantLock
|
||||
import kotlinx.atomicfu.locks.withLock
|
||||
import kotlinx.serialization.json.Json
|
||||
import java.io.IOException
|
||||
import java.security.GeneralSecurityException
|
||||
import java.security.KeyStore
|
||||
|
||||
/**
|
||||
* [AuthTokenStore] backed by [EncryptedSharedPreferences] with an Android
|
||||
* Keystore-backed master key. The account-keyed token map is stored as JSON in a
|
||||
* single encrypted preferences entry.
|
||||
*/
|
||||
class EncryptedSharedPrefsAuthTokenStore internal constructor(
|
||||
class EncryptedSharedPrefsAuthTokenStore(
|
||||
context: Context,
|
||||
private val json: Json,
|
||||
private val openPrefs: () -> SharedPreferences,
|
||||
private val resetPrefs: () -> Unit,
|
||||
) : AuthTokenStore {
|
||||
|
||||
constructor(context: Context, json: Json) : this(
|
||||
json = json,
|
||||
openPrefs = { openEncryptedPrefs(context) },
|
||||
resetPrefs = { resetEncryptedPrefs(context) },
|
||||
)
|
||||
|
||||
private val lock = reentrantLock()
|
||||
|
||||
private val prefs: SharedPreferences by lazy {
|
||||
try {
|
||||
openPrefs()
|
||||
} catch (e: GeneralSecurityException) {
|
||||
recreatePrefs(e)
|
||||
} catch (e: IOException) {
|
||||
recreatePrefs(e)
|
||||
}
|
||||
}
|
||||
val masterKey = MasterKey.Builder(context)
|
||||
.setKeyScheme(MasterKey.KeyScheme.AES256_GCM)
|
||||
.build()
|
||||
|
||||
// The keyset lives in the prefs file but its master key lives in the Keystore, which is
|
||||
// never backed up, so a restored or orphaned file is undecryptable, and some keystores
|
||||
// leave the master key itself unusable. Start over with both (forces re-login).
|
||||
private fun recreatePrefs(cause: Exception): SharedPreferences {
|
||||
Napier.w("Auth token keyset is unreadable; recreating the store", cause)
|
||||
resetPrefs()
|
||||
return openPrefs()
|
||||
EncryptedSharedPreferences.create(
|
||||
context,
|
||||
PREFS_NAME,
|
||||
masterKey,
|
||||
EncryptedSharedPreferences.PrefKeyEncryptionScheme.AES256_SIV,
|
||||
EncryptedSharedPreferences.PrefValueEncryptionScheme.AES256_GCM,
|
||||
)
|
||||
}
|
||||
|
||||
override fun get(url: String, userId: Long): AuthTokens? = lock.withLock {
|
||||
|
|
@ -69,11 +54,11 @@ class EncryptedSharedPrefsAuthTokenStore internal constructor(
|
|||
}
|
||||
|
||||
private fun loadMap(): Map<String, AuthTokens> {
|
||||
val stored = prefs.getString(TOKENS_KEY, null) ?: return emptyMap()
|
||||
return try {
|
||||
val stored = prefs.getString(TOKENS_KEY, null) ?: return emptyMap()
|
||||
json.decodeFromString<Map<String, AuthTokens>>(stored)
|
||||
} catch (@Suppress("TooGenericExceptionCaught") e: Exception) {
|
||||
Napier.w("Failed to read encrypted auth tokens; treating as empty", e)
|
||||
Napier.w("Failed to parse encrypted auth tokens; treating as empty", e)
|
||||
emptyMap()
|
||||
}
|
||||
}
|
||||
|
|
@ -85,26 +70,5 @@ class EncryptedSharedPrefsAuthTokenStore internal constructor(
|
|||
companion object {
|
||||
private const val PREFS_NAME = "hammer_auth_tokens"
|
||||
private const val TOKENS_KEY = "tokens"
|
||||
private const val ANDROID_KEYSTORE = "AndroidKeyStore"
|
||||
|
||||
private fun openEncryptedPrefs(context: Context): SharedPreferences {
|
||||
val masterKey = MasterKey.Builder(context)
|
||||
.setKeyScheme(MasterKey.KeyScheme.AES256_GCM)
|
||||
.build()
|
||||
|
||||
return EncryptedSharedPreferences.create(
|
||||
context,
|
||||
PREFS_NAME,
|
||||
masterKey,
|
||||
EncryptedSharedPreferences.PrefKeyEncryptionScheme.AES256_SIV,
|
||||
EncryptedSharedPreferences.PrefValueEncryptionScheme.AES256_GCM,
|
||||
)
|
||||
}
|
||||
|
||||
private fun resetEncryptedPrefs(context: Context) {
|
||||
context.deleteSharedPreferences(PREFS_NAME)
|
||||
KeyStore.getInstance(ANDROID_KEYSTORE).apply { load(null) }
|
||||
.deleteEntry(MasterKey.DEFAULT_MASTER_KEY_ALIAS)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,8 +1,5 @@
|
|||
package com.darkrockstudios.apps.hammer.common.dependencyinjection
|
||||
|
||||
import com.darkrockstudios.apps.hammer.common.data.appupdate.AppUpdater
|
||||
import com.darkrockstudios.apps.hammer.common.data.appupdate.NoOpAppUpdater
|
||||
|
||||
import com.darkrockstudios.apps.hammer.common.components.projectselection.accountsettings.AndroidPlatformSettingsComponent
|
||||
import com.darkrockstudios.apps.hammer.common.components.projectselection.accountsettings.PlatformSettings
|
||||
import com.darkrockstudios.apps.hammer.common.components.storyeditor.focusmode.FocusModeService
|
||||
|
|
@ -22,7 +19,6 @@ actual val platformModule = module {
|
|||
single<NetworkConnectivity>()
|
||||
single<StrResImpl>() bind StrRes::class
|
||||
single<DeviceLocaleResolver>()
|
||||
single<AvailableLocalesProvider>()
|
||||
single<UrlLauncherAndroid>() bind UrlLauncher::class
|
||||
single<LanguageUtil>()
|
||||
factory { params ->
|
||||
|
|
@ -39,5 +35,4 @@ actual val platformModule = module {
|
|||
single<AndroidShareService>()
|
||||
single<BackupManagerService>()
|
||||
single<GlobalSettingsDatasource> { get<GlobalSettingsFilesystemDatasource>() }
|
||||
single<AppUpdater> { NoOpAppUpdater }
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Add table
Add a link
Reference in a new issue