pull/24561/merge
Miłosz 2025-12-15 12:38:38 -06:00 committed by GitHub
commit 6c694f16f5
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
13 changed files with 515 additions and 0 deletions

View File

@ -2163,6 +2163,10 @@
"uploading_media": "Uploading media",
"url": "URL",
"usage": "Usage",
"use_as_wallpaper": "Set as wallpaper",
"use_as_wallpaper_failed": "Failed to set wallpaper",
"use_as_wallpaper_image_only": "Only images can be set as wallpaper",
"use_as_wallpaper_not_supported": "Set as wallpaper is currently only supported on Android",
"use_biometric": "Use biometric",
"use_current_connection": "use current connection",
"use_custom_date_range": "Use custom date range instead",

View File

@ -2138,6 +2138,10 @@
"uploading_media": "Przesyłanie multimediów",
"url": "URL",
"usage": "Użycie",
"use_as_wallpaper": "Ustaw jako tło ekranu",
"use_as_wallpaper_failed": "Nie udało się ustawić jako tła ekranu",
"use_as_wallpaper_image_only": "Tylko obrazy mogą być ustawione jako tło ekranu",
"use_as_wallpaper_not_supported": "Ustawienie jako tło ekranu jest aktualnie obsługiwane tylko na Androidzie",
"use_biometric": "Użyj biometrii",
"use_current_connection": "użyj bieżącego połączenia",
"use_custom_date_range": "Zamiast tego użyj niestandardowego zakresu dat",

View File

@ -144,6 +144,17 @@
android:authorities="${applicationId}.androidx-startup"
tools:node="remove" />
<!-- FileProvider for sharing files with other apps -->
<provider
android:name="androidx.core.content.FileProvider"
android:authorities="${applicationId}.provider"
android:exported="false"
android:grantUriPermissions="true">
<meta-data
android:name="android.support.FILE_PROVIDER_PATHS"
android:resource="@xml/file_paths" />
</provider>
<!-- Widgets -->
<receiver

View File

