mirror of
https://github.com/twonlyapp/twonly-app.git
synced 2026-10-09 02:54:14 +00:00
allow to store the location in memories
Some checks are pending
Flutter analyze & test / flutter_analyze_and_test (push) Waiting to run
Some checks are pending
Flutter analyze & test / flutter_analyze_and_test (push) Waiting to run
This commit is contained in:
parent
acf0d25a5e
commit
351d82c6d7
44 changed files with 2273 additions and 553 deletions
|
|
@ -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"/>
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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
|
|
@ -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 */,
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
],
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
],
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
164
ios/Runner/NativeLocation.swift
Normal file
164
ios/Runner/NativeLocation.swift
Normal 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
|
||||
}
|
||||
|
|
@ -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
|
|
@ -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 &&
|
||||
|
|
|
|||
|
|
@ -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()();
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
|
|
|
|||
13
lib/src/services/location_metadata.service.dart
Normal file
13
lib/src/services/location_metadata.service.dart
Normal 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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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());
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 &&
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -225,6 +225,7 @@ class _DeveloperSettingsViewState extends State<DeveloperSettingsView> {
|
|||
isFavorite: false,
|
||||
hasCropAnalyzed: false,
|
||||
hasThumbnail: false,
|
||||
galleryExportPending: false,
|
||||
cloudState: CloudState.none,
|
||||
createdAt: now,
|
||||
);
|
||||
|
|
|
|||
420
pubspec.lock
420
pubspec.lock
File diff suppressed because it is too large
Load diff
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
16
rust/src/database/app/migrations/0023_memory_locations.sql
Normal file
16
rust/src/database/app/migrations/0023_memory_locations.sql
Normal 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';
|
||||
|
||||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
96
rust/src/native/location.rs
Normal file
96
rust/src/native/location.rs
Normal 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)
|
||||
}
|
||||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
233
rust/src/services/location_metadata.rs
Normal file
233
rust/src/services/location_metadata.rs
Normal 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(())
|
||||
}
|
||||
|
|
@ -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");
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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() {
|
||||
self.update_media(
|
||||
"UPDATE media_files SET has_thumbnail = ? WHERE media_id = ?",
|
||||
1_i64,
|
||||
media_id,
|
||||
)
|
||||
.await?;
|
||||
}
|
||||
let has_thumbnail = i64::from(files.thumbnail_path(media_id).exists());
|
||||
self.update_media(
|
||||
"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");
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -27,6 +27,7 @@ UserConfig testUserConfig({
|
|||
showShowImagePreviewWhenSending: false,
|
||||
startWithCameraOpen: true,
|
||||
storeMediaFilesInGallery: true,
|
||||
storeLocationInMemories: false,
|
||||
autoStoreAllSendUnlimitedMediaFiles: false,
|
||||
typingIndicators: true,
|
||||
showRestoreFlame: true,
|
||||
|
|
|
|||
Loading…
Reference in a new issue