Skip to content
Open
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
42 changes: 42 additions & 0 deletions FRAME_SETUP.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
# Setting up a new photo frame

## 1. Find the device

```bash
adb devices -l
```

Note the device serial (e.g. `c3d9b8674f4b94f6`). Use `-s <serial>` in all commands below if multiple devices are connected.

## 2. Install the APK

```bash
adb install -r build/app/outputs/flutter-apk/app-release.apk
```

## 3. Disable the stock frame app (if any)

```bash
adb shell pm list packages -s # find the stock app package name
adb shell pm disable-user --user 0 net.frameo.frame # replace with actual package
```

## 4. Set Open Photo Frame as default home (auto-starts on boot)

```bash
adb shell cmd package set-home-activity io.github.micw.openphotoframe/.MainActivity
```

## 5. Launch now

```bash
adb shell am start -n io.github.micw.openphotoframe/.MainActivity
```

## Build the APK

```bash
cd android && ./gradlew assembleRelease
```

Output: `build/app/outputs/flutter-apk/app-release.apk`
4 changes: 4 additions & 0 deletions android/app/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,10 @@ android {
jvmTarget = JavaVersion.VERSION_17.toString()
}

lint {
disable += "Instantiatable"
}

defaultConfig {
applicationId = "io.github.micw.openphotoframe"
// You can update the following values to match your application needs.
Expand Down
7 changes: 7 additions & 0 deletions android/app/src/main/AndroidManifest.xml
Original file line number Diff line number Diff line change
Expand Up @@ -89,6 +89,13 @@
</intent-filter>
</receiver>

<!-- Short-lived service used to launch MainActivity from boot (Android 11+ background start workaround) -->
<service
android:name=".BootLaunchService"
android:enabled="true"
android:exported="false"
android:foregroundServiceType="mediaPlayback"/>

<!-- Keep Alive Service to prevent app from being killed on low-memory devices -->
<service
android:name=".KeepAliveService"
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
package io.github.micw.openphotoframe

import android.app.NotificationChannel
import android.app.NotificationManager
import android.app.Service
import android.content.Intent
import android.os.Build
import android.os.Handler
import android.os.IBinder
import android.os.Looper
import android.util.Log
import androidx.core.app.NotificationCompat

/**
* Short-lived foreground service that launches MainActivity on boot.
*
* Android 11+ blocks activities started directly from a BroadcastReceiver
* (background activity start restriction). Starting a foreground service first,
* then starting the activity from the service, is the supported workaround.
*/
class BootLaunchService : Service() {
companion object {
private const val TAG = "BootLaunchService"
private const val CHANNEL_ID = "boot_launch"
private const val NOTIFICATION_ID = 2001
}

override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
Log.d(TAG, "BootLaunchService started")
ensureNotificationChannel()

// Must call startForeground within 5 seconds
val notification = NotificationCompat.Builder(this, CHANNEL_ID)
.setContentTitle("Open Photo Frame")
.setContentText("Starting…")
.setSmallIcon(android.R.drawable.ic_media_play)
.setPriority(NotificationCompat.PRIORITY_LOW)
.build()
startForeground(NOTIFICATION_ID, notification)

// Small delay to let system settle after boot before launching the UI
Handler(Looper.getMainLooper()).postDelayed({
Log.d(TAG, "Launching MainActivity")
val activityIntent = Intent(this, MainActivity::class.java).apply {
addFlags(Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TOP)
}
startActivity(activityIntent)
stopSelf()
}, 1000)

return START_NOT_STICKY
}

override fun onBind(intent: Intent?): IBinder? = null

private fun ensureNotificationChannel() {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
val channel = NotificationChannel(
CHANNEL_ID,
"Boot launch",
NotificationManager.IMPORTANCE_LOW
)
getSystemService(NotificationManager::class.java)?.createNotificationChannel(channel)
}
}
}
Original file line number Diff line number Diff line change
@@ -1,42 +1,59 @@
package io.github.micw.openphotoframe

import android.app.AlarmManager
import android.app.PendingIntent
import android.content.BroadcastReceiver
import android.content.Context
import android.content.Intent
import android.content.SharedPreferences
import android.os.SystemClock
import android.util.Log