@ -15,6 +15,8 @@ import app.alextran.immich.images.ThumbnailsImpl
import app.alextran.immich.sync.NativeSyncApi
import app.alextran.immich.sync.NativeSyncApiImpl26
import app.alextran.immich.sync.NativeSyncApiImpl30
import app.alextran.immich.use_as_wallpaper.UseAsWallpaperApi
import app.alextran.immich.use_as_wallpaper.UseAsWallpaperApiImpl
import io.flutter.embedding.android.FlutterFragmentActivity
import io.flutter.embedding.engine.FlutterEngine
@ -39,6 +41,7 @@ class MainActivity : FlutterFragmentActivity() {
ThumbnailApi.setUp(messenger, ThumbnailsImpl(ctx))
BackgroundWorkerFgHostApi.setUp(messenger, BackgroundWorkerApiImpl(ctx))
ConnectivityApi.setUp(messenger, ConnectivityApiImpl(ctx))
UseAsWallpaperApi.setUp(messenger, UseAsWallpaperApiImpl(ctx))
flutterEngine.plugins.add(BackgroundServicePlugin())
flutterEngine.plugins.add(HttpSSLOptionsPlugin())

View File

@ -0,0 +1,95 @@
// Autogenerated from Pigeon (v26.0.2), do not edit directly.
// See also: https://pub.dev/packages/pigeon
@file:Suppress("UNCHECKED_CAST", "ArrayInDataClass")
package app.alextran.immich.use_as_wallpaper
import android.util.Log
import io.flutter.plugin.common.BasicMessageChannel
import io.flutter.plugin.common.BinaryMessenger
import io.flutter.plugin.common.EventChannel
import io.flutter.plugin.common.MessageCodec
import io.flutter.plugin.common.StandardMethodCodec
import io.flutter.plugin.common.StandardMessageCodec
import java.io.ByteArrayOutputStream
import java.nio.ByteBuffer
private object MessagesPigeonUtils {
fun wrapResult(result: Any?): List<Any?> {
return listOf(result)
}
fun wrapError(exception: Throwable): List<Any?> {
return if (exception is FlutterError) {
listOf(
exception.code,
exception.message,
exception.details
)
} else {
listOf(
exception.javaClass.simpleName,
exception.toString(),
"Cause: " + exception.cause + ", Stacktrace: " + Log.getStackTraceString(exception)
)
}
}
}
/**
* Error class for passing custom error details to Flutter via a thrown PlatformException.
* @property code The error code.
* @property message The error message.
* @property details The error details. Must be a datatype supported by the api codec.
*/
class FlutterError (
val code: String,
override val message: String? = null,
val details: Any? = null
) : Throwable()
private open class MessagesPigeonCodec : StandardMessageCodec() {
override fun readValueOfType(type: Byte, buffer: ByteBuffer): Any? {
return super.readValueOfType(type, buffer)
}
override fun writeValue(stream: ByteArrayOutputStream, value: Any?) {
super.writeValue(stream, value)
}
}
/** Generated interface from Pigeon that represents a handler of messages from Flutter. */
interface UseAsWallpaperApi {
fun useAsWallpaper(filePath: String, callback: (Result<Boolean>) -> Unit)
companion object {
/** The codec used by UseAsWallpaperApi. */
val codec: MessageCodec<Any?> by lazy {
MessagesPigeonCodec()
}
/** Sets up an instance of `UseAsWallpaperApi` to handle messages through the `binaryMessenger`. */
@JvmOverloads
fun setUp(binaryMessenger: BinaryMessenger, api: UseAsWallpaperApi?, messageChannelSuffix: String = "") {
val separatedMessageChannelSuffix = if (messageChannelSuffix.isNotEmpty()) ".$messageChannelSuffix" else ""
run {
val channel = BasicMessageChannel<Any?>(binaryMessenger, "dev.flutter.pigeon.immich_mobile.UseAsWallpaperApi.useAsWallpaper$separatedMessageChannelSuffix", codec)
if (api != null) {
channel.setMessageHandler { message, reply ->
val args = message as List<Any?>
val filePathArg = args[0] as String
api.useAsWallpaper(filePathArg) { result: Result<Boolean> ->
val error = result.exceptionOrNull()
if (error != null) {
reply.reply(MessagesPigeonUtils.wrapError(error))
} else {
val data = result.getOrNull()
reply.reply(MessagesPigeonUtils.wrapResult(data))
}
}
}
} else {
channel.setMessageHandler(null)
}
}
}
}
}

View File

@ -0,0 +1,33 @@
package app.alextran.immich.use_as_wallpaper
import android.content.Context
import android.content.Intent
import android.util.Log
import androidx.core.content.FileProvider
import java.io.File
class UseAsWallpaperApiImpl(private val context: Context) : UseAsWallpaperApi {
override fun useAsWallpaper(filePath: String, callback: (Result<Boolean>) -> Unit) {
try {
val file = File(filePath)
val uri = FileProvider.getUriForFile(
context,
context.packageName + ".provider",
file
)
val intent = Intent(Intent.ACTION_ATTACH_DATA).apply {
setDataAndType(uri, "image/*")
putExtra("mimeType", "image/*")
addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION)
addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
}
context.startActivity(Intent.createChooser(intent, "Set as Wallpaper"))
callback(Result.success(true))
} catch (e: Exception) {
Log.e("UseAsWallpaperApiImpl", "Failed to launch wallpaper intent", e)
callback(Result.success(false))
}
}
}

View File

@ -0,0 +1,17 @@
<?xml version="1.0" encoding="utf-8"?>
<paths>
<!-- Cache directory for temporary files -->
<cache-path name="cache" path="." />
<!-- External cache directory -->
<external-cache-path name="external_cache" path="." />
<!-- Files directory -->
<files-path name="files" path="." />
<!-- External files directory -->
<external-files-path name="external_files" path="." />
<!-- External storage (for downloaded images) -->
<external-path name="external" path="." />
</paths>

View File

