allow to store the location in memories
Some checks are pending
Flutter analyze & test / flutter_analyze_and_test (push) Waiting to run

This commit is contained in:
otsmr 2026-09-11 18:02:09 +02:00
parent acf0d25a5e
commit 351d82c6d7
44 changed files with 2273 additions and 553 deletions

View file

@ -139,6 +139,9 @@
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_DATA_SYNC"/>
<uses-permission android:name="android.permission.RECORD_AUDIO"/>
<uses-permission android:name="android.permission.CAMERA"/>
<!-- Optional and requested only when "Location in Memories" is enabled. -->
<uses-permission android:name="android.permission.ACCESS_COARSE_LOCATION"/>
<uses-permission android:name="android.permission.ACCESS_FINE_LOCATION"/>
<uses-permission android:name="android.permission.USE_BIOMETRIC"/>
<uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE" />
<uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE"/>

View file

@ -20,12 +20,16 @@ import io.flutter.plugin.common.MethodChannel
import eu.twonly.notifications.NotificationTapChannel
import eu.twonly.webxdc.WebxdcChannel
import eu.twonly.widget.WidgetRuntimeChannel
import android.Manifest
import android.content.pm.PackageManager
class MainActivity : FlutterFragmentActivity() {
private val CHANNEL = "eu.twonly/photo_picker"
private var pendingResult: MethodChannel.Result? = null
private var pendingLocationPermissionResult: MethodChannel.Result? = null
private lateinit var pickMultipleMedia: ActivityResultLauncher<PickVisualMediaRequest>
private lateinit var requestLocationPermissions: ActivityResultLauncher<Array<String>>
override fun onCreate(savedInstanceState: Bundle?) {
installSplashScreen()
@ -43,6 +47,14 @@ class MainActivity : FlutterFragmentActivity() {
}
pendingResult = null
}
requestLocationPermissions = registerForActivityResult(
ActivityResultContracts.RequestMultiplePermissions(),
) {
pendingLocationPermissionResult?.success(
checkSelfPermission(Manifest.permission.ACCESS_FINE_LOCATION) == PackageManager.PERMISSION_GRANTED,
)
pendingLocationPermissionResult = null
}
super.onCreate(savedInstanceState)
}
@ -112,6 +124,28 @@ class MainActivity : FlutterFragmentActivity() {
else -> result.notImplemented()
}
}
MethodChannel(
flutterEngine.dartExecutor.binaryMessenger,
"eu.twonly/location_metadata",
).setMethodCallHandler { call, result ->
if (call.method != "requestPrecisePermission") {
result.notImplemented()
return@setMethodCallHandler
}
if (checkSelfPermission(Manifest.permission.ACCESS_FINE_LOCATION) == PackageManager.PERMISSION_GRANTED) {
result.success(true)
} else if (pendingLocationPermissionResult != null) {
result.error("request_in_progress", "A location permission request is already open.", null)
} else {
pendingLocationPermissionResult = result
requestLocationPermissions.launch(
arrayOf(
Manifest.permission.ACCESS_COARSE_LOCATION,
Manifest.permission.ACCESS_FINE_LOCATION,
),
)
}
}
}
override fun cleanUpFlutterEngine(flutterEngine: FlutterEngine) {

View file

@ -0,0 +1,60 @@
package eu.twonly.location
import android.Manifest
import android.annotation.SuppressLint
import android.content.Context
import android.content.pm.PackageManager
import android.location.Location
import android.location.LocationListener
import android.location.LocationManager
import android.os.Bundle
import android.os.Handler
import android.os.Looper
import eu.twonly.MyApplication
import java.util.concurrent.CountDownLatch
import java.util.concurrent.TimeUnit
import java.util.concurrent.atomic.AtomicReference
/** One-shot precise location lookup called synchronously by Rust/JNI. */
object NativeLocation {
private const val TARGET_ACCURACY_METERS = 50f
@JvmStatic
@SuppressLint("MissingPermission")
fun current(timeoutMillis: Long): String? {
val context = MyApplication.instance
if (context.checkSelfPermission(Manifest.permission.ACCESS_FINE_LOCATION) != PackageManager.PERMISSION_GRANTED) {
return null
}
val manager = context.getSystemService(Context.LOCATION_SERVICE) as? LocationManager ?: return null
val finished = CountDownLatch(1)
val best = AtomicReference<Location?>(null)
val listener = object : LocationListener {
override fun onLocationChanged(location: Location) {
if (location.accuracy < 0) return
val previous = best.get()
if (previous == null || location.accuracy < previous.accuracy) best.set(location)
if (location.accuracy <= TARGET_ACCURACY_METERS) finished.countDown()
}
@Deprecated("Deprecated in Android")
override fun onStatusChanged(provider: String?, status: Int, extras: Bundle?) = Unit
}
val main = Handler(Looper.getMainLooper())
main.post {
var requested = false
for (provider in listOf(LocationManager.GPS_PROVIDER, LocationManager.NETWORK_PROVIDER)) {
if (!runCatching { manager.isProviderEnabled(provider) }.getOrDefault(false)) continue
runCatching {
manager.requestLocationUpdates(provider, 0L, 0f, listener, Looper.getMainLooper())
requested = true
}
}
if (!requested) finished.countDown()
}
finished.await(timeoutMillis.coerceAtLeast(0), TimeUnit.MILLISECONDS)
main.post { runCatching { manager.removeUpdates(listener) } }
val location = best.get() ?: return null
return "[${location.latitude},${location.longitude},${location.accuracy}]"
}
}

File diff suppressed because one or more lines are too long

View file

@ -31,6 +31,7 @@
D3A100032F70000100D1A002 /* NativeImageCodec.swift in Sources */ = {isa = PBXBuildFile; fileRef = D3A100042F70000100D1A002 /* NativeImageCodec.swift */; };
D3A100052F70000100D1A003 /* NativeVideoCodec.swift in Sources */ = {isa = PBXBuildFile; fileRef = D3A100062F70000100D1A003 /* NativeVideoCodec.swift */; };
D3A100072F70000100D1A004 /* NativeGallery.swift in Sources */ = {isa = PBXBuildFile; fileRef = D3A100082F70000100D1A004 /* NativeGallery.swift */; };
D3A100092F70000100D1A005 /* NativeLocation.swift in Sources */ = {isa = PBXBuildFile; fileRef = D3A1000A2F70000100D1A005 /* NativeLocation.swift */; };
D3A1000B2F70000100D1A0FE /* WebxdcHost.swift in Sources */ = {isa = PBXBuildFile; fileRef = D3A1000C2F70000100D1A0FE /* WebxdcHost.swift */; };
F3C66D726A2EB28484DF0B10 /* GoogleService-Info.plist in Resources */ = {isa = PBXBuildFile; fileRef = 16FBC6F5B58E1C6646F5D447 /* GoogleService-Info.plist */; };
/* End PBXBuildFile section */
@ -135,6 +136,7 @@
D3A100042F70000100D1A002 /* NativeImageCodec.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NativeImageCodec.swift; sourceTree = "<group>"; };
D3A100062F70000100D1A003 /* NativeVideoCodec.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NativeVideoCodec.swift; sourceTree = "<group>"; };
D3A100082F70000100D1A004 /* NativeGallery.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NativeGallery.swift; sourceTree = "<group>"; };
D3A1000A2F70000100D1A005 /* NativeLocation.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NativeLocation.swift; sourceTree = "<group>"; };
D3A1000C2F70000100D1A0FE /* WebxdcHost.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WebxdcHost.swift; sourceTree = "<group>"; };
DC1EE71614E1B4F84D6FDC2D /* Pods_RunnerTests.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_RunnerTests.framework; sourceTree = BUILT_PRODUCTS_DIR; };
E190E82D9973B318A389650B /* Pods_ShareExtension.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_ShareExtension.framework; sourceTree = BUILT_PRODUCTS_DIR; };
@ -305,6 +307,7 @@
D3A100042F70000100D1A002 /* NativeImageCodec.swift */,
D3A100062F70000100D1A003 /* NativeVideoCodec.swift */,
D3A100082F70000100D1A004 /* NativeGallery.swift */,
D3A1000A2F70000100D1A005 /* NativeLocation.swift */,
D3A1000C2F70000100D1A0FE /* WebxdcHost.swift */,
D24E27CC2F38ABC10055D9D1 /* RunnerRelease.entitlements */,
D25D4D802EFF437F0029F805 /* RunnerDebug.entitlements */,
@ -739,6 +742,7 @@
D3A100032F70000100D1A002 /* NativeImageCodec.swift in Sources */,
D3A100052F70000100D1A003 /* NativeVideoCodec.swift in Sources */,
D3A100072F70000100D1A004 /* NativeGallery.swift in Sources */,
D3A100092F70000100D1A005 /* NativeLocation.swift in Sources */,
D3A1000B2F70000100D1A0FE /* WebxdcHost.swift in Sources */,
D4A100022F81000100A10002 /* TwonlyWidgetShared.swift in Sources */,
74858FAF1ED2DC5600515810 /* AppDelegate.swift in Sources */,

View file

@ -14,8 +14,8 @@
"kind" : "remoteSourceControl",
"location" : "https://github.com/google/app-check.git",
"state" : {
"revision" : "61b85103a1aeed8218f17c794687781505fbbef5",
"version" : "11.2.0"
"revision" : "3e33dd27dd4c69bd81c7c81fe61d8ccf58846902",
"version" : "11.3.1"
}
},
{
@ -23,17 +23,8 @@
"kind" : "remoteSourceControl",
"location" : "https://github.com/firebase/firebase-ios-sdk",
"state" : {
"revision" : "8d5b4189f1f482df8d5c58c9985ea70491ef5382",
"version" : "12.14.0"
}
},
{
"identity" : "flutterfire",
"kind" : "remoteSourceControl",
"location" : "https://github.com/firebase/flutterfire",
"state" : {
"revision" : "a10a4148e769fadb01b1ff8d6bb76e9137f35b81",
"version" : "4.6.0-firebase-core-swift"
"revision" : "346daa9f46316aa372b35b317e18224acc2e9063",
"version" : "12.18.0"
}
},
{
@ -41,8 +32,8 @@
"kind" : "remoteSourceControl",
"location" : "https://github.com/googleads/google-ads-on-device-conversion-ios-sdk",
"state" : {
"revision" : "9bfcc6cf435b2e7c5562c1900b8680c594fa9a64",
"version" : "3.6.0"
"revision" : "dc39082d8881109d35b94b1c122164c0e8d08a55",
"version" : "3.6.1"
}
},
{
@ -50,8 +41,8 @@
"kind" : "remoteSourceControl",
"location" : "https://github.com/google/GoogleAppMeasurement.git",
"state" : {
"revision" : "219e564a8510e983e675c94f77f7f7c50049f22d",
"version" : "12.14.0"
"revision" : "f04760d460296cc0fa430935a7be212e5bd67fc5",
"version" : "12.18.0"
}
},
{
@ -59,8 +50,8 @@
"kind" : "remoteSourceControl",
"location" : "https://github.com/google/GoogleDataTransport.git",
"state" : {
"revision" : "617af071af9aa1d6a091d59a202910ac482128f9",
"version" : "10.1.0"
"revision" : "ba3358d3c3dbae8ef230b58a46b97ad65e84e974",
"version" : "10.1.1"
}
},
{
@ -68,8 +59,8 @@
"kind" : "remoteSourceControl",
"location" : "https://github.com/google/GoogleUtilities.git",
"state" : {
"revision" : "60da361632d0de02786f709bdc0c4df340f7613e",
"version" : "8.1.0"
"revision" : "92c8f6dc3ac375d6febdfcb3db68bc3d10633db3",
"version" : "8.1.3"
}
},
{
@ -86,8 +77,8 @@
"kind" : "remoteSourceControl",
"location" : "https://github.com/google/gtm-session-fetcher.git",
"state" : {
"revision" : "c0ac7575d70050c2973ba2318bd5af47f8e8153a",
"version" : "5.3.0"
"revision" : "724a52eea6329b7e12d3ad8300d76ca9f3895fcc",
"version" : "5.3.1"
}
},
{
@ -113,8 +104,8 @@
"kind" : "remoteSourceControl",
"location" : "https://github.com/firebase/nanopb.git",
"state" : {
"revision" : "b7e1104502eca3a213b46303391ca4d3bc8ddec1",
"version" : "2.30910.0"
"revision" : "3851d94a41890dea16dc3db34caf60e585cb4163",
"version" : "2.30910.1"
}
},
{
@ -122,8 +113,8 @@
"kind" : "remoteSourceControl",
"location" : "https://github.com/google/promises.git",
"state" : {
"revision" : "540318ecedd63d883069ae7f1ed811a2df00b6ac",
"version" : "2.4.0"
"revision" : "f4a19a3c313dc2616c70bb49d29a799fb16be837",
"version" : "2.4.1"
}
},
{
@ -131,8 +122,8 @@
"kind" : "remoteSourceControl",
"location" : "https://github.com/getsentry/sentry-cocoa",
"state" : {
"revision" : "16cd512711375fa73f25ae5e373f596bdf4251ae",
"version" : "8.58.0"
"revision" : "f196cbb98e0388381d57908f2f5a9bdc385cde68",
"version" : "8.58.4"
}
}
],

View file

@ -27,15 +27,6 @@
"version" : "12.18.0"
}
},
{
"identity" : "flutterfire",
"kind" : "remoteSourceControl",
"location" : "https://github.com/firebase/flutterfire",
"state" : {
"revision" : "a10a4148e769fadb01b1ff8d6bb76e9137f35b81",
"version" : "4.6.0-firebase-core-swift"
}
},
{
"identity" : "google-ads-on-device-conversion-ios-sdk",
"kind" : "remoteSourceControl",
@ -131,8 +122,8 @@
"kind" : "remoteSourceControl",
"location" : "https://github.com/getsentry/sentry-cocoa",
"state" : {
"revision" : "16cd512711375fa73f25ae5e373f596bdf4251ae",
"version" : "8.58.0"
"revision" : "f196cbb98e0388381d57908f2f5a9bdc385cde68",
"version" : "8.58.4"
}
}
],

View file

