diff --git a/.github/workflows/publish-r2.yml b/.github/workflows/publish-r2.yml new file mode 100644 index 0000000..88f64ff --- /dev/null +++ b/.github/workflows/publish-r2.yml @@ -0,0 +1,108 @@ +name: Publish release to R2 + +on: + release: + types: [published] + workflow_dispatch: + inputs: + tag: + description: Existing published release tag (also refreshes expiring APT metadata) + required: true + type: string + +permissions: + contents: read + +concurrency: + group: r2-publish + cancel-in-progress: false + +jobs: + publish: + runs-on: ubuntu-24.04 + environment: release + env: + TAG: ${{ github.event.release.tag_name || inputs.tag }} + GH_TOKEN: ${{ github.token }} + GH_REPO: ${{ github.repository }} + AWS_ACCESS_KEY_ID: ${{ secrets.R2_ACCESS_KEY_ID }} + AWS_SECRET_ACCESS_KEY: ${{ secrets.R2_SECRET_ACCESS_KEY }} + AWS_DEFAULT_REGION: auto + R2_BUCKET: ${{ vars.R2_BUCKET }} + R2_ENDPOINT: ${{ vars.R2_ENDPOINT }} + GNUPGHOME: ${{ runner.temp }}/apt-gnupg + steps: + # Environment rules must restrict publishing to reviewed branches/tags. + - uses: actions/checkout@v4 + - name: Test upload safeguards offline + run: python3 scripts/test_r2_upload.py + - name: Validate published stable release and configuration + shell: bash + run: | + [[ "$TAG" =~ ^v[0-9]+\.[0-9]+\.[0-9]+$ ]] || { echo 'Expected stable vX.Y.Z tag'; exit 1; } + gh release view "$TAG" --json isDraft,isPrerelease > release.json + python3 - <<'PY' + import json + release = json.load(open('release.json')) + assert not release['isDraft'] and not release['isPrerelease'], 'Release must be published and stable' + PY + : "${R2_BUCKET:?Set environment variable R2_BUCKET}" + : "${R2_ENDPOINT:?Set environment variable R2_ENDPOINT}" + : "${AWS_ACCESS_KEY_ID:?Missing R2_ACCESS_KEY_ID secret}" + : "${AWS_SECRET_ACCESS_KEY:?Missing R2_SECRET_ACCESS_KEY secret}" + - name: Install repository tools + run: | + sudo apt-get update + sudo apt-get install -y dpkg-dev apt-utils gnupg + command -v aws + - name: Download release assets and retain existing APT pool + run: | + mkdir -p dist/deb "dist/repo/releases/$TAG" dist/repo/apt/pool + gh release download "$TAG" --pattern 'tinline_*.deb' --dir dist/deb + gh release download "$TAG" --pattern "tinline-${TAG}-x86_64-linux.tar.gz" --dir "dist/repo/releases/$TAG" + gh release download "$TAG" --pattern "tinline-${TAG}.apk" --dir "dist/repo/releases/$TAG" + aws s3 sync "s3://$R2_BUCKET/apt/pool/" dist/repo/apt/pool/ --endpoint-url "$R2_ENDPOINT" + # Reject changes to any existing versioned object, including old .deb files. + python3 - <<'PY' + import os, pathlib, subprocess + subprocess.run(['aws', 's3api', 'list-objects-v2', '--bucket', os.environ['R2_BUCKET'], + '--endpoint-url', os.environ['R2_ENDPOINT'], '--output', 'json'], + check=True, stdout=open('objects.json', 'w')) + import json + existing = {x['Key'] for x in json.load(open('objects.json')).get('Contents', [])} + candidates = [(p, f'releases/{os.environ["TAG"]}/{p.name}') + for p in pathlib.Path(f'dist/repo/releases/{os.environ["TAG"]}').iterdir()] + candidates += [(p, f'apt/pool/main/t/tinline/{p.name}') for p in pathlib.Path('dist/deb').glob('*.deb')] + for path, key in candidates: + if key in existing: + subprocess.run(['aws', 's3', 'cp', f's3://{os.environ["R2_BUCKET"]}/{key}', + 'existing-object', '--endpoint-url', os.environ['R2_ENDPOINT']], check=True) + if path.read_bytes() != pathlib.Path('existing-object').read_bytes(): + raise SystemExit(f'Refusing to overwrite immutable object: {key}') + PY + (cd "dist/repo/releases/$TAG" && sha256sum *.apk *.tar.gz > SHA256SUMS) + - name: Import APT signing key + env: + PRIVATE_KEY: ${{ secrets.APT_GPG_PRIVATE_KEY }} + PASSPHRASE: ${{ secrets.APT_GPG_PASSPHRASE }} + FINGERPRINT: ${{ vars.APT_GPG_KEY_ID }} + run: | + : "${PRIVATE_KEY:?Missing APT_GPG_PRIVATE_KEY armored secret}" + : "${FINGERPRINT:?Set APT_GPG_KEY_ID full fingerprint}" + mkdir -m 700 -p "$GNUPGHOME" + printf '%s' "$PRIVATE_KEY" | gpg --batch --import + gpg --list-secret-keys "$FINGERPRINT" + umask 077 + printf '%s' "$PASSPHRASE" > "$RUNNER_TEMP/apt-passphrase" + - name: Sign and publish repository + env: + GPG_KEY_ID: ${{ vars.APT_GPG_KEY_ID }} + GPG_PASSPHRASE_FILE: ${{ runner.temp }}/apt-passphrase + run: | + ./scripts/build_apt_repo.sh + ./scripts/upload_repo_r2.sh + - name: Clean signing material + if: always() + run: | + gpgconf --kill gpg-agent || true + rm -rf "$GNUPGHOME" "$RUNNER_TEMP/apt-passphrase" diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..4803592 --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,166 @@ +name: Release builds + +on: + workflow_dispatch: + push: + branches: ['release/linux-android-packaging'] + tags: ['v*'] + +permissions: + contents: read + +concurrency: + group: release-${{ github.ref }} + cancel-in-progress: false + +jobs: + version: + runs-on: ubuntu-24.04 + outputs: + version: ${{ steps.version.outputs.version }} + code: ${{ steps.version.outputs.code }} + steps: + - uses: actions/checkout@v4 + with: + fetch-depth: 0 + - id: version + name: Validate release version + shell: bash + run: | + python3 - <<'PY' + import os, re, subprocess, tomllib + with open('Cargo.toml', 'rb') as f: + version = tomllib.load(f)['workspace']['package']['version'] + if not re.fullmatch(r'\d+\.\d+\.\d+', version): + raise SystemExit('Only stable X.Y.Z versions supported by this workflow') + if os.environ['GITHUB_REF_TYPE'] == 'tag' and os.environ['GITHUB_REF_NAME'] != f'v{version}': + raise SystemExit('Tag must match Cargo workspace version') + code = subprocess.check_output(['git', 'rev-list', '--count', 'HEAD'], text=True).strip() + with open(os.environ['GITHUB_OUTPUT'], 'a') as f: + f.write(f'version={version}\ncode={code}\n') + PY + + linux: + needs: version + runs-on: ubuntu-24.04 + env: + VERSION: ${{ needs.version.outputs.version }} + steps: + - uses: actions/checkout@v4 + - name: Install build and packaging dependencies + run: | + sudo apt-get update + sudo apt-get install -y build-essential pkg-config cmake meson ninja-build clang libclang-dev \ + libgtk-3-dev libasound2-dev libssl-dev libdbus-1-dev libabsl-dev \ + libxkbcommon-dev libxcb1-dev dpkg-dev desktop-file-utils + - name: Install Rust + run: rustup toolchain install stable --profile minimal && rustup default stable + - name: Build tarball and Debian package + run: | + desktop-file-validate packaging/linux/tinline.desktop + ./scripts/package_linux_tarball.sh + ./scripts/package_deb.sh + - name: Inspect and install Debian package on build runner + run: | + dpkg-deb --info dist/deb/*.deb + dpkg-deb --contents dist/deb/*.deb + sudo apt-get install -y ./dist/deb/*.deb + ldd /usr/bin/tinline | tee dist/linux-libraries.txt + ! grep -q 'not found' dist/linux-libraries.txt + - uses: actions/upload-artifact@v4 + with: + name: linux-release + path: | + dist/deb/* + dist/releases/**/* + dist/linux-libraries.txt + if-no-files-found: error + + android: + needs: version + runs-on: ubuntu-24.04 + environment: release + env: + VERSION: ${{ needs.version.outputs.version }} + VERSION_CODE: ${{ needs.version.outputs.code }} + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-java@v4 + with: + distribution: temurin + java-version: '21' + - uses: android-actions/setup-android@v3 + with: + packages: platform-tools + - name: Install native build dependencies and Android SDK + run: | + sudo apt-get update + sudo apt-get install -y build-essential cmake ninja-build pkg-config clang libclang-dev + sdkmanager 'platforms;android-36' 'build-tools;36.0.0' 'ndk;28.2.13676358' + - name: Install Rust and cargo-ndk + run: | + rustup toolchain install stable --profile minimal + rustup default stable + rustup target add aarch64-linux-android x86_64-linux-android + cargo install cargo-ndk --version 4.1.2 --locked + - name: Restore release keystore + env: + KEYSTORE_BASE64: ${{ secrets.ANDROID_KEYSTORE_BASE64 }} + run: | + test -n "$KEYSTORE_BASE64" || { echo 'Missing Android release keystore secret'; exit 1; } + umask 077 + printf '%s' "$KEYSTORE_BASE64" | base64 --decode > "$RUNNER_TEMP/tinline-release.jks" + - name: Build signed release APK + working-directory: android + env: + ORG_GRADLE_PROJECT_RELEASE_STORE_PASSWORD: ${{ secrets.ANDROID_STORE_PASSWORD }} + ORG_GRADLE_PROJECT_RELEASE_KEY_ALIAS: ${{ secrets.ANDROID_KEY_ALIAS }} + ORG_GRADLE_PROJECT_RELEASE_KEY_PASSWORD: ${{ secrets.ANDROID_KEY_PASSWORD }} + run: | + export ORG_GRADLE_PROJECT_RELEASE_STORE_FILE="$RUNNER_TEMP/tinline-release.jks" + ./gradlew --no-daemon --console=plain :app:assembleRelease \ + -PrequireReleaseSigning=true -PversionName="$VERSION" -PversionCode="$VERSION_CODE" \ + -Pabis=x86_64,arm64-v8a + - name: Verify APK signature and collect artifact + run: | + "$ANDROID_HOME/build-tools/36.0.0/apksigner" verify --verbose --print-certs \ + android/app/build/outputs/apk/release/app-release.apk + mkdir -p dist/android + cp android/app/build/outputs/apk/release/app-release.apk "dist/android/tinline-v${VERSION}.apk" + cd dist/android + sha256sum *.apk > SHA256SUMS + - name: Remove keystore + if: always() + run: rm -f "$RUNNER_TEMP/tinline-release.jks" + - uses: actions/upload-artifact@v4 + with: + name: android-release + path: dist/android/* + if-no-files-found: error + + draft-release: + if: github.ref_type == 'tag' + needs: [version, linux, android] + runs-on: ubuntu-24.04 + permissions: + contents: write + steps: + - uses: actions/download-artifact@v4 + with: + path: artifacts + - name: Create draft release and attach packages + env: + GH_TOKEN: ${{ github.token }} + GH_REPO: ${{ github.repository }} + TAG: ${{ github.ref_name }} + run: | + if gh release view "$TAG" >/dev/null 2>&1; then + test "$(gh release view "$TAG" --json isDraft --jq .isDraft)" = true || { + echo 'Refusing to replace artifacts on a published release'; exit 1; + } + else + gh release create "$TAG" --verify-tag --draft --title "Tinline $TAG" \ + --notes 'Candidate Linux tarball, Debian package, and signed Android APK. Test before publishing. Linux build baseline: Ubuntu 24.04; older distros are not verified.' + fi + mapfile -d '' files < <(find artifacts -type f -print0) + gh release upload "$TAG" "${files[@]}" --clobber diff --git a/.gitignore b/.gitignore index becd1cf..ef17088 100644 --- a/.gitignore +++ b/.gitignore @@ -1,4 +1,5 @@ target +dist/ android/.gradle/ android/build/ android/app/build/ diff --git a/Cargo.lock b/Cargo.lock index 5e2a8af..d6af361 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1790,12 +1790,14 @@ dependencies = [ "p2pcore", "png 0.17.16", "qrcode", + "resvg", "rfd", "rtrb", "serde", "serde_json", "tokio", "tray-icon", + "usvg", "webrtc-audio-processing", "windows-sys 0.61.2", "zeroize", @@ -2686,16 +2688,6 @@ dependencies = [ "polyval", ] -[[package]] -name = "gif" -version = "0.13.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4ae047235e33e2829703574b54fdec96bfbad892062d97fed2f76022287de61b" -dependencies = [ - "color_quant", - "weezl", -] - [[package]] name = "gif" version = "0.14.2" @@ -3432,7 +3424,6 @@ dependencies = [ "iced_graphics", "kurbo 0.10.4", "log", - "resvg", "rustc-hash 2.1.3", "softbuffer", "tiny-skia", @@ -3454,7 +3445,6 @@ dependencies = [ "iced_graphics", "log", "lyon", - "resvg", "rustc-hash 2.1.3", "thiserror 2.0.21", "wgpu", @@ -3668,7 +3658,7 @@ dependencies = [ "byteorder-lite", "color_quant", "exr", - "gif 0.14.2", + "gif", "image-webp", "moxcms", "num-traits", @@ -3678,8 +3668,8 @@ dependencies = [ "rayon", "rgb", "tiff", - "zune-core 0.5.3", - "zune-jpeg 0.5.15", + "zune-core", + "zune-jpeg", ] [[package]] @@ -7155,15 +7145,12 @@ version = "0.45.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a8928798c0a55e03c9ca6c4c6846f76377427d2c1e1f7e6de3c06ae57942df43" dependencies = [ - "gif 0.13.3", - "image-webp", "log", "pico-args", "rgb", "svgtypes", "tiny-skia", "usvg", - "zune-jpeg 0.4.21", ] [[package]] @@ -7353,24 +7340,6 @@ version = "1.0.23" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "cf54715a573b99ac80df0bc206da022bcd442c974952c7b9720069370852e21f" -[[package]] -name = "rustybuzz" -version = "0.20.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fd3c7c96f8a08ee34eff8857b11b49b07d71d1c3f4e88f8a88d4c9e9f90b1702" -dependencies = [ - "bitflags 2.13.2", - "bytemuck", - "core_maths", - "log", - "smallvec", - "ttf-parser", - "unicode-bidi-mirroring", - "unicode-ccc", - "unicode-properties", - "unicode-script", -] - [[package]] name = "ryu" version = "1.0.23" @@ -8321,7 +8290,7 @@ dependencies = [ "half", "quick-error", "weezl", - "zune-jpeg 0.5.15", + "zune-jpeg", ] [[package]] @@ -8759,18 +8728,6 @@ version = "0.3.18" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5c1cb5db39152898a79168971543b1cb5020dff7fe43c8dc468b0885f5e29df5" -[[package]] -name = "unicode-bidi-mirroring" -version = "0.4.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5dfa6e8c60bb66d49db113e0125ee8711b7647b5579dc7f5f19c42357ed039fe" - -[[package]] -name = "unicode-ccc" -version = "0.4.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ce61d488bcdc9bc8b5d1772c404828b17fc481c0a582b5581e95fb233aef503e" - [[package]] name = "unicode-ident" version = "1.0.26" @@ -8792,12 +8749,6 @@ dependencies = [ "tinyvec", ] -[[package]] -name = "unicode-properties" -version = "0.1.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7df058c713841ad818f1dc5d3fd88063241cc61f49f5fbea4b951e8cf5a8d71d" - [[package]] name = "unicode-script" version = "0.5.8" @@ -8810,12 +8761,6 @@ version = "1.13.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c6f5d3c3b1bf09027a88a6bc961fc00497d651009560b5463668dc81b0fa87a8" -[[package]] -name = "unicode-vo" -version = "0.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b1d386ff53b415b7fe27b50bb44679e2cc4660272694b7b6f3326d8480823a94" - [[package]] name = "unicode-width" version = "0.2.2" @@ -8987,21 +8932,16 @@ dependencies = [ "base64 0.22.1", "data-url", "flate2", - "fontdb", "imagesize", "kurbo 0.11.3", "log", "pico-args", "roxmltree", - "rustybuzz", "simplecss", "siphasher 1.0.4", "strict-num", "svgtypes", "tiny-skia-path", - "unicode-bidi", - "unicode-script", - "unicode-vo", "xmlwriter", ] @@ -10531,12 +10471,6 @@ version = "1.0.23" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b" -[[package]] -name = "zune-core" -version = "0.4.12" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3f423a2c17029964870cfaabb1f13dfab7d092a62a29a89264f4d36990ca414a" - [[package]] name = "zune-core" version = "0.5.3" @@ -10552,22 +10486,13 @@ dependencies = [ "simd-adler32", ] -[[package]] -name = "zune-jpeg" -version = "0.4.21" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "29ce2c8a9384ad323cf564b67da86e21d3cfdff87908bc1223ed5c99bc792713" -dependencies = [ - "zune-core 0.4.12", -] - [[package]] name = "zune-jpeg" version = "0.5.15" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "27bc9d5b815bc103f142aa054f561d9187d191692ec7c2d1e2b4737f8dbd7296" dependencies = [ - "zune-core 0.5.3", + "zune-core", ] [[package]] diff --git a/android/app/build.gradle.kts b/android/app/build.gradle.kts index b01f7b3..71d8d45 100644 --- a/android/app/build.gradle.kts +++ b/android/app/build.gradle.kts @@ -17,7 +17,7 @@ android { targetSdk = 36 // Play needs a higher code on every upload; scripts/build_android.py --bundle passes the commit count. versionCode = (findProperty("versionCode") as String?)?.toInt() ?: 1 - versionName = "0.1.0" + versionName = (findProperty("versionName") as String?) ?: "0.1.0" // Only ship ABIs that have libp2pcore: libraries like JNA bring 32-bit/mips copies, which // would make Play offer the app to devices where the core can't load. ndk { abiFilters += (findProperty("abis") as String? ?: "x86_64,arm64-v8a").split(",").map { it.trim() } } @@ -26,6 +26,10 @@ android { // RELEASE_KEY_PASSWORD in ~/.gradle/gradle.properties (or -P). Without them the release build // is signed with the DEBUG key so it can be installed locally for testing -- NOT publishable. val relStore = findProperty("RELEASE_STORE_FILE") as String? + if ((findProperty("requireReleaseSigning") as String?) == "true") { + listOf("RELEASE_STORE_FILE", "RELEASE_STORE_PASSWORD", "RELEASE_KEY_ALIAS", "RELEASE_KEY_PASSWORD") + .forEach { if ((findProperty(it) as String?).isNullOrBlank()) throw GradleException("Missing release signing property: $it") } + } signingConfigs { if (relStore != null) create("release") { storeFile = file(relStore) @@ -107,7 +111,7 @@ val cargoNdkBuild = tasks.register("cargoNdkBuild") { cmd.set(buildList { add("cargo"); add("ndk") rustAbis.forEach { add("-t"); add(it) } - addAll(listOf("-P", "28", "-o", jniOut.get().asFile.absolutePath, "build", "-p", "p2pcore")) + addAll(listOf("-P", "28", "-o", jniOut.get().asFile.absolutePath, "build", "--locked", "-p", "p2pcore")) if (rustRelease) add("--release") }) inputs.files(rustInputs).withPropertyName("rustSources").withPathSensitivity(PathSensitivity.RELATIVE) @@ -123,7 +127,7 @@ val uniffiBindgen = tasks.register("uniffiBindgen") { workDir.set(repoRoot.absolutePath) extraEnv.set(envMap) val so = File(repoRoot, "target/x86_64-linux-android/$profileDir/libp2pcore.so") - cmd.set(listOf("cargo", "run", "-q", "-p", "p2pcore", "--bin", "uniffi-bindgen", "--", + cmd.set(listOf("cargo", "run", "--locked", "-q", "-p", "p2pcore", "--bin", "uniffi-bindgen", "--", "generate", "--library", so.absolutePath, "--language", "kotlin", "--out-dir", uniffiOut.get().asFile.absolutePath)) inputs.files(rustInputs).withPropertyName("rustSources").withPathSensitivity(PathSensitivity.RELATIVE) diff --git a/android/app/src/debug/kotlin/com/osvauld/p2p/DebugReceiver.kt b/android/app/src/debug/kotlin/com/osvauld/p2p/DebugReceiver.kt index c054bc7..7e59bfd 100644 --- a/android/app/src/debug/kotlin/com/osvauld/p2p/DebugReceiver.kt +++ b/android/app/src/debug/kotlin/com/osvauld/p2p/DebugReceiver.kt @@ -31,6 +31,11 @@ class DebugReceiver : BroadcastReceiver() { app.refresh() testLog("added name=${c.name} did=${c.did}") } + "clip" -> { + val cm = app.getSystemService(Context.CLIPBOARD_SERVICE) as android.content.ClipboardManager + cm.setPrimaryClip(android.content.ClipData.newPlainText("test", i.getStringExtra("text") ?: node.myTicket())) + testLog("clip set") + } "contacts" -> node.contacts().forEach { testLog("contact name=${it.name} did=${it.did}") } "call" -> { val who = i.getStringExtra("who") ?: "" diff --git a/android/app/src/debug/kotlin/com/osvauld/p2p/GalleryActivity.kt b/android/app/src/debug/kotlin/com/osvauld/p2p/GalleryActivity.kt index a46d2d4..0202f3c 100644 --- a/android/app/src/debug/kotlin/com/osvauld/p2p/GalleryActivity.kt +++ b/android/app/src/debug/kotlin/com/osvauld/p2p/GalleryActivity.kt @@ -78,9 +78,9 @@ private fun Gallery(which: String, app: P2pApp, act: ComponentActivity) { "home_search" -> HomeContent(contacts, true, false, emptyList(), {}, {}, none, {}, {}, none, emptyList(), null, startSearching = true, startQuery = "jo") "home_empty" -> HomeContent(emptyList(), true, false, emptyList(), {}, {}, none, {}, {}, none, emptyList(), null) "home_offline" -> HomeContent(contacts, false, false, listOf(Need.Mic), {}, {}, none, {}, {}, none, emptyList(), null) - "mycode" -> AddContactScreen(app, false, none, {}, {}) - "scan" -> AddContactScreen(app, true, none, {}, {}) - "paste" -> PasteCardScreen("OSVC2:MFRGGZDFMZTWQ2LKNNWG23TPOBYXE43UOV3HO6DZPE======", {}, none, none) + "mycode" -> AddContactScreen(app, false, onClose = none, onCall = {}, onVerify = {}) + "scan" -> AddContactScreen(app, true, onClose = none, onCall = {}, onVerify = {}) + "paste" -> PasteCardScreen(app, "OSVC2:MFRGGZDFMZTWQ2LKNNWG23TPOBYXE43UOV3HO6DZPE======", {}, none, {}) "adding" -> AddingScreen(maya, none) "added" -> AddedScreen(maya, arjun, none, none, none) "failed" -> FailedScreen(maya, null, none, none) diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml index 819c931..0a4abef 100644 --- a/android/app/src/main/AndroidManifest.xml +++ b/android/app/src/main/AndroidManifest.xml @@ -35,12 +35,19 @@ + + + + + + Unit, onCall: (Contact) -> Unit, onVerify: (Contact) -> Unit) { +fun AddContactScreen(app: P2pApp, startOnScan: Boolean, autoAdd: String? = null, pasteOnOpen: Boolean = false, onClose: () -> Unit, onCall: (Contact) -> Unit, onVerify: (Contact) -> Unit) { val ctx = LocalContext.current val scope = rememberCoroutineScope() val contacts by app.contacts.collectAsState() val meName = remember { app.node.profile()?.name ?: "" } - var stage by rememberSaveable { mutableStateOf(AddStage.Main) } + var stage by rememberSaveable { mutableStateOf(if (autoAdd != null) AddStage.Adding else AddStage.Main) } var tab by rememberSaveable { mutableIntStateOf(if (startOnScan) 1 else 0) } - var pasted by rememberSaveable { mutableStateOf("") } + var pasted by rememberSaveable { mutableStateOf(autoAdd ?: "") } + var peek by remember { mutableStateOf(null) } + var pasteNote by remember { mutableStateOf(null) } var added by remember { mutableStateOf(null) } var failure by remember { mutableStateOf(null) } val knownAtOpen = remember { contacts.map { it.did }.toSet() } @@ -74,14 +76,30 @@ fun AddContactScreen(app: P2pApp, startOnScan: Boolean, onClose: () -> Unit, onC .onFailure { failure = it.message; stage = AddStage.Failed } } } + LaunchedEffect(Unit) { if (autoAdd != null) add(autoAdd) } + /** Paste button: a card on the clipboard goes straight to "Add ?"; otherwise the text field. */ + fun pasteNow() { + val t = clipboardText(ctx) + scope.launch { + val p = withContext(Dispatchers.IO) { peekOrNull(app, t) } + when { + p == null -> { pasteNote = "No Tinline card on your clipboard. Paste the message here."; pasted = t.orEmpty(); stage = AddStage.Paste } + p.known -> { pasteNote = null; android.widget.Toast.makeText(ctx, "${p.name.ifBlank { "They" }} is already in your contacts", android.widget.Toast.LENGTH_LONG).show() } + else -> { peek = p; pasted = p.ticket; stage = AddStage.Confirm } + } + } + } + LaunchedEffect(Unit) { if (pasteOnOpen) pasteNow() } // The other phone dialled us while our code was on screen: they are in the contact list now. LaunchedEffect(contacts, stage) { if (stage == AddStage.Main) contacts.firstOrNull { it.did !in knownAtOpen }?.let { added = it; stage = AddStage.Added } } when (stage) { - AddStage.Main -> AddMain(app, meName, tab, { tab = it }, onClose, onPaste = { stage = AddStage.Paste }, onScanned = { pasted = it; add(it) }) - AddStage.Paste -> PasteCardScreen(pasted, { pasted = it }, onBack = { stage = AddStage.Main }, onAdd = { add(pasted) }) + AddStage.Main -> AddMain(app, meName, tab, { tab = it }, onClose, onPaste = ::pasteNow, onScanned = { pasted = it; add(it) }) + AddStage.Confirm -> peek?.let { p -> ConfirmScreen(p, onAdd = { add(p.ticket) }, onBack = { stage = AddStage.Main }) } + ?: AddMain(app, meName, tab, { tab = it }, onClose, onPaste = ::pasteNow, onScanned = { pasted = it; add(it) }) + AddStage.Paste -> PasteCardScreen(app, pasted, { pasted = it }, onBack = { stage = AddStage.Main }, onAdd = { add(it) }, note = pasteNote) AddStage.Adding -> AddingScreen(meName) { job?.cancel(); stage = AddStage.Main } AddStage.Added -> added?.let { AddedScreen(meName, it, onCall = { onCall(it) }, onVerify = { onVerify(it) }, onDone = onClose, onSaveAs = { a -> if (a != null) runCatching { app.node.renameContact(it.did, a); app.refresh() } }) } @@ -95,7 +113,7 @@ fun AddContactScreen(app: P2pApp, startOnScan: Boolean, onClose: () -> Unit, onC private fun AddMain(app: P2pApp, meName: String, tab: Int, onTab: (Int) -> Unit, onClose: () -> Unit, onPaste: () -> Unit, onScanned: (String) -> Unit) { if (tab == 1) TinlineTheme(dark = true) { ScanTab(tab, onTab, onClose, onPaste, onScanned) - } else MyCodeTab(app, meName, tab, onTab, onClose) + } else MyCodeTab(app, meName, tab, onTab, onClose, onPaste) } @Composable @@ -116,19 +134,8 @@ private fun Segmented(tab: Int, onTab: (Int) -> Unit, outline: Color) { } @Composable -private fun MyCodeTab(app: P2pApp, meName: String, tab: Int, onTab: (Int) -> Unit, onClose: () -> Unit) { - val ctx = LocalContext.current +private fun MyCodeTab(app: P2pApp, meName: String, tab: Int, onTab: (Int) -> Unit, onClose: () -> Unit, onPaste: () -> Unit) { val c = Tin.c - val scope = rememberCoroutineScope() - val status by app.status.collectAsState() - var ticket by remember { mutableStateOf(null) } - var err by remember { mutableStateOf(null) } - var version by remember { mutableIntStateOf(0) } - var share by remember { mutableStateOf(null) } - LaunchedEffect(status?.online, version) { - withContext(Dispatchers.IO) { runCatching { app.node.myTicket() } } - .onSuccess { ticket = it; err = null }.onFailure { err = it.message } - } Page { TopBar("Add contact", onClose) Segmented(tab, onTab, c.ln2) @@ -136,35 +143,8 @@ private fun MyCodeTab(app: P2pApp, meName: String, tab: Int, onTab: (Int) -> Uni Modifier.weight(1f).verticalScroll(rememberScrollState()).padding(start = 24.dp, end = 24.dp, top = 24.dp, bottom = 16.dp), horizontalAlignment = Alignment.CenterHorizontally, verticalArrangement = Arrangement.spacedBy(14.dp), ) { - CardBox(Modifier.fillMaxWidth(), radius = 24.dp) { - Column(Modifier.padding(20.dp).fillMaxWidth(), horizontalAlignment = Alignment.CenterHorizontally, verticalArrangement = Arrangement.spacedBy(14.dp)) { - Text(meName, style = TinType.titleL.copy(fontSize = 20.sp), color = c.ink) - val t = ticket - Box(Modifier.size(248.dp).clip(RoundedCornerShape(14.dp)).background(Color.White), contentAlignment = Alignment.Center) { - if (t != null) { - val bmp = remember(t) { qrBitmap(t, 720, fg = 0xFF17201D.toInt()) } - Image(bmp.asImageBitmap(), "QR code of your contact card", Modifier.fillMaxSize().padding(10.dp)) - Box(Modifier.size(40.dp).clip(RoundedCornerShape(10.dp)).background(Color.White).padding(3.dp).clip(RoundedCornerShape(8.dp)).background(Color(0xFF0B6B5B)), contentAlignment = Alignment.Center) { - TinMark(26.dp, can = Color.White, string = Color(0xFFE8B04A)) - } - } else Text(err ?: "Preparing your code…", Modifier.padding(16.dp), style = TinType.bodyM, color = Color(0xFF4D5853), textAlign = TextAlign.Center) - } - Hint("Works once. Making a new code cancels this one.", Modifier.widthIn(max = 300.dp), align = TextAlign.Center) - } - } - Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(10.dp)) { - TinButton("Share", { - ticket?.let { t -> - try { ctx.startActivity(Intent.createChooser(Intent(Intent.ACTION_SEND).setType("text/plain").putExtra(Intent.EXTRA_TEXT, t), "Share contact card")) } - catch (_: Exception) { share = "Nothing to share with" } - } - }, Modifier.weight(1f), style = BtnStyle.Tonal, icon = Icons.Rounded.Share, enabled = ticket != null) - TinButton("Copy", { ticket?.let { copyToClipboard(ctx, "ticket", it) } }, Modifier.weight(1f), style = BtnStyle.Outlined, icon = Icons.Rounded.ContentCopy, enabled = ticket != null) - } - share?.let { Hint(it, color = c.er) } - TinButton("New code", { - scope.launch { withContext(Dispatchers.IO) { runCatching { app.node.resetTicket() } }; ticket = null; version++ } - }, style = BtnStyle.Text, icon = Icons.Rounded.Refresh) + MyCodeBlock(app, meName) + TinButton("Paste their card", onPaste, style = BtnStyle.Tonal, icon = Icons.Rounded.ContentPaste) } Row( Modifier.padding(start = 24.dp, end = 24.dp, bottom = 24.dp).fillMaxWidth().clip(RoundedCornerShape(14.dp)).background(c.sf2).padding(horizontal = 14.dp, vertical = 12.dp), @@ -237,7 +217,7 @@ private fun ScanTab(tab: Int, onTab: (Int) -> Unit, onClose: () -> Unit, onPaste } } Column(Modifier.padding(start = 24.dp, end = 24.dp, top = 20.dp, bottom = 24.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) { - TinButton("Paste a card instead", onPaste, style = BtnStyle.Outlined, icon = Icons.Rounded.ContentPaste) + TinButton("Paste their card", onPaste, style = BtnStyle.Outlined, icon = Icons.Rounded.ContentPaste) Hint("Camera is used only to read the code. Nothing is recorded.", Modifier.fillMaxWidth(), align = TextAlign.Center) } } @@ -246,15 +226,18 @@ private fun ScanTab(tab: Int, onTab: (Int) -> Unit, onClose: () -> Unit, onPaste // ------------------------------------------------------------------ paste, adding, added, failed @Composable -fun PasteCardScreen(text: String, onChange: (String) -> Unit, onBack: () -> Unit, onAdd: () -> Unit) { +fun PasteCardScreen(app: P2pApp, text: String, onChange: (String) -> Unit, onBack: () -> Unit, onAdd: (String) -> Unit, note: String? = null) { val ctx = LocalContext.current val c = Tin.c - val looks = text.trim().let { it.startsWith("OSVC2:", true) || it.startsWith("osvc1.", true) } + // A card may sit inside a greeting; the core finds it and checks it. + val peek = remember(text) { peekOrNull(app, text) } + val looks = peek != null Page { TopBar("Paste a card", onBack) { TinButton("Paste", { clipboardText(ctx)?.let(onChange) }, style = BtnStyle.Text, icon = Icons.Rounded.ContentPaste, fill = false, height = 40.dp, textStyle = TinType.label) } Column(Modifier.weight(1f).verticalScroll(rememberScrollState()).padding(start = 24.dp, end = 24.dp, top = 12.dp, bottom = 8.dp), verticalArrangement = Arrangement.spacedBy(16.dp)) { + if (note != null) Text(note, style = TinType.bodyM, color = c.er) Text(androidx.compose.ui.text.buildAnnotatedString { append("If they sent their card as a message, paste the whole text here. It starts with ") pushStyle(androidx.compose.ui.text.SpanStyle(fontFamily = PlexMono, color = c.ink)); append("OSVC2:"); pop() @@ -266,13 +249,13 @@ fun PasteCardScreen(text: String, onChange: (String) -> Unit, onBack: () -> Unit ) { Column(Modifier.weight(1f)) { Text("Looks like a Tinline card", style = TinType.bodyL.copy(fontSize = 15.sp, fontWeight = FontWeight.Bold), color = c.onPrc) - Text("Single use", style = TinType.bodyM, color = c.onPrc) + Text(if (peek?.known == true) "Already a contact" else "Add ${peek?.name?.ifBlank { null } ?: "them"} · single use", style = TinType.bodyM, color = c.onPrc) } Icon(Icons.Rounded.CheckCircle, null, tint = c.onPrc) } Hint("Tip: a card is safest sent over an app you already trust. Anyone who gets it first could use it instead.") } - Column(Modifier.padding(start = 24.dp, end = 24.dp, bottom = 24.dp)) { TinButton("Add contact", onAdd, enabled = looks) } + Column(Modifier.padding(start = 24.dp, end = 24.dp, bottom = 24.dp)) { TinButton("Add contact", { peek?.let { onAdd(it.ticket) } }, enabled = looks && peek?.known != true) } } } @@ -285,6 +268,19 @@ private fun CenterScreen(footer: @Composable ColumnScope.() -> Unit = {}, body: } } +@Composable +private fun ConfirmScreen(p: uniffi.p2pcore.CardPeek, onAdd: () -> Unit, onBack: () -> Unit) { + val name = p.name.ifBlank { "this contact" } + CenterScreen(footer = { + TinButton("Add $name", onAdd, icon = Icons.Rounded.PersonAdd) + TinButton("Not now", onBack, style = BtnStyle.Text) + }) { + Avatar(p.name.ifBlank { "?" }, p.did, 88.dp) + H1("Add $name?", align = TextAlign.Center) + Lead("Found their Tinline card on your clipboard. Both phones need Tinline open and online to connect.", Modifier.widthIn(max = 320.dp), align = TextAlign.Center) + } +} + @Composable fun AddingScreen(me: String, onCancel: () -> Unit) { CenterScreen(footer = { TinButton("Cancel", onCancel, style = BtnStyle.Text) }) { diff --git a/android/app/src/main/kotlin/com/osvauld/p2p/CardShare.kt b/android/app/src/main/kotlin/com/osvauld/p2p/CardShare.kt new file mode 100644 index 0000000..a2b91e5 --- /dev/null +++ b/android/app/src/main/kotlin/com/osvauld/p2p/CardShare.kt @@ -0,0 +1,194 @@ +package com.osvauld.p2p + +import android.content.Context +import android.content.Intent +import android.widget.Toast +import androidx.compose.foundation.Image +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.rounded.* +import androidx.compose.material3.Text +import androidx.compose.runtime.* +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.draw.shadow +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.asImageBitmap +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.platform.LocalLifecycleOwner +import androidx.compose.ui.platform.LocalView +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.zIndex +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import androidx.lifecycle.Lifecycle +import androidx.lifecycle.LifecycleEventObserver +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext +import uniffi.p2pcore.CardPeek + +/** Text shared into Tinline from another app ("Share -> Tinline"); the UI picks it up once the app is unlocked. */ +object CardInbox { + val shared = MutableStateFlow(null) + + /** Called from MainActivity for an ACTION_SEND text/plain intent. */ + fun take(intent: Intent?) { + if (intent?.action != Intent.ACTION_SEND || intent.type?.startsWith("text/") != true) return + val t = intent.getStringExtra(Intent.EXTRA_TEXT) + intent.action = null // do not handle the same share again after a recreate + shared.value = t ?: "" + } +} + +private fun hashOf(ticket: String): String = + java.security.MessageDigest.getInstance("SHA-256").digest(ticket.toByteArray()).take(8).joinToString("") { "%02x".format(it) } + +fun peekOrNull(app: P2pApp, text: String?): CardPeek? = + if (text.isNullOrBlank()) null else runCatching { app.node.peekCard(text) }.getOrNull() + +/** Copy button that says "Copied" with a check for two seconds. */ +@Composable +fun CopyButton(text: String?, modifier: Modifier = Modifier, label: String = "Copy", style: BtnStyle = BtnStyle.Outlined) { + val ctx = LocalContext.current + var copied by remember { mutableStateOf(false) } + LaunchedEffect(copied) { if (copied) { delay(2000); copied = false } } + TinButton( + if (copied) "Copied" else label, + { text?.let { copyToClipboard(ctx, "ticket", it); copied = true } }, + modifier, style = style, icon = if (copied) Icons.Rounded.Check else Icons.Rounded.ContentCopy, enabled = text != null, + ) +} + +fun shareCard(ctx: Context, ticket: String): Boolean = try { + ctx.startActivity(Intent.createChooser(Intent(Intent.ACTION_SEND).setType("text/plain").putExtra(Intent.EXTRA_TEXT, ticket), "Share contact card")); true +} catch (_: Exception) { false } + +/** My QR code with Copy and Share right under it. Shared by the first-run home and the Add contact screen. */ +@Composable +fun MyCodeBlock(app: P2pApp, meName: String, qr: Dp = 248.dp, showNewCode: Boolean = true) { + val ctx = LocalContext.current + val c = Tin.c + val scope = rememberCoroutineScope() + val status by app.status.collectAsState() + var ticket by remember { mutableStateOf(null) } + var err by remember { mutableStateOf(null) } + var version by remember { mutableIntStateOf(0) } + var share by remember { mutableStateOf(null) } + LaunchedEffect(status?.online, version) { + withContext(Dispatchers.IO) { runCatching { app.node.myTicket() } } + .onSuccess { ticket = it; err = null }.onFailure { err = it.message } + } + Column(Modifier.fillMaxWidth(), horizontalAlignment = Alignment.CenterHorizontally, verticalArrangement = Arrangement.spacedBy(14.dp)) { + CardBox(Modifier.fillMaxWidth(), radius = 24.dp) { + Column(Modifier.padding(20.dp).fillMaxWidth(), horizontalAlignment = Alignment.CenterHorizontally, verticalArrangement = Arrangement.spacedBy(14.dp)) { + Text(meName, style = TinType.titleL.copy(fontSize = 20.sp), color = c.ink) + val t = ticket + Box(Modifier.size(qr).clip(RoundedCornerShape(14.dp)).background(Color.White), contentAlignment = Alignment.Center) { + if (t != null) { + val bmp = remember(t) { qrBitmap(t, 720, fg = 0xFF17201D.toInt()) } + Image(bmp.asImageBitmap(), "QR code of your contact card", Modifier.fillMaxSize().padding(10.dp)) + Box(Modifier.size(40.dp).clip(RoundedCornerShape(10.dp)).background(Color.White).padding(3.dp).clip(RoundedCornerShape(8.dp)).background(Color(0xFF0B6B5B)), contentAlignment = Alignment.Center) { + TinMark(26.dp, can = Color.White, string = Color(0xFFE8B04A)) + } + } else Text(err ?: "Preparing your code…", Modifier.padding(16.dp), style = TinType.bodyM, color = Color(0xFF4D5853), textAlign = TextAlign.Center) + } + Hint("Works once. Making a new code cancels this one.", Modifier.widthIn(max = 300.dp), align = TextAlign.Center) + } + } + Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(10.dp)) { + TinButton("Share", { ticket?.let { t -> if (!shareCard(ctx, t)) share = "Nothing to share with" } }, Modifier.weight(1f), style = BtnStyle.Tonal, icon = Icons.Rounded.Share, enabled = ticket != null) + CopyButton(ticket, Modifier.weight(1f)) + } + share?.let { Hint(it, color = c.er) } + if (showNewCode) TinButton("New code", { + scope.launch { withContext(Dispatchers.IO) { runCatching { app.node.resetTicket() } }; ticket = null; version++ } + }, style = BtnStyle.Text, icon = Icons.Rounded.Refresh) + } +} + +/** + * Looks for a card on the clipboard when the app comes forward, and for text shared into the app, and + * offers "Add ?". Draws nothing when there is nothing to offer. + * [clipboardOk]: this screen may prompt from the clipboard (Home, Add; not in a call, not locked, not onboarding). + * [shareOk]: a shared card may be handled now (unlocked, past onboarding). + */ +@Composable +fun CardPrompts(app: P2pApp, clipboardOk: Boolean, shareOk: Boolean, showOnScreen: Boolean, onChat: (CardPeek) -> Unit, onAdd: (CardPeek) -> Unit) { + val ctx = LocalContext.current + val view = LocalView.current + val owner = LocalLifecycleOwner.current + val scope = rememberCoroutineScope() + val prefs = remember { ctx.getSharedPreferences("card_prompt", Context.MODE_PRIVATE) } + val dismissed = remember { mutableSetOf().also { s -> prefs.getString("dismissed", null)?.let(s::add) } } + var prompt by remember { mutableStateOf(null) } + var armed by remember { mutableStateOf(true) } + val okNow by rememberUpdatedState(clipboardOk) + + fun check() { + testLog("clipcheck armed=$armed ok=$okNow focus=${view.hasWindowFocus()}") + if (!armed || !okNow || !view.hasWindowFocus()) return + val cm = ctx.getSystemService(Context.CLIPBOARD_SERVICE) as android.content.ClipboardManager + val d = cm.primaryClipDescription + armed = false + // Reading the clipboard shows a system toast on Android 12+, so only read real text, and only if it changed. + if (d == null || !(d.hasMimeType("text/plain") || d.hasMimeType("text/html"))) return + val stamp = d.timestamp + testLog("clipread stamp=$stamp last=${prefs.getLong("clip_stamp", -1L)}") + if (stamp != 0L && stamp == prefs.getLong("clip_stamp", -1L)) return + val text = runCatching { clipboardText(ctx) }.getOrNull() + prefs.edit().putLong("clip_stamp", stamp).apply() + scope.launch { + val p = withContext(Dispatchers.IO) { peekOrNull(app, text) } + testLog("clippeek len=${text?.length} -> ${p?.name} known=${p?.known}") + if (p != null && hashOf(p.ticket) !in dismissed && prompt == null) prompt = p + } + } + DisposableEffect(owner, view) { + val o = LifecycleEventObserver { _, e -> if (e == Lifecycle.Event.ON_RESUME) { armed = true; check() } } + val f = android.view.ViewTreeObserver.OnWindowFocusChangeListener { if (it) check() } + owner.lifecycle.addObserver(o) + view.viewTreeObserver.addOnWindowFocusChangeListener(f) + onDispose { owner.lifecycle.removeObserver(o); view.viewTreeObserver.removeOnWindowFocusChangeListener(f) } + } + LaunchedEffect(clipboardOk) { if (clipboardOk) check() } + + val shared by CardInbox.shared.collectAsState() + LaunchedEffect(shared, shareOk) { + val t = shared ?: return@LaunchedEffect + if (!shareOk) return@LaunchedEffect + CardInbox.shared.value = null + val p = withContext(Dispatchers.IO) { peekOrNull(app, t) } + when { + p == null -> Toast.makeText(ctx, "No Tinline contact card in that message", Toast.LENGTH_LONG).show() + else -> prompt = p + } + } + + val p = prompt + if (p != null && showOnScreen) { + val c = Tin.c + Box(Modifier.fillMaxSize().zIndex(10f).statusBarsPadding().padding(12.dp), contentAlignment = Alignment.TopCenter) { + Row( + Modifier.fillMaxWidth().shadow(6.dp, RoundedCornerShape(20.dp)).clip(RoundedCornerShape(20.dp)).background(c.sf2).padding(start = 14.dp, end = 8.dp, top = 12.dp, bottom = 12.dp), + verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(12.dp), + ) { + Avatar(p.name.ifBlank { "?" }, p.did, 44.dp) + Column(Modifier.weight(1f)) { + Text(if (p.known) "Chat with ${p.name.ifBlank { "them" }}?" else "Add ${p.name.ifBlank { "this contact" }}?", style = TinType.bodyL.copy(fontWeight = FontWeight.Bold), color = c.ink, maxLines = 1, overflow = TextOverflow.Ellipsis) + Text(if (p.known) "They are already in your contacts." else "Tinline found their card.", style = TinType.bodyM, color = c.ink2) + } + TinButton("Not now", { dismissed += hashOf(p.ticket); prefs.edit().putString("dismissed", hashOf(p.ticket)).apply(); prompt = null }, style = BtnStyle.Text, fill = false, height = 44.dp, textStyle = TinType.label) + TinButton(if (p.known) "Open" else "Add", { prompt = null; if (p.known) onChat(p) else onAdd(p) }, fill = false, height = 44.dp, textStyle = TinType.label) + } + } + } +} diff --git a/android/app/src/main/kotlin/com/osvauld/p2p/Home.kt b/android/app/src/main/kotlin/com/osvauld/p2p/Home.kt index 894ca73..099d521 100644 --- a/android/app/src/main/kotlin/com/osvauld/p2p/Home.kt +++ b/android/app/src/main/kotlin/com/osvauld/p2p/Home.kt @@ -3,6 +3,9 @@ package com.osvauld.p2p import androidx.compose.foundation.background import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.* +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll +import androidx.compose.ui.text.style.TextAlign import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.items import androidx.compose.foundation.shape.CircleShape @@ -48,7 +51,7 @@ private fun String.matchesQuery(q: String) = q.isBlank() || contains(q.trim(), i fun HomeScreen( app: P2pApp, missing: List, onFix: (Need) -> Unit, onAdd: (scan: Boolean) -> Unit, onSettings: () -> Unit, onContact: (Contact) -> Unit, onCall: (Contact) -> Unit, - onChat: (String) -> Unit = {}, onNewChat: () -> Unit = {}, callError: String? = null, + onChat: (String) -> Unit = {}, onNewChat: () -> Unit = {}, callError: String? = null, onPaste: () -> Unit = {}, ) { val ctx = androidx.compose.ui.platform.LocalContext.current val status by app.status.collectAsState() @@ -82,6 +85,7 @@ fun HomeScreen( available = avail.available, onTurnOn = { app.setAvailable(true) }, subLines = subs, tab = tab, onTab = { tab = it }, chatRows = rows, onChat = onChat, onNewChat = onNewChat, chatBadge = rows.sumOf { it.unread }, callBadge = history.count { it.missed && it.startedAt.toLong() > callsSeen }, + firstRun = { FirstRunAdd(app, onScan = { onAdd(true) }, onPaste = onPaste) }, ) if (sheet) AvailabilitySheet(avail.available, avail.until?.toLong(), app.node.profile()?.name ?: "", onDismiss = { sheet = false }) { available, until -> sheet = false @@ -115,6 +119,7 @@ fun HomeContent( available: Boolean = true, onTurnOn: () -> Unit = {}, subLines: Map = emptyMap(), tab: HomeTab = HomeTab.Contacts, onTab: (HomeTab) -> Unit = {}, chatRows: List = emptyList(), onChat: (String) -> Unit = {}, onNewChat: () -> Unit = {}, chatBadge: Int = 0, callBadge: Int = 0, + firstRun: (@Composable () -> Unit)? = null, ) { val c = Tin.c var searching by remember { mutableStateOf(startSearching) } @@ -125,6 +130,8 @@ fun HomeContent( LaunchedEffect(tab) { searching = false; query = "" } val banner: @Composable () -> Unit = { TopBanner(online, connectingGrace, missing, onFix, available, onTurnOn) } + // No contacts yet: the home screen is the add screen (my code, Copy, Share, Scan, Paste), on Chats and Contacts alike. + val firstRunShown = firstRun != null && contacts.isEmpty() && !searching && tab != HomeTab.Calls Column(Modifier.fillMaxSize().background(c.bg).statusBarsPadding().imePadding()) { Box(Modifier.weight(1f).fillMaxWidth()) { Column(Modifier.fillMaxSize()) { @@ -137,7 +144,7 @@ fun HomeContent( IconBtn(Icons.Rounded.Settings, "Settings", onSettings) } } - when (tab) { + if (firstRunShown) Column(Modifier.weight(1f)) { banner(); firstRun?.invoke() } else when (tab) { HomeTab.Chats -> Box(Modifier.weight(1f)) { ChatsBody(chatRows, query, banner, onSearch = { searching = true }, onChat = onChat, onNewChat = onNewChat, searching = searching) } @@ -159,12 +166,7 @@ fun HomeContent( item { Hint("History stays on this phone only.", Modifier.padding(horizontal = 20.dp, vertical = 12.dp)) } } HomeTab.Contacts -> Box(Modifier.weight(1f)) { - if (contacts.isEmpty() && !searching) { - Column(Modifier.fillMaxSize()) { - banner() - EmptyHome(onAdd) - } - } else LazyColumn(Modifier.fillMaxSize(), contentPadding = PaddingValues(bottom = 112.dp)) { + LazyColumn(Modifier.fillMaxSize(), contentPadding = PaddingValues(bottom = 112.dp)) { if (!searching) { item { banner() } item { @@ -293,17 +295,26 @@ private fun RowItem( } } +/** First run: my code with Copy and Share under it, Scan their code, Paste their card. One screen, nothing to open. */ @Composable -private fun EmptyHome(onAdd: (Boolean) -> Unit) { +private fun FirstRunAdd(app: P2pApp, onScan: () -> Unit, onPaste: () -> Unit) { + val c = Tin.c + val meName = remember { app.node.profile()?.name ?: "" } Column( - Modifier.fillMaxSize().padding(start = 28.dp, end = 28.dp, bottom = 48.dp), - verticalArrangement = Arrangement.spacedBy(16.dp, Alignment.CenterVertically), + Modifier.fillMaxSize().verticalScroll(rememberScrollState()).padding(start = 24.dp, end = 24.dp, top = 8.dp, bottom = 24.dp), + verticalArrangement = Arrangement.spacedBy(14.dp), horizontalAlignment = Alignment.CenterHorizontally, ) { - StringIllustration(height = 150.dp) - H1("Your line is ready") - Lead("Add someone to call. Meet up or video-chat, open Tinline on both phones, and scan each other’s code.") - Spacer(Modifier.height(4.dp)) - TinButton("Scan their code", { onAdd(true) }, icon = Icons.Rounded.QrCodeScanner) - TinButton("Show my code", { onAdd(false) }, style = BtnStyle.Outlined, icon = Icons.Rounded.QrCode2) + H1("Add your first contact", align = TextAlign.Center) + Lead("Show your code to a friend, or copy it and send it. Or scan theirs.", Modifier.widthIn(max = 320.dp), align = TextAlign.Center) + MyCodeBlock(app, meName, qr = 220.dp, showNewCode = false) + TinButton("Scan their code", onScan, icon = Icons.Rounded.QrCodeScanner) + TinButton("Paste their card", onPaste, style = BtnStyle.Outlined, icon = Icons.Rounded.ContentPaste) + Row( + Modifier.fillMaxWidth().clip(RoundedCornerShape(14.dp)).background(c.sf2).padding(horizontal = 14.dp, vertical = 12.dp), + verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(10.dp), + ) { + Dot(c.pr, 10.dp) + Text("Waiting for them to scan — keep this open.", style = TinType.bodyM, color = c.ink2) + } } } diff --git a/android/app/src/main/kotlin/com/osvauld/p2p/MainActivity.kt b/android/app/src/main/kotlin/com/osvauld/p2p/MainActivity.kt index 8912877..57d11dc 100644 --- a/android/app/src/main/kotlin/com/osvauld/p2p/MainActivity.kt +++ b/android/app/src/main/kotlin/com/osvauld/p2p/MainActivity.kt @@ -42,18 +42,20 @@ class MainActivity : ComponentActivity() { val app = P2pApp.get(this) if (app.node.hasIdentity()) CoreService.ensureRunning(this) intent?.getStringExtra(ChatNotifier.EXTRA_PEER)?.let { OpenChat.request.value = it } + if (savedInstanceState == null) CardInbox.take(intent) setContent { TinlineTheme { Root(app) } } } override fun onNewIntent(intent: android.content.Intent) { super.onNewIntent(intent) intent.getStringExtra(ChatNotifier.EXTRA_PEER)?.let { OpenChat.request.value = it } + CardInbox.take(intent) } } private sealed interface Route { data object Home : Route - data class Add(val scan: Boolean) : Route + data class Add(val scan: Boolean, val ticket: String? = null, val paste: Boolean = false) : Route data class Contact(val did: String) : Route data class Verify(val did: String) : Route data object Settings : Route @@ -165,9 +167,16 @@ private fun Root(app: P2pApp) { !termsOk -> TermsScreen(progress = null, onBack = null) { LegalStore.accept(ctx); termsOk = true } else -> { BackHandler(stack.size > 1) { pop() } + val inCall by app.calls.ui.collectAsState() + val top = stack.last() + val onHomeOrAdd = top is Route.Home || top is Route.Add + CardPrompts(app, clipboardOk = onHomeOrAdd && inCall == null, shareOk = true, showOnScreen = inCall == null, onChat = { p -> if (top is Route.Add) pop(); stack.add(Route.Chat(p.did)) }) { p -> + if (stack.last() is Route.Add) pop() + stack.add(Route.Add(false, ticket = p.ticket)) + } when (val r = stack.last()) { Route.Home -> HomeScreen( - app, missing, fix, onAdd = { stack.add(Route.Add(it)) }, onSettings = { stack.add(Route.Settings) }, + app, missing, fix, onAdd = { stack.add(Route.Add(it)) }, onPaste = { stack.add(Route.Add(false, paste = true)) }, onSettings = { stack.add(Route.Settings) }, onContact = { stack.add(Route.Contact(it.did)) }, onCall = { call(it.did) }, callError = callError, onChat = { stack.add(Route.Chat(it)) }, onNewChat = { stack.add(Route.NewChat) }, ) @@ -184,7 +193,7 @@ private fun Root(app: P2pApp) { ConversationScreen(app.chat, r.did, name, status?.online == true, onBack = ::pop, onCall = { call(r.did) }, addedAtSecs = c?.addedAt?.toLong()) } Route.History -> HistoryScreen(app, onBack = ::pop, onContact = { stack.add(Route.Contact(it.did)) }) - is Route.Add -> AddContactScreen(app, r.scan, onClose = ::pop, + is Route.Add -> AddContactScreen(app, r.scan, r.ticket, r.paste, onClose = ::pop, onCall = { c -> pop(); call(c.did) }, onVerify = { c -> pop(); stack.add(Route.Contact(c.did)); stack.add(Route.Verify(c.did)) }) is Route.Contact -> { val c = contacts.firstOrNull { it.did == r.did } diff --git a/crates/desktop/Cargo.toml b/crates/desktop/Cargo.toml index 051b04e..44294ee 100644 --- a/crates/desktop/Cargo.toml +++ b/crates/desktop/Cargo.toml @@ -13,8 +13,11 @@ p2pcore = { path = "../core" } audio.workspace = true serde.workspace = true serde_json.workspace = true -iced = { version = "0.14", features = ["tokio", "canvas", "svg", "image"] } +iced = { version = "0.14", features = ["tokio", "canvas", "image"] } png = "0.17" +# Icons are rasterised here (no text, so no system font scan; see ui.rs). +resvg = { version = "0.45", default-features = false } +usvg = { version = "0.45", default-features = false } image = { version = "0.25", default-features = false, features = ["png", "jpeg", "gif", "webp", "bmp"] } rfd = { version = "0.17", default-features = false, features = ["xdg-portal"] } open = "5" diff --git a/crates/desktop/src/app.rs b/crates/desktop/src/app.rs index 42a696e..e9b0699 100644 --- a/crates/desktop/src/app.rs +++ b/crates/desktop/src/app.rs @@ -175,6 +175,21 @@ struct App { ticks: u32, fetching: bool, chat: ChatState, + /// The window has keyboard focus (false with no window). + focused: bool, + /// A contact card found on the clipboard, offered as "Add ?". + offer: Option, + /// Tickets of cards the user said "Not now" to; not offered again by the clipboard watcher. + dismissed: std::collections::HashSet, +} + +/// Where a click on a notification should lead. +#[derive(Debug, Clone)] +pub enum Target { + /// Just bring the window up (calls: the call screen is already what it shows). + Show, + /// The conversation with this contact. + Chat(String), } #[derive(Debug, Clone)] @@ -257,6 +272,12 @@ enum Msg { OutDev(String), ToneToggled(bool), Chat(Cm), + /// The window gained or lost keyboard focus. + Focus(window::Id, bool), + Scale(f32), + ClipText(bool, Option), + OfferAdd, + OfferDismiss, /// Test-hooks: the window's pixels, written to P2P_SHOT. Shot(window::Screenshot), } @@ -317,8 +338,9 @@ fn take_secret(z: &mut Zeroizing) -> String { const RENAME_ID: &str = "rename"; const SEARCH_ID: &str = "search"; -/// A desktop notification, off the UI thread (some servers block on show). -fn notify(summary: &str, body: &str) { +/// A desktop notification, off the UI thread (some servers block on show). Clicking it (the +/// "default" action, Linux) sends `target` back into the app. +fn notify(summary: &str, body: &str, target: Target) { let (summary, body) = (summary.to_string(), body.to_string()); std::thread::spawn(move || { let mut n = notify_rust::Notification::new(); @@ -327,7 +349,25 @@ fn notify(summary: &str, body: &str) { // a shortcut with the same id so they show under the app's name and icon. #[cfg(windows)] n.app_id("com.osvauld.tinline"); - let _ = n.show(); + #[cfg(all(unix, not(target_os = "macos")))] + { + n.action("default", "Open"); + if let Ok(handle) = n.show() { + // Blocks until the notification is clicked or closed; this thread is its own. + handle.wait_for_action(|action| { + if action == "default" + && let Some(init) = INIT.get() + { + let _ = init.tx.send(Ev::Open(target)); + } + }); + } + } + #[cfg(not(all(unix, not(target_os = "macos"))))] + { + let _ = target; + let _ = n.show(); + } }); } @@ -438,6 +478,9 @@ impl App { ticks: 0, fetching: false, chat: ChatState::default(), + focused: false, + offer: None, + dismissed: Default::default(), node, }; if has && crate::test_env("P2P_SCREEN").is_some_and(|v| v == "settings") { @@ -453,7 +496,7 @@ impl App { tasks.push(blocking(audio::list_devices, Msg::Devices)); } if !init.hidden { - tasks.push(app.show_window()); + tasks.push(app.open_window(false)); } #[cfg(feature = "test-hooks")] if crate::test_env("P2P_CHAT_FAKE").is_some_and(|v| v == "1") { @@ -482,9 +525,21 @@ impl App { (app, Task::batch(tasks)) } + /// Brings the window up for the user: opens it if closed, un-minimises, and asks the + /// compositor to put it in front of them (see `crate::raise`). fn show_window(&mut self) -> Task { + crate::raise::to_user(); + self.open_window(true) + } + + fn open_window(&mut self, attention: bool) -> Task { + let ask = move |id| if attention { window::request_user_attention(id, Some(window::UserAttention::Informational)) } else { Task::none() }; if let Some(id) = self.win { - return Task::batch([window::minimize(id, false), window::gain_focus(id)]); + return Task::batch([ + window::minimize(id, false), + window::gain_focus(id), + ask(id), + ]); } let (id, task) = window::open(window::Settings { size: WINDOW, @@ -493,7 +548,28 @@ impl App { ..Default::default() }); self.win = Some(id); - task.map(Msg::WindowOpened) + Task::batch([ + task.map(Msg::WindowOpened), + ask(id), + ]) + } + + /// Whether the home screen is what the user is looking at, so a card offer makes sense. + fn can_offer(&self) -> bool { + self.win.is_some() + && !self.demo + && self.call.is_none() + && self.ended.is_none() + && self.screen == Screen::Home + && self.lock != LockState::Locked + && self.lock != LockState::NoIdentity + } + + fn check_clipboard(&self, explicit: bool) -> Task { + if !explicit && !self.can_offer() { + return Task::none(); + } + clipboard::read().map(move |t| Msg::ClipText(explicit, t)) } fn start_node(&self) -> Task { @@ -574,7 +650,7 @@ impl App { match presentation { End::Hidden => {} End::Missed(text) => { - notify("Tinline", &text); + notify("Tinline", &text, Target::Show); self.notice = Some(text); } End::Screen(text) => { @@ -706,12 +782,14 @@ impl App { c.stats = self.node.call_stats(); } } - Msg::WindowOpened(_) => {} + Msg::WindowOpened(id) => return window::scale_factor(id).map(Msg::Scale), + Msg::Scale(f) => ui::set_scale(f), Msg::CloseReq(id) => { if Some(id) == self.win { self.hide_phrase(); if INIT.get().unwrap().tray { self.win = None; + self.focused = false; return window::close(id); } return window::minimize(id, true); @@ -721,6 +799,7 @@ impl App { if Some(id) == self.win { self.hide_phrase(); self.win = None; + self.focused = false; } } Msg::Theme(m) => self.dark = self.forced_dark.unwrap_or(m != theme::Mode::Light), @@ -1049,7 +1128,7 @@ impl App { self.status = self.node.status(); self.contacts = self.node.contacts(); match r { - Ok(()) => return Task::batch([self.fetch_ticket(), self.refresh_chats()]), + Ok(()) => return Task::batch([self.fetch_ticket(), self.refresh_chats(), self.check_clipboard(false)]), Err(e) => self.notice = Some(format!("Could not start: {e}")), } } @@ -1087,12 +1166,58 @@ impl App { Key::Named(Named::Escape) if self.call.is_none() && (self.sel.is_some() || self.screen != Screen::Home) => { return self.update(Msg::Home); } + // Only reaches here when no text input took the paste. + Key::Character("v") if mods.command() && self.can_offer() && self.chat.viewer.is_none() => { + return self.check_clipboard(true); + } Key::Character("k") if mods.command() && self.call.is_none() => return operation::focus(SEARCH_ID), Key::Character("m") if mods.command() && active => return self.update(Msg::ToggleMute), Key::Character("e") if mods.command() && self.call.is_some() => return self.update(Msg::Hangup), _ => {} } } + Msg::Focus(id, on) => { + if Some(id) != self.win { + return Task::none(); + } + self.focused = on; + if on { + // What arrived while the user was elsewhere counts as read now. + let mut tasks = vec![self.check_clipboard(false)]; + if self.chat_visible() + && let Some(peer) = self.chat.peer.clone() + { + tasks.push(self.mark_read(&peer)); + } + return Task::batch(tasks); + } + } + Msg::ClipText(explicit, text) => { + if !explicit && !self.can_offer() { + return Task::none(); + } + let Some(peek) = text.filter(|t| t.len() < 64 * 1024).and_then(|t| self.node.peek_card(t)) else { + return Task::none(); + }; + if peek.known { + if explicit { + self.notice = Some(format!("{} is already in your contacts", peek.name)); + } + } else if explicit || !self.dismissed.contains(&peek.ticket) { + self.offer = Some(peek); + } + } + Msg::OfferAdd => { + if let Some(p) = self.offer.take() { + self.add_in = p.ticket; + return self.update(Msg::AddPressed); + } + } + Msg::OfferDismiss => { + if let Some(p) = self.offer.take() { + self.dismissed.insert(p.ticket); + } + } Msg::AddAlias(v) => self.add_alias = v, Msg::OpenAdd => { self.screen = Screen::AddContact; @@ -1102,10 +1227,16 @@ impl App { self.notice = None; } Msg::AddPressed => { - let t = self.add_in.trim().to_string(); + let mut t = self.add_in.trim().to_string(); if t.is_empty() || matches!(self.add_phase, AddPhase::Connecting) { return Task::none(); } + // A card pasted with text around it (a chat message, a mail) is cut out of it. + if let Some(p) = self.node.peek_card(t.clone()) { + t = p.ticket; + } + self.offer = None; + self.screen = Screen::AddContact; self.add_phase = AddPhase::Connecting; let node = self.node.clone(); return blocking(move || node.add_contact(t).map_err(s), Msg::Added); @@ -1367,13 +1498,16 @@ impl App { Ev::Contacts => { if !self.demo { self.contacts = self.node.contacts(); + if self.offer.as_ref().is_some_and(|o| self.contacts.iter().any(|c| c.did == o.did)) { + self.offer = None; + } self.refresh_history(); return self.refresh_chats(); } } Ev::Chat(c) => return self.on_chat_event(c), Ev::Incoming(info) if self.call.as_ref().is_some_and(|c| c.state == CallState::Active && c.info.call_id != info.call_id) => { - notify("Tinline", &format!("{} is calling", info.peer_name)); + notify("Tinline", &format!("{} is calling", info.peer_name), Target::Show); let mut tasks = vec![self.show_window()]; if let Some(act) = INIT.get().unwrap().waiting_action.clone() { let (node, id) = (self.node.clone(), info.call_id.clone()); @@ -1405,7 +1539,7 @@ impl App { stats: None, }); self.ended = None; - notify("Tinline", &format!("{name} is calling")); + notify("Tinline", &format!("{name} is calling"), Target::Show); let mut tasks = vec![self.show_window()]; if let Some(secs) = INIT.get().unwrap().auto_answer { let (node, id) = (self.node.clone(), info.call_id); @@ -1426,7 +1560,7 @@ impl App { self.refresh_history(); // Unanswered, or the caller gave up: a missed call like any other. if let End::Missed(text) = reason::present(&reason, &w.peer_name, true, false) { - notify("Tinline", &text); + notify("Tinline", &text, Target::Show); self.notice = Some(text); } } @@ -1445,6 +1579,16 @@ impl App { } Ev::AudioNotice(m) => self.notice = Some(m), Ev::Tray(TrayCmd::Show) => return self.show_window(), + Ev::Open(target) => { + let mut tasks = vec![self.show_window()]; + if let Target::Chat(did) = target + && self.contacts.iter().any(|c| c.did == did) + && self.call.is_none() + { + tasks.push(self.update(Msg::Select(did))); + } + return Task::batch(tasks); + } Ev::Tray(TrayCmd::Quit) => return self.update(Msg::Quit), } Task::none() @@ -1457,7 +1601,10 @@ impl App { window::close_requests().map(Msg::CloseReq), window::close_events().map(Msg::Closed), system::theme_changes().map(Msg::Theme), - iced::event::listen_with(|e, _, _| match e { + iced::event::listen_with(|e, _, id| match e { + iced::Event::Window(window::Event::Focused) => Some(Msg::Focus(id, true)), + iced::Event::Window(window::Event::Unfocused) => Some(Msg::Focus(id, false)), + iced::Event::Window(window::Event::Rescaled(f)) => Some(Msg::Scale(f)), iced::Event::Window(window::Event::FileDropped(p)) => Some(Msg::Chat(Cm::Dropped(p))), iced::Event::Window(window::Event::FileHovered(_)) => Some(Msg::Chat(Cm::DropHover(true))), iced::Event::Window(window::Event::FilesHoveredLeft) => Some(Msg::Chat(Cm::DropHover(false))), diff --git a/crates/desktop/src/chat.rs b/crates/desktop/src/chat.rs index 21faede..a1df5da 100644 --- a/crates/desktop/src/chat.rs +++ b/crates/desktop/src/chat.rs @@ -11,6 +11,7 @@ use iced::widget::text_editor; use iced::{clipboard, Task}; use p2pcore::{Attachment, AttachmentKind, Chat, Contact, DayPage, Error, Message as ChatMsg, Node, TransferState}; +use crate::app::Target; use crate::media::{self, Class, Pixels}; use crate::voice::{Player, Recorder}; @@ -446,7 +447,7 @@ impl App { blocking(move || src.chat_day(p, day).map_err(s), move |r| Msg::Chat(Cm::Day(peer.clone(), prepend, r))) } - fn mark_read(&mut self, peer: &str) -> Task { + pub(crate) fn mark_read(&mut self, peer: &str) -> Task { if let Some(c) = self.chat.chats.iter_mut().find(|c| c.peer_did == peer) { if c.unread == 0 { return Task::none(); @@ -903,11 +904,13 @@ impl App { ChatEv::Added(m) => { let (peer, incoming) = (m.peer_did.clone(), !m.outgoing); let open = self.chat_visible() && self.chat.peer.as_deref() == Some(peer.as_str()); - if incoming && !(open && self.win.is_some()) { - notify(&self.chat_name(&peer), &preview_of(&m)); + // Seen only if the window exists and the user is in it. + let attended = open && self.win.is_some() && self.focused; + if incoming && !attended { + notify(&self.chat_name(&peer), &preview_of(&m), Target::Chat(peer.clone())); } self.chat.upsert(m); - if incoming && open { + if incoming && attended { return self.mark_read(&peer); } } diff --git a/crates/desktop/src/chat_view.rs b/crates/desktop/src/chat_view.rs index 9343bc5..574490b 100644 --- a/crates/desktop/src/chat_view.rs +++ b/crates/desktop/src/chat_view.rs @@ -10,6 +10,9 @@ use super::*; use crate::app::chat::{preview_of, Cm, SideTab, Thumb, ViewBody, COMPOSER_ID}; use crate::media::{self, Class}; +/// Width of the hover actions beside a bubble: three 28 px buttons, 2 px apart. +const ACTIONS_W: f32 = 3.0 * 28.0 + 2.0 * 2.0; + fn local(ms: u64) -> Option> { Local.timestamp_millis_opt(ms as i64).single() } @@ -359,7 +362,8 @@ impl App { let open = self.chat.menu.as_ref() == Some(&m.id); let hot = open || self.chat.hover.as_ref() == Some(&m.id); let mut line = row![].spacing(6).align_y(Alignment::Start); - let actions: El = if hot && !m.deleted { self.hover_actions(t, m) } else { Space::new().width(0).into() }; + // The actions' room is kept when they are hidden, so hovering never rewraps the bubble. + let actions: El = if hot && !m.deleted { self.hover_actions(t, m) } else { Space::new().width(ACTIONS_W).into() }; if m.outgoing { line = line.push(Space::new().width(Fill)).push(actions).push(bubble); } else { diff --git a/crates/desktop/src/demo.rs b/crates/desktop/src/demo.rs index efa5fe7..00d4cb7 100644 --- a/crates/desktop/src/demo.rs +++ b/crates/desktop/src/demo.rs @@ -96,6 +96,13 @@ impl App { }; self.safety = Some("41203 88127 05519 73360 29841 66012 90475 13398 57206 84431 20987 36654".into()); } + "offer" | "empty-offer" => { + if name == "empty-offer" { + contacts.clear(); + self.recents.clear(); + } + self.offer = Some(p2pcore::CardPeek { ticket: "OSVC2:x".into(), name: "Priya Nair".into(), did: "did:key:z6MkPriya".into(), known: false }); + } "avail" => self.avail_open = true, "offline" => self.status.online = false, "unavailable" => { diff --git a/crates/desktop/src/main.rs b/crates/desktop/src/main.rs index d3a2bcb..8d36b15 100644 --- a/crates/desktop/src/main.rs +++ b/crates/desktop/src/main.rs @@ -20,6 +20,7 @@ pub(crate) use tlog; mod app; mod audio; mod keystore; +mod raise; mod media; mod reason; mod single; @@ -45,6 +46,8 @@ pub enum Ev { /// Audio device trouble worth telling the user about. AudioNotice(String), Chat(ChatEv), + /// A notification was clicked. + Open(app::Target), } /// What the core's `ChatEvents` report, as plain data for the UI loop. diff --git a/crates/desktop/src/raise.rs b/crates/desktop/src/raise.rs new file mode 100644 index 0000000..58ea597 --- /dev/null +++ b/crates/desktop/src/raise.rs @@ -0,0 +1,36 @@ +//! Getting the window in front of the user when the app shows it (tray, notification click, a +//! second launch, a call coming in). +//! +//! What iced 0.14 / winit 0.30 can do on Wayland: `gain_focus` is a no-op there, and +//! `request_user_attention` goes through xdg-activation but only marks the window urgent (no +//! activation token from a user action is passed, and iced exposes neither +//! `request_activation_token` nor a token at window creation). So that part is done with the +//! compositor's own protocol where we know one: under sway the window is moved to the workspace +//! the user is on and focused with `swaymsg`. Elsewhere this does nothing. + +/// Asks the compositor to bring our window to the user's current workspace and focus it. +pub fn to_user() { + #[cfg(target_os = "linux")] + sway(); +} + +#[cfg(target_os = "linux")] +fn sway() { + if std::env::var_os("SWAYSOCK").is_none() { + return; + } + let pid = std::process::id(); + let _ = std::thread::Builder::new().name("raise".into()).spawn(move || { + // A window opened just now is not mapped yet; keep trying for a while. + for _ in 0..60 { + let out = std::process::Command::new("swaymsg") + .arg(format!("[pid={pid}] move container to workspace current; [pid={pid}] focus")) + .output(); + match out { + Ok(o) if o.status.success() && !String::from_utf8_lossy(&o.stdout).contains("\"success\": false") => return, + Ok(_) => std::thread::sleep(std::time::Duration::from_millis(250)), + Err(_) => return, // no swaymsg + } + } + }); +} diff --git a/crates/desktop/src/ui.rs b/crates/desktop/src/ui.rs index 679a4ba..192b638 100644 --- a/crates/desktop/src/ui.rs +++ b/crates/desktop/src/ui.rs @@ -1,7 +1,7 @@ //! Tinline look: colour tokens (docs/design/Main.dc.html), fonts, Lucide-style icons and the //! widget styles built from them. -use iced::widget::{button, container, pick_list, scrollable, svg, text_input, toggler}; +use iced::widget::{button, container, pick_list, scrollable, text_input, toggler}; use iced::{font, Background, Border, Color, Element, Font, Length, Theme}; /// Figtree (variable, 400-700) and IBM Plex Mono, embedded; licences in assets/fonts. @@ -211,55 +211,140 @@ impl Icon { } } -fn from_markup(markup: String) -> svg::Handle { - svg::Handle::from_memory(markup.into_bytes()) +// ---- vector drawing without iced's `svg` widget ---- +// +// iced_wgpu's SVG cache loads every system font for each new window's renderer; with a large +// font collection that costs seconds per window. Our vector art has no text, so it is rasterised +// here with an empty font set (resvg without its `text` feature) into `image` handles, once per +// (art, pixel size, colour). + +use std::collections::HashMap; +use std::hash::{Hash, Hasher}; +use std::sync::atomic::{AtomicU32, Ordering}; +use std::sync::{Mutex, OnceLock}; + +use iced::widget::image; + +static SCALE_BITS: AtomicU32 = AtomicU32::new(0x3f80_0000); // 1.0f32 + +/// The window's scale factor, so icons are drawn at device pixels. +pub fn set_scale(s: f32) { + if s.is_finite() && s > 0.0 { + SCALE_BITS.store(s.to_bits(), Ordering::Relaxed); + } } -/// An icon of `size` px in `color`. The SVG is black; the widget tints it. -pub fn icon<'a, M: 'a>(i: Icon, size: f32, color: Color) -> Element<'a, M> { - let markup = format!( - r##"{}"##, - i.body() +fn scale() -> f32 { + f32::from_bits(SCALE_BITS.load(Ordering::Relaxed)) +} + +type RasterKey = (u64, u32, [u8; 4]); + +/// Draws `markup` (an SVG without text) `px` pixels square. With `tint`, every pixel takes that +/// colour and keeps only its own alpha (what iced's svg `color` did); without, own colours stay. +fn raster(art: u64, markup: impl FnOnce() -> String, px: u32, tint: Option) -> image::Handle { + static CACHE: OnceLock>> = OnceLock::new(); + let rgba = tint.map(|c| [c.r, c.g, c.b, c.a].map(|v| (v.clamp(0.0, 1.0) * 255.0).round() as u8)).unwrap_or([0; 4]); + let key = (art, px, rgba); + let cache = CACHE.get_or_init(Default::default); + if let Some(h) = cache.lock().unwrap().get(&key) { + return h.clone(); + } + let handle = render(&markup(), px, tint.map(|_| rgba)); + cache.lock().unwrap().insert(key, handle.clone()); + handle +} + +fn render(markup: &str, px: u32, tint: Option<[u8; 4]>) -> image::Handle { + let px = px.clamp(1, 1024); + let blank = || image::Handle::from_rgba(1, 1, vec![0, 0, 0, 0]); + let Ok(tree) = usvg::Tree::from_str(markup, &usvg::Options::default()) else { return blank() }; + let Some(mut pm) = resvg::tiny_skia::Pixmap::new(px, px) else { return blank() }; + let ts = resvg::tiny_skia::Transform::from_scale(px as f32 / tree.size().width(), px as f32 / tree.size().height()); + resvg::render(&tree, ts, &mut pm.as_mut()); + let mut out = Vec::with_capacity(pm.data().len()); + for p in pm.pixels() { + let c = p.demultiply(); + match tint { + Some([r, g, b, a]) => out.extend_from_slice(&[r, g, b, (c.alpha() as u32 * a as u32 / 255) as u8]), + None => out.extend_from_slice(&[c.red(), c.green(), c.blue(), c.alpha()]), + } + } + image::Handle::from_rgba(px, px, out) +} + +fn hash_of(x: impl Hash) -> u64 { + let mut h = std::collections::hash_map::DefaultHasher::new(); + x.hash(&mut h); + h.finish() +} + +fn art<'a, M: 'a>(handle: image::Handle, size: f32) -> Element<'a, M> { + image(handle).width(Length::Fixed(size)).height(Length::Fixed(size)).into() +} + +fn device_px(size: f32) -> u32 { + (size * scale()).ceil().max(1.0) as u32 +} + +/// A single-colour glyph (24 grid): `paint` is the root element's attributes, `body` its shapes. +/// Black in the SVG; the pixels are then tinted. +pub fn glyph<'a, M: 'a>(paint: &'static str, body: &'static str, size: f32, color: Color) -> Element<'a, M> { + let h = raster( + hash_of((paint, body)), + || format!(r#"{body}"#), + device_px(size), + Some(color), ); - svg(from_markup(markup)) - .width(Length::Fixed(size)) - .height(Length::Fixed(size)) - .style(move |_: &Theme, _| svg::Style { color: Some(color) }) - .into() + art(h, size) +} + +pub const STROKE: &str = r##"fill="none" stroke="#000" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round""##; + +/// An icon of `size` px in `color`. +pub fn icon<'a, M: 'a>(i: Icon, size: f32, color: Color) -> Element<'a, M> { + glyph(STROKE, i.body(), size, color) } /// The brand mark: two cans joined by the amber string. Own colours, never tinted. pub fn logo<'a, M: 'a>(size: f32, can: Color, string: Color) -> Element<'a, M> { let hex = |c: Color| format!("#{:02X}{:02X}{:02X}", (c.r * 255.0) as u8, (c.g * 255.0) as u8, (c.b * 255.0) as u8); - let m = format!( - r#""#, - c = hex(can), - s = hex(string) + let (c, s) = (hex(can), hex(string)); + let h = raster( + hash_of(("logo", &c, &s)), + || { + format!( + r#""# + ) + }, + device_px(size), + None, ); - svg(from_markup(m)).width(Length::Fixed(size)).height(Length::Fixed(size)).into() + art(h, size) } /// Four quality bars, `n` of them filled. pub fn bars<'a, M: 'a>(n: u8, on: Color, off: Color) -> Element<'a, M> { - let mut body = String::new(); - for i in 0..4u8 { - let h = 5 + i as u32 * 4; - let c = if i < n { on } else { off }; - body += &format!( - r##""##, - 2 + i as u32 * 5, - 21 - h, - h, - (c.r * 255.0) as u8, - (c.g * 255.0) as u8, - (c.b * 255.0) as u8, - c.a - ); - } - svg(from_markup(format!(r#"{body}"#))) - .width(Length::Fixed(18.0)) - .height(Length::Fixed(18.0)) - .into() + let build = || { + let mut body = String::new(); + for i in 0..4u8 { + let h = 5 + i as u32 * 4; + let c = if i < n { on } else { off }; + body += &format!( + r##""##, + 2 + i as u32 * 5, + 21 - h, + h, + (c.r * 255.0) as u8, + (c.g * 255.0) as u8, + (c.b * 255.0) as u8, + c.a + ); + } + format!(r#"{body}"#) + }; + let key = hash_of(("bars", n, [on, off].map(|c| [c.r, c.g, c.b, c.a].map(f32::to_bits)))); + art(raster(key, build, device_px(18.0), None), 18.0) } // ---- widget styles ---- diff --git a/crates/desktop/src/view.rs b/crates/desktop/src/view.rs index 2a692b9..eed04ec 100644 --- a/crates/desktop/src/view.rs +++ b/crates/desktop/src/view.rs @@ -705,7 +705,14 @@ impl App { main = main.push(self.notice_bar(t)); } if let Some(c) = self.selected().filter(|_| !self.chat.info) { - return row![self.sidebar(t), self.conversation_view(t, c)].into(); + let pane: El = match self.offer_bar(t) { + Some(o) => column![container(o).padding([12, 16]), self.conversation_view(t, c)].width(Fill).height(Fill).into(), + None => self.conversation_view(t, c), + }; + return row![self.sidebar(t), pane].into(); + } + if let Some(o) = self.offer_bar(t) { + main = main.push(o); } main = main.push(match self.selected() { Some(c) => self.detail_view(t, c), @@ -718,21 +725,96 @@ impl App { .into() } + + /// Our QR code and "Copy card": what a first-timer shows or sends. + fn my_code(&self, t: Tok, title: &str) -> El<'_> { + let qr: El = match &self.qr { + Some(q) => container(canvas(QrView(q)).width(Length::Fixed(200.0)).height(Length::Fixed(200.0))) + .padding(8) + .style(ui::plain(Color::WHITE, 12.0)) + .into(), + None => container(tx("Preparing your code\u{2026}", 14.0, t.ink2)).width(216).height(216).center_x(216).center_y(216).into(), + }; + column![ + label(t, title), + qr, + tx("Works once. Share it with someone who should be able to call you.", 13.0, t.ink2), + if self.copied_at.is_some_and(|at| at.elapsed() < Duration::from_secs(2)) { + pill(t, Kind::Quiet, Some(Icon::Check), "Copied", self.ticket.as_ref().map(|_| Msg::CopyTicket)) + } else { + pill(t, Kind::Quiet, Some(Icon::Copy), "Copy card", self.ticket.as_ref().map(|_| Msg::CopyTicket)) + }, + ] + .spacing(12) + .width(Fill) + .into() + } + + /// The paste field for someone else's card; a card with text around it is fine. + fn their_card(&self, t: Tok, title: &str) -> El<'_> { + column![ + label(t, title), + text_input("Paste their card (OSVC2:\u{2026})", &self.add_in) + .on_input(Msg::AddChanged) + .on_paste(Msg::AddChanged) + .on_submit(Msg::AddPressed) + .padding(12) + .size(14) + .font(ui::MONO) + .style(ui::input_style(t)), + pill(t, Kind::Primary, None, "Add contact", (!self.add_in.trim().is_empty()).then_some(Msg::AddPressed)), + tx( + "Tip: a card is safest sent over an app you already trust. Anyone who gets it first could use it instead.", + 13.0, + t.ink2 + ), + ] + .spacing(12) + .width(Fill) + .into() + } + + /// "Add ?" for a card found on the clipboard. + fn offer_bar(&self, t: Tok) -> Option> { + let o = self.offer.as_ref()?; + Some( + container( + row![ + avatar(t, &o.name, &o.did, 36.0), + column![semi(format!("Add {}?", o.name), 15.0, t.ink), tx("Their card is on your clipboard.", 13.0, t.ink2)] + .spacing(2) + .width(Fill), + pill(t, Kind::Primary, None, "Add", Some(Msg::OfferAdd)), + pill(t, Kind::Ghost, None, "Not now", Some(Msg::OfferDismiss)), + ] + .spacing(12) + .align_y(Alignment::Center), + ) + .padding([12, 16]) + .width(Fill) + .style(ui::card(t)) + .into(), + ) + } + /// Home with nothing selected: a quiet starting point. Your code lives in Add contact. fn code_view(&self, t: Tok) -> El<'_> { if self.contacts.is_empty() { + let both = row![ + self.my_code(t, "Show this to them"), + container(Space::new()).width(1).height(Fill).style(ui::plain(t.line, 0.0)), + self.their_card(t, "Or paste theirs"), + ] + .spacing(28) + .height(Length::Shrink); return column![ bold("Your line is ready", 28.0, t.ink), tx( - "Add the first person you want to call. You\u{2019}ll both need Tinline open for a moment \u{2014} side by side, or over a video call.", + "Let them scan your code or send them your card, or paste theirs. You\u{2019}ll both need Tinline open for a moment \u{2014} side by side, or over a video call.", 15.0, t.ink2 ), - row![ - pill(t, Kind::Primary, Some(Icon::UserPlus), "Add your first contact", Some(Msg::OpenAdd)), - pill(t, Kind::Quiet, None, "Show my code", Some(Msg::OpenAdd)), - ] - .spacing(10), + container(both).padding(24).width(Fill).style(ui::card(t)), ] .spacing(14) .into(); @@ -1004,43 +1086,8 @@ impl App { .spacing(8) .into(), AddPhase::Idle => { - let qr: El = match &self.qr { - Some(q) => container(canvas(QrView(q)).width(Length::Fixed(200.0)).height(Length::Fixed(200.0))) - .padding(8) - .style(ui::plain(Color::WHITE, 12.0)) - .into(), - None => container(tx("Preparing your code\u{2026}", 14.0, t.ink2)).width(216).height(216).center_x(216).center_y(216).into(), - }; - let mine = column![ - label(t, "They scan your code"), - qr, - tx("Works once. Share it with someone who should be able to call you.", 13.0, t.ink2), - if self.copied_at.is_some_and(|at| at.elapsed() < Duration::from_secs(2)) { - pill(t, Kind::Quiet, Some(Icon::Check), "Copied", self.ticket.as_ref().map(|_| Msg::CopyTicket)) - } else { - pill(t, Kind::Quiet, Some(Icon::Copy), "Copy card", self.ticket.as_ref().map(|_| Msg::CopyTicket)) - }, - ] - .spacing(12) - .width(Fill); - let theirs = column![ - label(t, "Or add theirs"), - text_input("Paste their card (OSVC2:\u{2026})", &self.add_in) - .on_input(Msg::AddChanged) - .on_submit(Msg::AddPressed) - .padding(12) - .size(14) - .font(ui::MONO) - .style(ui::input_style(t)), - pill(t, Kind::Primary, None, "Add contact", (!self.add_in.trim().is_empty()).then_some(Msg::AddPressed)), - tx( - "Tip: a card is safest sent over an app you already trust. Anyone who gets it first could use it instead.", - 13.0, - t.ink2 - ), - ] - .spacing(12) - .width(Fill); + let mine = self.my_code(t, "They scan your code"); + let theirs = self.their_card(t, "Or add theirs"); row![mine, container(Space::new()).width(1).height(Fill).style(ui::plain(t.line, 0.0)), theirs] .spacing(28) .height(Length::Shrink) diff --git a/crates/desktop/src/voice.rs b/crates/desktop/src/voice.rs index 9701c40..bd70a20 100644 --- a/crates/desktop/src/voice.rs +++ b/crates/desktop/src/voice.rs @@ -13,7 +13,7 @@ use std::time::Duration; use cpal::traits::{DeviceTrait, HostTrait, StreamTrait}; use cpal::{FromSample, Sample, SampleFormat, SizedSample, Stream, StreamConfig}; -use iced::widget::{button, canvas, container, row, svg, text, Space}; +use iced::widget::{button, canvas, container, row, text, Space}; use iced::{Alignment, Color, Element, Length, Point, Rectangle, Renderer, Size, Theme}; use p2pcore::{VoiceDecoder, VoiceInfo, VoiceRecorder}; use rtrb::RingBuffer; @@ -363,14 +363,9 @@ pub fn clock(ms: u32) -> String { format!("{}:{:02}", s / 60, s % 60) } -fn glyph<'a, M: 'a>(body: &str, size: f32, color: Color, fill: bool) -> Element<'a, M> { - let paint = if fill { r##"fill="#000" stroke="none""## } else { r##"fill="none" stroke="#000" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round""## }; - let markup = format!(r#"{body}"#); - svg(svg::Handle::from_memory(markup.into_bytes())) - .width(Length::Fixed(size)) - .height(Length::Fixed(size)) - .style(move |_: &Theme, _| svg::Style { color: Some(color) }) - .into() +fn glyph<'a, M: 'a>(body: &'static str, size: f32, color: Color, fill: bool) -> Element<'a, M> { + let paint = if fill { r##"fill="#000" stroke="none""## } else { ui::STROKE }; + ui::glyph(paint, body, size, color) } /// What the bubble shows. `position_ms` is `None` before the first play. diff --git a/docs/github-releases.md b/docs/github-releases.md new file mode 100644 index 0000000..5ddbe7d --- /dev/null +++ b/docs/github-releases.md @@ -0,0 +1,69 @@ +# GitHub release builds + +`.github/workflows/release.yml` builds Linux x86_64 tarballs, a Debian package, +and a signed universal Android release APK (arm64-v8a + x86_64). Tag builds +create a **draft** GitHub release only after both platform jobs succeed. Manual +runs produce Actions artifacts without creating a GitHub release. + +## One-time setup + +1. Push the workflow to GitHub (`osvauld/tinline`). Enable Actions if needed. +2. Create a GitHub environment named `release` under Settings → Environments. + Add required reviewers if available on your plan, and restrict it to trusted + release tags and the branch used for manual test runs. +3. Add these environment secrets: + - `ANDROID_KEYSTORE_BASE64`: base64-encoded release keystore + - `ANDROID_STORE_PASSWORD` + - `ANDROID_KEY_ALIAS` + - `ANDROID_KEY_PASSWORD` +4. Before merging, push `release/linux-android-packaging` to trigger both builds. + Allow that trusted branch in the `release` environment deployment rules and + approve the job if required. Branch builds upload artifacts only, not releases. + After merging into the default branch, manual Run workflow is also available. + +Do not paste secrets into chat, commit them, or include signing keys in artifacts. +Base64 is encoding, not encryption. Keep an encrypted backup of the signing key +and passwords outside GitHub. CI fails instead of falling back to debug signing. + +If you already have a signing key for distributed APKs, reuse it. Android requires +the same certificate for updates. If Play App Signing is enabled, an upload key +is not necessarily the app signing key: APKs signed with it cannot update a +Play-installed app signed with another certificate. + +If this is the first direct APK distribution, generate a dedicated release key +locally using `keytool` (it prompts for passwords): + +```sh +keytool -genkeypair -keystore tinline-release.jks -alias tinline \ + -keyalg RSA -keysize 4096 -validity 10000 +# Encode locally and put the result directly into the GitHub secret UI. +base64 -w0 tinline-release.jks +``` + +## Release + +Set workspace version in `Cargo.toml`, commit, then tag that commit: + +```sh +git tag v0.1.0 +git push origin v0.1.0 +``` + +The tag must match the workspace version. Android versionName is taken from that +version, and versionCode uses the commit count. Keep linear release ancestry and +check the code exceeds all previously distributed/Play-uploaded versions; do not +rewrite release history. + +Download artifacts from the manual run or draft release. Test desktop install, +launch, chat and calls on a clean Ubuntu 24.04 VM, and install the APK on a real +phone. The workflow installs the Debian package on the build runner, but this is +**not** a clean-system or GUI functional test. Older Debian/Ubuntu versions are +not promised compatible; build against an older baseline separately if needed. +The Ubuntu-built tarball must also be tested on current Arch before publishing +`tinline-bin` to AUR. Resolve all dynamic dependencies and replace the AUR checksum +placeholder with a real checksum, then regenerate `.SRCINFO`. + +Publishing the draft makes it visible on GitHub Releases and triggers the separate +`Publish release to R2` workflow. Configure its Cloudflare and APT signing credentials +before publishing; see [R2 publishing](r2-publishing.md). AUR submission and Play +uploads remain separate. diff --git a/docs/r2-publishing.md b/docs/r2-publishing.md new file mode 100644 index 0000000..3bf4dbf --- /dev/null +++ b/docs/r2-publishing.md @@ -0,0 +1,79 @@ +# Publishing releases to R2 + +`Publish release to R2` runs when a stable GitHub release is **published**, not +when its draft is created or a branch builds. It can also be run manually for an +existing published tag. Merge the workflow into the default branch before relying +on release events or manual dispatch. + +## GitHub configuration + +In Settings → Environments → `release`, configure: + +Secrets: + +- `R2_ACCESS_KEY_ID` +- `R2_SECRET_ACCESS_KEY` (scope credentials to this bucket) +- `APT_GPG_PRIVATE_KEY` (ASCII-armored private signing key, not Base64) +- `APT_GPG_PASSPHRASE` + +Environment **variables** (not secrets): + +- `R2_BUCKET`: `tinline` +- `R2_ENDPOINT`: `https://.r2.cloudflarestorage.com` +- `APT_GPG_KEY_ID`: full signing-key fingerprint + +R2 credentials configured as secrets for bucket/endpoint must be moved to +variables or the workflow adapted. Android secrets remain unchanged. Enable +required reviewers if supported, and allow only reviewed release branches/tags. +R2 hosting is public: APKs and tarballs become downloadable immediately on upload. + +## Create an APT key locally + +Use a dedicated package-repository key, separate from Android signing. Keep an +encrypted offline backup and never commit/paste private material into chat. + +```sh +gpg --quick-generate-key 'Tinline package repository ' rsa4096 sign 2y +gpg --list-secret-keys --keyid-format long +# Copy the full fingerprint into APT_GPG_KEY_ID. +# Export locally to a file, then put its entire contents in APT_GPG_PRIVATE_KEY. +gpg --armor --export-secret-keys > /tmp/tinline-apt-private.asc +``` + +Add the key's password as `APT_GPG_PASSPHRASE`. Delete the temporary private-key +export after transferring it. The workflow exports the corresponding public key +as `https://repo.osvauld.com/tinline.gpg` automatically. + +## Publication + +1. Complete builds and test installation, chat, calls and Android updates. +2. Tag the approved commit; let Release builds create its draft with all assets. +3. Publish the draft on GitHub, then approve the R2 job if required. +4. Verify the public key, APK, tarball and APT InRelease URLs over HTTPS. +5. Test `apt update` and installation on a clean Ubuntu 24.04 machine. + +Bucket layout: + +```text +tinline.gpg +releases/v0.1.0/tinline-v0.1.0.apk +releases/v0.1.0/tinline-v0.1.0-x86_64-linux.tar.gz +releases/v0.1.0/SHA256SUMS +apt/pool/main/t/tinline/tinline_0.1.0_amd64.deb +apt/dists/stable/InRelease +``` + +Older pool packages and by-hash indices are retained. Existing versioned artifacts +cannot be replaced with different contents. Package/index objects upload before +signed metadata; InRelease is uploaded last. Never use `sync --delete`. Avoid +Cloudflare cache rules that override the metadata's no-cache headers. The tarball +is Ubuntu-built and still needs Arch compatibility testing before AUR submission. + +APT metadata expires after 30 days to limit replay attacks. Until scheduled +renewal is implemented, **rerun publishing for the latest published tag at least +monthly**, even if there is no new release. Monitor key expiry too. Do not rotate +the public signing key without planning how existing clients receive the new key. + +Public downloads do not make an app self-update: APT handles Debian updates, +AUR publishing is separate, and direct APK users must download/install updates +manually unless an in-app updater is added. diff --git a/packaging/linux/README.md b/packaging/linux/README.md new file mode 100644 index 0000000..e15d4c7 --- /dev/null +++ b/packaging/linux/README.md @@ -0,0 +1,54 @@ +# Linux packaging + +First channels: + +- Debian/Ubuntu APT repo at `https://repo.osvauld.com/apt` +- Arch AUR package `tinline-bin` +- Release tarballs at `https://repo.osvauld.com/releases/vX.Y.Z/` + +## Verification status + +Packaging is scaffolding, not a verified release. Build tarballs natively on the +intended distro; an Arch build is not automatically compatible with Debian/Ubuntu. +The local development host is Arch and lacks `dpkg-deb`/`dpkg-shlibdeps`. +Debian builds must run on Debian/Ubuntu with `dpkg-dev` installed; library +requirements are calculated using `dpkg-shlibdeps`. Test on the oldest supported +distro before claiming compatibility. AUR runtime dependencies also need checking +against the final tarball (including versioned Abseil dependencies). + +Builds use `target/distribution` and `--locked`, without `test-hooks`, and queue +through `scripts/buildlock.py`. Build output is ignored under `dist/`. + +Local build: + +```sh +./scripts/package_linux_tarball.sh +./scripts/package_deb.sh +sudo apt install dpkg-dev apt-utils +GPG_KEY_ID= ./scripts/build_apt_repo.sh +``` + +Upload to Cloudflare R2: + +```sh +export AWS_ACCESS_KEY_ID=... +export AWS_SECRET_ACCESS_KEY=... +export R2_BUCKET=osvauld-packages +export R2_ENDPOINT=https://.r2.cloudflarestorage.com +./scripts/upload_repo_r2.sh +``` + +APT install target: + +```sh +curl -fsSL https://repo.osvauld.com/tinline.gpg \ + | sudo tee /usr/share/keyrings/tinline.gpg >/dev/null + +echo "deb [signed-by=/usr/share/keyrings/tinline.gpg] https://repo.osvauld.com/apt stable main" \ + | sudo tee /etc/apt/sources.list.d/tinline.list + +sudo apt update +sudo apt install tinline +``` + +Before publishing AUR, replace `SKIP` with the real SHA-256 from the tarball and regenerate `.SRCINFO` with `makepkg --printsrcinfo > .SRCINFO`. diff --git a/packaging/linux/aur/tinline-bin/.SRCINFO b/packaging/linux/aur/tinline-bin/.SRCINFO new file mode 100644 index 0000000..9f30b53 --- /dev/null +++ b/packaging/linux/aur/tinline-bin/.SRCINFO @@ -0,0 +1,19 @@ +pkgbase = tinline-bin + pkgdesc = Peer-to-peer voice calls and 1:1 chat + pkgver = 0.1.0 + pkgrel = 1 + url = https://tinline.osvauld.com + arch = x86_64 + license = GPL-3.0-or-later + depends = gtk3 + depends = alsa-lib + depends = libxkbcommon + depends = libxcb + depends = libsecret + depends = xdg-desktop-portal + provides = tinline + conflicts = tinline + source_x86_64 = https://repo.osvauld.com/releases/v0.1.0/tinline-v0.1.0-x86_64-linux.tar.gz + sha256sums_x86_64 = SKIP + +pkgname = tinline-bin diff --git a/packaging/linux/aur/tinline-bin/PKGBUILD b/packaging/linux/aur/tinline-bin/PKGBUILD new file mode 100644 index 0000000..d2fc1d4 --- /dev/null +++ b/packaging/linux/aur/tinline-bin/PKGBUILD @@ -0,0 +1,21 @@ +# Maintainer: Osvauld +pkgname=tinline-bin +pkgver=0.1.0 +pkgrel=1 +pkgdesc="Peer-to-peer voice calls and 1:1 chat" +arch=('x86_64') +url="https://tinline.osvauld.com" +license=('GPL-3.0-or-later') +depends=('gtk3' 'alsa-lib' 'libxkbcommon' 'libxcb' 'libsecret' 'xdg-desktop-portal') +provides=('tinline') +conflicts=('tinline') +source_x86_64=("https://repo.osvauld.com/releases/v${pkgver}/tinline-v${pkgver}-x86_64-linux.tar.gz") +sha256sums_x86_64=('SKIP') + +package() { + local srcdir_name="tinline-${pkgver}-x86_64-linux" + install -Dm755 "${srcdir}/${srcdir_name}/bin/tinline" "${pkgdir}/usr/bin/tinline" + install -Dm644 "${srcdir}/${srcdir_name}/share/applications/tinline.desktop" "${pkgdir}/usr/share/applications/tinline.desktop" + install -Dm644 "${srcdir}/${srcdir_name}/share/icons/hicolor/512x512/apps/tinline.png" "${pkgdir}/usr/share/icons/hicolor/512x512/apps/tinline.png" + install -Dm644 "${srcdir}/${srcdir_name}/LICENSE" "${pkgdir}/usr/share/licenses/tinline/LICENSE" +} diff --git a/packaging/linux/tinline.desktop b/packaging/linux/tinline.desktop new file mode 100644 index 0000000..bfb9600 --- /dev/null +++ b/packaging/linux/tinline.desktop @@ -0,0 +1,9 @@ +[Desktop Entry] +Type=Application +Name=Tinline +Comment=Peer-to-peer voice calls and 1:1 chat +Exec=tinline %u +Icon=tinline +Terminal=false +Categories=Network;Chat;InstantMessaging; +StartupNotify=true diff --git a/packaging/linux/tinline.png b/packaging/linux/tinline.png new file mode 100644 index 0000000..71665e7 Binary files /dev/null and b/packaging/linux/tinline.png differ diff --git a/scripts/build_apt_repo.sh b/scripts/build_apt_repo.sh new file mode 100755 index 0000000..ef314a7 --- /dev/null +++ b/scripts/build_apt_repo.sh @@ -0,0 +1,42 @@ +#!/usr/bin/env bash +set -euo pipefail +ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +cd "$ROOT" +REPO="${REPO:-dist/repo/apt}" +ARCH="${ARCH:-amd64}" +: "${GPG_KEY_ID:?set the full APT signing key fingerprint}" +for tool in dpkg-scanpackages apt-ftparchive gpg; do + command -v "$tool" >/dev/null || { echo "missing $tool" >&2; exit 1; } +done +mkdir -p "$REPO/pool/main/t/tinline" "$REPO/dists/stable/main/binary-$ARCH" +cp dist/deb/tinline_*_${ARCH}.deb "$REPO/pool/main/t/tinline/" +pushd "$REPO" >/dev/null +INDEX="dists/stable/main/binary-$ARCH" +dpkg-scanpackages --multiversion --arch "$ARCH" pool > "$INDEX/Packages" +gzip -9nc "$INDEX/Packages" > "$INDEX/Packages.gz" +# Generate before adding by-hash aliases; never checksum old signatures. +rm -f dists/stable/Release dists/stable/Release.gpg dists/stable/InRelease +apt-ftparchive \ + -o APT::FTPArchive::Release::Origin=Osvauld \ + -o APT::FTPArchive::Release::Label=Tinline \ + -o APT::FTPArchive::Release::Suite=stable \ + -o APT::FTPArchive::Release::Codename=stable \ + -o APT::FTPArchive::Release::Architectures="$ARCH" \ + -o APT::FTPArchive::Release::Components=main \ + -o APT::FTPArchive::Release::Acquire-By-Hash=yes \ + -o APT::FTPArchive::Release::Valid-Until="$(date -u -d '+30 days' -R)" \ + release dists/stable > dists/stable/Release +mkdir -p "$INDEX/by-hash/SHA256" "$INDEX/by-hash/SHA512" +for file in "$INDEX/Packages" "$INDEX/Packages.gz"; do + cp "$file" "$INDEX/by-hash/SHA256/$(sha256sum "$file" | cut -d' ' -f1)" + cp "$file" "$INDEX/by-hash/SHA512/$(sha512sum "$file" | cut -d' ' -f1)" +done +SIGN_ARGS=(--batch --yes --local-user "$GPG_KEY_ID") +if [[ -n "${GPG_PASSPHRASE_FILE:-}" ]]; then + SIGN_ARGS+=(--pinentry-mode loopback --passphrase-file "$GPG_PASSPHRASE_FILE") +fi +gpg "${SIGN_ARGS[@]}" --detach-sign --armor -o dists/stable/Release.gpg dists/stable/Release +gpg "${SIGN_ARGS[@]}" --clearsign -o dists/stable/InRelease dists/stable/Release +gpg --verify dists/stable/InRelease +gpg --batch --yes --export "$GPG_KEY_ID" > ../tinline.gpg +popd >/dev/null diff --git a/scripts/package_deb.sh b/scripts/package_deb.sh new file mode 100755 index 0000000..635ff3e --- /dev/null +++ b/scripts/package_deb.sh @@ -0,0 +1,66 @@ +#!/usr/bin/env bash +set -euo pipefail + +ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +cd "$ROOT" + +VERSION="${VERSION:-$(python3 - <<'PY' +import tomllib +with open('Cargo.toml','rb') as f: + print(tomllib.load(f)['workspace']['package']['version']) +PY +)}" +ARCH="${ARCH:-$(dpkg --print-architecture 2>/dev/null || uname -m)}" +case "$ARCH" in + x86_64|amd64) DEB_ARCH=amd64 ;; + aarch64|arm64) DEB_ARCH=arm64 ;; + *) echo "unsupported arch: $ARCH" >&2; exit 1 ;; +esac + +for tool in dpkg-deb dpkg-shlibdeps; do + command -v "$tool" >/dev/null || { echo "missing $tool; build on Debian/Ubuntu with dpkg-dev installed" >&2; exit 1; } +done + +python3 scripts/buildlock.py --who deb-package -- \ + cargo build --locked --release -p desktop --target-dir target/distribution + +PKGROOT="target/package/deb/tinline_${VERSION}_${DEB_ARCH}" +rm -rf "$PKGROOT" +mkdir -p "$PKGROOT/DEBIAN" \ + "$PKGROOT/usr/bin" \ + "$PKGROOT/usr/share/applications" \ + "$PKGROOT/usr/share/icons/hicolor/512x512/apps" \ + "$PKGROOT/usr/share/doc/tinline" + +install -m 0755 target/distribution/release/p2p-desktop "$PKGROOT/usr/bin/tinline" +install -m 0644 packaging/linux/tinline.desktop "$PKGROOT/usr/share/applications/tinline.desktop" +install -m 0644 packaging/linux/tinline.png "$PKGROOT/usr/share/icons/hicolor/512x512/apps/tinline.png" +install -m 0644 LICENSE "$PKGROOT/usr/share/doc/tinline/copyright" + +# Resolve versioned runtime dependencies against the build distro, not a guessed list. +DEPS_DIR="$(mktemp -d)" +trap 'rm -rf "$DEPS_DIR"' EXIT +mkdir -p "$DEPS_DIR/debian" +printf 'Source: tinline\n\nPackage: tinline\nArchitecture: any\n' > "$DEPS_DIR/debian/control" +DEPENDS="$(cd "$DEPS_DIR" && dpkg-shlibdeps -O -e"$ROOT/$PKGROOT/usr/bin/tinline")" +DEPENDS="${DEPENDS#shlibs:Depends=}" +INSTALLED_SIZE="$(du -sk "$PKGROOT/usr" | cut -f1)" +cat > "$PKGROOT/DEBIAN/control" < +Installed-Size: ${INSTALLED_SIZE} +Depends: ${DEPENDS}, libsecret-1-0, xdg-desktop-portal +Recommends: gnome-keyring | kwalletmanager +Homepage: https://tinline.osvauld.com +Description: Peer-to-peer voice calls and 1:1 chat + Tinline is an Osvauld product for peer-to-peer voice calls and 1:1 chat. +CONTROL + +mkdir -p dist/deb +dpkg-deb --build --root-owner-group "$PKGROOT" "dist/deb/tinline_${VERSION}_${DEB_ARCH}.deb" +sha256sum "dist/deb/tinline_${VERSION}_${DEB_ARCH}.deb" > "dist/deb/tinline_${VERSION}_${DEB_ARCH}.deb.sha256" +echo "dist/deb/tinline_${VERSION}_${DEB_ARCH}.deb" diff --git a/scripts/package_linux_tarball.sh b/scripts/package_linux_tarball.sh new file mode 100755 index 0000000..7240552 --- /dev/null +++ b/scripts/package_linux_tarball.sh @@ -0,0 +1,35 @@ +#!/usr/bin/env bash +set -euo pipefail + +ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +cd "$ROOT" + +VERSION="${VERSION:-$(python3 - <<'PY' +import tomllib +with open('Cargo.toml','rb') as f: + print(tomllib.load(f)['workspace']['package']['version']) +PY +)}" +ARCH="${ARCH:-$(uname -m)}" +case "$ARCH" in + x86_64|amd64) DEB_ARCH=amd64; TARBALL_ARCH=x86_64 ;; + aarch64|arm64) DEB_ARCH=arm64; TARBALL_ARCH=aarch64 ;; + *) echo "unsupported arch: $ARCH" >&2; exit 1 ;; +esac + +# Isolate distributable builds from development/test-hooks artifacts. +python3 scripts/buildlock.py --who linux-package -- \ + cargo build --locked --release -p desktop --target-dir target/distribution + +OUT="dist/releases/v${VERSION}" +STAGE="target/package/tinline-${VERSION}-${TARBALL_ARCH}-linux" +rm -rf "$STAGE" +mkdir -p "$STAGE/bin" "$STAGE/share/applications" "$STAGE/share/icons/hicolor/512x512/apps" +install -m 0755 target/distribution/release/p2p-desktop "$STAGE/bin/tinline" +install -m 0644 packaging/linux/tinline.desktop "$STAGE/share/applications/tinline.desktop" +install -m 0644 packaging/linux/tinline.png "$STAGE/share/icons/hicolor/512x512/apps/tinline.png" +install -m 0644 LICENSE "$STAGE/LICENSE" +mkdir -p "$OUT" +tar -C "$(dirname "$STAGE")" -czf "$OUT/tinline-v${VERSION}-${TARBALL_ARCH}-linux.tar.gz" "$(basename "$STAGE")" +sha256sum "$OUT/tinline-v${VERSION}-${TARBALL_ARCH}-linux.tar.gz" > "$OUT/tinline-v${VERSION}-${TARBALL_ARCH}-linux.tar.gz.sha256" +echo "$OUT/tinline-v${VERSION}-${TARBALL_ARCH}-linux.tar.gz" diff --git a/scripts/test_r2_upload.py b/scripts/test_r2_upload.py new file mode 100644 index 0000000..4fb2e5c --- /dev/null +++ b/scripts/test_r2_upload.py @@ -0,0 +1,59 @@ +#!/usr/bin/env python3 +"""Offline publication-order tests; never calls Cloudflare.""" +import json +import os +from pathlib import Path +import subprocess +import tempfile +import unittest + +ROOT = Path(__file__).resolve().parent.parent + + +class UploadTests(unittest.TestCase): + def run_upload(self, signed): + with tempfile.TemporaryDirectory() as directory: + root = Path(directory) + repo = root / 'repo' + metadata = repo / 'apt/dists/stable' + metadata.mkdir(parents=True) + if signed: + for name in ('Release', 'Release.gpg', 'InRelease'): + (metadata / name).write_text('test metadata') + (repo / 'tinline.gpg').write_text('test public key') + binary = root / 'bin' + binary.mkdir() + log = root / 'aws.jsonl' + aws = binary / 'aws' + aws.write_text('#!/usr/bin/env python3\nimport json,os,sys\n' + 'with open(os.environ["AWS_TEST_LOG"],"a") as f:\n' + ' f.write(json.dumps(sys.argv[1:])+"\\n")\n') + aws.chmod(0o755) + env = dict(os.environ, PATH=f'{binary}:{os.environ["PATH"]}', + AWS_TEST_LOG=str(log), R2_BUCKET='test-bucket', + R2_ENDPOINT='https://example.invalid', REPO_DIR=str(repo)) + result = subprocess.run(['bash', str(ROOT / 'scripts/upload_repo_r2.sh')], + env=env, capture_output=True, text=True) + calls = [json.loads(line) for line in log.read_text().splitlines()] if log.exists() else [] + return result, calls + + def test_unsigned_repo_never_uploads(self): + result, calls = self.run_upload(False) + self.assertNotEqual(result.returncode, 0) + self.assertEqual(calls, []) + + def test_order_and_retention(self): + result, calls = self.run_upload(True) + self.assertEqual(result.returncode, 0, result.stderr) + self.assertEqual(len(calls), 6) + self.assertTrue(all('--delete' not in call for call in calls)) + self.assertIn('apt/dists/*', calls[0]) + self.assertIn('*/by-hash/*', calls[1]) + for call, name in zip(calls[3:], ('Release', 'Release.gpg', 'InRelease')): + self.assertEqual(call[1], 'cp') + self.assertTrue(call[3].endswith('/' + name)) + self.assertIn('no-cache,max-age=0,must-revalidate', call) + + +if __name__ == '__main__': + unittest.main() diff --git a/scripts/upload_repo_r2.sh b/scripts/upload_repo_r2.sh new file mode 100755 index 0000000..87eb4d2 --- /dev/null +++ b/scripts/upload_repo_r2.sh @@ -0,0 +1,21 @@ +#!/usr/bin/env bash +set -euo pipefail +: "${R2_BUCKET:?set R2_BUCKET}" +: "${R2_ENDPOINT:?set R2_ENDPOINT}" +REPO_DIR="${REPO_DIR:-dist/repo}" +command -v aws >/dev/null || { echo 'missing AWS CLI' >&2; exit 1; } +test -s "$REPO_DIR/apt/dists/stable/InRelease" +test -s "$REPO_DIR/tinline.gpg" +DEST="s3://${R2_BUCKET}" +# Never delete older packages or indices. Upload referenced objects first. +aws s3 sync "$REPO_DIR/" "$DEST/" --endpoint-url "$R2_ENDPOINT" \ + --exclude 'apt/dists/*' --cache-control 'public,max-age=300' +aws s3 sync "$REPO_DIR/apt/dists/" "$DEST/apt/dists/" --endpoint-url "$R2_ENDPOINT" \ + --exclude '*' --include '*/by-hash/*' --cache-control 'public,max-age=31536000,immutable' +aws s3 sync "$REPO_DIR/apt/dists/" "$DEST/apt/dists/" --endpoint-url "$R2_ENDPOINT" \ + --exclude '*/by-hash/*' --exclude '*/Release' --exclude '*/Release.gpg' --exclude '*/InRelease' \ + --cache-control 'no-cache,max-age=0,must-revalidate' +for name in Release Release.gpg InRelease; do + aws s3 cp "$REPO_DIR/apt/dists/stable/$name" "$DEST/apt/dists/stable/$name" \ + --endpoint-url "$R2_ENDPOINT" --cache-control 'no-cache,max-age=0,must-revalidate' +done