@ -0,0 +1,80 @@
// Autogenerated from Pigeon (v26.0.2), do not edit directly.
// See also: https://pub.dev/packages/pigeon
// ignore_for_file: public_member_api_docs, non_constant_identifier_names, avoid_as, unused_import, unnecessary_parenthesis, prefer_null_aware_operators, omit_local_variable_types, unused_shown_name, unnecessary_import, no_leading_underscores_for_local_identifiers
import 'dart:async';
import 'dart:typed_data' show Float64List, Int32List, Int64List, Uint8List;
import 'package:flutter/foundation.dart' show ReadBuffer, WriteBuffer;
import 'package:flutter/services.dart';
PlatformException _createConnectionError(String channelName) {
return PlatformException(
code: 'channel-error',
message: 'Unable to establish connection on channel: "$channelName".',
);
}
class _PigeonCodec extends StandardMessageCodec {
const _PigeonCodec();
@override
void writeValue(WriteBuffer buffer, Object? value) {
if (value is int) {
buffer.putUint8(4);
buffer.putInt64(value);
} else {
super.writeValue(buffer, value);
}
}
@override
Object? readValueOfType(int type, ReadBuffer buffer) {
switch (type) {
default:
return super.readValueOfType(type, buffer);
}
}
}
class UseAsWallpaperApi {
/// Constructor for [UseAsWallpaperApi]. The [binaryMessenger] named argument is
/// available for dependency injection. If it is left null, the default
/// BinaryMessenger will be used which routes to the host platform.
UseAsWallpaperApi({BinaryMessenger? binaryMessenger, String messageChannelSuffix = ''})
: pigeonVar_binaryMessenger = binaryMessenger,
pigeonVar_messageChannelSuffix = messageChannelSuffix.isNotEmpty ? '.$messageChannelSuffix' : '';
final BinaryMessenger? pigeonVar_binaryMessenger;
static const MessageCodec<Object?> pigeonChannelCodec = _PigeonCodec();
final String pigeonVar_messageChannelSuffix;
Future<bool> useAsWallpaper({required String filePath}) async {
final String pigeonVar_channelName = 'dev.flutter.pigeon.immich_mobile.UseAsWallpaperApi.useAsWallpaper$pigeonVar_messageChannelSuffix';
final BasicMessageChannel<Object?> pigeonVar_channel = BasicMessageChannel<Object?>(
pigeonVar_channelName,
pigeonChannelCodec,
binaryMessenger: pigeonVar_binaryMessenger,
);
final Future<Object?> pigeonVar_sendFuture = pigeonVar_channel.send(<Object?>[filePath]);
final List<Object?>? pigeonVar_replyList =
await pigeonVar_sendFuture as List<Object?>?;
if (pigeonVar_replyList == null) {
throw _createConnectionError(pigeonVar_channelName);
} else if (pigeonVar_replyList.length > 1) {
throw PlatformException(
code: pigeonVar_replyList[0]! as String,
message: pigeonVar_replyList[1] as String?,
details: pigeonVar_replyList[2],
);
} else if (pigeonVar_replyList[0] == null) {
throw PlatformException(
code: 'null-error',
message: 'Host platform returned null value for non-null return value.',
);
} else {
return (pigeonVar_replyList[0] as bool?)!;
}
}
}

View File

@ -0,0 +1,115 @@
import 'dart:io';
import 'package:flutter/material.dart';
import 'package:fluttertoast/fluttertoast.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/constants/enums.dart';
import 'package:immich_mobile/domain/models/asset/base_asset.model.dart';
import 'package:immich_mobile/extensions/translate_extensions.dart';
import 'package:immich_mobile/presentation/widgets/action_buttons/base_action_button.widget.dart';
import 'package:immich_mobile/providers/infrastructure/asset_viewer/current_asset.provider.dart';
import 'package:immich_mobile/providers/timeline/multiselect.provider.dart';
import 'package:immich_mobile/services/wallpaper.service.dart';
import 'package:immich_mobile/widgets/common/immich_toast.dart';
class UseAsWallpaperActionButton extends ConsumerWidget {
final ActionSource source;
final bool iconOnly;
final bool menuItem;
const UseAsWallpaperActionButton({super.key, required this.source, this.iconOnly = false, this.menuItem = false});
void _onTap(BuildContext context, WidgetRef ref) async {
if (!context.mounted) {
return;
}
// TODO: Implement iOS support
if (!Platform.isAndroid) {
ImmichToast.show(
context: context,
msg: 'use_as_wallpaper_not_supported'.t(context: context),
gravity: ToastGravity.BOTTOM,
toastType: ToastType.error,
);
return;
}
final asset = _getAsset(ref);
if (asset == null) {
return;
}
if (!asset.isImage) {
ImmichToast.show(
context: context,
msg: 'use_as_wallpaper_image_only'.t(context: context),
gravity: ToastGravity.BOTTOM,
toastType: ToastType.error,
);
return;
}
try {
final wallpaperService = ref.read(wallpaperServiceProvider);
final result = await wallpaperService.setAsWallpaper(asset);
if (!context.mounted) {
return;
}
if (!result) {
ImmichToast.show(
context: context,
msg: 'use_as_wallpaper_failed'.t(context: context),
gravity: ToastGravity.BOTTOM,
toastType: ToastType.error,
);
}
} catch (e) {
if (!context.mounted) {
return;
}
ImmichToast.show(
context: context,
msg: 'use_as_wallpaper_failed'.t(context: context),
gravity: ToastGravity.BOTTOM,
toastType: ToastType.error,
);
} finally {
if (source == ActionSource.timeline) {
ref.read(multiSelectProvider.notifier).reset();
}
}
}
BaseAsset? _getAsset(WidgetRef ref) {
return switch (source) {
ActionSource.timeline => () {
final selectedAssets = ref.read(multiSelectProvider).selectedAssets;
if (selectedAssets.length == 1) {
return selectedAssets.first;
}
return null;
}(),
ActionSource.viewer => ref.read(currentAssetNotifier),
};
}
@override
Widget build(BuildContext context, WidgetRef ref) {
// TODO: Implement iOS support - hide button on iOS for now
if (!Platform.isAndroid) {
return const SizedBox.shrink();
}
return BaseActionButton(
iconData: Icons.wallpaper,
maxWidth: 95,
label: 'use_as_wallpaper'.t(context: context),
iconOnly: iconOnly,
menuItem: menuItem,
onPressed: () => _onTap(context, ref),
);
}
}