@ -64,6 +64,7 @@ import flutter_sharing_intent
RuntimeStorageChannel.register(with: engineBridge.pluginRegistry)
NativeNotificationChannel.register(with: engineBridge.pluginRegistry)
WebxdcHostChannel.register(with: engineBridge.pluginRegistry)
NativeLocationPermission.register(with: engineBridge.pluginRegistry)
}
override func userNotificationCenter(

View file

@ -62,7 +62,12 @@
<key>NSPhotoLibraryAddUsageDescription</key>
<string>twonly will save photos or videos to your library.</string>
<key>NSLocationWhenInUseUsageDescription</key>
<string>This app does not use or store your location information.</string>
<string>If you enable location metadata, twonly uses your precise location to add it to Memories stored on this device and photos you export to your library.</string>
<key>NSLocationTemporaryUsageDescriptionDictionary</key>
<dict>
<key>MemoryLocation</key>
<string>Your precise location is needed to attach an accurate capture location to this Memory.</string>
</dict>
<key>UIApplicationSceneManifest</key>
<dict>
<key>UIApplicationSupportsMultipleScenes</key>

View file

@ -0,0 +1,164 @@
import CoreLocation
import Flutter
import Foundation
private let memoryLocationPurpose = "MemoryLocation"
/// Permission is requested only from the settings switch through this channel.
final class NativeLocationPermission: NSObject, CLLocationManagerDelegate {
private static var active: NativeLocationPermission?
private let manager = CLLocationManager()
private let completion: FlutterResult
init(completion: @escaping FlutterResult) {
self.completion = completion
super.init()
manager.delegate = self
}
static func register(with registry: FlutterPluginRegistry) {
guard let registrar = registry.registrar(forPlugin: "TwonlyLocationMetadata") else { return }
let channel = FlutterMethodChannel(
name: "eu.twonly/location_metadata",
binaryMessenger: registrar.messenger()
)
channel.setMethodCallHandler { call, result in
guard call.method == "requestPrecisePermission" else {
result(FlutterMethodNotImplemented)
return
}
DispatchQueue.main.async {
guard active == nil else {
result(false)
return
}
let request = NativeLocationPermission(completion: result)
active = request
request.begin()
}
}
}
private func begin() {
switch manager.authorizationStatus {
case .notDetermined:
manager.requestWhenInUseAuthorization()
case .authorizedAlways, .authorizedWhenInUse:
ensureFullAccuracy()
default:
finish(false)
}
}
func locationManagerDidChangeAuthorization(_ manager: CLLocationManager) {
switch manager.authorizationStatus {
case .authorizedAlways, .authorizedWhenInUse: ensureFullAccuracy()
case .denied, .restricted: finish(false)
default: break
}
}
private func ensureFullAccuracy() {
guard manager.accuracyAuthorization != .fullAccuracy else {
finish(true)
return
}
manager.requestTemporaryFullAccuracyAuthorization(withPurposeKey: memoryLocationPurpose) {
[weak self] _ in
guard let self else { return }
self.finish(self.manager.accuracyAuthorization == .fullAccuracy)
}
}
private func finish(_ granted: Bool) {
guard Self.active === self else { return }
Self.active = nil
completion(granted)
}
}
private final class NativeLocationRequest: NSObject, CLLocationManagerDelegate {
private let manager = CLLocationManager()
private let completion: (CLLocation?) -> Void
private(set) var best: CLLocation?
private var finished = false
init(completion: @escaping (CLLocation?) -> Void) {
self.completion = completion
super.init()
manager.delegate = self
manager.desiredAccuracy = kCLLocationAccuracyBest
manager.distanceFilter = kCLDistanceFilterNone
}
func start() {
guard CLLocationManager.locationServicesEnabled(),
[.authorizedAlways, .authorizedWhenInUse].contains(manager.authorizationStatus),
manager.accuracyAuthorization == .fullAccuracy
else {
stop(returning: nil)
return
}
manager.startUpdatingLocation()
}
func locationManager(_ manager: CLLocationManager, didUpdateLocations locations: [CLLocation]) {
for location in locations where location.horizontalAccuracy >= 0 {
if best == nil || location.horizontalAccuracy < best!.horizontalAccuracy {
best = location
}
}
if let best, best.horizontalAccuracy <= 50 {
stop(returning: best)
}
}
func locationManager(_ manager: CLLocationManager, didFailWithError error: Error) {
if (error as? CLError)?.code != .locationUnknown {
stop(returning: best)
}
}
func stop(returning location: CLLocation?) {
guard !finished else { return }
finished = true
manager.stopUpdatingLocation()
completion(location)
}
}
private var activeNativeLocationRequest: NativeLocationRequest?
/// Synchronous C ABI used from Rust's blocking location worker.
@_cdecl("twonly_current_location")
func twonlyCurrentLocation(
_ timeoutMillis: Int64,
_ latitude: UnsafeMutablePointer<Double>?,
_ longitude: UnsafeMutablePointer<Double>?,
_ accuracy: UnsafeMutablePointer<Double>?
) -> Bool {
guard let latitude, let longitude, let accuracy else { return false }
let finished = DispatchSemaphore(value: 0)
var found: CLLocation?
DispatchQueue.main.async {
let request = NativeLocationRequest { location in
found = location
activeNativeLocationRequest = nil
finished.signal()
}
activeNativeLocationRequest = request
request.start()
}
if finished.wait(timeout: .now() + .milliseconds(Int(max(0, timeoutMillis)))) == .timedOut {
DispatchQueue.main.sync {
found = activeNativeLocationRequest?.best
activeNativeLocationRequest?.stop(returning: found)
activeNativeLocationRequest = nil
}
}
guard let found else { return false }
latitude.pointee = found.coordinate.latitude
longitude.pointee = found.coordinate.longitude
accuracy.pointee = found.horizontalAccuracy
return true
}

View file

@ -471,6 +471,13 @@ class RustApi {
isDraftMedia: isDraftMedia,
);
/// Starts the optional location window for a Memory row created by the
/// local save button rather than the outgoing-media initializer.
static Future<void> initializeMemoryLocation({required String mediaId}) =>
RustLib.instance.api.crateBridgeApiRustApiInitializeMemoryLocation(
mediaId: mediaId,
);
static Future<String> insertAndSendAdditionalData({
required String groupId,
required String messageType,
@ -568,6 +575,11 @@ class RustApi {
.api
.crateBridgeApiRustApiPrerenderMedia(mediaId: mediaId);
/// Begins the optional precise-location lookup when the capture camera is
/// opened, so a later photo usually has a fix immediately available.
static Future<void> prewarmMemoryLocation() =>
RustLib.instance.api.crateBridgeApiRustApiPrewarmMemoryLocation();
/// Deletes temporary media whose messages are finished with it.
static Future<void> purgeMediaTempFolder() =>
RustLib.instance.api.crateBridgeApiRustApiPurgeMediaTempFolder();

File diff suppressed because it is too large Load diff

View file

@ -131,6 +131,10 @@ class UserConfig {
List<String>? preSelectedEmojies;
Map<String, List<String>>? autoDownloadOptions;
bool storeMediaFilesInGallery;
/// Adds the capture location to locally stored Memories and gallery
/// exports. Disabled by default and never added to outgoing media.
bool storeLocationInMemories;
bool autoStoreAllSendUnlimitedMediaFiles;
bool typingIndicators;
bool showRestoreFlame;
@ -202,6 +206,7 @@ class UserConfig {
this.preSelectedEmojies,
this.autoDownloadOptions,
required this.storeMediaFilesInGallery,
required this.storeLocationInMemories,
required this.autoStoreAllSendUnlimitedMediaFiles,
required this.typingIndicators,
required this.showRestoreFlame,
@ -267,6 +272,7 @@ class UserConfig {
preSelectedEmojies.hashCode ^
autoDownloadOptions.hashCode ^
storeMediaFilesInGallery.hashCode ^
storeLocationInMemories.hashCode ^
autoStoreAllSendUnlimitedMediaFiles.hashCode ^
typingIndicators.hashCode ^
showRestoreFlame.hashCode ^
@ -336,6 +342,7 @@ class UserConfig {
preSelectedEmojies == other.preSelectedEmojies &&
autoDownloadOptions == other.autoDownloadOptions &&
storeMediaFilesInGallery == other.storeMediaFilesInGallery &&
storeLocationInMemories == other.storeLocationInMemories &&
autoStoreAllSendUnlimitedMediaFiles ==
other.autoStoreAllSendUnlimitedMediaFiles &&
typingIndicators == other.typingIndicators &&

View file

@ -91,6 +91,14 @@ class MediaFiles extends Table {
IntColumn get sizeInBytes => integer().nullable()();
RealColumn get locationLatitude => real().nullable()();
RealColumn get locationLongitude => real().nullable()();
RealColumn get locationAccuracy => real().nullable()();
TextColumn get locationStatus => text().nullable()();
IntColumn get locationDeadlineAt => integer().nullable()();
BoolColumn get galleryExportPending =>
boolean().withDefault(const Constant(false))();
DateTimeColumn get createdAt => dateTime().withDefault(currentDateAndTime)();
TextColumn get createdAtMonth => text().nullable()();

View file

@ -3536,6 +3536,75 @@ class $MediaFilesTable extends MediaFiles
type: DriftSqlType.int,
requiredDuringInsert: false,
);
static const VerificationMeta _locationLatitudeMeta = const VerificationMeta(
'locationLatitude',
);
@override
late final GeneratedColumn<double> locationLatitude = GeneratedColumn<double>(
'location_latitude',
aliasedName,
true,
type: DriftSqlType.double,
requiredDuringInsert: false,
);
static const VerificationMeta _locationLongitudeMeta = const VerificationMeta(
'locationLongitude',
);
@override
late final GeneratedColumn<double> locationLongitude =
GeneratedColumn<double>(
'location_longitude',
aliasedName,
true,
type: DriftSqlType.double,
requiredDuringInsert: false,
);
static const VerificationMeta _locationAccuracyMeta = const VerificationMeta(
'locationAccuracy',
);
@override
late final GeneratedColumn<double> locationAccuracy = GeneratedColumn<double>(
'location_accuracy',
aliasedName,
true,
type: DriftSqlType.double,
requiredDuringInsert: false,
);
static const VerificationMeta _locationStatusMeta = const VerificationMeta(
'locationStatus',
);
@override
late final GeneratedColumn<String> locationStatus = GeneratedColumn<String>(
'location_status',
aliasedName,
true,
type: DriftSqlType.string,
requiredDuringInsert: false,
);
static const VerificationMeta _locationDeadlineAtMeta =
const VerificationMeta('locationDeadlineAt');
@override
late final GeneratedColumn<int> locationDeadlineAt = GeneratedColumn<int>(
'location_deadline_at',
aliasedName,
true,
type: DriftSqlType.int,
requiredDuringInsert: false,
);
static const VerificationMeta _galleryExportPendingMeta =
const VerificationMeta('galleryExportPending');
@override
late final GeneratedColumn<bool> galleryExportPending = GeneratedColumn<bool>(
'gallery_export_pending',
aliasedName,
false,
type: DriftSqlType.bool,
requiredDuringInsert: false,
defaultConstraints: GeneratedColumn.constraintIsAlways(
'CHECK ("gallery_export_pending" IN (0, 1))',
),
defaultValue: const Constant(false),
);
static const VerificationMeta _createdAtMeta = const VerificationMeta(
'createdAt',
);
@ -3586,6 +3655,12 @@ class $MediaFilesTable extends MediaFiles
storedFileHash,
hasThumbnail,
sizeInBytes,
locationLatitude,
locationLongitude,
locationAccuracy,
locationStatus,
locationDeadlineAt,
galleryExportPending,
createdAt,
createdAtMonth,
];
@ -3768,6 +3843,60 @@ class $MediaFilesTable extends MediaFiles
),
);
}
if (data.containsKey('location_latitude')) {
context.handle(
_locationLatitudeMeta,
locationLatitude.isAcceptableOrUnknown(
data['location_latitude']!,
_locationLatitudeMeta,
),
);
}
if (data.containsKey('location_longitude')) {
context.handle(
_locationLongitudeMeta,
locationLongitude.isAcceptableOrUnknown(
data['location_longitude']!,
_locationLongitudeMeta,
),
);
}
if (data.containsKey('location_accuracy')) {
context.handle(
_locationAccuracyMeta,
locationAccuracy.isAcceptableOrUnknown(
data['location_accuracy']!,
_locationAccuracyMeta,
),
);
}
if (data.containsKey('location_status')) {
context.handle(
_locationStatusMeta,
locationStatus.isAcceptableOrUnknown(
data['location_status']!,
_locationStatusMeta,
),
);
}
if (data.containsKey('location_deadline_at')) {
context.handle(
_locationDeadlineAtMeta,
locationDeadlineAt.isAcceptableOrUnknown(
data['location_deadline_at']!,
_locationDeadlineAtMeta,
),
);
}
if (data.containsKey('gallery_export_pending')) {
context.handle(
_galleryExportPendingMeta,
galleryExportPending.isAcceptableOrUnknown(
data['gallery_export_pending']!,
_galleryExportPendingMeta,
),
);
}
if (data.containsKey('created_at')) {
context.handle(
_createdAtMeta,
@ -3903,6 +4032,30 @@ class $MediaFilesTable extends MediaFiles
DriftSqlType.int,
data['${effectivePrefix}size_in_bytes'],
),
locationLatitude: attachedDatabase.typeMapping.read(
DriftSqlType.double,
data['${effectivePrefix}location_latitude'],
),
locationLongitude: attachedDatabase.typeMapping.read(
DriftSqlType.double,
data['${effectivePrefix}location_longitude'],
),
locationAccuracy: attachedDatabase.typeMapping.read(
DriftSqlType.double,
data['${effectivePrefix}location_accuracy'],
),
locationStatus: attachedDatabase.typeMapping.read(
DriftSqlType.string,
data['${effectivePrefix}location_status'],
),
locationDeadlineAt: attachedDatabase.typeMapping.read(
DriftSqlType.int,
data['${effectivePrefix}location_deadline_at'],
),
galleryExportPending: attachedDatabase.typeMapping.read(
DriftSqlType.bool,
data['${effectivePrefix}gallery_export_pending'],
)!,
createdAt: attachedDatabase.typeMapping.read(
DriftSqlType.dateTime,
data['${effectivePrefix}created_at'],
@ -3972,6 +4125,12 @@ class MediaFile extends DataClass implements Insertable<MediaFile> {
final Uint8List? storedFileHash;
final bool hasThumbnail;
final int? sizeInBytes;
final double? locationLatitude;
final double? locationLongitude;
final double? locationAccuracy;
final String? locationStatus;
final int? locationDeadlineAt;
final bool galleryExportPending;
final DateTime createdAt;
final String? createdAtMonth;
const MediaFile({
@ -4000,6 +4159,12 @@ class MediaFile extends DataClass implements Insertable<MediaFile> {
this.storedFileHash,
required this.hasThumbnail,
this.sizeInBytes,
this.locationLatitude,
this.locationLongitude,
this.locationAccuracy,
this.locationStatus,
this.locationDeadlineAt,
required this.galleryExportPending,
required this.createdAt,
this.createdAtMonth,
});
@ -4079,6 +4244,22 @@ class MediaFile extends DataClass implements Insertable<MediaFile> {
if (!nullToAbsent || sizeInBytes != null) {
map['size_in_bytes'] = Variable<int>(sizeInBytes);
}
if (!nullToAbsent || locationLatitude != null) {
map['location_latitude'] = Variable<double>(locationLatitude);
}
if (!nullToAbsent || locationLongitude != null) {
map['location_longitude'] = Variable<double>(locationLongitude);
}
if (!nullToAbsent || locationAccuracy != null) {
map['location_accuracy'] = Variable<double>(locationAccuracy);
}
if (!nullToAbsent || locationStatus != null) {
map['location_status'] = Variable<String>(locationStatus);
}
if (!nullToAbsent || locationDeadlineAt != null) {
map['location_deadline_at'] = Variable<int>(locationDeadlineAt);
}
map['gallery_export_pending'] = Variable<bool>(galleryExportPending);
map['created_at'] = Variable<DateTime>(createdAt);
if (!nullToAbsent || createdAtMonth != null) {
map['created_at_month'] = Variable<String>(createdAtMonth);
@ -4144,6 +4325,22 @@ class MediaFile extends DataClass implements Insertable<MediaFile> {
sizeInBytes: sizeInBytes == null && nullToAbsent
? const Value.absent()
: Value(sizeInBytes),
locationLatitude: locationLatitude == null && nullToAbsent
? const Value.absent()
: Value(locationLatitude),
locationLongitude: locationLongitude == null && nullToAbsent
? const Value.absent()
: Value(locationLongitude),
locationAccuracy: locationAccuracy == null && nullToAbsent
? const Value.absent()
: Value(locationAccuracy),
locationStatus: locationStatus == null && nullToAbsent
? const Value.absent()
: Value(locationStatus),
locationDeadlineAt: locationDeadlineAt == null && nullToAbsent
? const Value.absent()
: Value(locationDeadlineAt),
galleryExportPending: Value(galleryExportPending),
createdAt: Value(createdAt),
createdAtMonth: createdAtMonth == null && nullToAbsent
? const Value.absent()
@ -4198,6 +4395,16 @@ class MediaFile extends DataClass implements Insertable<MediaFile> {
storedFileHash: serializer.fromJson<Uint8List?>(json['storedFileHash']),
hasThumbnail: serializer.fromJson<bool>(json['hasThumbnail']),
sizeInBytes: serializer.fromJson<int?>(json['sizeInBytes']),
locationLatitude: serializer.fromJson<double?>(json['locationLatitude']),
locationLongitude: serializer.fromJson<double?>(
json['locationLongitude'],
),
locationAccuracy: serializer.fromJson<double?>(json['locationAccuracy']),
locationStatus: serializer.fromJson<String?>(json['locationStatus']),
locationDeadlineAt: serializer.fromJson<int?>(json['locationDeadlineAt']),
galleryExportPending: serializer.fromJson<bool>(
json['galleryExportPending'],
),
createdAt: serializer.fromJson<DateTime>(json['createdAt']),
createdAtMonth: serializer.fromJson<String?>(json['createdAtMonth']),
);
@ -4241,6 +4448,12 @@ class MediaFile extends DataClass implements Insertable<MediaFile> {
'storedFileHash': serializer.toJson<Uint8List?>(storedFileHash),
'hasThumbnail': serializer.toJson<bool>(hasThumbnail),
'sizeInBytes': serializer.toJson<int?>(sizeInBytes),
'locationLatitude': serializer.toJson<double?>(locationLatitude),
'locationLongitude': serializer.toJson<double?>(locationLongitude),
'locationAccuracy': serializer.toJson<double?>(locationAccuracy),
'locationStatus': serializer.toJson<String?>(locationStatus),
'locationDeadlineAt': serializer.toJson<int?>(locationDeadlineAt),
'galleryExportPending': serializer.toJson<bool>(galleryExportPending),
'createdAt': serializer.toJson<DateTime>(createdAt),
'createdAtMonth': serializer.toJson<String?>(createdAtMonth),
};
@ -4272,6 +4485,12 @@ class MediaFile extends DataClass implements Insertable<MediaFile> {
Value<Uint8List?> storedFileHash = const Value.absent(),
bool? hasThumbnail,
Value<int?> sizeInBytes = const Value.absent(),
Value<double?> locationLatitude = const Value.absent(),
Value<double?> locationLongitude = const Value.absent(),
Value<double?> locationAccuracy = const Value.absent(),
Value<String?> locationStatus = const Value.absent(),
Value<int?> locationDeadlineAt = const Value.absent(),
bool? galleryExportPending,
DateTime? createdAt,
Value<String?> createdAtMonth = const Value.absent(),
}) => MediaFile(
@ -4319,6 +4538,22 @@ class MediaFile extends DataClass implements Insertable<MediaFile> {
: this.storedFileHash,
hasThumbnail: hasThumbnail ?? this.hasThumbnail,
sizeInBytes: sizeInBytes.present ? sizeInBytes.value : this.sizeInBytes,
locationLatitude: locationLatitude.present
? locationLatitude.value
: this.locationLatitude,
locationLongitude: locationLongitude.present
? locationLongitude.value
: this.locationLongitude,
locationAccuracy: locationAccuracy.present
? locationAccuracy.value
: this.locationAccuracy,
locationStatus: locationStatus.present
? locationStatus.value
: this.locationStatus,
locationDeadlineAt: locationDeadlineAt.present
? locationDeadlineAt.value
: this.locationDeadlineAt,
galleryExportPending: galleryExportPending ?? this.galleryExportPending,
createdAt: createdAt ?? this.createdAt,
createdAtMonth: createdAtMonth.present
? createdAtMonth.value
@ -4391,6 +4626,24 @@ class MediaFile extends DataClass implements Insertable<MediaFile> {
sizeInBytes: data.sizeInBytes.present
? data.sizeInBytes.value
: this.sizeInBytes,
locationLatitude: data.locationLatitude.present
? data.locationLatitude.value
: this.locationLatitude,
locationLongitude: data.locationLongitude.present
? data.locationLongitude.value
: this.locationLongitude,
locationAccuracy: data.locationAccuracy.present
? data.locationAccuracy.value
: this.locationAccuracy,
locationStatus: data.locationStatus.present
? data.locationStatus.value
: this.locationStatus,
locationDeadlineAt: data.locationDeadlineAt.present
? data.locationDeadlineAt.value
: this.locationDeadlineAt,
galleryExportPending: data.galleryExportPending.present
? data.galleryExportPending.value
: this.galleryExportPending,
createdAt: data.createdAt.present ? data.createdAt.value : this.createdAt,
createdAtMonth: data.createdAtMonth.present
? data.createdAtMonth.value
@ -4426,6 +4679,12 @@ class MediaFile extends DataClass implements Insertable<MediaFile> {
..write('storedFileHash: $storedFileHash, ')
..write('hasThumbnail: $hasThumbnail, ')
..write('sizeInBytes: $sizeInBytes, ')
..write('locationLatitude: $locationLatitude, ')
..write('locationLongitude: $locationLongitude, ')
..write('locationAccuracy: $locationAccuracy, ')
..write('locationStatus: $locationStatus, ')
..write('locationDeadlineAt: $locationDeadlineAt, ')
..write('galleryExportPending: $galleryExportPending, ')
..write('createdAt: $createdAt, ')
..write('createdAtMonth: $createdAtMonth')
..write(')'))
@ -4459,6 +4718,12 @@ class MediaFile extends DataClass implements Insertable<MediaFile> {
$driftBlobEquality.hash(storedFileHash),
hasThumbnail,
sizeInBytes,
locationLatitude,
locationLongitude,
locationAccuracy,
locationStatus,
locationDeadlineAt,
galleryExportPending,
createdAt,
createdAtMonth,
]);
@ -4497,6 +4762,12 @@ class MediaFile extends DataClass implements Insertable<MediaFile> {
) &&
other.hasThumbnail == this.hasThumbnail &&
other.sizeInBytes == this.sizeInBytes &&
other.locationLatitude == this.locationLatitude &&
other.locationLongitude == this.locationLongitude &&
other.locationAccuracy == this.locationAccuracy &&
other.locationStatus == this.locationStatus &&
other.locationDeadlineAt == this.locationDeadlineAt &&
other.galleryExportPending == this.galleryExportPending &&
other.createdAt == this.createdAt &&
other.createdAtMonth == this.createdAtMonth);
}
@ -4527,6 +4798,12 @@ class MediaFilesCompanion extends UpdateCompanion<MediaFile> {
final Value<Uint8List?> storedFileHash;
final Value<bool> hasThumbnail;
final Value<int?> sizeInBytes;
final Value<double?> locationLatitude;
final Value<double?> locationLongitude;
final Value<double?> locationAccuracy;
final Value<String?> locationStatus;
final Value<int?> locationDeadlineAt;
final Value<bool> galleryExportPending;
final Value<DateTime> createdAt;
final Value<String?> createdAtMonth;
final Value<int> rowid;
@ -4556,6 +4833,12 @@ class MediaFilesCompanion extends UpdateCompanion<MediaFile> {
this.storedFileHash = const Value.absent(),
this.hasThumbnail = const Value.absent(),
this.sizeInBytes = const Value.absent(),
this.locationLatitude = const Value.absent(),
this.locationLongitude = const Value.absent(),
this.locationAccuracy = const Value.absent(),
this.locationStatus = const Value.absent(),
this.locationDeadlineAt = const Value.absent(),
this.galleryExportPending = const Value.absent(),
this.createdAt = const Value.absent(),
this.createdAtMonth = const Value.absent(),
this.rowid = const Value.absent(),
@ -4586,6 +4869,12 @@ class MediaFilesCompanion extends UpdateCompanion<MediaFile> {
this.storedFileHash = const Value.absent(),
this.hasThumbnail = const Value.absent(),
this.sizeInBytes = const Value.absent(),
this.locationLatitude = const Value.absent(),
this.locationLongitude = const Value.absent(),
this.locationAccuracy = const Value.absent(),
this.locationStatus = const Value.absent(),
this.locationDeadlineAt = const Value.absent(),
this.galleryExportPending = const Value.absent(),
this.createdAt = const Value.absent(),
this.createdAtMonth = const Value.absent(),
this.rowid = const Value.absent(),
@ -4617,6 +4906,12 @@ class MediaFilesCompanion extends UpdateCompanion<MediaFile> {
Expression<Uint8List>? storedFileHash,
Expression<bool>? hasThumbnail,
Expression<int>? sizeInBytes,
Expression<double>? locationLatitude,
Expression<double>? locationLongitude,
Expression<double>? locationAccuracy,
Expression<String>? locationStatus,
Expression<int>? locationDeadlineAt,
Expression<bool>? galleryExportPending,
Expression<DateTime>? createdAt,
Expression<String>? createdAtMonth,
Expression<int>? rowid,
@ -4651,6 +4946,14 @@ class MediaFilesCompanion extends UpdateCompanion<MediaFile> {
if (storedFileHash != null) 'stored_file_hash': storedFileHash,
if (hasThumbnail != null) 'has_thumbnail': hasThumbnail,
if (sizeInBytes != null) 'size_in_bytes': sizeInBytes,
if (locationLatitude != null) 'location_latitude': locationLatitude,
if (locationLongitude != null) 'location_longitude': locationLongitude,
if (locationAccuracy != null) 'location_accuracy': locationAccuracy,
if (locationStatus != null) 'location_status': locationStatus,
if (locationDeadlineAt != null)
'location_deadline_at': locationDeadlineAt,
if (galleryExportPending != null)
'gallery_export_pending': galleryExportPending,
if (createdAt != null) 'created_at': createdAt,
if (createdAtMonth != null) 'created_at_month': createdAtMonth,
if (rowid != null) 'rowid': rowid,
@ -4683,6 +4986,12 @@ class MediaFilesCompanion extends UpdateCompanion<MediaFile> {
Value<Uint8List?>? storedFileHash,
Value<bool>? hasThumbnail,
Value<int?>? sizeInBytes,
Value<double?>? locationLatitude,
Value<double?>? locationLongitude,
Value<double?>? locationAccuracy,
Value<String?>? locationStatus,
Value<int?>? locationDeadlineAt,
Value<bool>? galleryExportPending,
Value<DateTime>? createdAt,
Value<String?>? createdAtMonth,
Value<int>? rowid,
@ -4716,6 +5025,12 @@ class MediaFilesCompanion extends UpdateCompanion<MediaFile> {
storedFileHash: storedFileHash ?? this.storedFileHash,
hasThumbnail: hasThumbnail ?? this.hasThumbnail,
sizeInBytes: sizeInBytes ?? this.sizeInBytes,
locationLatitude: locationLatitude ?? this.locationLatitude,
locationLongitude: locationLongitude ?? this.locationLongitude,
locationAccuracy: locationAccuracy ?? this.locationAccuracy,
locationStatus: locationStatus ?? this.locationStatus,
locationDeadlineAt: locationDeadlineAt ?? this.locationDeadlineAt,
galleryExportPending: galleryExportPending ?? this.galleryExportPending,
createdAt: createdAt ?? this.createdAt,
createdAtMonth: createdAtMonth ?? this.createdAtMonth,
rowid: rowid ?? this.rowid,
@ -4818,6 +5133,26 @@ class MediaFilesCompanion extends UpdateCompanion<MediaFile> {
if (sizeInBytes.present) {
map['size_in_bytes'] = Variable<int>(sizeInBytes.value);
}
if (locationLatitude.present) {
map['location_latitude'] = Variable<double>(locationLatitude.value);
}
if (locationLongitude.present) {
map['location_longitude'] = Variable<double>(locationLongitude.value);
}
if (locationAccuracy.present) {
map['location_accuracy'] = Variable<double>(locationAccuracy.value);
}
if (locationStatus.present) {
map['location_status'] = Variable<String>(locationStatus.value);
}
if (locationDeadlineAt.present) {
map['location_deadline_at'] = Variable<int>(locationDeadlineAt.value);
}
if (galleryExportPending.present) {
map['gallery_export_pending'] = Variable<bool>(
galleryExportPending.value,
);
}
if (createdAt.present) {
map['created_at'] = Variable<DateTime>(createdAt.value);
}
@ -4858,6 +5193,12 @@ class MediaFilesCompanion extends UpdateCompanion<MediaFile> {
..write('storedFileHash: $storedFileHash, ')
..write('hasThumbnail: $hasThumbnail, ')
..write('sizeInBytes: $sizeInBytes, ')
..write('locationLatitude: $locationLatitude, ')
..write('locationLongitude: $locationLongitude, ')
..write('locationAccuracy: $locationAccuracy, ')
..write('locationStatus: $locationStatus, ')
..write('locationDeadlineAt: $locationDeadlineAt, ')
..write('galleryExportPending: $galleryExportPending, ')
..write('createdAt: $createdAt, ')
..write('createdAtMonth: $createdAtMonth, ')
..write('rowid: $rowid')
@ -17839,6 +18180,12 @@ typedef $$MediaFilesTableCreateCompanionBuilder =
Value<Uint8List?> storedFileHash,
Value<bool> hasThumbnail,
Value<int?> sizeInBytes,
Value<double?> locationLatitude,
Value<double?> locationLongitude,
Value<double?> locationAccuracy,
Value<String?> locationStatus,
Value<int?> locationDeadlineAt,
Value<bool> galleryExportPending,
Value<DateTime> createdAt,
Value<String?> createdAtMonth,
Value<int> rowid,
@ -17870,6 +18217,12 @@ typedef $$MediaFilesTableUpdateCompanionBuilder =
Value<Uint8List?> storedFileHash,
Value<bool> hasThumbnail,
Value<int?> sizeInBytes,
Value<double?> locationLatitude,
Value<double?> locationLongitude,
Value<double?> locationAccuracy,
Value<String?> locationStatus,
Value<int?> locationDeadlineAt,
Value<bool> galleryExportPending,
Value<DateTime> createdAt,
Value<String?> createdAtMonth,
Value<int> rowid,
@ -18037,6 +18390,36 @@ class $$MediaFilesTableFilterComposer
builder: (column) => ColumnFilters(column),
);
ColumnFilters<double> get locationLatitude => $composableBuilder(
column: $table.locationLatitude,
builder: (column) => ColumnFilters(column),
);
ColumnFilters<double> get locationLongitude => $composableBuilder(
column: $table.locationLongitude,
builder: (column) => ColumnFilters(column),
);
ColumnFilters<double> get locationAccuracy => $composableBuilder(
column: $table.locationAccuracy,
builder: (column) => ColumnFilters(column),
);
ColumnFilters<String> get locationStatus => $composableBuilder(
column: $table.locationStatus,
builder: (column) => ColumnFilters(column),
);
ColumnFilters<int> get locationDeadlineAt => $composableBuilder(
column: $table.locationDeadlineAt,
builder: (column) => ColumnFilters(column),
);
ColumnFilters<bool> get galleryExportPending => $composableBuilder(
column: $table.galleryExportPending,
builder: (column) => ColumnFilters(column),
);
ColumnFilters<DateTime> get createdAt => $composableBuilder(
column: $table.createdAt,
builder: (column) => ColumnFilters(column),
@ -18207,6 +18590,36 @@ class $$MediaFilesTableOrderingComposer
builder: (column) => ColumnOrderings(column),
);
ColumnOrderings<double> get locationLatitude => $composableBuilder(
column: $table.locationLatitude,
builder: (column) => ColumnOrderings(column),
);
ColumnOrderings<double> get locationLongitude => $composableBuilder(
column: $table.locationLongitude,
builder: (column) => ColumnOrderings(column),
);
ColumnOrderings<double> get locationAccuracy => $composableBuilder(
column: $table.locationAccuracy,
builder: (column) => ColumnOrderings(column),
);
ColumnOrderings<String> get locationStatus => $composableBuilder(
column: $table.locationStatus,
builder: (column) => ColumnOrderings(column),
);
ColumnOrderings<int> get locationDeadlineAt => $composableBuilder(
column: $table.locationDeadlineAt,
builder: (column) => ColumnOrderings(column),
);
ColumnOrderings<bool> get galleryExportPending => $composableBuilder(
column: $table.galleryExportPending,
builder: (column) => ColumnOrderings(column),
);
ColumnOrderings<DateTime> get createdAt => $composableBuilder(
column: $table.createdAt,
builder: (column) => ColumnOrderings(column),
@ -18346,6 +18759,36 @@ class $$MediaFilesTableAnnotationComposer
builder: (column) => column,
);
GeneratedColumn<double> get locationLatitude => $composableBuilder(
column: $table.locationLatitude,
builder: (column) => column,
);
GeneratedColumn<double> get locationLongitude => $composableBuilder(
column: $table.locationLongitude,
builder: (column) => column,
);
GeneratedColumn<double> get locationAccuracy => $composableBuilder(
column: $table.locationAccuracy,
builder: (column) => column,
);
GeneratedColumn<String> get locationStatus => $composableBuilder(
column: $table.locationStatus,
builder: (column) => column,
);
GeneratedColumn<int> get locationDeadlineAt => $composableBuilder(
column: $table.locationDeadlineAt,
builder: (column) => column,
);
GeneratedColumn<bool> get galleryExportPending => $composableBuilder(
column: $table.galleryExportPending,
builder: (column) => column,
);
GeneratedColumn<DateTime> get createdAt =>
$composableBuilder(column: $table.createdAt, builder: (column) => column);
@ -18433,6 +18876,12 @@ class $$MediaFilesTableTableManager
Value<Uint8List?> storedFileHash = const Value.absent(),
Value<bool> hasThumbnail = const Value.absent(),
Value<int?> sizeInBytes = const Value.absent(),
Value<double?> locationLatitude = const Value.absent(),
Value<double?> locationLongitude = const Value.absent(),
Value<double?> locationAccuracy = const Value.absent(),
Value<String?> locationStatus = const Value.absent(),
Value<int?> locationDeadlineAt = const Value.absent(),
Value<bool> galleryExportPending = const Value.absent(),
Value<DateTime> createdAt = const Value.absent(),
Value<String?> createdAtMonth = const Value.absent(),
Value<int> rowid = const Value.absent(),
@ -18462,6 +18911,12 @@ class $$MediaFilesTableTableManager
storedFileHash: storedFileHash,
hasThumbnail: hasThumbnail,
sizeInBytes: sizeInBytes,
locationLatitude: locationLatitude,
locationLongitude: locationLongitude,
locationAccuracy: locationAccuracy,
locationStatus: locationStatus,
locationDeadlineAt: locationDeadlineAt,
galleryExportPending: galleryExportPending,
createdAt: createdAt,
createdAtMonth: createdAtMonth,
rowid: rowid,
@ -18493,6 +18948,12 @@ class $$MediaFilesTableTableManager
Value<Uint8List?> storedFileHash = const Value.absent(),
Value<bool> hasThumbnail = const Value.absent(),
Value<int?> sizeInBytes = const Value.absent(),
Value<double?> locationLatitude = const Value.absent(),
Value<double?> locationLongitude = const Value.absent(),
Value<double?> locationAccuracy = const Value.absent(),
Value<String?> locationStatus = const Value.absent(),
Value<int?> locationDeadlineAt = const Value.absent(),
Value<bool> galleryExportPending = const Value.absent(),
Value<DateTime> createdAt = const Value.absent(),
Value<String?> createdAtMonth = const Value.absent(),
Value<int> rowid = const Value.absent(),
@ -18522,6 +18983,12 @@ class $$MediaFilesTableTableManager
storedFileHash: storedFileHash,
hasThumbnail: hasThumbnail,
sizeInBytes: sizeInBytes,
locationLatitude: locationLatitude,
locationLongitude: locationLongitude,
locationAccuracy: locationAccuracy,
locationStatus: locationStatus,
locationDeadlineAt: locationDeadlineAt,
galleryExportPending: galleryExportPending,
createdAt: createdAt,
createdAtMonth: createdAtMonth,
rowid: rowid,

View file

@ -521,9 +521,27 @@ abstract class AppLocalizations {
/// No description provided for @settingsStorageData.
///
/// In en, this message translates to:
/// **'Data and storage'**
/// **'Memories & Storage'**
String get settingsStorageData;
/// No description provided for @memoryLocationTitle.
///
/// In en, this message translates to:
/// **'Store location in Memories'**
String get memoryLocationTitle;
/// No description provided for @memoryLocationSubtitle.
///
/// In en, this message translates to:
/// **'Location for Memories and gallery exports. Never sent to contacts.'**
String get memoryLocationSubtitle;
/// No description provided for @memoryLocationPermissionDenied.
///
/// In en, this message translates to:
/// **'Precise location access was not granted. The setting remains disabled.'**
String get memoryLocationPermissionDenied;
/// No description provided for @settingsStorageDataStoreInGTitle.
///
/// In en, this message translates to:

View file

@ -231,7 +231,18 @@ class AppLocalizationsDe extends AppLocalizations {
String get settingsProfile => 'Profil';
@override
String get settingsStorageData => 'Daten und Speicher';
String get settingsStorageData => 'Memories & Speicher';
@override
String get memoryLocationTitle => 'Standort in Memories speichern';
@override
String get memoryLocationSubtitle =>
'Standort für Memories und Galerie-Exporte. Wird nicht an Kontakte gesendet.';
@override
String get memoryLocationPermissionDenied =>
'Der genaue Standortzugriff wurde nicht erlaubt. Die Einstellung bleibt deaktiviert.';
@override
String get settingsStorageDataStoreInGTitle => 'In der Galerie speichern';

View file

@ -229,7 +229,18 @@ class AppLocalizationsEn extends AppLocalizations {
String get settingsProfile => 'Profile';
@override
String get settingsStorageData => 'Data and storage';
String get settingsStorageData => 'Memories & Storage';
@override
String get memoryLocationTitle => 'Store location in Memories';
@override
String get memoryLocationSubtitle =>
'Location for Memories and gallery exports. Never sent to contacts.';
@override
String get memoryLocationPermissionDenied =>
'Precise location access was not granted. The setting remains disabled.';
@override
String get settingsStorageDataStoreInGTitle => 'Store in Gallery';

View file

@ -74,7 +74,10 @@
"imageEditorDrawOk": "Zeichnung machen",
"settingsTitle": "Einstellungen",
"settingsChats": "Chats",
"settingsStorageData": "Daten und Speicher",
"settingsStorageData": "Memories & Speicher",
"memoryLocationTitle": "Standort in Memories speichern",
"memoryLocationSubtitle": "Standort für Memories und Galerie-Exporte. Wird nicht an Kontakte gesendet.",
"memoryLocationPermissionDenied": "Der genaue Standortzugriff wurde nicht erlaubt. Die Einstellung bleibt deaktiviert.",
"settingsStorageDataStoreInGTitle": "In der Galerie speichern",
"settingsStorageDataMediaAutoDownload": "Automatischer Mediendownload",
"settingsStorageDataAutoDownMobile": "Bei Nutzung mobiler Daten",

View file

@ -70,7 +70,10 @@
"settingsAutomaticallyMarkEqualMediaFilesAsOpenedSubtitle": "If you receive the same media in multiple chats, opening one marks all others as opened.",
"settingsPreSelectedReactionsError": "A maximum of 12 reactions can be selected.",
"settingsProfile": "Profile",
"settingsStorageData": "Data and storage",
"settingsStorageData": "Memories & Storage",
"memoryLocationTitle": "Store location in Memories",
"memoryLocationSubtitle": "Location for Memories and gallery exports. Never sent to contacts.",
"memoryLocationPermissionDenied": "Precise location access was not granted. The setting remains disabled.",
"settingsStorageDataStoreInGTitle": "Store in Gallery",
"settingsStorageDataMediaAutoDownload": "Media auto-download",
"settingsStorageDataAutoDownMobile": "When using mobile data",

View file

@ -108,11 +108,14 @@ class PurchasesProvider with ChangeNotifier, DiagnosticableTreeMixin {
isPayingUser(
planFromString(userService.currentUser.subscriptionPlan),
)) {
globalForceIpaCheck = Timer(const Duration(seconds: 5), () async {
globalForceIpaCheck = Timer(const Duration(seconds: 5), () {
Log.info(
'Force Ipa check was not stopped. Requesting forced check...',
);
await RustApi.forceIpaCheck();
unawaitedRustCall(
RustApi.forceIpaCheck(),
'Forced IAP check',
);
});
}

View file

@ -0,0 +1,13 @@
import 'package:flutter/services.dart';
class LocationMetadataService {
static const _channel = MethodChannel('eu.twonly/location_metadata');
/// Opens the native permission sheet only after the user enables the switch.
/// Approximate/reduced access is rejected because this feature promises a
/// precise capture location.
static Future<bool> requestPrecisePermission() async {
return await _channel.invokeMethod<bool>('requestPrecisePermission') ??
false;
}
}

View file

@ -384,6 +384,7 @@ class MemoriesService {
final mediaFile = item.mediaService.mediaFile;
if (!mediaFile.hasThumbnail &&
mediaFile.type != MediaType.audio &&
item.mediaService.storedPath.existsSync() &&
_thumbnailRequests.add(mediaFile.mediaId)) {
unawaited(item.mediaService.createThumbnail());
}

View file

@ -169,6 +169,7 @@ class _CameraPreviewViewState extends State<CameraPreviewView> {
initVolumeControl();
initAsync();
_checkAndInitCamera();
_prewarmMemoryLocation();
}
@override
@ -178,12 +179,24 @@ class _CameraPreviewViewState extends State<CameraPreviewView> {
if (widget.isVisible) {
initVolumeControl();
_checkAndInitCamera();
_prewarmMemoryLocation();
} else {
_deInitVolumeControl();
}
}
}
void _prewarmMemoryLocation() {
// QR scanning deliberately does not touch location. A normal camera view
// has controllers and can create a Memory, so start looking as soon as it
// becomes visible instead of waiting for the shutter.
if (!widget.isVisible || widget.hideControllers) return;
unawaitedRustCall(
RustApi.prewarmMemoryLocation(),
'prewarmMemoryLocation',
);
}
void _checkAndInitCamera() {
if (widget.isVisible &&
mc.cameraController == null &&

View file

@ -52,13 +52,19 @@ class SaveToGalleryButtonState extends State<SaveToGalleryButton> {
MediaFilesCompanion(
type: Value(widget.mediaService.mediaFile.type),
createdAt: Value(clock.now()),
stored: const Value(true),
// Rust publishes the Memory as stored only after the
// plaintext and its thumbnail have been written.
stored: const Value(false),
),
);
if (newMediaFile != null) {
final newService = MediaFileService(newMediaFile);
await RustApi.initializeMemoryLocation(
mediaId: newMediaFile.mediaId,
);
if (widget.mediaService.tempPath.existsSync()) {
widget.mediaService.tempPath.copySync(
newService.tempPath.path,

View file

@ -142,8 +142,13 @@ class _MemoriesThumbnailCompState extends State<MemoriesThumbnailComp> {
@override
void didUpdateWidget(covariant MemoriesThumbnailComp oldWidget) {
super.didUpdateWidget(oldWidget);
if (oldWidget.galleryItem.mediaService.mediaFile.mediaId !=
widget.galleryItem.mediaService.mediaFile.mediaId) {
final oldMedia = oldWidget.galleryItem.mediaService.mediaFile;
final media = widget.galleryItem.mediaService.mediaFile;
final changedMedia = oldMedia.mediaId != media.mediaId;
final sourceBecameAvailable =
!oldMedia.stored && media.stored ||
!oldMedia.hasThumbnail && media.hasThumbnail;
if (changedMedia || sourceBecameAvailable) {
_imageStream?.removeListener(_listener);
_imageStream = null;
_imageProvider = null;

View file

@ -5,8 +5,10 @@ import 'package:go_router/go_router.dart';
import 'package:twonly/locator.dart';
import 'package:twonly/src/constants/routes.keys.dart';
import 'package:twonly/src/database/tables/mediafiles.table.dart';
import 'package:twonly/src/services/location_metadata.service.dart';
import 'package:twonly/src/services/mediafiles/media_download_policy.dart';
import 'package:twonly/src/services/user.service.dart';
import 'package:twonly/src/utils/log.dart';
import 'package:twonly/src/utils/misc.dart';
class DataAndStorageView extends StatefulWidget {
@ -18,6 +20,7 @@ class DataAndStorageView extends StatefulWidget {
class _DataAndStorageViewState extends State<DataAndStorageView> {
late Future<Map<MediaType, int>> _storageStatsFuture;
bool _isUpdatingLocationSetting = false;
@override
void initState() {
@ -86,6 +89,46 @@ class _DataAndStorageViewState extends State<DataAndStorageView> {
});
}
Future<void> toggleLocationInMemories() async {
if (_isUpdatingLocationSetting) return;
setState(() => _isUpdatingLocationSetting = true);
final currentlyEnabled = userService.currentUser.storeLocationInMemories;
try {
if (!currentlyEnabled) {
final granted =
await LocationMetadataService.requestPrecisePermission();
if (!granted) {
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(context.lang.memoryLocationPermissionDenied),
),
);
}
return;
}
}
await UserService.update((user) {
user.storeLocationInMemories = !currentlyEnabled;
});
} catch (error, stackTrace) {
Log.error(
'Could not update the Memory location setting',
error: error,
stackTrace: stackTrace,
);
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(context.lang.memoryLocationPermissionDenied),
),
);
}
} finally {
if (mounted) setState(() => _isUpdatingLocationSetting = false);
}
}
@override
Widget build(BuildContext context) {
return Scaffold(
@ -124,6 +167,22 @@ class _DataAndStorageViewState extends State<DataAndStorageView> {
onChanged: (a) => toggleStoreInGallery(),
),
),
ListTile(
title: Text(context.lang.memoryLocationTitle),
subtitle: Text(
context.lang.memoryLocationSubtitle,
style: const TextStyle(fontSize: 12),
),
onTap: _isUpdatingLocationSetting
? null
: toggleLocationInMemories,
trailing: Switch.adaptive(
value: userService.currentUser.storeLocationInMemories,
onChanged: _isUpdatingLocationSetting
? null
: (_) => toggleLocationInMemories(),
),
),
ListTile(
title: Text(context.lang.autoStoreAllSendUnlimitedMediaFiles),
subtitle: Text(

View file

@ -225,6 +225,7 @@ class _DeveloperSettingsViewState extends State<DeveloperSettingsView> {
isFavorite: false,
hasCropAnalyzed: false,
hasThumbnail: false,
galleryExportPending: false,
cloudState: CloudState.none,
createdAt: now,
);

File diff suppressed because it is too large Load diff

View file

@ -71,7 +71,9 @@ dependencies:
permission_handler: ^12.0.0+1 # 2 mio
# Not yet checked
cryptography_flutter_plus: ^3.0.0
# 3.1.0 has a broken AGP 9 build script: it skips the Kotlin plugin but still
# configures the missing `kotlin` extension.
cryptography_flutter_plus: 3.0.0
cryptography_plus: ^3.0.0
flutter_android_volume_keydown: ^1.0.1
flutter_volume_controller: ^1.3.4

View file

@ -209,6 +209,20 @@ impl RustApi {
.await
}
/// Starts the optional location window for a Memory row created by the
/// local save button rather than the outgoing-media initializer.
pub async fn initialize_memory_location(media_id: String) -> Result<()> {
let ctx = Context::get_static()?;
crate::services::location_metadata::initialize_for_media(ctx, &media_id).await
}
/// Begins the optional precise-location lookup when the capture camera is
/// opened, so a later photo usually has a fix immediately available.
pub async fn prewarm_memory_location() -> Result<()> {
let ctx = Context::get_static()?;
crate::services::location_metadata::prewarm(ctx)
}
/// Creates one outgoing message per selected group and starts the upload.
pub async fn send_media_to_groups(
media_id: String,

View file

@ -51,12 +51,17 @@ impl UserConfigApi {
pub async fn update(base: UserConfig, config: UserConfig) -> Result<UserConfig> {
let ctx = Context::get_static()?;
let location_was_disabled =
base.store_location_in_memories && !config.store_location_in_memories;
let normalized = UserConfig::update_json(
ctx,
&serde_json::to_string(&base)?,
&serde_json::to_string(&config)?,
)?;
let config: UserConfig = serde_json::from_str(&normalized)?;
if location_was_disabled {
crate::services::location_metadata::disable_pending(ctx).await?;
}
if let Ok(callbacks) = crate::bridge::callbacks::get_callbacks() {
(callbacks.api.user_config_changed)(config.clone()).await;
}

View file

@ -0,0 +1,16 @@
-- Optional capture location for a locally stored Memory. Coordinates never
-- leave the device through the messaging path; they are only written to a
-- gallery export after the user explicitly enables the feature.
ALTER TABLE media_files ADD COLUMN location_latitude REAL;
ALTER TABLE media_files ADD COLUMN location_longitude REAL;
ALTER TABLE media_files ADD COLUMN location_accuracy REAL;
ALTER TABLE media_files ADD COLUMN location_status TEXT
CHECK (location_status IN ('pending', 'resolved', 'timedOut'));
ALTER TABLE media_files ADD COLUMN location_deadline_at INTEGER;
ALTER TABLE media_files ADD COLUMN gallery_export_pending INTEGER NOT NULL DEFAULT 0
CHECK (gallery_export_pending IN (0, 1));
CREATE INDEX idx_media_files_pending_location
ON media_files(location_status, location_deadline_at)
WHERE location_status = 'pending';

View file

@ -16,7 +16,7 @@ mod legacy_import;
pub mod tables;
pub const APP_DATABASE_FILE: &str = "app_db.sqlite";
pub const APP_SCHEMA_VERSION: i64 = 8;
pub const APP_SCHEMA_VERSION: i64 = 9;
/// User-owned application tables in the current Rust schema. Rust-only outbox
/// tables are deliberately absent because they are reconstructed locally.

View file

@ -38,7 +38,7 @@ flutter_rust_bridge::frb_generated_boilerplate!(
default_rust_auto_opaque = RustAutoOpaqueMoi,
);
pub(crate) const FLUTTER_RUST_BRIDGE_CODEGEN_VERSION: &str = "2.12.0";
pub(crate) const FLUTTER_RUST_BRIDGE_CODEGEN_CONTENT_HASH: i32 = -1591152700;
pub(crate) const FLUTTER_RUST_BRIDGE_CODEGEN_CONTENT_HASH: i32 = 101877534;
// Section: executor
@ -2568,6 +2568,44 @@ fn wire__crate__bridge__api__rust_api_initialize_media_upload_impl(
},
)
}
fn wire__crate__bridge__api__rust_api_initialize_memory_location_impl(
port_: flutter_rust_bridge::for_generated::MessagePort,
ptr_: flutter_rust_bridge::for_generated::PlatformGeneralizedUint8ListPtr,
rust_vec_len_: i32,
data_len_: i32,
) {
FLUTTER_RUST_BRIDGE_HANDLER.wrap_async::<flutter_rust_bridge::for_generated::SseCodec, _, _, _>(
flutter_rust_bridge::for_generated::TaskInfo {
debug_name: "rust_api_initialize_memory_location",
port: Some(port_),
mode: flutter_rust_bridge::for_generated::FfiCallMode::Normal,
},
move || {
let message = unsafe {
flutter_rust_bridge::for_generated::Dart2RustMessageSse::from_wire(
ptr_,
rust_vec_len_,
data_len_,
)
};
let mut deserializer =
flutter_rust_bridge::for_generated::SseDeserializer::new(message);
let api_media_id = <String>::sse_decode(&mut deserializer);
deserializer.end();
move |context| async move {
transform_result_sse::<_, flutter_rust_bridge::for_generated::anyhow::Error>(
(move || async move {
let output_ok =
crate::bridge::api::RustApi::initialize_memory_location(api_media_id)
.await?;
Ok(output_ok)
})()
.await,
)
}
},
)
}
fn wire__crate__bridge__api__rust_api_insert_and_send_additional_data_impl(
port_: flutter_rust_bridge::for_generated::MessagePort,
ptr_: flutter_rust_bridge::for_generated::PlatformGeneralizedUint8ListPtr,
@ -3050,6 +3088,42 @@ fn wire__crate__bridge__api__rust_api_prerender_media_impl(
},
)
}
fn wire__crate__bridge__api__rust_api_prewarm_memory_location_impl(
port_: flutter_rust_bridge::for_generated::MessagePort,
ptr_: flutter_rust_bridge::for_generated::PlatformGeneralizedUint8ListPtr,
rust_vec_len_: i32,
data_len_: i32,
) {
FLUTTER_RUST_BRIDGE_HANDLER.wrap_async::<flutter_rust_bridge::for_generated::SseCodec, _, _, _>(
flutter_rust_bridge::for_generated::TaskInfo {
debug_name: "rust_api_prewarm_memory_location",
port: Some(port_),
mode: flutter_rust_bridge::for_generated::FfiCallMode::Normal,
},
move || {
let message = unsafe {
flutter_rust_bridge::for_generated::Dart2RustMessageSse::from_wire(
ptr_,
rust_vec_len_,
data_len_,
)
};
let mut deserializer =
flutter_rust_bridge::for_generated::SseDeserializer::new(message);
deserializer.end();
move |context| async move {
transform_result_sse::<_, flutter_rust_bridge::for_generated::anyhow::Error>(
(move || async move {
let output_ok =
crate::bridge::api::RustApi::prewarm_memory_location().await?;
Ok(output_ok)
})()
.await,
)
}
},
)
}
fn wire__crate__bridge__api__rust_api_purge_media_temp_folder_impl(
port_: flutter_rust_bridge::for_generated::MessagePort,
ptr_: flutter_rust_bridge::for_generated::PlatformGeneralizedUint8ListPtr,
@ -7320,6 +7394,7 @@ impl SseDecode for crate::user_config::UserConfig {
let mut var_autoDownloadOptions =
<Option<std::collections::HashMap<String, Vec<String>>>>::sse_decode(deserializer);
let mut var_storeMediaFilesInGallery = <bool>::sse_decode(deserializer);
let mut var_storeLocationInMemories = <bool>::sse_decode(deserializer);
let mut var_autoStoreAllSendUnlimitedMediaFiles = <bool>::sse_decode(deserializer);
let mut var_typingIndicators = <bool>::sse_decode(deserializer);
let mut var_showRestoreFlame = <bool>::sse_decode(deserializer);
@ -7387,6 +7462,7 @@ impl SseDecode for crate::user_config::UserConfig {
pre_selected_emojies: var_preSelectedEmojies,
auto_download_options: var_autoDownloadOptions,
store_media_files_in_gallery: var_storeMediaFilesInGallery,
store_location_in_memories: var_storeLocationInMemories,
auto_store_all_send_unlimited_media_files: var_autoStoreAllSendUnlimitedMediaFiles,
typing_indicators: var_typingIndicators,
show_restore_flame: var_showRestoreFlame,
@ -7617,103 +7693,105 @@ fn pde_ffi_dispatcher_primary_impl(
67 => wire__crate__bridge__api__rust_api_get_user_id_from_username_impl(port, ptr, rust_vec_len, data_len),
68 => wire__crate__bridge__api__rust_api_hand_outbox_to_os_impl(port, ptr, rust_vec_len, data_len),
69 => wire__crate__bridge__api__rust_api_initialize_media_upload_impl(port, ptr, rust_vec_len, data_len),
70 => wire__crate__bridge__api__rust_api_insert_and_send_additional_data_impl(port, ptr, rust_vec_len, data_len),
71 => wire__crate__bridge__api__rust_api_insert_and_send_ask_about_user_impl(port, ptr, rust_vec_len, data_len),
72 => wire__crate__bridge__api__rust_api_insert_and_send_contact_share_impl(port, ptr, rust_vec_len, data_len),
73 => wire__crate__bridge__api__rust_api_insert_and_send_text_impl(port, ptr, rust_vec_len, data_len),
74 => wire__crate__bridge__api__rust_api_ipa_purchase_impl(port, ptr, rust_vec_len, data_len),
75 => wire__crate__bridge__api__rust_api_load_plan_balance_impl(port, ptr, rust_vec_len, data_len),
76 => wire__crate__bridge__api__rust_api_media_size_limit_report_impl(port, ptr, rust_vec_len, data_len),
77 => wire__crate__bridge__api__rust_api_media_step_finished_impl(port, ptr, rust_vec_len, data_len),
78 => wire__crate__bridge__api__rust_api_notification_badge_count_impl(port, ptr, rust_vec_len, data_len),
79 => wire__crate__bridge__api__rust_api_notify_messages_opened_impl(port, ptr, rust_vec_len, data_len),
80 => wire__crate__bridge__api__rust_api_perform_passwordless_recovery_heartbeat_impl(port, ptr, rust_vec_len, data_len),
81 => wire__crate__bridge__api__rust_api_prerender_media_impl(port, ptr, rust_vec_len, data_len),
82 => wire__crate__bridge__api__rust_api_purge_media_temp_folder_impl(port, ptr, rust_vec_len, data_len),
83 => wire__crate__bridge__api__rust_api_purge_widget_media_impl(port, ptr, rust_vec_len, data_len),
84 => wire__crate__bridge__api__rust_api_refresh_widget_manifest_impl(port, ptr, rust_vec_len, data_len),
85 => wire__crate__bridge__api__rust_api_register_impl(port, ptr, rust_vec_len, data_len),
86 => wire__crate__bridge__api__rust_api_register_home_widget_impl(port, ptr, rust_vec_len, data_len),
87 => wire__crate__bridge__api__rust_api_register_passwordless_notification_impl(port, ptr, rust_vec_len, data_len),
88 => wire__crate__bridge__api__rust_api_register_passwordless_recovery_impl(port, ptr, rust_vec_len, data_len),
89 => wire__crate__bridge__api__rust_api_reload_configuration_impl(port, ptr, rust_vec_len, data_len),
90 => wire__crate__bridge__api__rust_api_remove_additional_user_impl(port, ptr, rust_vec_len, data_len),
91 => wire__crate__bridge__api__rust_api_remove_media_files_impl(port, ptr, rust_vec_len, data_len),
92 => wire__crate__bridge__api__rust_api_report_user_impl(port, ptr, rust_vec_len, data_len),
93 => wire__crate__bridge__api__rust_api_request_binary_impl(port, ptr, rust_vec_len, data_len),
94 => wire__crate__bridge__api__rust_api_request_contact_by_username_impl(port, ptr, rust_vec_len, data_len),
95 => wire__crate__bridge__api__rust_api_request_media_reupload_impl(port, ptr, rust_vec_len, data_len),
96 => wire__crate__bridge__api__rust_api_request_memories_upload_impl(port, ptr, rust_vec_len, data_len),
97 => wire__crate__bridge__api__rust_api_retransmit_all_messages_impl(port, ptr, rust_vec_len, data_len),
98 => wire__crate__bridge__api__rust_api_retry_pending_media_reuploads_impl(port, ptr, rust_vec_len, data_len),
99 => wire__crate__bridge__api__rust_api_reupload_pending_media_impl(port, ptr, rust_vec_len, data_len),
100 => wire__crate__bridge__api__rust_api_save_media_to_gallery_impl(port, ptr, rust_vec_len, data_len),
101 => wire__crate__bridge__api__rust_api_send_binary_impl(port, ptr, rust_vec_len, data_len),
102 => wire__crate__bridge__api__rust_api_send_contact_profile_impl(port, ptr, rust_vec_len, data_len),
103 => wire__crate__bridge__api__rust_api_send_encrypted_content_impl(port, ptr, rust_vec_len, data_len),
104 => wire__crate__bridge__api__rust_api_send_encrypted_content_to_group_impl(port, ptr, rust_vec_len, data_len),
105 => wire__crate__bridge__api__rust_api_send_media_to_groups_impl(port, ptr, rust_vec_len, data_len),
106 => wire__crate__bridge__api__rust_api_send_queued_message_impl(port, ptr, rust_vec_len, data_len),
107 => wire__crate__bridge__api__rust_api_send_text_message_impl(port, ptr, rust_vec_len, data_len),
108 => wire__crate__bridge__api__rust_api_send_typing_impl(port, ptr, rust_vec_len, data_len),
109 => wire__crate__bridge__api__rust_api_set_background_impl(port, ptr, rust_vec_len, data_len),
110 => wire__crate__bridge__api__rust_api_set_home_widget_groups_impl(port, ptr, rust_vec_len, data_len),
111 => wire__crate__bridge__api__rust_api_set_login_token_impl(port, ptr, rust_vec_len, data_len),
112 => wire__crate__bridge__api__rust_api_set_media_display_limit_impl(port, ptr, rust_vec_len, data_len),
113 => wire__crate__bridge__api__rust_api_set_media_requires_authentication_impl(port, ptr, rust_vec_len, data_len),
114 => wire__crate__bridge__api__rust_api_set_media_trim_impl(port, ptr, rust_vec_len, data_len),
115 => wire__crate__bridge__api__rust_api_set_network_available_impl(port, ptr, rust_vec_len, data_len),
116 => wire__crate__bridge__api__rust_api_store_media_impl(port, ptr, rust_vec_len, data_len),
117 => wire__crate__bridge__api__rust_api_submit_recovery_share_impl(port, ptr, rust_vec_len, data_len),
118 => wire__crate__bridge__api__rust_api_sync_widget_permissions_impl(port, ptr, rust_vec_len, data_len),
119 => wire__crate__bridge__api__rust_api_toggle_media_remove_audio_impl(port, ptr, rust_vec_len, data_len),
120 => wire__crate__bridge__api__rust_api_try_request_contact_by_id_impl(port, ptr, rust_vec_len, data_len),
121 => wire__crate__bridge__api__rust_api_unregister_home_widget_impl(port, ptr, rust_vec_len, data_len),
122 => wire__crate__bridge__api__rust_api_update_fcm_token_impl(port, ptr, rust_vec_len, data_len),
123 => wire__crate__bridge__api__rust_api_update_signed_pre_key_impl(port, ptr, rust_vec_len, data_len),
124 => wire__crate__bridge__api__rust_api_upload_pqc_pre_keys_impl(port, ptr, rust_vec_len, data_len),
125 => wire__crate__bridge__wrapper__app_database__rust_app_database_changes_impl(port, ptr, rust_vec_len, data_len),
126 => wire__crate__bridge__wrapper__app_database__rust_app_database_execute_impl(port, ptr, rust_vec_len, data_len),
127 => wire__crate__bridge__wrapper__app_database__rust_app_database_legacy_import_complete_impl(port, ptr, rust_vec_len, data_len),
128 => wire__crate__bridge__wrapper__app_database__rust_app_database_migrate_legacy_database_impl(port, ptr, rust_vec_len, data_len),
129 => wire__crate__bridge__wrapper__app_database__rust_app_database_select_impl(port, ptr, rust_vec_len, data_len),
130 => wire__crate__bridge__wrapper__backup__rust_backup_archive_create_backup_archive_impl(port, ptr, rust_vec_len, data_len),
131 => wire__crate__bridge__wrapper__backup__rust_backup_archive_get_backup_download_token_impl(port, ptr, rust_vec_len, data_len),
132 => wire__crate__bridge__wrapper__backup__rust_backup_archive_restore_backup_archive_impl(port, ptr, rust_vec_len, data_len),
133 => wire__crate__bridge__wrapper__backup__rust_backup_identity_get_backup_id_impl(port, ptr, rust_vec_len, data_len),
134 => wire__crate__bridge__wrapper__backup__rust_backup_identity_get_backup_password_keys_impl(port, ptr, rust_vec_len, data_len),
135 => wire__crate__bridge__wrapper__backup__rust_backup_identity_get_identity_backup_bytes_impl(port, ptr, rust_vec_len, data_len),
136 => wire__crate__bridge__wrapper__backup__rust_backup_identity_import_backup_password_keys_impl(port, ptr, rust_vec_len, data_len),
137 => wire__crate__bridge__wrapper__backup__rust_backup_identity_restore_identity_backup_impl(port, ptr, rust_vec_len, data_len),
138 => wire__crate__bridge__wrapper__backup__rust_backup_identity_set_backup_password_keys_impl(port, ptr, rust_vec_len, data_len),
139 => wire__crate__bridge__wrapper__key_manager__rust_key_manager_decrypt_cloud_media_key_impl(port, ptr, rust_vec_len, data_len),
140 => wire__crate__bridge__wrapper__key_manager__rust_key_manager_encrypt_cloud_media_key_impl(port, ptr, rust_vec_len, data_len),
141 => wire__crate__bridge__wrapper__key_manager__rust_key_manager_get_signal_identity_impl(port, ptr, rust_vec_len, data_len),
142 => wire__crate__bridge__wrapper__key_manager__rust_key_manager_get_user_id_impl(port, ptr, rust_vec_len, data_len),
143 => wire__crate__bridge__wrapper__key_manager__rust_key_manager_import_serialized_impl(port, ptr, rust_vec_len, data_len),
144 => wire__crate__bridge__wrapper__key_manager__rust_key_manager_import_signal_identity_impl(port, ptr, rust_vec_len, data_len),
145 => wire__crate__bridge__wrapper__key_manager__rust_key_manager_remove_local_credentials_impl(port, ptr, rust_vec_len, data_len),
146 => wire__crate__bridge__wrapper__key_manager__rust_key_manager_serialize_impl(port, ptr, rust_vec_len, data_len),
147 => wire__crate__bridge__wrapper__key_manager__rust_key_manager_set_user_id_impl(port, ptr, rust_vec_len, data_len),
148 => wire__crate__bridge__wrapper__signal__rust_signal_decrypt_impl(port, ptr, rust_vec_len, data_len),
149 => wire__crate__bridge__wrapper__signal__rust_signal_encrypt_impl(port, ptr, rust_vec_len, data_len),
150 => wire__crate__bridge__wrapper__signal__rust_signal_generate_bundle_impl(port, ptr, rust_vec_len, data_len),
151 => wire__crate__bridge__wrapper__signal__rust_signal_generate_pqc_prekeys_impl(port, ptr, rust_vec_len, data_len),
152 => wire__crate__bridge__wrapper__signal__rust_signal_get_contact_public_key_impl(port, ptr, rust_vec_len, data_len),
153 => wire__crate__bridge__wrapper__signal__rust_signal_get_user_public_key_impl(port, ptr, rust_vec_len, data_len),
154 => wire__crate__bridge__wrapper__signal__rust_signal_process_prekey_bundle_impl(port, ptr, rust_vec_len, data_len),
155 => wire__crate__bridge__wrapper__rust_utils_generate_shares_impl(port, ptr, rust_vec_len, data_len),
156 => wire__crate__bridge__wrapper__rust_utils_recover_secret_impl(port, ptr, rust_vec_len, data_len),
157 => wire__crate__bridge__webxdc__send_update_impl(port, ptr, rust_vec_len, data_len),
158 => wire__crate__bridge__webxdc__serve_impl(port, ptr, rust_vec_len, data_len),
159 => wire__crate__bridge__groups__update_chat_deletion_time_impl(port, ptr, rust_vec_len, data_len),
160 => wire__crate__bridge__groups__update_group_name_impl(port, ptr, rust_vec_len, data_len),
161 => wire__crate__bridge__webxdc__updates_after_impl(port, ptr, rust_vec_len, data_len),
163 => wire__crate__bridge__user_config__user_config_api_create_impl(port, ptr, rust_vec_len, data_len),
164 => wire__crate__bridge__user_config__user_config_api_import_json_impl(port, ptr, rust_vec_len, data_len),
165 => wire__crate__bridge__user_config__user_config_api_load_impl(port, ptr, rust_vec_len, data_len),
166 => wire__crate__bridge__user_config__user_config_api_save_impl(port, ptr, rust_vec_len, data_len),
167 => wire__crate__bridge__user_config__user_config_api_update_impl(port, ptr, rust_vec_len, data_len),
70 => wire__crate__bridge__api__rust_api_initialize_memory_location_impl(port, ptr, rust_vec_len, data_len),
71 => wire__crate__bridge__api__rust_api_insert_and_send_additional_data_impl(port, ptr, rust_vec_len, data_len),
72 => wire__crate__bridge__api__rust_api_insert_and_send_ask_about_user_impl(port, ptr, rust_vec_len, data_len),
73 => wire__crate__bridge__api__rust_api_insert_and_send_contact_share_impl(port, ptr, rust_vec_len, data_len),
74 => wire__crate__bridge__api__rust_api_insert_and_send_text_impl(port, ptr, rust_vec_len, data_len),
75 => wire__crate__bridge__api__rust_api_ipa_purchase_impl(port, ptr, rust_vec_len, data_len),
76 => wire__crate__bridge__api__rust_api_load_plan_balance_impl(port, ptr, rust_vec_len, data_len),
77 => wire__crate__bridge__api__rust_api_media_size_limit_report_impl(port, ptr, rust_vec_len, data_len),
78 => wire__crate__bridge__api__rust_api_media_step_finished_impl(port, ptr, rust_vec_len, data_len),
79 => wire__crate__bridge__api__rust_api_notification_badge_count_impl(port, ptr, rust_vec_len, data_len),
80 => wire__crate__bridge__api__rust_api_notify_messages_opened_impl(port, ptr, rust_vec_len, data_len),
81 => wire__crate__bridge__api__rust_api_perform_passwordless_recovery_heartbeat_impl(port, ptr, rust_vec_len, data_len),
82 => wire__crate__bridge__api__rust_api_prerender_media_impl(port, ptr, rust_vec_len, data_len),
83 => wire__crate__bridge__api__rust_api_prewarm_memory_location_impl(port, ptr, rust_vec_len, data_len),
84 => wire__crate__bridge__api__rust_api_purge_media_temp_folder_impl(port, ptr, rust_vec_len, data_len),
85 => wire__crate__bridge__api__rust_api_purge_widget_media_impl(port, ptr, rust_vec_len, data_len),
86 => wire__crate__bridge__api__rust_api_refresh_widget_manifest_impl(port, ptr, rust_vec_len, data_len),
87 => wire__crate__bridge__api__rust_api_register_impl(port, ptr, rust_vec_len, data_len),
88 => wire__crate__bridge__api__rust_api_register_home_widget_impl(port, ptr, rust_vec_len, data_len),
89 => wire__crate__bridge__api__rust_api_register_passwordless_notification_impl(port, ptr, rust_vec_len, data_len),
90 => wire__crate__bridge__api__rust_api_register_passwordless_recovery_impl(port, ptr, rust_vec_len, data_len),
91 => wire__crate__bridge__api__rust_api_reload_configuration_impl(port, ptr, rust_vec_len, data_len),
92 => wire__crate__bridge__api__rust_api_remove_additional_user_impl(port, ptr, rust_vec_len, data_len),
93 => wire__crate__bridge__api__rust_api_remove_media_files_impl(port, ptr, rust_vec_len, data_len),
94 => wire__crate__bridge__api__rust_api_report_user_impl(port, ptr, rust_vec_len, data_len),
95 => wire__crate__bridge__api__rust_api_request_binary_impl(port, ptr, rust_vec_len, data_len),
96 => wire__crate__bridge__api__rust_api_request_contact_by_username_impl(port, ptr, rust_vec_len, data_len),
97 => wire__crate__bridge__api__rust_api_request_media_reupload_impl(port, ptr, rust_vec_len, data_len),
98 => wire__crate__bridge__api__rust_api_request_memories_upload_impl(port, ptr, rust_vec_len, data_len),
99 => wire__crate__bridge__api__rust_api_retransmit_all_messages_impl(port, ptr, rust_vec_len, data_len),
100 => wire__crate__bridge__api__rust_api_retry_pending_media_reuploads_impl(port, ptr, rust_vec_len, data_len),
101 => wire__crate__bridge__api__rust_api_reupload_pending_media_impl(port, ptr, rust_vec_len, data_len),
102 => wire__crate__bridge__api__rust_api_save_media_to_gallery_impl(port, ptr, rust_vec_len, data_len),
103 => wire__crate__bridge__api__rust_api_send_binary_impl(port, ptr, rust_vec_len, data_len),
104 => wire__crate__bridge__api__rust_api_send_contact_profile_impl(port, ptr, rust_vec_len, data_len),
105 => wire__crate__bridge__api__rust_api_send_encrypted_content_impl(port, ptr, rust_vec_len, data_len),
106 => wire__crate__bridge__api__rust_api_send_encrypted_content_to_group_impl(port, ptr, rust_vec_len, data_len),
107 => wire__crate__bridge__api__rust_api_send_media_to_groups_impl(port, ptr, rust_vec_len, data_len),
108 => wire__crate__bridge__api__rust_api_send_queued_message_impl(port, ptr, rust_vec_len, data_len),
109 => wire__crate__bridge__api__rust_api_send_text_message_impl(port, ptr, rust_vec_len, data_len),
110 => wire__crate__bridge__api__rust_api_send_typing_impl(port, ptr, rust_vec_len, data_len),
111 => wire__crate__bridge__api__rust_api_set_background_impl(port, ptr, rust_vec_len, data_len),
112 => wire__crate__bridge__api__rust_api_set_home_widget_groups_impl(port, ptr, rust_vec_len, data_len),
113 => wire__crate__bridge__api__rust_api_set_login_token_impl(port, ptr, rust_vec_len, data_len),
114 => wire__crate__bridge__api__rust_api_set_media_display_limit_impl(port, ptr, rust_vec_len, data_len),
115 => wire__crate__bridge__api__rust_api_set_media_requires_authentication_impl(port, ptr, rust_vec_len, data_len),
116 => wire__crate__bridge__api__rust_api_set_media_trim_impl(port, ptr, rust_vec_len, data_len),
117 => wire__crate__bridge__api__rust_api_set_network_available_impl(port, ptr, rust_vec_len, data_len),
118 => wire__crate__bridge__api__rust_api_store_media_impl(port, ptr, rust_vec_len, data_len),
119 => wire__crate__bridge__api__rust_api_submit_recovery_share_impl(port, ptr, rust_vec_len, data_len),
120 => wire__crate__bridge__api__rust_api_sync_widget_permissions_impl(port, ptr, rust_vec_len, data_len),
121 => wire__crate__bridge__api__rust_api_toggle_media_remove_audio_impl(port, ptr, rust_vec_len, data_len),
122 => wire__crate__bridge__api__rust_api_try_request_contact_by_id_impl(port, ptr, rust_vec_len, data_len),
123 => wire__crate__bridge__api__rust_api_unregister_home_widget_impl(port, ptr, rust_vec_len, data_len),
124 => wire__crate__bridge__api__rust_api_update_fcm_token_impl(port, ptr, rust_vec_len, data_len),
125 => wire__crate__bridge__api__rust_api_update_signed_pre_key_impl(port, ptr, rust_vec_len, data_len),
126 => wire__crate__bridge__api__rust_api_upload_pqc_pre_keys_impl(port, ptr, rust_vec_len, data_len),
127 => wire__crate__bridge__wrapper__app_database__rust_app_database_changes_impl(port, ptr, rust_vec_len, data_len),
128 => wire__crate__bridge__wrapper__app_database__rust_app_database_execute_impl(port, ptr, rust_vec_len, data_len),
129 => wire__crate__bridge__wrapper__app_database__rust_app_database_legacy_import_complete_impl(port, ptr, rust_vec_len, data_len),
130 => wire__crate__bridge__wrapper__app_database__rust_app_database_migrate_legacy_database_impl(port, ptr, rust_vec_len, data_len),
131 => wire__crate__bridge__wrapper__app_database__rust_app_database_select_impl(port, ptr, rust_vec_len, data_len),
132 => wire__crate__bridge__wrapper__backup__rust_backup_archive_create_backup_archive_impl(port, ptr, rust_vec_len, data_len),
133 => wire__crate__bridge__wrapper__backup__rust_backup_archive_get_backup_download_token_impl(port, ptr, rust_vec_len, data_len),
134 => wire__crate__bridge__wrapper__backup__rust_backup_archive_restore_backup_archive_impl(port, ptr, rust_vec_len, data_len),
135 => wire__crate__bridge__wrapper__backup__rust_backup_identity_get_backup_id_impl(port, ptr, rust_vec_len, data_len),
136 => wire__crate__bridge__wrapper__backup__rust_backup_identity_get_backup_password_keys_impl(port, ptr, rust_vec_len, data_len),
137 => wire__crate__bridge__wrapper__backup__rust_backup_identity_get_identity_backup_bytes_impl(port, ptr, rust_vec_len, data_len),
138 => wire__crate__bridge__wrapper__backup__rust_backup_identity_import_backup_password_keys_impl(port, ptr, rust_vec_len, data_len),
139 => wire__crate__bridge__wrapper__backup__rust_backup_identity_restore_identity_backup_impl(port, ptr, rust_vec_len, data_len),
140 => wire__crate__bridge__wrapper__backup__rust_backup_identity_set_backup_password_keys_impl(port, ptr, rust_vec_len, data_len),
141 => wire__crate__bridge__wrapper__key_manager__rust_key_manager_decrypt_cloud_media_key_impl(port, ptr, rust_vec_len, data_len),
142 => wire__crate__bridge__wrapper__key_manager__rust_key_manager_encrypt_cloud_media_key_impl(port, ptr, rust_vec_len, data_len),
143 => wire__crate__bridge__wrapper__key_manager__rust_key_manager_get_signal_identity_impl(port, ptr, rust_vec_len, data_len),
144 => wire__crate__bridge__wrapper__key_manager__rust_key_manager_get_user_id_impl(port, ptr, rust_vec_len, data_len),
145 => wire__crate__bridge__wrapper__key_manager__rust_key_manager_import_serialized_impl(port, ptr, rust_vec_len, data_len),
146 => wire__crate__bridge__wrapper__key_manager__rust_key_manager_import_signal_identity_impl(port, ptr, rust_vec_len, data_len),
147 => wire__crate__bridge__wrapper__key_manager__rust_key_manager_remove_local_credentials_impl(port, ptr, rust_vec_len, data_len),
148 => wire__crate__bridge__wrapper__key_manager__rust_key_manager_serialize_impl(port, ptr, rust_vec_len, data_len),
149 => wire__crate__bridge__wrapper__key_manager__rust_key_manager_set_user_id_impl(port, ptr, rust_vec_len, data_len),
150 => wire__crate__bridge__wrapper__signal__rust_signal_decrypt_impl(port, ptr, rust_vec_len, data_len),
151 => wire__crate__bridge__wrapper__signal__rust_signal_encrypt_impl(port, ptr, rust_vec_len, data_len),
152 => wire__crate__bridge__wrapper__signal__rust_signal_generate_bundle_impl(port, ptr, rust_vec_len, data_len),
153 => wire__crate__bridge__wrapper__signal__rust_signal_generate_pqc_prekeys_impl(port, ptr, rust_vec_len, data_len),
154 => wire__crate__bridge__wrapper__signal__rust_signal_get_contact_public_key_impl(port, ptr, rust_vec_len, data_len),
155 => wire__crate__bridge__wrapper__signal__rust_signal_get_user_public_key_impl(port, ptr, rust_vec_len, data_len),
156 => wire__crate__bridge__wrapper__signal__rust_signal_process_prekey_bundle_impl(port, ptr, rust_vec_len, data_len),
157 => wire__crate__bridge__wrapper__rust_utils_generate_shares_impl(port, ptr, rust_vec_len, data_len),
158 => wire__crate__bridge__wrapper__rust_utils_recover_secret_impl(port, ptr, rust_vec_len, data_len),
159 => wire__crate__bridge__webxdc__send_update_impl(port, ptr, rust_vec_len, data_len),
160 => wire__crate__bridge__webxdc__serve_impl(port, ptr, rust_vec_len, data_len),
161 => wire__crate__bridge__groups__update_chat_deletion_time_impl(port, ptr, rust_vec_len, data_len),
162 => wire__crate__bridge__groups__update_group_name_impl(port, ptr, rust_vec_len, data_len),
163 => wire__crate__bridge__webxdc__updates_after_impl(port, ptr, rust_vec_len, data_len),
165 => wire__crate__bridge__user_config__user_config_api_create_impl(port, ptr, rust_vec_len, data_len),
166 => wire__crate__bridge__user_config__user_config_api_import_json_impl(port, ptr, rust_vec_len, data_len),
167 => wire__crate__bridge__user_config__user_config_api_load_impl(port, ptr, rust_vec_len, data_len),
168 => wire__crate__bridge__user_config__user_config_api_save_impl(port, ptr, rust_vec_len, data_len),
169 => wire__crate__bridge__user_config__user_config_api_update_impl(port, ptr, rust_vec_len, data_len),
_ => unreachable!(),
}
}
@ -7731,12 +7809,12 @@ fn pde_ffi_dispatcher_sync_impl(
47 => {
wire__crate__bridge__api__rust_api_decode_avatar_svg_impl(ptr, rust_vec_len, data_len)
}
162 => wire__crate__bridge__user_config__user_config_api_clone_impl(
164 => wire__crate__bridge__user_config__user_config_api_clone_impl(
ptr,
rust_vec_len,
data_len,
),
168 => wire__crate__bridge__logging__write_log_impl(ptr, rust_vec_len, data_len),
170 => wire__crate__bridge__logging__write_log_impl(ptr, rust_vec_len, data_len),
_ => unreachable!(),
}
}
@ -8571,6 +8649,7 @@ impl flutter_rust_bridge::IntoDart for crate::user_config::UserConfig {
self.store_media_files_in_gallery
.into_into_dart()
.into_dart(),
self.store_location_in_memories.into_into_dart().into_dart(),
self.auto_store_all_send_unlimited_media_files
.into_into_dart()
.into_dart(),
@ -9688,6 +9767,7 @@ impl SseEncode for crate::user_config::UserConfig {
serializer,
);
<bool>::sse_encode(self.store_media_files_in_gallery, serializer);
<bool>::sse_encode(self.store_location_in_memories, serializer);
<bool>::sse_encode(self.auto_store_all_send_unlimited_media_files, serializer);
<bool>::sse_encode(self.typing_indicators, serializer);
<bool>::sse_encode(self.show_restore_flame, serializer);

View file

@ -0,0 +1,96 @@
/*
* Copyright (c) 2026, Tobias Müller git@tsmr.eu
*
*/
//! One-shot, precise foreground location access implemented by the host app.
use crate::error::Result;
#[cfg(any(target_os = "android", target_os = "ios"))]
use crate::error::TwonlyError;
use std::time::Duration;
#[derive(Clone, Copy, Debug)]
pub(crate) struct LocationFix {
pub latitude: f64,
pub longitude: f64,
pub accuracy: f64,
}
#[cfg(target_os = "ios")]
pub(crate) fn current(timeout: Duration) -> Result<Option<LocationFix>> {
type CurrentLocation = unsafe extern "C" fn(i64, *mut f64, *mut f64, *mut f64) -> bool;
let callback = unsafe { libc::dlsym(libc::RTLD_DEFAULT, c"twonly_current_location".as_ptr()) };
if callback.is_null() {
return Err(TwonlyError::Generic(
"iOS location service is unavailable".into(),
));
}
// SAFETY: the Swift @_cdecl declaration has this exact stable C ABI signature.
let callback: CurrentLocation = unsafe { std::mem::transmute(callback) };
let mut latitude = 0.0;
let mut longitude = 0.0;
let mut accuracy = 0.0;
// SAFETY: all output pointers remain valid until the synchronous call returns.
let found = unsafe {
callback(
timeout.as_millis().min(i64::MAX as u128) as i64,
&mut latitude,
&mut longitude,
&mut accuracy,
)
};
Ok(found.then_some(LocationFix {
latitude,
longitude,
accuracy,
}))
}
#[cfg(target_os = "android")]
pub(crate) fn current(timeout: Duration) -> Result<Option<LocationFix>> {
use crate::native::transfer::android::{jni_env, location_class};
use jni::objects::JString;
let class = location_class()?;
let mut env = jni_env()?;
let value = env
.call_static_method(
class,
"current",
"(J)Ljava/lang/String;",
&[jni::objects::JValue::Long(
timeout.as_millis().min(i64::MAX as u128) as i64,
)],
)
.and_then(|value| value.l());
if env.exception_check().unwrap_or(false) {
let _ = env.exception_describe();
let _ = env.exception_clear();
}
let value = value.map_err(|error| TwonlyError::Generic(error.to_string()))?;
if value.is_null() {
return Ok(None);
}
let json: String = env
.get_string(&JString::from(value))
.map_err(|error| TwonlyError::Generic(error.to_string()))?
.into();
let values: Vec<f64> = serde_json::from_str(&json)?;
if values.len() != 3 {
return Err(TwonlyError::Generic(
"Android returned an invalid location".into(),
));
}
Ok(Some(LocationFix {
latitude: values[0],
longitude: values[1],
accuracy: values[2],
}))
}
#[cfg(not(any(target_os = "android", target_os = "ios")))]
pub(crate) fn current(_timeout: Duration) -> Result<Option<LocationFix>> {
Ok(None)
}

View file

@ -13,6 +13,7 @@
pub(crate) mod background;
pub(crate) mod gallery;
pub(crate) mod image;
pub(crate) mod location;
pub(crate) mod notifications;
pub(crate) mod prepare;
pub(crate) mod transfer;

View file

@ -44,6 +44,7 @@ pub(crate) mod android {
const MEDIA_CODEC_CLASS: &str = "eu/twonly/media/NativeImageCodec";
const VIDEO_CODEC_CLASS: &str = "eu/twonly/media/NativeVideoCodec";
const GALLERY_CLASS: &str = "eu/twonly/media/NativeGallery";
const LOCATION_CLASS: &str = "eu/twonly/location/NativeLocation";
static JAVA_VM: OnceLock<JavaVM> = OnceLock::new();
static TRANSFER: OnceLock<GlobalRef> = OnceLock::new();
@ -51,6 +52,7 @@ pub(crate) mod android {
static MEDIA_CODEC: OnceLock<GlobalRef> = OnceLock::new();
static VIDEO_CODEC: OnceLock<GlobalRef> = OnceLock::new();
static GALLERY: OnceLock<GlobalRef> = OnceLock::new();
static LOCATION: OnceLock<GlobalRef> = OnceLock::new();
/// `JNI_OnLoad` runs before Rust logging exists, so failures there are
/// recorded rather than logged, and reported by the first call that needs
/// the class.
@ -71,6 +73,7 @@ pub(crate) mod android {
(MEDIA_CODEC_CLASS, &MEDIA_CODEC),
(VIDEO_CODEC_CLASS, &VIDEO_CODEC),
(GALLERY_CLASS, &GALLERY),
(LOCATION_CLASS, &LOCATION),
] {
match env
.find_class(name)
@ -123,6 +126,10 @@ pub(crate) mod android {
cached_class(&GALLERY, GALLERY_CLASS)
}
pub(crate) fn location_class() -> Result<&'static GlobalRef> {
cached_class(&LOCATION, LOCATION_CLASS)
}
pub(crate) fn jni_env() -> Result<AttachGuard<'static>> {
JAVA_VM
.get()

View file

@ -0,0 +1,233 @@
/*
* Copyright (c) 2026, Tobias Müller git@tsmr.eu
*
*/
//! Optional, device-local location metadata for Memories.
use crate::context::Context;
use crate::error::{Result, TwonlyError};
use crate::native::location::{self, LocationFix};
use crate::services::media_upload::MediaUploadService;
use crate::user_config::UserConfig;
use sqlx::FromRow;
use std::collections::HashSet;
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::Mutex as SyncMutex;
use std::sync::{Arc, LazyLock};
use std::time::{Duration, SystemTime, UNIX_EPOCH};
use tokio::sync::Mutex;
pub(crate) const LOCATION_WINDOW_SECONDS: i64 = 3 * 60;
static LOCATION_REQUEST: LazyLock<Mutex<Option<(SystemTime, LocationFix)>>> =
LazyLock::new(|| Mutex::new(None));
static LOCATION_IN_FLIGHT: LazyLock<SyncMutex<HashSet<String>>> = LazyLock::new(Default::default);
static CAMERA_WARMUP_IN_FLIGHT: AtomicBool = AtomicBool::new(false);
struct CameraWarmup;
impl Drop for CameraWarmup {
fn drop(&mut self) {
CAMERA_WARMUP_IN_FLIGHT.store(false, Ordering::Release);
}
}
struct InFlight(String);
impl Drop for InFlight {
fn drop(&mut self) {
if let Ok(mut active) = LOCATION_IN_FLIGHT.lock() {
active.remove(&self.0);
}
}
}
#[derive(FromRow)]
struct PendingLocation {
media_id: String,
location_deadline_at: i64,
}
/// Starts acquiring a fix as soon as the user opens a real capture camera.
/// Nothing is persisted until a media row exists; the result only warms the
/// in-process cache consumed by `initialize_for_media`.
pub(crate) fn prewarm(ctx: &Arc<Context>) -> Result<()> {
if !UserConfig::load_required_from(ctx)?.store_location_in_memories
|| CAMERA_WARMUP_IN_FLIGHT.swap(true, Ordering::AcqRel)
{
return Ok(());
}
tokio::spawn(async move {
let _warmup = CameraWarmup;
let mut request = LOCATION_REQUEST.lock().await;
if request.as_ref().is_some_and(|(captured_at, _)| {
captured_at.elapsed().unwrap_or(Duration::MAX)
< Duration::from_secs(LOCATION_WINDOW_SECONDS as u64)
}) {
return;
}
let timeout = Duration::from_secs(LOCATION_WINDOW_SECONDS as u64);
match tokio::task::spawn_blocking(move || location::current(timeout)).await {
Ok(Ok(Some(location))) => {
*request = Some((SystemTime::now(), location));
}
Ok(Ok(None)) => {}
Ok(Err(error)) => tracing::warn!(%error, "camera location warm-up failed"),
Err(error) => tracing::warn!(%error, "camera location worker failed"),
}
});
Ok(())
}
pub(crate) async fn initialize_for_media(ctx: &Arc<Context>, media_id: &str) -> Result<()> {
let config = UserConfig::load_required_from(ctx)?;
if !config.store_location_in_memories {
return Ok(());
}
let deadline = chrono::Utc::now().timestamp() + LOCATION_WINDOW_SECONDS;
let database = ctx.app_db.read().await.clone();
sqlx::query(
r#"UPDATE media_files
SET location_status = 'pending', location_deadline_at = ?,
location_latitude = NULL, location_longitude = NULL,
location_accuracy = NULL
WHERE media_id = ?"#,
)
.bind(deadline)
.bind(media_id)
.execute(&database.pool)
.await?;
drop(database);
schedule(ctx, media_id.to_owned(), deadline);
Ok(())
}
pub(crate) fn schedule(ctx: &Arc<Context>, media_id: String, deadline: i64) {
let Ok(mut active) = LOCATION_IN_FLIGHT.lock() else {
return;
};
if !active.insert(media_id.clone()) {
return;
}
drop(active);
let ctx = ctx.clone();
tokio::spawn(async move {
let _in_flight = InFlight(media_id.clone());
if let Err(error) = capture(&ctx, &media_id, deadline).await {
tracing::warn!(media_id, %error, "could not capture Memory location");
}
});
}
async fn capture(ctx: &Arc<Context>, media_id: &str, deadline: i64) -> Result<()> {
// One native request serves captures made close together. Besides saving
// battery, this avoids multiple CLLocationManager/LocationManager sessions
// racing for identical coordinates.
let mut request = LOCATION_REQUEST.lock().await;
if let Some((captured_at, location)) = *request {
let captured_before_deadline = captured_at
.duration_since(UNIX_EPOCH)
.map(|value| value.as_secs() <= deadline.max(0) as u64)
.unwrap_or(false);
if captured_before_deadline
&& captured_at.elapsed().unwrap_or(Duration::MAX)
< Duration::from_secs(LOCATION_WINDOW_SECONDS as u64)
{
drop(request);
finish(ctx, media_id, Some(location)).await?;
return Ok(());
}
}
// Recalculate only after waiting for another Memory's request. Otherwise
// that wait would accidentally extend this Memory's three-minute window.
let remaining = deadline.saturating_sub(chrono::Utc::now().timestamp());
if remaining <= 0 {
drop(request);
finish(ctx, media_id, None).await?;
return Ok(());
}
let timeout = Duration::from_secs(remaining as u64);
let location = tokio::task::spawn_blocking(move || location::current(timeout))
.await
.map_err(|error| TwonlyError::Generic(format!("location worker failed: {error}")))??;
if let Some(location) = location {
*request = Some((SystemTime::now(), location));
}
drop(request);
finish(ctx, media_id, location).await
}
async fn finish(ctx: &Arc<Context>, media_id: &str, location: Option<LocationFix>) -> Result<()> {
let database = ctx.app_db.read().await.clone();
if let Some(location) = location {
sqlx::query(
r#"UPDATE media_files
SET location_status = 'resolved', location_latitude = ?,
location_longitude = ?, location_accuracy = ?
WHERE media_id = ? AND location_status = 'pending'"#,
)
.bind(location.latitude)
.bind(location.longitude)
.bind(location.accuracy)
.bind(media_id)
.execute(&database.pool)
.await?;
} else {
sqlx::query(
"UPDATE media_files SET location_status = 'timedOut' WHERE media_id = ? AND location_status = 'pending'",
)
.bind(media_id)
.execute(&database.pool)
.await?;
}
drop(database);
MediaUploadService::new(ctx)
.finish_pending_gallery_export(media_id)
.await
}
/// Resumes pending foreground requests and releases exports whose three-minute
/// deadline elapsed while the app was not running.
pub(crate) async fn resume(ctx: &Arc<Context>) -> Result<()> {
let database = ctx.app_db.read().await.clone();
let pending = sqlx::query_as::<_, PendingLocation>(
r#"SELECT media_id, location_deadline_at
FROM media_files WHERE location_status = 'pending'"#,
)
.fetch_all(&database.pool)
.await?;
drop(database);
let now = chrono::Utc::now().timestamp();
for item in pending {
if item.location_deadline_at <= now {
finish(ctx, &item.media_id, None).await?;
} else if ctx.is_flutter_runtime() {
schedule(ctx, item.media_id, item.location_deadline_at);
}
}
Ok(())
}
/// Turning the option off must not leave already requested gallery exports in
/// limbo. Existing coordinates remain attached to their Memories.
pub(crate) async fn disable_pending(ctx: &Arc<Context>) -> Result<()> {
let database = ctx.app_db.read().await.clone();
let media_ids = sqlx::query_scalar::<_, String>(
"SELECT media_id FROM media_files WHERE location_status = 'pending'",
)
.fetch_all(&database.pool)
.await?;
sqlx::query(
"UPDATE media_files SET location_status = 'timedOut' WHERE location_status = 'pending'",
)
.execute(&database.pool)
.await?;
drop(database);
for media_id in media_ids {
MediaUploadService::new(ctx)
.finish_pending_gallery_export(&media_id)
.await?;
}
Ok(())
}

View file

@ -21,10 +21,19 @@ const DATE_FORMAT: &str = "%Y:%m:%d %H:%M:%S";
const TAG_DATE_TIME: u16 = 0x0132;
const TAG_EXIF_IFD: u16 = 0x8769;
const TAG_GPS_IFD: u16 = 0x8825;
const TAG_DATE_TIME_ORIGINAL: u16 = 0x9003;
const TAG_DATE_TIME_DIGITIZED: u16 = 0x9004;
const TAG_GPS_VERSION: u16 = 0x0000;
const TAG_GPS_LATITUDE_REF: u16 = 0x0001;
const TAG_GPS_LATITUDE: u16 = 0x0002;
const TAG_GPS_LONGITUDE_REF: u16 = 0x0003;
const TAG_GPS_LONGITUDE: u16 = 0x0004;
const TAG_GPS_HORIZONTAL_ERROR: u16 = 0x001f;
const TYPE_BYTE: u16 = 1;
const TYPE_ASCII: u16 = 2;
const TYPE_LONG: u16 = 4;
const TYPE_RATIONAL: u16 = 5;
/// The `VP8X` flag bit that says an `EXIF` chunk is present.
const FLAG_EXIF: u8 = 0b0000_1000;
@ -34,14 +43,56 @@ const FLAG_ALPHA: u8 = 0b0001_0000;
#[derive(Default)]
pub(crate) struct ExifMetadata {
pub created_at: Option<DateTime<Utc>>,
pub latitude: Option<f64>,
pub longitude: Option<f64>,
pub accuracy: Option<f64>,
}
impl ExifMetadata {
fn is_empty(&self) -> bool {
self.created_at.is_none()
self.created_at.is_none() && (self.latitude.is_none() || self.longitude.is_none())
}
}
/// Removes the complete EXIF block from the WebP upload copy without decoding
/// or recompressing its pixels.
pub(crate) fn remove_exif_from_file(path: &std::path::Path) -> Result<()> {
let bytes = std::fs::read(path)?;
let stripped = without_exif(&bytes)?;
if stripped != bytes {
std::fs::write(path, stripped)?;
}
Ok(())
}
fn without_exif(webp: &[u8]) -> Result<Vec<u8>> {
let chunks = parse_chunks(webp)?;
if !chunks.iter().any(|chunk| chunk.id == *b"EXIF") {
return Ok(webp.to_vec());
}
let mut body = Vec::new();
for chunk in chunks {
if chunk.id == *b"EXIF" {
continue;
}
if chunk.id == *b"VP8X" {
let mut header = chunk.payload.to_vec();
if let Some(flags) = header.first_mut() {
*flags &= !FLAG_EXIF;
}
write_chunk(&mut body, &chunk.id, &header);
} else {
write_chunk(&mut body, &chunk.id, chunk.payload);
}
}
let mut output = Vec::with_capacity(body.len() + 12);
output.extend_from_slice(b"RIFF");
output.extend_from_slice(&((body.len() + 4) as u32).to_le_bytes());
output.extend_from_slice(b"WEBP");
output.extend_from_slice(&body);
Ok(output)
}
/// Returns the WebP bytes with an `EXIF` chunk attached, converting a simple
/// file to the extended layout when needed.
pub(crate) fn with_exif(webp: &[u8], metadata: &ExifMetadata) -> Result<Vec<u8>> {
@ -169,69 +220,120 @@ fn write_chunk(output: &mut Vec<u8>, id: &[u8; 4], payload: &[u8]) {
}
}
/// Builds a little-endian TIFF header with one IFD0 and one Exif sub-IFD.
/// Builds a little-endian TIFF header with Exif and GPS sub-IFDs.
fn tiff_block(metadata: &ExifMetadata) -> Vec<u8> {
let mut ifd0: Vec<(u16, u16, Vec<u8>)> = Vec::new();
let mut exif: Vec<(u16, u16, Vec<u8>)> = Vec::new();
let mut gps: Vec<(u16, u16, Vec<u8>)> = Vec::new();
if let Some(created_at) = metadata.created_at {
let text = format!("{}\0", created_at.format(DATE_FORMAT)).into_bytes();
ifd0.push((TAG_DATE_TIME, TYPE_ASCII, text.clone()));
exif.push((TAG_DATE_TIME_ORIGINAL, TYPE_ASCII, text.clone()));
exif.push((TAG_DATE_TIME_DIGITIZED, TYPE_ASCII, text));
}
if let (Some(latitude), Some(longitude)) = (metadata.latitude, metadata.longitude) {
gps.push((TAG_GPS_VERSION, TYPE_BYTE, vec![2, 3, 0, 0]));
gps.push((
TAG_GPS_LATITUDE_REF,
TYPE_ASCII,
vec![if latitude < 0.0 { b'S' } else { b'N' }, 0],
));
gps.push((TAG_GPS_LATITUDE, TYPE_RATIONAL, coordinate(latitude)));
gps.push((
TAG_GPS_LONGITUDE_REF,
TYPE_ASCII,
vec![if longitude < 0.0 { b'W' } else { b'E' }, 0],
));
gps.push((TAG_GPS_LONGITUDE, TYPE_RATIONAL, coordinate(longitude)));
if let Some(accuracy) = metadata
.accuracy
.filter(|value| value.is_finite() && *value >= 0.0)
{
gps.push((TAG_GPS_HORIZONTAL_ERROR, TYPE_RATIONAL, rational(accuracy)));
}
}
let mut output = Vec::new();
output.extend_from_slice(b"II\x2a\x00");
output.extend_from_slice(&8_u32.to_le_bytes());
// IFD0 gains a pointer entry to the Exif sub-IFD, so reserve room for it.
let ifd0_entries = ifd0.len() + usize::from(!exif.is_empty());
let ifd0_start = 8;
let ifd0_end = ifd0_start + 2 + ifd0_entries * 12 + 4;
let mut values = Vec::new();
let mut entries = Vec::new();
let mut value_offset = ifd0_end;
for (tag, kind, data) in &ifd0 {
entries.push(entry(*tag, *kind, data, &mut values, &mut value_offset));
}
// The Exif sub-IFD starts after IFD0 and its value block.
let exif_ifd_offset = ifd0_end + values.len();
let pointer_count = usize::from(!exif.is_empty()) + usize::from(!gps.is_empty());
let ifd0_size = ifd_size(&ifd0, pointer_count);
let exif_ifd_offset = 8 + ifd0_size;
let gps_ifd_offset = exif_ifd_offset + ifd_size(&exif, 0);
if !exif.is_empty() {
entries.push(entry_inline(
ifd0.push((
TAG_EXIF_IFD,
TYPE_LONG,
1,
(exif_ifd_offset as u32).to_le_bytes(),
(exif_ifd_offset as u32).to_le_bytes().to_vec(),
));
}
output.extend_from_slice(&(ifd0_entries as u16).to_le_bytes());
for bytes in &entries {
output.extend_from_slice(bytes);
}
output.extend_from_slice(&0_u32.to_le_bytes());
output.extend_from_slice(&values);
if !exif.is_empty() {
let sub_start = exif_ifd_offset;
let sub_end = sub_start + 2 + exif.len() * 12 + 4;
let mut sub_values = Vec::new();
let mut sub_entries = Vec::new();
let mut sub_offset = sub_end;
for (tag, kind, data) in &exif {
sub_entries.push(entry(*tag, *kind, data, &mut sub_values, &mut sub_offset));
}
output.extend_from_slice(&(exif.len() as u16).to_le_bytes());
for bytes in &sub_entries {
output.extend_from_slice(bytes);
}
output.extend_from_slice(&0_u32.to_le_bytes());
output.extend_from_slice(&sub_values);
if !gps.is_empty() {
ifd0.push((
TAG_GPS_IFD,
TYPE_LONG,
(gps_ifd_offset as u32).to_le_bytes().to_vec(),
));
}
output.extend_from_slice(&serialize_ifd(&ifd0, 8));
output.extend_from_slice(&serialize_ifd(&exif, exif_ifd_offset));
output.extend_from_slice(&serialize_ifd(&gps, gps_ifd_offset));
output
}
fn ifd_size(entries: &[(u16, u16, Vec<u8>)], extra_entries: usize) -> usize {
if entries.is_empty() && extra_entries == 0 {
return 0;
}
2 + (entries.len() + extra_entries) * 12
+ 4
+ entries
.iter()
.filter(|(_, _, data)| data.len() > 4)
.map(|(_, _, data)| data.len() + (data.len() & 1))
.sum::<usize>()
}
fn serialize_ifd(entries: &[(u16, u16, Vec<u8>)], start: usize) -> Vec<u8> {
if entries.is_empty() {
return Vec::new();
}
let mut values = Vec::new();
let mut encoded = Vec::new();
let mut value_offset = start + 2 + entries.len() * 12 + 4;
encoded.extend_from_slice(&(entries.len() as u16).to_le_bytes());
for (tag, kind, data) in entries {
encoded.extend_from_slice(&entry(*tag, *kind, data, &mut values, &mut value_offset));
}
encoded.extend_from_slice(&0_u32.to_le_bytes());
encoded.extend_from_slice(&values);
encoded
}
fn coordinate(value: f64) -> Vec<u8> {
let value = value.abs();
let degrees = value.floor();
let minutes_full = (value - degrees) * 60.0;
let minutes = minutes_full.floor();
let seconds = (minutes_full - minutes) * 60.0;
let mut bytes = Vec::with_capacity(24);
bytes.extend_from_slice(&rational(degrees));
bytes.extend_from_slice(&rational(minutes));
bytes.extend_from_slice(&rational(seconds));
bytes
}
fn rational(value: f64) -> Vec<u8> {
const DENOMINATOR: u32 = 1_000_000;
let numerator = (value * f64::from(DENOMINATOR))
.round()
.clamp(0.0, f64::from(u32::MAX)) as u32;
let mut bytes = Vec::with_capacity(8);
bytes.extend_from_slice(&numerator.to_le_bytes());
bytes.extend_from_slice(&DENOMINATOR.to_le_bytes());
bytes
}
/// One 12-byte IFD entry. Values longer than four bytes live in the value block
/// and the entry stores their offset instead.
fn entry(
@ -244,7 +346,7 @@ fn entry(
if data.len() <= 4 {
let mut inline = [0_u8; 4];
inline[..data.len()].copy_from_slice(data);
return entry_inline(tag, kind, data.len() as u32, inline);
return entry_inline(tag, kind, component_count(kind, data), inline);
}
let offset = *value_offset;
values.extend_from_slice(data);
@ -253,7 +355,20 @@ fn entry(
*value_offset += 1;
}
*value_offset += data.len();
entry_inline(tag, kind, data.len() as u32, (offset as u32).to_le_bytes())
entry_inline(
tag,
kind,
component_count(kind, data),
(offset as u32).to_le_bytes(),
)
}
fn component_count(kind: u16, data: &[u8]) -> u32 {
match kind {
TYPE_LONG => (data.len() / 4) as u32,
TYPE_RATIONAL => (data.len() / 8) as u32,
_ => data.len() as u32,
}
}
fn entry_inline(tag: u16, kind: u16, count: u32, value: [u8; 4]) -> [u8; 12] {
@ -295,6 +410,7 @@ mod tests {
let webp = sample_webp();
let metadata = ExifMetadata {
created_at: Some(Utc.with_ymd_and_hms(2026, 8, 30, 12, 34, 56).unwrap()),
..Default::default()
};
let written = with_exif(&webp, &metadata).unwrap();
@ -325,6 +441,7 @@ mod tests {
fn writing_twice_replaces_rather_than_appends() {
let metadata = ExifMetadata {
created_at: Some(Utc.with_ymd_and_hms(2026, 1, 2, 3, 4, 5).unwrap()),
..Default::default()
};
let once = with_exif(&sample_webp(), &metadata).unwrap();
let twice = with_exif(&once, &metadata).unwrap();
@ -333,4 +450,34 @@ mod tests {
assert_eq!(chunks.iter().filter(|c| c.id == *b"EXIF").count(), 1);
assert_eq!(chunks.iter().filter(|c| c.id == *b"VP8X").count(), 1);
}
#[test]
fn writes_gps_metadata_and_removes_it_from_the_send_copy() {
let metadata = ExifMetadata {
created_at: Some(Utc.with_ymd_and_hms(2026, 9, 10, 12, 0, 0).unwrap()),
latitude: Some(52.520_008),
longitude: Some(13.404_954),
accuracy: Some(8.5),
};
let exported = with_exif(&sample_webp(), &metadata).unwrap();
let chunks = parse_chunks(&exported).unwrap();
let exif = chunks
.iter()
.find(|chunk| chunk.id == *b"EXIF")
.expect("EXIF chunk");
assert!(
exif.payload.windows(2).any(|value| value == [0x25, 0x88]),
"IFD0 must point at a GPS IFD"
);
assert!(
exif.payload.windows(2).any(|value| value == [0x02, 0x00]),
"GPS IFD must contain latitude"
);
let upload = without_exif(&exported).unwrap();
let upload_chunks = parse_chunks(&upload).unwrap();
assert!(!upload_chunks.iter().any(|chunk| chunk.id == *b"EXIF"));
assert_eq!(upload_chunks[0].payload[0] & FLAG_EXIF, 0);
image::load_from_memory(&upload).expect("stripped image remains decodable");
}
}

View file

@ -78,6 +78,16 @@ struct MediaRow {
created_at: i64,
}
#[derive(FromRow)]
struct GalleryLocationRow {
location_status: Option<String>,
location_deadline_at: Option<i64>,
location_latitude: Option<f64>,
location_longitude: Option<f64>,
location_accuracy: Option<f64>,
gallery_export_pending: i64,
}
/// Why a media send stopped at `fileLimitReached`, for the chat entry to show.
pub struct MediaSizeReport {
/// The encoded media as it sits on disk, while it is still there.
@ -147,6 +157,7 @@ impl MediaUploadService {
.execute(&mut *transaction)
.await?;
transaction.commit().await?;
crate::services::location_metadata::initialize_for_media(&self.ctx, &media_id).await?;
Ok(media_id)
}
@ -400,6 +411,14 @@ impl MediaUploadService {
self.store(media_id).await?;
}
// The stored copy is device-local and may later gain EXIF metadata.
// The upload copy is a separate file and is always scrubbed before
// encryption, including when it originated from an imported Memory.
if media.media_type == "image" {
let path = temp_path.clone();
blocking(move || media_exif::remove_exif_from_file(&path)).await?;
}
match DirectMediaUploadService::new(&self.ctx)
.prepare_and_schedule(media_id)
.await
@ -439,6 +458,7 @@ impl MediaUploadService {
/// settles the transfers the server has meanwhile accepted or rejected.
pub async fn finish_started_uploads(&self) -> Result<()> {
let _guard = self.ctx.media_preprocessing.lock().await;
crate::services::location_metadata::resume(&self.ctx).await?;
let direct = DirectMediaUploadService::new(&self.ctx);
if let Err(error) = direct.reconcile().await {
tracing::warn!(%error, "direct media reconciliation failed");
@ -647,13 +667,6 @@ impl MediaUploadService {
let Some(media) = self.load(media_id).await? else {
return Ok(());
};
let database = self.ctx.app_db.read().await.clone();
sqlx::query("UPDATE media_files SET stored = 1 WHERE media_id = ?")
.bind(media_id)
.execute(&database.pool)
.await?;
drop(database);
let files = MediaFileService::new(&self.ctx);
let temp_path = files.temp_path(media_id, &media.media_type);
let stored_path = files.stored_path(media_id, &media.media_type);
@ -676,14 +689,13 @@ impl MediaUploadService {
self.create_thumbnail(&media).await?;
// Trust the filesystem rather than what the encoder reported: a
// thumbnail that was not written must not be advertised to the UI.
if files.thumbnail_path(media_id).exists() {
let has_thumbnail = i64::from(files.thumbnail_path(media_id).exists());
self.update_media(
"UPDATE media_files SET has_thumbnail = ? WHERE media_id = ?",
1_i64,
"UPDATE media_files SET stored = 1, has_thumbnail = ? WHERE media_id = ?",
has_thumbnail,
media_id,
)
.await?;
}
self.refresh_stored_metadata(media_id, &stored_path).await
}
@ -1265,7 +1277,7 @@ impl MediaUploadService {
Ok(())
}
/// Renders the preview shown in chat lists and the gallery. /// Renders the preview shown in chat lists and the gallery.
/// Renders the preview shown in chat lists and the gallery.
async fn create_thumbnail(&self, media: &MediaRow) -> Result<()> {
let files = MediaFileService::new(&self.ctx);
let stored = files.stored_path(&media.media_id, &media.media_type);
@ -1295,9 +1307,83 @@ impl MediaUploadService {
Ok(())
}
/// Exports a stored media file to the user's photo library, stamping the
/// capture time into the file so it survives being copied elsewhere.
/// Exports a stored media file, or durably queues it while its optional
/// location is still being resolved.
pub async fn save_to_gallery(&self, media_id: &str) -> Result<()> {
let database = self.ctx.app_db.read().await.clone();
let location = sqlx::query_as::<_, GalleryLocationRow>(
r#"SELECT location_status, location_deadline_at, location_latitude,
location_longitude, location_accuracy, gallery_export_pending
FROM media_files WHERE media_id = ?"#,
)
.bind(media_id)
.fetch_optional(&database.pool)
.await?;
let Some(location) = location else {
return Ok(());
};
if location.location_status.as_deref() == Some("pending")
&& location.location_deadline_at.unwrap_or_default() > chrono::Utc::now().timestamp()
{
sqlx::query("UPDATE media_files SET gallery_export_pending = 1 WHERE media_id = ?")
.bind(media_id)
.execute(&database.pool)
.await?;
drop(database);
if let Some(deadline) = location.location_deadline_at {
crate::services::location_metadata::schedule(
&self.ctx,
media_id.to_owned(),
deadline,
);
}
return Ok(());
}
if location.location_status.as_deref() == Some("pending") {
sqlx::query("UPDATE media_files SET location_status = 'timedOut' WHERE media_id = ?")
.bind(media_id)
.execute(&database.pool)
.await?;
}
drop(database);
self.export_to_gallery_now(media_id, location).await
}
pub(crate) async fn finish_pending_gallery_export(&self, media_id: &str) -> Result<()> {
let database = self.ctx.app_db.read().await.clone();
let location = sqlx::query_as::<_, GalleryLocationRow>(
r#"SELECT location_status, location_deadline_at, location_latitude,
location_longitude, location_accuracy, gallery_export_pending
FROM media_files WHERE media_id = ?"#,
)
.bind(media_id)
.fetch_optional(&database.pool)
.await?;
let Some(location) = location else {
return Ok(());
};
if location.gallery_export_pending == 0
|| location.location_status.as_deref() == Some("pending")
{
return Ok(());
}
// This flag represents waiting for location, not a general gallery
// retry queue. Clear it before handing the export to the platform.
sqlx::query("UPDATE media_files SET gallery_export_pending = 0 WHERE media_id = ?")
.bind(media_id)
.execute(&database.pool)
.await?;
drop(database);
self.export_to_gallery_now(media_id, location).await
}
/// Stamps capture time and the locally stored location into a temporary
/// export. The Memory itself remains untouched.
async fn export_to_gallery_now(
&self,
media_id: &str,
location: GalleryLocationRow,
) -> Result<()> {
let Some(media) = self.load(media_id).await? else {
return Ok(());
};
@ -1319,6 +1405,9 @@ impl MediaUploadService {
} else {
let metadata = media_exif::ExifMetadata {
created_at: chrono::DateTime::from_timestamp_millis(created_at_millis),
latitude: location.location_latitude,
longitude: location.location_longitude,
accuracy: location.location_accuracy,
};
let bytes = std::fs::read(&stored)?;
match media_exif::with_exif(&bytes, &metadata) {
@ -1491,6 +1580,46 @@ mod tests {
Ok(())
}
/// A freshly saved Memory used to become visible to Flutter before its
/// source file existed. The one-shot repair added for the Memories viewer
/// then consumed its attempt too early and left the row without a preview.
#[tokio::test]
async fn storing_a_memory_publishes_it_with_its_thumbnail() -> anyhow::Result<()> {
let temp = tempfile::tempdir()?;
let data_dir = temp.path().join("data");
std::fs::create_dir_all(data_dir.join("keyvalue"))?;
std::fs::write(
data_dir.join("keyvalue/user.json"),
serde_json::to_vec(&UserConfig::default())?,
)?;
let ctx = Context::init_for_testing(temp.path().join("database"), data_dir).await?;
let media_id = "new-memory";
let database = ctx.app_db.read().await.clone();
sqlx::query("INSERT INTO media_files(media_id, type, stored) VALUES (?, 'image', 0)")
.bind(media_id)
.execute(&database.pool)
.await?;
drop(database);
let files = MediaFileService::new(&ctx);
let source = files.temp_path(media_id, "image");
MediaFileService::ensure_parent(&source)?;
image::DynamicImage::ImageRgb8(image::RgbImage::new(900, 1600)).save(&source)?;
MediaUploadService::new(&ctx).store(media_id).await?;
assert!(files.thumbnail_path(media_id).exists());
let database = ctx.app_db.read().await.clone();
let state: (i64, i64) =
sqlx::query_as("SELECT stored, has_thumbnail FROM media_files WHERE media_id = ?")
.bind(media_id)
.fetch_one(&database.pool)
.await?;
assert_eq!(state, (1, 1));
Ok(())
}
#[test]
fn media_lock_is_shared_per_media_id() {
let first = media_lock("media-a");

View file

@ -9,6 +9,7 @@ pub mod contacts;
pub mod direct_media_upload;
pub mod groups;
pub mod home_widget;
pub mod location_metadata;
pub mod media_codec;
pub mod media_exif;
pub mod media_upload;

View file

@ -242,6 +242,11 @@ pub struct UserConfig {
#[serde(default = "defaults::true_value")]
#[frb(non_final)]
pub store_media_files_in_gallery: bool,
/// Adds the capture location to locally stored Memories and gallery
/// exports. Disabled by default and never added to outgoing media.
#[serde(default)]
#[frb(non_final)]
pub store_location_in_memories: bool,
#[serde(default)]
#[frb(non_final)]
pub auto_store_all_send_unlimited_media_files: bool,

View file

@ -27,6 +27,7 @@ UserConfig testUserConfig({
showShowImagePreviewWhenSending: false,
startWithCameraOpen: true,
storeMediaFilesInGallery: true,
storeLocationInMemories: false,
autoStoreAllSendUnlimitedMediaFiles: false,
typingIndicators: true,
showRestoreFlame: true,