Compare commits

...

25 Commits

Author SHA1 Message Date
copilot-swe-agent[bot] 1d2b379e5a Document ProGuard retrace usage in BUILDING.md
Agent-Logs-Url: https://github.com/ArcaneChat/android/sessions/9b58d3e8-6163-4dac-8ca0-d6e7fa2df3a8

Co-authored-by: adbenitez <24558636+adbenitez@users.noreply.github.com>
2026-04-28 13:52:01 +00:00
copilot-swe-agent[bot] d2c3ed8be3 Include ProGuard mapping.txt in release symbols zip
Agent-Logs-Url: https://github.com/ArcaneChat/android/sessions/5f6429cb-14d1-4558-863e-ea68cf744422

Co-authored-by: adbenitez <24558636+adbenitez@users.noreply.github.com>
2026-04-28 13:45:52 +00:00
adbenitez f870e9c8fd Merge remote-tracking branch 'upstream/main' 2026-04-22 21:48:58 +02:00
wchen342 d5982ba09f Merge pull request #4361 from deltachat/wch423/location-streaming
Add foreground service for location streaming
2026-04-18 17:30:56 -04:00
adbenitez 22d70d7bd4 Merge remote-tracking branch 'upstream/main' 2026-04-18 22:15:03 +02:00
adb 09bdc32a9c Merge pull request #4315 from deltachat/r10s/markfresh
feat: mark messages as "unread"
2026-04-18 21:09:18 +02:00
adb 7763ba17bf Merge branch 'main' into r10s/markfresh 2026-04-18 20:15:57 +02:00
adb 965204c46f Merge branch 'main' into wch423/location-streaming 2026-04-18 19:56:00 +02:00
adbenitez 815e4def6f Merge branch 'wch423/location-streaming' of https://github.com/deltachat/deltachat-android into wch423/location-streaming 2026-04-18 19:54:17 +02:00
adbenitez dc3ca8ff70 use "Location Streaming" for notification title and channel name 2026-04-18 19:48:39 +02:00
adb 3bccb3fb84 Apply suggestion from @adbenitez 2026-04-18 19:44:39 +02:00
adb 402d93b5a4 Merge pull request #4363 from deltachat/adb/allow-autoplay
allow webxdc to auto-play audio without requiring user gesture/touch
2026-04-18 17:10:48 +02:00
adbenitez 927dc46431 update changelog 2026-04-18 00:02:19 +02:00
adbenitez 87c32b1904 allow webxdc to auto-play audio without requiring user gesture/touch 2026-04-17 23:59:51 +02:00
adbenitez bb97213460 apply spotless 2026-04-17 23:52:22 +02:00
adbenitez 9ca9bf1acd remove unnecessary entry in src/main/res/xml/file_provider_paths.xml 2026-04-17 23:45:36 +02:00
wch423 885c902b14 Allow fallback to different providers 2026-04-17 21:11:56 +02:00
wch423 61bf5aaad4 Change how location sources work 2026-04-17 20:30:57 +02:00
wch423 5d76586ac1 Add foreground service for location streaming 2026-04-15 19:07:34 +02:00
B. Petersen beb45af440 prefer 'mark noticed' over 'mark fresh' 2026-04-02 15:50:40 +02:00
adbenitez 87a21eb0f2 apply spotless 2026-04-02 15:41:41 +02:00
adbenitez dea51cd356 use RPC API instead of C API 2026-04-02 15:41:41 +02:00
B. Petersen ed540e5584 update CHANGLOG 2026-04-02 15:41:38 +02:00
B. Petersen 9f80c9f35f make spotless formatter happy 2026-04-02 15:40:32 +02:00
B. Petersen 5dec1b24cd option to mark chat fresh 2026-04-02 15:40:32 +02:00
40 changed files with 725 additions and 489 deletions
+11
View File
@@ -243,3 +243,14 @@ $ANDROID_NDK_ROOT/ndk-stack --sym obj/local/armeabi-v7a --dump crash.txt > decod
`obj/local/armeabi-v7a` is the extracted path from `deltachat-gplay-release-X.X.X.apk-symbols.zip` file from https://download.delta.chat/android/symbols/
Replace `armeabi-v7a` by the correct architecture the logs come from (can be guessed by trial and error)
### Deobfuscating Java/Kotlin Stack Traces
Because the app uses code minification (ProGuard/R8), Java stack traces in crash reports are obfuscated.
To decode them, use `retrace` with the `mapping.txt` file that is included in the symbols zip:
```
retrace mapping.txt crash.txt > decoded-crash.txt
```
`mapping.txt` is extracted from the same `deltachat-gplay-release-X.X.X.apk-symbols.zip` file available at https://download.delta.chat/android/symbols/
+3
View File
@@ -4,6 +4,9 @@
* Better incoming call system integration
* Calls are not experimental anymore and don't need to be manually enabled
* Display a permanent notification when doing location streaming and get rid of dangerous "Access Location in Background" permission
* Allow mini-apps to play audio without user interaction
* Mark chats as unread (long tap a chat and select the corresponding option from the three-dot-menu)
## v2.49.0
2026-04
+2 -1
View File
@@ -223,7 +223,7 @@ spotless {
}
format 'xml', {
target 'src/*/res/**/*.xml', 'src/*/AndroidManifest.xml'
targetExclude 'src/*/res/values*/strings.xml' // line-break changes invalidate translations
targetExclude 'src/*/res/values*/strings*.xml' // line-break changes invalidate translations
eclipseWtp('xml').configFile('spotless/eclipse-wtp-xml.prefs')
trimTrailingWhitespace()
endWithNewline()
@@ -307,6 +307,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'
@@ -25,7 +25,7 @@ ArcaneChat is a Delta Chat client and was created with a focus on usability, goo
<li>Multiple color themes/skins</li>
<li>It is possible to disable profiles to completely disconnect them saving data/bandwidth</li>
<li>You can easily see the connection status of all your profiles in the profile switcher</li>
<li>Extra option to share location for 12 hours</li>
<li>Extra option to share location for 24 hours</li>
<li>Clicking on a message with a POI location, will open the POI on the map</li>
<li>Last-seen status of contacts is shown in your contact list, like in WhatsApp, Telegram, etc.</li>
<li>Videos are played in loop, useful for short GIF videos</li>
+1
View File
@@ -1393,6 +1393,7 @@ JNIEXPORT jint Java_com_b44t_messenger_DcMsg_getId(JNIEnv *env, jobject obj)
return dc_msg_get_id(get_dc_msg(env, obj));
}
JNIEXPORT jstring Java_com_b44t_messenger_DcMsg_getText(JNIEnv *env, jobject obj)
{
char* temp = dc_msg_get_text(get_dc_msg(env, obj));
+1
View File
@@ -34,6 +34,7 @@ cd ../..
SYMBOLS_ZIP="$APK-symbols.zip"
rm $SYMBOLS_ZIP
zip -r $SYMBOLS_ZIP obj
zip $SYMBOLS_ZIP build/outputs/mapping/gplayRelease/mapping.txt
ls -l $SYMBOLS_ZIP
rsync --progress $SYMBOLS_ZIP jekyll@download.delta.chat:/var/www/html/download/android/symbols/
@@ -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();
}
}
@@ -0,0 +1,61 @@
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 = 3_000;
private static final long FASTEST_INTERVAL_MS = 1_000;
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(0)
.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;
}
}
}
@@ -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;
}
}
}
+6 -5
View File
@@ -71,6 +71,7 @@
android:name="android.permission.FOREGROUND_SERVICE_MEDIA_PLAYBACK" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_CAMERA" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_MICROPHONE" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_LOCATION" />
<uses-permission android:name="android.permission.MANAGE_OWN_CALLS" />
<!-- force compiling libs on older sdk than supported; runtime checks are required -->
@@ -81,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"
@@ -464,10 +464,6 @@
android:configChanges="touchscreen|keyboard|keyboardHidden|orientation|screenLayout|screenSize">
</activity>
<service
android:name=".geolocation.LocationBackgroundService"
android:foregroundServiceType="location" />
<service
android:name=".service.GenericForegroundService"
android:foregroundServiceType="dataSync" />
@@ -476,6 +472,11 @@
android:name=".service.FetchForegroundService"
android:foregroundServiceType="dataSync" />
<service
android:name=".geolocation.LocationStreamingService"
android:foregroundServiceType="location"
android:exported="false" />
<service
android:name=".service.AudioPlaybackService"
android:foregroundServiceType="mediaPlayback"
@@ -40,7 +40,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.DcLocationManager;
import org.thoughtcrime.securesms.jobmanager.JobManager;
import org.thoughtcrime.securesms.notifications.FcmReceiveService;
import org.thoughtcrime.securesms.notifications.InChatSounds;
@@ -61,7 +60,6 @@ public class ApplicationContext extends MultiDexApplication {
private Rpc rpc;
private DcContext dcContext;
private DcLocationManager dcLocationManager;
private DcEventCenter eventCenter;
private NotificationCenter notificationCenter;
private JobManager jobManager;
@@ -126,15 +124,6 @@ public class ApplicationContext extends MultiDexApplication {
}
}
/**
* Get DcLocationManager instance, waiting for initialization if necessary. This method is
* thread-safe and will block until initialization is complete.
*/
public DcLocationManager getLocationManager() {
ensureInitialized();
return dcLocationManager;
}
/**
* Get DcEventCenter instance, waiting for initialization if necessary. This method is thread-safe
* and will block until initialization is complete.
@@ -282,7 +271,6 @@ public class ApplicationContext extends MultiDexApplication {
}
dcContext = dcAccounts.getSelectedAccount();
notificationCenter = new NotificationCenter(this);
dcLocationManager = new DcLocationManager(this, dcContext);
isInitialized = true;
initLock.notifyAll();
@@ -13,6 +13,7 @@ import android.content.Intent;
import android.graphics.Bitmap;
import android.net.Uri;
import android.os.AsyncTask;
import android.util.Log;
import android.view.Menu;
import android.view.MenuInflater;
import android.view.MenuItem;
@@ -26,6 +27,8 @@ import androidx.core.content.pm.ShortcutInfoCompat;
import androidx.core.content.pm.ShortcutManagerCompat;
import androidx.core.graphics.drawable.IconCompat;
import androidx.fragment.app.Fragment;
import chat.delta.rpc.Rpc;
import chat.delta.rpc.RpcException;
import com.b44t.messenger.DcChat;
import com.b44t.messenger.DcContact;
import com.b44t.messenger.DcContext;
@@ -44,6 +47,7 @@ import org.thoughtcrime.securesms.util.task.SnackbarAsyncTask;
import org.thoughtcrime.securesms.util.views.ProgressDialog;
public abstract class BaseConversationListFragment extends Fragment implements ActionMode.Callback {
private static final String TAG = BaseConversationListFragment.class.getSimpleName();
protected ActionMode actionMode;
protected PulsingFloatingActionButton fab;
@@ -170,6 +174,17 @@ public abstract class BaseConversationListFragment extends Fragment implements A
return false;
}
private boolean areSomeSelectedChatsFresh() {
DcContext dcContext = DcHelper.getContext(requireActivity());
final Set<Long> selectedChats = getListAdapter().getBatchSelections();
for (long chatId : selectedChats) {
if (dcContext.getFreshMsgCount((int) chatId) > 0) {
return true;
}
}
return false;
}
private void handlePinAllSelected() {
final DcContext dcContext = DcHelper.getContext(requireActivity());
final Set<Long> selectedConversations =
@@ -229,6 +244,26 @@ public abstract class BaseConversationListFragment extends Fragment implements A
}
}
private void handleMarkfreshSelected() {
final Set<Long> selectedConversations =
new HashSet<Long>(getListAdapter().getBatchSelections());
final Rpc rpc = DcHelper.getRpc(requireActivity());
try {
int accId = rpc.getSelectedAccountId();
for (long chatId : selectedConversations) {
rpc.markfreshChat(accId, (int) chatId);
}
} catch (RpcException e) {
Log.e(TAG, "RPC error", e);
}
if (actionMode != null) {
actionMode.finish();
actionMode = null;
}
}
@SuppressLint("StaticFieldLeak")
private void handleArchiveAllSelected() {
final DcContext dcContext = DcHelper.getContext(requireActivity());
@@ -409,6 +444,7 @@ public abstract class BaseConversationListFragment extends Fragment implements A
if (!isRelayingMessageContent(requireActivity())) {
final int selectedCount = getListAdapter().getBatchSelections().size();
menu.findItem(R.id.menu_add_to_home_screen).setVisible(selectedCount == 1);
MenuItem archiveItem = menu.findItem(R.id.menu_archive_selected);
if (offerToArchive()) {
archiveItem.setIcon(R.drawable.ic_archive_white_24dp);
@@ -417,6 +453,7 @@ public abstract class BaseConversationListFragment extends Fragment implements A
archiveItem.setIcon(R.drawable.ic_unarchive_white_24dp);
archiveItem.setTitle(R.string.menu_unarchive_chat);
}
MenuItem pinItem = menu.findItem(R.id.menu_pin_selected);
if (areSomeSelectedChatsUnpinned()) {
pinItem.setIcon(R.drawable.ic_pin_white);
@@ -425,12 +462,17 @@ public abstract class BaseConversationListFragment extends Fragment implements A
pinItem.setIcon(R.drawable.ic_unpin_white);
pinItem.setTitle(R.string.unpin_chat);
}
MenuItem muteItem = menu.findItem(R.id.menu_mute_selected);
if (areSomeSelectedChatsUnmuted()) {
muteItem.setTitle(R.string.menu_mute);
} else {
muteItem.setTitle(R.string.menu_unmute);
}
final boolean someAreFresh = areSomeSelectedChatsFresh();
menu.findItem(R.id.menu_marknoticed_selected).setVisible(someAreFresh);
menu.findItem(R.id.menu_markfresh_selected).setVisible(!someAreFresh);
}
}
@@ -484,6 +526,9 @@ public abstract class BaseConversationListFragment extends Fragment implements A
} else if (itemId == R.id.menu_marknoticed_selected) {
handleMarknoticedSelected();
return true;
} else if (itemId == R.id.menu_markfresh_selected) {
handleMarkfreshSelected();
return true;
} else if (itemId == R.id.menu_add_to_home_screen) {
handleAddToHomeScreen();
return true;
@@ -69,6 +69,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;
@@ -449,6 +450,9 @@ public class ConversationListActivity extends PassphraseRequiredActionBarActivit
refreshTitle();
invalidateOptionsMenu();
DirectShareUtil.triggerRefreshDirectShare(this);
if (DcHelper.getContext(this).isSendingLocationsToChat(0)) {
LocationStreamingService.ensureRunning(this);
}
}
@Override
@@ -688,7 +692,11 @@ public class ConversationListActivity extends PassphraseRequiredActionBarActivit
// Util.copy(inputStream, new FileOutputStream(outputFile));
// msg.setFile(outputFile, "image/jpeg");
msg.setText(getString(R.string.update_2_46ac, "https://arcanechat.me/#contribute", "https://i.delta.chat/#0A45953086F0C166D3BAF1D4BB2025496E4C2704&x=MVPi07rQBEmHO4FRb3brpwDe&j=n8mkKqu42WAKKUCx1bQOVh23&s=AM1YCd_2hKuiSiTEb-2177On&a=arcanechat%40arcanechat.me&n=ArcaneChat&b=ArcaneChat+Channel"));
msg.setText(
getString(
R.string.update_2_46ac,
"https://arcanechat.me/#contribute",
"https://i.delta.chat/#0A45953086F0C166D3BAF1D4BB2025496E4C2704&x=MVPi07rQBEmHO4FRb3brpwDe&j=n8mkKqu42WAKKUCx1bQOVh23&s=AM1YCd_2hKuiSiTEb-2177On&a=arcanechat%40arcanechat.me&n=ArcaneChat&b=ArcaneChat+Channel"));
}
dcContext.addDeviceMsg(deviceMsgLabel, msg);
@@ -33,7 +33,7 @@ public class ShareLocationDialog {
shareLocationUnit = 6 * 60 * 60;
break;
case 5:
shareLocationUnit = 12 * 60 * 60;
shareLocationUnit = 24 * 60 * 60;
break;
}
@@ -240,6 +240,7 @@ public class WebxdcActivity extends WebViewActivity implements DcEventCenter.DcE
WebSettings webSettings = webView.getSettings();
webSettings.setJavaScriptEnabled(true);
webSettings.setMediaPlaybackRequiresUserGesture(false);
webSettings.setAllowFileAccess(false);
webSettings.setBlockNetworkLoads(!internetAccess);
webSettings.setAllowContentAccess(false);
@@ -26,6 +26,7 @@ import androidx.loader.app.LoaderManager;
import org.thoughtcrime.securesms.R;
import org.thoughtcrime.securesms.connect.DcHelper;
import org.thoughtcrime.securesms.permissions.Permissions;
import org.thoughtcrime.securesms.util.Prefs;
import org.thoughtcrime.securesms.util.ViewUtil;
public class AttachmentTypeSelector extends PopupWindow {
@@ -45,7 +46,7 @@ public class AttachmentTypeSelector extends PopupWindow {
private final @NonNull ImageView imageButton;
private final @NonNull ImageView documentButton;
private final @NonNull ImageView contactButton;
// private final @NonNull ImageView cameraButton;
// private final @NonNull ImageView cameraButton;
private final @NonNull ImageView videoButton;
private final @NonNull ImageView locationButton;
private final @NonNull ImageView webxdcButton;
@@ -87,11 +88,9 @@ public class AttachmentTypeSelector extends PopupWindow {
this.webxdcButton.setOnClickListener(new PropagatingClickListener(ADD_WEBXDC));
this.recentRail.setListener(new RecentPhotoSelectedListener());
// disable location streaming button for now
// if (!Prefs.isLocationStreamingEnabled(context)) {
this.locationButton.setVisibility(View.GONE);
ViewUtil.findById(layout, R.id.location_button_label).setVisibility(View.GONE);
// }
if (!Prefs.isLocationStreamingEnabled(context)) {
ViewUtil.findById(layout, R.id.location_linear_layout).setVisibility(View.GONE);
}
setLocationButtonImage(context);
@@ -1,6 +1,5 @@
package org.thoughtcrime.securesms.connect;
import android.annotation.TargetApi;
import android.app.Notification;
import android.app.NotificationChannel;
import android.app.NotificationManager;
@@ -11,6 +10,7 @@ import android.content.Intent;
import android.os.Build;
import android.os.IBinder;
import android.util.Log;
import androidx.annotation.RequiresApi;
import androidx.core.app.NotificationCompat;
import androidx.core.content.ContextCompat;
import org.thoughtcrime.securesms.ConversationListActivity;
@@ -119,7 +119,7 @@ public class KeepAliveService extends Service {
private static boolean ch_created = false;
@TargetApi(Build.VERSION_CODES.O)
@RequiresApi(Build.VERSION_CODES.O)
private static void createFgNotificationChannel(Context context) {
if (!ch_created) {
ch_created = true;
@@ -0,0 +1,53 @@
package org.thoughtcrime.securesms.geolocation;
import android.content.Context;
import android.content.SharedPreferences;
import java.util.HashSet;
import java.util.Set;
public final class ActiveLocationChats {
private static final String PREFS_NAME = "location_streaming";
private static final String KEY_ACTIVE = "active_chat_ids";
private ActiveLocationChats() {}
private static SharedPreferences prefs(Context context) {
return context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE);
}
/**
* Add a chat. Uses commit() to guarantee the write reaches disk before the process can die to
* preserve the superset invariant.
*/
static void add(Context context, int chatId) {
Set<String> 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<String> 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<Integer> getAllIds(Context context) {
Set<Integer> ids = new HashSet<>();
for (String s : getAll(context)) {
try {
ids.add(Integer.parseInt(s));
} catch (NumberFormatException ignored) {
}
}
return ids;
}
private static Set<String> getAll(Context context) {
return prefs(context).getStringSet(KEY_ACTIVE, new HashSet<>());
}
}
@@ -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);
}
}
@@ -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<Integer> 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();
}
}
}
@@ -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);
}
}
}
@@ -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<Location> liveLocation = new MutableLiveData<>();
private LocationData() {}
public static LocationData getInstance() {
return INSTANCE;
}
public LiveData<Location> observable() {
return liveLocation;
}
@Nullable
public Location current() {
return liveLocation.getValue();
}
void post(@NonNull Location location) {
liveLocation.postValue(location);
}
void clear() {
liveLocation.postValue(null);
}
}
@@ -0,0 +1,17 @@
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();
interface Callback {
void onLocationUpdate(@NonNull Location location);
}
}
@@ -0,0 +1,231 @@
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())) {
stopAllSharing();
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;
}
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) {
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.startUpdates(this, this::onNewLocation);
}
private void onNewLocation(Location location) {
Log.d(TAG, "onNewLocation raw: " + location);
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());
Log.d(TAG, "keepGoing: " + keepGoing);
if (!keepGoing) {
stopAllSharing();
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_notification_title),
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;
}
}
@@ -0,0 +1,88 @@
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.Executor;
public class PlatformLocationSource implements LocationSource {
private static final String TAG = PlatformLocationSource.class.getSimpleName();
private static final long UPDATE_INTERVAL_MS = 0;
private LocationManager locationManager;
private final List<LocationListenerCompat> activeListeners = new ArrayList<>();
@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;
}
boolean registered = false;
// API 31+: try the platform fused provider first
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
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 boolean requestProvider(Context context, String provider, Callback callback) {
if (locationManager == null) return false;
if (!locationManager.isProviderEnabled(provider)) {
Log.d(TAG, "Provider " + provider + " not enabled, skipping");
return false;
}
try {
LocationRequestCompat request =
new LocationRequestCompat.Builder(UPDATE_INTERVAL_MS)
.setMinUpdateDistanceMeters(0)
.setQuality(LocationRequestCompat.QUALITY_HIGH_ACCURACY)
.build();
LocationListenerCompat listener = callback::onLocationUpdate;
Executor mainExecutor = ContextCompat.getMainExecutor(context);
LocationManagerCompat.requestLocationUpdates(
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;
}
}
@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();
}
}
}
@@ -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 ArcaneChat 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() {
+11
View File
@@ -0,0 +1,11 @@
<vector
xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="960"
android:viewportHeight="960"
android:tint="?attr/colorControlNormal">
<path
android:fillColor="@android:color/white"
android:pathData="M320,640L640,640L640,320L320,320L320,640ZM480,880Q397,880 324,848.5Q251,817 197,763Q143,709 111.5,636Q80,563 80,480Q80,397 111.5,324Q143,251 197,197Q251,143 324,111.5Q397,80 480,80Q563,80 636,111.5Q709,143 763,197Q817,251 848.5,324Q880,397 880,480Q880,563 848.5,636Q817,709 763,763Q709,817 636,848.5Q563,880 480,880ZM480,800Q614,800 707,707Q800,614 800,480Q800,346 707,253Q614,160 480,160Q346,160 253,253Q160,346 160,480Q160,614 253,707Q346,800 480,800ZM480,480Q480,480 480,480Q480,480 480,480Q480,480 480,480Q480,480 480,480Q480,480 480,480Q480,480 480,480Q480,480 480,480Q480,480 480,480Z" />
</vector>
+5
View File
@@ -31,6 +31,11 @@
android:id="@+id/menu_marknoticed_selected"
app:showAsAction="never" />
<item
android:title="@string/mark_as_unread"
android:id="@+id/menu_markfresh_selected"
app:showAsAction="never" />
<item
android:title="@string/add_to_home_screen"
android:id="@+id/menu_add_to_home_screen"
-1
View File
@@ -377,7 +377,6 @@
<string name="share_location_for_one_hour">für 1 Stunde</string>
<string name="share_location_for_two_hours">für 2 Stunden</string>
<string name="share_location_for_six_hours">für 6 Stunden</string>
<string name="share_location_for_12_hours">für 12 Stunden</string>
<plurals name="ask_send_following_n_files_to">
<item quantity="one">Die folgende Datei an %s senden?</item>
@@ -0,0 +1,14 @@
<?xml version="1.0" encoding="utf-8"?>
<resources
xmlns:tools="http://schemas.android.com/tools"
tools:ignore="MissingTranslation">
<string name="or_separator">oder</string>
<string name="online">online</string>
<string name="pink">Rosa</string>
<string name="gray">Grau</string>
<string name="share_location_for_24_hours">für 24 Stunden</string>
<!-- device messages for updates -->
<string name="device_welcome_message">🔮 Willkommen bei ArcaneChat✨\n\nUm mit Freunden in Kontakt zu treten:\n\n🤳🏻Tippe auf dem Startbildschirm beider Geräte auf „QR-Code“. Wähle auf einem Gerät „QR-Code scannen“ und richte die Kamera auf das andere.\n\n🔗 Falls ihr euch nicht im selben Raum befindet, teilt euren Einladungslink über „Freunde einladen“ im Menü.\n\n📢Tritt dem [Offiziellen ArcaneChat-Kanal](%1$s) (auf Englisch) bei, um über die App-Entwicklung auf dem Laufenden zu bleiben.</string>
</resources>
@@ -2,20 +2,14 @@
<resources
xmlns:tools="http://schemas.android.com/tools"
tools:ignore="MissingTranslation">
<string name="description">Descripción</string>
<string name="or_separator">o</string>
<string name="online">en línea</string>
<string name="pink">Rosa</string>
<string name="gray">Gris</string>
<string name="share_location_for_12_hours">Por 12 horas</string>
<string name="share_location_for_24_hours">Por 24 horas</string>
<!-- device messages for updates -->
<string name="device_welcome_message">🔮 Bienvenido a ArcaneChat✨\n\nPara ponerte en contacto con amigos:\n\n🤳🏻
Toca \"Código QR\" en la pantalla principal de ambos dispositivos. Elije \"Escanear código
QR\" en un dispositivo y apunta la cámara hacia el otro\n\n🔗 Si no estás en la misma
habitación, comparte tu enlace de invitación usando \"Invitar amigos\" en el menú\n\n📢
Únete al [Canal Oficial de ArcaneChat](%1$s) (en inglés) para noticias sobre el desarrollo
de la aplicación.</string>
<string name="device_welcome_message">🔮 Bienvenido a ArcaneChat✨\n\nPara ponerte en contacto con amigos:\n\n🤳🏻 Toca \"Código QR\" en la pantalla principal de ambos dispositivos. Elije \"Escanear código QR\" en un dispositivo y apunta la cámara hacia el otro\n\n🔗 Si no estás en la misma habitación, comparte tu enlace de invitación usando \"Invitar amigos\" en el menú\n\n📢 Únete al [Canal Oficial de ArcaneChat](%1$s) (en inglés) para noticias sobre el desarrollo de la aplicación.</string>
<string name="update_2_46ac">🔮 ¿Qué hay de nuevo?\n\n✦ Llamadas de audio/video experimentales mejoradas con una mejor integración con el sistema\n\n✦ Se agregó un selector de stickers básico\n\n✦ Se mejoró el selector de archivos adjuntos\n\n✦ Se minimizó aún más los metadatos en los mensajes\n\n✦ Mayor resiliencia con el uso de múltiples relays: si un relay falla, los mensajes se siguen recibiendo en los demás\n\n✦ Mejor sincronización del estado de las notificaciones en varios dispositivos\n\n✦ Enlace de invitación corregido para unirse al 📢 [Canal oficial de ArcaneChat](%2$s) (en inglés)\n\n✦ Varias correcciones de errores y traducciones actualizadas\n\n\n💜 Por favor, dona para ayudarnos a mantenernos independientes y continuar brindando mejoras: %1$s</string>
</resources>
@@ -2,15 +2,13 @@
<resources
xmlns:tools="http://schemas.android.com/tools"
tools:ignore="MissingTranslation">
<string name="description">Deskribapena</string>
<string name="could_not_open_file">Ezin izan da fitxategia ireki</string>
<string name="menu_export_image">Esportatu irudia</string>
<!-- the word "or" to separate blocks in the user interface that are mutually exclusive -->
<string name="or_separator">edo</string>
<string name="online">online</string>
<string name="pink">Arrosa</string>
<string name="gray">Grisa</string>
<string name="share_location_for_12_hours">12 orduz</string>
<string name="share_location_for_24_hours">24 orduz</string>
<!-- device messages for updates -->
<!-- when translating the sentence about the ArcaneChat channel, clarify that the channel is in English -->
-1
View File
@@ -388,7 +388,6 @@
<string name="share_location_for_one_hour">pour 1 heure</string>
<string name="share_location_for_two_hours">pour 2 heures</string>
<string name="share_location_for_six_hours">pour 6 heures</string>
<string name="share_location_for_12_hours">pour 12 heures</string>
<plurals name="ask_send_following_n_files_to">
<item quantity="one">Envoyer le fichier suivant à %s ?</item>
@@ -0,0 +1,10 @@
<?xml version="1.0" encoding="utf-8"?>
<resources
xmlns:tools="http://schemas.android.com/tools"
tools:ignore="MissingTranslation">
<string name="share_location_for_24_hours">pour 24 heures</string>
<!-- device messages for updates -->
<string name="device_welcome_message">🔮 Bienvenue sur ArcaneChat ✨\n\nPour vous connecter avec vos amis:\n\n🤳🏻Appuyez sur «Code QR» sur l’écran d’accueil des deux appareils. Sélectionnez «Scanner le code QR» sur un appareil et pointez l’appareil photo vers l’autre.\n\n🔗 Si vous n’êtes pas dans la même pièce, partagez votre lien d’invitation via «Inviter des amis» dans le menu.\n\n📢Rejoignez le [Chaîne officielle ArcaneChat](%1$s) (en anglais) pour suivre l’actualité du développement de l’application.</string>
</resources>
-1
View File
@@ -388,7 +388,6 @@
<string name="share_location_for_one_hour">Per 1 ora</string>
<string name="share_location_for_two_hours">Per 2 ore</string>
<string name="share_location_for_six_hours">Per 6 ore</string>
<string name="share_location_for_12_hours">Per 12 ore</string>
<plurals name="ask_send_following_n_files_to">
<item quantity="one">Invia il seguente file a %s?</item>
@@ -0,0 +1,10 @@
<?xml version="1.0" encoding="utf-8"?>
<resources
xmlns:tools="http://schemas.android.com/tools"
tools:ignore="MissingTranslation">
<string name="share_location_for_24_hours">Per 24 ore</string>
<!-- device messages for updates -->
<string name="device_welcome_message">🔮 Benvenuti su ArcaneChat✨\n\nPer connettervi con gli amici:\n\n🤳🏻 Tocca "Codice QR" sulla schermata iniziale di entrambi i dispositivi. Seleziona \"Scansiona codice QR\" su un dispositivo e punta la fotocamera verso l\'altro.\n\n🔗 Se non siete nella stessa stanza, condividi il tuo link di invito utilizzando \"Invita amici\" nel menu.\n\n📢 Unisciti al [Canale ufficiale di ArcaneChat](%1$s) (in inglese) per gli aggiornamenti sullo sviluppo dell\'app.</string>
</resources>
+1 -1
View File
@@ -138,7 +138,7 @@
<item>@string/share_location_for_one_hour</item>
<item>@string/share_location_for_two_hours</item>
<item>@string/share_location_for_six_hours</item>
<item>@string/share_location_for_12_hours</item>
<item>@string/share_location_for_24_hours</item>
</string-array>
<string-array name="recipient_vibrate_entries">
+11 -2
View File
@@ -66,10 +66,13 @@
<string name="show_full_message">Show Full Message…</string>
<!-- Stay short here, say ~16 characters. The source string could also be "All Read", maybe that hint can make translations easier :) -->
<string name="mark_all_as_read">Mark All as Read</string>
<!-- Used as an menu entry or button -->
<string name="mark_as_read">Mark as Read</string>
<!-- Shortest text for "Mark as being read". In english, this could be "Read" (past tense of "to read"), in german, this could be "Gelesen". -->
<!-- Used beside an icon with very few space. Shortest text for "Mark as being read". In english, this could be "Read" (past tense of "to read"), in german, this could be "Gelesen". -->
<string name="mark_as_read_short">Mark Read</string>
<!-- Shortest text for "Mark as being unread". In english, this could be "Unread" (past tense of "to unread"), in german, this could be "Ungelesen". -->
<!-- Used as an menu entry or button -->
<string name="mark_as_unread">Mark as Unread</string>
<!-- Used beside an icon with very few space. Shortest text for "Mark as being unread". In english, this could be "Unread" (past tense of "to unread"), in german, this could be "Ungelesen". -->
<string name="mark_as_unread_short">Unread</string>
<!-- Placeholder text when something is loading -->
<string name="loading">Loading…</string>
@@ -1039,6 +1042,12 @@
<string name="new_messages_body">You have new messages</string>
<string name="n_messages_in_m_chats">%1$d messages in %2$d chats</string>
<!-- location streaming -->
<string name="location_streaming_channel_desc">Channel for On-demand Location Streaming</string>
<string name="location_streaming_notification_title">Location Streaming</string>
<string name="location_streaming_notification_text">You are sharing your location</string>
<string name="location_rationale">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.</string>
<!-- permissions -->
<string name="perm_required_title">Permission required</string>
<string name="perm_continue">Continue</string>
+1 -3
View File
@@ -2,15 +2,13 @@
<resources
xmlns:tools="http://schemas.android.com/tools"
tools:ignore="MissingTranslation">
<string name="description">Description</string>
<string name="could_not_open_file">Could not open file</string>
<string name="menu_export_image">Export Image</string>
<!-- the word "or" to separate blocks in the user interface that are mutually exclusive -->
<string name="or_separator">or</string>
<string name="online">online</string>
<string name="pink">Pink</string>
<string name="gray">Gray</string>
<string name="share_location_for_12_hours">For 12 hours</string>
<string name="share_location_for_24_hours">For 24 hours</string>
<!-- device messages for updates -->
<!-- when translating the sentence about the ArcaneChat channel, clarify that the channel is in English -->
-5
View File
@@ -11,11 +11,6 @@
name="external_files"
path="." />
<!-- this is needed for access to the cache dir in SD card -->
<root-path
name="external_root"
path="/storage/" />
<external-path
name="external_pictures"
path="Pictures" />