diff --git a/.github/workflows/release-checks.yml b/.github/workflows/release-checks.yml
index f149491..cf3910d 100644
--- a/.github/workflows/release-checks.yml
+++ b/.github/workflows/release-checks.yml
@@ -2,10 +2,10 @@ name: Release configuration checks
on:
pull_request:
- paths: ['tools/**', '.github/workflows/**', 'app/pubspec.yaml', 'app/android/**', 'app/linux/**', 'app/windows/**', 'packaging/**', 'packages/**']
+ paths: ['tools/**', '.github/workflows/**', 'app/pubspec.yaml', 'app/android/**', 'app/lib/updates/**', 'app/linux/**', 'app/windows/**', 'packaging/**', 'packages/**']
push:
branches: [development, master]
- paths: ['tools/**', '.github/workflows/**', 'app/pubspec.yaml', 'app/android/**', 'app/linux/**', 'app/windows/**', 'packaging/**', 'packages/**']
+ paths: ['tools/**', '.github/workflows/**', 'app/pubspec.yaml', 'app/android/**', 'app/lib/updates/**', 'app/linux/**', 'app/windows/**', 'packaging/**', 'packages/**']
permissions:
contents: read
@@ -19,3 +19,30 @@ jobs:
with:
python-version: '3.12'
- run: python -m unittest discover -s tools/tests -v
+
+ android-updates:
+ runs-on: ubuntu-latest
+ strategy:
+ matrix:
+ flavor: [play, direct]
+ steps:
+ - uses: actions/checkout@v4
+ - uses: actions/setup-java@v4
+ with:
+ distribution: temurin
+ java-version: '17'
+ cache: gradle
+ - uses: subosito/flutter-action@v2
+ with:
+ flutter-version: '3.41.2'
+ cache: true
+ - run: flutter pub get --enforce-lockfile
+ working-directory: app
+ - run: flutter build apk --debug --flavor ${{ matrix.flavor }} --no-pub
+ working-directory: app
+ - name: Check distribution permissions
+ env:
+ FLAVOR: ${{ matrix.flavor }}
+ run: |
+ manifest=$(find app/build/app/intermediates/merged_manifests -path "*/${FLAVOR}Debug/*/AndroidManifest.xml" -print -quit)
+ python tools/check_android_distribution.py "$manifest" --flavor "$FLAVOR"
diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml
index ad89553..baf5b81 100644
--- a/.github/workflows/release.yml
+++ b/.github/workflows/release.yml
@@ -85,24 +85,30 @@ jobs:
run: flutter pub get --enforce-lockfile
- name: Build signed Google Play bundle
run: |
- flutter build appbundle --release \
+ flutter build appbundle --flavor play --release \
--dart-define=PAPYRUS_API_BASE_URL="$PAPYRUS_API_BASE_URL" \
--dart-define=POWERSYNC_SERVICE_URL="$POWERSYNC_SERVICE_URL"
+ - name: Check Play distribution permissions
+ run: python ../tools/check_android_distribution.py build/app/intermediates/merged_manifests/playRelease/processPlayReleaseManifest/AndroidManifest.xml --flavor play
- name: Check 16 KB native and bundle alignment
run: |
- python ../tools/check_android_bundle.py build/app/outputs/bundle/release/app-release.aab
+ python ../tools/check_android_bundle.py build/app/outputs/bundle/playRelease/app-play-release.aab
curl --fail --location --retry 3 https://github.com/google/bundletool/releases/download/1.18.3/bundletool-all-1.18.3.jar --output "$RUNNER_TEMP/bundletool.jar"
printf 'a099cfa1543f55593bc2ed16a70a7c67fe54b1747bb7301f37fdfd6d91028e29 %s/bundletool.jar\n' "$RUNNER_TEMP" | sha256sum --check
- java -jar "$RUNNER_TEMP/bundletool.jar" dump config --bundle=build/app/outputs/bundle/release/app-release.aab > "$RUNNER_TEMP/bundle-config.json"
+ java -jar "$RUNNER_TEMP/bundletool.jar" dump config --bundle=build/app/outputs/bundle/playRelease/app-play-release.aab > "$RUNNER_TEMP/bundle-config.json"
python -c 'import json, os; from pathlib import Path; config = json.loads((Path(os.environ["RUNNER_TEMP"]) / "bundle-config.json").read_text()); assert config["optimizations"]["uncompressNativeLibraries"]["alignment"] == "PAGE_ALIGNMENT_16K", "Bundle must request 16 KB APK alignment"'
- name: Package signed direct-install APK
run: |
"$ANDROID_HOME/cmdline-tools/latest/bin/sdkmanager" "build-tools;36.0.0"
- python ../tools/package_android_apk.py build/app/outputs/bundle/release/app-release.aab \
+ flutter build appbundle --flavor direct --release \
+ --dart-define=PAPYRUS_API_BASE_URL="$PAPYRUS_API_BASE_URL" \
+ --dart-define=POWERSYNC_SERVICE_URL="$POWERSYNC_SERVICE_URL"
+ python ../tools/check_android_distribution.py build/app/intermediates/merged_manifests/directRelease/processDirectReleaseManifest/AndroidManifest.xml --flavor direct
+ python ../tools/package_android_apk.py build/app/outputs/bundle/directRelease/app-direct-release.aab \
--bundletool "$RUNNER_TEMP/bundletool.jar" \
--build-tools "$ANDROID_HOME/build-tools/36.0.0" --output . --tag "$RELEASE_TAG"
- name: Name Android bundle download
- run: cp build/app/outputs/bundle/release/app-release.aab "papyrus-${RELEASE_TAG}-android-universal.aab"
+ run: cp build/app/outputs/bundle/playRelease/app-play-release.aab "papyrus-${RELEASE_TAG}-android-universal.aab"
- uses: actions/upload-artifact@v4
with:
name: android-release
@@ -147,6 +153,7 @@ jobs:
- name: Build web
run: |
flutter build web --release \
+ --dart-define=PAPYRUS_BUILD_NUMBER=${{ needs.version.outputs.build_number }} \
--dart-define=PAPYRUS_API_BASE_URL="${PAPYRUS_API_BASE_URL}" \
--dart-define=POWERSYNC_SERVICE_URL="${POWERSYNC_SERVICE_URL}" \
--build-name=${{ needs.version.outputs.version }} \
@@ -274,6 +281,7 @@ jobs:
if-no-files-found: error
release:
+ environment: release
needs: [version, build-android, build-web, build-linux, build-windows]
if: needs.version.outputs.release == 'true'
runs-on: ubuntu-24.04
@@ -291,6 +299,12 @@ jobs:
pattern: '*-release'
merge-multiple: true
path: artifacts
+ - name: Build signed APT repository
+ if: vars.APT_UPDATES_ENABLED == 'true'
+ env:
+ APT_SIGNING_KEY: ${{ secrets.APT_SIGNING_KEY }}
+ APT_SIGNING_FINGERPRINT: ${{ vars.APT_SIGNING_FINGERPRINT }}
+ run: python3 tools/apt_repository.py artifacts --tag "$RELEASE_TAG"
- name: Verify and seal release
run: python3 tools/release_assets.py seal artifacts --tag "$RELEASE_TAG"
- name: Publish immutable downloads
diff --git a/app/android/app/build.gradle.kts b/app/android/app/build.gradle.kts
index c111880..5561749 100644
--- a/app/android/app/build.gradle.kts
+++ b/app/android/app/build.gradle.kts
@@ -48,6 +48,13 @@ android {
versionName = flutter.versionName
}
+ buildFeatures { buildConfig = true }
+ flavorDimensions += "distribution"
+ productFlavors {
+ create("play") { dimension = "distribution" }
+ create("direct") { dimension = "distribution" }
+ }
+
signingConfigs {
if (hasUploadKey) {
create("upload") {
@@ -71,5 +78,7 @@ flutter {
}
dependencies {
+ implementation("com.google.android.play:app-update:2.1.0")
+ implementation("androidx.core:core-ktx:1.15.0")
implementation("org.slf4j:slf4j-nop:1.7.36")
}
diff --git a/app/android/app/src/direct/AndroidManifest.xml b/app/android/app/src/direct/AndroidManifest.xml
new file mode 100644
index 0000000..97a54aa
--- /dev/null
+++ b/app/android/app/src/direct/AndroidManifest.xml
@@ -0,0 +1,12 @@
+
+
+
+
+
+
+
+
diff --git a/app/android/app/src/direct/res/xml/update_paths.xml b/app/android/app/src/direct/res/xml/update_paths.xml
new file mode 100644
index 0000000..5e8a648
--- /dev/null
+++ b/app/android/app/src/direct/res/xml/update_paths.xml
@@ -0,0 +1,3 @@
+
+
+
diff --git a/app/android/app/src/main/kotlin/com/papyrus/reader/AppUpdates.kt b/app/android/app/src/main/kotlin/com/papyrus/reader/AppUpdates.kt
new file mode 100644
index 0000000..bf6ab9d
--- /dev/null
+++ b/app/android/app/src/main/kotlin/com/papyrus/reader/AppUpdates.kt
@@ -0,0 +1,109 @@
+package com.papyrus.reader
+
+import android.app.Activity
+import android.content.Intent
+import android.content.pm.PackageManager
+import android.net.Uri
+import android.os.Build
+import android.provider.Settings
+import androidx.core.content.FileProvider
+import com.google.android.play.core.appupdate.AppUpdateManagerFactory
+import com.google.android.play.core.appupdate.AppUpdateOptions
+import com.google.android.play.core.install.model.AppUpdateType
+import com.google.android.play.core.install.model.UpdateAvailability
+import io.flutter.embedding.engine.FlutterEngine
+import io.flutter.plugin.common.MethodChannel
+import java.io.File
+
+class AppUpdates(private val activity: Activity) {
+ private val packageManager get() = activity.packageManager
+ private val packageName get() = activity.packageName
+ private val cacheDir get() = activity.cacheDir
+ private val preferences by lazy { activity.getPreferences(Activity.MODE_PRIVATE) }
+ private val updates by lazy { AppUpdateManagerFactory.create(activity) }
+ private val requestCode = 7412
+
+ fun configure(flutterEngine: FlutterEngine) {
+ MethodChannel(flutterEngine.dartExecutor.binaryMessenger, "com.papyrus.reader/updates")
+ .setMethodCallHandler { call, result ->
+ try {
+ when (call.method) {
+ "channel" -> result.success(BuildConfig.FLAVOR)
+ "check" -> updates.appUpdateInfo
+ .addOnSuccessListener { info ->
+ val available = info.updateAvailability() == UpdateAvailability.UPDATE_AVAILABLE &&
+ info.isUpdateTypeAllowed(AppUpdateType.IMMEDIATE)
+ result.success(if (available) info.availableVersionCode() else null)
+ }
+ .addOnFailureListener { result.error("play", "Google Play could not check for updates", null) }
+ "installPlay" -> updates.appUpdateInfo
+ .addOnSuccessListener { info ->
+ try {
+ val started = updates.startUpdateFlowForResult(
+ info, activity, AppUpdateOptions.newBuilder(AppUpdateType.IMMEDIATE).build(), requestCode
+ )
+ if (started) {
+ preferences.edit().putBoolean("playUpdateStarted", true).apply()
+ result.success(null)
+ }
+ else result.error("play", "Google Play did not start the update", null)
+ } catch (error: Exception) {
+ result.error("play", "Could not start the Play update", null)
+ }
+ }
+ .addOnFailureListener { result.error("play", "Google Play update is unavailable", null) }
+ "canInstall" -> {
+ check(BuildConfig.FLAVOR == "direct")
+ val allowed = Build.VERSION.SDK_INT < 26 || packageManager.canRequestPackageInstalls()
+ if (!allowed) {
+ activity.startActivity(Intent(Settings.ACTION_MANAGE_UNKNOWN_APP_SOURCES, Uri.parse("package:$packageName")))
+ }
+ result.success(allowed)
+ }
+ "installApk" -> {
+ installApk(requireNotNull(call.argument("path")))
+ result.success(null)
+ }
+ else -> result.notImplemented()
+ }
+ } catch (error: Exception) {
+ result.error("update", "Could not prepare the update", null)
+ }
+ }
+ }
+
+ @Suppress("DEPRECATION")
+ private fun installApk(filename: String) {
+ check(BuildConfig.FLAVOR == "direct")
+ val file = File(filename).canonicalFile
+ val directory = File(cacheDir, "papyrus-updates").canonicalFile
+ require(file.path.startsWith(directory.path + File.separator) && file.isFile)
+ val flags = PackageManager.GET_SIGNATURES
+ val candidate = requireNotNull(packageManager.getPackageArchiveInfo(file.path, flags))
+ val installed = packageManager.getPackageInfo(packageName, flags)
+ require(candidate.packageName == packageName && candidate.versionCode > installed.versionCode)
+ require(candidate.signatures?.toSet() == installed.signatures?.toSet() && !candidate.signatures.isNullOrEmpty())
+ val uri = FileProvider.getUriForFile(activity, "$packageName.updates", file)
+ activity.startActivity(Intent(Intent.ACTION_VIEW).apply {
+ setDataAndType(uri, "application/vnd.android.package-archive")
+ addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION)
+ })
+ }
+
+ fun resumeUpdate() {
+ if (BuildConfig.FLAVOR != "play" || !preferences.getBoolean("playUpdateStarted", false)) return
+ updates.appUpdateInfo.addOnSuccessListener { info ->
+ if (info.updateAvailability() == UpdateAvailability.DEVELOPER_TRIGGERED_UPDATE_IN_PROGRESS) {
+ try {
+ updates.startUpdateFlowForResult(
+ info, activity, AppUpdateOptions.newBuilder(AppUpdateType.IMMEDIATE).build(), requestCode
+ )
+ } catch (_: Exception) {
+ // A failed or cancelled update must leave the library usable.
+ }
+ } else {
+ preferences.edit().remove("playUpdateStarted").apply()
+ }
+ }
+ }
+}
diff --git a/app/android/app/src/main/kotlin/com/papyrus/reader/MainActivity.kt b/app/android/app/src/main/kotlin/com/papyrus/reader/MainActivity.kt
index c4a0299..ea0d40b 100644
--- a/app/android/app/src/main/kotlin/com/papyrus/reader/MainActivity.kt
+++ b/app/android/app/src/main/kotlin/com/papyrus/reader/MainActivity.kt
@@ -15,6 +15,7 @@ import java.util.UUID
import java.util.concurrent.Executors
class MainActivity : FlutterActivity() {
+ private val appUpdates by lazy { AppUpdates(this) }
private val folderRequest = 7142
private var folderResult: MethodChannel.Result? = null
private val worker = Executors.newSingleThreadExecutor()
@@ -24,6 +25,7 @@ class MainActivity : FlutterActivity() {
override fun configureFlutterEngine(flutterEngine: FlutterEngine) {
super.configureFlutterEngine(flutterEngine)
+ appUpdates.configure(flutterEngine)
MethodChannel(flutterEngine.dartExecutor.binaryMessenger, "papyrus/folders").setMethodCallHandler { call, result ->
if (call.method == "pick") {
if (folderResult != null) {
@@ -124,6 +126,11 @@ class MainActivity : FlutterActivity() {
}
}
+ override fun onResume() {
+ super.onResume()
+ appUpdates.resumeUpdate()
+ }
+
override fun onDestroy() {
worker.execute {
cursors.values.forEach { it.close() }
diff --git a/app/assets/updates/install_windows.ps1 b/app/assets/updates/install_windows.ps1
new file mode 100644
index 0000000..2efd0b1
--- /dev/null
+++ b/app/assets/updates/install_windows.ps1
@@ -0,0 +1,24 @@
+param(
+ [Parameter(Mandatory)][int]$ParentId,
+ [Parameter(Mandatory)][string]$Installer,
+ [Parameter(Mandatory)][string]$Checksum,
+ [Parameter(Mandatory)][string]$InstallDirectory
+)
+$ErrorActionPreference = 'Stop'
+$Log = Join-Path (Split-Path $Installer) 'update.log'
+try {
+ # Wait for the user's normal shutdown, preserving pending library writes.
+ $Parent = Get-Process -Id $ParentId -ErrorAction SilentlyContinue
+ if ($Parent -and -not $Parent.WaitForExit(86400000)) { throw 'Update expired while Papyrus was still running.' }
+ if ((Get-FileHash -LiteralPath $Installer -Algorithm SHA256).Hash.ToLowerInvariant() -ne $Checksum) {
+ throw 'Update checksum changed after download.'
+ }
+ $Arguments = @('/SP-', '/SILENT', '/NORESTART', '/NOCLOSEAPPLICATIONS', '/SUPPRESSMSGBOXES', "/DIR=`"$InstallDirectory`"", "/LOG=`"$Log`"")
+ $Setup = Start-Process -FilePath $Installer -ArgumentList $Arguments -PassThru -Wait
+ if ($Setup.ExitCode -ne 0) { throw "Installer failed with exit code $($Setup.ExitCode). See $Log" }
+ Start-Process -FilePath (Join-Path $InstallDirectory 'papyrus.exe')
+ Remove-Item -LiteralPath $Installer
+} catch {
+ $_ | Out-String | Add-Content -LiteralPath $Log
+ # Keep the verified installer and diagnostics for a manual retry.
+}
diff --git a/app/lib/main.dart b/app/lib/main.dart
index c5767b2..16efa8f 100644
--- a/app/lib/main.dart
+++ b/app/lib/main.dart
@@ -1,6 +1,8 @@
import 'package:papyrus/folders/directory_access_factory.dart';
import 'package:papyrus/folders/folder_import_target.dart';
import 'package:papyrus/folders/watched_folders.dart';
+import 'package:papyrus/updates/update_controller.dart';
+import 'package:papyrus/updates/update_settings.dart';
import 'package:papyrus/services/reading_device_identity.dart';
import 'dart:async';
import 'dart:convert';
@@ -214,6 +216,7 @@ class _PapyrusState extends State {
late final AcquisitionAvailabilityProvider _acquisitionAvailabilityProvider;
late final AcquisitionDownloadsComposition _acquisitionDownloadsComposition;
late final PreferencesProvider _preferencesProvider;
+ late final UpdateController _updateController;
late final SyncSettingsProvider _syncSettingsProvider;
late final MediaUploadQueue _mediaUploadQueue;
late final BookImportService _bookImportService;
@@ -253,6 +256,7 @@ class _PapyrusState extends State {
_dataStore = DataStore();
_preferencesProvider = PreferencesProvider(widget.prefs);
+ _updateController = UpdateController(preferences: widget.prefs)..start();
_mediaUploadQueue = widget._uploads ?? MediaUploadQueue(widget.prefs, onWorkAvailable: _processMediaUploads);
_bookImportService = widget._importer ?? BookImportService();
_authProvider = AuthProvider(widget.prefs, repository: _authRepository);
@@ -363,6 +367,7 @@ class _PapyrusState extends State {
_authProvider.dispose();
_syncSettingsProvider.dispose();
_preferencesProvider.dispose();
+ _updateController.dispose();
super.dispose();
}
@@ -647,6 +652,7 @@ class _PapyrusState extends State {
create: (_) => LibraryProvider(dataStore: _dataStore, preferences: _preferencesProvider),
),
ChangeNotifierProvider.value(value: _preferencesProvider),
+ ChangeNotifierProvider.value(value: _updateController),
],
child:
widget._child ??
@@ -680,7 +686,10 @@ class _PapyrusState extends State {
isEink || preferencesProvider.reduceAnimations || MediaQuery.disableAnimationsOf(context)
? Duration.zero
: kThemeAnimationDuration,
- builder: (context, child) => AppAccessibilityScope(preferences: preferencesProvider, child: child!),
+ builder: (context, child) => AppAccessibilityScope(
+ preferences: preferencesProvider,
+ child: UpdateNotice(controller: _updateController, router: _appRouter.router, child: child!),
+ ),
routerConfig: _appRouter.router,
);
},
diff --git a/app/lib/updates/update_appimage.dart b/app/lib/updates/update_appimage.dart
new file mode 100644
index 0000000..7215e53
--- /dev/null
+++ b/app/lib/updates/update_appimage.dart
@@ -0,0 +1,25 @@
+import 'dart:io';
+
+import 'package:path/path.dart' as path;
+
+Future replaceAppImage(File target, File verifiedDownload) async {
+ if (await FileSystemEntity.isLink(target.path) || !await target.exists()) {
+ throw const FileSystemException('Update the original AppImage, not a symbolic link');
+ }
+
+ // Stage on the same filesystem so rename cannot leave a partial executable.
+ final staging = await Directory(path.dirname(target.path)).createTemp('.papyrus-update-');
+
+ try {
+ final replacement = await verifiedDownload.copy(path.join(staging.path, 'Papyrus.AppImage'));
+ final result = await Process.run('/bin/chmod', ['755', replacement.path]);
+
+ if (result.exitCode != 0) {
+ throw const FileSystemException('Cannot make the new AppImage executable');
+ }
+
+ await replacement.rename(target.path);
+ } finally {
+ await staging.delete(recursive: true);
+ }
+}
diff --git a/app/lib/updates/update_controller.dart b/app/lib/updates/update_controller.dart
new file mode 100644
index 0000000..6868111
--- /dev/null
+++ b/app/lib/updates/update_controller.dart
@@ -0,0 +1,205 @@
+import 'dart:async';
+import 'dart:convert';
+
+import 'package:flutter/foundation.dart';
+import 'package:http/http.dart' as http;
+import 'package:package_info_plus/package_info_plus.dart';
+import 'package:papyrus/services/app_info.dart';
+import 'package:papyrus/updates/update_platform.dart' as platform;
+import 'package:papyrus/updates/update_release.dart';
+import 'package:shared_preferences/shared_preferences.dart';
+
+class UpdateController extends ChangeNotifier {
+ static const checkFailureMessage = 'Could not check for updates. Try again.';
+
+ final SharedPreferences preferences;
+ final Future Function() appInfo;
+ final Future Function() channelProvider;
+ final Future Function() playCheck;
+ final Future