/**
* BroadcastReceiver that starts the app when the device boots.
* Only starts if autostart is enabled in app settings.
*
* Android 11+ blocks activity starts from background broadcast receivers
* (and even from foreground services started by them). Using AlarmManager
* with a PendingIntent works around this: the alarm fires via the system
* process, which is whitelisted for background activity starts.
*/
class BootReceiver : BroadcastReceiver() {
companion object {
private const val TAG = "BootReceiver"
private const val PREFS_NAME = "FlutterSharedPreferences"
private const val AUTOSTART_KEY = "flutter.autostart_on_boot"
private const val BOOT_ALARM_REQUEST_CODE = 9001
}

override fun onReceive(context: Context, intent: Intent) {
if (intent.action == Intent.ACTION_BOOT_COMPLETED ||
intent.action == "android.intent.action.QUICKBOOT_POWERON") {

Log.d(TAG, "Boot completed received")

// Check if autostart is enabled in shared preferences
val prefs: SharedPreferences = context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE)
val autostartEnabled = prefs.getBoolean(AUTOSTART_KEY, false)

Log.d(TAG, "Autostart enabled: $autostartEnabled")

if (autostartEnabled) {
Log.d(TAG, "Starting MainActivity")
val startIntent = Intent(context, MainActivity::class.java).apply {
addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
addFlags(Intent.FLAG_ACTIVITY_CLEAR_TOP)
}
context.startActivity(startIntent)
}
if (intent.action != Intent.ACTION_BOOT_COMPLETED &&
intent.action != "android.intent.action.QUICKBOOT_POWERON") return

Log.i(TAG, "Boot completed received")

val prefs: SharedPreferences = context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE)
val autostartEnabled = prefs.getBoolean(AUTOSTART_KEY, false)
Log.i(TAG, "Autostart enabled: $autostartEnabled")
if (!autostartEnabled) return

// Schedule MainActivity to start in ~3 seconds via AlarmManager.
// The alarm fires through the system process, bypassing Android 11's
// background activity start restriction (isBgStartWhitelisted).
val activityIntent = Intent(context, MainActivity::class.java).apply {
addFlags(Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TOP)
}
val pendingIntent = PendingIntent.getActivity(
context,
BOOT_ALARM_REQUEST_CODE,
activityIntent,
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE
)
val alarmManager = context.getSystemService(AlarmManager::class.java)
val triggerAt = SystemClock.elapsedRealtime() + 3_000L
alarmManager.setExactAndAllowWhileIdle(
AlarmManager.ELAPSED_REALTIME_WAKEUP, triggerAt, pendingIntent
)
Log.i(TAG, "Scheduled MainActivity launch via AlarmManager in 3s")
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,13 @@ class ScreenControlHandler(private val context: Context) {
openDeviceAdminSettings()
result.success(null)
}
"openWifiSettings" -> {
val intent = Intent(android.provider.Settings.ACTION_WIFI_SETTINGS).apply {
addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
}
context.startActivity(intent)
result.success(null)
}
"turnScreenOff" -> {
val success = turnScreenOff()
result.success(success)
Expand Down
4 changes: 4 additions & 0 deletions android/gradle.properties
Original file line number Diff line number Diff line change
@@ -1,2 +1,6 @@
org.gradle.jvmargs=-Xmx8G -XX:MaxMetaspaceSize=4G -XX:ReservedCodeCacheSize=512m -XX:+HeapDumpOnOutOfMemoryError
android.useAndroidX=true
# This builtInKotlin flag was added automatically by Flutter migrator
android.builtInKotlin=false
# This newDsl flag was added automatically by Flutter migrator
android.newDsl=false
3 changes: 3 additions & 0 deletions assets/config.json
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,9 @@
"url": "",
"folder_sync_mode": "all",
"selected_folders": []
},
"icloud_album": {
"album_url": ""
}
}
}
5 changes: 4 additions & 1 deletion lib/domain/interfaces/config_provider.dart
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,10 @@ abstract class ConfigProvider extends ChangeNotifier {
// Sync settings
int get syncIntervalMinutes; // 0 = disabled, otherwise interval in minutes
set syncIntervalMinutes(int value);


int get syncTimeoutSeconds; // Network timeout for sync requests (default 60)
set syncTimeoutSeconds(int value);

bool get deleteOrphanedFiles; // Delete local files not on server
set deleteOrphanedFiles(bool value);

Expand Down
29 changes: 8 additions & 21 deletions lib/infrastructure/services/geocoding_service.dart
Original file line number Diff line number Diff line change
Expand Up @@ -18,9 +18,9 @@ class GeocodingService {
/// User-Agent required by Nominatim usage policy
static const String _userAgent = 'OpenPhotoFrame/1.0';

/// Prefix for SharedPreferences keys
static const String _prefsPrefix = 'geocache_';
static const String _prefsTsPrefix = 'geocache_ts_';
/// Prefix for SharedPreferences keys (v2 = city-only format)
static const String _prefsPrefix = 'geocache_v2_';
static const String _prefsTsPrefix = 'geocache_v2_ts_';

/// Maximum age for cache entries (3 months)
static const Duration _maxCacheAge = Duration(days: 90);
Expand Down Expand Up @@ -108,26 +108,13 @@ class GeocodingService {
return null;
}

// Build location string: City, State, Country
final parts = <String>[];

// City (try multiple fields)
final city = address['city'] ??
address['town'] ??
address['village'] ??
// Build location string: city only
final city = address['city'] ??
address['town'] ??
address['village'] ??
address['municipality'] ??
address['county'];
if (city != null) parts.add(city.toString());

// State/Region
final state = address['state'];
if (state != null) parts.add(state.toString());

// Country
final country = address['country'];
if (country != null) parts.add(country.toString());

final result = parts.isNotEmpty ? parts.join(', ') : null;
final result = city?.toString();
await _cacheResult(cacheKey, result);

_log.fine('Geocoded ($latitude, $longitude) → $result');
Expand Down
34 changes: 34 additions & 0 deletions lib/infrastructure/services/icloud_album_source_config.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
class ICloudAlbumSourceConfig {
final String albumUrl;

const ICloudAlbumSourceConfig({this.albumUrl = ''});

/// Extracts the share token.
/// Handles both URL styles:
/// https://www.icloud.com/sharedalbum/#TOKEN (token in fragment)
/// https://www.icloud.com/photos/TOKEN (token in last path segment)
String get token {
final trimmed = albumUrl.trim();
if (trimmed.isEmpty) return '';
final uri = Uri.tryParse(trimmed);
if (uri == null) return '';
if (uri.fragment.isNotEmpty) return uri.fragment;
final segments = uri.pathSegments.where((s) => s.isNotEmpty).toList();
return segments.isEmpty ? '' : segments.last;
}

bool get isValid {
if (albumUrl.trim().isEmpty) return false;
final uri = Uri.tryParse(albumUrl.trim());
if (uri == null) return false;
return uri.host.contains('icloud.com') && token.isNotEmpty;
}

factory ICloudAlbumSourceConfig.fromMap(Map<String, dynamic> config) {
return ICloudAlbumSourceConfig(
albumUrl: (config['album_url'] as String? ?? '').trim(),
);
}

Map<String, dynamic> toMap() => {'album_url': albumUrl.trim()};
}
Loading