From 5d76586ac16b598c2630d2568c84b0bc11ea901d Mon Sep 17 00:00:00 2001 From: wch423 Date: Tue, 14 Apr 2026 19:13:53 +0200 Subject: [PATCH 1/5] Add foreground service for location streaming --- CHANGELOG.md | 1 + build.gradle | 1 + .../geolocation/LocationSourceFactory.java | 17 ++ .../geolocation/GmsLocationSource.java | 79 ++++++ .../geolocation/LocationSourceFactory.java | 35 +++ src/main/AndroidManifest.xml | 12 +- .../securesms/ApplicationContext.java | 17 +- .../securesms/connect/KeepAliveService.java | 4 +- .../geolocation/ActiveLocationChats.java | 53 +++++ .../securesms/geolocation/DcLocation.java | 128 ---------- .../geolocation/DcLocationManager.java | 125 ---------- .../LocationBackgroundService.java | 142 ----------- .../securesms/geolocation/LocationData.java | 40 ++++ .../securesms/geolocation/LocationSource.java | 23 ++ .../geolocation/LocationStreamingService.java | 225 ++++++++++++++++++ .../securesms/geolocation/LocationUtils.java | 36 +++ .../geolocation/PlatformLocationSource.java | 100 ++++++++ .../securesms/mms/AttachmentManager.java | 69 +++--- src/main/res/drawable/ic_stop_circle.xml | 11 + src/main/res/values/strings.xml | 7 + 20 files changed, 670 insertions(+), 455 deletions(-) create mode 100644 src/foss/java/org/thoughtcrime/securesms/geolocation/LocationSourceFactory.java create mode 100644 src/gplay/java/org/thoughtcrime/securesms/geolocation/GmsLocationSource.java create mode 100644 src/gplay/java/org/thoughtcrime/securesms/geolocation/LocationSourceFactory.java create mode 100644 src/main/java/org/thoughtcrime/securesms/geolocation/ActiveLocationChats.java delete mode 100644 src/main/java/org/thoughtcrime/securesms/geolocation/DcLocation.java delete mode 100644 src/main/java/org/thoughtcrime/securesms/geolocation/DcLocationManager.java delete mode 100644 src/main/java/org/thoughtcrime/securesms/geolocation/LocationBackgroundService.java create mode 100644 src/main/java/org/thoughtcrime/securesms/geolocation/LocationData.java create mode 100644 src/main/java/org/thoughtcrime/securesms/geolocation/LocationSource.java create mode 100644 src/main/java/org/thoughtcrime/securesms/geolocation/LocationStreamingService.java create mode 100644 src/main/java/org/thoughtcrime/securesms/geolocation/LocationUtils.java create mode 100644 src/main/java/org/thoughtcrime/securesms/geolocation/PlatformLocationSource.java create mode 100644 src/main/res/drawable/ic_stop_circle.xml diff --git a/CHANGELOG.md b/CHANGELOG.md index 9e30d1b99..376f4c2d4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,7 @@ * Better incoming call system integration * Calls are not experimental anymore and don't need to be manually enabled +* Allow sharing location while the app is in background ## v2.49.0 2026-04 diff --git a/build.gradle b/build.gradle index e9acd1d2c..fec1705da 100644 --- a/build.gradle +++ b/build.gradle @@ -248,6 +248,7 @@ dependencies { exclude group: 'com.google.firebase', module: 'firebase-analytics' exclude group: 'com.google.firebase', module: 'firebase-measurement-connector' } + gplayImplementation 'com.google.android.gms:play-services-location:21.3.0' testImplementation 'junit:junit:4.13.2' testImplementation 'org.assertj:assertj-core:3.27.3' diff --git a/src/foss/java/org/thoughtcrime/securesms/geolocation/LocationSourceFactory.java b/src/foss/java/org/thoughtcrime/securesms/geolocation/LocationSourceFactory.java new file mode 100644 index 000000000..2daa4a15e --- /dev/null +++ b/src/foss/java/org/thoughtcrime/securesms/geolocation/LocationSourceFactory.java @@ -0,0 +1,17 @@ +package org.thoughtcrime.securesms.geolocation; + +import android.content.Context; +import android.util.Log; + +/** Non-GMS, always uses the platform LocationManager. */ +public final class LocationSourceFactory { + + private static final String TAG = LocationSourceFactory.class.getSimpleName(); + + private LocationSourceFactory() {} + + public static LocationSource create(Context context) { + Log.i(TAG, "Non-GMS build, Using platform LocationManager"); + return new PlatformLocationSource(); + } +} diff --git a/src/gplay/java/org/thoughtcrime/securesms/geolocation/GmsLocationSource.java b/src/gplay/java/org/thoughtcrime/securesms/geolocation/GmsLocationSource.java new file mode 100644 index 000000000..3ebdbc755 --- /dev/null +++ b/src/gplay/java/org/thoughtcrime/securesms/geolocation/GmsLocationSource.java @@ -0,0 +1,79 @@ +package org.thoughtcrime.securesms.geolocation; + +import android.content.Context; +import android.location.Location; +import android.os.Looper; +import android.util.Log; +import androidx.annotation.NonNull; +import com.google.android.gms.location.FusedLocationProviderClient; +import com.google.android.gms.location.LocationCallback; +import com.google.android.gms.location.LocationRequest; +import com.google.android.gms.location.LocationResult; +import com.google.android.gms.location.LocationServices; +import com.google.android.gms.location.Priority; + +public class GmsLocationSource implements LocationSource { + + private static final String TAG = GmsLocationSource.class.getSimpleName(); + private static final long UPDATE_INTERVAL_MS = 3000; + private static final long FASTEST_INTERVAL_MS = 1000; + private static final float MIN_DISTANCE_M = 5f; + + private FusedLocationProviderClient client; + private LocationCallback locationCallback; + + @Override + public void startUpdates(@NonNull Context context, @NonNull Callback callback) { + client = LocationServices.getFusedLocationProviderClient(context); + + LocationRequest request = + new LocationRequest.Builder(Priority.PRIORITY_HIGH_ACCURACY, UPDATE_INTERVAL_MS) + .setMinUpdateIntervalMillis(FASTEST_INTERVAL_MS) + .setMinUpdateDistanceMeters(MIN_DISTANCE_M) + .setWaitForAccurateLocation(false) + .build(); + + locationCallback = + new LocationCallback() { + @Override + public void onLocationResult(@NonNull LocationResult result) { + Location loc = result.getLastLocation(); + if (loc != null) { + callback.onLocationUpdate(loc); + } + } + }; + + try { + client.requestLocationUpdates(request, locationCallback, Looper.getMainLooper()); + } catch (SecurityException e) { + Log.e(TAG, "Missing location permission", e); + } + } + + @Override + public void stopUpdates() { + if (client != null && locationCallback != null) { + client.removeLocationUpdates(locationCallback); + client = null; + locationCallback = null; + } + } + + @Override + public void getCurrentLocation(@NonNull Context context, @NonNull Callback callback) { + FusedLocationProviderClient client = LocationServices.getFusedLocationProviderClient(context); + try { + client + .getCurrentLocation(Priority.PRIORITY_HIGH_ACCURACY, null) + .addOnSuccessListener( + location -> { + if (location != null) { + callback.onLocationUpdate(location); + } + }); + } catch (SecurityException e) { + Log.w(TAG, "No permission for getCurrentLocation", e); + } + } +} diff --git a/src/gplay/java/org/thoughtcrime/securesms/geolocation/LocationSourceFactory.java b/src/gplay/java/org/thoughtcrime/securesms/geolocation/LocationSourceFactory.java new file mode 100644 index 000000000..081051e87 --- /dev/null +++ b/src/gplay/java/org/thoughtcrime/securesms/geolocation/LocationSourceFactory.java @@ -0,0 +1,35 @@ +package org.thoughtcrime.securesms.geolocation; + +import android.content.Context; +import android.util.Log; +import com.google.android.gms.common.ConnectionResult; +import com.google.android.gms.common.GoogleApiAvailability; + +/** + * Prefers FusedLocationProviderClient, falls back to platform LocationManager if Play Services are + * somehow unavailable. + */ +public final class LocationSourceFactory { + + private static final String TAG = LocationSourceFactory.class.getSimpleName(); + + private LocationSourceFactory() {} + + public static LocationSource create(Context context) { + if (isGmsAvailable(context)) { + Log.i(TAG, "Using FusedLocationProviderClient"); + return new GmsLocationSource(); + } + Log.i(TAG, "GMS unavailable, falling back to LocationManager"); + return new PlatformLocationSource(); + } + + private static boolean isGmsAvailable(Context context) { + try { + return GoogleApiAvailability.getInstance().isGooglePlayServicesAvailable(context) + == ConnectionResult.SUCCESS; + } catch (Exception e) { + return false; + } + } +} diff --git a/src/main/AndroidManifest.xml b/src/main/AndroidManifest.xml index b0d93c56d..191e1e81a 100644 --- a/src/main/AndroidManifest.xml +++ b/src/main/AndroidManifest.xml @@ -46,7 +46,6 @@ android:name="android.permission.BLUETOOTH" android:maxSdkVersion="30" /> - @@ -72,6 +71,7 @@ android:name="android.permission.FOREGROUND_SERVICE_MEDIA_PLAYBACK" /> + @@ -82,7 +82,6 @@ android:icon="@mipmap/ic_launcher" android:label="@string/app_name" android:supportsRtl="true" - tools:replace="android:allowBackup" android:allowBackup="false" android:theme="@style/TextSecure.LightTheme" android:largeHeap="true" @@ -465,10 +464,6 @@ android:configChanges="touchscreen|keyboard|keyboardHidden|orientation|screenLayout|screenSize"> - - @@ -477,6 +472,11 @@ android:name=".service.FetchForegroundService" android:foregroundServiceType="dataSync" /> + + current = new HashSet<>(getAll(context)); + current.add(String.valueOf(chatId)); + prefs(context).edit().putStringSet(KEY_ACTIVE, current).commit(); + } + + public static void remove(Context context, int chatId) { + Set current = new HashSet<>(getAll(context)); + current.remove(String.valueOf(chatId)); + prefs(context).edit().putStringSet(KEY_ACTIVE, current).apply(); + } + + static void clear(Context context) { + prefs(context).edit().remove(KEY_ACTIVE).apply(); + } + + static Set getAllIds(Context context) { + Set ids = new HashSet<>(); + for (String s : getAll(context)) { + try { + ids.add(Integer.parseInt(s)); + } catch (NumberFormatException ignored) { + } + } + return ids; + } + + private static Set getAll(Context context) { + return prefs(context).getStringSet(KEY_ACTIVE, new HashSet<>()); + } +} diff --git a/src/main/java/org/thoughtcrime/securesms/geolocation/DcLocation.java b/src/main/java/org/thoughtcrime/securesms/geolocation/DcLocation.java deleted file mode 100644 index 614823e8b..000000000 --- a/src/main/java/org/thoughtcrime/securesms/geolocation/DcLocation.java +++ /dev/null @@ -1,128 +0,0 @@ -package org.thoughtcrime.securesms.geolocation; - -import android.location.Location; -import android.util.Log; -import java.util.Observable; - -public class DcLocation extends Observable { - private static final String TAG = DcLocation.class.getSimpleName(); - private Location lastLocation; - private static DcLocation instance; - private static final int TIMEOUT = 1000 * 15; - private static final int EARTH_RADIUS = 6371; - - private DcLocation() { - lastLocation = getDefault(); - } - - public static DcLocation getInstance() { - if (instance == null) { - instance = new DcLocation(); - } - return instance; - } - - public Location getLastLocation() { - return lastLocation; - } - - public boolean isValid() { - return !"?".equals(lastLocation.getProvider()); - } - - void updateLocation(Location location) { - if (isBetterLocation(location, lastLocation)) { - lastLocation = location; - - instance.setChanged(); - instance.notifyObservers(); - } - } - - void reset() { - updateLocation(getDefault()); - } - - private Location getDefault() { - return new Location("?"); - } - - /** - * https://developer.android.com/guide/topics/location/strategies Determines whether one Location - * reading is better than the current Location fix - * - * @param location The new Location that you want to evaluate - * @param currentBestLocation The current Location fix, to which you want to compare the new one - */ - private boolean isBetterLocation(Location location, Location currentBestLocation) { - if (currentBestLocation == null) { - // A new location is always better than no location - return true; - } - - // Check whether the new location fix is newer or older - long timeDelta = location.getTime() - currentBestLocation.getTime(); - boolean isSignificantlyOlder = timeDelta < -TIMEOUT; - - if (isSignificantlyOlder) { - return false; - } - - // Check whether the new location fix is more or less accurate - int accuracyDelta = (int) (location.getAccuracy() - currentBestLocation.getAccuracy()); - Log.d(TAG, "accuracyDelta: " + accuracyDelta); - boolean isSignificantlyMoreAccurate = accuracyDelta > 50; - boolean isSameProvider = - isSameProvider(location.getProvider(), currentBestLocation.getProvider()); - - if (isSignificantlyMoreAccurate && isSameProvider) { - return true; - } - - boolean isMoreAccurate = accuracyDelta > 0; - double distance = distance(location, currentBestLocation); - return hasLocationChanged(distance) && isMoreAccurate - || hasLocationSignificantlyChanged(distance); - } - - private boolean hasLocationSignificantlyChanged(double distance) { - return distance > 30D; - } - - private boolean hasLocationChanged(double distance) { - return distance > 10D; - } - - private double distance(Location location, Location currentBestLocation) { - - double startLat = location.getLatitude(); - double startLong = location.getLongitude(); - double endLat = currentBestLocation.getLatitude(); - double endLong = currentBestLocation.getLongitude(); - - double dLat = Math.toRadians(endLat - startLat); - double dLong = Math.toRadians(endLong - startLong); - - startLat = Math.toRadians(startLat); - endLat = Math.toRadians(endLat); - - double a = haversin(dLat) + Math.cos(startLat) * Math.cos(endLat) * haversin(dLong); - double c = 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a)); - - double distance = EARTH_RADIUS * c * 1000; - Log.d(TAG, "Distance between location updates: " + distance); - return distance; - } - - private double haversin(double val) { - return Math.pow(Math.sin(val / 2), 2); - } - - /** Checks whether two providers are the same */ - private boolean isSameProvider(String provider1, String provider2) { - if (provider1 == null) { - return provider2 == null; - } - return provider1.equals(provider2); - } -} diff --git a/src/main/java/org/thoughtcrime/securesms/geolocation/DcLocationManager.java b/src/main/java/org/thoughtcrime/securesms/geolocation/DcLocationManager.java deleted file mode 100644 index 6a12d06c2..000000000 --- a/src/main/java/org/thoughtcrime/securesms/geolocation/DcLocationManager.java +++ /dev/null @@ -1,125 +0,0 @@ -package org.thoughtcrime.securesms.geolocation; - -import static android.content.Context.BIND_AUTO_CREATE; - -import android.content.ComponentName; -import android.content.Context; -import android.content.Intent; -import android.content.ServiceConnection; -import android.location.Location; -import android.os.IBinder; -import android.util.Log; -import com.b44t.messenger.DcContext; -import java.util.LinkedList; -import java.util.Observable; -import java.util.Observer; -import org.thoughtcrime.securesms.connect.DcHelper; - -public class DcLocationManager implements Observer { - - private static final String TAG = DcLocationManager.class.getSimpleName(); - private LocationBackgroundService.LocationBackgroundServiceBinder serviceBinder; - private final Context context; - private DcLocation dcLocation = DcLocation.getInstance(); - private final LinkedList pendingShareLastLocation = new LinkedList<>(); - private final ServiceConnection serviceConnection = - new ServiceConnection() { - @Override - public void onServiceConnected(ComponentName name, IBinder service) { - Log.d(TAG, "background service connected"); - serviceBinder = (LocationBackgroundService.LocationBackgroundServiceBinder) service; - while (!pendingShareLastLocation.isEmpty()) { - shareLastLocation(pendingShareLastLocation.pop()); - } - } - - @Override - public void onServiceDisconnected(ComponentName name) { - Log.d(TAG, "background service disconnected"); - serviceBinder = null; - } - }; - - public DcLocationManager(Context context, DcContext dcContext) { - this.context = context.getApplicationContext(); - DcLocation.getInstance().addObserver(this); - if (dcContext.isSendingLocationsToChat(0)) { - startLocationEngine(); - } - } - - public void startLocationEngine() { - if (serviceBinder == null) { - Intent intent = new Intent(context.getApplicationContext(), LocationBackgroundService.class); - context.bindService(intent, serviceConnection, BIND_AUTO_CREATE); - } - } - - public void stopLocationEngine() { - if (serviceBinder == null) { - return; - } - context.unbindService(serviceConnection); - serviceBinder.stop(); - serviceBinder = null; - } - - public void stopSharingLocation(int chatId) { - DcHelper.getContext(context).sendLocationsToChat(chatId, 0); - if (!DcHelper.getContext(context).isSendingLocationsToChat(0)) { - stopLocationEngine(); - } - } - - public void shareLocation(int duration, int chatId) { - startLocationEngine(); - Log.d(TAG, String.format("Share location in chat %d for %d seconds", chatId, duration)); - DcHelper.getContext(context).sendLocationsToChat(chatId, duration); - if (dcLocation.isValid()) { - writeDcLocationUpdateMessage(); - } - } - - public void shareLastLocation(int chatId) { - if (serviceBinder == null) { - pendingShareLastLocation.push(chatId); - startLocationEngine(); - return; - } - - if (dcLocation.isValid()) { - DcHelper.getContext(context).sendLocationsToChat(chatId, 1); - writeDcLocationUpdateMessage(); - } - } - - @Override - public void update(Observable o, Object arg) { - if (o instanceof DcLocation) { - dcLocation = (DcLocation) o; - if (dcLocation.isValid()) { - writeDcLocationUpdateMessage(); - } - } - } - - private void writeDcLocationUpdateMessage() { - Log.d( - TAG, - "Share location: " - + dcLocation.getLastLocation().getLatitude() - + ", " - + dcLocation.getLastLocation().getLongitude()); - Location lastLocation = dcLocation.getLastLocation(); - - boolean continueLocationStreaming = - DcHelper.getContext(context) - .setLocation( - (float) lastLocation.getLatitude(), - (float) lastLocation.getLongitude(), - lastLocation.getAccuracy()); - if (!continueLocationStreaming) { - stopLocationEngine(); - } - } -} diff --git a/src/main/java/org/thoughtcrime/securesms/geolocation/LocationBackgroundService.java b/src/main/java/org/thoughtcrime/securesms/geolocation/LocationBackgroundService.java deleted file mode 100644 index a4fc4adbe..000000000 --- a/src/main/java/org/thoughtcrime/securesms/geolocation/LocationBackgroundService.java +++ /dev/null @@ -1,142 +0,0 @@ -package org.thoughtcrime.securesms.geolocation; - -import android.app.Service; -import android.content.Context; -import android.content.Intent; -import android.content.ServiceConnection; -import android.location.Location; -import android.location.LocationListener; -import android.location.LocationManager; -import android.os.Binder; -import android.os.Bundle; -import android.os.IBinder; -import android.util.Log; -import androidx.annotation.NonNull; - -public class LocationBackgroundService extends Service { - - private static final int INITIAL_TIMEOUT = 1000 * 60 * 2; - private static final String TAG = LocationBackgroundService.class.getSimpleName(); - private LocationManager locationManager = null; - private static final int LOCATION_INTERVAL = 1000; - private static final float LOCATION_DISTANCE = 25F; - ServiceLocationListener locationListener; - - private final IBinder mBinder = new LocationBackgroundServiceBinder(); - - @Override - public boolean bindService(Intent service, ServiceConnection conn, int flags) { - return super.bindService(service, conn, flags); - } - - @Override - public IBinder onBind(Intent intent) { - return mBinder; - } - - @Override - public void onCreate() { - locationManager = - (LocationManager) getApplicationContext().getSystemService(Context.LOCATION_SERVICE); - if (locationManager == null) { - Log.e(TAG, "Unable to initialize location service"); - return; - } - - locationListener = new ServiceLocationListener(); - Location lastLocation = locationManager.getLastKnownLocation(LocationManager.GPS_PROVIDER); - if (lastLocation != null) { - long locationAge = System.currentTimeMillis() - lastLocation.getTime(); - if (locationAge <= 600 * 1000) { // not older than 10 minutes - DcLocation.getInstance().updateLocation(lastLocation); - } - } - // requestLocationUpdate(LocationManager.NETWORK_PROVIDER); - requestLocationUpdate(LocationManager.GPS_PROVIDER); - initialLocationUpdate(); - } - - @Override - public int onStartCommand(Intent intent, int flags, int startId) { - super.onStartCommand(intent, flags, startId); - return START_STICKY; - } - - @Override - public void onDestroy() { - super.onDestroy(); - - if (locationManager == null) { - return; - } - - try { - locationManager.removeUpdates(locationListener); - } catch (Exception ex) { - Log.i(TAG, "fail to remove location listeners, ignore", ex); - } - } - - private void requestLocationUpdate(String provider) { - try { - locationManager.requestLocationUpdates( - provider, LOCATION_INTERVAL, LOCATION_DISTANCE, locationListener); - } catch (SecurityException | IllegalArgumentException ex) { - Log.e( - TAG, - String.format("Unable to request %s provider based location updates.", provider), - ex); - } - } - - private void initialLocationUpdate() { - try { - Location gpsLocation = locationManager.getLastKnownLocation(LocationManager.GPS_PROVIDER); - if (gpsLocation != null - && System.currentTimeMillis() - gpsLocation.getTime() < INITIAL_TIMEOUT) { - locationListener.onLocationChanged(gpsLocation); - } - - } catch (NullPointerException | SecurityException e) { - e.printStackTrace(); - } - } - - class LocationBackgroundServiceBinder extends Binder { - LocationBackgroundServiceBinder getService() { - return LocationBackgroundServiceBinder.this; - } - - void stop() { - DcLocation.getInstance().reset(); - stopSelf(); - } - } - - private class ServiceLocationListener implements LocationListener { - - @Override - public void onLocationChanged(@NonNull Location location) { - Log.d(TAG, "onLocationChanged: " + location); - if (location == null) { - return; - } - DcLocation.getInstance().updateLocation(location); - } - - @Override - public void onProviderDisabled(@NonNull String provider) { - Log.e(TAG, "onProviderDisabled: " + provider); - } - - @Override - public void onProviderEnabled(@NonNull String provider) { - Log.e(TAG, "onProviderEnabled: " + provider); - } - - @Override - public void onStatusChanged(String provider, int status, Bundle extras) { - Log.e(TAG, "onStatusChanged: " + provider + " status: " + status); - } - } -} diff --git a/src/main/java/org/thoughtcrime/securesms/geolocation/LocationData.java b/src/main/java/org/thoughtcrime/securesms/geolocation/LocationData.java new file mode 100644 index 000000000..3e0ba8923 --- /dev/null +++ b/src/main/java/org/thoughtcrime/securesms/geolocation/LocationData.java @@ -0,0 +1,40 @@ +package org.thoughtcrime.securesms.geolocation; + +import android.location.Location; +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import androidx.lifecycle.LiveData; +import androidx.lifecycle.MutableLiveData; + +/** + * Process-wide holder for the current streamed location. Foreground service writes, UI observes. + */ +public final class LocationData { + + private static final LocationData INSTANCE = new LocationData(); + + private final MutableLiveData liveLocation = new MutableLiveData<>(); + + private LocationData() {} + + public static LocationData getInstance() { + return INSTANCE; + } + + public LiveData observable() { + return liveLocation; + } + + @Nullable + public Location current() { + return liveLocation.getValue(); + } + + void post(@NonNull Location location) { + liveLocation.postValue(location); + } + + void clear() { + liveLocation.postValue(null); + } +} diff --git a/src/main/java/org/thoughtcrime/securesms/geolocation/LocationSource.java b/src/main/java/org/thoughtcrime/securesms/geolocation/LocationSource.java new file mode 100644 index 000000000..2c440c1f3 --- /dev/null +++ b/src/main/java/org/thoughtcrime/securesms/geolocation/LocationSource.java @@ -0,0 +1,23 @@ +package org.thoughtcrime.securesms.geolocation; + +import android.content.Context; +import android.location.Location; +import androidx.annotation.NonNull; + +/** Abstraction over platform LocationManager and GMS FusedLocationProviderClient. */ +public interface LocationSource { + + void startUpdates(@NonNull Context context, @NonNull Callback callback); + + void stopUpdates(); + + /** + * Request a single current location to seed the stream. May return null if no fresh fix is + * available. + */ + void getCurrentLocation(@NonNull Context context, @NonNull Callback callback); + + interface Callback { + void onLocationUpdate(@NonNull Location location); + } +} diff --git a/src/main/java/org/thoughtcrime/securesms/geolocation/LocationStreamingService.java b/src/main/java/org/thoughtcrime/securesms/geolocation/LocationStreamingService.java new file mode 100644 index 000000000..e2849e964 --- /dev/null +++ b/src/main/java/org/thoughtcrime/securesms/geolocation/LocationStreamingService.java @@ -0,0 +1,225 @@ +package org.thoughtcrime.securesms.geolocation; + +import android.app.Notification; +import android.app.NotificationChannel; +import android.app.NotificationManager; +import android.app.PendingIntent; +import android.app.Service; +import android.content.Context; +import android.content.Intent; +import android.content.pm.PackageManager; +import android.content.pm.ServiceInfo; +import android.location.Location; +import android.os.Build; +import android.os.IBinder; +import android.util.Log; +import androidx.annotation.Nullable; +import androidx.core.app.NotificationCompat; +import androidx.core.app.ServiceCompat; +import androidx.core.content.ContextCompat; +import org.thoughtcrime.securesms.ConversationListActivity; +import org.thoughtcrime.securesms.R; +import org.thoughtcrime.securesms.connect.DcHelper; + +public class LocationStreamingService extends Service { + + private static final String TAG = LocationStreamingService.class.getSimpleName(); + private static final String ACTION_STOP = "org.thoughtcrime.securesms.geolocation.STOP_STREAMING"; + private static final int NOTIFICATION_ID = 8801; + private static final String CHANNEL_ID = "location_streaming"; + + private static volatile boolean running = false; + + private LocationSource source; + private Location lastPublished; + + // static API + + /** Register a chat for location updates, then ensure the service is running. */ + public static void startSharing(Context context, int chatId, int durationSeconds) { + ActiveLocationChats.add(context, chatId); + DcHelper.getContext(context).sendLocationsToChat(chatId, durationSeconds); + ContextCompat.startForegroundService( + context, new Intent(context, LocationStreamingService.class)); + } + + /** Unregister a chat. If no chats remain, stop the service. */ + public static void stopSharing(Context context, int chatId) { + ActiveLocationChats.remove(context, chatId); + DcHelper.getContext(context).sendLocationsToChat(chatId, 0); + if (!DcHelper.getContext(context).isSendingLocationsToChat(0)) { + context.stopService(new Intent(context, LocationStreamingService.class)); + } + } + + public static void ensureRunning(Context context) { + if (!hasLocationPermission(context)) { + for (int chatId : ActiveLocationChats.getAllIds(context)) { + DcHelper.getContext(context).sendLocationsToChat(chatId, 0); + } + ActiveLocationChats.clear(context); + return; + } + ContextCompat.startForegroundService( + context, new Intent(context, LocationStreamingService.class)); + } + + public static boolean isRunning() { + return running; + } + + // lifecycle + + @Override + public void onCreate() { + super.onCreate(); + if (!hasLocationPermission(this)) { + Log.w(TAG, "Location permission not granted, stopping"); + stopSelf(); + return; + } + running = true; + promoteToForeground(); + beginLocationUpdates(); + } + + @Override + public int onStartCommand(@Nullable Intent intent, int flags, int startId) { + if (intent != null && ACTION_STOP.equals(intent.getAction())) { + stopSelf(); + return START_NOT_STICKY; + } + + // If the service is already running, and we already have a fix, + // push it immediately. + if (lastPublished != null) { + publishAndWrite(lastPublished); + } + + return START_STICKY; + } + + @Nullable + @Override + public IBinder onBind(Intent intent) { + return null; + } + + @Override + public void onDestroy() { + running = false; + if (source != null) { + source.stopUpdates(); + source = null; + } + LocationData.getInstance().clear(); + super.onDestroy(); + } + + @Override + public void onTimeout(int startId) { + stopSelf(); + } + + @Override + public void onTimeout(int startId, int fgsType) { + stopSelf(); + } + + // location + + private void beginLocationUpdates() { + source = LocationSourceFactory.create(this); + + source.getCurrentLocation(this, this::onNewLocation); + source.startUpdates(this, this::onNewLocation); + } + + private void onNewLocation(Location location) { + if (LocationUtils.isBetter(location, lastPublished)) { + publishAndWrite(location); + lastPublished = location; + } + } + + private void publishAndWrite(Location location) { + LocationData.getInstance().post(location); + + boolean keepGoing = + DcHelper.getContext(this) + .setLocation( + (float) location.getLatitude(), + (float) location.getLongitude(), + location.getAccuracy()); + + if (!keepGoing) { + ActiveLocationChats.clear(this); + stopSelf(); + } + } + + // foreground / notification + + private void promoteToForeground() { + ensureNotificationChannel(); + Notification notification = buildNotification(); + try { + int type = + Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q + ? ServiceInfo.FOREGROUND_SERVICE_TYPE_LOCATION + : 0; + ServiceCompat.startForeground(this, NOTIFICATION_ID, notification, type); + } catch (Exception e) { + // SecurityException on API 34+ if permission missing, + // ForegroundServiceStartNotAllowedException on API 31+ if in background. + Log.e(TAG, "Cannot promote to foreground", e); + stopSelf(); + } + } + + private void ensureNotificationChannel() { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + NotificationChannel channel = + new NotificationChannel( + CHANNEL_ID, + getString(R.string.location_streaming_channel_name), + NotificationManager.IMPORTANCE_LOW); + channel.setDescription(getString(R.string.location_streaming_channel_desc)); + channel.setShowBadge(false); + NotificationManager nm = getSystemService(NotificationManager.class); + if (nm != null) nm.createNotificationChannel(channel); + } + } + + private Notification buildNotification() { + Intent tapIntent = new Intent(this, ConversationListActivity.class); + PendingIntent contentPendingIntent = + PendingIntent.getActivity( + this, 0, tapIntent, PendingIntent.FLAG_UPDATE_CURRENT | PendingIntent.FLAG_IMMUTABLE); + + Intent stopIntent = new Intent(this, LocationStreamingService.class); + stopIntent.setAction(ACTION_STOP); + PendingIntent stopPendingIntent = + PendingIntent.getService(this, 1, stopIntent, PendingIntent.FLAG_IMMUTABLE); + + return new NotificationCompat.Builder(this, CHANNEL_ID) + .setContentTitle(getString(R.string.location_streaming_notification_title)) + .setContentText(getString(R.string.location_streaming_notification_text)) + .setSmallIcon(R.drawable.ic_location_on_white_24dp) + .setOngoing(true) + .setContentIntent(contentPendingIntent) + .addAction( + R.drawable.ic_stop_circle, getString(R.string.stop_sharing_location), stopPendingIntent) + .setPriority(NotificationCompat.PRIORITY_LOW) + .build(); + } + + private static boolean hasLocationPermission(Context context) { + return ContextCompat.checkSelfPermission( + context, android.Manifest.permission.ACCESS_FINE_LOCATION) + == PackageManager.PERMISSION_GRANTED + || ContextCompat.checkSelfPermission( + context, android.Manifest.permission.ACCESS_COARSE_LOCATION) + == PackageManager.PERMISSION_GRANTED; + } +} diff --git a/src/main/java/org/thoughtcrime/securesms/geolocation/LocationUtils.java b/src/main/java/org/thoughtcrime/securesms/geolocation/LocationUtils.java new file mode 100644 index 000000000..b2a2763f2 --- /dev/null +++ b/src/main/java/org/thoughtcrime/securesms/geolocation/LocationUtils.java @@ -0,0 +1,36 @@ +package org.thoughtcrime.securesms.geolocation; + +import android.location.Location; +import android.text.TextUtils; +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; + +public final class LocationUtils { + + private static final long STALE_THRESHOLD_MS = 15_000; + private static final double SIGNIFICANT_MOVE_M = 30.0; + private static final double MINOR_MOVE_M = 10.0; + private static final float SIGNIFICANT_ACCURACY_DELTA = 50f; + + private LocationUtils() {} + + public static boolean isBetter(@NonNull Location candidate, @Nullable Location current) { + + if (current == null) return true; + + long timeDelta = candidate.getTime() - current.getTime(); + if (timeDelta < -STALE_THRESHOLD_MS) return false; + + float accuracyDelta = candidate.getAccuracy() - current.getAccuracy(); + boolean significantlyLessAccurate = accuracyDelta > SIGNIFICANT_ACCURACY_DELTA; + boolean sameProvider = TextUtils.equals(candidate.getProvider(), current.getProvider()); + + if (significantlyLessAccurate && sameProvider) return false; + + boolean moreAccurate = accuracyDelta < 0; + double distance = candidate.distanceTo(current); + + if (distance > SIGNIFICANT_MOVE_M) return true; + return distance > MINOR_MOVE_M && moreAccurate; + } +} diff --git a/src/main/java/org/thoughtcrime/securesms/geolocation/PlatformLocationSource.java b/src/main/java/org/thoughtcrime/securesms/geolocation/PlatformLocationSource.java new file mode 100644 index 000000000..d4c6c4841 --- /dev/null +++ b/src/main/java/org/thoughtcrime/securesms/geolocation/PlatformLocationSource.java @@ -0,0 +1,100 @@ +package org.thoughtcrime.securesms.geolocation; + +import android.content.Context; +import android.location.LocationManager; +import android.util.Log; +import androidx.annotation.NonNull; +import androidx.core.location.LocationListenerCompat; +import androidx.core.location.LocationManagerCompat; +import androidx.core.location.LocationRequestCompat; +import java.util.ArrayList; +import java.util.List; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; + +public class PlatformLocationSource implements LocationSource { + + private static final String TAG = PlatformLocationSource.class.getSimpleName(); + private static final long UPDATE_INTERVAL_MS = 3_000; + private static final float MIN_DISTANCE_M = 5f; + + private LocationManager locationManager; + private final List activeListeners = new ArrayList<>(); + private final ExecutorService executor = Executors.newSingleThreadExecutor(); + + @Override + public void startUpdates(@NonNull Context context, @NonNull Callback callback) { + locationManager = (LocationManager) context.getSystemService(Context.LOCATION_SERVICE); + if (locationManager == null) { + Log.e(TAG, "LocationManager unavailable"); + return; + } + + requestProvider(LocationManager.GPS_PROVIDER, callback); + requestProvider(LocationManager.NETWORK_PROVIDER, callback); + } + + private void requestProvider(String provider, Callback callback) { + if (locationManager == null) return; + + try { + if (!locationManager.isProviderEnabled(provider)) return; + + LocationRequestCompat request = + new LocationRequestCompat.Builder(UPDATE_INTERVAL_MS) + .setMinUpdateDistanceMeters(MIN_DISTANCE_M) + .setQuality(LocationRequestCompat.QUALITY_HIGH_ACCURACY) + .build(); + + LocationListenerCompat listener = callback::onLocationUpdate; + + LocationManagerCompat.requestLocationUpdates( + locationManager, provider, request, executor, listener); + activeListeners.add(listener); + } catch (SecurityException | IllegalArgumentException e) { + Log.e(TAG, "Cannot request " + provider + " updates", e); + } + } + + @Override + public void stopUpdates() { + if (locationManager != null) { + for (LocationListenerCompat listener : activeListeners) { + try { + locationManager.removeUpdates(listener); + } catch (Exception e) { + Log.w(TAG, "Error removing listener", e); + } + } + activeListeners.clear(); + } + executor.shutdown(); + } + + @Override + public void getCurrentLocation(@NonNull Context context, @NonNull Callback callback) { + LocationManager lm = (LocationManager) context.getSystemService(Context.LOCATION_SERVICE); + if (lm == null) return; + + // Prefer GPS, but fall back to network if GPS isn't enabled + String provider = + lm.isProviderEnabled(LocationManager.GPS_PROVIDER) + ? LocationManager.GPS_PROVIDER + : LocationManager.NETWORK_PROVIDER; + + try { + LocationManagerCompat.getCurrentLocation( + lm, + provider, + (android.os.CancellationSignal) null, + executor, + location -> { + if (location != null) { + callback.onLocationUpdate(location); + } + }); + } catch (SecurityException e) { + Log.w(TAG, "No permission for getCurrentLocation", e); + } + } +} diff --git a/src/main/java/org/thoughtcrime/securesms/mms/AttachmentManager.java b/src/main/java/org/thoughtcrime/securesms/mms/AttachmentManager.java index c86d7d544..c54d0918c 100644 --- a/src/main/java/org/thoughtcrime/securesms/mms/AttachmentManager.java +++ b/src/main/java/org/thoughtcrime/securesms/mms/AttachmentManager.java @@ -47,7 +47,6 @@ import java.util.Iterator; import java.util.LinkedList; import java.util.List; import java.util.concurrent.ExecutionException; -import org.thoughtcrime.securesms.ApplicationContext; import org.thoughtcrime.securesms.MediaPreviewActivity; import org.thoughtcrime.securesms.R; import org.thoughtcrime.securesms.ShareLocationDialog; @@ -64,7 +63,8 @@ import org.thoughtcrime.securesms.components.audioplay.AudioPlaybackViewModel; import org.thoughtcrime.securesms.components.audioplay.AudioView; import org.thoughtcrime.securesms.connect.DcHelper; import org.thoughtcrime.securesms.database.AttachmentDatabase; -import org.thoughtcrime.securesms.geolocation.DcLocationManager; +import org.thoughtcrime.securesms.geolocation.ActiveLocationChats; +import org.thoughtcrime.securesms.geolocation.LocationStreamingService; import org.thoughtcrime.securesms.permissions.Permissions; import org.thoughtcrime.securesms.providers.PersistentBlobProvider; import org.thoughtcrime.securesms.scribbles.ScribbleActivity; @@ -490,47 +490,36 @@ public class AttachmentManager { } public static void selectLocation(Activity activity, int chatId) { - ApplicationContext applicationContext = ApplicationContext.getInstance(activity); - DcLocationManager dcLocationManager = applicationContext.getLocationManager(); + Context appContext = activity.getApplicationContext(); - if (DcHelper.getContext(applicationContext).isSendingLocationsToChat(chatId)) { - dcLocationManager.stopSharingLocation(chatId); - return; + if (DcHelper.getContext(appContext).isSendingLocationsToChat(chatId)) { + if (LocationStreamingService.isRunning()) { + LocationStreamingService.stopSharing(appContext, chatId); + return; + } + // Stale — service is dead but chat layer still thinks it's sharing. + // Clean up this chat and fall through to the fresh start flow. + ActiveLocationChats.remove(appContext, chatId); + DcHelper.getContext(appContext).sendLocationsToChat(chatId, 0); } - // see - // https://support.google.com/googleplay/android-developer/answer/9799150#zippy=%2Cstep-provide-prominent-in-app-disclosure - // for rationale dialog requirements - Permissions.PermissionsBuilder permissionsBuilder = - Permissions.with(activity) - .ifNecessary() - .withRationaleDialog( - "To share your live location with chat members, allow Delta Chat to use your location data.\n\nTo make live location work gaplessly, location data is used even when the app is closed or not in use.", - R.drawable.ic_location_on_white_24dp) - .withPermanentDenialDialog( - activity.getString(R.string.perm_explain_access_to_location_denied)) - .onAllGranted( - () -> { - ShareLocationDialog.show( - activity, - durationInSeconds -> { - if (durationInSeconds == 1) { - dcLocationManager.shareLastLocation(chatId); - } else { - dcLocationManager.shareLocation(durationInSeconds, chatId); - } - }); - }); - if (android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.Q) { - permissionsBuilder.request( - Manifest.permission.ACCESS_BACKGROUND_LOCATION, - Manifest.permission.ACCESS_FINE_LOCATION, - Manifest.permission.ACCESS_COARSE_LOCATION); - } else { - permissionsBuilder.request( - Manifest.permission.ACCESS_FINE_LOCATION, Manifest.permission.ACCESS_COARSE_LOCATION); - } - permissionsBuilder.execute(); + Permissions.with(activity) + .ifNecessary() + .withRationaleDialog( + activity.getString(R.string.location_rationale), R.drawable.ic_location_on_white_24dp) + .withPermanentDenialDialog( + activity.getString(R.string.perm_explain_access_to_location_denied)) + .onAllGranted( + () -> + ShareLocationDialog.show( + activity, + durationInSeconds -> + LocationStreamingService.startSharing( + appContext, chatId, durationInSeconds))) + .request( + android.Manifest.permission.ACCESS_FINE_LOCATION, + android.Manifest.permission.ACCESS_COARSE_LOCATION) + .execute(); } private @Nullable Uri getSlideUri() { diff --git a/src/main/res/drawable/ic_stop_circle.xml b/src/main/res/drawable/ic_stop_circle.xml new file mode 100644 index 000000000..04d2f81a9 --- /dev/null +++ b/src/main/res/drawable/ic_stop_circle.xml @@ -0,0 +1,11 @@ + + + diff --git a/src/main/res/values/strings.xml b/src/main/res/values/strings.xml index 9d6db3eea..ab22ac386 100644 --- a/src/main/res/values/strings.xml +++ b/src/main/res/values/strings.xml @@ -1039,6 +1039,13 @@ You have new messages %1$d messages in %2$d chats + + Location Streaming + Channel for On-demand Location Streaming + On-demand Location Streaming + You are sharing your location + To share your live location with chat members, allow Delta Chat to use your location data.\n\nTo make live location work gaplessly, location data is used even when the app is closed or not in use. + Permission required Continue From 61bf5aaad4784ac0908c3f7c5167e7129772a261 Mon Sep 17 00:00:00 2001 From: wch423 Date: Thu, 16 Apr 2026 22:37:36 +0200 Subject: [PATCH 2/5] Change how location sources work --- .../geolocation/GmsLocationSource.java | 24 +------- .../securesms/geolocation/LocationSource.java | 6 -- .../geolocation/LocationStreamingService.java | 20 +++--- .../securesms/geolocation/LocationUtils.java | 36 ----------- .../geolocation/PlatformLocationSource.java | 61 +++++++------------ .../securesms/mms/AttachmentManager.java | 12 ++-- 6 files changed, 43 insertions(+), 116 deletions(-) delete mode 100644 src/main/java/org/thoughtcrime/securesms/geolocation/LocationUtils.java diff --git a/src/gplay/java/org/thoughtcrime/securesms/geolocation/GmsLocationSource.java b/src/gplay/java/org/thoughtcrime/securesms/geolocation/GmsLocationSource.java index 3ebdbc755..106d9b138 100644 --- a/src/gplay/java/org/thoughtcrime/securesms/geolocation/GmsLocationSource.java +++ b/src/gplay/java/org/thoughtcrime/securesms/geolocation/GmsLocationSource.java @@ -15,9 +15,8 @@ import com.google.android.gms.location.Priority; public class GmsLocationSource implements LocationSource { private static final String TAG = GmsLocationSource.class.getSimpleName(); - private static final long UPDATE_INTERVAL_MS = 3000; - private static final long FASTEST_INTERVAL_MS = 1000; - private static final float MIN_DISTANCE_M = 5f; + private static final long UPDATE_INTERVAL_MS = 3_000; + private static final long FASTEST_INTERVAL_MS = 1_000; private FusedLocationProviderClient client; private LocationCallback locationCallback; @@ -29,7 +28,7 @@ public class GmsLocationSource implements LocationSource { LocationRequest request = new LocationRequest.Builder(Priority.PRIORITY_HIGH_ACCURACY, UPDATE_INTERVAL_MS) .setMinUpdateIntervalMillis(FASTEST_INTERVAL_MS) - .setMinUpdateDistanceMeters(MIN_DISTANCE_M) + .setMinUpdateDistanceMeters(0) .setWaitForAccurateLocation(false) .build(); @@ -59,21 +58,4 @@ public class GmsLocationSource implements LocationSource { locationCallback = null; } } - - @Override - public void getCurrentLocation(@NonNull Context context, @NonNull Callback callback) { - FusedLocationProviderClient client = LocationServices.getFusedLocationProviderClient(context); - try { - client - .getCurrentLocation(Priority.PRIORITY_HIGH_ACCURACY, null) - .addOnSuccessListener( - location -> { - if (location != null) { - callback.onLocationUpdate(location); - } - }); - } catch (SecurityException e) { - Log.w(TAG, "No permission for getCurrentLocation", e); - } - } } diff --git a/src/main/java/org/thoughtcrime/securesms/geolocation/LocationSource.java b/src/main/java/org/thoughtcrime/securesms/geolocation/LocationSource.java index 2c440c1f3..1a71019fd 100644 --- a/src/main/java/org/thoughtcrime/securesms/geolocation/LocationSource.java +++ b/src/main/java/org/thoughtcrime/securesms/geolocation/LocationSource.java @@ -11,12 +11,6 @@ public interface LocationSource { void stopUpdates(); - /** - * Request a single current location to seed the stream. May return null if no fresh fix is - * available. - */ - void getCurrentLocation(@NonNull Context context, @NonNull Callback callback); - interface Callback { void onLocationUpdate(@NonNull Location location); } diff --git a/src/main/java/org/thoughtcrime/securesms/geolocation/LocationStreamingService.java b/src/main/java/org/thoughtcrime/securesms/geolocation/LocationStreamingService.java index e2849e964..389641118 100644 --- a/src/main/java/org/thoughtcrime/securesms/geolocation/LocationStreamingService.java +++ b/src/main/java/org/thoughtcrime/securesms/geolocation/LocationStreamingService.java @@ -86,6 +86,7 @@ public class LocationStreamingService extends Service { @Override public int onStartCommand(@Nullable Intent intent, int flags, int startId) { if (intent != null && ACTION_STOP.equals(intent.getAction())) { + stopAllSharing(); stopSelf(); return START_NOT_STICKY; } @@ -99,6 +100,13 @@ public class LocationStreamingService extends Service { return START_STICKY; } + private void stopAllSharing() { + for (int chatId : ActiveLocationChats.getAllIds(this)) { + DcHelper.getContext(this).sendLocationsToChat(chatId, 0); + } + ActiveLocationChats.clear(this); + } + @Nullable @Override public IBinder onBind(Intent intent) { @@ -130,16 +138,13 @@ public class LocationStreamingService extends Service { private void beginLocationUpdates() { source = LocationSourceFactory.create(this); - - source.getCurrentLocation(this, this::onNewLocation); source.startUpdates(this, this::onNewLocation); } private void onNewLocation(Location location) { - if (LocationUtils.isBetter(location, lastPublished)) { - publishAndWrite(location); - lastPublished = location; - } + Log.d(TAG, "onNewLocation raw: " + location); + publishAndWrite(location); + lastPublished = location; } private void publishAndWrite(Location location) { @@ -151,9 +156,10 @@ public class LocationStreamingService extends Service { (float) location.getLatitude(), (float) location.getLongitude(), location.getAccuracy()); + Log.d(TAG, "keepGoing: " + keepGoing); if (!keepGoing) { - ActiveLocationChats.clear(this); + stopAllSharing(); stopSelf(); } } diff --git a/src/main/java/org/thoughtcrime/securesms/geolocation/LocationUtils.java b/src/main/java/org/thoughtcrime/securesms/geolocation/LocationUtils.java deleted file mode 100644 index b2a2763f2..000000000 --- a/src/main/java/org/thoughtcrime/securesms/geolocation/LocationUtils.java +++ /dev/null @@ -1,36 +0,0 @@ -package org.thoughtcrime.securesms.geolocation; - -import android.location.Location; -import android.text.TextUtils; -import androidx.annotation.NonNull; -import androidx.annotation.Nullable; - -public final class LocationUtils { - - private static final long STALE_THRESHOLD_MS = 15_000; - private static final double SIGNIFICANT_MOVE_M = 30.0; - private static final double MINOR_MOVE_M = 10.0; - private static final float SIGNIFICANT_ACCURACY_DELTA = 50f; - - private LocationUtils() {} - - public static boolean isBetter(@NonNull Location candidate, @Nullable Location current) { - - if (current == null) return true; - - long timeDelta = candidate.getTime() - current.getTime(); - if (timeDelta < -STALE_THRESHOLD_MS) return false; - - float accuracyDelta = candidate.getAccuracy() - current.getAccuracy(); - boolean significantlyLessAccurate = accuracyDelta > SIGNIFICANT_ACCURACY_DELTA; - boolean sameProvider = TextUtils.equals(candidate.getProvider(), current.getProvider()); - - if (significantlyLessAccurate && sameProvider) return false; - - boolean moreAccurate = accuracyDelta < 0; - double distance = candidate.distanceTo(current); - - if (distance > SIGNIFICANT_MOVE_M) return true; - return distance > MINOR_MOVE_M && moreAccurate; - } -} diff --git a/src/main/java/org/thoughtcrime/securesms/geolocation/PlatformLocationSource.java b/src/main/java/org/thoughtcrime/securesms/geolocation/PlatformLocationSource.java index d4c6c4841..f43fbcf97 100644 --- a/src/main/java/org/thoughtcrime/securesms/geolocation/PlatformLocationSource.java +++ b/src/main/java/org/thoughtcrime/securesms/geolocation/PlatformLocationSource.java @@ -2,25 +2,24 @@ package org.thoughtcrime.securesms.geolocation; import android.content.Context; import android.location.LocationManager; +import android.os.Build; import android.util.Log; import androidx.annotation.NonNull; +import androidx.core.content.ContextCompat; import androidx.core.location.LocationListenerCompat; import androidx.core.location.LocationManagerCompat; import androidx.core.location.LocationRequestCompat; import java.util.ArrayList; import java.util.List; -import java.util.concurrent.ExecutorService; -import java.util.concurrent.Executors; +import java.util.concurrent.Executor; public class PlatformLocationSource implements LocationSource { private static final String TAG = PlatformLocationSource.class.getSimpleName(); - private static final long UPDATE_INTERVAL_MS = 3_000; - private static final float MIN_DISTANCE_M = 5f; + private static final long UPDATE_INTERVAL_MS = 0; private LocationManager locationManager; private final List activeListeners = new ArrayList<>(); - private final ExecutorService executor = Executors.newSingleThreadExecutor(); @Override public void startUpdates(@NonNull Context context, @NonNull Callback callback) { @@ -30,27 +29,37 @@ public class PlatformLocationSource implements LocationSource { return; } - requestProvider(LocationManager.GPS_PROVIDER, callback); - requestProvider(LocationManager.NETWORK_PROVIDER, callback); + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { + // API 31+, fused provider manages GPS + network internally + requestProvider(context, LocationManager.FUSED_PROVIDER, callback); + } else { + // API 23–30: register on both providers separately. + requestProvider(context, LocationManager.GPS_PROVIDER, callback); + requestProvider(context, LocationManager.NETWORK_PROVIDER, callback); + } } - private void requestProvider(String provider, Callback callback) { + private void requestProvider(Context context, String provider, Callback callback) { if (locationManager == null) return; - try { - if (!locationManager.isProviderEnabled(provider)) return; + boolean enabled = locationManager.isProviderEnabled(provider); + Log.d(TAG, "Provider " + provider + " enabled: " + enabled); + if (!enabled) return; + try { LocationRequestCompat request = new LocationRequestCompat.Builder(UPDATE_INTERVAL_MS) - .setMinUpdateDistanceMeters(MIN_DISTANCE_M) + .setMinUpdateDistanceMeters(0) .setQuality(LocationRequestCompat.QUALITY_HIGH_ACCURACY) .build(); LocationListenerCompat listener = callback::onLocationUpdate; + Executor mainExecutor = ContextCompat.getMainExecutor(context); LocationManagerCompat.requestLocationUpdates( - locationManager, provider, request, executor, listener); + locationManager, provider, request, mainExecutor, listener); activeListeners.add(listener); + Log.d(TAG, "Registered on provider: " + provider); } catch (SecurityException | IllegalArgumentException e) { Log.e(TAG, "Cannot request " + provider + " updates", e); } @@ -68,33 +77,5 @@ public class PlatformLocationSource implements LocationSource { } activeListeners.clear(); } - executor.shutdown(); - } - - @Override - public void getCurrentLocation(@NonNull Context context, @NonNull Callback callback) { - LocationManager lm = (LocationManager) context.getSystemService(Context.LOCATION_SERVICE); - if (lm == null) return; - - // Prefer GPS, but fall back to network if GPS isn't enabled - String provider = - lm.isProviderEnabled(LocationManager.GPS_PROVIDER) - ? LocationManager.GPS_PROVIDER - : LocationManager.NETWORK_PROVIDER; - - try { - LocationManagerCompat.getCurrentLocation( - lm, - provider, - (android.os.CancellationSignal) null, - executor, - location -> { - if (location != null) { - callback.onLocationUpdate(location); - } - }); - } catch (SecurityException e) { - Log.w(TAG, "No permission for getCurrentLocation", e); - } } } diff --git a/src/main/java/org/thoughtcrime/securesms/mms/AttachmentManager.java b/src/main/java/org/thoughtcrime/securesms/mms/AttachmentManager.java index c54d0918c..a139ddf75 100644 --- a/src/main/java/org/thoughtcrime/securesms/mms/AttachmentManager.java +++ b/src/main/java/org/thoughtcrime/securesms/mms/AttachmentManager.java @@ -510,12 +510,12 @@ public class AttachmentManager { .withPermanentDenialDialog( activity.getString(R.string.perm_explain_access_to_location_denied)) .onAllGranted( - () -> - ShareLocationDialog.show( - activity, - durationInSeconds -> - LocationStreamingService.startSharing( - appContext, chatId, durationInSeconds))) + () -> { + ShareLocationDialog.show( + activity, + durationInSeconds -> + LocationStreamingService.startSharing(appContext, chatId, durationInSeconds)); + }) .request( android.Manifest.permission.ACCESS_FINE_LOCATION, android.Manifest.permission.ACCESS_COARSE_LOCATION) From 885c902b14141fb31a15d6e9af256902472718da Mon Sep 17 00:00:00 2001 From: wch423 Date: Fri, 17 Apr 2026 21:11:56 +0200 Subject: [PATCH 3/5] Allow fallback to different providers --- .../securesms/ApplicationContext.java | 5 ---- .../securesms/ConversationListActivity.java | 4 +++ .../geolocation/PlatformLocationSource.java | 25 ++++++++++++------- 3 files changed, 20 insertions(+), 14 deletions(-) diff --git a/src/main/java/org/thoughtcrime/securesms/ApplicationContext.java b/src/main/java/org/thoughtcrime/securesms/ApplicationContext.java index b72c23b67..08fef794c 100644 --- a/src/main/java/org/thoughtcrime/securesms/ApplicationContext.java +++ b/src/main/java/org/thoughtcrime/securesms/ApplicationContext.java @@ -38,7 +38,6 @@ import org.thoughtcrime.securesms.connect.KeepAliveService; import org.thoughtcrime.securesms.connect.NetworkStateReceiver; import org.thoughtcrime.securesms.crypto.DatabaseSecret; import org.thoughtcrime.securesms.crypto.DatabaseSecretProvider; -import org.thoughtcrime.securesms.geolocation.LocationStreamingService; import org.thoughtcrime.securesms.jobmanager.JobManager; import org.thoughtcrime.securesms.notifications.FcmReceiveService; import org.thoughtcrime.securesms.notifications.InChatSounds; @@ -250,10 +249,6 @@ public class ApplicationContext extends MultiDexApplication { initLock.notifyAll(); Log.i(TAG, "DcAccounts initialization complete"); - if (dcContext.isSendingLocationsToChat(0)) { - LocationStreamingService.ensureRunning(this); - } - // set translations before starting I/O to avoid sending untranslated MDNs (issue // #2288) DcHelper.setStockTranslations(this); diff --git a/src/main/java/org/thoughtcrime/securesms/ConversationListActivity.java b/src/main/java/org/thoughtcrime/securesms/ConversationListActivity.java index 76c538a73..2080a54b8 100644 --- a/src/main/java/org/thoughtcrime/securesms/ConversationListActivity.java +++ b/src/main/java/org/thoughtcrime/securesms/ConversationListActivity.java @@ -67,6 +67,7 @@ import org.thoughtcrime.securesms.components.SearchToolbar; import org.thoughtcrime.securesms.connect.AccountManager; import org.thoughtcrime.securesms.connect.DcHelper; import org.thoughtcrime.securesms.connect.DirectShareUtil; +import org.thoughtcrime.securesms.geolocation.LocationStreamingService; import org.thoughtcrime.securesms.mms.GlideApp; import org.thoughtcrime.securesms.permissions.Permissions; import org.thoughtcrime.securesms.providers.PersistentBlobProvider; @@ -440,6 +441,9 @@ public class ConversationListActivity extends PassphraseRequiredActionBarActivit refreshTitle(); invalidateOptionsMenu(); DirectShareUtil.triggerRefreshDirectShare(this); + if (DcHelper.getContext(this).isSendingLocationsToChat(0)) { + LocationStreamingService.ensureRunning(this); + } } @Override diff --git a/src/main/java/org/thoughtcrime/securesms/geolocation/PlatformLocationSource.java b/src/main/java/org/thoughtcrime/securesms/geolocation/PlatformLocationSource.java index f43fbcf97..2a0164004 100644 --- a/src/main/java/org/thoughtcrime/securesms/geolocation/PlatformLocationSource.java +++ b/src/main/java/org/thoughtcrime/securesms/geolocation/PlatformLocationSource.java @@ -29,22 +29,27 @@ public class PlatformLocationSource implements LocationSource { return; } + boolean registered = false; + + // API 31+: try the platform fused provider first if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { - // API 31+, fused provider manages GPS + network internally - requestProvider(context, LocationManager.FUSED_PROVIDER, callback); - } else { - // API 23–30: register on both providers separately. + registered = requestProvider(context, LocationManager.FUSED_PROVIDER, callback); + } + + // Fall back (or complement) with individual providers + if (!registered) { requestProvider(context, LocationManager.GPS_PROVIDER, callback); requestProvider(context, LocationManager.NETWORK_PROVIDER, callback); } } - private void requestProvider(Context context, String provider, Callback callback) { - if (locationManager == null) return; + private boolean requestProvider(Context context, String provider, Callback callback) { + if (locationManager == null) return false; - boolean enabled = locationManager.isProviderEnabled(provider); - Log.d(TAG, "Provider " + provider + " enabled: " + enabled); - if (!enabled) return; + if (!locationManager.isProviderEnabled(provider)) { + Log.d(TAG, "Provider " + provider + " not enabled, skipping"); + return false; + } try { LocationRequestCompat request = @@ -60,8 +65,10 @@ public class PlatformLocationSource implements LocationSource { locationManager, provider, request, mainExecutor, listener); activeListeners.add(listener); Log.d(TAG, "Registered on provider: " + provider); + return true; } catch (SecurityException | IllegalArgumentException e) { Log.e(TAG, "Cannot request " + provider + " updates", e); + return false; } } From 3bccb3fb84c11605aa851f3714526350ac9d3148 Mon Sep 17 00:00:00 2001 From: adb Date: Sat, 18 Apr 2026 19:44:39 +0200 Subject: [PATCH 4/5] Apply suggestion from @adbenitez --- CHANGELOG.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 376f4c2d4..15505ca48 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,7 +4,7 @@ * Better incoming call system integration * Calls are not experimental anymore and don't need to be manually enabled -* Allow sharing location while the app is in background +* Display a permanent notification when doing location streaming and get rid of dangerous "Access Location in Background" permission ## v2.49.0 2026-04 From dc3ca8ff70332aeae0af7bf7da8cdfa65593ac9d Mon Sep 17 00:00:00 2001 From: adbenitez Date: Sat, 18 Apr 2026 19:48:39 +0200 Subject: [PATCH 5/5] use "Location Streaming" for notification title and channel name --- .../securesms/geolocation/LocationStreamingService.java | 2 +- src/main/res/values/strings.xml | 3 +-- 2 files changed, 2 insertions(+), 3 deletions(-) diff --git a/src/main/java/org/thoughtcrime/securesms/geolocation/LocationStreamingService.java b/src/main/java/org/thoughtcrime/securesms/geolocation/LocationStreamingService.java index 389641118..f6d2bd8db 100644 --- a/src/main/java/org/thoughtcrime/securesms/geolocation/LocationStreamingService.java +++ b/src/main/java/org/thoughtcrime/securesms/geolocation/LocationStreamingService.java @@ -188,7 +188,7 @@ public class LocationStreamingService extends Service { NotificationChannel channel = new NotificationChannel( CHANNEL_ID, - getString(R.string.location_streaming_channel_name), + getString(R.string.location_streaming_notification_title), NotificationManager.IMPORTANCE_LOW); channel.setDescription(getString(R.string.location_streaming_channel_desc)); channel.setShowBadge(false); diff --git a/src/main/res/values/strings.xml b/src/main/res/values/strings.xml index ab22ac386..3bbfaca00 100644 --- a/src/main/res/values/strings.xml +++ b/src/main/res/values/strings.xml @@ -1040,9 +1040,8 @@ %1$d messages in %2$d chats - Location Streaming Channel for On-demand Location Streaming - On-demand Location Streaming + Location Streaming You are sharing your location To share your live location with chat members, allow Delta Chat to use your location data.\n\nTo make live location work gaplessly, location data is used even when the app is closed or not in use.