diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml new file mode 100644 index 000000000..3b0746918 --- /dev/null +++ b/.github/workflows/main.yml @@ -0,0 +1,62 @@ +name: Release an APK and an App Bundle on tagging + +on: + push: + tags: + - v* + +jobs: + build: + name: Build and release artifacts. + runs-on: ubuntu-latest + steps: + - uses: actions/setup-java@v1 + with: + java-version: '11.x' + + - uses: subosito/flutter-action@v1 + with: + channel: 'stable' + + # Workaround for this Android Gradle Plugin issue (supposedly fixed in AGP 4.1): + # https://issuetracker.google.com/issues/144111441 + - name: Install NDK + run: echo "y" | sudo /usr/local/lib/android/sdk/tools/bin/sdkmanager --install "ndk;20.0.5594570" --sdk_root=${ANDROID_SDK_ROOT} + + - name: Clone the repository. + uses: actions/checkout@v2 + + - name: Get packages for the Flutter project. + run: flutter pub get + + - name: Update the flutter version file. + working-directory: ${{ github.workspace }}/scripts + run: ./update_flutter_version.sh + + # `flutter test` fails if test directory is missing + #- name: Run the unit tests. + # run: flutter test + + - name: Build signed artifacts. + # `KEY_JKS` should contain the result of: + # gpg -c --armor keystore.jks + # `KEY_JKS_PASSPHRASE` should contain the passphrase used for the command above + run: | + echo "${{ secrets.KEY_JKS }}" > release.keystore.asc + gpg -d --passphrase "${{ secrets.KEY_JKS_PASSPHRASE }}" --batch release.keystore.asc > $AVES_STORE_FILE + rm release.keystore.asc + flutter build apk + flutter build appbundle + rm $AVES_STORE_FILE + env: + AVES_STORE_FILE: ${{ github.workspace }}/key.jks + AVES_STORE_PASSWORD: ${{ secrets.AVES_STORE_PASSWORD }} + AVES_KEY_ALIAS: ${{ secrets.AVES_KEY_ALIAS }} + AVES_KEY_PASSWORD: ${{ secrets.AVES_KEY_PASSWORD }} + AVES_GOOGLE_API_KEY: ${{ secrets.AVES_GOOGLE_API_KEY }} + + - name: Create a release with the APK and App Bundle. + uses: ncipollo/release-action@v1 + with: + artifacts: "build/app/outputs/apk/release/*.apk,build/app/outputs/bundle/release/*.aab" + token: ${{ secrets.RELEASE_WORKFLOW_TOKEN }} diff --git a/android/app/build.gradle b/android/app/build.gradle index 043a73751..c53db25ad 100644 --- a/android/app/build.gradle +++ b/android/app/build.gradle @@ -27,7 +27,17 @@ apply from: "$flutterRoot/packages/flutter_tools/gradle/flutter.gradle" def keystoreProperties = new Properties() def keystorePropertiesFile = rootProject.file('key.properties') if (keystorePropertiesFile.exists()) { + // for release using credentials stored in a local file keystoreProperties.load(new FileInputStream(keystorePropertiesFile)) +} else { + // for release using credentials in environment variables set up by Github Actions + // warning: in property file, single quotes should be escaped with a backslash + // but they should not be escaped when stored in env variables + keystoreProperties['storeFile'] = System.getenv('AVES_STORE_FILE') + keystoreProperties['storePassword'] = System.getenv('AVES_STORE_PASSWORD') + keystoreProperties['keyAlias'] = System.getenv('AVES_KEY_ALIAS') + keystoreProperties['keyPassword'] = System.getenv('AVES_KEY_PASSWORD') + keystoreProperties['googleApiKey'] = System.getenv('AVES_GOOGLE_API_KEY') } android { diff --git a/android/app/src/main/java/deckers/thibault/aves/model/provider/ImageProvider.java b/android/app/src/main/java/deckers/thibault/aves/model/provider/ImageProvider.java index 946bc7081..d05ad556a 100644 --- a/android/app/src/main/java/deckers/thibault/aves/model/provider/ImageProvider.java +++ b/android/app/src/main/java/deckers/thibault/aves/model/provider/ImageProvider.java @@ -1,9 +1,7 @@ package deckers.thibault.aves.model.provider; import android.app.Activity; -import android.content.ContentResolver; import android.content.ContentUris; -import android.content.ContentValues; import android.content.Context; import android.database.Cursor; import android.graphics.Bitmap; @@ -11,7 +9,6 @@ import android.graphics.BitmapFactory; import android.graphics.Matrix; import android.media.MediaScannerConnection; import android.net.Uri; -import android.os.Build; import android.os.Handler; import android.os.Looper; import android.provider.MediaStore; diff --git a/android/app/src/main/java/deckers/thibault/aves/utils/PermissionManager.java b/android/app/src/main/java/deckers/thibault/aves/utils/PermissionManager.java index 9463e9f24..5c29c5a9f 100644 --- a/android/app/src/main/java/deckers/thibault/aves/utils/PermissionManager.java +++ b/android/app/src/main/java/deckers/thibault/aves/utils/PermissionManager.java @@ -1,7 +1,6 @@ package deckers.thibault.aves.utils; import android.app.Activity; -import android.content.Context; import android.content.Intent; import android.content.UriPermission; import android.net.Uri; @@ -20,7 +19,6 @@ import androidx.core.app.ActivityCompat; import java.io.File; import java.util.Optional; import java.util.concurrent.ConcurrentHashMap; -import java.util.stream.Stream; public class PermissionManager { private static final String LOG_TAG = Utils.createLogTag(PermissionManager.class); diff --git a/android/app/src/main/java/deckers/thibault/aves/utils/StorageUtils.java b/android/app/src/main/java/deckers/thibault/aves/utils/StorageUtils.java index 46f5488f5..142d5a5cf 100644 --- a/android/app/src/main/java/deckers/thibault/aves/utils/StorageUtils.java +++ b/android/app/src/main/java/deckers/thibault/aves/utils/StorageUtils.java @@ -45,7 +45,10 @@ public class StorageUtils { * Volume paths */ + // volume paths, with trailing "/" private static String[] mStorageVolumePaths; + + // primary volume path, with trailing "/" private static String mPrimaryVolumePath; private static String getPrimaryVolumePath() { @@ -90,8 +93,8 @@ public class StorageUtils { private static String findPrimaryVolumePath() { String primaryVolumePath = Environment.getExternalStorageDirectory().getAbsolutePath(); - if (!primaryVolumePath.endsWith("/")) { - primaryVolumePath += "/"; + if (!primaryVolumePath.endsWith(File.separator)) { + primaryVolumePath += File.separator; } return primaryVolumePath; } @@ -167,8 +170,8 @@ public class StorageUtils { String[] paths = rv.toArray(new String[0]); for (int i = 0; i < paths.length; i++) { String path = paths[i]; - if (path.endsWith(File.separator)) { - paths[i] = path.substring(0, path.length() - 1); + if (!path.endsWith(File.separator)) { + paths[i] = path + File.separator; } } return paths; @@ -361,8 +364,11 @@ public class StorageUtils { */ public static boolean requireAccessPermission(@NonNull String anyPath) { + // on Android R, we should always require access permission, even on primary volume + if (Build.VERSION.SDK_INT > Build.VERSION_CODES.Q) { + return true; + } boolean onPrimaryVolume = anyPath.startsWith(getPrimaryVolumePath()); - // TODO TLAD on Android R, we should require access permission even on primary return !onPrimaryVolume; } diff --git a/lib/flutter_version.dart b/lib/flutter_version.dart new file mode 100644 index 000000000..54ce29eac --- /dev/null +++ b/lib/flutter_version.dart @@ -0,0 +1,10 @@ +// run `scripts/update_flutter_version.sh` to update with the content of `flutter --version --machine` +const Map version = { + 'channel': 'unknown', + 'dartSdkVersion': 'unknown', + 'engineRevision': 'unknown', + 'frameworkCommitDate': 'unknown', + 'frameworkRevision': 'unknown', + 'frameworkVersion': 'unknown', + 'repositoryUrl': 'unknown', +}; diff --git a/lib/main.dart b/lib/main.dart index 2090e5063..8d8f4722c 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -62,7 +62,7 @@ class _AvesAppState extends State { future: _appSetup, builder: (context, AsyncSnapshot snapshot) { if (snapshot.hasError) return const Icon(AIcons.error); - if (snapshot.connectionState != ConnectionState.done) return const SizedBox.shrink(); + if (snapshot.connectionState != ConnectionState.done) return const Scaffold(); return settings.hasAcceptedTerms ? const HomePage() : const WelcomePage(); }, ), diff --git a/lib/services/android_app_service.dart b/lib/services/android_app_service.dart index 886149d02..ab4672036 100644 --- a/lib/services/android_app_service.dart +++ b/lib/services/android_app_service.dart @@ -1,5 +1,7 @@ import 'dart:typed_data'; +import 'package:aves/model/image_entry.dart'; +import 'package:collection/collection.dart'; import 'package:flutter/foundation.dart'; import 'package:flutter/services.dart'; @@ -86,7 +88,10 @@ class AndroidAppService { } } - static Future share(Map> urisByMimeType) async { + static Future share(Set entries) async { + // loosen mime type to a generic one, so we can share with badly defined apps + // e.g. Google Lens declares receiving "image/jpeg" only, but it can actually handle more formats + final urisByMimeType = groupBy(entries, (e) => e.mimeTypeAnySubtype).map((k, v) => MapEntry(k, v.map((e) => e.uri).toList())); try { await platform.invokeMethod('share', { 'title': 'Share via:', diff --git a/lib/widgets/about/about_page.dart b/lib/widgets/about/about_page.dart index 6777a7257..a0708ee8b 100644 --- a/lib/widgets/about/about_page.dart +++ b/lib/widgets/about/about_page.dart @@ -1,7 +1,10 @@ +import 'package:aves/flutter_version.dart'; import 'package:aves/widgets/about/licenses.dart'; +import 'package:aves/widgets/common/aves_logo.dart'; import 'package:aves/widgets/common/link_chip.dart'; import 'package:flutter/material.dart'; import 'package:flutter_staggered_animations/flutter_staggered_animations.dart'; +import 'package:package_info/package_info.dart'; class AboutPage extends StatelessWidget { @override @@ -19,31 +22,8 @@ class AboutPage extends StatelessWidget { sliver: SliverList( delegate: SliverChildListDelegate( [ - Center( - child: Column( - children: [ - Text.rich( - TextSpan( - children: [ - const TextSpan(text: 'Made with ❤️ and '), - WidgetSpan( - child: FlutterLogo( - size: Theme.of(context).textTheme.bodyText2.fontSize * 1.25, - ), - ), - ], - ), - ), - const SizedBox(height: 8), - const LinkChip( - text: 'Sources', - url: 'https://github.com/deckerst/aves', - textStyle: TextStyle(fontWeight: FontWeight.bold), - ), - ], - ), - ), - const SizedBox(height: 8), + AppReference(), + const SizedBox(height: 16), const Divider(), ], ), @@ -57,3 +37,71 @@ class AboutPage extends StatelessWidget { ); } } + +class AppReference extends StatefulWidget { + @override + _AppReferenceState createState() => _AppReferenceState(); +} + +class _AppReferenceState extends State { + Future packageInfoLoader; + + @override + void initState() { + super.initState(); + packageInfoLoader = PackageInfo.fromPlatform(); + } + + @override + Widget build(BuildContext context) { + return Center( + child: Column( + children: [ + _buildAvesLine(), + _buildFlutterLine(), + ], + ), + ); + } + + Widget _buildAvesLine() { + final textTheme = Theme.of(context).textTheme; + final style = textTheme.headline6.copyWith(fontWeight: FontWeight.bold); + + return FutureBuilder( + future: packageInfoLoader, + builder: (context, snapshot) { + return LinkChip( + leading: AvesLogo( + size: style.fontSize * 1.25, + ), + text: 'Aves ${snapshot.data?.version}', + url: 'https://github.com/deckerst/aves', + textStyle: style, + ); + }, + ); + } + + Widget _buildFlutterLine() { + final style = DefaultTextStyle.of(context).style; + final subColor = style.color.withOpacity(.6); + + return Text.rich( + TextSpan( + children: [ + WidgetSpan( + child: Padding( + padding: const EdgeInsetsDirectional.only(end: 4), + child: FlutterLogo( + size: style.fontSize * 1.25, + ), + ), + ), + TextSpan(text: 'Flutter ${version['frameworkVersion']}'), + ], + ), + style: TextStyle(color: subColor), + ); + } +} diff --git a/lib/widgets/common/action_delegates/entry_action_delegate.dart b/lib/widgets/common/action_delegates/entry_action_delegate.dart index d64d061f9..56fc62b2a 100644 --- a/lib/widgets/common/action_delegates/entry_action_delegate.dart +++ b/lib/widgets/common/action_delegates/entry_action_delegate.dart @@ -64,9 +64,7 @@ class EntryActionDelegate with FeedbackMixin, PermissionAwareMixin { AndroidAppService.setAs(entry.uri, entry.mimeType); break; case EntryAction.share: - AndroidAppService.share({ - entry.mimeType: [entry.uri] - }); + AndroidAppService.share({entry}); break; case EntryAction.debug: _goToDebug(context, entry); diff --git a/lib/widgets/common/action_delegates/selection_action_delegate.dart b/lib/widgets/common/action_delegates/selection_action_delegate.dart index f80e3af18..c2ecf39fd 100644 --- a/lib/widgets/common/action_delegates/selection_action_delegate.dart +++ b/lib/widgets/common/action_delegates/selection_action_delegate.dart @@ -17,7 +17,6 @@ import 'package:aves/widgets/common/action_delegates/permission_aware.dart'; import 'package:aves/widgets/common/entry_actions.dart'; import 'package:aves/widgets/common/icons.dart'; import 'package:aves/widgets/filter_grid_page.dart'; -import 'package:collection/collection.dart'; import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; import 'package:flutter/scheduler.dart'; @@ -38,7 +37,7 @@ class SelectionActionDelegate with FeedbackMixin, PermissionAwareMixin { _showDeleteDialog(context); break; case EntryAction.share: - _share(); + AndroidAppService.share(collection.selection); break; default: break; @@ -229,11 +228,6 @@ class SelectionActionDelegate with FeedbackMixin, PermissionAwareMixin { ); } - void _share() { - final urisByMimeType = groupBy(collection.selection, (e) => e.mimeType).map((k, v) => MapEntry(k, v.map((e) => e.uri).toList())); - AndroidAppService.share(urisByMimeType); - } - // selection action report overlay OverlayEntry _opReportOverlayEntry; diff --git a/lib/widgets/common/link_chip.dart b/lib/widgets/common/link_chip.dart index 5a50dee38..bddceacdf 100644 --- a/lib/widgets/common/link_chip.dart +++ b/lib/widgets/common/link_chip.dart @@ -3,6 +3,7 @@ import 'package:flutter/material.dart'; import 'package:url_launcher/url_launcher.dart'; class LinkChip extends StatelessWidget { + final Widget leading; final String text; final String url; final Color color; @@ -12,6 +13,7 @@ class LinkChip extends StatelessWidget { const LinkChip({ Key key, + this.leading, @required this.text, @required this.url, this.color, @@ -20,32 +22,35 @@ class LinkChip extends StatelessWidget { @override Widget build(BuildContext context) { - final effectiveTextStyle = (textStyle ?? DefaultTextStyle.of(context).style).copyWith( - color: color, - ); - return InkWell( - borderRadius: borderRadius, - onTap: () async { - if (await canLaunch(url)) { - await launch(url); - } - }, - child: Padding( - padding: const EdgeInsets.all(8.0), - child: Row( - mainAxisSize: MainAxisSize.min, - children: [ - Text( - text, - style: effectiveTextStyle, - ), - const SizedBox(width: 8), - Icon( - AIcons.openInNew, - size: Theme.of(context).textTheme.bodyText2.fontSize, - color: color, - ) - ], + return DefaultTextStyle.merge( + style: (textStyle ?? const TextStyle()).copyWith(color: color), + child: InkWell( + borderRadius: borderRadius, + onTap: () async { + if (await canLaunch(url)) { + await launch(url); + } + }, + child: Padding( + padding: const EdgeInsets.all(8.0), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + if (leading != null) ...[ + leading, + const SizedBox(width: 8), + ], + Text(text), + const SizedBox(width: 8), + Builder( + builder: (context) => Icon( + AIcons.openInNew, + size: DefaultTextStyle.of(context).style.fontSize, + color: color, + ), + ), + ], + ), ), ), ); diff --git a/lib/widgets/home_page.dart b/lib/widgets/home_page.dart index 083423ffd..c57e6dbf6 100644 --- a/lib/widgets/home_page.dart +++ b/lib/widgets/home_page.dart @@ -96,7 +96,7 @@ class _HomePageState extends State { future: _appSetup, builder: (context, AsyncSnapshot snapshot) { if (snapshot.hasError) return const Icon(AIcons.error); - if (snapshot.connectionState != ConnectionState.done) return const SizedBox.shrink(); + if (snapshot.connectionState != ConnectionState.done) return const Scaffold(); if (AvesApp.mode == AppMode.view) { return SingleFullscreenPage(entry: _viewerEntry); } diff --git a/pubspec.lock b/pubspec.lock index 813cb8ea0..4df3672d0 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -260,6 +260,13 @@ packages: url: "https://pub.dartlang.org" source: hosted version: "0.1.1" + package_info: + dependency: "direct main" + description: + name: package_info + url: "https://pub.dartlang.org" + source: hosted + version: "0.4.1" palette_generator: dependency: "direct main" description: diff --git a/pubspec.yaml b/pubspec.yaml index 9a772708a..78efde26d 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -11,7 +11,7 @@ description: A new Flutter application. # In iOS, build-name is used as CFBundleShortVersionString while build-number used as CFBundleVersion. # Read more about iOS versioning at # https://developer.apple.com/library/archive/documentation/General/Reference/InfoPlistKeyReference/Articles/CoreFoundationKeys.html -version: 1.0.8+9 +version: 1.0.9+10 # video_player (as of v0.10.8+2, backed by ExoPlayer): # - does not support content URIs (by default, but trivial by fork) @@ -57,6 +57,7 @@ dependencies: google_maps_flutter: intl: outline_material_icons: + package_info: palette_generator: pdf: pedantic: diff --git a/scripts/update_flutter_version.sh b/scripts/update_flutter_version.sh new file mode 100755 index 000000000..55efd68ca --- /dev/null +++ b/scripts/update_flutter_version.sh @@ -0,0 +1,10 @@ +#!/bin/bash +FILE_PATH="../lib/flutter_version.dart" +rm "$FILE_PATH" +echo "Updating flutter_version.dart:" +{ + echo "const Map version = " + flutter --version --machine + echo ";" +} >> "$FILE_PATH" +cat "$FILE_PATH"