diff --git a/.github/workflows/tauri-build.yml b/.github/workflows/tauri-build.yml index ca695d41ee..adf3a41b7a 100644 --- a/.github/workflows/tauri-build.yml +++ b/.github/workflows/tauri-build.yml @@ -258,6 +258,8 @@ jobs: ANDROID_KEY_BASE64: ${{ secrets.ANDROID_KEY_BASE64 }} ANDROID_KEY_ALIAS: ${{ secrets.ANDROID_KEY_ALIAS }} ANDROID_KEY_PASSWORD: ${{ secrets.ANDROID_KEY_PASSWORD }} + # Release tags only: excludes nightly and unparseable tags (empty version). + IS_RELEASE: ${{ startsWith(needs.setup-release.outputs.tag, 'v') }} steps: - name: Checkout repository uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 @@ -323,6 +325,24 @@ jobs: base64 -d <<< "$ANDROID_KEY_BASE64" > "$RUNNER_TEMP/keystore.jks" echo "storeFile=$RUNNER_TEMP/keystore.jks" >> keystore.properties + # Must run before the Google build: they share one Gradle output directory. + - name: Build FOSS Android APK + if: ${{ env.IS_RELEASE == 'true' }} + shell: bash + env: + SABLE_FOSS: '1' + run: | + # tauri-build validates every file in capabilities/, so the geolocation + # permissions must be absent, not just unreferenced. + rm src-tauri/capabilities/geolocation.json + pnpm tauri android build --apk --target aarch64 armv7 -- --no-default-features --features wry,matrix-crypto + git checkout -- src-tauri/capabilities/geolocation.json + OUT='src-tauri/gen/android/app/build/outputs/apk/universal/release' + APK=$(find "$OUT" -name '*.apk' -type f | head -1) + [ -n "$APK" ] || { echo 'FOSS APK not found' >&2; exit 1; } + mkdir -p android-artifacts + mv "$APK" "android-artifacts/Sable-${VERSION}-android-universal-foss.apk" + - name: Build Android bundles shell: bash env: @@ -349,6 +369,7 @@ jobs: subject-path: | src-tauri/gen/android/app/build/outputs/apk/universal/release/*.apk src-tauri/gen/android/app/build/outputs/bundle/universalRelease/*.aab + ${{ env.IS_RELEASE == 'true' && 'android-artifacts/*.apk' || '' }} - name: Attach Android bundles to release shell: bash @@ -357,7 +378,8 @@ jobs: run: | OUT="src-tauri/gen/android/app/build/outputs" for f in "$OUT"/apk/universal/release/*.apk \ - "$OUT"/bundle/universalRelease/*.aab; do + "$OUT"/bundle/universalRelease/*.aab \ + android-artifacts/*.apk; do [ -e "$f" ] || continue echo "Uploading $f" gh release upload "$TAG" "$f" --clobber diff --git a/fastlane/metadata/android/en-US/changelogs/1022000.txt b/fastlane/metadata/android/en-US/changelogs/1022000.txt new file mode 100644 index 0000000000..e067321022 --- /dev/null +++ b/fastlane/metadata/android/en-US/changelogs/1022000.txt @@ -0,0 +1,4 @@ +• Add support for description lists +• Allow swiping away in-app notifications on mobile +• Add a configurable gif provider (Tenor, Giphy or Klipy) and send gifs through the media proxy +• Add a customizable Global shortcut to show or hide the app window diff --git a/fastlane/metadata/android/en-US/changelogs/1022001.txt b/fastlane/metadata/android/en-US/changelogs/1022001.txt new file mode 100644 index 0000000000..0a61c74f92 --- /dev/null +++ b/fastlane/metadata/android/en-US/changelogs/1022001.txt @@ -0,0 +1 @@ +• Cap update banner changelog text so the update is updateable. diff --git a/fastlane/metadata/android/en-US/changelogs/1022003.txt b/fastlane/metadata/android/en-US/changelogs/1022003.txt new file mode 100644 index 0000000000..979799413b --- /dev/null +++ b/fastlane/metadata/android/en-US/changelogs/1022003.txt @@ -0,0 +1 @@ +• Enable native FCM push on Android by adding the Firebase app config. diff --git a/fastlane/metadata/android/en-US/changelogs/1022004.txt b/fastlane/metadata/android/en-US/changelogs/1022004.txt new file mode 100644 index 0000000000..cfcc60a4cc --- /dev/null +++ b/fastlane/metadata/android/en-US/changelogs/1022004.txt @@ -0,0 +1 @@ +• Fix devices repeatedly reverting to unverified. diff --git a/fastlane/metadata/android/en-US/full_description.txt b/fastlane/metadata/android/en-US/full_description.txt new file mode 100644 index 0000000000..16b3a3dfd5 --- /dev/null +++ b/fastlane/metadata/android/en-US/full_description.txt @@ -0,0 +1,12 @@ +Sable is a Matrix client built to enhance the user experience with quality-of-life features, cosmetics, utilities, and sheer usability. It is a fork of Cinny. + +Features: + +* End-to-end encrypted messaging, with encrypted push notification previews decrypted on-device +* Push without Google Play Services, using a built-in UnifiedPush distributor — no separate distributor app required +* Voice and video calls over MatrixRTC +* Spaces, threads, and forum rooms +* Customisable app icons, themes, and a configurable GIF provider +* Message reporting, forwarding, and read-receipt controls + +Sable is free software licensed under the AGPL-3.0. Crash reporting is opt-in and disabled until you consent. diff --git a/fastlane/metadata/android/en-US/images/icon.png b/fastlane/metadata/android/en-US/images/icon.png new file mode 100644 index 0000000000..32db07c0d1 Binary files /dev/null and b/fastlane/metadata/android/en-US/images/icon.png differ diff --git a/fastlane/metadata/android/en-US/images/phoneScreenshots/1_login.png b/fastlane/metadata/android/en-US/images/phoneScreenshots/1_login.png new file mode 100644 index 0000000000..27797d75b7 Binary files /dev/null and b/fastlane/metadata/android/en-US/images/phoneScreenshots/1_login.png differ diff --git a/fastlane/metadata/android/en-US/images/phoneScreenshots/2_timeline.png b/fastlane/metadata/android/en-US/images/phoneScreenshots/2_timeline.png new file mode 100644 index 0000000000..297fb58f17 Binary files /dev/null and b/fastlane/metadata/android/en-US/images/phoneScreenshots/2_timeline.png differ diff --git a/fastlane/metadata/android/en-US/images/phoneScreenshots/3_rooms.png b/fastlane/metadata/android/en-US/images/phoneScreenshots/3_rooms.png new file mode 100644 index 0000000000..293ddcde4d Binary files /dev/null and b/fastlane/metadata/android/en-US/images/phoneScreenshots/3_rooms.png differ diff --git a/fastlane/metadata/android/en-US/short_description.txt b/fastlane/metadata/android/en-US/short_description.txt new file mode 100644 index 0000000000..c9ae2c435e --- /dev/null +++ b/fastlane/metadata/android/en-US/short_description.txt @@ -0,0 +1 @@ +An almost stable Matrix client diff --git a/fastlane/metadata/android/en-US/title.txt b/fastlane/metadata/android/en-US/title.txt new file mode 100644 index 0000000000..3e980dcdb2 --- /dev/null +++ b/fastlane/metadata/android/en-US/title.txt @@ -0,0 +1 @@ +Sable diff --git a/knope.toml b/knope.toml index a3d53ae420..fc86b7eb17 100644 --- a/knope.toml +++ b/knope.toml @@ -37,6 +37,12 @@ command = "git switch -c release" [[workflows.steps]] type = "PrepareRelease" +# IzzyOnDroid reads fastlane metadata from the tagged commit, so the changelog +# has to be written here rather than in tauri-build.yml, which runs after tagging. +[[workflows.steps]] +type = "Command" +command = "node scripts/fastlane-changelog.js $version && git add fastlane/metadata/android/en-US/changelogs" + [[workflows.steps]] type = "Command" command = "git commit -m \"chore: prepare release $version\"" diff --git a/scripts/fastlane-changelog.js b/scripts/fastlane-changelog.js new file mode 100644 index 0000000000..2b9c6cf740 --- /dev/null +++ b/scripts/fastlane-changelog.js @@ -0,0 +1,54 @@ +#!/usr/bin/env node +//MISE hide=true +//MISE description="Generate the fastlane changelog for the current version" +/* oxlint-disable no-console */ + +import { mkdirSync, readFileSync, writeFileSync } from 'node:fs'; +import { dirname } from 'node:path'; +import process from 'node:process'; + +const version = process.argv[2]; +if (!version || !/^\d+\.\d+\.\d+/.test(version)) { + console.error( + `Usage: node scripts/fastlane-changelog.js (got: ${version ?? ''})` + ); + process.exit(1); +} + +// Matches the versionCode Tauri writes into gen/android/app/tauri.properties, +// which is what fastlane keys changelog files by. +const [major, minor, patch] = version.split(/[.-]/, 3).map(Number); +const versionCode = major * 1_000_000 + minor * 1_000 + patch; + +// F-Droid and Play both reject changelogs over 500 characters. Measured in +// bytes so the multi-byte bullets cannot push an accepted file over the limit. +const LIMIT = 500; +const size = (text) => Buffer.byteLength(text, 'utf8'); + +const changelog = readFileSync('CHANGELOG.md', 'utf8'); +const start = changelog.indexOf(`\n## ${version} `); +if (start === -1) { + console.error(`No "## ${version}" section in CHANGELOG.md`); + process.exit(1); +} +const rest = changelog.slice(start + 1); +const end = rest.indexOf('\n## ', 1); +const section = end === -1 ? rest : rest.slice(0, end); + +const entries = section + .split('\n') + .filter((line) => line.startsWith('* ')) + // Attribution is noise on a store listing and eats the character budget. + .map((line) => `• ${line.slice(2).replace(/ by @\S+( in #\d+)?\.?$/, '')}`); + +let body = ''; +for (const entry of entries) { + if (size(body) + size(entry) + 1 > LIMIT) break; + body += `${entry}\n`; +} +if (!body) body = `${entries[0].slice(0, LIMIT / 4)}\n`; + +const out = `fastlane/metadata/android/en-US/changelogs/${versionCode}.txt`; +mkdirSync(dirname(out), { recursive: true }); +writeFileSync(out, body); +console.log(`Wrote ${out} (${size(body)} bytes)`); diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index fc7b4eb64a..ee8eb5aa5b 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -6834,7 +6834,7 @@ checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f" [[package]] name = "sable" -version = "1.22.1" +version = "1.22.4" dependencies = [ "aes 0.9.2", "async-stream", @@ -8340,7 +8340,7 @@ dependencies = [ [[package]] name = "tauri-plugin-notifications" version = "0.5.0" -source = "git+https://github.com/SableClient/tauri-plugin-notifications.git?rev=658491c87be246740216f97cc367e29e31e690af#658491c87be246740216f97cc367e29e31e690af" +source = "git+https://github.com/SableClient/tauri-plugin-notifications.git?rev=92c96b300517c49024a48aaff863338e3bd63616#92c96b300517c49024a48aaff863338e3bd63616" dependencies = [ "log", "notify-rust", diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index d3bb37ba87..71ad20ad3e 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -110,12 +110,12 @@ windows = { version = "0.62", features = [ tauri-plugin-single-instance = { version = "2.4.3", features = ["deep-link"] } [target.'cfg(any(windows, target_os = "linux"))'.dependencies] -tauri-plugin-notifications = { git = "https://github.com/SableClient/tauri-plugin-notifications.git", rev = "658491c87be246740216f97cc367e29e31e690af" } +tauri-plugin-notifications = { git = "https://github.com/SableClient/tauri-plugin-notifications.git", rev = "92c96b300517c49024a48aaff863338e3bd63616" } # default-features = false drops notify-rust so macOS uses the native # UNUserNotificationCenter backend (needs a signed .app to deliver). [target.'cfg(target_os = "macos")'.dependencies] -tauri-plugin-notifications = { git = "https://github.com/SableClient/tauri-plugin-notifications.git", rev = "658491c87be246740216f97cc367e29e31e690af", default-features = false } +tauri-plugin-notifications = { git = "https://github.com/SableClient/tauri-plugin-notifications.git", rev = "92c96b300517c49024a48aaff863338e3bd63616", default-features = false } [target.'cfg(not(any(target_os = "android", target_os = "ios")))'.dependencies] tauri-plugin-updater = { version = "2", optional = true } @@ -139,14 +139,14 @@ libloading = "0.9" zbus = "5" [target.'cfg(any(target_os = "android", target_os = "ios"))'.dependencies] -tauri-plugin-notifications = { git = "https://github.com/SableClient/tauri-plugin-notifications.git", rev = "658491c87be246740216f97cc367e29e31e690af", features = [ +tauri-plugin-notifications = { git = "https://github.com/SableClient/tauri-plugin-notifications.git", rev = "92c96b300517c49024a48aaff863338e3bd63616", features = [ "push-notifications", ] } tauri-plugin-edge-to-edge = { git = "https://github.com/SableClient/tauri-plugin-edge-to-edge.git", rev = "33c6116c27be28c06df5a9d02231ecc5fdeb93c5" } tauri-plugin-sharekit = { git = "https://github.com/Choochmeque/tauri-plugin-sharekit", rev = "9f2b4c5d8a4f0ab910d900ba234ed8bae0ab854b" } tauri-plugin-app-icon = { git = "https://github.com/SableClient/tauri-plugin-app-icon", rev = "bab80ae17f6d018702e472c539d0f02eba480c21" } tauri-plugin-livekit-mobile = { git = "https://github.com/SableClient/tauri-plugin-livekit-mobile.git", rev = "c6f0d02ab3da1174535bc1dc8e598a2e6873f977" } -tauri-plugin-geolocation = "2.3.3" +tauri-plugin-geolocation = { version = "2.3.3", optional = true } [target.'cfg(target_os = "ios")'.dependencies] objc2 = "0.6" @@ -198,7 +198,7 @@ tauri-plugin-android-fs = { version = "=29.0.0", features = ["legacy-storage-per jni = "0.22" [features] -default = ["wry", "updater", "matrix-crypto"] +default = ["wry", "updater", "matrix-crypto", "geolocation"] custom-protocol = ["tauri/custom-protocol"] wry = ["tauri/wry", "dep:webkit2gtk"] # Tauri auto-updater. Disable with --no-default-features --features wry,cef. @@ -209,6 +209,8 @@ cef = ["dep:tauri-runtime-cef", "dep:cef"] # session, which costs hundreds of MB per minute once tauri-plugin-log feeds it. devtools = ["dep:tauri-plugin-devtools"] matrix-crypto = ["dep:matrix-sdk", "dep:matrix-sdk-crypto", "dep:matrix-sdk-sqlite", "dep:http"] +# Pulls play-services-location on Android. Off for FOSS-repo builds. +geolocation = ["dep:tauri-plugin-geolocation"] [patch.crates-io] tauri-typegen = { git = "https://github.com/SableClient/tauri-typegen", branch = "fix/nondeterministic-generation-cache" } diff --git a/src-tauri/capabilities/geolocation.json b/src-tauri/capabilities/geolocation.json new file mode 100644 index 0000000000..22fffb6d1d --- /dev/null +++ b/src-tauri/capabilities/geolocation.json @@ -0,0 +1,11 @@ +{ + "$schema": "../gen/schemas/mobile-schema.json", + "identifier": "geolocation-capability", + "platforms": ["android", "iOS"], + "windows": ["main"], + "permissions": [ + "geolocation:allow-check-permissions", + "geolocation:allow-request-permissions", + "geolocation:allow-get-current-position" + ] +} diff --git a/src-tauri/capabilities/mobile.json b/src-tauri/capabilities/mobile.json index 58c0323288..ced96b9c24 100644 --- a/src-tauri/capabilities/mobile.json +++ b/src-tauri/capabilities/mobile.json @@ -8,9 +8,6 @@ "app-icon:default", "deep-link:default", "edge-to-edge:default", - "geolocation:allow-check-permissions", - "geolocation:allow-request-permissions", - "geolocation:allow-get-current-position", "livekit-mobile:default", "notifications:default", "sharekit:default" diff --git a/src-tauri/gen/android/app/build.gradle.kts b/src-tauri/gen/android/app/build.gradle.kts index 90abfc67a0..c65309122a 100644 --- a/src-tauri/gen/android/app/build.gradle.kts +++ b/src-tauri/gen/android/app/build.gradle.kts @@ -7,6 +7,8 @@ plugins { id("rust") } +val fossBuild = providers.environmentVariable("SABLE_FOSS").orNull == "1" + val tauriProperties = Properties().apply { val propFile = file("tauri.properties") if (propFile.exists()) { @@ -24,6 +26,8 @@ android { targetSdk = 36 versionCode = tauriProperties.getProperty("tauri.android.versionCode", "1").toInt() versionName = tauriProperties.getProperty("tauri.android.versionName", "1.0") + // Not an app flavour: that would change the output paths tauri-build.yml globs. + missingDimensionStrategy("push", if (fossBuild) "foss" else "gms") } signingConfigs { create("release") { @@ -105,6 +109,8 @@ apply(from = "tauri.build.gradle.kts") // Native FCM push (Sygnal): applies only once google-services.json is added to this // directory, so builds without Firebase configured still succeed. -if (file("google-services.json").exists()) { +// Skipped for FOSS builds: the plugin injects the Firebase project ids as string +// resources even when no Firebase library is linked. +if (!fossBuild && file("google-services.json").exists()) { apply(plugin = "com.google.gms.google-services") } diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 7724e11e06..bc7f1eb0cf 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -371,10 +371,12 @@ pub fn run() { let builder = builder .plugin(tauri_plugin_app_icon::init()) .plugin(tauri_plugin_edge_to_edge::init()) - .plugin(tauri_plugin_geolocation::init()) .plugin(tauri_plugin_sharekit::init()) .plugin(tauri_plugin_livekit_mobile::init()); + #[cfg(all(mobile, feature = "geolocation"))] + let builder = builder.plugin(tauri_plugin_geolocation::init()); + #[cfg(target_os = "android")] let builder = builder.plugin(tauri_plugin_android_fs::init()); diff --git a/src/app/features/room/location-modal/LocationDialog.tsx b/src/app/features/room/location-modal/LocationDialog.tsx index 5bc2bfb96b..64da16fd55 100644 --- a/src/app/features/room/location-modal/LocationDialog.tsx +++ b/src/app/features/room/location-modal/LocationDialog.tsx @@ -96,7 +96,7 @@ export enum LocationErrors { none, permissions = 'You have denied Sable access to you location services', module = 'Your device does not have a gps module, or it may not be turned on', - unsupported = 'Location services are unavailable on desktop, enter or paste the coordinates instead', + unsupported = 'Location services are unavailable in this build, enter or paste the coordinates instead', unknown = 'The sharing failed for unknown reasons', clipboard = 'Unable to identify the coordinates from clipboard', missingClipboard = 'Unable to retrieve clipboard contents', diff --git a/src/app/utils/geolocation.ts b/src/app/utils/geolocation.ts index bfe437cecb..82a9abc6d7 100644 --- a/src/app/utils/geolocation.ts +++ b/src/app/utils/geolocation.ts @@ -4,7 +4,7 @@ import { getCurrentPosition, requestPermissions, } from '@tauri-apps/plugin-geolocation'; -import { isMobileTauri } from './platform'; +import { isGeolocationEnabled, isMobileTauri } from './platform'; export type Coordinates = { lat: number; lon: number }; @@ -52,7 +52,7 @@ const getWebCoordinates = (): Promise => export const getCurrentCoordinates = async (): Promise => { if (!isTauri()) return getWebCoordinates(); - if (!isMobileTauri()) throw new GeolocationError('unsupported'); + if (!isMobileTauri() || !isGeolocationEnabled()) throw new GeolocationError('unsupported'); try { return await getNativeCoordinates(); } catch (err) { diff --git a/src/app/utils/platform.ts b/src/app/utils/platform.ts index 0c161904bf..4fa5f14d1c 100644 --- a/src/app/utils/platform.ts +++ b/src/app/utils/platform.ts @@ -74,6 +74,10 @@ export function isDesktopUpdaterEnabled(): boolean { return DESKTOP_UPDATER_ENABLED; } +export function isGeolocationEnabled(): boolean { + return SABLE_GEOLOCATION_ENABLED; +} + export function isMobileTauri(): boolean { const tauriOS = getTauriOS(); return tauriOS === 'ios' || tauriOS === 'android'; diff --git a/src/ext.d.ts b/src/ext.d.ts index ccde726283..87d3cf72a2 100644 --- a/src/ext.d.ts +++ b/src/ext.d.ts @@ -6,6 +6,7 @@ declare const APP_VERSION: string; declare const BUILD_HASH: string; declare const IS_RELEASE_TAG: boolean; declare const DESKTOP_UPDATER_ENABLED: boolean; +declare const SABLE_GEOLOCATION_ENABLED: boolean; declare module 'browser-encrypt-attachment' { export interface EncryptedAttachmentInfo { diff --git a/vite.config.ts b/vite.config.ts index 5659bcc144..aa91ce9aa5 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -56,6 +56,8 @@ const isTauriBuild = Boolean(process.env.TAURI_ENV_PLATFORM); const isTauriDebug = process.env.TAURI_ENV_DEBUG === 'true'; const tauriBuildMinify = !isTauriDebug ? 'esbuild' : false; const desktopUpdaterEnabled = process.env.VITE_DESKTOP_UPDATER_ENABLED !== 'false'; +// FOSS builds drop tauri-plugin-geolocation, so its commands are not registered. +const geolocationEnabled = process.env.SABLE_FOSS !== '1'; const sentryUploadEnabled = Boolean( process.env.SENTRY_AUTH_TOKEN && process.env.SENTRY_ORG && process.env.SENTRY_PROJECT ); @@ -163,6 +165,7 @@ export default defineConfig(({ command }) => { SABLE_PRODUCT_NAME: JSON.stringify(baseProductName), SABLE_BUILD_FLAVOR: JSON.stringify(buildFlavor), DESKTOP_UPDATER_ENABLED: JSON.stringify(desktopUpdaterEnabled), + SABLE_GEOLOCATION_ENABLED: JSON.stringify(geolocationEnabled), }, resolve: { alias: {