View File

@ -22,6 +22,7 @@ import 'package:immich_mobile/presentation/widgets/action_buttons/stack_action_b
import 'package:immich_mobile/presentation/widgets/action_buttons/trash_action_button.widget.dart';
import 'package:immich_mobile/presentation/widgets/action_buttons/unstack_action_button.widget.dart';
import 'package:immich_mobile/presentation/widgets/action_buttons/upload_action_button.widget.dart';
import 'package:immich_mobile/presentation/widgets/action_buttons/use_as_wallpaper_action_button.widget.dart';
import 'package:immich_mobile/presentation/widgets/album/album_selector.widget.dart';
import 'package:immich_mobile/presentation/widgets/bottom_sheet/base_bottom_sheet.widget.dart';
import 'package:immich_mobile/providers/infrastructure/album.provider.dart';
@ -121,6 +122,8 @@ class _GeneralBottomSheetState extends ConsumerState<GeneralBottomSheet> {
if (multiselect.hasStacked) const UnStackActionButton(source: ActionSource.timeline),
const DeleteActionButton(source: ActionSource.timeline),
],
if (multiselect.selectedAssets.length == 1 && multiselect.selectedAssets.first.isImage)
const UseAsWallpaperActionButton(source: ActionSource.timeline),
if (multiselect.hasLocal || multiselect.hasMerged) const DeleteLocalActionButton(source: ActionSource.timeline),
if (multiselect.hasLocal) const UploadActionButton(source: ActionSource.timeline),
],

View File

