Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
31 changes: 29 additions & 2 deletions .github/workflows/release-checks.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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"
24 changes: 19 additions & 5 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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 }} \
Expand Down Expand Up @@ -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
Expand All @@ -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
Expand Down
9 changes: 9 additions & 0 deletions app/android/app/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -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") {
Expand All @@ -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")
}
12 changes: 12 additions & 0 deletions app/android/app/src/direct/AndroidManifest.xml
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<uses-permission android:name="android.permission.REQUEST_INSTALL_PACKAGES" />
<application>
<provider
android:name="androidx.core.content.FileProvider"
android:authorities="${applicationId}.updates"
android:exported="false"
android:grantUriPermissions="true">
<meta-data android:name="android.support.FILE_PROVIDER_PATHS" android:resource="@xml/update_paths" />
</provider>
</application>
</manifest>
3 changes: 3 additions & 0 deletions app/android/app/src/direct/res/xml/update_paths.xml
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
<paths xmlns:android="http://schemas.android.com/apk/res/android">
<cache-path name="updates" path="papyrus-updates/" />
</paths>
109 changes: 109 additions & 0 deletions app/android/app/src/main/kotlin/com/papyrus/reader/AppUpdates.kt
Original file line number Diff line number Diff line change
@@ -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<String>("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()
}
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand All @@ -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) {
Expand Down Expand Up @@ -124,6 +126,11 @@ class MainActivity : FlutterActivity() {
}
}

override fun onResume() {
super.onResume()
appUpdates.resumeUpdate()
}

override fun onDestroy() {
worker.execute {
cursors.values.forEach { it.close() }
Expand Down
24 changes: 24 additions & 0 deletions app/assets/updates/install_windows.ps1
Original file line number Diff line number Diff line change
@@ -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.
}
11 changes: 10 additions & 1 deletion app/lib/main.dart
Original file line number Diff line number Diff line change
@@ -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';
Expand Down Expand Up @@ -214,6 +216,7 @@ class _PapyrusState extends State<Papyrus> {
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;
Expand Down Expand Up @@ -253,6 +256,7 @@ class _PapyrusState extends State<Papyrus> {

_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);
Expand Down Expand Up @@ -363,6 +367,7 @@ class _PapyrusState extends State<Papyrus> {
_authProvider.dispose();
_syncSettingsProvider.dispose();
_preferencesProvider.dispose();
_updateController.dispose();
super.dispose();
}

Expand Down Expand Up @@ -647,6 +652,7 @@ class _PapyrusState extends State<Papyrus> {
create: (_) => LibraryProvider(dataStore: _dataStore, preferences: _preferencesProvider),
),
ChangeNotifierProvider.value(value: _preferencesProvider),
ChangeNotifierProvider.value(value: _updateController),
],
child:
widget._child ??
Expand Down Expand Up @@ -680,7 +686,10 @@ class _PapyrusState extends State<Papyrus> {
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,
);
},
Expand Down
25 changes: 25 additions & 0 deletions app/lib/updates/update_appimage.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
import 'dart:io';

import 'package:path/path.dart' as path;

Future<void> 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);
}
}
Loading
Loading