diff --git a/.github/workflows/mobile-internal-release-rust.yml b/.github/workflows/mobile-internal-release-rust.yml new file mode 100644 index 0000000000..aa254f2268 --- /dev/null +++ b/.github/workflows/mobile-internal-release-rust.yml @@ -0,0 +1,77 @@ +name: "Internal release (photos)" + +on: + workflow_dispatch: # Allow manually running the action + +env: + FLUTTER_VERSION: "3.24.3" + RUST_VERSION: "1.85.1" + +permissions: + contents: write + +jobs: + build: + runs-on: ubuntu-latest + + defaults: + run: + working-directory: mobile + + steps: + - name: Checkout code and submodules + uses: actions/checkout@v4 + with: + submodules: recursive + + - name: Setup JDK 17 + uses: actions/setup-java@v1 + with: + java-version: 17 + + - name: Install Flutter ${{ env.FLUTTER_VERSION }} + uses: subosito/flutter-action@v2 + with: + channel: "stable" + flutter-version: ${{ env.FLUTTER_VERSION }} + cache: true + + - name: Install Rust ${{ env.RUST_VERSION }} + uses: dtolnay/rust-toolchain@master + with: + toolchain: ${{ env.RUST_VERSION }} + + - name: Install Flutter Rust Bridge + run: cargo install flutter_rust_bridge_codegen + + - name: Setup keys + uses: timheuer/base64-to-file@v1 + with: + fileName: "keystore/ente_photos_key.jks" + encodedString: ${{ secrets.SIGNING_KEY_PHOTOS }} + + - name: Build PlayStore AAB + run: | + flutter build appbundle --dart-define=cronetHttpNoPlay=true --release --flavor playstore + env: + SIGNING_KEY_PATH: "/home/runner/work/_temp/keystore/ente_photos_key.jks" + SIGNING_KEY_ALIAS: ${{ secrets.SIGNING_KEY_ALIAS_PHOTOS }} + SIGNING_KEY_PASSWORD: ${{ secrets.SIGNING_KEY_PASSWORD_PHOTOS }} + SIGNING_STORE_PASSWORD: ${{ secrets.SIGNING_STORE_PASSWORD_PHOTOS }} + + - name: Upload AAB to PlayStore + uses: r0adkll/upload-google-play@v1 + with: + serviceAccountJsonPlainText: ${{ secrets.SERVICE_ACCOUNT_JSON }} + packageName: io.ente.photos + releaseFiles: mobile/build/app/outputs/bundle/playstoreRelease/app-playstore-release.aab + track: internal + + - name: Notify Discord + uses: sarisia/actions-status-discord@v1 + with: + webhook: ${{ secrets.DISCORD_INTERNAL_RELEASE_WEBHOOK }} + nodetail: true + title: "🏆 Internal release available for Photos" + description: "[Download](https://play.google.com/store/apps/details?id=io.ente.photos)" + color: 0x00ff00 diff --git a/mobile/lib/ui/collections/album/new_row_item.dart b/mobile/lib/ui/collections/album/new_row_item.dart new file mode 100644 index 0000000000..6212d17657 --- /dev/null +++ b/mobile/lib/ui/collections/album/new_row_item.dart @@ -0,0 +1,96 @@ +import "package:dotted_border/dotted_border.dart"; +import 'package:flutter/material.dart'; +import "package:logging/logging.dart"; +import "package:photos/generated/l10n.dart"; +import 'package:photos/models/collection/collection.dart'; +import 'package:photos/models/collection/collection_items.dart'; +import "package:photos/services/collections_service.dart"; +import "package:photos/theme/ente_theme.dart"; +import "package:photos/ui/viewer/gallery/collection_page.dart"; +import "package:photos/utils/dialog_util.dart"; +import "package:photos/utils/navigation_util.dart"; + +class NewAlbumRowItemWidget extends StatelessWidget { + final Color? color; + final double height; + final double width; + const NewAlbumRowItemWidget({ + this.color, + super.key, + required this.height, + required this.width, + }); + + @override + Widget build(BuildContext context) { + final enteColorScheme = getEnteColorScheme(context); + return Padding( + padding: const EdgeInsets.symmetric(horizontal: 2.5), + child: GestureDetector( + onTap: () async { + final result = await showTextInputDialog( + context, + title: S.of(context).newAlbum, + submitButtonLabel: S.of(context).create, + hintText: S.of(context).enterAlbumName, + alwaysShowSuccessState: false, + initialValue: "", + textCapitalization: TextCapitalization.words, + popnavAfterSubmission: false, + onSubmit: (String text) async { + if (text.trim() == "") { + return; + } + + try { + final Collection c = + await CollectionsService.instance.createAlbum(text); + // ignore: unawaited_futures + await routeToPage( + context, + CollectionPage(CollectionWithThumbnail(c, null)), + ); + Navigator.of(context).pop(); + } catch (e, s) { + Logger("CreateNewAlbumRowItemWidget") + .severe("Failed to rename album", e, s); + rethrow; + } + }, + ); + + if (result is Exception) { + await showGenericErrorDialog(context: context, error: result); + } + }, + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + DottedBorder( + borderType: BorderType.RRect, + strokeWidth: 1.5, + borderPadding: const EdgeInsets.all(0.75), + dashPattern: const [3.75, 3.75], + radius: const Radius.circular(2.35), + padding: EdgeInsets.zero, + color: enteColorScheme.blurStrokePressed, + child: SizedBox( + height: height, + width: width, + child: Icon( + Icons.add, + color: enteColorScheme.blurStrokePressed, + ), + ), + ), + const SizedBox(height: 4), + Text( + S.of(context).addNew, + style: getEnteTextTheme(context).smallFaint, + ), + ], + ), + ), + ); + } +} diff --git a/mobile/lib/ui/collections/collection_list_page.dart b/mobile/lib/ui/collections/collection_list_page.dart index 974acdfdbb..1de8aab906 100644 --- a/mobile/lib/ui/collections/collection_list_page.dart +++ b/mobile/lib/ui/collections/collection_list_page.dart @@ -57,6 +57,12 @@ class _CollectionListPageState extends State { @override Widget build(BuildContext context) { + final displayLimitCount = (collections?.length ?? 0); + final bool enableSelectionMode = + widget.sectionType == UISectionType.homeCollections || + widget.sectionType == UISectionType.outgoingCollections || + widget.sectionType == UISectionType.incomingCollections; + return Scaffold( body: SafeArea( child: CustomScrollView( @@ -75,8 +81,10 @@ class _CollectionListPageState extends State { ), CollectionsFlexiGridViewWidget( collections, - displayLimitCount: collections?.length ?? 0, + displayLimitCount: displayLimitCount, tag: widget.tag, + enableSelectionMode: enableSelectionMode, + scrollBottomSafeArea: 140, ), ], ), diff --git a/mobile/lib/ui/collections/flex_grid_view.dart b/mobile/lib/ui/collections/flex_grid_view.dart index 4114ff0493..5fb3349896 100644 --- a/mobile/lib/ui/collections/flex_grid_view.dart +++ b/mobile/lib/ui/collections/flex_grid_view.dart @@ -2,16 +2,17 @@ import 'dart:math'; import 'package:flutter/material.dart'; import 'package:photos/models/collection/collection.dart'; +import "package:photos/ui/collections/album/new_row_item.dart"; import "package:photos/ui/collections/album/row_item.dart"; -class CollectionsFlexiGridViewWidget extends StatelessWidget { +class CollectionsFlexiGridViewWidget extends StatefulWidget { /* Aspect ratio 1:1 Max width 224 Fixed gap 8 Width changes dynamically with screen width such that we can fit 2 in one row. Keep the width integral (center the albums to distribute excess pixels) */ - static const maxThumbnailWidth = 224.0; - static const fixedGapBetweenAlbum = 8.0; + static const maxThumbnailWidth = 170.0; + static const fixedGapBetweenAlbum = 2.0; static const minGapForHorizontalPadding = 8.0; static const collectionItemsToPreload = 20; @@ -23,45 +24,83 @@ class CollectionsFlexiGridViewWidget extends StatelessWidget { final bool shrinkWrap; final String tag; + final bool enableSelectionMode; + final bool shouldShowCreateAlbum; + final double scrollBottomSafeArea; + const CollectionsFlexiGridViewWidget( this.collections, { this.displayLimitCount = 10, this.shrinkWrap = false, this.tag = "", super.key, + this.enableSelectionMode = false, + this.shouldShowCreateAlbum = false, + this.scrollBottomSafeArea = 8, }); + @override + State createState() => + _CollectionsFlexiGridViewWidgetState(); +} + +class _CollectionsFlexiGridViewWidgetState + extends State { @override Widget build(BuildContext context) { final double screenWidth = MediaQuery.of(context).size.width; - final int albumsCountInOneRow = max(screenWidth ~/ maxThumbnailWidth, 2); - final double gapBetweenAlbums = - (albumsCountInOneRow - 1) * fixedGapBetweenAlbum; + final int albumsCountInOneRow = + max(screenWidth ~/ CollectionsFlexiGridViewWidget.maxThumbnailWidth, 3); + final double gapBetweenAlbums = (albumsCountInOneRow - 1) * + CollectionsFlexiGridViewWidget.fixedGapBetweenAlbum; // gapOnSizeOfAlbums will be - final double gapOnSizeOfAlbums = minGapForHorizontalPadding + - (screenWidth - gapBetweenAlbums - (2 * minGapForHorizontalPadding)) % - albumsCountInOneRow; + final double gapOnSizeOfAlbums = + CollectionsFlexiGridViewWidget.minGapForHorizontalPadding + + (screenWidth - + gapBetweenAlbums - + (2 * + CollectionsFlexiGridViewWidget + .minGapForHorizontalPadding)) % + albumsCountInOneRow; final double sideOfThumbnail = (screenWidth - gapOnSizeOfAlbums - gapBetweenAlbums) / albumsCountInOneRow; + final int totalCollections = widget.collections!.length; + final bool showCreateAlbum = widget.shouldShowCreateAlbum; + final int totalItemCount = totalCollections + (showCreateAlbum ? 1 : 0); + final int displayItemCount = min(totalItemCount, widget.displayLimitCount); + return SliverPadding( - padding: const EdgeInsets.all(8), + padding: EdgeInsets.only( + top: 8, + left: 8, + right: 8, + bottom: widget.scrollBottomSafeArea, + ), sliver: SliverGrid( delegate: SliverChildBuilderDelegate( (context, index) { + if (showCreateAlbum && index == 0) { + return NewAlbumRowItemWidget( + height: sideOfThumbnail, + width: sideOfThumbnail, + ); + } + final collectionIndex = showCreateAlbum ? index - 1 : index; return AlbumRowItemWidget( - collections![index], + widget.collections![collectionIndex], sideOfThumbnail, - tag: tag, + tag: widget.tag, + showFileCount: false, ); }, - childCount: min(collections!.length, displayLimitCount), + childCount: displayItemCount, ), gridDelegate: SliverGridDelegateWithFixedCrossAxisCount( crossAxisCount: albumsCountInOneRow, - mainAxisSpacing: 4, + mainAxisSpacing: 2, crossAxisSpacing: gapBetweenAlbums, childAspectRatio: sideOfThumbnail / (sideOfThumbnail + 46), ), diff --git a/mobile/lib/ui/tabs/user_collections_tab.dart b/mobile/lib/ui/tabs/user_collections_tab.dart index afc6178c24..655f9a016c 100644 --- a/mobile/lib/ui/tabs/user_collections_tab.dart +++ b/mobile/lib/ui/tabs/user_collections_tab.dart @@ -57,7 +57,7 @@ class _UserCollectionsTabState extends State leading: true, ); - static const int _kOnEnteItemLimitCount = 10; + static const int _kOnEnteItemLimitCount = 12; @override void initState() { super.initState(); @@ -164,6 +164,8 @@ class _UserCollectionsTabState extends State collections, displayLimitCount: _kOnEnteItemLimitCount, shrinkWrap: true, + shouldShowCreateAlbum: true, + enableSelectionMode: true, ) : const SliverToBoxAdapter(child: EmptyState()), collections.length > _kOnEnteItemLimitCount diff --git a/server/ente/filedata/filedata.go b/server/ente/filedata/filedata.go index 9012a8249b..5b6ec70edd 100644 --- a/server/ente/filedata/filedata.go +++ b/server/ente/filedata/filedata.go @@ -63,8 +63,9 @@ func (g *GetFilesData) Validate() error { } type GetFileData struct { - FileID int64 `form:"fileID" binding:"required"` - Type ente.ObjectType `form:"type" binding:"required"` + FileID int64 `form:"fileID" binding:"required"` + Type ente.ObjectType `form:"type" binding:"required"` + PreferNoContent bool `form:"preferNoContent"` } func (g *GetFileData) Validate() error { diff --git a/server/pkg/api/file_data.go b/server/pkg/api/file_data.go index dbac6282ee..045c00f3a7 100644 --- a/server/pkg/api/file_data.go +++ b/server/pkg/api/file_data.go @@ -109,6 +109,10 @@ func (h *FileHandler) GetFileData(ctx *gin.Context) { handler.Error(ctx, err) return } + if resp == nil { + ctx.Status(http.StatusNoContent) + return + } ctx.JSON(http.StatusOK, gin.H{ "data": resp, }) diff --git a/server/pkg/api/public_collection.go b/server/pkg/api/public_collection.go index bf33a907fe..9f61ba788e 100644 --- a/server/pkg/api/public_collection.go +++ b/server/pkg/api/public_collection.go @@ -79,6 +79,10 @@ func (h *PublicCollectionHandler) GetFileData(c *gin.Context) { handler.Error(c, err) return } + if resp == nil { + c.Status(http.StatusNoContent) + return + } c.JSON(http.StatusOK, gin.H{ "data": resp, }) diff --git a/server/pkg/controller/filedata/controller.go b/server/pkg/controller/filedata/controller.go index 56eaeef398..91645cc720 100644 --- a/server/pkg/controller/filedata/controller.go +++ b/server/pkg/controller/filedata/controller.go @@ -2,6 +2,7 @@ package filedata import ( "context" + "database/sql" "errors" "github.com/aws/aws-sdk-go/aws/awserr" "github.com/aws/aws-sdk-go/service/s3" @@ -138,6 +139,9 @@ func (c *Controller) GetFileData(ctx *gin.Context, actorUser int64, req fileData } doRows, err := c.Repo.GetFilesData(ctx, req.Type, []int64{req.FileID}) if err != nil { + if errors.Is(err, sql.ErrNoRows) && req.PreferNoContent { + return nil, nil + } return nil, stacktrace.Propagate(err, "") } if len(doRows) == 0 || doRows[0].IsDeleted { diff --git a/web/apps/photos/src/utils/file/index.ts b/web/apps/photos/src/utils/file/index.ts index 005c815986..74dfb8eac0 100644 --- a/web/apps/photos/src/utils/file/index.ts +++ b/web/apps/photos/src/utils/file/index.ts @@ -22,6 +22,7 @@ import { import { safeFileName } from "ente-new/photos/utils/native-fs"; import { getData } from "ente-shared/storage/localStorage"; import type { User } from "ente-shared/user/types"; +import { wait } from "ente-utils/promise"; import { t } from "i18next"; import { addMultipleToFavorites, @@ -60,6 +61,9 @@ export async function downloadFile(file: EnteFile) { new Blob([videoData], { type: videoType.mimeType }), ); downloadAndRevokeObjectURL(tempImageURL, imageFileName); + // Downloading multiple works everywhere except, you guessed it, + // Safari. Make up for their incompetence by adding a setTimeout. + await wait(300) /* arbitrary constant, 300ms */; downloadAndRevokeObjectURL(tempVideoURL, videoFileName); } else { const fileType = await detectFileTypeInfo(