@ -0,0 +1,123 @@
import 'dart:io';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/domain/models/asset/base_asset.model.dart';
import 'package:immich_mobile/platform/use_as_wallpaper_api.g.dart';
import 'package:immich_mobile/repositories/asset_api.repository.dart';
import 'package:logging/logging.dart';
import 'package:path_provider/path_provider.dart';
import 'package:photo_manager/photo_manager.dart';
final wallpaperServiceProvider = Provider<WallpaperService>(
(ref) => WallpaperService(ref.watch(assetApiRepositoryProvider)),
);
class WallpaperService {
final AssetApiRepository _assetApiRepository;
final Logger _log = Logger("WallpaperService");
final UseAsWallpaperApi _wallpaperApi = UseAsWallpaperApi();
WallpaperService(this._assetApiRepository);
/// Sets the given asset as wallpaper.
/// Returns true if the wallpaper intent was launched successfully.
/// Note: This only works on Android.
Future<bool> setAsWallpaper(BaseAsset asset) async {
// TODO: Implement iOS support
if (!Platform.isAndroid) {
_log.warning('Set as wallpaper is currently only supported on Android');
return false;
}
if (!asset.isImage) {
_log.warning('Cannot set non-image asset as wallpaper');
return false;
}
try {
final filePath = await _getAssetFilePath(asset);
if (filePath == null) {
_log.severe('Failed to get file path for asset');
return false;
}
final result = await _wallpaperApi.useAsWallpaper(filePath: filePath);
// Clean up temp file if it was a remote asset
if (asset is RemoteAsset && asset.localId == null) {
await _cleanupTempFile(filePath);
}
return result;
} catch (e, stack) {
_log.severe('Failed to set wallpaper', e, stack);
return false;
}
}
/// Gets the file path for an asset.
/// For local assets, retrieves the original file path.
/// For remote assets, downloads to a temp file and returns the path.
Future<String?> _getAssetFilePath(BaseAsset asset) async {
// Try to get local file first
final localId = asset is LocalAsset
? asset.id
: asset is RemoteAsset
? asset.localId
: null;
if (localId != null) {
try {
final assetEntity = AssetEntity(id: localId, width: 1, height: 1, typeInt: 0);
final file = await assetEntity.originFile;
if (file != null && await file.exists()) {
return file.path;
}
} catch (e) {
_log.warning('Failed to get local file for asset: $e');
}
}
// Download remote asset to temp file
if (asset is RemoteAsset) {
return await _downloadToTempFile(asset);
}
return null;
}
/// Downloads a remote asset to a temp file and returns the path.
Future<String?> _downloadToTempFile(RemoteAsset asset) async {
try {
final tempDir = await getTemporaryDirectory();
final tempFile = File('${tempDir.path}/wallpaper_${asset.id}_${asset.name}');
final res = await _assetApiRepository.downloadAsset(asset.id);
if (res.statusCode != 200) {
_log.severe('Failed to download asset for wallpaper: ${res.statusCode}');
return null;
}
await tempFile.writeAsBytes(res.bodyBytes);
return tempFile.path;
} catch (e, stack) {
_log.severe('Failed to download asset for wallpaper', e, stack);
return null;
}
}
/// Cleans up a temp file after it's no longer needed.
Future<void> _cleanupTempFile(String filePath) async {
try {
final file = File(filePath);
if (await file.exists()) {
// Delay cleanup slightly to allow the system to read the file
await Future.delayed(const Duration(seconds: 5));
await file.delete();
}
} catch (e) {
_log.warning('Failed to cleanup temp file: $e');
}
}
}

View File

@ -1,3 +1,5 @@
import 'dart:io';
import 'package:auto_route/auto_route.dart';
import 'package:easy_localization/easy_localization.dart';
import 'package:flutter/material.dart';
@ -28,6 +30,7 @@ import 'package:immich_mobile/presentation/widgets/action_buttons/trash_action_b
import 'package:immich_mobile/presentation/widgets/action_buttons/unarchive_action_button.widget.dart';
import 'package:immich_mobile/presentation/widgets/action_buttons/unstack_action_button.widget.dart';
import 'package:immich_mobile/presentation/widgets/action_buttons/upload_action_button.widget.dart';
import 'package:immich_mobile/presentation/widgets/action_buttons/use_as_wallpaper_action_button.widget.dart';
import 'package:immich_mobile/routing/router.dart';
class ActionButtonContext {
@ -62,6 +65,7 @@ enum ActionButtonType {
archive,
unarchive,
download,
useAsWallpaper,
trash,
deletePermanent,
delete,
@ -94,6 +98,9 @@ enum ActionButtonType {
!context.isInLockedView && //
context.asset.hasRemote && //
!context.asset.hasLocal,
ActionButtonType.useAsWallpaper =>
(context.asset.type == AssetType.image) && //
Platform.isAndroid, // TODO: remove when iOS is supported
ActionButtonType.trash =>
context.isOwner && //
!context.isInLockedView && //
@ -149,6 +156,7 @@ enum ActionButtonType {
ActionButtonType.archive => ArchiveActionButton(source: context.source),
ActionButtonType.unarchive => UnArchiveActionButton(source: context.source),
ActionButtonType.download => DownloadActionButton(source: context.source),
ActionButtonType.useAsWallpaper => UseAsWallpaperActionButton(source: context.source),
ActionButtonType.trash => TrashActionButton(source: context.source),
ActionButtonType.deletePermanent => DeletePermanentActionButton(source: context.source),
ActionButtonType.delete => DeleteActionButton(source: context.source),

View File

@ -0,0 +1,19 @@
import 'package:pigeon/pigeon.dart';
@ConfigurePigeon(
PigeonOptions(
dartOut: 'lib/platform/use_as_wallpaper_api.g.dart',
kotlinOut: 'android/app/src/main/kotlin/app/alextran/immich/use_as_wallpaper/Messages.g.kt',
kotlinOptions: KotlinOptions(
package: 'app.alextran.immich.use_as_wallpaper',
),
dartPackageName: 'immich_mobile',
),
)
@HostApi()
abstract class UseAsWallpaperApi {
@async
bool useAsWallpaper({required String filePath});
}