mirror of
https://github.com/signalapp/Signal-Android.git
synced 2026-04-23 02:10:44 +01:00
Move all files to natural position.
This commit is contained in:
@@ -0,0 +1,100 @@
|
||||
package org.thoughtcrime.securesms.util;
|
||||
|
||||
import android.annotation.SuppressLint;
|
||||
import android.content.Context;
|
||||
import android.database.Cursor;
|
||||
import androidx.loader.content.AsyncTaskLoader;
|
||||
|
||||
import org.thoughtcrime.securesms.logging.Log;
|
||||
|
||||
/**
|
||||
* A Loader similar to CursorLoader that doesn't require queries to go through the ContentResolver
|
||||
* to get the benefits of reloading when content has changed.
|
||||
*/
|
||||
public abstract class AbstractCursorLoader extends AsyncTaskLoader<Cursor> {
|
||||
|
||||
@SuppressWarnings("unused")
|
||||
private static final String TAG = AbstractCursorLoader.class.getSimpleName();
|
||||
|
||||
@SuppressLint("StaticFieldLeak")
|
||||
protected final Context context;
|
||||
private final ForceLoadContentObserver observer;
|
||||
protected Cursor cursor;
|
||||
|
||||
public AbstractCursorLoader(Context context) {
|
||||
super(context);
|
||||
this.context = context.getApplicationContext();
|
||||
this.observer = new ForceLoadContentObserver();
|
||||
}
|
||||
|
||||
public abstract Cursor getCursor();
|
||||
|
||||
@Override
|
||||
public void deliverResult(Cursor newCursor) {
|
||||
if (isReset()) {
|
||||
if (newCursor != null) {
|
||||
newCursor.close();
|
||||
}
|
||||
return;
|
||||
}
|
||||
Cursor oldCursor = this.cursor;
|
||||
|
||||
this.cursor = newCursor;
|
||||
|
||||
if (isStarted()) {
|
||||
super.deliverResult(newCursor);
|
||||
}
|
||||
if (oldCursor != null && oldCursor != cursor && !oldCursor.isClosed()) {
|
||||
oldCursor.close();
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void onStartLoading() {
|
||||
if (cursor != null) {
|
||||
deliverResult(cursor);
|
||||
}
|
||||
if (takeContentChanged() || cursor == null) {
|
||||
forceLoad();
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void onStopLoading() {
|
||||
cancelLoad();
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onCanceled(Cursor cursor) {
|
||||
if (cursor != null && !cursor.isClosed()) {
|
||||
cursor.close();
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public Cursor loadInBackground() {
|
||||
long startTime = System.currentTimeMillis();
|
||||
|
||||
Cursor newCursor = getCursor();
|
||||
if (newCursor != null) {
|
||||
newCursor.getCount();
|
||||
newCursor.registerContentObserver(observer);
|
||||
}
|
||||
|
||||
Log.d(TAG, "[" + getClass().getSimpleName() + "] Cursor load time: " + (System.currentTimeMillis() - startTime) + " ms");
|
||||
return newCursor;
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void onReset() {
|
||||
super.onReset();
|
||||
|
||||
onStopLoading();
|
||||
|
||||
if (cursor != null && !cursor.isClosed()) {
|
||||
cursor.close();
|
||||
}
|
||||
cursor = null;
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
package org.thoughtcrime.securesms.util;
|
||||
|
||||
import android.content.Context;
|
||||
import android.provider.Settings;
|
||||
|
||||
public final class AccessibilityUtil {
|
||||
|
||||
private AccessibilityUtil() {
|
||||
}
|
||||
|
||||
public static boolean areAnimationsDisabled(Context context) {
|
||||
return Settings.Global.getFloat(context.getContentResolver(), Settings.Global.ANIMATOR_DURATION_SCALE, 1) == 0f;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,98 @@
|
||||
package org.thoughtcrime.securesms.util;
|
||||
|
||||
import android.app.AlarmManager;
|
||||
import android.app.PendingIntent;
|
||||
import android.content.BroadcastReceiver;
|
||||
import android.content.Context;
|
||||
import android.content.Intent;
|
||||
import android.content.IntentFilter;
|
||||
import android.os.Build;
|
||||
import android.os.SystemClock;
|
||||
import android.util.Log;
|
||||
|
||||
import java.util.concurrent.ConcurrentSkipListSet;
|
||||
|
||||
import org.whispersystems.signalservice.api.util.SleepTimer;
|
||||
|
||||
/**
|
||||
* A sleep timer that is based on elapsed realtime, so
|
||||
* that it works properly, even in low-power sleep modes.
|
||||
*
|
||||
*/
|
||||
public class AlarmSleepTimer implements SleepTimer {
|
||||
private static final String TAG = AlarmSleepTimer.class.getSimpleName();
|
||||
private static ConcurrentSkipListSet<Integer> actionIdList = new ConcurrentSkipListSet<>();
|
||||
|
||||
private final Context context;
|
||||
|
||||
public AlarmSleepTimer(Context context) {
|
||||
this.context = context;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void sleep(long millis) {
|
||||
final AlarmReceiver alarmReceiver = new AlarmSleepTimer.AlarmReceiver();
|
||||
int actionId = 0;
|
||||
while (!actionIdList.add(actionId)){
|
||||
actionId++;
|
||||
}
|
||||
try {
|
||||
context.registerReceiver(alarmReceiver,
|
||||
new IntentFilter(AlarmReceiver.WAKE_UP_THREAD_ACTION + "." + actionId));
|
||||
|
||||
final long startTime = System.currentTimeMillis();
|
||||
alarmReceiver.setAlarm(millis, AlarmReceiver.WAKE_UP_THREAD_ACTION + "." + actionId);
|
||||
|
||||
while (System.currentTimeMillis() - startTime < millis) {
|
||||
try {
|
||||
synchronized (this) {
|
||||
wait(millis - System.currentTimeMillis() + startTime);
|
||||
}
|
||||
} catch (InterruptedException e) {
|
||||
Log.w(TAG, e);
|
||||
}
|
||||
}
|
||||
context.unregisterReceiver(alarmReceiver);
|
||||
} catch(Exception e) {
|
||||
Log.w(TAG, "Exception during sleep ...",e);
|
||||
}finally {
|
||||
actionIdList.remove(actionId);
|
||||
}
|
||||
}
|
||||
|
||||
private class AlarmReceiver extends BroadcastReceiver {
|
||||
private static final String WAKE_UP_THREAD_ACTION = "org.thoughtcrime.securesms.util.AlarmSleepTimer.AlarmReceiver.WAKE_UP_THREAD";
|
||||
|
||||
private void setAlarm(long millis, String action) {
|
||||
final Intent intent = new Intent(action);
|
||||
final PendingIntent pendingIntent = PendingIntent.getBroadcast(context, 0, intent, 0);
|
||||
final AlarmManager alarmManager = (AlarmManager)context.getSystemService(Context.ALARM_SERVICE);
|
||||
|
||||
Log.w(TAG, "Setting alarm to wake up in " + millis + "ms.");
|
||||
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
|
||||
alarmManager.setExactAndAllowWhileIdle(AlarmManager.ELAPSED_REALTIME_WAKEUP,
|
||||
SystemClock.elapsedRealtime() + millis,
|
||||
pendingIntent);
|
||||
} else if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.KITKAT) {
|
||||
alarmManager.setExact(AlarmManager.ELAPSED_REALTIME_WAKEUP,
|
||||
SystemClock.elapsedRealtime() + millis,
|
||||
pendingIntent);
|
||||
} else {
|
||||
alarmManager.set(AlarmManager.ELAPSED_REALTIME_WAKEUP,
|
||||
SystemClock.elapsedRealtime() + millis,
|
||||
pendingIntent);
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onReceive(Context context, Intent intent) {
|
||||
Log.w(TAG, "Waking up.");
|
||||
|
||||
synchronized (AlarmSleepTimer.this) {
|
||||
AlarmSleepTimer.this.notifyAll();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,80 @@
|
||||
package org.thoughtcrime.securesms.util;
|
||||
|
||||
/*
|
||||
* Copyright (C) 2011 Alexander Blom
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import androidx.loader.content.AsyncTaskLoader;
|
||||
import android.content.Context;
|
||||
|
||||
/**
|
||||
* Loader which extends AsyncTaskLoaders and handles caveats
|
||||
* as pointed out in http://code.google.com/p/android/issues/detail?id=14944.
|
||||
*
|
||||
* Based on CursorLoader.java in the Fragment compatibility package
|
||||
*
|
||||
* @author Alexander Blom (me@alexanderblom.se)
|
||||
*
|
||||
* @param <D> data type
|
||||
*/
|
||||
public abstract class AsyncLoader<D> extends AsyncTaskLoader<D> {
|
||||
private D data;
|
||||
|
||||
public AsyncLoader(Context context) {
|
||||
super(context);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void deliverResult(D data) {
|
||||
if (isReset()) {
|
||||
// An async query came in while the loader is stopped
|
||||
return;
|
||||
}
|
||||
|
||||
this.data = data;
|
||||
|
||||
super.deliverResult(data);
|
||||
}
|
||||
|
||||
|
||||
@Override
|
||||
protected void onStartLoading() {
|
||||
if (data != null) {
|
||||
deliverResult(data);
|
||||
}
|
||||
|
||||
if (takeContentChanged() || data == null) {
|
||||
forceLoad();
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void onStopLoading() {
|
||||
// Attempt to cancel the current load task if possible.
|
||||
cancelLoad();
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void onReset() {
|
||||
super.onReset();
|
||||
|
||||
// Ensure the loader is stopped
|
||||
onStopLoading();
|
||||
|
||||
data = null;
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
@@ -0,0 +1,119 @@
|
||||
package org.thoughtcrime.securesms.util;
|
||||
|
||||
|
||||
import android.content.Context;
|
||||
import android.database.Cursor;
|
||||
import android.net.ConnectivityManager;
|
||||
import android.net.NetworkInfo;
|
||||
import androidx.annotation.NonNull;
|
||||
import androidx.annotation.Nullable;
|
||||
import androidx.annotation.WorkerThread;
|
||||
import android.text.TextUtils;
|
||||
|
||||
import org.thoughtcrime.securesms.attachments.AttachmentId;
|
||||
import org.thoughtcrime.securesms.attachments.DatabaseAttachment;
|
||||
import org.thoughtcrime.securesms.database.DatabaseFactory;
|
||||
import org.thoughtcrime.securesms.database.model.MessageRecord;
|
||||
import org.thoughtcrime.securesms.logging.Log;
|
||||
|
||||
import java.util.Collections;
|
||||
import java.util.Set;
|
||||
|
||||
public class AttachmentUtil {
|
||||
|
||||
private static final String TAG = AttachmentUtil.class.getSimpleName();
|
||||
|
||||
@WorkerThread
|
||||
public static boolean isAutoDownloadPermitted(@NonNull Context context, @Nullable DatabaseAttachment attachment) {
|
||||
if (attachment == null) {
|
||||
Log.w(TAG, "attachment was null, returning vacuous true");
|
||||
return true;
|
||||
}
|
||||
|
||||
if (isFromUnknownContact(context, attachment)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
Set<String> allowedTypes = getAllowedAutoDownloadTypes(context);
|
||||
String contentType = attachment.getContentType();
|
||||
|
||||
if (attachment.isVoiceNote() ||
|
||||
(MediaUtil.isAudio(attachment) && TextUtils.isEmpty(attachment.getFileName())) ||
|
||||
MediaUtil.isLongTextType(attachment.getContentType()) ||
|
||||
attachment.isSticker())
|
||||
{
|
||||
return true;
|
||||
} else if (isNonDocumentType(contentType)) {
|
||||
return allowedTypes.contains(MediaUtil.getDiscreteMimeType(contentType));
|
||||
} else {
|
||||
return allowedTypes.contains("documents");
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Deletes the specified attachment. If its the only attachment for its linked message, the entire
|
||||
* message is deleted.
|
||||
*/
|
||||
@WorkerThread
|
||||
public static void deleteAttachment(@NonNull Context context,
|
||||
@NonNull DatabaseAttachment attachment)
|
||||
{
|
||||
AttachmentId attachmentId = attachment.getAttachmentId();
|
||||
long mmsId = attachment.getMmsId();
|
||||
int attachmentCount = DatabaseFactory.getAttachmentDatabase(context)
|
||||
.getAttachmentsForMessage(mmsId)
|
||||
.size();
|
||||
|
||||
if (attachmentCount <= 1) {
|
||||
DatabaseFactory.getMmsDatabase(context).delete(mmsId);
|
||||
} else {
|
||||
DatabaseFactory.getAttachmentDatabase(context).deleteAttachment(attachmentId);
|
||||
}
|
||||
}
|
||||
|
||||
private static boolean isNonDocumentType(String contentType) {
|
||||
return
|
||||
MediaUtil.isImageType(contentType) ||
|
||||
MediaUtil.isVideoType(contentType) ||
|
||||
MediaUtil.isAudioType(contentType);
|
||||
}
|
||||
|
||||
private static @NonNull Set<String> getAllowedAutoDownloadTypes(@NonNull Context context) {
|
||||
if (isConnectedWifi(context)) return TextSecurePreferences.getWifiMediaDownloadAllowed(context);
|
||||
else if (isConnectedRoaming(context)) return TextSecurePreferences.getRoamingMediaDownloadAllowed(context);
|
||||
else if (isConnectedMobile(context)) return TextSecurePreferences.getMobileMediaDownloadAllowed(context);
|
||||
else return Collections.emptySet();
|
||||
}
|
||||
|
||||
private static NetworkInfo getNetworkInfo(@NonNull Context context) {
|
||||
return ServiceUtil.getConnectivityManager(context).getActiveNetworkInfo();
|
||||
}
|
||||
|
||||
private static boolean isConnectedWifi(@NonNull Context context) {
|
||||
final NetworkInfo info = getNetworkInfo(context);
|
||||
return info != null && info.isConnected() && info.getType() == ConnectivityManager.TYPE_WIFI;
|
||||
}
|
||||
|
||||
private static boolean isConnectedMobile(@NonNull Context context) {
|
||||
final NetworkInfo info = getNetworkInfo(context);
|
||||
return info != null && info.isConnected() && info.getType() == ConnectivityManager.TYPE_MOBILE;
|
||||
}
|
||||
|
||||
private static boolean isConnectedRoaming(@NonNull Context context) {
|
||||
final NetworkInfo info = getNetworkInfo(context);
|
||||
return info != null && info.isConnected() && info.isRoaming() && info.getType() == ConnectivityManager.TYPE_MOBILE;
|
||||
}
|
||||
|
||||
@WorkerThread
|
||||
private static boolean isFromUnknownContact(@NonNull Context context, @NonNull DatabaseAttachment attachment) {
|
||||
try (Cursor messageCursor = DatabaseFactory.getMmsDatabase(context).getMessage(attachment.getMmsId())) {
|
||||
final MessageRecord message = DatabaseFactory.getMmsDatabase(context).readerFor(messageCursor).getNext();
|
||||
|
||||
if (message == null || (!message.getRecipient().isSystemContact() && !message.isOutgoing() && !message.getRecipient().isLocalNumber())) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
package org.thoughtcrime.securesms.util;
|
||||
|
||||
import android.content.Context;
|
||||
import android.graphics.drawable.Drawable;
|
||||
import android.text.TextUtils;
|
||||
import android.widget.ImageView;
|
||||
|
||||
import androidx.annotation.NonNull;
|
||||
|
||||
import com.bumptech.glide.load.engine.DiskCacheStrategy;
|
||||
|
||||
import org.thoughtcrime.securesms.R;
|
||||
import org.thoughtcrime.securesms.color.MaterialColor;
|
||||
import org.thoughtcrime.securesms.contacts.avatars.ContactColors;
|
||||
import org.thoughtcrime.securesms.contacts.avatars.GeneratedContactPhoto;
|
||||
import org.thoughtcrime.securesms.contacts.avatars.ProfileContactPhoto;
|
||||
import org.thoughtcrime.securesms.mms.GlideApp;
|
||||
import org.thoughtcrime.securesms.recipients.Recipient;
|
||||
import org.whispersystems.libsignal.util.guava.Optional;
|
||||
|
||||
public final class AvatarUtil {
|
||||
|
||||
private AvatarUtil() {
|
||||
}
|
||||
|
||||
public static void loadIconIntoImageView(@NonNull Recipient recipient, @NonNull ImageView target) {
|
||||
Context context = target.getContext();
|
||||
String name = Optional.fromNullable(recipient.getDisplayName(context)).or(Optional.fromNullable(TextSecurePreferences.getProfileName(context))).or("");
|
||||
MaterialColor fallbackColor = recipient.getColor();
|
||||
|
||||
if (fallbackColor == ContactColors.UNKNOWN_COLOR && !TextUtils.isEmpty(name)) {
|
||||
fallbackColor = ContactColors.generateFor(name);
|
||||
}
|
||||
|
||||
Drawable fallback = new GeneratedContactPhoto(name, R.drawable.ic_profile_outline_40).asDrawable(context, fallbackColor.toAvatarColor(context));
|
||||
|
||||
GlideApp.with(context)
|
||||
.load(new ProfileContactPhoto(recipient.getId(), String.valueOf(TextSecurePreferences.getProfileAvatarId(context))))
|
||||
.error(fallback)
|
||||
.circleCrop()
|
||||
.diskCacheStrategy(DiskCacheStrategy.ALL)
|
||||
.into(target);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,165 @@
|
||||
package org.thoughtcrime.securesms.util;
|
||||
|
||||
|
||||
import android.content.Context;
|
||||
|
||||
import androidx.annotation.NonNull;
|
||||
import androidx.annotation.Nullable;
|
||||
|
||||
import org.thoughtcrime.securesms.R;
|
||||
import org.thoughtcrime.securesms.database.NoExternalStorageException;
|
||||
import org.thoughtcrime.securesms.logging.Log;
|
||||
import org.whispersystems.libsignal.util.ByteUtil;
|
||||
|
||||
import java.io.File;
|
||||
import java.security.SecureRandom;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Calendar;
|
||||
import java.util.Collections;
|
||||
import java.util.List;
|
||||
import java.util.Locale;
|
||||
|
||||
public class BackupUtil {
|
||||
|
||||
private static final String TAG = BackupUtil.class.getSimpleName();
|
||||
|
||||
public static @NonNull String getLastBackupTime(@NonNull Context context, @NonNull Locale locale) {
|
||||
try {
|
||||
BackupInfo backup = getLatestBackup();
|
||||
|
||||
if (backup == null) return context.getString(R.string.BackupUtil_never);
|
||||
else return DateUtils.getExtendedRelativeTimeSpanString(context, locale, backup.getTimestamp());
|
||||
} catch (NoExternalStorageException e) {
|
||||
Log.w(TAG, e);
|
||||
return context.getString(R.string.BackupUtil_unknown);
|
||||
}
|
||||
}
|
||||
|
||||
public static @Nullable BackupInfo getLatestBackup() throws NoExternalStorageException {
|
||||
List<BackupInfo> backups = getAllBackupsNewestFirst();
|
||||
|
||||
return backups.isEmpty() ? null : backups.get(0);
|
||||
}
|
||||
|
||||
public static void deleteAllBackups() {
|
||||
Log.i(TAG, "Deleting all backups");
|
||||
|
||||
try {
|
||||
List<BackupInfo> backups = getAllBackupsNewestFirst();
|
||||
|
||||
for (BackupInfo backup : backups) {
|
||||
backup.delete();
|
||||
}
|
||||
} catch (NoExternalStorageException e) {
|
||||
Log.w(TAG, e);
|
||||
}
|
||||
}
|
||||
|
||||
public static void deleteOldBackups() {
|
||||
Log.i(TAG, "Deleting older backups");
|
||||
|
||||
try {
|
||||
List<BackupInfo> backups = getAllBackupsNewestFirst();
|
||||
|
||||
for (int i = 2; i < backups.size(); i++) {
|
||||
backups.get(i).delete();
|
||||
}
|
||||
} catch (NoExternalStorageException e) {
|
||||
Log.w(TAG, e);
|
||||
}
|
||||
}
|
||||
|
||||
private static List<BackupInfo> getAllBackupsNewestFirst() throws NoExternalStorageException {
|
||||
File backupDirectory = StorageUtil.getBackupDirectory();
|
||||
File[] files = backupDirectory.listFiles();
|
||||
List<BackupInfo> backups = new ArrayList<>(files.length);
|
||||
|
||||
for (File file : files) {
|
||||
if (file.isFile() && file.getAbsolutePath().endsWith(".backup")) {
|
||||
long backupTimestamp = getBackupTimestamp(file);
|
||||
|
||||
if (backupTimestamp != -1) {
|
||||
backups.add(new BackupInfo(backupTimestamp, file.length(), file));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Collections.sort(backups, (a, b) -> Long.compare(b.timestamp, a.timestamp));
|
||||
|
||||
return backups;
|
||||
}
|
||||
|
||||
public static @NonNull String[] generateBackupPassphrase() {
|
||||
String[] result = new String[6];
|
||||
byte[] random = new byte[30];
|
||||
|
||||
new SecureRandom().nextBytes(random);
|
||||
|
||||
for (int i=0;i<30;i+=5) {
|
||||
result[i/5] = String.format("%05d", ByteUtil.byteArray5ToLong(random, i) % 100000);
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
private static long getBackupTimestamp(File backup) {
|
||||
String name = backup.getName();
|
||||
String[] prefixSuffix = name.split("[.]");
|
||||
|
||||
if (prefixSuffix.length == 2) {
|
||||
String[] parts = prefixSuffix[0].split("\\-");
|
||||
|
||||
if (parts.length == 7) {
|
||||
try {
|
||||
Calendar calendar = Calendar.getInstance();
|
||||
calendar.set(Calendar.YEAR, Integer.parseInt(parts[1]));
|
||||
calendar.set(Calendar.MONTH, Integer.parseInt(parts[2]) - 1);
|
||||
calendar.set(Calendar.DAY_OF_MONTH, Integer.parseInt(parts[3]));
|
||||
calendar.set(Calendar.HOUR_OF_DAY, Integer.parseInt(parts[4]));
|
||||
calendar.set(Calendar.MINUTE, Integer.parseInt(parts[5]));
|
||||
calendar.set(Calendar.SECOND, Integer.parseInt(parts[6]));
|
||||
calendar.set(Calendar.MILLISECOND, 0);
|
||||
|
||||
return calendar.getTimeInMillis();
|
||||
} catch (NumberFormatException e) {
|
||||
Log.w(TAG, e);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return -1;
|
||||
}
|
||||
|
||||
public static class BackupInfo {
|
||||
|
||||
private final long timestamp;
|
||||
private final long size;
|
||||
private final File file;
|
||||
|
||||
BackupInfo(long timestamp, long size, File file) {
|
||||
this.timestamp = timestamp;
|
||||
this.size = size;
|
||||
this.file = file;
|
||||
}
|
||||
|
||||
public long getTimestamp() {
|
||||
return timestamp;
|
||||
}
|
||||
|
||||
public long getSize() {
|
||||
return size;
|
||||
}
|
||||
|
||||
public File getFile() {
|
||||
return file;
|
||||
}
|
||||
|
||||
private void delete() {
|
||||
Log.i(TAG, "Deleting: " + file.getAbsolutePath());
|
||||
|
||||
if (!file.delete()) {
|
||||
Log.w(TAG, "Delete failed: " + file.getAbsolutePath());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
package org.thoughtcrime.securesms.util;
|
||||
|
||||
import androidx.annotation.NonNull;
|
||||
|
||||
import java.io.IOException;
|
||||
|
||||
public final class Base64 {
|
||||
|
||||
private Base64() {
|
||||
}
|
||||
|
||||
public static @NonNull byte[] decode(@NonNull String s) throws IOException {
|
||||
return org.whispersystems.util.Base64.decode(s);
|
||||
}
|
||||
|
||||
public static @NonNull String encodeBytes(@NonNull byte[] source) {
|
||||
return org.whispersystems.util.Base64.encodeBytes(source);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
package org.thoughtcrime.securesms.util;
|
||||
|
||||
public class BitmapDecodingException extends Exception {
|
||||
|
||||
public BitmapDecodingException(String s) {
|
||||
super(s);
|
||||
}
|
||||
|
||||
public BitmapDecodingException(Exception nested) {
|
||||
super(nested);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,449 @@
|
||||
package org.thoughtcrime.securesms.util;
|
||||
|
||||
import android.content.Context;
|
||||
import android.graphics.Bitmap;
|
||||
import android.graphics.Bitmap.CompressFormat;
|
||||
import android.graphics.BitmapFactory;
|
||||
import android.graphics.Canvas;
|
||||
import android.graphics.ImageFormat;
|
||||
import android.graphics.Rect;
|
||||
import android.graphics.YuvImage;
|
||||
import android.graphics.drawable.BitmapDrawable;
|
||||
import android.graphics.drawable.Drawable;
|
||||
import androidx.annotation.NonNull;
|
||||
import androidx.annotation.Nullable;
|
||||
import androidx.annotation.WorkerThread;
|
||||
import androidx.exifinterface.media.ExifInterface;
|
||||
import android.util.Pair;
|
||||
|
||||
import com.bumptech.glide.load.engine.DiskCacheStrategy;
|
||||
|
||||
import org.thoughtcrime.securesms.logging.Log;
|
||||
import org.thoughtcrime.securesms.mms.GlideApp;
|
||||
import org.thoughtcrime.securesms.mms.MediaConstraints;
|
||||
|
||||
import java.io.BufferedInputStream;
|
||||
import java.io.ByteArrayInputStream;
|
||||
import java.io.ByteArrayOutputStream;
|
||||
import java.io.IOException;
|
||||
import java.io.InputStream;
|
||||
import java.util.Locale;
|
||||
import java.util.concurrent.ExecutionException;
|
||||
import java.util.concurrent.atomic.AtomicBoolean;
|
||||
|
||||
import javax.microedition.khronos.egl.EGL10;
|
||||
import javax.microedition.khronos.egl.EGLConfig;
|
||||
import javax.microedition.khronos.egl.EGLContext;
|
||||
import javax.microedition.khronos.egl.EGLDisplay;
|
||||
|
||||
public class BitmapUtil {
|
||||
|
||||
private static final String TAG = BitmapUtil.class.getSimpleName();
|
||||
|
||||
private static final int MAX_COMPRESSION_QUALITY = 90;
|
||||
private static final int MIN_COMPRESSION_QUALITY = 45;
|
||||
private static final int MAX_COMPRESSION_ATTEMPTS = 5;
|
||||
private static final int MIN_COMPRESSION_QUALITY_DECREASE = 5;
|
||||
private static final int MAX_IMAGE_HALF_SCALES = 3;
|
||||
|
||||
@WorkerThread
|
||||
public static <T> ScaleResult createScaledBytes(@NonNull Context context, @NonNull T model, @NonNull MediaConstraints constraints)
|
||||
throws BitmapDecodingException
|
||||
{
|
||||
return createScaledBytes(context, model,
|
||||
constraints.getImageMaxWidth(context),
|
||||
constraints.getImageMaxHeight(context),
|
||||
constraints.getImageMaxSize(context));
|
||||
}
|
||||
|
||||
@WorkerThread
|
||||
public static <T> ScaleResult createScaledBytes(@NonNull Context context,
|
||||
@NonNull T model,
|
||||
final int maxImageWidth,
|
||||
final int maxImageHeight,
|
||||
final int maxImageSize)
|
||||
throws BitmapDecodingException
|
||||
{
|
||||
return createScaledBytes(context, model, maxImageWidth, maxImageHeight, maxImageSize, CompressFormat.JPEG);
|
||||
}
|
||||
|
||||
@WorkerThread
|
||||
public static <T> ScaleResult createScaledBytes(Context context,
|
||||
T model,
|
||||
int maxImageWidth,
|
||||
int maxImageHeight,
|
||||
int maxImageSize,
|
||||
@NonNull CompressFormat format)
|
||||
throws BitmapDecodingException
|
||||
{
|
||||
return createScaledBytes(context, model, maxImageWidth, maxImageHeight, maxImageSize, format, 1, 0);
|
||||
}
|
||||
|
||||
@WorkerThread
|
||||
private static <T> ScaleResult createScaledBytes(@NonNull Context context,
|
||||
@NonNull T model,
|
||||
final int maxImageWidth,
|
||||
final int maxImageHeight,
|
||||
final int maxImageSize,
|
||||
@NonNull CompressFormat format,
|
||||
final int sizeAttempt,
|
||||
int totalAttempts)
|
||||
throws BitmapDecodingException
|
||||
{
|
||||
try {
|
||||
int quality = MAX_COMPRESSION_QUALITY;
|
||||
int attempts = 0;
|
||||
byte[] bytes;
|
||||
|
||||
Bitmap scaledBitmap = GlideApp.with(context.getApplicationContext())
|
||||
.asBitmap()
|
||||
.load(model)
|
||||
.skipMemoryCache(true)
|
||||
.diskCacheStrategy(DiskCacheStrategy.NONE)
|
||||
.centerInside()
|
||||
.submit(maxImageWidth, maxImageHeight)
|
||||
.get();
|
||||
|
||||
if (scaledBitmap == null) {
|
||||
throw new BitmapDecodingException("Unable to decode image");
|
||||
}
|
||||
|
||||
Log.i(TAG, String.format(Locale.US,"Initial scaled bitmap has size of %d bytes.", scaledBitmap.getByteCount()));
|
||||
Log.i(TAG, String.format(Locale.US, "Max dimensions %d x %d, %d bytes", maxImageWidth, maxImageHeight, maxImageSize));
|
||||
|
||||
try {
|
||||
do {
|
||||
totalAttempts++;
|
||||
ByteArrayOutputStream baos = new ByteArrayOutputStream();
|
||||
scaledBitmap.compress(format, quality, baos);
|
||||
bytes = baos.toByteArray();
|
||||
|
||||
Log.d(TAG, "iteration with quality " + quality + " size " + bytes.length + " bytes.");
|
||||
if (quality == MIN_COMPRESSION_QUALITY) break;
|
||||
|
||||
int nextQuality = (int)Math.floor(quality * Math.sqrt((double)maxImageSize / bytes.length));
|
||||
if (quality - nextQuality < MIN_COMPRESSION_QUALITY_DECREASE) {
|
||||
nextQuality = quality - MIN_COMPRESSION_QUALITY_DECREASE;
|
||||
}
|
||||
quality = Math.max(nextQuality, MIN_COMPRESSION_QUALITY);
|
||||
}
|
||||
while (bytes.length > maxImageSize && attempts++ < MAX_COMPRESSION_ATTEMPTS);
|
||||
|
||||
if (bytes.length > maxImageSize) {
|
||||
if (sizeAttempt <= MAX_IMAGE_HALF_SCALES) {
|
||||
scaledBitmap.recycle();
|
||||
scaledBitmap = null;
|
||||
|
||||
Log.i(TAG, "Halving dimensions and retrying.");
|
||||
return createScaledBytes(context, model, maxImageWidth / 2, maxImageHeight / 2, maxImageSize, format, sizeAttempt + 1, totalAttempts);
|
||||
} else {
|
||||
throw new BitmapDecodingException("Unable to scale image below " + bytes.length + " bytes.");
|
||||
}
|
||||
}
|
||||
|
||||
if (bytes.length <= 0) {
|
||||
throw new BitmapDecodingException("Decoding failed. Bitmap has a length of " + bytes.length + " bytes.");
|
||||
}
|
||||
|
||||
Log.i(TAG, String.format(Locale.US, "createScaledBytes(%s) -> quality %d, %d attempt(s) over %d sizes.", model.getClass().getName(), quality, totalAttempts, sizeAttempt));
|
||||
|
||||
return new ScaleResult(bytes, scaledBitmap.getWidth(), scaledBitmap.getHeight());
|
||||
} finally {
|
||||
if (scaledBitmap != null) scaledBitmap.recycle();
|
||||
}
|
||||
} catch (InterruptedException | ExecutionException e) {
|
||||
throw new BitmapDecodingException(e);
|
||||
}
|
||||
}
|
||||
|
||||
@WorkerThread
|
||||
public static <T> Bitmap createScaledBitmap(Context context, T model, int maxWidth, int maxHeight)
|
||||
throws BitmapDecodingException
|
||||
{
|
||||
try {
|
||||
return GlideApp.with(context.getApplicationContext())
|
||||
.asBitmap()
|
||||
.load(model)
|
||||
.centerInside()
|
||||
.submit(maxWidth, maxHeight)
|
||||
.get();
|
||||
} catch (InterruptedException | ExecutionException e) {
|
||||
throw new BitmapDecodingException(e);
|
||||
}
|
||||
}
|
||||
|
||||
@WorkerThread
|
||||
public static Bitmap createScaledBitmap(Bitmap bitmap, int maxWidth, int maxHeight) {
|
||||
if (bitmap.getWidth() <= maxWidth && bitmap.getHeight() <= maxHeight) {
|
||||
return bitmap;
|
||||
}
|
||||
|
||||
if (maxWidth <= 0 || maxHeight <= 0) {
|
||||
return bitmap;
|
||||
}
|
||||
|
||||
int newWidth = maxWidth;
|
||||
int newHeight = maxHeight;
|
||||
|
||||
float widthRatio = bitmap.getWidth() / (float) maxWidth;
|
||||
float heightRatio = bitmap.getHeight() / (float) maxHeight;
|
||||
|
||||
if (widthRatio > heightRatio) {
|
||||
newHeight = (int) (bitmap.getHeight() / widthRatio);
|
||||
} else {
|
||||
newWidth = (int) (bitmap.getWidth() / heightRatio);
|
||||
}
|
||||
|
||||
return Bitmap.createScaledBitmap(bitmap, newWidth, newHeight, true);
|
||||
}
|
||||
|
||||
public static @NonNull CompressFormat getCompressFormatForContentType(@Nullable String contentType) {
|
||||
if (contentType == null) return CompressFormat.JPEG;
|
||||
|
||||
switch (contentType) {
|
||||
case MediaUtil.IMAGE_JPEG: return CompressFormat.JPEG;
|
||||
case MediaUtil.IMAGE_PNG: return CompressFormat.PNG;
|
||||
case MediaUtil.IMAGE_WEBP: return CompressFormat.WEBP;
|
||||
default: return CompressFormat.JPEG;
|
||||
}
|
||||
}
|
||||
|
||||
private static BitmapFactory.Options getImageDimensions(InputStream inputStream)
|
||||
throws BitmapDecodingException
|
||||
{
|
||||
BitmapFactory.Options options = new BitmapFactory.Options();
|
||||
options.inJustDecodeBounds = true;
|
||||
BufferedInputStream fis = new BufferedInputStream(inputStream);
|
||||
BitmapFactory.decodeStream(fis, null, options);
|
||||
try {
|
||||
fis.close();
|
||||
} catch (IOException ioe) {
|
||||
Log.w(TAG, "failed to close the InputStream after reading image dimensions");
|
||||
}
|
||||
|
||||
if (options.outWidth == -1 || options.outHeight == -1) {
|
||||
throw new BitmapDecodingException("Failed to decode image dimensions: " + options.outWidth + ", " + options.outHeight);
|
||||
}
|
||||
|
||||
return options;
|
||||
}
|
||||
|
||||
@Nullable
|
||||
public static Pair<Integer, Integer> getExifDimensions(InputStream inputStream) throws IOException {
|
||||
ExifInterface exif = new ExifInterface(inputStream);
|
||||
int width = exif.getAttributeInt(ExifInterface.TAG_IMAGE_WIDTH, 0);
|
||||
int height = exif.getAttributeInt(ExifInterface.TAG_IMAGE_LENGTH, 0);
|
||||
if (width == 0 || height == 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
int orientation = exif.getAttributeInt(ExifInterface.TAG_ORIENTATION, 0);
|
||||
if (orientation == ExifInterface.ORIENTATION_ROTATE_90 ||
|
||||
orientation == ExifInterface.ORIENTATION_ROTATE_270 ||
|
||||
orientation == ExifInterface.ORIENTATION_TRANSVERSE ||
|
||||
orientation == ExifInterface.ORIENTATION_TRANSPOSE)
|
||||
{
|
||||
return new Pair<>(height, width);
|
||||
}
|
||||
return new Pair<>(width, height);
|
||||
}
|
||||
|
||||
public static Pair<Integer, Integer> getDimensions(InputStream inputStream) throws BitmapDecodingException {
|
||||
BitmapFactory.Options options = getImageDimensions(inputStream);
|
||||
return new Pair<>(options.outWidth, options.outHeight);
|
||||
}
|
||||
|
||||
public static InputStream toCompressedJpeg(Bitmap bitmap) {
|
||||
ByteArrayOutputStream thumbnailBytes = new ByteArrayOutputStream();
|
||||
bitmap.compress(CompressFormat.JPEG, 85, thumbnailBytes);
|
||||
return new ByteArrayInputStream(thumbnailBytes.toByteArray());
|
||||
}
|
||||
|
||||
public static @Nullable byte[] toByteArray(@Nullable Bitmap bitmap) {
|
||||
if (bitmap == null) return null;
|
||||
ByteArrayOutputStream stream = new ByteArrayOutputStream();
|
||||
bitmap.compress(Bitmap.CompressFormat.PNG, 100, stream);
|
||||
return stream.toByteArray();
|
||||
}
|
||||
|
||||
public static @Nullable Bitmap fromByteArray(@Nullable byte[] bytes) {
|
||||
if (bytes == null) return null;
|
||||
return BitmapFactory.decodeByteArray(bytes, 0, bytes.length);
|
||||
}
|
||||
|
||||
public static byte[] createFromNV21(@NonNull final byte[] data,
|
||||
final int width,
|
||||
final int height,
|
||||
int rotation,
|
||||
final Rect croppingRect,
|
||||
final boolean flipHorizontal)
|
||||
throws IOException
|
||||
{
|
||||
byte[] rotated = rotateNV21(data, width, height, rotation, flipHorizontal);
|
||||
final int rotatedWidth = rotation % 180 > 0 ? height : width;
|
||||
final int rotatedHeight = rotation % 180 > 0 ? width : height;
|
||||
YuvImage previewImage = new YuvImage(rotated, ImageFormat.NV21,
|
||||
rotatedWidth, rotatedHeight, null);
|
||||
|
||||
ByteArrayOutputStream outputStream = new ByteArrayOutputStream();
|
||||
previewImage.compressToJpeg(croppingRect, 80, outputStream);
|
||||
byte[] bytes = outputStream.toByteArray();
|
||||
outputStream.close();
|
||||
return bytes;
|
||||
}
|
||||
|
||||
/*
|
||||
* NV21 a.k.a. YUV420sp
|
||||
* YUV 4:2:0 planar image, with 8 bit Y samples, followed by interleaved V/U plane with 8bit 2x2
|
||||
* subsampled chroma samples.
|
||||
*
|
||||
* http://www.fourcc.org/yuv.php#NV21
|
||||
*/
|
||||
public static byte[] rotateNV21(@NonNull final byte[] yuv,
|
||||
final int width,
|
||||
final int height,
|
||||
final int rotation,
|
||||
final boolean flipHorizontal)
|
||||
throws IOException
|
||||
{
|
||||
if (rotation == 0) return yuv;
|
||||
if (rotation % 90 != 0 || rotation < 0 || rotation > 270) {
|
||||
throw new IllegalArgumentException("0 <= rotation < 360, rotation % 90 == 0");
|
||||
} else if ((width * height * 3) / 2 != yuv.length) {
|
||||
throw new IOException("provided width and height don't jive with the data length (" +
|
||||
yuv.length + "). Width: " + width + " height: " + height +
|
||||
" = data length: " + (width * height * 3) / 2);
|
||||
}
|
||||
|
||||
final byte[] output = new byte[yuv.length];
|
||||
final int frameSize = width * height;
|
||||
final boolean swap = rotation % 180 != 0;
|
||||
final boolean xflip = flipHorizontal ? rotation % 270 == 0 : rotation % 270 != 0;
|
||||
final boolean yflip = rotation >= 180;
|
||||
|
||||
for (int j = 0; j < height; j++) {
|
||||
for (int i = 0; i < width; i++) {
|
||||
final int yIn = j * width + i;
|
||||
final int uIn = frameSize + (j >> 1) * width + (i & ~1);
|
||||
final int vIn = uIn + 1;
|
||||
|
||||
final int wOut = swap ? height : width;
|
||||
final int hOut = swap ? width : height;
|
||||
final int iSwapped = swap ? j : i;
|
||||
final int jSwapped = swap ? i : j;
|
||||
final int iOut = xflip ? wOut - iSwapped - 1 : iSwapped;
|
||||
final int jOut = yflip ? hOut - jSwapped - 1 : jSwapped;
|
||||
|
||||
final int yOut = jOut * wOut + iOut;
|
||||
final int uOut = frameSize + (jOut >> 1) * wOut + (iOut & ~1);
|
||||
final int vOut = uOut + 1;
|
||||
|
||||
output[yOut] = (byte)(0xff & yuv[yIn]);
|
||||
output[uOut] = (byte)(0xff & yuv[uIn]);
|
||||
output[vOut] = (byte)(0xff & yuv[vIn]);
|
||||
}
|
||||
}
|
||||
return output;
|
||||
}
|
||||
|
||||
public static Bitmap createFromDrawable(final Drawable drawable, final int width, final int height) {
|
||||
final AtomicBoolean created = new AtomicBoolean(false);
|
||||
final Bitmap[] result = new Bitmap[1];
|
||||
|
||||
Runnable runnable = new Runnable() {
|
||||
@Override
|
||||
public void run() {
|
||||
if (drawable instanceof BitmapDrawable) {
|
||||
result[0] = ((BitmapDrawable) drawable).getBitmap();
|
||||
} else {
|
||||
int canvasWidth = drawable.getIntrinsicWidth();
|
||||
if (canvasWidth <= 0) canvasWidth = width;
|
||||
|
||||
int canvasHeight = drawable.getIntrinsicHeight();
|
||||
if (canvasHeight <= 0) canvasHeight = height;
|
||||
|
||||
Bitmap bitmap;
|
||||
|
||||
try {
|
||||
bitmap = Bitmap.createBitmap(canvasWidth, canvasHeight, Bitmap.Config.ARGB_8888);
|
||||
Canvas canvas = new Canvas(bitmap);
|
||||
drawable.setBounds(0, 0, canvas.getWidth(), canvas.getHeight());
|
||||
drawable.draw(canvas);
|
||||
} catch (Exception e) {
|
||||
Log.w(TAG, e);
|
||||
bitmap = null;
|
||||
}
|
||||
|
||||
result[0] = bitmap;
|
||||
}
|
||||
|
||||
synchronized (result) {
|
||||
created.set(true);
|
||||
result.notifyAll();
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
Util.runOnMain(runnable);
|
||||
|
||||
synchronized (result) {
|
||||
while (!created.get()) Util.wait(result, 0);
|
||||
return result[0];
|
||||
}
|
||||
}
|
||||
|
||||
public static int getMaxTextureSize() {
|
||||
final int MAX_ALLOWED_TEXTURE_SIZE = 2048;
|
||||
|
||||
EGL10 egl = (EGL10) EGLContext.getEGL();
|
||||
EGLDisplay display = egl.eglGetDisplay(EGL10.EGL_DEFAULT_DISPLAY);
|
||||
|
||||
int[] version = new int[2];
|
||||
egl.eglInitialize(display, version);
|
||||
|
||||
int[] totalConfigurations = new int[1];
|
||||
egl.eglGetConfigs(display, null, 0, totalConfigurations);
|
||||
|
||||
EGLConfig[] configurationsList = new EGLConfig[totalConfigurations[0]];
|
||||
egl.eglGetConfigs(display, configurationsList, totalConfigurations[0], totalConfigurations);
|
||||
|
||||
int[] textureSize = new int[1];
|
||||
int maximumTextureSize = 0;
|
||||
|
||||
for (int i = 0; i < totalConfigurations[0]; i++) {
|
||||
egl.eglGetConfigAttrib(display, configurationsList[i], EGL10.EGL_MAX_PBUFFER_WIDTH, textureSize);
|
||||
|
||||
if (maximumTextureSize < textureSize[0])
|
||||
maximumTextureSize = textureSize[0];
|
||||
}
|
||||
|
||||
egl.eglTerminate(display);
|
||||
|
||||
return Math.min(maximumTextureSize, MAX_ALLOWED_TEXTURE_SIZE);
|
||||
}
|
||||
|
||||
public static class ScaleResult {
|
||||
private final byte[] bitmap;
|
||||
private final int width;
|
||||
private final int height;
|
||||
|
||||
public ScaleResult(byte[] bitmap, int width, int height) {
|
||||
this.bitmap = bitmap;
|
||||
this.width = width;
|
||||
this.height = height;
|
||||
}
|
||||
|
||||
|
||||
public byte[] getBitmap() {
|
||||
return bitmap;
|
||||
}
|
||||
|
||||
public int getWidth() {
|
||||
return width;
|
||||
}
|
||||
|
||||
public int getHeight() {
|
||||
return height;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,95 @@
|
||||
package org.thoughtcrime.securesms.util;
|
||||
|
||||
import android.app.usage.UsageEvents;
|
||||
import android.app.usage.UsageStatsManager;
|
||||
|
||||
import androidx.annotation.NonNull;
|
||||
import androidx.annotation.RequiresApi;
|
||||
|
||||
import java.util.Date;
|
||||
|
||||
@RequiresApi(28)
|
||||
public final class BucketInfo {
|
||||
|
||||
/**
|
||||
* UsageStatsManager.STANDBY_BUCKET_EXEMPTED: is a Hidden API
|
||||
*/
|
||||
public static final int STANDBY_BUCKET_EXEMPTED = 5;
|
||||
|
||||
private final int currentBucket;
|
||||
private final int worstBucket;
|
||||
private final int bestBucket;
|
||||
private final CharSequence history;
|
||||
|
||||
private BucketInfo(int currentBucket, int worstBucket, int bestBucket, CharSequence history) {
|
||||
this.currentBucket = currentBucket;
|
||||
this.worstBucket = worstBucket;
|
||||
this.bestBucket = bestBucket;
|
||||
this.history = history;
|
||||
}
|
||||
|
||||
public static @NonNull BucketInfo getInfo(@NonNull UsageStatsManager usageStatsManager, long overLastDurationMs) {
|
||||
StringBuilder stringBuilder = new StringBuilder();
|
||||
|
||||
int currentBucket = usageStatsManager.getAppStandbyBucket();
|
||||
int worseBucket = currentBucket;
|
||||
int bestBucket = currentBucket;
|
||||
|
||||
long now = System.currentTimeMillis();
|
||||
UsageEvents.Event event = new UsageEvents.Event();
|
||||
UsageEvents usageEvents = usageStatsManager.queryEventsForSelf(now - overLastDurationMs, now);
|
||||
|
||||
while (usageEvents.hasNextEvent()) {
|
||||
usageEvents.getNextEvent(event);
|
||||
|
||||
if (event.getEventType() == UsageEvents.Event.STANDBY_BUCKET_CHANGED) {
|
||||
int appStandbyBucket = event.getAppStandbyBucket();
|
||||
|
||||
stringBuilder.append(new Date(event.getTimeStamp()))
|
||||
.append(": ")
|
||||
.append("Bucket Change: ")
|
||||
.append(bucketToString(appStandbyBucket))
|
||||
.append("\n");
|
||||
|
||||
if (appStandbyBucket > worseBucket) {
|
||||
worseBucket = appStandbyBucket;
|
||||
}
|
||||
if (appStandbyBucket < bestBucket) {
|
||||
bestBucket = appStandbyBucket;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return new BucketInfo(currentBucket, worseBucket, bestBucket, stringBuilder);
|
||||
}
|
||||
|
||||
/**
|
||||
* Not localized, for logs and debug only.
|
||||
*/
|
||||
public static String bucketToString(int bucket) {
|
||||
switch (bucket) {
|
||||
case UsageStatsManager.STANDBY_BUCKET_ACTIVE: return "Active";
|
||||
case UsageStatsManager.STANDBY_BUCKET_FREQUENT: return "Frequent";
|
||||
case UsageStatsManager.STANDBY_BUCKET_WORKING_SET: return "Working Set";
|
||||
case UsageStatsManager.STANDBY_BUCKET_RARE: return "Rare";
|
||||
case STANDBY_BUCKET_EXEMPTED: return "Exempted";
|
||||
default: return "Unknown " + bucket;
|
||||
}
|
||||
}
|
||||
|
||||
public int getBestBucket() {
|
||||
return bestBucket;
|
||||
}
|
||||
|
||||
public int getWorstBucket() {
|
||||
return worstBucket;
|
||||
}
|
||||
|
||||
public int getCurrentBucket() {
|
||||
return currentBucket;
|
||||
}
|
||||
|
||||
public CharSequence getHistory() {
|
||||
return history;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
package org.thoughtcrime.securesms.util;
|
||||
|
||||
import androidx.annotation.NonNull;
|
||||
|
||||
import java.util.Calendar;
|
||||
|
||||
public final class CalendarDateOnly {
|
||||
|
||||
public static Calendar getInstance() {
|
||||
Calendar calendar = Calendar.getInstance();
|
||||
|
||||
removeTime(calendar);
|
||||
|
||||
return calendar;
|
||||
}
|
||||
|
||||
public static void removeTime(@NonNull Calendar calendar) {
|
||||
calendar.set(Calendar.HOUR_OF_DAY, calendar.getActualMinimum(Calendar.HOUR_OF_DAY));
|
||||
calendar.set(Calendar.MINUTE, calendar.getActualMinimum(Calendar.MINUTE));
|
||||
calendar.set(Calendar.SECOND, calendar.getActualMinimum(Calendar.SECOND));
|
||||
calendar.set(Calendar.MILLISECOND, calendar.getActualMinimum(Calendar.MILLISECOND));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
/**
|
||||
* Copyright (C) 2015 Whisper Systems
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
*/
|
||||
package org.thoughtcrime.securesms.util;
|
||||
|
||||
import android.os.Parcel;
|
||||
import androidx.annotation.NonNull;
|
||||
|
||||
public abstract class CharacterCalculator {
|
||||
|
||||
public abstract CharacterState calculateCharacters(String messageBody);
|
||||
|
||||
public static CharacterCalculator readFromParcel(@NonNull Parcel in) {
|
||||
switch (in.readInt()) {
|
||||
case 1: return new SmsCharacterCalculator();
|
||||
case 2: return new MmsCharacterCalculator();
|
||||
case 3: return new PushCharacterCalculator();
|
||||
default: throw new IllegalArgumentException("Read an unsupported value for a calculator.");
|
||||
}
|
||||
}
|
||||
|
||||
public static void writeToParcel(@NonNull Parcel dest, @NonNull CharacterCalculator calculator) {
|
||||
if (calculator instanceof SmsCharacterCalculator) {
|
||||
dest.writeInt(1);
|
||||
} else if (calculator instanceof MmsCharacterCalculator) {
|
||||
dest.writeInt(2);
|
||||
} else if (calculator instanceof PushCharacterCalculator) {
|
||||
dest.writeInt(3);
|
||||
} else {
|
||||
throw new IllegalArgumentException("Tried to write an unsupported calculator to a parcel.");
|
||||
}
|
||||
}
|
||||
|
||||
public static class CharacterState {
|
||||
public final int charactersRemaining;
|
||||
public final int messagesSpent;
|
||||
public final int maxTotalMessageSize;
|
||||
public final int maxPrimaryMessageSize;
|
||||
|
||||
public CharacterState(int messagesSpent, int charactersRemaining, int maxTotalMessageSize, int maxPrimaryMessageSize) {
|
||||
this.messagesSpent = messagesSpent;
|
||||
this.charactersRemaining = charactersRemaining;
|
||||
this.maxTotalMessageSize = maxTotalMessageSize;
|
||||
this.maxPrimaryMessageSize = maxPrimaryMessageSize;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
package org.thoughtcrime.securesms.util;
|
||||
|
||||
import androidx.lifecycle.MutableLiveData;
|
||||
|
||||
import java.io.Closeable;
|
||||
|
||||
/**
|
||||
* Implementation of {@link androidx.lifecycle.LiveData} that will handle closing the contained
|
||||
* {@link Closeable} when the value changes.
|
||||
*/
|
||||
public class CloseableLiveData<E extends Closeable> extends MutableLiveData<E> {
|
||||
|
||||
@Override
|
||||
public void setValue(E value) {
|
||||
setValue(value, true);
|
||||
}
|
||||
|
||||
public void setValue(E value, boolean closePrevious) {
|
||||
E previous = getValue();
|
||||
|
||||
if (previous != null && closePrevious) {
|
||||
Util.close(previous);
|
||||
}
|
||||
|
||||
super.setValue(value);
|
||||
}
|
||||
|
||||
public void close() {
|
||||
E value = getValue();
|
||||
|
||||
if (value != null) {
|
||||
Util.close(value);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,163 @@
|
||||
package org.thoughtcrime.securesms.util;
|
||||
|
||||
import android.Manifest;
|
||||
import android.app.Activity;
|
||||
import android.content.ActivityNotFoundException;
|
||||
import android.content.Context;
|
||||
import android.content.Intent;
|
||||
import android.net.Uri;
|
||||
import android.os.AsyncTask;
|
||||
import androidx.annotation.NonNull;
|
||||
import androidx.annotation.Nullable;
|
||||
import androidx.appcompat.app.AlertDialog;
|
||||
import androidx.core.app.TaskStackBuilder;
|
||||
|
||||
import android.os.Bundle;
|
||||
import android.os.Handler;
|
||||
import android.os.Looper;
|
||||
import android.os.ResultReceiver;
|
||||
import android.text.TextUtils;
|
||||
import android.widget.Toast;
|
||||
|
||||
import org.thoughtcrime.securesms.conversation.ConversationActivity;
|
||||
import org.thoughtcrime.securesms.R;
|
||||
import org.thoughtcrime.securesms.WebRtcCallActivity;
|
||||
import org.thoughtcrime.securesms.database.DatabaseFactory;
|
||||
import org.thoughtcrime.securesms.permissions.Permissions;
|
||||
import org.thoughtcrime.securesms.recipients.Recipient;
|
||||
import org.thoughtcrime.securesms.service.WebRtcCallService;
|
||||
import org.thoughtcrime.securesms.util.concurrent.SimpleTask;
|
||||
|
||||
public class CommunicationActions {
|
||||
|
||||
public static void startVoiceCall(@NonNull Activity activity, @NonNull Recipient recipient) {
|
||||
if (TelephonyUtil.isAnyPstnLineBusy(activity)) {
|
||||
Toast.makeText(activity,
|
||||
R.string.CommunicationActions_a_cellular_call_is_already_in_progress,
|
||||
Toast.LENGTH_SHORT)
|
||||
.show();
|
||||
return;
|
||||
}
|
||||
|
||||
WebRtcCallService.isCallActive(activity, new ResultReceiver(new Handler(Looper.getMainLooper())) {
|
||||
@Override
|
||||
protected void onReceiveResult(int resultCode, Bundle resultData) {
|
||||
if (resultCode == 1) {
|
||||
startCallInternal(activity, recipient, false);
|
||||
} else {
|
||||
new AlertDialog.Builder(activity)
|
||||
.setMessage(R.string.CommunicationActions_start_voice_call)
|
||||
.setPositiveButton(R.string.CommunicationActions_call, (d, w) -> startCallInternal(activity, recipient, false))
|
||||
.setNegativeButton(R.string.CommunicationActions_cancel, (d, w) -> d.dismiss())
|
||||
.setCancelable(true)
|
||||
.show();
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
public static void startVideoCall(@NonNull Activity activity, @NonNull Recipient recipient) {
|
||||
if (TelephonyUtil.isAnyPstnLineBusy(activity)) {
|
||||
Toast.makeText(activity,
|
||||
R.string.CommunicationActions_a_cellular_call_is_already_in_progress,
|
||||
Toast.LENGTH_SHORT)
|
||||
.show();
|
||||
return;
|
||||
}
|
||||
|
||||
WebRtcCallService.isCallActive(activity, new ResultReceiver(new Handler(Looper.getMainLooper())) {
|
||||
@Override
|
||||
protected void onReceiveResult(int resultCode, Bundle resultData) {
|
||||
if (resultCode == 1) {
|
||||
startCallInternal(activity, recipient, false);
|
||||
} else {
|
||||
new AlertDialog.Builder(activity)
|
||||
.setMessage(R.string.CommunicationActions_start_video_call)
|
||||
.setPositiveButton(R.string.CommunicationActions_call, (d, w) -> startCallInternal(activity, recipient, true))
|
||||
.setNegativeButton(R.string.CommunicationActions_cancel, (d, w) -> d.dismiss())
|
||||
.setCancelable(true)
|
||||
.show();
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
public static void startConversation(@NonNull Context context, @NonNull Recipient recipient, @Nullable String text) {
|
||||
startConversation(context, recipient, text, null);
|
||||
}
|
||||
|
||||
public static void startConversation(@NonNull Context context,
|
||||
@NonNull Recipient recipient,
|
||||
@Nullable String text,
|
||||
@Nullable TaskStackBuilder backStack)
|
||||
{
|
||||
new AsyncTask<Void, Void, Long>() {
|
||||
@Override
|
||||
protected Long doInBackground(Void... voids) {
|
||||
return DatabaseFactory.getThreadDatabase(context).getThreadIdFor(recipient);
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void onPostExecute(Long threadId) {
|
||||
Intent intent = new Intent(context, ConversationActivity.class);
|
||||
intent.putExtra(ConversationActivity.RECIPIENT_EXTRA, recipient.getId());
|
||||
intent.putExtra(ConversationActivity.THREAD_ID_EXTRA, threadId);
|
||||
|
||||
if (!TextUtils.isEmpty(text)) {
|
||||
intent.putExtra(ConversationActivity.TEXT_EXTRA, text);
|
||||
}
|
||||
|
||||
if (backStack != null) {
|
||||
backStack.addNextIntent(intent);
|
||||
backStack.startActivities();
|
||||
} else {
|
||||
context.startActivity(intent);
|
||||
}
|
||||
}
|
||||
}.execute();
|
||||
}
|
||||
|
||||
public static void composeSmsThroughDefaultApp(@NonNull Context context, @NonNull Recipient recipient, @Nullable String text) {
|
||||
Intent intent = new Intent(Intent.ACTION_SENDTO, Uri.parse("smsto:" + recipient.requireSmsAddress()));
|
||||
if (text != null) {
|
||||
intent.putExtra("sms_body", text);
|
||||
}
|
||||
context.startActivity(intent);
|
||||
}
|
||||
|
||||
public static void openBrowserLink(@NonNull Context context, @NonNull String link) {
|
||||
try {
|
||||
Intent intent = new Intent(Intent.ACTION_VIEW, Uri.parse(link));
|
||||
context.startActivity(intent);
|
||||
} catch (ActivityNotFoundException e) {
|
||||
Toast.makeText(context, R.string.CommunicationActions_no_browser_found, Toast.LENGTH_SHORT).show();
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
private static void startCallInternal(@NonNull Activity activity, @NonNull Recipient recipient, boolean isVideo) {
|
||||
Permissions.with(activity)
|
||||
.request(Manifest.permission.RECORD_AUDIO, Manifest.permission.CAMERA)
|
||||
.ifNecessary()
|
||||
.withRationaleDialog(activity.getString(R.string.ConversationActivity_to_call_s_signal_needs_access_to_your_microphone_and_camera, recipient.getDisplayName(activity)),
|
||||
R.drawable.ic_mic_solid_24,
|
||||
R.drawable.ic_video_solid_24_tinted)
|
||||
.withPermanentDenialDialog(activity.getString(R.string.ConversationActivity_signal_needs_the_microphone_and_camera_permissions_in_order_to_call_s, recipient.getDisplayName(activity)))
|
||||
.onAllGranted(() -> {
|
||||
Intent intent = new Intent(activity, WebRtcCallService.class);
|
||||
intent.setAction(WebRtcCallService.ACTION_OUTGOING_CALL);
|
||||
intent.putExtra(WebRtcCallService.EXTRA_REMOTE_RECIPIENT, recipient.getId());
|
||||
activity.startService(intent);
|
||||
|
||||
Intent activityIntent = new Intent(activity, WebRtcCallActivity.class);
|
||||
activityIntent.setFlags(Intent.FLAG_ACTIVITY_NEW_TASK);
|
||||
|
||||
if (isVideo) {
|
||||
activityIntent.putExtra(WebRtcCallActivity.EXTRA_ENABLE_VIDEO_IF_AVAILABLE, true);
|
||||
}
|
||||
|
||||
activity.startActivity(activityIntent);
|
||||
})
|
||||
.execute();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,180 @@
|
||||
/**
|
||||
* Copyright (C) 2014 Open Whisper Systems
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
*/
|
||||
package org.thoughtcrime.securesms.util;
|
||||
|
||||
public class Conversions {
|
||||
|
||||
public static byte intsToByteHighAndLow(int highValue, int lowValue) {
|
||||
return (byte)((highValue << 4 | lowValue) & 0xFF);
|
||||
}
|
||||
|
||||
public static int highBitsToInt(byte value) {
|
||||
return (value & 0xFF) >> 4;
|
||||
}
|
||||
|
||||
public static int lowBitsToInt(byte value) {
|
||||
return (value & 0xF);
|
||||
}
|
||||
|
||||
public static int highBitsToMedium(int value) {
|
||||
return (value >> 12);
|
||||
}
|
||||
|
||||
public static int lowBitsToMedium(int value) {
|
||||
return (value & 0xFFF);
|
||||
}
|
||||
|
||||
public static byte[] shortToByteArray(int value) {
|
||||
byte[] bytes = new byte[2];
|
||||
shortToByteArray(bytes, 0, value);
|
||||
return bytes;
|
||||
}
|
||||
|
||||
public static int shortToByteArray(byte[] bytes, int offset, int value) {
|
||||
bytes[offset+1] = (byte)value;
|
||||
bytes[offset] = (byte)(value >> 8);
|
||||
return 2;
|
||||
}
|
||||
|
||||
public static int shortToLittleEndianByteArray(byte[] bytes, int offset, int value) {
|
||||
bytes[offset] = (byte)value;
|
||||
bytes[offset+1] = (byte)(value >> 8);
|
||||
return 2;
|
||||
}
|
||||
|
||||
public static byte[] mediumToByteArray(int value) {
|
||||
byte[] bytes = new byte[3];
|
||||
mediumToByteArray(bytes, 0, value);
|
||||
return bytes;
|
||||
}
|
||||
|
||||
public static int mediumToByteArray(byte[] bytes, int offset, int value) {
|
||||
bytes[offset + 2] = (byte)value;
|
||||
bytes[offset + 1] = (byte)(value >> 8);
|
||||
bytes[offset] = (byte)(value >> 16);
|
||||
return 3;
|
||||
}
|
||||
|
||||
public static byte[] intToByteArray(int value) {
|
||||
byte[] bytes = new byte[4];
|
||||
intToByteArray(bytes, 0, value);
|
||||
return bytes;
|
||||
}
|
||||
|
||||
public static int intToByteArray(byte[] bytes, int offset, int value) {
|
||||
bytes[offset + 3] = (byte)value;
|
||||
bytes[offset + 2] = (byte)(value >> 8);
|
||||
bytes[offset + 1] = (byte)(value >> 16);
|
||||
bytes[offset] = (byte)(value >> 24);
|
||||
return 4;
|
||||
}
|
||||
|
||||
public static int intToLittleEndianByteArray(byte[] bytes, int offset, int value) {
|
||||
bytes[offset] = (byte)value;
|
||||
bytes[offset+1] = (byte)(value >> 8);
|
||||
bytes[offset+2] = (byte)(value >> 16);
|
||||
bytes[offset+3] = (byte)(value >> 24);
|
||||
return 4;
|
||||
}
|
||||
|
||||
public static byte[] longToByteArray(long l) {
|
||||
byte[] bytes = new byte[8];
|
||||
longToByteArray(bytes, 0, l);
|
||||
return bytes;
|
||||
}
|
||||
|
||||
public static int longToByteArray(byte[] bytes, int offset, long value) {
|
||||
bytes[offset + 7] = (byte)value;
|
||||
bytes[offset + 6] = (byte)(value >> 8);
|
||||
bytes[offset + 5] = (byte)(value >> 16);
|
||||
bytes[offset + 4] = (byte)(value >> 24);
|
||||
bytes[offset + 3] = (byte)(value >> 32);
|
||||
bytes[offset + 2] = (byte)(value >> 40);
|
||||
bytes[offset + 1] = (byte)(value >> 48);
|
||||
bytes[offset] = (byte)(value >> 56);
|
||||
return 8;
|
||||
}
|
||||
|
||||
public static int longTo4ByteArray(byte[] bytes, int offset, long value) {
|
||||
bytes[offset + 3] = (byte)value;
|
||||
bytes[offset + 2] = (byte)(value >> 8);
|
||||
bytes[offset + 1] = (byte)(value >> 16);
|
||||
bytes[offset + 0] = (byte)(value >> 24);
|
||||
return 4;
|
||||
}
|
||||
|
||||
public static int byteArrayToShort(byte[] bytes) {
|
||||
return byteArrayToShort(bytes, 0);
|
||||
}
|
||||
|
||||
public static int byteArrayToShort(byte[] bytes, int offset) {
|
||||
return
|
||||
(bytes[offset] & 0xff) << 8 | (bytes[offset + 1] & 0xff);
|
||||
}
|
||||
|
||||
// The SSL patented 3-byte Value.
|
||||
public static int byteArrayToMedium(byte[] bytes, int offset) {
|
||||
return
|
||||
(bytes[offset] & 0xff) << 16 |
|
||||
(bytes[offset + 1] & 0xff) << 8 |
|
||||
(bytes[offset + 2] & 0xff);
|
||||
}
|
||||
|
||||
public static int byteArrayToInt(byte[] bytes) {
|
||||
return byteArrayToInt(bytes, 0);
|
||||
}
|
||||
|
||||
public static int byteArrayToInt(byte[] bytes, int offset) {
|
||||
return
|
||||
(bytes[offset] & 0xff) << 24 |
|
||||
(bytes[offset + 1] & 0xff) << 16 |
|
||||
(bytes[offset + 2] & 0xff) << 8 |
|
||||
(bytes[offset + 3] & 0xff);
|
||||
}
|
||||
|
||||
public static int byteArrayToIntLittleEndian(byte[] bytes, int offset) {
|
||||
return
|
||||
(bytes[offset + 3] & 0xff) << 24 |
|
||||
(bytes[offset + 2] & 0xff) << 16 |
|
||||
(bytes[offset + 1] & 0xff) << 8 |
|
||||
(bytes[offset] & 0xff);
|
||||
}
|
||||
|
||||
public static long byteArrayToLong(byte[] bytes) {
|
||||
return byteArrayToLong(bytes, 0);
|
||||
}
|
||||
|
||||
public static long byteArray4ToLong(byte[] bytes, int offset) {
|
||||
return
|
||||
((bytes[offset + 0] & 0xffL) << 24) |
|
||||
((bytes[offset + 1] & 0xffL) << 16) |
|
||||
((bytes[offset + 2] & 0xffL) << 8) |
|
||||
((bytes[offset + 3] & 0xffL));
|
||||
}
|
||||
|
||||
public static long byteArrayToLong(byte[] bytes, int offset) {
|
||||
return
|
||||
((bytes[offset] & 0xffL) << 56) |
|
||||
((bytes[offset + 1] & 0xffL) << 48) |
|
||||
((bytes[offset + 2] & 0xffL) << 40) |
|
||||
((bytes[offset + 3] & 0xffL) << 32) |
|
||||
((bytes[offset + 4] & 0xffL) << 24) |
|
||||
((bytes[offset + 5] & 0xffL) << 16) |
|
||||
((bytes[offset + 6] & 0xffL) << 8) |
|
||||
((bytes[offset + 7] & 0xffL));
|
||||
}
|
||||
}
|
||||
155
app/src/main/java/org/thoughtcrime/securesms/util/DateUtils.java
Normal file
155
app/src/main/java/org/thoughtcrime/securesms/util/DateUtils.java
Normal file
@@ -0,0 +1,155 @@
|
||||
/*
|
||||
* Copyright (C) 2014 Open Whisper Systems
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
*/
|
||||
package org.thoughtcrime.securesms.util;
|
||||
|
||||
import android.content.Context;
|
||||
import android.text.format.DateFormat;
|
||||
|
||||
import androidx.annotation.NonNull;
|
||||
|
||||
import org.thoughtcrime.securesms.R;
|
||||
|
||||
import java.text.SimpleDateFormat;
|
||||
import java.util.Calendar;
|
||||
import java.util.Date;
|
||||
import java.util.Locale;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
|
||||
/**
|
||||
* Utility methods to help display dates in a nice, easily readable way.
|
||||
*/
|
||||
public class DateUtils extends android.text.format.DateUtils {
|
||||
|
||||
@SuppressWarnings("unused")
|
||||
private static final String TAG = DateUtils.class.getSimpleName();
|
||||
private static final SimpleDateFormat DATE_FORMAT = new SimpleDateFormat("yyyyMMdd");
|
||||
|
||||
private static boolean isWithin(final long millis, final long span, final TimeUnit unit) {
|
||||
return System.currentTimeMillis() - millis <= unit.toMillis(span);
|
||||
}
|
||||
|
||||
private static boolean isYesterday(final long when) {
|
||||
return DateUtils.isToday(when + TimeUnit.DAYS.toMillis(1));
|
||||
}
|
||||
|
||||
private static int convertDelta(final long millis, TimeUnit to) {
|
||||
return (int) to.convert(System.currentTimeMillis() - millis, TimeUnit.MILLISECONDS);
|
||||
}
|
||||
|
||||
private static String getFormattedDateTime(long time, String template, Locale locale) {
|
||||
final String localizedPattern = getLocalizedPattern(template, locale);
|
||||
return new SimpleDateFormat(localizedPattern, locale).format(new Date(time));
|
||||
}
|
||||
|
||||
public static String getBriefRelativeTimeSpanString(final Context c, final Locale locale, final long timestamp) {
|
||||
if (isWithin(timestamp, 1, TimeUnit.MINUTES)) {
|
||||
return c.getString(R.string.DateUtils_just_now);
|
||||
} else if (isWithin(timestamp, 1, TimeUnit.HOURS)) {
|
||||
int mins = convertDelta(timestamp, TimeUnit.MINUTES);
|
||||
return c.getResources().getString(R.string.DateUtils_minutes_ago, mins);
|
||||
} else if (isWithin(timestamp, 1, TimeUnit.DAYS)) {
|
||||
int hours = convertDelta(timestamp, TimeUnit.HOURS);
|
||||
return c.getResources().getQuantityString(R.plurals.hours_ago, hours, hours);
|
||||
} else if (isWithin(timestamp, 6, TimeUnit.DAYS)) {
|
||||
return getFormattedDateTime(timestamp, "EEE", locale);
|
||||
} else if (isWithin(timestamp, 365, TimeUnit.DAYS)) {
|
||||
return getFormattedDateTime(timestamp, "MMM d", locale);
|
||||
} else {
|
||||
return getFormattedDateTime(timestamp, "MMM d, yyyy", locale);
|
||||
}
|
||||
}
|
||||
|
||||
public static String getExtendedRelativeTimeSpanString(final Context c, final Locale locale, final long timestamp) {
|
||||
if (isWithin(timestamp, 1, TimeUnit.MINUTES)) {
|
||||
return c.getString(R.string.DateUtils_just_now);
|
||||
} else if (isWithin(timestamp, 1, TimeUnit.HOURS)) {
|
||||
int mins = (int)TimeUnit.MINUTES.convert(System.currentTimeMillis() - timestamp, TimeUnit.MILLISECONDS);
|
||||
return c.getResources().getString(R.string.DateUtils_minutes_ago, mins);
|
||||
} else {
|
||||
StringBuilder format = new StringBuilder();
|
||||
if (isWithin(timestamp, 6, TimeUnit.DAYS)) format.append("EEE ");
|
||||
else if (isWithin(timestamp, 365, TimeUnit.DAYS)) format.append("MMM d, ");
|
||||
else format.append("MMM d, yyyy, ");
|
||||
|
||||
if (DateFormat.is24HourFormat(c)) format.append("HH:mm");
|
||||
else format.append("hh:mm a");
|
||||
|
||||
return getFormattedDateTime(timestamp, format.toString(), locale);
|
||||
}
|
||||
}
|
||||
|
||||
public static String getDayPrecisionTimeSpanString(Context context, Locale locale, long timestamp) {
|
||||
SimpleDateFormat simpleDateFormat = new SimpleDateFormat("yyyyMMdd");
|
||||
|
||||
if (simpleDateFormat.format(System.currentTimeMillis()).equals(simpleDateFormat.format(timestamp))) {
|
||||
return context.getString(R.string.DeviceListItem_today);
|
||||
} else {
|
||||
String format;
|
||||
|
||||
if (isWithin(timestamp, 6, TimeUnit.DAYS)) format = "EEE ";
|
||||
else if (isWithin(timestamp, 365, TimeUnit.DAYS)) format = "MMM d";
|
||||
else format = "MMM d, yyy";
|
||||
|
||||
return getFormattedDateTime(timestamp, format, locale);
|
||||
}
|
||||
}
|
||||
|
||||
public static SimpleDateFormat getDetailedDateFormatter(Context context, Locale locale) {
|
||||
String dateFormatPattern;
|
||||
|
||||
if (DateFormat.is24HourFormat(context)) {
|
||||
dateFormatPattern = getLocalizedPattern("MMM d, yyyy HH:mm:ss zzz", locale);
|
||||
} else {
|
||||
dateFormatPattern = getLocalizedPattern("MMM d, yyyy hh:mm:ss a zzz", locale);
|
||||
}
|
||||
|
||||
return new SimpleDateFormat(dateFormatPattern, locale);
|
||||
}
|
||||
|
||||
public static String getRelativeDate(@NonNull Context context,
|
||||
@NonNull Locale locale,
|
||||
long timestamp)
|
||||
{
|
||||
if (isToday(timestamp)) {
|
||||
return context.getString(R.string.DateUtils_today);
|
||||
} else if (isYesterday(timestamp)) {
|
||||
return context.getString(R.string.DateUtils_yesterday);
|
||||
} else {
|
||||
return formatDate(locale, timestamp);
|
||||
}
|
||||
}
|
||||
|
||||
public static String formatDate(@NonNull Locale locale, long timestamp) {
|
||||
return getFormattedDateTime(timestamp, "EEE, MMM d, yyyy", locale);
|
||||
}
|
||||
|
||||
public static String formatDateWithoutDayOfWeek(@NonNull Locale locale, long timestamp) {
|
||||
return getFormattedDateTime(timestamp, "MMM d yyyy", locale);
|
||||
}
|
||||
|
||||
public static boolean isSameDay(long t1, long t2) {
|
||||
return DATE_FORMAT.format(new Date(t1)).equals(DATE_FORMAT.format(new Date(t2)));
|
||||
}
|
||||
|
||||
public static boolean isSameExtendedRelativeTimestamp(@NonNull Context context, @NonNull Locale locale, long t1, long t2) {
|
||||
return getExtendedRelativeTimeSpanString(context, locale, t1).equals(getExtendedRelativeTimeSpanString(context, locale, t2));
|
||||
}
|
||||
|
||||
private static String getLocalizedPattern(String template, Locale locale) {
|
||||
return DateFormat.getBestDateTimePattern(locale, template);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
package org.thoughtcrime.securesms.util;
|
||||
|
||||
import android.os.Handler;
|
||||
|
||||
/**
|
||||
* A class that will throttle the number of runnables executed to be at most once every specified
|
||||
* interval. However, it could be longer if events are published consistently.
|
||||
*
|
||||
* Useful for performing actions in response to rapid user input, such as inputting text, where you
|
||||
* don't necessarily want to perform an action after <em>every</em> input.
|
||||
*
|
||||
* See http://rxmarbles.com/#debounce
|
||||
*/
|
||||
public class Debouncer {
|
||||
|
||||
private final Handler handler;
|
||||
private final long threshold;
|
||||
|
||||
/**
|
||||
* @param threshold Only one runnable will be executed via {@link #publish(Runnable)} every
|
||||
* {@code threshold} milliseconds.
|
||||
*/
|
||||
public Debouncer(long threshold) {
|
||||
this.handler = new Handler();
|
||||
this.threshold = threshold;
|
||||
}
|
||||
|
||||
public void publish(Runnable runnable) {
|
||||
handler.removeCallbacksAndMessages(null);
|
||||
handler.postDelayed(runnable, threshold);
|
||||
}
|
||||
|
||||
public void clear() {
|
||||
handler.removeCallbacksAndMessages(null);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
package org.thoughtcrime.securesms.util;
|
||||
|
||||
|
||||
import android.text.TextUtils;
|
||||
|
||||
import java.util.regex.Pattern;
|
||||
|
||||
public class DelimiterUtil {
|
||||
|
||||
public static String escape(String value, char delimiter) {
|
||||
return value.replace("" + delimiter, "\\" + delimiter);
|
||||
}
|
||||
|
||||
public static String unescape(String value, char delimiter) {
|
||||
return value.replace("\\" + delimiter, "" + delimiter);
|
||||
}
|
||||
|
||||
public static String[] split(String value, char delimiter) {
|
||||
if (TextUtils.isEmpty(value)) {
|
||||
return new String[0];
|
||||
} else {
|
||||
String regex = "(?<!\\\\)" + Pattern.quote(delimiter + "");
|
||||
return value.split(regex);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
/**
|
||||
* Copyright (C) 2011 Whisper Systems
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
*/
|
||||
package org.thoughtcrime.securesms.util;
|
||||
|
||||
import android.content.Context;
|
||||
import androidx.appcompat.app.AlertDialog;
|
||||
|
||||
import org.thoughtcrime.securesms.R;
|
||||
|
||||
public class Dialogs {
|
||||
public static void showAlertDialog(Context context, String title, String message) {
|
||||
AlertDialog.Builder dialog = new AlertDialog.Builder(context);
|
||||
dialog.setTitle(title);
|
||||
dialog.setMessage(message);
|
||||
dialog.setIconAttribute(R.attr.dialog_alert_icon);
|
||||
dialog.setPositiveButton(android.R.string.ok, null);
|
||||
dialog.show();
|
||||
}
|
||||
|
||||
public static void showInfoDialog(Context context, String title, String message) {
|
||||
AlertDialog.Builder dialog = new AlertDialog.Builder(context);
|
||||
dialog.setTitle(title);
|
||||
dialog.setMessage(message);
|
||||
dialog.setIconAttribute(R.attr.dialog_info_icon);
|
||||
dialog.setPositiveButton(android.R.string.ok, null);
|
||||
dialog.show();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
package org.thoughtcrime.securesms.util;
|
||||
|
||||
import android.app.Activity;
|
||||
|
||||
import org.thoughtcrime.securesms.R;
|
||||
|
||||
public class DynamicDarkActionBarTheme extends DynamicTheme {
|
||||
@Override
|
||||
protected int getSelectedTheme(Activity activity) {
|
||||
String theme = TextSecurePreferences.getTheme(activity);
|
||||
|
||||
if (theme.equals("dark")) {
|
||||
return R.style.TextSecure_DarkTheme_Conversation;
|
||||
}
|
||||
|
||||
return R.style.TextSecure_LightTheme_Conversation;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
package org.thoughtcrime.securesms.util;
|
||||
|
||||
import android.app.Activity;
|
||||
|
||||
import org.thoughtcrime.securesms.R;
|
||||
|
||||
public class DynamicDarkToolbarTheme extends DynamicTheme {
|
||||
@Override
|
||||
protected int getSelectedTheme(Activity activity) {
|
||||
String theme = TextSecurePreferences.getTheme(activity);
|
||||
|
||||
if (theme.equals("dark")) {
|
||||
return R.style.TextSecure_DarkNoActionBar_DarkToolbar;
|
||||
}
|
||||
|
||||
return R.style.TextSecure_LightNoActionBar_DarkToolbar;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
package org.thoughtcrime.securesms.util;
|
||||
|
||||
import android.app.Activity;
|
||||
|
||||
import org.thoughtcrime.securesms.R;
|
||||
|
||||
public class DynamicIntroTheme extends DynamicTheme {
|
||||
@Override
|
||||
protected int getSelectedTheme(Activity activity) {
|
||||
String theme = TextSecurePreferences.getTheme(activity);
|
||||
|
||||
if (theme.equals("dark")) return R.style.TextSecure_DarkIntroTheme;
|
||||
|
||||
return R.style.TextSecure_LightIntroTheme;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
package org.thoughtcrime.securesms.util;
|
||||
|
||||
import android.app.Activity;
|
||||
import android.app.Service;
|
||||
import android.content.Context;
|
||||
import android.content.res.Configuration;
|
||||
|
||||
import org.thoughtcrime.securesms.util.dynamiclanguage.LanguageString;
|
||||
|
||||
import java.util.Locale;
|
||||
|
||||
/**
|
||||
* @deprecated Use a base activity that uses the {@link org.thoughtcrime.securesms.util.dynamiclanguage.DynamicLanguageContextWrapper}
|
||||
*/
|
||||
@Deprecated
|
||||
public class DynamicLanguage {
|
||||
|
||||
public void onCreate(Activity activity) {
|
||||
}
|
||||
|
||||
public void onResume(Activity activity) {
|
||||
}
|
||||
|
||||
public void updateServiceLocale(Service service) {
|
||||
setContextLocale(service, getSelectedLocale(service));
|
||||
}
|
||||
|
||||
public Locale getCurrentLocale() {
|
||||
return Locale.getDefault();
|
||||
}
|
||||
|
||||
static int getLayoutDirection(Context context) {
|
||||
Configuration configuration = context.getResources().getConfiguration();
|
||||
return configuration.getLayoutDirection();
|
||||
}
|
||||
|
||||
private static void setContextLocale(Context context, Locale selectedLocale) {
|
||||
Configuration configuration = context.getResources().getConfiguration();
|
||||
|
||||
if (!configuration.locale.equals(selectedLocale)) {
|
||||
configuration.setLocale(selectedLocale);
|
||||
context.getResources().updateConfiguration(configuration,
|
||||
context.getResources().getDisplayMetrics());
|
||||
}
|
||||
}
|
||||
|
||||
private static Locale getSelectedLocale(Context context) {
|
||||
Locale locale = LanguageString.parseLocale(TextSecurePreferences.getLanguage(context));
|
||||
if (locale == null) {
|
||||
return Locale.getDefault();
|
||||
} else {
|
||||
return locale;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
package org.thoughtcrime.securesms.util;
|
||||
|
||||
import android.app.Activity;
|
||||
|
||||
import org.thoughtcrime.securesms.R;
|
||||
|
||||
public class DynamicNoActionBarInviteTheme extends DynamicTheme {
|
||||
@Override
|
||||
protected int getSelectedTheme(Activity activity) {
|
||||
String theme = TextSecurePreferences.getTheme(activity);
|
||||
|
||||
if (theme.equals("dark")) return R.style.Signal_NoActionBar_Invite;
|
||||
|
||||
return R.style.Signal_Light_NoActionBar_Invite;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
package org.thoughtcrime.securesms.util;
|
||||
|
||||
import android.app.Activity;
|
||||
|
||||
import org.thoughtcrime.securesms.R;
|
||||
|
||||
public class DynamicNoActionBarTheme extends DynamicTheme {
|
||||
@Override
|
||||
protected int getSelectedTheme(Activity activity) {
|
||||
String theme = TextSecurePreferences.getTheme(activity);
|
||||
|
||||
if (theme.equals("dark")) return R.style.TextSecure_DarkNoActionBar;
|
||||
|
||||
return R.style.TextSecure_LightNoActionBar;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
package org.thoughtcrime.securesms.util;
|
||||
|
||||
import android.app.Activity;
|
||||
|
||||
import org.thoughtcrime.securesms.R;
|
||||
|
||||
public class DynamicRegistrationTheme extends DynamicTheme {
|
||||
@Override
|
||||
protected int getSelectedTheme(Activity activity) {
|
||||
String theme = TextSecurePreferences.getTheme(activity);
|
||||
|
||||
if (theme.equals("dark")) return R.style.TextSecure_DarkRegistrationTheme;
|
||||
|
||||
return R.style.TextSecure_LightRegistrationTheme;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
package org.thoughtcrime.securesms.util;
|
||||
|
||||
import android.app.Activity;
|
||||
import android.content.Intent;
|
||||
import android.content.res.Configuration;
|
||||
|
||||
import androidx.appcompat.app.AppCompatDelegate;
|
||||
|
||||
import org.thoughtcrime.securesms.R;
|
||||
|
||||
public class DynamicTheme {
|
||||
|
||||
public static final String DARK = "dark";
|
||||
public static final String LIGHT = "light";
|
||||
|
||||
private int currentTheme;
|
||||
|
||||
public void onCreate(Activity activity) {
|
||||
currentTheme = getSelectedTheme(activity);
|
||||
activity.setTheme(currentTheme);
|
||||
}
|
||||
|
||||
public void onResume(Activity activity) {
|
||||
if (currentTheme != getSelectedTheme(activity)) {
|
||||
Intent intent = activity.getIntent();
|
||||
activity.finish();
|
||||
OverridePendingTransition.invoke(activity);
|
||||
activity.startActivity(intent);
|
||||
OverridePendingTransition.invoke(activity);
|
||||
}
|
||||
}
|
||||
|
||||
protected int getSelectedTheme(Activity activity) {
|
||||
String theme = TextSecurePreferences.getTheme(activity);
|
||||
|
||||
if (theme.equals(DARK)) {
|
||||
return R.style.TextSecure_DarkTheme;
|
||||
}
|
||||
|
||||
return R.style.TextSecure_LightTheme;
|
||||
}
|
||||
|
||||
private static final class OverridePendingTransition {
|
||||
static void invoke(Activity activity) {
|
||||
activity.overridePendingTransition(0, 0);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
package org.thoughtcrime.securesms.util;
|
||||
|
||||
import android.content.Context;
|
||||
|
||||
import org.thoughtcrime.securesms.R;
|
||||
|
||||
import java.util.concurrent.TimeUnit;
|
||||
|
||||
public class ExpirationUtil {
|
||||
|
||||
public static String getExpirationDisplayValue(Context context, int expirationTime) {
|
||||
if (expirationTime <= 0) {
|
||||
return context.getString(R.string.expiration_off);
|
||||
} else if (expirationTime < TimeUnit.MINUTES.toSeconds(1)) {
|
||||
return context.getResources().getQuantityString(R.plurals.expiration_seconds, expirationTime, expirationTime);
|
||||
} else if (expirationTime < TimeUnit.HOURS.toSeconds(1)) {
|
||||
int minutes = expirationTime / (int)TimeUnit.MINUTES.toSeconds(1);
|
||||
return context.getResources().getQuantityString(R.plurals.expiration_minutes, minutes, minutes);
|
||||
} else if (expirationTime < TimeUnit.DAYS.toSeconds(1)) {
|
||||
int hours = expirationTime / (int)TimeUnit.HOURS.toSeconds(1);
|
||||
return context.getResources().getQuantityString(R.plurals.expiration_hours, hours, hours);
|
||||
} else if (expirationTime < TimeUnit.DAYS.toSeconds(7)) {
|
||||
int days = expirationTime / (int)TimeUnit.DAYS.toSeconds(1);
|
||||
return context.getResources().getQuantityString(R.plurals.expiration_days, days, days);
|
||||
} else {
|
||||
int weeks = expirationTime / (int)TimeUnit.DAYS.toSeconds(7);
|
||||
return context.getResources().getQuantityString(R.plurals.expiration_weeks, weeks, weeks);
|
||||
}
|
||||
}
|
||||
|
||||
public static String getExpirationAbbreviatedDisplayValue(Context context, int expirationTime) {
|
||||
if (expirationTime < TimeUnit.MINUTES.toSeconds(1)) {
|
||||
return context.getResources().getString(R.string.expiration_seconds_abbreviated, expirationTime);
|
||||
} else if (expirationTime < TimeUnit.HOURS.toSeconds(1)) {
|
||||
int minutes = expirationTime / (int)TimeUnit.MINUTES.toSeconds(1);
|
||||
return context.getResources().getString(R.string.expiration_minutes_abbreviated, minutes);
|
||||
} else if (expirationTime < TimeUnit.DAYS.toSeconds(1)) {
|
||||
int hours = expirationTime / (int)TimeUnit.HOURS.toSeconds(1);
|
||||
return context.getResources().getString(R.string.expiration_hours_abbreviated, hours);
|
||||
} else if (expirationTime < TimeUnit.DAYS.toSeconds(7)) {
|
||||
int days = expirationTime / (int)TimeUnit.DAYS.toSeconds(1);
|
||||
return context.getResources().getString(R.string.expiration_days_abbreviated, days);
|
||||
} else {
|
||||
int weeks = expirationTime / (int)TimeUnit.DAYS.toSeconds(7);
|
||||
return context.getResources().getString(R.string.expiration_weeks_abbreviated, weeks);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
package org.thoughtcrime.securesms.util;
|
||||
|
||||
/**
|
||||
* A location for constants that allows us to turn features on and off during development.
|
||||
* After a feature has been launched, the flag should be removed.
|
||||
*/
|
||||
public class FeatureFlags {
|
||||
/** UUID-related stuff that shouldn't be activated until the user-facing launch. */
|
||||
public static final boolean UUIDS = false;
|
||||
|
||||
/** Favoring profile names when displaying contacts. */
|
||||
public static final boolean PROFILE_DISPLAY = UUIDS;
|
||||
|
||||
/** MessageRequest stuff */
|
||||
public static final boolean MESSAGE_REQUESTS = UUIDS;
|
||||
|
||||
/** Creating usernames, sending messages by username. Requires {@link #UUIDS}. */
|
||||
public static final boolean USERNAMES = false;
|
||||
|
||||
/** Set or migrate PIN to KBS */
|
||||
public static final boolean KBS = false;
|
||||
|
||||
/** Storage service. Requires {@link #KBS}. */
|
||||
public static final boolean STORAGE_SERVICE = false;
|
||||
|
||||
/** Send support for reactions. */
|
||||
public static final boolean REACTION_SENDING = false;
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
package org.thoughtcrime.securesms.util;
|
||||
|
||||
|
||||
import android.content.Context;
|
||||
import android.net.Uri;
|
||||
import android.os.Build;
|
||||
import androidx.annotation.NonNull;
|
||||
import androidx.core.content.FileProvider;
|
||||
|
||||
import java.io.File;
|
||||
|
||||
public class FileProviderUtil {
|
||||
|
||||
private static final String AUTHORITY = "org.thoughtcrime.securesms.fileprovider";
|
||||
|
||||
public static Uri getUriFor(@NonNull Context context, @NonNull File file) {
|
||||
if (Build.VERSION.SDK_INT >= 24) return FileProvider.getUriForFile(context, AUTHORITY, file);
|
||||
else return Uri.fromFile(file);
|
||||
}
|
||||
|
||||
public static boolean isAuthority(@NonNull Uri uri) {
|
||||
return AUTHORITY.equals(uri.getAuthority());
|
||||
}
|
||||
|
||||
public static boolean delete(@NonNull Context context, @NonNull Uri uri) {
|
||||
if (AUTHORITY.equals(uri.getAuthority())) {
|
||||
return context.getContentResolver().delete(uri, null, null) > 0;
|
||||
}
|
||||
return new File(uri.getPath()).delete();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
package org.thoughtcrime.securesms.util;
|
||||
|
||||
import androidx.annotation.Nullable;
|
||||
|
||||
import java.io.File;
|
||||
import java.io.FileDescriptor;
|
||||
import java.io.FileInputStream;
|
||||
import java.io.IOException;
|
||||
import java.security.MessageDigest;
|
||||
import java.security.NoSuchAlgorithmException;
|
||||
|
||||
public final class FileUtils {
|
||||
|
||||
static {
|
||||
System.loadLibrary("native-utils");
|
||||
}
|
||||
|
||||
public static native int getFileDescriptorOwner(FileDescriptor fileDescriptor);
|
||||
|
||||
static native int createMemoryFileDescriptor(String name);
|
||||
|
||||
public static byte[] getFileDigest(FileInputStream fin) throws IOException {
|
||||
try {
|
||||
MessageDigest digest = MessageDigest.getInstance("SHA256");
|
||||
|
||||
byte[] buffer = new byte[4096];
|
||||
int read = 0;
|
||||
|
||||
while ((read = fin.read(buffer, 0, buffer.length)) != -1) {
|
||||
digest.update(buffer, 0, read);
|
||||
}
|
||||
|
||||
return digest.digest();
|
||||
} catch (NoSuchAlgorithmException e) {
|
||||
throw new AssertionError(e);
|
||||
}
|
||||
}
|
||||
|
||||
public static void deleteDirectoryContents(@Nullable File directory) {
|
||||
if (directory == null || !directory.exists() || !directory.isDirectory()) return;
|
||||
|
||||
File[] files = directory.listFiles();
|
||||
|
||||
if (files != null) {
|
||||
for (File file : files) {
|
||||
if (file.isDirectory()) deleteDirectory(file);
|
||||
else file.delete();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public static void deleteDirectory(@Nullable File directory) {
|
||||
if (directory == null || !directory.exists() || !directory.isDirectory()) {
|
||||
return;
|
||||
}
|
||||
|
||||
deleteDirectoryContents(directory);
|
||||
|
||||
directory.delete();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,192 @@
|
||||
package org.thoughtcrime.securesms.util;
|
||||
|
||||
import android.content.Context;
|
||||
import android.view.Choreographer;
|
||||
import android.view.Display;
|
||||
import androidx.annotation.NonNull;
|
||||
|
||||
import org.thoughtcrime.securesms.logging.Log;
|
||||
|
||||
import java.util.ArrayDeque;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import java.util.Locale;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
|
||||
/**
|
||||
* Tracks the frame rate of the app and logs when things are bad.
|
||||
*
|
||||
* In general, whenever alterations are made here, the author should be very cautious to do as
|
||||
* little work as possible, because we don't want the tracker itself to impact the frame rate.
|
||||
*/
|
||||
public class FrameRateTracker {
|
||||
|
||||
private static final String TAG = Log.tag(FrameRateTracker.class);
|
||||
|
||||
private static final long REPORTING_INTERVAL = TimeUnit.SECONDS.toMillis(1);
|
||||
|
||||
private static final int MAX_CONSECUTIVE_FRAME_LOGS = 10;
|
||||
private static final int MAX_CONSECUTIVE_INTERVAL_LOGS = 10;
|
||||
|
||||
private final Context context;
|
||||
private final List<Double> fpsData;
|
||||
private final RingBuffer runningAverageFps;
|
||||
|
||||
private double refreshRate;
|
||||
private long idealTimePerFrameNanos;
|
||||
private long badFrameThresholdNanos;
|
||||
private double badIntervalThresholdFps;
|
||||
|
||||
private long lastFrameTimeNanos;
|
||||
private long lastReportTimeNanos;
|
||||
|
||||
private long consecutiveFrameWarnings;
|
||||
private long consecutiveIntervalWarnings;
|
||||
|
||||
public FrameRateTracker(@NonNull Context context) {
|
||||
this.context = context;
|
||||
this.fpsData = new ArrayList<>();
|
||||
this.runningAverageFps = new RingBuffer(TimeUnit.SECONDS.toMillis(10));
|
||||
|
||||
updateRefreshRate();
|
||||
}
|
||||
|
||||
public void begin() {
|
||||
Log.d(TAG, String.format(Locale.ENGLISH, "Beginning frame rate tracking. Screen refresh rate: %.2f hz, or %.2f ms per frame.", refreshRate, idealTimePerFrameNanos / (float) 1_000_000));
|
||||
|
||||
lastFrameTimeNanos = System.nanoTime();
|
||||
lastReportTimeNanos = System.nanoTime();
|
||||
|
||||
Choreographer.getInstance().postFrameCallback(calculator);
|
||||
Choreographer.getInstance().postFrameCallbackDelayed(reporter, 1000);
|
||||
}
|
||||
|
||||
public void end() {
|
||||
Choreographer.getInstance().removeFrameCallback(calculator);
|
||||
Choreographer.getInstance().removeFrameCallback(reporter);
|
||||
|
||||
fpsData.clear();
|
||||
runningAverageFps.clear();
|
||||
}
|
||||
|
||||
public double getRunningAverageFps() {
|
||||
return runningAverageFps.getAverage();
|
||||
}
|
||||
|
||||
/**
|
||||
* The natural screen refresh rate, in hertz. May not always return the same value if a display
|
||||
* has a dynamic refresh rate.
|
||||
*/
|
||||
public static float getDisplayRefreshRate(@NonNull Context context) {
|
||||
Display display = ServiceUtil.getWindowManager(context).getDefaultDisplay();
|
||||
return display.getRefreshRate();
|
||||
}
|
||||
|
||||
/**
|
||||
* Displays with dynamic refresh rates may change their reported refresh rate over time.
|
||||
*/
|
||||
private void updateRefreshRate() {
|
||||
double newRefreshRate = getDisplayRefreshRate(context);
|
||||
|
||||
if (this.refreshRate != newRefreshRate) {
|
||||
if (this.refreshRate > 0) {
|
||||
Log.d(TAG, String.format(Locale.ENGLISH, "Refresh rate changed from %.2f hz to %.2f hz", refreshRate, newRefreshRate));
|
||||
}
|
||||
|
||||
this.refreshRate = getDisplayRefreshRate(context);
|
||||
this.idealTimePerFrameNanos = (long) (TimeUnit.SECONDS.toNanos(1) / refreshRate);
|
||||
this.badFrameThresholdNanos = idealTimePerFrameNanos * (int) (refreshRate / 4);
|
||||
this.badIntervalThresholdFps = refreshRate / 2;
|
||||
}
|
||||
}
|
||||
|
||||
private final Choreographer.FrameCallback calculator = new Choreographer.FrameCallback() {
|
||||
@Override
|
||||
public void doFrame(long frameTimeNanos) {
|
||||
long elapsedNanos = frameTimeNanos - lastFrameTimeNanos;
|
||||
double fps = TimeUnit.SECONDS.toNanos(1) / (double) elapsedNanos;
|
||||
|
||||
if (elapsedNanos > badFrameThresholdNanos) {
|
||||
if (consecutiveFrameWarnings < MAX_CONSECUTIVE_FRAME_LOGS) {
|
||||
long droppedFrames = elapsedNanos / idealTimePerFrameNanos;
|
||||
Log.w(TAG, String.format(Locale.ENGLISH, "Bad frame! Took %d ms (%d dropped frames, or %.2f FPS)", TimeUnit.NANOSECONDS.toMillis(elapsedNanos), droppedFrames, fps));
|
||||
consecutiveFrameWarnings++;
|
||||
}
|
||||
} else {
|
||||
consecutiveFrameWarnings = 0;
|
||||
}
|
||||
|
||||
fpsData.add(fps);
|
||||
runningAverageFps.add(fps);
|
||||
|
||||
lastFrameTimeNanos = frameTimeNanos;
|
||||
Choreographer.getInstance().postFrameCallback(this);
|
||||
}
|
||||
};
|
||||
|
||||
private final Choreographer.FrameCallback reporter = new Choreographer.FrameCallback() {
|
||||
@Override
|
||||
public void doFrame(long frameTimeNanos) {
|
||||
double averageFps = 0;
|
||||
int size = fpsData.size();
|
||||
|
||||
for (double fps : fpsData) {
|
||||
averageFps += fps / size;
|
||||
}
|
||||
|
||||
if (averageFps < badIntervalThresholdFps) {
|
||||
if (consecutiveIntervalWarnings < MAX_CONSECUTIVE_INTERVAL_LOGS) {
|
||||
Log.w(TAG, String.format(Locale.ENGLISH, "Bad interval! Average of %.2f FPS over the last %d ms", averageFps, TimeUnit.NANOSECONDS.toMillis(frameTimeNanos - lastReportTimeNanos)));
|
||||
consecutiveIntervalWarnings++;
|
||||
}
|
||||
} else {
|
||||
consecutiveIntervalWarnings = 0;
|
||||
}
|
||||
|
||||
lastReportTimeNanos = frameTimeNanos;
|
||||
updateRefreshRate();
|
||||
Choreographer.getInstance().postFrameCallbackDelayed(this, REPORTING_INTERVAL);
|
||||
}
|
||||
};
|
||||
|
||||
private static class RingBuffer {
|
||||
private final long interval;
|
||||
private final ArrayDeque<Long> timestamps;
|
||||
private final ArrayDeque<Double> elements;
|
||||
|
||||
RingBuffer(long interval) {
|
||||
this.interval = interval;
|
||||
this.timestamps = new ArrayDeque<>();
|
||||
this.elements = new ArrayDeque<>();
|
||||
}
|
||||
|
||||
void add(double value) {
|
||||
long currentTime = System.currentTimeMillis();
|
||||
|
||||
while (!timestamps.isEmpty() && timestamps.getFirst() < (currentTime - interval)) {
|
||||
timestamps.pollFirst();
|
||||
elements.pollFirst();
|
||||
}
|
||||
|
||||
timestamps.addLast(currentTime);
|
||||
elements.addLast(value);
|
||||
}
|
||||
|
||||
double getAverage() {
|
||||
List<Double> elementsCopy = new ArrayList<>(elements);
|
||||
double average = 0;
|
||||
int size = elementsCopy.size();
|
||||
|
||||
for (double element : elementsCopy) {
|
||||
average += element / size;
|
||||
}
|
||||
|
||||
return average;
|
||||
}
|
||||
|
||||
void clear() {
|
||||
timestamps.clear();
|
||||
elements.clear();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
package org.thoughtcrime.securesms.util;
|
||||
|
||||
/**
|
||||
* A function which takes 3 inputs and returns 1 output.
|
||||
*/
|
||||
public interface Function3<A, B, C, D> {
|
||||
D apply(A a, B b, C c);
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
/**
|
||||
* Copyright (C) 2014 Open Whisper Systems
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
*/
|
||||
package org.thoughtcrime.securesms.util;
|
||||
|
||||
import java.util.concurrent.ExecutionException;
|
||||
|
||||
public interface FutureTaskListener<V> {
|
||||
public void onSuccess(V result);
|
||||
public void onFailure(ExecutionException exception);
|
||||
}
|
||||
175
app/src/main/java/org/thoughtcrime/securesms/util/GroupUtil.java
Normal file
175
app/src/main/java/org/thoughtcrime/securesms/util/GroupUtil.java
Normal file
@@ -0,0 +1,175 @@
|
||||
package org.thoughtcrime.securesms.util;
|
||||
|
||||
import android.content.Context;
|
||||
import android.text.TextUtils;
|
||||
|
||||
import androidx.annotation.NonNull;
|
||||
import androidx.annotation.Nullable;
|
||||
import androidx.annotation.WorkerThread;
|
||||
|
||||
import com.google.protobuf.ByteString;
|
||||
|
||||
import org.thoughtcrime.securesms.R;
|
||||
import org.thoughtcrime.securesms.database.DatabaseFactory;
|
||||
import org.thoughtcrime.securesms.database.GroupDatabase;
|
||||
import org.thoughtcrime.securesms.logging.Log;
|
||||
import org.thoughtcrime.securesms.mms.OutgoingGroupMediaMessage;
|
||||
import org.thoughtcrime.securesms.recipients.Recipient;
|
||||
import org.thoughtcrime.securesms.recipients.RecipientForeverObserver;
|
||||
import org.whispersystems.libsignal.util.guava.Optional;
|
||||
import org.whispersystems.signalservice.api.push.SignalServiceAddress;
|
||||
import org.whispersystems.signalservice.api.util.UuidUtil;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.util.Collections;
|
||||
import java.util.LinkedList;
|
||||
import java.util.List;
|
||||
|
||||
import static org.whispersystems.signalservice.internal.push.SignalServiceProtos.GroupContext;
|
||||
|
||||
public class GroupUtil {
|
||||
|
||||
private static final String ENCODED_SIGNAL_GROUP_PREFIX = "__textsecure_group__!";
|
||||
private static final String ENCODED_MMS_GROUP_PREFIX = "__signal_mms_group__!";
|
||||
private static final String TAG = GroupUtil.class.getSimpleName();
|
||||
|
||||
public static String getEncodedId(byte[] groupId, boolean mms) {
|
||||
return (mms ? ENCODED_MMS_GROUP_PREFIX : ENCODED_SIGNAL_GROUP_PREFIX) + Hex.toStringCondensed(groupId);
|
||||
}
|
||||
|
||||
public static byte[] getDecodedId(String groupId) throws IOException {
|
||||
if (!isEncodedGroup(groupId)) {
|
||||
throw new IOException("Invalid encoding");
|
||||
}
|
||||
|
||||
return Hex.fromStringCondensed(groupId.split("!", 2)[1]);
|
||||
}
|
||||
|
||||
public static boolean isEncodedGroup(@NonNull String groupId) {
|
||||
return groupId.startsWith(ENCODED_SIGNAL_GROUP_PREFIX) || groupId.startsWith(ENCODED_MMS_GROUP_PREFIX);
|
||||
}
|
||||
|
||||
public static boolean isMmsGroup(@NonNull String groupId) {
|
||||
return groupId.startsWith(ENCODED_MMS_GROUP_PREFIX);
|
||||
}
|
||||
|
||||
@WorkerThread
|
||||
public static Optional<OutgoingGroupMediaMessage> createGroupLeaveMessage(@NonNull Context context, @NonNull Recipient groupRecipient) {
|
||||
String encodedGroupId = groupRecipient.requireGroupId();
|
||||
GroupDatabase groupDatabase = DatabaseFactory.getGroupDatabase(context);
|
||||
|
||||
if (!groupDatabase.isActive(encodedGroupId)) {
|
||||
Log.w(TAG, "Group has already been left.");
|
||||
return Optional.absent();
|
||||
}
|
||||
|
||||
ByteString decodedGroupId;
|
||||
try {
|
||||
decodedGroupId = ByteString.copyFrom(getDecodedId(encodedGroupId));
|
||||
} catch (IOException e) {
|
||||
Log.w(TAG, "Failed to decode group ID.", e);
|
||||
return Optional.absent();
|
||||
}
|
||||
|
||||
GroupContext groupContext = GroupContext.newBuilder()
|
||||
.setId(decodedGroupId)
|
||||
.setType(GroupContext.Type.QUIT)
|
||||
.build();
|
||||
|
||||
return Optional.of(new OutgoingGroupMediaMessage(groupRecipient, groupContext, null, System.currentTimeMillis(), 0, false, null, Collections.emptyList(), Collections.emptyList()));
|
||||
}
|
||||
|
||||
|
||||
public static @NonNull GroupDescription getDescription(@NonNull Context context, @Nullable String encodedGroup) {
|
||||
if (encodedGroup == null) {
|
||||
return new GroupDescription(context, null);
|
||||
}
|
||||
|
||||
try {
|
||||
GroupContext groupContext = GroupContext.parseFrom(Base64.decode(encodedGroup));
|
||||
return new GroupDescription(context, groupContext);
|
||||
} catch (IOException e) {
|
||||
Log.w(TAG, e);
|
||||
return new GroupDescription(context, null);
|
||||
}
|
||||
}
|
||||
|
||||
public static class GroupDescription {
|
||||
|
||||
@NonNull private final Context context;
|
||||
@Nullable private final GroupContext groupContext;
|
||||
@Nullable private final List<Recipient> members;
|
||||
|
||||
public GroupDescription(@NonNull Context context, @Nullable GroupContext groupContext) {
|
||||
this.context = context.getApplicationContext();
|
||||
this.groupContext = groupContext;
|
||||
|
||||
if (groupContext == null || groupContext.getMembersList().isEmpty()) {
|
||||
this.members = null;
|
||||
} else {
|
||||
this.members = new LinkedList<>();
|
||||
|
||||
for (GroupContext.Member member : groupContext.getMembersList()) {
|
||||
Recipient recipient = Recipient.externalPush(context, new SignalServiceAddress(UuidUtil.parseOrNull(member.getUuid()), member.getE164()));
|
||||
if (!recipient.isLocalNumber()) {
|
||||
this.members.add(recipient);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public String toString(Recipient sender) {
|
||||
StringBuilder description = new StringBuilder();
|
||||
description.append(context.getString(R.string.MessageRecord_s_updated_group, sender.toShortString(context)));
|
||||
|
||||
if (groupContext == null) {
|
||||
return description.toString();
|
||||
}
|
||||
|
||||
String title = groupContext.getName();
|
||||
|
||||
if (members != null) {
|
||||
description.append("\n");
|
||||
description.append(context.getResources().getQuantityString(R.plurals.GroupUtil_joined_the_group,
|
||||
members.size(), toString(members)));
|
||||
}
|
||||
|
||||
if (title != null && !title.trim().isEmpty()) {
|
||||
if (members != null) description.append(" ");
|
||||
else description.append("\n");
|
||||
description.append(context.getString(R.string.GroupUtil_group_name_is_now, title));
|
||||
}
|
||||
|
||||
return description.toString();
|
||||
}
|
||||
|
||||
public void addObserver(RecipientForeverObserver listener) {
|
||||
if (this.members != null) {
|
||||
for (Recipient member : this.members) {
|
||||
member.live().observeForever(listener);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public void removeObserver(RecipientForeverObserver listener) {
|
||||
if (this.members != null) {
|
||||
for (Recipient member : this.members) {
|
||||
member.live().removeForeverObserver(listener);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private String toString(List<Recipient> recipients) {
|
||||
String result = "";
|
||||
|
||||
for (int i=0;i<recipients.size();i++) {
|
||||
result += recipients.get(i).toShortString(context);
|
||||
|
||||
if (i != recipients.size() -1 )
|
||||
result += ", ";
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
}
|
||||
}
|
||||
138
app/src/main/java/org/thoughtcrime/securesms/util/Hex.java
Normal file
138
app/src/main/java/org/thoughtcrime/securesms/util/Hex.java
Normal file
@@ -0,0 +1,138 @@
|
||||
/**
|
||||
* Copyright (C) 2011 Whisper Systems
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
*/
|
||||
package org.thoughtcrime.securesms.util;
|
||||
|
||||
import java.io.IOException;
|
||||
|
||||
/**
|
||||
* Utility for generating hex dumps.
|
||||
*/
|
||||
public class Hex {
|
||||
|
||||
private final static int HEX_DIGITS_START = 10;
|
||||
private final static int ASCII_TEXT_START = HEX_DIGITS_START + (16*2 + (16/2));
|
||||
|
||||
final static String EOL = System.getProperty("line.separator");
|
||||
|
||||
private final static char[] HEX_DIGITS = {
|
||||
'0', '1', '2', '3', '4', '5', '6', '7', '8', '9', 'a', 'b', 'c', 'd', 'e', 'f'
|
||||
};
|
||||
|
||||
public static String toString(byte[] bytes) {
|
||||
return toString(bytes, 0, bytes.length);
|
||||
}
|
||||
|
||||
public static String toString(byte[] bytes, int offset, int length) {
|
||||
StringBuffer buf = new StringBuffer();
|
||||
for (int i = 0; i < length; i++) {
|
||||
appendHexChar(buf, bytes[offset + i]);
|
||||
buf.append(' ');
|
||||
}
|
||||
return buf.toString();
|
||||
}
|
||||
|
||||
public static String toStringCondensed(byte[] bytes) {
|
||||
StringBuffer buf = new StringBuffer();
|
||||
for (int i=0;i<bytes.length;i++) {
|
||||
appendHexChar(buf, bytes[i]);
|
||||
}
|
||||
return buf.toString();
|
||||
}
|
||||
|
||||
public static byte[] fromStringCondensed(String encoded) throws IOException {
|
||||
final char[] data = encoded.toCharArray();
|
||||
final int len = data.length;
|
||||
|
||||
if ((len & 0x01) != 0) {
|
||||
throw new IOException("Odd number of characters.");
|
||||
}
|
||||
|
||||
final byte[] out = new byte[len >> 1];
|
||||
|
||||
// two characters form the hex value.
|
||||
for (int i = 0, j = 0; j < len; i++) {
|
||||
int f = Character.digit(data[j], 16) << 4;
|
||||
j++;
|
||||
f = f | Character.digit(data[j], 16);
|
||||
j++;
|
||||
out[i] = (byte) (f & 0xFF);
|
||||
}
|
||||
|
||||
return out;
|
||||
}
|
||||
|
||||
public static String dump(byte[] bytes) {
|
||||
return dump(bytes, 0, bytes.length);
|
||||
}
|
||||
|
||||
public static String dump(byte[] bytes, int offset, int length) {
|
||||
StringBuffer buf = new StringBuffer();
|
||||
int lines = ((length - 1) / 16) + 1;
|
||||
int lineOffset;
|
||||
int lineLength;
|
||||
|
||||
for (int i = 0; i < lines; i++) {
|
||||
lineOffset = (i * 16) + offset;
|
||||
lineLength = Math.min(16, (length - (i * 16)));
|
||||
appendDumpLine(buf, i, bytes, lineOffset, lineLength);
|
||||
buf.append(EOL);
|
||||
}
|
||||
|
||||
return buf.toString();
|
||||
}
|
||||
|
||||
private static void appendDumpLine(StringBuffer buf, int line, byte[] bytes, int lineOffset, int lineLength) {
|
||||
buf.append(HEX_DIGITS[(line >> 28) & 0xf]);
|
||||
buf.append(HEX_DIGITS[(line >> 24) & 0xf]);
|
||||
buf.append(HEX_DIGITS[(line >> 20) & 0xf]);
|
||||
buf.append(HEX_DIGITS[(line >> 16) & 0xf]);
|
||||
buf.append(HEX_DIGITS[(line >> 12) & 0xf]);
|
||||
buf.append(HEX_DIGITS[(line >> 8) & 0xf]);
|
||||
buf.append(HEX_DIGITS[(line >> 4) & 0xf]);
|
||||
buf.append(HEX_DIGITS[(line ) & 0xf]);
|
||||
buf.append(": ");
|
||||
|
||||
for (int i = 0; i < 16; i++) {
|
||||
int idx = i + lineOffset;
|
||||
if (i < lineLength) {
|
||||
int b = bytes[idx];
|
||||
appendHexChar(buf, b);
|
||||
} else {
|
||||
buf.append(" ");
|
||||
}
|
||||
if ((i % 2) == 1) {
|
||||
buf.append(' ');
|
||||
}
|
||||
}
|
||||
|
||||
for (int i = 0; i < 16 && i < lineLength; i++) {
|
||||
int idx = i + lineOffset;
|
||||
int b = bytes[idx];
|
||||
if (b >= 0x20 && b <= 0x7e) {
|
||||
buf.append((char)b);
|
||||
} else {
|
||||
buf.append('.');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static void appendHexChar(StringBuffer buf, int b) {
|
||||
buf.append(HEX_DIGITS[(b >> 4) & 0xf]);
|
||||
buf.append(HEX_DIGITS[b & 0xf]);
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
package org.thoughtcrime.securesms.util;
|
||||
|
||||
import java.io.IOException;
|
||||
|
||||
/**
|
||||
* A function which takes 1 input and returns 1 output, and is capable of throwing an IO Exception.
|
||||
*/
|
||||
public interface IOFunction<I, O> {
|
||||
O apply(I input) throws IOException;
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
package org.thoughtcrime.securesms.util;
|
||||
|
||||
import android.content.Context;
|
||||
|
||||
import androidx.annotation.NonNull;
|
||||
|
||||
import org.thoughtcrime.securesms.push.IasTrustStore;
|
||||
import org.whispersystems.signalservice.api.push.TrustStore;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.security.KeyStore;
|
||||
import java.security.KeyStoreException;
|
||||
import java.security.NoSuchAlgorithmException;
|
||||
import java.security.cert.CertificateException;
|
||||
|
||||
public final class IasKeyStore {
|
||||
|
||||
private IasKeyStore() {
|
||||
}
|
||||
|
||||
public static KeyStore getIasKeyStore(@NonNull Context context) {
|
||||
try {
|
||||
TrustStore contactTrustStore = new IasTrustStore(context);
|
||||
|
||||
KeyStore keyStore = KeyStore.getInstance("BKS");
|
||||
keyStore.load(contactTrustStore.getKeyStoreInputStream(), contactTrustStore.getKeyStorePassword().toCharArray());
|
||||
|
||||
return keyStore;
|
||||
} catch (KeyStoreException | CertificateException | IOException | NoSuchAlgorithmException e) {
|
||||
throw new AssertionError(e);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,248 @@
|
||||
package org.thoughtcrime.securesms.util;
|
||||
|
||||
import android.content.Context;
|
||||
import android.os.AsyncTask;
|
||||
|
||||
import androidx.annotation.NonNull;
|
||||
import androidx.annotation.Nullable;
|
||||
import androidx.annotation.StringRes;
|
||||
|
||||
import org.thoughtcrime.securesms.R;
|
||||
import org.thoughtcrime.securesms.crypto.storage.TextSecureIdentityKeyStore;
|
||||
import org.thoughtcrime.securesms.crypto.storage.TextSecureSessionStore;
|
||||
import org.thoughtcrime.securesms.database.DatabaseFactory;
|
||||
import org.thoughtcrime.securesms.database.GroupDatabase;
|
||||
import org.thoughtcrime.securesms.database.IdentityDatabase;
|
||||
import org.thoughtcrime.securesms.database.IdentityDatabase.IdentityRecord;
|
||||
import org.thoughtcrime.securesms.database.MessagingDatabase.InsertResult;
|
||||
import org.thoughtcrime.securesms.database.SmsDatabase;
|
||||
import org.thoughtcrime.securesms.logging.Log;
|
||||
import org.thoughtcrime.securesms.notifications.MessageNotifier;
|
||||
import org.thoughtcrime.securesms.recipients.Recipient;
|
||||
import org.thoughtcrime.securesms.recipients.RecipientId;
|
||||
import org.thoughtcrime.securesms.sms.IncomingIdentityDefaultMessage;
|
||||
import org.thoughtcrime.securesms.sms.IncomingIdentityUpdateMessage;
|
||||
import org.thoughtcrime.securesms.sms.IncomingIdentityVerifiedMessage;
|
||||
import org.thoughtcrime.securesms.sms.IncomingTextMessage;
|
||||
import org.thoughtcrime.securesms.sms.OutgoingIdentityDefaultMessage;
|
||||
import org.thoughtcrime.securesms.sms.OutgoingIdentityVerifiedMessage;
|
||||
import org.thoughtcrime.securesms.sms.OutgoingTextMessage;
|
||||
import org.thoughtcrime.securesms.util.concurrent.ListenableFuture;
|
||||
import org.thoughtcrime.securesms.util.concurrent.SettableFuture;
|
||||
import org.whispersystems.libsignal.IdentityKey;
|
||||
import org.whispersystems.libsignal.SignalProtocolAddress;
|
||||
import org.whispersystems.libsignal.state.IdentityKeyStore;
|
||||
import org.whispersystems.libsignal.state.SessionRecord;
|
||||
import org.whispersystems.libsignal.state.SessionStore;
|
||||
import org.whispersystems.libsignal.util.guava.Optional;
|
||||
import org.whispersystems.signalservice.api.messages.multidevice.VerifiedMessage;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
import static org.whispersystems.libsignal.SessionCipher.SESSION_LOCK;
|
||||
|
||||
public class IdentityUtil {
|
||||
|
||||
private static final String TAG = IdentityUtil.class.getSimpleName();
|
||||
|
||||
public static ListenableFuture<Optional<IdentityRecord>> getRemoteIdentityKey(final Context context, final Recipient recipient) {
|
||||
final SettableFuture<Optional<IdentityRecord>> future = new SettableFuture<>();
|
||||
|
||||
new AsyncTask<Recipient, Void, Optional<IdentityRecord>>() {
|
||||
@Override
|
||||
protected Optional<IdentityRecord> doInBackground(Recipient... recipient) {
|
||||
return DatabaseFactory.getIdentityDatabase(context)
|
||||
.getIdentity(recipient[0].getId());
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void onPostExecute(Optional<IdentityRecord> result) {
|
||||
future.set(result);
|
||||
}
|
||||
}.executeOnExecutor(AsyncTask.THREAD_POOL_EXECUTOR, recipient);
|
||||
|
||||
return future;
|
||||
}
|
||||
|
||||
public static void markIdentityVerified(Context context, Recipient recipient, boolean verified, boolean remote)
|
||||
{
|
||||
long time = System.currentTimeMillis();
|
||||
SmsDatabase smsDatabase = DatabaseFactory.getSmsDatabase(context);
|
||||
GroupDatabase groupDatabase = DatabaseFactory.getGroupDatabase(context);
|
||||
GroupDatabase.Reader reader = groupDatabase.getGroups();
|
||||
|
||||
GroupDatabase.GroupRecord groupRecord;
|
||||
|
||||
while ((groupRecord = reader.getNext()) != null) {
|
||||
if (groupRecord.getMembers().contains(recipient.getId()) && groupRecord.isActive() && !groupRecord.isMms()) {
|
||||
|
||||
if (remote) {
|
||||
IncomingTextMessage incoming = new IncomingTextMessage(recipient.getId(), 1, time, null, Optional.of(groupRecord.getEncodedId()), 0, false);
|
||||
|
||||
if (verified) incoming = new IncomingIdentityVerifiedMessage(incoming);
|
||||
else incoming = new IncomingIdentityDefaultMessage(incoming);
|
||||
|
||||
smsDatabase.insertMessageInbox(incoming);
|
||||
} else {
|
||||
RecipientId recipientId = DatabaseFactory.getRecipientDatabase(context).getOrInsertFromGroupId(groupRecord.getEncodedId());
|
||||
Recipient groupRecipient = Recipient.resolved(recipientId);
|
||||
long threadId = DatabaseFactory.getThreadDatabase(context).getThreadIdFor(groupRecipient);
|
||||
OutgoingTextMessage outgoing ;
|
||||
|
||||
if (verified) outgoing = new OutgoingIdentityVerifiedMessage(recipient);
|
||||
else outgoing = new OutgoingIdentityDefaultMessage(recipient);
|
||||
|
||||
DatabaseFactory.getSmsDatabase(context).insertMessageOutbox(threadId, outgoing, false, time, null);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (remote) {
|
||||
IncomingTextMessage incoming = new IncomingTextMessage(recipient.getId(), 1, time, null, Optional.absent(), 0, false);
|
||||
|
||||
if (verified) incoming = new IncomingIdentityVerifiedMessage(incoming);
|
||||
else incoming = new IncomingIdentityDefaultMessage(incoming);
|
||||
|
||||
smsDatabase.insertMessageInbox(incoming);
|
||||
} else {
|
||||
OutgoingTextMessage outgoing;
|
||||
|
||||
if (verified) outgoing = new OutgoingIdentityVerifiedMessage(recipient);
|
||||
else outgoing = new OutgoingIdentityDefaultMessage(recipient);
|
||||
|
||||
long threadId = DatabaseFactory.getThreadDatabase(context).getThreadIdFor(recipient);
|
||||
|
||||
Log.i(TAG, "Inserting verified outbox...");
|
||||
DatabaseFactory.getSmsDatabase(context).insertMessageOutbox(threadId, outgoing, false, time, null);
|
||||
}
|
||||
}
|
||||
|
||||
public static void markIdentityUpdate(Context context, Recipient recipient) {
|
||||
long time = System.currentTimeMillis();
|
||||
SmsDatabase smsDatabase = DatabaseFactory.getSmsDatabase(context);
|
||||
GroupDatabase groupDatabase = DatabaseFactory.getGroupDatabase(context);
|
||||
GroupDatabase.Reader reader = groupDatabase.getGroups();
|
||||
|
||||
GroupDatabase.GroupRecord groupRecord;
|
||||
|
||||
while ((groupRecord = reader.getNext()) != null) {
|
||||
if (groupRecord.getMembers().contains(recipient.getId()) && groupRecord.isActive()) {
|
||||
IncomingTextMessage incoming = new IncomingTextMessage(recipient.getId(), 1, time, null, Optional.of(groupRecord.getEncodedId()), 0, false);
|
||||
IncomingIdentityUpdateMessage groupUpdate = new IncomingIdentityUpdateMessage(incoming);
|
||||
|
||||
smsDatabase.insertMessageInbox(groupUpdate);
|
||||
}
|
||||
}
|
||||
|
||||
IncomingTextMessage incoming = new IncomingTextMessage(recipient.getId(), 1, time, null, Optional.absent(), 0, false);
|
||||
IncomingIdentityUpdateMessage individualUpdate = new IncomingIdentityUpdateMessage(incoming);
|
||||
Optional<InsertResult> insertResult = smsDatabase.insertMessageInbox(individualUpdate);
|
||||
|
||||
if (insertResult.isPresent()) {
|
||||
MessageNotifier.updateNotification(context, insertResult.get().getThreadId());
|
||||
}
|
||||
}
|
||||
|
||||
public static void saveIdentity(Context context, String user, IdentityKey identityKey) {
|
||||
synchronized (SESSION_LOCK) {
|
||||
IdentityKeyStore identityKeyStore = new TextSecureIdentityKeyStore(context);
|
||||
SessionStore sessionStore = new TextSecureSessionStore(context);
|
||||
SignalProtocolAddress address = new SignalProtocolAddress(user, 1);
|
||||
|
||||
if (identityKeyStore.saveIdentity(address, identityKey)) {
|
||||
if (sessionStore.containsSession(address)) {
|
||||
SessionRecord sessionRecord = sessionStore.loadSession(address);
|
||||
sessionRecord.archiveCurrentState();
|
||||
|
||||
sessionStore.storeSession(address, sessionRecord);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public static void processVerifiedMessage(Context context, VerifiedMessage verifiedMessage) {
|
||||
synchronized (SESSION_LOCK) {
|
||||
IdentityDatabase identityDatabase = DatabaseFactory.getIdentityDatabase(context);
|
||||
Recipient recipient = Recipient.externalPush(context, verifiedMessage.getDestination());
|
||||
Optional<IdentityRecord> identityRecord = identityDatabase.getIdentity(recipient.getId());
|
||||
|
||||
if (!identityRecord.isPresent() && verifiedMessage.getVerified() == VerifiedMessage.VerifiedState.DEFAULT) {
|
||||
Log.w(TAG, "No existing record for default status");
|
||||
return;
|
||||
}
|
||||
|
||||
if (verifiedMessage.getVerified() == VerifiedMessage.VerifiedState.DEFAULT &&
|
||||
identityRecord.isPresent() &&
|
||||
identityRecord.get().getIdentityKey().equals(verifiedMessage.getIdentityKey()) &&
|
||||
identityRecord.get().getVerifiedStatus() != IdentityDatabase.VerifiedStatus.DEFAULT)
|
||||
{
|
||||
identityDatabase.setVerified(recipient.getId(), identityRecord.get().getIdentityKey(), IdentityDatabase.VerifiedStatus.DEFAULT);
|
||||
markIdentityVerified(context, recipient, false, true);
|
||||
}
|
||||
|
||||
if (verifiedMessage.getVerified() == VerifiedMessage.VerifiedState.VERIFIED &&
|
||||
(!identityRecord.isPresent() ||
|
||||
(identityRecord.isPresent() && !identityRecord.get().getIdentityKey().equals(verifiedMessage.getIdentityKey())) ||
|
||||
(identityRecord.isPresent() && identityRecord.get().getVerifiedStatus() != IdentityDatabase.VerifiedStatus.VERIFIED)))
|
||||
{
|
||||
saveIdentity(context, verifiedMessage.getDestination().getIdentifier(), verifiedMessage.getIdentityKey());
|
||||
identityDatabase.setVerified(recipient.getId(), verifiedMessage.getIdentityKey(), IdentityDatabase.VerifiedStatus.VERIFIED);
|
||||
markIdentityVerified(context, recipient, true, true);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
public static @Nullable String getUnverifiedBannerDescription(@NonNull Context context,
|
||||
@NonNull List<Recipient> unverified)
|
||||
{
|
||||
return getPluralizedIdentityDescription(context, unverified,
|
||||
R.string.IdentityUtil_unverified_banner_one,
|
||||
R.string.IdentityUtil_unverified_banner_two,
|
||||
R.string.IdentityUtil_unverified_banner_many);
|
||||
}
|
||||
|
||||
public static @Nullable String getUnverifiedSendDialogDescription(@NonNull Context context,
|
||||
@NonNull List<Recipient> unverified)
|
||||
{
|
||||
return getPluralizedIdentityDescription(context, unverified,
|
||||
R.string.IdentityUtil_unverified_dialog_one,
|
||||
R.string.IdentityUtil_unverified_dialog_two,
|
||||
R.string.IdentityUtil_unverified_dialog_many);
|
||||
}
|
||||
|
||||
public static @Nullable String getUntrustedSendDialogDescription(@NonNull Context context,
|
||||
@NonNull List<Recipient> untrusted)
|
||||
{
|
||||
return getPluralizedIdentityDescription(context, untrusted,
|
||||
R.string.IdentityUtil_untrusted_dialog_one,
|
||||
R.string.IdentityUtil_untrusted_dialog_two,
|
||||
R.string.IdentityUtil_untrusted_dialog_many);
|
||||
}
|
||||
|
||||
private static @Nullable String getPluralizedIdentityDescription(@NonNull Context context,
|
||||
@NonNull List<Recipient> recipients,
|
||||
@StringRes int resourceOne,
|
||||
@StringRes int resourceTwo,
|
||||
@StringRes int resourceMany)
|
||||
{
|
||||
if (recipients.isEmpty()) return null;
|
||||
|
||||
if (recipients.size() == 1) {
|
||||
String name = recipients.get(0).toShortString(context);
|
||||
return context.getString(resourceOne, name);
|
||||
} else {
|
||||
String firstName = recipients.get(0).toShortString(context);
|
||||
String secondName = recipients.get(1).toShortString(context);
|
||||
|
||||
if (recipients.size() == 2) {
|
||||
return context.getString(resourceTwo, firstName, secondName);
|
||||
} else {
|
||||
int othersCount = recipients.size() - 2;
|
||||
String nMore = context.getResources().getQuantityString(R.plurals.identity_others, othersCount, othersCount);
|
||||
|
||||
return context.getString(resourceMany, firstName, secondName, nMore);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
package org.thoughtcrime.securesms.util;
|
||||
|
||||
|
||||
import android.content.Context;
|
||||
import android.content.Intent;
|
||||
import android.content.pm.ResolveInfo;
|
||||
import androidx.annotation.NonNull;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
public class IntentUtils {
|
||||
|
||||
public static boolean isResolvable(@NonNull Context context, @NonNull Intent intent) {
|
||||
List<ResolveInfo> resolveInfoList = context.getPackageManager().queryIntentActivities(intent, 0);
|
||||
return resolveInfoList != null && resolveInfoList.size() > 1;
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
package org.thoughtcrime.securesms.util;
|
||||
|
||||
import com.fasterxml.jackson.databind.DeserializationFeature;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import com.fasterxml.jackson.databind.SerializationFeature;
|
||||
|
||||
import org.json.JSONException;
|
||||
import org.json.JSONObject;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.io.InputStream;
|
||||
import java.io.InputStreamReader;
|
||||
import java.io.Reader;
|
||||
|
||||
public class JsonUtils {
|
||||
|
||||
private static final ObjectMapper objectMapper = new ObjectMapper();
|
||||
|
||||
static {
|
||||
objectMapper.configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false);
|
||||
objectMapper.enable(SerializationFeature.WRITE_ENUMS_USING_TO_STRING);
|
||||
objectMapper.enable(DeserializationFeature.READ_ENUMS_USING_TO_STRING);
|
||||
}
|
||||
|
||||
public static <T> T fromJson(byte[] serialized, Class<T> clazz) throws IOException {
|
||||
return fromJson(new String(serialized), clazz);
|
||||
}
|
||||
|
||||
public static <T> T fromJson(String serialized, Class<T> clazz) throws IOException {
|
||||
return objectMapper.readValue(serialized, clazz);
|
||||
}
|
||||
|
||||
public static <T> T fromJson(InputStream serialized, Class<T> clazz) throws IOException {
|
||||
return objectMapper.readValue(serialized, clazz);
|
||||
}
|
||||
|
||||
public static <T> T fromJson(Reader serialized, Class<T> clazz) throws IOException {
|
||||
return objectMapper.readValue(serialized, clazz);
|
||||
}
|
||||
|
||||
public static String toJson(Object object) throws IOException {
|
||||
return objectMapper.writeValueAsString(object);
|
||||
}
|
||||
|
||||
public static ObjectMapper getMapper() {
|
||||
return objectMapper;
|
||||
}
|
||||
|
||||
public static class SaneJSONObject {
|
||||
|
||||
private final JSONObject delegate;
|
||||
|
||||
public SaneJSONObject(JSONObject delegate) {
|
||||
this.delegate = delegate;
|
||||
}
|
||||
|
||||
public String getString(String name) throws JSONException {
|
||||
if (delegate.isNull(name)) return null;
|
||||
else return delegate.getString(name);
|
||||
}
|
||||
|
||||
public long getLong(String name) throws JSONException {
|
||||
return delegate.getLong(name);
|
||||
}
|
||||
|
||||
public boolean isNull(String name) {
|
||||
return delegate.isNull(name);
|
||||
}
|
||||
|
||||
public int getInt(String name) throws JSONException {
|
||||
return delegate.getInt(name);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
package org.thoughtcrime.securesms.util;
|
||||
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.Map;
|
||||
|
||||
public class LRUCache<K,V> extends LinkedHashMap<K,V> {
|
||||
|
||||
private final int maxSize;
|
||||
|
||||
public LRUCache(int maxSize) {
|
||||
this.maxSize = maxSize;
|
||||
}
|
||||
|
||||
@Override
|
||||
protected boolean removeEldestEntry (Map.Entry<K,V> eldest) {
|
||||
return size() > maxSize;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,120 @@
|
||||
package org.thoughtcrime.securesms.util;
|
||||
|
||||
|
||||
/*
|
||||
* Licensed to the Apache Software Foundation (ASF) under one or more
|
||||
* contributor license agreements. See the NOTICE file distributed with
|
||||
* this work for additional information regarding copyright ownership.
|
||||
* The ASF licenses this file to You under the Apache License, Version 2.0
|
||||
* (the "License"); you may not use this file except in compliance with
|
||||
* the License. You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import java.io.FilterInputStream;
|
||||
import java.io.IOException;
|
||||
import java.io.InputStream;
|
||||
|
||||
|
||||
/**
|
||||
* An input stream, which limits its data size. This stream is
|
||||
* used, if the content length is unknown.
|
||||
*/
|
||||
public class LimitedInputStream extends FilterInputStream {
|
||||
|
||||
/**
|
||||
* The maximum size of an item, in bytes.
|
||||
*/
|
||||
private long sizeMax;
|
||||
|
||||
/**
|
||||
* The current number of bytes.
|
||||
*/
|
||||
private long count;
|
||||
|
||||
/**
|
||||
* Whether this stream is already closed.
|
||||
*/
|
||||
private boolean closed;
|
||||
|
||||
/**
|
||||
* Creates a new instance.
|
||||
* @param pIn The input stream, which shall be limited.
|
||||
* @param pSizeMax The limit; no more than this number of bytes
|
||||
* shall be returned by the source stream.
|
||||
*/
|
||||
public LimitedInputStream(InputStream pIn, long pSizeMax) {
|
||||
super(pIn);
|
||||
sizeMax = pSizeMax;
|
||||
}
|
||||
|
||||
/**
|
||||
* Reads the next byte of data from this input stream. The value
|
||||
* byte is returned as an <code>int</code> in the range
|
||||
* <code>0</code> to <code>255</code>. If no byte is available
|
||||
* because the end of the stream has been reached, the value
|
||||
* <code>-1</code> is returned. This method blocks until input data
|
||||
* is available, the end of the stream is detected, or an exception
|
||||
* is thrown.
|
||||
*
|
||||
* This method
|
||||
* simply performs <code>in.read()</code> and returns the result.
|
||||
*
|
||||
* @return the next byte of data, or <code>-1</code> if the end of the
|
||||
* stream is reached.
|
||||
* @exception IOException if an I/O error occurs.
|
||||
* @see java.io.FilterInputStream#in
|
||||
*/
|
||||
public int read() throws IOException {
|
||||
if (count >= sizeMax) return -1;
|
||||
|
||||
int res = super.read();
|
||||
if (res != -1) {
|
||||
count++;
|
||||
}
|
||||
return res;
|
||||
}
|
||||
|
||||
/**
|
||||
* Reads up to <code>len</code> bytes of data from this input stream
|
||||
* into an array of bytes. If <code>len</code> is not zero, the method
|
||||
* blocks until some input is available; otherwise, no
|
||||
* bytes are read and <code>0</code> is returned.
|
||||
*
|
||||
* This method simply performs <code>in.read(b, off, len)</code>
|
||||
* and returns the result.
|
||||
*
|
||||
* @param b the buffer into which the data is read.
|
||||
* @param off The start offset in the destination array
|
||||
* <code>b</code>.
|
||||
* @param len the maximum number of bytes read.
|
||||
* @return the total number of bytes read into the buffer, or
|
||||
* <code>-1</code> if there is no more data because the end of
|
||||
* the stream has been reached.
|
||||
* @exception NullPointerException If <code>b</code> is <code>null</code>.
|
||||
* @exception IndexOutOfBoundsException If <code>off</code> is negative,
|
||||
* <code>len</code> is negative, or <code>len</code> is greater than
|
||||
* <code>b.length - off</code>
|
||||
* @exception IOException if an I/O error occurs.
|
||||
* @see java.io.FilterInputStream#in
|
||||
*/
|
||||
public int read(byte[] b, int off, int len) throws IOException {
|
||||
if (count >= sizeMax) return -1;
|
||||
|
||||
long correctLength = Math.min(len, sizeMax - count);
|
||||
|
||||
int res = super.read(b, off, Util.toIntExact(correctLength));
|
||||
if (res > 0) {
|
||||
count += res;
|
||||
}
|
||||
return res;
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
package org.thoughtcrime.securesms.util;
|
||||
|
||||
|
||||
import java.util.concurrent.LinkedBlockingDeque;
|
||||
|
||||
public class LinkedBlockingLifoQueue<E> extends LinkedBlockingDeque<E> {
|
||||
@Override
|
||||
public void put(E runnable) throws InterruptedException {
|
||||
super.putFirst(runnable);
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean add(E runnable) {
|
||||
super.addFirst(runnable);
|
||||
return true;
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean offer(E runnable) {
|
||||
super.addFirst(runnable);
|
||||
return true;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,126 @@
|
||||
/**
|
||||
* Copyright (C) 2014 Open Whisper Systems
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
*/
|
||||
package org.thoughtcrime.securesms.util;
|
||||
|
||||
import androidx.annotation.Nullable;
|
||||
|
||||
import java.util.LinkedList;
|
||||
import java.util.List;
|
||||
import java.util.concurrent.Callable;
|
||||
import java.util.concurrent.ExecutionException;
|
||||
import java.util.concurrent.Executor;
|
||||
import java.util.concurrent.FutureTask;
|
||||
|
||||
public class ListenableFutureTask<V> extends FutureTask<V> {
|
||||
|
||||
private final List<FutureTaskListener<V>> listeners = new LinkedList<>();
|
||||
|
||||
@Nullable
|
||||
private final Object identifier;
|
||||
|
||||
@Nullable
|
||||
private final Executor callbackExecutor;
|
||||
|
||||
public ListenableFutureTask(Callable<V> callable) {
|
||||
this(callable, null);
|
||||
}
|
||||
|
||||
public ListenableFutureTask(Callable<V> callable, @Nullable Object identifier) {
|
||||
this(callable, identifier, null);
|
||||
}
|
||||
|
||||
public ListenableFutureTask(Callable<V> callable, @Nullable Object identifier, @Nullable Executor callbackExecutor) {
|
||||
super(callable);
|
||||
this.identifier = identifier;
|
||||
this.callbackExecutor = callbackExecutor;
|
||||
}
|
||||
|
||||
|
||||
public ListenableFutureTask(final V result) {
|
||||
this(result, null);
|
||||
}
|
||||
|
||||
public ListenableFutureTask(final V result, @Nullable Object identifier) {
|
||||
super(new Callable<V>() {
|
||||
@Override
|
||||
public V call() throws Exception {
|
||||
return result;
|
||||
}
|
||||
});
|
||||
this.identifier = identifier;
|
||||
this.callbackExecutor = null;
|
||||
this.run();
|
||||
}
|
||||
|
||||
public synchronized void addListener(FutureTaskListener<V> listener) {
|
||||
if (this.isDone()) {
|
||||
callback(listener);
|
||||
} else {
|
||||
this.listeners.add(listener);
|
||||
}
|
||||
}
|
||||
|
||||
public synchronized void removeListener(FutureTaskListener<V> listener) {
|
||||
this.listeners.remove(listener);
|
||||
}
|
||||
|
||||
@Override
|
||||
protected synchronized void done() {
|
||||
callback();
|
||||
}
|
||||
|
||||
private void callback() {
|
||||
Runnable callbackRunnable = new Runnable() {
|
||||
@Override
|
||||
public void run() {
|
||||
for (FutureTaskListener<V> listener : listeners) {
|
||||
callback(listener);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
if (callbackExecutor == null) callbackRunnable.run();
|
||||
else callbackExecutor.execute(callbackRunnable);
|
||||
}
|
||||
|
||||
private void callback(FutureTaskListener<V> listener) {
|
||||
if (listener != null) {
|
||||
try {
|
||||
listener.onSuccess(get());
|
||||
} catch (InterruptedException e) {
|
||||
throw new AssertionError(e);
|
||||
} catch (ExecutionException e) {
|
||||
listener.onFailure(e);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean equals(Object other) {
|
||||
if (other != null && other instanceof ListenableFutureTask && this.identifier != null) {
|
||||
return identifier.equals(other);
|
||||
} else {
|
||||
return super.equals(other);
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public int hashCode() {
|
||||
if (identifier != null) return identifier.hashCode();
|
||||
else return super.hashCode();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
package org.thoughtcrime.securesms.util;
|
||||
|
||||
import android.annotation.TargetApi;
|
||||
import android.content.ClipData;
|
||||
import android.content.Context;
|
||||
import androidx.annotation.ColorInt;
|
||||
import androidx.annotation.NonNull;
|
||||
import android.text.TextPaint;
|
||||
import android.text.style.URLSpan;
|
||||
import android.view.View;
|
||||
import android.widget.Toast;
|
||||
|
||||
import org.thoughtcrime.securesms.R;
|
||||
|
||||
public class LongClickCopySpan extends URLSpan {
|
||||
private static final String PREFIX_MAILTO = "mailto:";
|
||||
private static final String PREFIX_TEL = "tel:";
|
||||
|
||||
private boolean isHighlighted;
|
||||
@ColorInt
|
||||
private int highlightColor;
|
||||
|
||||
public LongClickCopySpan(String url) {
|
||||
super(url);
|
||||
}
|
||||
|
||||
void onLongClick(View widget) {
|
||||
Context context = widget.getContext();
|
||||
String preparedUrl = prepareUrl(getURL());
|
||||
copyUrl(context, preparedUrl);
|
||||
Toast.makeText(context,
|
||||
context.getString(R.string.ConversationItem_copied_text, preparedUrl), Toast.LENGTH_SHORT).show();
|
||||
}
|
||||
|
||||
@Override
|
||||
public void updateDrawState(@NonNull TextPaint ds) {
|
||||
super.updateDrawState(ds);
|
||||
ds.bgColor = highlightColor;
|
||||
ds.setUnderlineText(!isHighlighted);
|
||||
}
|
||||
|
||||
void setHighlighted(boolean highlighted, @ColorInt int highlightColor) {
|
||||
this.isHighlighted = highlighted;
|
||||
this.highlightColor = highlightColor;
|
||||
}
|
||||
|
||||
private void copyUrl(Context context, String url) {
|
||||
int sdk = android.os.Build.VERSION.SDK_INT;
|
||||
if (sdk < android.os.Build.VERSION_CODES.HONEYCOMB) {
|
||||
@SuppressWarnings("deprecation") android.text.ClipboardManager clipboard =
|
||||
(android.text.ClipboardManager) context.getSystemService(Context.CLIPBOARD_SERVICE);
|
||||
clipboard.setText(url);
|
||||
} else {
|
||||
copyUriSdk11(context, url);
|
||||
}
|
||||
}
|
||||
|
||||
@TargetApi(android.os.Build.VERSION_CODES.HONEYCOMB)
|
||||
private void copyUriSdk11(Context context, String url) {
|
||||
android.content.ClipboardManager clipboard =
|
||||
(android.content.ClipboardManager) context.getSystemService(Context.CLIPBOARD_SERVICE);
|
||||
ClipData clip = ClipData.newPlainText(context.getString(R.string.app_name), url);
|
||||
clipboard.setPrimaryClip(clip);
|
||||
}
|
||||
|
||||
private String prepareUrl(String url) {
|
||||
if (url.startsWith(PREFIX_MAILTO)) {
|
||||
return url.substring(PREFIX_MAILTO.length());
|
||||
} else if (url.startsWith(PREFIX_TEL)) {
|
||||
return url.substring(PREFIX_TEL.length());
|
||||
}
|
||||
return url;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,104 @@
|
||||
package org.thoughtcrime.securesms.util;
|
||||
|
||||
import android.annotation.SuppressLint;
|
||||
import android.content.Context;
|
||||
import android.graphics.Color;
|
||||
import androidx.core.content.ContextCompat;
|
||||
import android.text.Layout;
|
||||
import android.text.Selection;
|
||||
import android.text.Spannable;
|
||||
import android.text.method.LinkMovementMethod;
|
||||
import android.view.GestureDetector;
|
||||
import android.view.MotionEvent;
|
||||
import android.view.View;
|
||||
import android.widget.TextView;
|
||||
|
||||
import org.thoughtcrime.securesms.R;
|
||||
|
||||
public class LongClickMovementMethod extends LinkMovementMethod {
|
||||
@SuppressLint("StaticFieldLeak")
|
||||
private static LongClickMovementMethod sInstance;
|
||||
|
||||
private final GestureDetector gestureDetector;
|
||||
private View widget;
|
||||
private LongClickCopySpan currentSpan;
|
||||
|
||||
private LongClickMovementMethod(final Context context) {
|
||||
gestureDetector = new GestureDetector(context, new GestureDetector.SimpleOnGestureListener() {
|
||||
@Override
|
||||
public void onLongPress(MotionEvent e) {
|
||||
if (currentSpan != null && widget != null) {
|
||||
currentSpan.onLongClick(widget);
|
||||
widget = null;
|
||||
currentSpan = null;
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean onSingleTapUp(MotionEvent e) {
|
||||
if (currentSpan != null && widget != null) {
|
||||
currentSpan.onClick(widget);
|
||||
widget = null;
|
||||
currentSpan = null;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean onTouchEvent(TextView widget, Spannable buffer, MotionEvent event) {
|
||||
int action = event.getAction();
|
||||
|
||||
if (action == MotionEvent.ACTION_UP ||
|
||||
action == MotionEvent.ACTION_DOWN) {
|
||||
int x = (int) event.getX();
|
||||
int y = (int) event.getY();
|
||||
|
||||
x -= widget.getTotalPaddingLeft();
|
||||
y -= widget.getTotalPaddingTop();
|
||||
|
||||
x += widget.getScrollX();
|
||||
y += widget.getScrollY();
|
||||
|
||||
Layout layout = widget.getLayout();
|
||||
int line = layout.getLineForVertical(y);
|
||||
int off = layout.getOffsetForHorizontal(line, x);
|
||||
|
||||
LongClickCopySpan longClickCopySpan[] = buffer.getSpans(off, off, LongClickCopySpan.class);
|
||||
if (longClickCopySpan.length != 0) {
|
||||
LongClickCopySpan aSingleSpan = longClickCopySpan[0];
|
||||
if (action == MotionEvent.ACTION_DOWN) {
|
||||
Selection.setSelection(buffer, buffer.getSpanStart(aSingleSpan),
|
||||
buffer.getSpanEnd(aSingleSpan));
|
||||
aSingleSpan.setHighlighted(true,
|
||||
ContextCompat.getColor(widget.getContext(), R.color.touch_highlight));
|
||||
} else {
|
||||
Selection.removeSelection(buffer);
|
||||
aSingleSpan.setHighlighted(false, Color.TRANSPARENT);
|
||||
}
|
||||
|
||||
this.currentSpan = aSingleSpan;
|
||||
this.widget = widget;
|
||||
return gestureDetector.onTouchEvent(event);
|
||||
}
|
||||
} else if (action == MotionEvent.ACTION_CANCEL) {
|
||||
// Remove Selections.
|
||||
LongClickCopySpan[] spans = buffer.getSpans(Selection.getSelectionStart(buffer),
|
||||
Selection.getSelectionEnd(buffer), LongClickCopySpan.class);
|
||||
for (LongClickCopySpan aSpan : spans) {
|
||||
aSpan.setHighlighted(false, Color.TRANSPARENT);
|
||||
}
|
||||
Selection.removeSelection(buffer);
|
||||
return gestureDetector.onTouchEvent(event);
|
||||
}
|
||||
return super.onTouchEvent(widget, buffer, event);
|
||||
}
|
||||
|
||||
public static LongClickMovementMethod getInstance(Context context) {
|
||||
if (sInstance == null) {
|
||||
sInstance = new LongClickMovementMethod(context.getApplicationContext());
|
||||
}
|
||||
return sInstance;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
package org.thoughtcrime.securesms.util;
|
||||
|
||||
import android.graphics.PointF;
|
||||
import androidx.annotation.NonNull;
|
||||
|
||||
public class MathUtils {
|
||||
|
||||
/**
|
||||
* For more info:
|
||||
* <a href="http://math.stackexchange.com/questions/190111/how-to-check-if-a-point-is-inside-a-rectangle">StackOverflow: How to check point is in rectangle</a>
|
||||
*
|
||||
* @param pt point to check
|
||||
* @param v1 vertex 1 of the triangle
|
||||
* @param v2 vertex 2 of the triangle
|
||||
* @param v3 vertex 3 of the triangle
|
||||
* @return true if point (x, y) is inside the triangle
|
||||
*/
|
||||
public static boolean pointInTriangle(@NonNull PointF pt, @NonNull PointF v1,
|
||||
@NonNull PointF v2, @NonNull PointF v3) {
|
||||
|
||||
boolean b1 = crossProduct(pt, v1, v2) < 0.0f;
|
||||
boolean b2 = crossProduct(pt, v2, v3) < 0.0f;
|
||||
boolean b3 = crossProduct(pt, v3, v1) < 0.0f;
|
||||
|
||||
return (b1 == b2) && (b2 == b3);
|
||||
}
|
||||
|
||||
/**
|
||||
* calculates cross product of vectors AB and AC
|
||||
*
|
||||
* @param a beginning of 2 vectors
|
||||
* @param b end of vector 1
|
||||
* @param c end of vector 2
|
||||
* @return cross product AB * AC
|
||||
*/
|
||||
private static float crossProduct(@NonNull PointF a, @NonNull PointF b, @NonNull PointF c) {
|
||||
return crossProduct(a.x, a.y, b.x, b.y, c.x, c.y);
|
||||
}
|
||||
|
||||
/**
|
||||
* calculates cross product of vectors AB and AC
|
||||
*
|
||||
* @param ax X coordinate of point A
|
||||
* @param ay Y coordinate of point A
|
||||
* @param bx X coordinate of point B
|
||||
* @param by Y coordinate of point B
|
||||
* @param cx X coordinate of point C
|
||||
* @param cy Y coordinate of point C
|
||||
* @return cross product AB * AC
|
||||
*/
|
||||
private static float crossProduct(float ax, float ay, float bx, float by, float cx, float cy) {
|
||||
return (ax - cx) * (by - cy) - (bx - cx) * (ay - cy);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
package org.thoughtcrime.securesms.util;
|
||||
|
||||
import android.media.MediaDataSource;
|
||||
import android.media.MediaMetadataRetriever;
|
||||
|
||||
import androidx.annotation.NonNull;
|
||||
import androidx.annotation.RequiresApi;
|
||||
|
||||
import java.io.IOException;
|
||||
|
||||
public final class MediaMetadataRetrieverUtil {
|
||||
|
||||
private MediaMetadataRetrieverUtil() {}
|
||||
|
||||
/**
|
||||
* {@link MediaMetadataRetriever#setDataSource(MediaDataSource)} tends to crash in native code on
|
||||
* specific devices, so this just a wrapper to convert that into an {@link IOException}.
|
||||
*/
|
||||
@RequiresApi(23)
|
||||
public static void setDataSource(@NonNull MediaMetadataRetriever retriever,
|
||||
@NonNull MediaDataSource dataSource)
|
||||
throws IOException
|
||||
{
|
||||
try {
|
||||
retriever.setDataSource(dataSource);
|
||||
} catch (Exception e) {
|
||||
throw new IOException(e);
|
||||
}
|
||||
}
|
||||
}
|
||||
378
app/src/main/java/org/thoughtcrime/securesms/util/MediaUtil.java
Normal file
378
app/src/main/java/org/thoughtcrime/securesms/util/MediaUtil.java
Normal file
@@ -0,0 +1,378 @@
|
||||
package org.thoughtcrime.securesms.util;
|
||||
|
||||
import android.content.ContentResolver;
|
||||
import android.content.Context;
|
||||
import android.graphics.Bitmap;
|
||||
import android.media.MediaDataSource;
|
||||
import android.media.MediaMetadataRetriever;
|
||||
import android.media.ThumbnailUtils;
|
||||
import android.net.Uri;
|
||||
import android.os.Build;
|
||||
import android.provider.MediaStore;
|
||||
import android.text.TextUtils;
|
||||
import android.util.Pair;
|
||||
import android.webkit.MimeTypeMap;
|
||||
|
||||
import androidx.annotation.NonNull;
|
||||
import androidx.annotation.Nullable;
|
||||
import androidx.annotation.WorkerThread;
|
||||
|
||||
import com.bumptech.glide.load.engine.DiskCacheStrategy;
|
||||
import com.bumptech.glide.load.resource.gif.GifDrawable;
|
||||
|
||||
import org.thoughtcrime.securesms.attachments.Attachment;
|
||||
import org.thoughtcrime.securesms.logging.Log;
|
||||
import org.thoughtcrime.securesms.mms.AudioSlide;
|
||||
import org.thoughtcrime.securesms.mms.DecryptableStreamUriLoader.DecryptableUri;
|
||||
import org.thoughtcrime.securesms.mms.DocumentSlide;
|
||||
import org.thoughtcrime.securesms.mms.GifSlide;
|
||||
import org.thoughtcrime.securesms.mms.GlideApp;
|
||||
import org.thoughtcrime.securesms.mms.ImageSlide;
|
||||
import org.thoughtcrime.securesms.mms.MmsSlide;
|
||||
import org.thoughtcrime.securesms.mms.PartAuthority;
|
||||
import org.thoughtcrime.securesms.mms.Slide;
|
||||
import org.thoughtcrime.securesms.mms.StickerSlide;
|
||||
import org.thoughtcrime.securesms.mms.TextSlide;
|
||||
import org.thoughtcrime.securesms.mms.VideoSlide;
|
||||
import org.thoughtcrime.securesms.providers.BlobProvider;
|
||||
|
||||
import java.io.FileNotFoundException;
|
||||
import java.io.IOException;
|
||||
import java.io.InputStream;
|
||||
import java.net.URLConnection;
|
||||
import java.util.concurrent.ExecutionException;
|
||||
|
||||
public class MediaUtil {
|
||||
|
||||
private static final String TAG = MediaUtil.class.getSimpleName();
|
||||
|
||||
public static final String IMAGE_PNG = "image/png";
|
||||
public static final String IMAGE_JPEG = "image/jpeg";
|
||||
public static final String IMAGE_WEBP = "image/webp";
|
||||
public static final String IMAGE_GIF = "image/gif";
|
||||
public static final String AUDIO_AAC = "audio/aac";
|
||||
public static final String AUDIO_UNSPECIFIED = "audio/*";
|
||||
public static final String VIDEO_MP4 = "video/mp4";
|
||||
public static final String VIDEO_UNSPECIFIED = "video/*";
|
||||
public static final String VCARD = "text/x-vcard";
|
||||
public static final String LONG_TEXT = "text/x-signal-plain";
|
||||
|
||||
public static SlideType getSlideTypeFromContentType(@NonNull String contentType) {
|
||||
if (isGif(contentType)) {
|
||||
return SlideType.GIF;
|
||||
} else if (isImageType(contentType)) {
|
||||
return SlideType.IMAGE;
|
||||
} else if (isVideoType(contentType)) {
|
||||
return SlideType.VIDEO;
|
||||
} else if (isAudioType(contentType)) {
|
||||
return SlideType.AUDIO;
|
||||
} else if (isMms(contentType)) {
|
||||
return SlideType.MMS;
|
||||
} else if (isLongTextType(contentType)) {
|
||||
return SlideType.LONG_TEXT;
|
||||
} else {
|
||||
return SlideType.DOCUMENT;
|
||||
}
|
||||
}
|
||||
|
||||
public static @NonNull Slide getSlideForAttachment(Context context, Attachment attachment) {
|
||||
if (attachment.isSticker()) {
|
||||
return new StickerSlide(context, attachment);
|
||||
}
|
||||
|
||||
switch (getSlideTypeFromContentType(attachment.getContentType())) {
|
||||
case GIF : return new GifSlide(context, attachment);
|
||||
case IMAGE : return new ImageSlide(context, attachment);
|
||||
case VIDEO : return new VideoSlide(context, attachment);
|
||||
case AUDIO : return new AudioSlide(context, attachment);
|
||||
case MMS : return new MmsSlide(context, attachment);
|
||||
case LONG_TEXT : return new TextSlide(context, attachment);
|
||||
case DOCUMENT : return new DocumentSlide(context, attachment);
|
||||
default : throw new AssertionError();
|
||||
}
|
||||
}
|
||||
|
||||
public static @Nullable String getMimeType(@NonNull Context context, @Nullable Uri uri) {
|
||||
if (uri == null) return null;
|
||||
|
||||
if (PartAuthority.isLocalUri(uri)) {
|
||||
return PartAuthority.getAttachmentContentType(context, uri);
|
||||
}
|
||||
|
||||
String type = context.getContentResolver().getType(uri);
|
||||
if (type == null) {
|
||||
final String extension = MimeTypeMap.getFileExtensionFromUrl(uri.toString());
|
||||
type = MimeTypeMap.getSingleton().getMimeTypeFromExtension(extension.toLowerCase());
|
||||
}
|
||||
|
||||
return getCorrectedMimeType(type);
|
||||
}
|
||||
|
||||
public static @Nullable String getExtension(@NonNull Context context, @Nullable Uri uri) {
|
||||
return MimeTypeMap.getSingleton()
|
||||
.getExtensionFromMimeType(getMimeType(context, uri));
|
||||
}
|
||||
|
||||
public static @Nullable String getCorrectedMimeType(@Nullable String mimeType) {
|
||||
if (mimeType == null) return null;
|
||||
|
||||
switch(mimeType) {
|
||||
case "image/jpg":
|
||||
return MimeTypeMap.getSingleton().hasMimeType(IMAGE_JPEG)
|
||||
? IMAGE_JPEG
|
||||
: mimeType;
|
||||
default:
|
||||
return mimeType;
|
||||
}
|
||||
}
|
||||
|
||||
public static long getMediaSize(Context context, Uri uri) throws IOException {
|
||||
InputStream in = PartAuthority.getAttachmentStream(context, uri);
|
||||
if (in == null) throw new IOException("Couldn't obtain input stream.");
|
||||
|
||||
long size = 0;
|
||||
byte[] buffer = new byte[4096];
|
||||
int read;
|
||||
|
||||
while ((read = in.read(buffer)) != -1) {
|
||||
size += read;
|
||||
}
|
||||
in.close();
|
||||
|
||||
return size;
|
||||
}
|
||||
|
||||
@WorkerThread
|
||||
public static Pair<Integer, Integer> getDimensions(@NonNull Context context, @Nullable String contentType, @Nullable Uri uri) {
|
||||
if (uri == null || !MediaUtil.isImageType(contentType)) {
|
||||
return new Pair<>(0, 0);
|
||||
}
|
||||
|
||||
Pair<Integer, Integer> dimens = null;
|
||||
|
||||
if (MediaUtil.isGif(contentType)) {
|
||||
try {
|
||||
GifDrawable drawable = GlideApp.with(context)
|
||||
.asGif()
|
||||
.skipMemoryCache(true)
|
||||
.diskCacheStrategy(DiskCacheStrategy.NONE)
|
||||
.load(new DecryptableUri(uri))
|
||||
.submit()
|
||||
.get();
|
||||
dimens = new Pair<>(drawable.getIntrinsicWidth(), drawable.getIntrinsicHeight());
|
||||
} catch (InterruptedException e) {
|
||||
Log.w(TAG, "Was unable to complete work for GIF dimensions.", e);
|
||||
} catch (ExecutionException e) {
|
||||
Log.w(TAG, "Glide experienced an exception while trying to get GIF dimensions.", e);
|
||||
}
|
||||
} else {
|
||||
InputStream attachmentStream = null;
|
||||
try {
|
||||
if (MediaUtil.isJpegType(contentType)) {
|
||||
attachmentStream = PartAuthority.getAttachmentStream(context, uri);
|
||||
dimens = BitmapUtil.getExifDimensions(attachmentStream);
|
||||
attachmentStream.close();
|
||||
attachmentStream = null;
|
||||
}
|
||||
if (dimens == null) {
|
||||
attachmentStream = PartAuthority.getAttachmentStream(context, uri);
|
||||
dimens = BitmapUtil.getDimensions(attachmentStream);
|
||||
}
|
||||
} catch (FileNotFoundException e) {
|
||||
Log.w(TAG, "Failed to find file when retrieving media dimensions.", e);
|
||||
} catch (IOException e) {
|
||||
Log.w(TAG, "Experienced a read error when retrieving media dimensions.", e);
|
||||
} catch (BitmapDecodingException e) {
|
||||
Log.w(TAG, "Bitmap decoding error when retrieving dimensions.", e);
|
||||
} finally {
|
||||
if (attachmentStream != null) {
|
||||
try {
|
||||
attachmentStream.close();
|
||||
} catch (IOException e) {
|
||||
Log.w(TAG, "Failed to close stream after retrieving dimensions.", e);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if (dimens == null) {
|
||||
dimens = new Pair<>(0, 0);
|
||||
}
|
||||
Log.d(TAG, "Dimensions for [" + uri + "] are " + dimens.first + " x " + dimens.second);
|
||||
return dimens;
|
||||
}
|
||||
|
||||
public static boolean isMms(String contentType) {
|
||||
return !TextUtils.isEmpty(contentType) && contentType.trim().equals("application/mms");
|
||||
}
|
||||
|
||||
public static boolean isGif(Attachment attachment) {
|
||||
return isGif(attachment.getContentType());
|
||||
}
|
||||
|
||||
public static boolean isJpeg(Attachment attachment) {
|
||||
return isJpegType(attachment.getContentType());
|
||||
}
|
||||
|
||||
public static boolean isImage(Attachment attachment) {
|
||||
return isImageType(attachment.getContentType());
|
||||
}
|
||||
|
||||
public static boolean isAudio(Attachment attachment) {
|
||||
return isAudioType(attachment.getContentType());
|
||||
}
|
||||
|
||||
public static boolean isVideo(Attachment attachment) {
|
||||
return isVideoType(attachment.getContentType());
|
||||
}
|
||||
|
||||
public static boolean isVideo(String contentType) {
|
||||
return !TextUtils.isEmpty(contentType) && contentType.trim().startsWith("video/");
|
||||
}
|
||||
|
||||
public static boolean isVcard(String contentType) {
|
||||
return !TextUtils.isEmpty(contentType) && contentType.trim().equals(VCARD);
|
||||
}
|
||||
|
||||
public static boolean isGif(String contentType) {
|
||||
return !TextUtils.isEmpty(contentType) && contentType.trim().equals("image/gif");
|
||||
}
|
||||
|
||||
public static boolean isJpegType(String contentType) {
|
||||
return !TextUtils.isEmpty(contentType) && contentType.trim().equals(IMAGE_JPEG);
|
||||
}
|
||||
|
||||
public static boolean isFile(Attachment attachment) {
|
||||
return !isGif(attachment) && !isImage(attachment) && !isAudio(attachment) && !isVideo(attachment);
|
||||
}
|
||||
|
||||
public static boolean isTextType(String contentType) {
|
||||
return (null != contentType) && contentType.startsWith("text/");
|
||||
}
|
||||
|
||||
public static boolean isImageType(String contentType) {
|
||||
return (null != contentType) && contentType.startsWith("image/");
|
||||
}
|
||||
|
||||
public static boolean isAudioType(String contentType) {
|
||||
return (null != contentType) && contentType.startsWith("audio/");
|
||||
}
|
||||
|
||||
public static boolean isVideoType(String contentType) {
|
||||
return (null != contentType) && contentType.startsWith("video/");
|
||||
}
|
||||
|
||||
public static boolean isImageOrVideoType(String contentType) {
|
||||
return isImageType(contentType) || isVideoType(contentType);
|
||||
}
|
||||
|
||||
public static boolean isLongTextType(String contentType) {
|
||||
return (null != contentType) && contentType.equals(LONG_TEXT);
|
||||
}
|
||||
|
||||
public static boolean hasVideoThumbnail(Uri uri) {
|
||||
if (BlobProvider.isAuthority(uri) && MediaUtil.isVideo(BlobProvider.getMimeType(uri)) && Build.VERSION.SDK_INT >= 23) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (uri == null || !isSupportedVideoUriScheme(uri.getScheme())) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if ("com.android.providers.media.documents".equals(uri.getAuthority())) {
|
||||
return uri.getLastPathSegment().contains("video");
|
||||
} else if (uri.toString().startsWith(MediaStore.Video.Media.EXTERNAL_CONTENT_URI.toString())) {
|
||||
return true;
|
||||
} else if (uri.toString().startsWith("file://") &&
|
||||
MediaUtil.isVideo(URLConnection.guessContentTypeFromName(uri.toString()))) {
|
||||
return true;
|
||||
} else {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
@WorkerThread
|
||||
public static @Nullable Bitmap getVideoThumbnail(Context context, Uri uri) {
|
||||
if ("com.android.providers.media.documents".equals(uri.getAuthority())) {
|
||||
long videoId = Long.parseLong(uri.getLastPathSegment().split(":")[1]);
|
||||
|
||||
return MediaStore.Video.Thumbnails.getThumbnail(context.getContentResolver(),
|
||||
videoId,
|
||||
MediaStore.Images.Thumbnails.MINI_KIND,
|
||||
null);
|
||||
} else if (uri.toString().startsWith(MediaStore.Video.Media.EXTERNAL_CONTENT_URI.toString())) {
|
||||
long videoId = Long.parseLong(uri.getLastPathSegment());
|
||||
|
||||
return MediaStore.Video.Thumbnails.getThumbnail(context.getContentResolver(),
|
||||
videoId,
|
||||
MediaStore.Images.Thumbnails.MINI_KIND,
|
||||
null);
|
||||
} else if (uri.toString().startsWith("file://") &&
|
||||
MediaUtil.isVideo(URLConnection.guessContentTypeFromName(uri.toString()))) {
|
||||
return ThumbnailUtils.createVideoThumbnail(uri.toString().replace("file://", ""),
|
||||
MediaStore.Video.Thumbnails.MINI_KIND);
|
||||
} else if (BlobProvider.isAuthority(uri) &&
|
||||
MediaUtil.isVideo(BlobProvider.getMimeType(uri)) &&
|
||||
Build.VERSION.SDK_INT >= 23) {
|
||||
try {
|
||||
MediaDataSource mediaDataSource = BlobProvider.getInstance().getMediaDataSource(context, uri);
|
||||
MediaMetadataRetriever mediaMetadataRetriever = new MediaMetadataRetriever();
|
||||
|
||||
MediaMetadataRetrieverUtil.setDataSource(mediaMetadataRetriever, mediaDataSource);
|
||||
return mediaMetadataRetriever.getFrameAtTime(1000);
|
||||
} catch (IOException e) {
|
||||
Log.w(TAG, "failed to get thumbnail for video blob uri: " + uri, e);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
public static @Nullable String getDiscreteMimeType(@NonNull String mimeType) {
|
||||
final String[] sections = mimeType.split("/", 2);
|
||||
return sections.length > 1 ? sections[0] : null;
|
||||
}
|
||||
|
||||
public static class ThumbnailData implements AutoCloseable {
|
||||
|
||||
@NonNull private final Bitmap bitmap;
|
||||
private final float aspectRatio;
|
||||
|
||||
public ThumbnailData(@NonNull Bitmap bitmap) {
|
||||
this.bitmap = bitmap;
|
||||
this.aspectRatio = (float) bitmap.getWidth() / (float) bitmap.getHeight();
|
||||
}
|
||||
|
||||
public @NonNull Bitmap getBitmap() {
|
||||
return bitmap;
|
||||
}
|
||||
|
||||
public float getAspectRatio() {
|
||||
return aspectRatio;
|
||||
}
|
||||
|
||||
public InputStream toDataStream() {
|
||||
return BitmapUtil.toCompressedJpeg(bitmap);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void close() {
|
||||
bitmap.recycle();
|
||||
}
|
||||
}
|
||||
|
||||
private static boolean isSupportedVideoUriScheme(@Nullable String scheme) {
|
||||
return ContentResolver.SCHEME_CONTENT.equals(scheme) ||
|
||||
ContentResolver.SCHEME_FILE.equals(scheme);
|
||||
}
|
||||
|
||||
public enum SlideType {
|
||||
GIF,
|
||||
IMAGE,
|
||||
VIDEO,
|
||||
AUDIO,
|
||||
MMS,
|
||||
LONG_TEXT,
|
||||
DOCUMENT
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,192 @@
|
||||
package org.thoughtcrime.securesms.util;
|
||||
|
||||
import android.app.ActivityManager;
|
||||
import android.content.Context;
|
||||
import android.os.ParcelFileDescriptor;
|
||||
|
||||
import androidx.annotation.NonNull;
|
||||
|
||||
import org.thoughtcrime.securesms.logging.Log;
|
||||
|
||||
import java.io.Closeable;
|
||||
import java.io.FileDescriptor;
|
||||
import java.io.FileInputStream;
|
||||
import java.io.FileOutputStream;
|
||||
import java.io.IOException;
|
||||
import java.nio.channels.FileChannel;
|
||||
import java.text.NumberFormat;
|
||||
import java.util.Locale;
|
||||
import java.util.concurrent.atomic.AtomicLong;
|
||||
|
||||
public final class MemoryFileDescriptor implements Closeable {
|
||||
|
||||
private static final String TAG = Log.tag(MemoryFileDescriptor.class);
|
||||
|
||||
private static Boolean supported;
|
||||
|
||||
private final ParcelFileDescriptor parcelFileDescriptor;
|
||||
private final AtomicLong sizeEstimate;
|
||||
|
||||
/**
|
||||
* Does this device support memory file descriptor.
|
||||
*/
|
||||
public synchronized static boolean supported() {
|
||||
if (supported == null) {
|
||||
try {
|
||||
int fileDescriptor = FileUtils.createMemoryFileDescriptor("CHECK");
|
||||
|
||||
if (fileDescriptor < 0) {
|
||||
supported = false;
|
||||
Log.w(TAG, "MemoryFileDescriptor is not available.");
|
||||
} else {
|
||||
supported = true;
|
||||
ParcelFileDescriptor.adoptFd(fileDescriptor).close();
|
||||
}
|
||||
} catch (IOException e) {
|
||||
Log.w(TAG, e);
|
||||
}
|
||||
}
|
||||
return supported;
|
||||
}
|
||||
|
||||
/**
|
||||
* memfd files do not show on the available RAM, so we must track our allocations in addition.
|
||||
*/
|
||||
private static long sizeOfAllMemoryFileDescriptors;
|
||||
|
||||
private MemoryFileDescriptor(@NonNull ParcelFileDescriptor parcelFileDescriptor, long sizeEstimate) {
|
||||
this.parcelFileDescriptor = parcelFileDescriptor;
|
||||
this.sizeEstimate = new AtomicLong(sizeEstimate);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param debugName The name supplied in name is used as a filename and will be displayed
|
||||
* as the target of the corresponding symbolic link in the directory
|
||||
* /proc/self/fd/. The displayed name is always prefixed with memfd:
|
||||
* and serves only for debugging purposes. Names do not affect the
|
||||
* behavior of the file descriptor, and as such multiple files can have
|
||||
* the same name without any side effects.
|
||||
* @param sizeEstimate An estimated upper bound on this file. This is used to check there will be
|
||||
* enough RAM available and to register with a global counter of reservations.
|
||||
* Use zero to avoid RAM check.
|
||||
* @return MemoryFileDescriptor
|
||||
* @throws MemoryLimitException If there is not enough available RAM to comfortably fit this file.
|
||||
* @throws MemoryFileCreationException If fails to create a memory file descriptor.
|
||||
*/
|
||||
public static MemoryFileDescriptor newMemoryFileDescriptor(@NonNull Context context,
|
||||
@NonNull String debugName,
|
||||
long sizeEstimate)
|
||||
throws MemoryFileException
|
||||
{
|
||||
if (sizeEstimate < 0) throw new IllegalArgumentException();
|
||||
|
||||
if (sizeEstimate > 0) {
|
||||
ActivityManager activityManager = ServiceUtil.getActivityManager(context);
|
||||
ActivityManager.MemoryInfo memoryInfo = new ActivityManager.MemoryInfo();
|
||||
|
||||
synchronized (MemoryFileDescriptor.class) {
|
||||
activityManager.getMemoryInfo(memoryInfo);
|
||||
|
||||
long remainingRam = memoryInfo.availMem - memoryInfo.threshold - sizeEstimate - sizeOfAllMemoryFileDescriptors;
|
||||
|
||||
if (remainingRam <= 0) {
|
||||
NumberFormat numberFormat = NumberFormat.getInstance(Locale.US);
|
||||
Log.w(TAG, String.format("Not enough RAM available without taking the system into a low memory state.%n" +
|
||||
"Available: %s%n" +
|
||||
"Low memory threshold: %s%n" +
|
||||
"Requested: %s%n" +
|
||||
"Total MemoryFileDescriptor limit: %s%n" +
|
||||
"Shortfall: %s",
|
||||
numberFormat.format(memoryInfo.availMem),
|
||||
numberFormat.format(memoryInfo.threshold),
|
||||
numberFormat.format(sizeEstimate),
|
||||
numberFormat.format(sizeOfAllMemoryFileDescriptors),
|
||||
numberFormat.format(remainingRam)
|
||||
));
|
||||
throw new MemoryLimitException();
|
||||
}
|
||||
|
||||
sizeOfAllMemoryFileDescriptors += sizeEstimate;
|
||||
}
|
||||
}
|
||||
|
||||
int fileDescriptor = FileUtils.createMemoryFileDescriptor(debugName);
|
||||
|
||||
if (fileDescriptor < 0) {
|
||||
Log.w(TAG, "Failed to create file descriptor: " + fileDescriptor);
|
||||
throw new MemoryFileCreationException();
|
||||
}
|
||||
|
||||
return new MemoryFileDescriptor(ParcelFileDescriptor.adoptFd(fileDescriptor), sizeEstimate);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void close() throws IOException {
|
||||
try {
|
||||
clearAndRemoveAllocation();
|
||||
} catch (Exception e) {
|
||||
Log.w(TAG, "Failed to clear data in MemoryFileDescriptor", e);
|
||||
} finally {
|
||||
parcelFileDescriptor.close();
|
||||
}
|
||||
}
|
||||
|
||||
private void clearAndRemoveAllocation() throws IOException {
|
||||
clear();
|
||||
|
||||
long oldEstimate = sizeEstimate.getAndSet(0);
|
||||
|
||||
synchronized (MemoryFileDescriptor.class) {
|
||||
sizeOfAllMemoryFileDescriptors -= oldEstimate;
|
||||
}
|
||||
}
|
||||
|
||||
/** Rewinds and clears all bytes. */
|
||||
private void clear() throws IOException {
|
||||
long size;
|
||||
try (FileInputStream fileInputStream = new FileInputStream(getFileDescriptor())) {
|
||||
FileChannel channel = fileInputStream.getChannel();
|
||||
size = channel.size();
|
||||
|
||||
if (size == 0) return;
|
||||
|
||||
channel.position(0);
|
||||
}
|
||||
byte[] zeros = new byte[16 * 1024];
|
||||
|
||||
try (FileOutputStream output = new FileOutputStream(getFileDescriptor())) {
|
||||
while (size > 0) {
|
||||
int limit = (int) Math.min(size, zeros.length);
|
||||
|
||||
output.write(zeros, 0, limit);
|
||||
|
||||
size -= limit;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public FileDescriptor getFileDescriptor() {
|
||||
return parcelFileDescriptor.getFileDescriptor();
|
||||
}
|
||||
|
||||
public void seek(long position) throws IOException {
|
||||
try (FileInputStream fileInputStream = new FileInputStream(getFileDescriptor())) {
|
||||
fileInputStream.getChannel().position(position);
|
||||
}
|
||||
}
|
||||
|
||||
public long size() throws IOException {
|
||||
try (FileInputStream fileInputStream = new FileInputStream(getFileDescriptor())) {
|
||||
return fileInputStream.getChannel().size();
|
||||
}
|
||||
}
|
||||
|
||||
public static class MemoryFileException extends IOException {
|
||||
}
|
||||
|
||||
private static final class MemoryLimitException extends MemoryFileException {
|
||||
}
|
||||
|
||||
private static final class MemoryFileCreationException extends MemoryFileException {
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
package org.thoughtcrime.securesms.util;
|
||||
|
||||
|
||||
import android.os.Build;
|
||||
import android.os.MemoryFile;
|
||||
import android.os.ParcelFileDescriptor;
|
||||
|
||||
import java.io.FileDescriptor;
|
||||
import java.io.IOException;
|
||||
import java.lang.reflect.Field;
|
||||
import java.lang.reflect.InvocationTargetException;
|
||||
import java.lang.reflect.Method;
|
||||
|
||||
public class MemoryFileUtil {
|
||||
|
||||
public static ParcelFileDescriptor getParcelFileDescriptor(MemoryFile file) throws IOException {
|
||||
try {
|
||||
Method method = MemoryFile.class.getDeclaredMethod("getFileDescriptor");
|
||||
FileDescriptor fileDescriptor = (FileDescriptor) method.invoke(file);
|
||||
|
||||
Field field = fileDescriptor.getClass().getDeclaredField("descriptor");
|
||||
field.setAccessible(true);
|
||||
|
||||
int fd = field.getInt(fileDescriptor);
|
||||
|
||||
return ParcelFileDescriptor.adoptFd(fd);
|
||||
} catch (IllegalAccessException e) {
|
||||
throw new IOException(e);
|
||||
} catch (InvocationTargetException e) {
|
||||
throw new IOException(e);
|
||||
} catch (NoSuchMethodException e) {
|
||||
throw new IOException(e);
|
||||
} catch (NoSuchFieldException e) {
|
||||
throw new IOException(e);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
package org.thoughtcrime.securesms.util;
|
||||
|
||||
import androidx.annotation.NonNull;
|
||||
|
||||
import com.annimon.stream.Stream;
|
||||
|
||||
import org.thoughtcrime.securesms.database.model.MediaMmsMessageRecord;
|
||||
import org.thoughtcrime.securesms.database.model.MessageRecord;
|
||||
import org.thoughtcrime.securesms.database.model.MmsMessageRecord;
|
||||
import org.thoughtcrime.securesms.mms.Slide;
|
||||
|
||||
public final class MessageRecordUtil {
|
||||
|
||||
private MessageRecordUtil() {
|
||||
}
|
||||
|
||||
public static boolean isMediaMessage(@NonNull MessageRecord messageRecord) {
|
||||
return messageRecord.isMms() &&
|
||||
!messageRecord.isMmsNotification() &&
|
||||
((MediaMmsMessageRecord)messageRecord).containsMediaSlide() &&
|
||||
((MediaMmsMessageRecord)messageRecord).getSlideDeck().getStickerSlide() == null;
|
||||
}
|
||||
|
||||
public static boolean hasSticker(@NonNull MessageRecord messageRecord) {
|
||||
return messageRecord.isMms() && ((MmsMessageRecord)messageRecord).getSlideDeck().getStickerSlide() != null;
|
||||
}
|
||||
|
||||
public static boolean hasSharedContact(@NonNull MessageRecord messageRecord) {
|
||||
return messageRecord.isMms() && !((MmsMessageRecord)messageRecord).getSharedContacts().isEmpty();
|
||||
}
|
||||
|
||||
public static boolean hasLocation(@NonNull MessageRecord messageRecord) {
|
||||
return messageRecord.isMms() && !Stream.of(((MmsMessageRecord) messageRecord).getSlideDeck().getSlides())
|
||||
.anyMatch(Slide::hasLocation);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
package org.thoughtcrime.securesms.util;
|
||||
|
||||
public class MmsCharacterCalculator extends CharacterCalculator {
|
||||
|
||||
private static final int MAX_SIZE = 5000;
|
||||
|
||||
@Override
|
||||
public CharacterState calculateCharacters(String messageBody) {
|
||||
return new CharacterState(1, MAX_SIZE - messageBody.length(), MAX_SIZE, MAX_SIZE);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
package org.thoughtcrime.securesms.util;
|
||||
|
||||
import androidx.lifecycle.MutableLiveData;
|
||||
import android.database.ContentObserver;
|
||||
import androidx.annotation.NonNull;
|
||||
|
||||
import org.thoughtcrime.securesms.database.ObservableContent;
|
||||
|
||||
import java.io.Closeable;
|
||||
|
||||
/**
|
||||
* Implementation of {@link androidx.lifecycle.LiveData} that will handle closing the contained
|
||||
* {@link Closeable} when the value changes.
|
||||
*/
|
||||
public class ObservingLiveData<E extends ObservableContent> extends MutableLiveData<E> {
|
||||
|
||||
private ContentObserver observer;
|
||||
|
||||
@Override
|
||||
public void setValue(E value) {
|
||||
E previous = getValue();
|
||||
|
||||
if (previous != null) {
|
||||
previous.unregisterContentObserver(observer);
|
||||
Util.close(previous);
|
||||
}
|
||||
|
||||
value.registerContentObserver(observer);
|
||||
|
||||
super.setValue(value);
|
||||
}
|
||||
|
||||
public void close() {
|
||||
E value = getValue();
|
||||
|
||||
if (value != null) {
|
||||
value.unregisterContentObserver(observer);
|
||||
Util.close(value);
|
||||
}
|
||||
}
|
||||
|
||||
public void registerContentObserver(@NonNull ContentObserver observer) {
|
||||
this.observer = observer;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
package org.thoughtcrime.securesms.util;
|
||||
|
||||
import android.os.Parcel;
|
||||
import android.os.Parcelable;
|
||||
|
||||
public class ParcelUtil {
|
||||
|
||||
public static byte[] serialize(Parcelable parceable) {
|
||||
Parcel parcel = Parcel.obtain();
|
||||
parceable.writeToParcel(parcel, 0);
|
||||
byte[] bytes = parcel.marshall();
|
||||
parcel.recycle();
|
||||
return bytes;
|
||||
}
|
||||
|
||||
public static Parcel deserialize(byte[] bytes) {
|
||||
Parcel parcel = Parcel.obtain();
|
||||
parcel.unmarshall(bytes, 0, bytes.length);
|
||||
parcel.setDataPosition(0);
|
||||
return parcel;
|
||||
}
|
||||
|
||||
public static <T> T deserialize(byte[] bytes, Parcelable.Creator<T> creator) {
|
||||
Parcel parcel = deserialize(bytes);
|
||||
return creator.createFromParcel(parcel);
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
package org.thoughtcrime.securesms.util;
|
||||
|
||||
|
||||
import android.content.Context;
|
||||
import android.content.pm.ApplicationInfo;
|
||||
import android.content.pm.PackageManager;
|
||||
import org.thoughtcrime.securesms.logging.Log;
|
||||
|
||||
import com.google.android.gms.common.ConnectionResult;
|
||||
import com.google.android.gms.common.GoogleApiAvailability;
|
||||
|
||||
public class PlayServicesUtil {
|
||||
|
||||
private static final String TAG = PlayServicesUtil.class.getSimpleName();
|
||||
|
||||
public enum PlayServicesStatus {
|
||||
SUCCESS,
|
||||
MISSING,
|
||||
NEEDS_UPDATE,
|
||||
TRANSIENT_ERROR
|
||||
}
|
||||
|
||||
public static PlayServicesStatus getPlayServicesStatus(Context context) {
|
||||
int gcmStatus = 0;
|
||||
|
||||
try {
|
||||
gcmStatus = GoogleApiAvailability.getInstance().isGooglePlayServicesAvailable(context);
|
||||
} catch (Throwable t) {
|
||||
Log.w(TAG, t);
|
||||
return PlayServicesStatus.MISSING;
|
||||
}
|
||||
|
||||
Log.i(TAG, "Play Services: " + gcmStatus);
|
||||
|
||||
switch (gcmStatus) {
|
||||
case ConnectionResult.SUCCESS:
|
||||
return PlayServicesStatus.SUCCESS;
|
||||
case ConnectionResult.SERVICE_VERSION_UPDATE_REQUIRED:
|
||||
try {
|
||||
ApplicationInfo applicationInfo = context.getPackageManager().getApplicationInfo("com.google.android.gms", 0);
|
||||
|
||||
if (applicationInfo != null && !applicationInfo.enabled) {
|
||||
return PlayServicesStatus.MISSING;
|
||||
}
|
||||
} catch (PackageManager.NameNotFoundException e) {
|
||||
Log.w(TAG, e);
|
||||
}
|
||||
|
||||
return PlayServicesStatus.NEEDS_UPDATE;
|
||||
case ConnectionResult.SERVICE_DISABLED:
|
||||
case ConnectionResult.SERVICE_MISSING:
|
||||
case ConnectionResult.SERVICE_INVALID:
|
||||
case ConnectionResult.API_UNAVAILABLE:
|
||||
case ConnectionResult.SERVICE_MISSING_PERMISSION:
|
||||
return PlayServicesStatus.MISSING;
|
||||
default:
|
||||
return PlayServicesStatus.TRANSIENT_ERROR;
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
package org.thoughtcrime.securesms.util;
|
||||
|
||||
import android.os.Build;
|
||||
import android.os.PowerManager;
|
||||
import androidx.annotation.NonNull;
|
||||
|
||||
public class PowerManagerCompat {
|
||||
|
||||
public static boolean isDeviceIdleMode(@NonNull PowerManager powerManager) {
|
||||
if (Build.VERSION.SDK_INT >= 23) {
|
||||
return powerManager.isDeviceIdleMode();
|
||||
}
|
||||
return false;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,96 @@
|
||||
package org.thoughtcrime.securesms.util;
|
||||
|
||||
import android.content.Context;
|
||||
|
||||
import androidx.annotation.NonNull;
|
||||
import androidx.annotation.Nullable;
|
||||
import androidx.annotation.WorkerThread;
|
||||
|
||||
import org.thoughtcrime.securesms.crypto.UnidentifiedAccessUtil;
|
||||
import org.thoughtcrime.securesms.dependencies.ApplicationDependencies;
|
||||
import org.thoughtcrime.securesms.logging.Log;
|
||||
import org.thoughtcrime.securesms.recipients.Recipient;
|
||||
import org.thoughtcrime.securesms.recipients.RecipientUtil;
|
||||
import org.thoughtcrime.securesms.service.IncomingMessageObserver;
|
||||
import org.whispersystems.libsignal.util.guava.Optional;
|
||||
import org.whispersystems.signalservice.api.SignalServiceMessagePipe;
|
||||
import org.whispersystems.signalservice.api.SignalServiceMessageReceiver;
|
||||
import org.whispersystems.signalservice.api.crypto.InvalidCiphertextException;
|
||||
import org.whispersystems.signalservice.api.crypto.ProfileCipher;
|
||||
import org.whispersystems.signalservice.api.crypto.UnidentifiedAccess;
|
||||
import org.whispersystems.signalservice.api.crypto.UnidentifiedAccessPair;
|
||||
import org.whispersystems.signalservice.api.profiles.SignalServiceProfile;
|
||||
import org.whispersystems.signalservice.api.push.SignalServiceAddress;
|
||||
import org.whispersystems.signalservice.api.push.exceptions.NonSuccessfulResponseCodeException;
|
||||
|
||||
import java.io.IOException;
|
||||
|
||||
/**
|
||||
* Aids in the retrieval and decryption of profiles.
|
||||
*/
|
||||
public class ProfileUtil {
|
||||
|
||||
private static final String TAG = Log.tag(ProfileUtil.class);
|
||||
|
||||
@WorkerThread
|
||||
public static SignalServiceProfile retrieveProfile(@NonNull Context context, @NonNull Recipient recipient) throws IOException {
|
||||
SignalServiceAddress address = RecipientUtil.toSignalServiceAddress(context, recipient);
|
||||
Optional<UnidentifiedAccess> unidentifiedAccess = getUnidentifiedAccess(context, recipient);
|
||||
|
||||
SignalServiceProfile profile;
|
||||
|
||||
try {
|
||||
profile = retrieveProfileInternal(address, unidentifiedAccess);
|
||||
} catch (NonSuccessfulResponseCodeException e) {
|
||||
if (unidentifiedAccess.isPresent()) {
|
||||
profile = retrieveProfileInternal(address, Optional.absent());
|
||||
} else {
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
|
||||
return profile;
|
||||
}
|
||||
|
||||
public static @Nullable String decryptName(@NonNull byte[] profileKey, @Nullable String encryptedName)
|
||||
throws InvalidCiphertextException, IOException
|
||||
{
|
||||
if (encryptedName == null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
ProfileCipher profileCipher = new ProfileCipher(profileKey);
|
||||
return new String(profileCipher.decryptName(Base64.decode(encryptedName)));
|
||||
}
|
||||
|
||||
@WorkerThread
|
||||
private static SignalServiceProfile retrieveProfileInternal(@NonNull SignalServiceAddress address, Optional<UnidentifiedAccess> unidentifiedAccess)
|
||||
throws IOException
|
||||
{
|
||||
SignalServiceMessagePipe authPipe = IncomingMessageObserver.getPipe();
|
||||
SignalServiceMessagePipe unidentifiedPipe = IncomingMessageObserver.getUnidentifiedPipe();
|
||||
SignalServiceMessagePipe pipe = unidentifiedPipe != null && unidentifiedAccess.isPresent() ? unidentifiedPipe
|
||||
: authPipe;
|
||||
|
||||
if (pipe != null) {
|
||||
try {
|
||||
return pipe.getProfile(address, unidentifiedAccess);
|
||||
} catch (IOException e) {
|
||||
Log.w(TAG, e);
|
||||
}
|
||||
}
|
||||
|
||||
SignalServiceMessageReceiver receiver = ApplicationDependencies.getSignalServiceMessageReceiver();
|
||||
return receiver.retrieveProfile(address, unidentifiedAccess);
|
||||
}
|
||||
|
||||
private static Optional<UnidentifiedAccess> getUnidentifiedAccess(@NonNull Context context, @NonNull Recipient recipient) {
|
||||
Optional<UnidentifiedAccessPair> unidentifiedAccess = UnidentifiedAccessUtil.getAccessFor(context, recipient);
|
||||
|
||||
if (unidentifiedAccess.isPresent()) {
|
||||
return unidentifiedAccess.get().getTargetUnidentifiedAccess();
|
||||
}
|
||||
|
||||
return Optional.absent();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
/**
|
||||
* Copyright (C) 2015 Whisper Systems
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
*/
|
||||
package org.thoughtcrime.securesms.util;
|
||||
|
||||
public class PushCharacterCalculator extends CharacterCalculator {
|
||||
private static final int MAX_TOTAL_SIZE = 64 * 1024;
|
||||
private static final int MAX_PRIMARY_SIZE = 2000;
|
||||
@Override
|
||||
public CharacterState calculateCharacters(String messageBody) {
|
||||
return new CharacterState(1, MAX_TOTAL_SIZE - messageBody.length(), MAX_TOTAL_SIZE, MAX_PRIMARY_SIZE);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
/**
|
||||
* Copyright (C) 2011 Whisper Systems
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
*/
|
||||
package org.thoughtcrime.securesms.util;
|
||||
|
||||
public interface RedPhoneCallTypes {
|
||||
public static final int INCOMING = 1023;
|
||||
public static final int OUTGOING = 1024;
|
||||
public static final int MISSED = 1025;
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
/**
|
||||
* Copyright (C) 2015 Open Whisper Systems
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
package org.thoughtcrime.securesms.util;
|
||||
|
||||
import android.content.Context;
|
||||
import android.content.res.Resources.Theme;
|
||||
import android.content.res.TypedArray;
|
||||
import android.graphics.drawable.Drawable;
|
||||
import androidx.annotation.ArrayRes;
|
||||
import androidx.annotation.AttrRes;
|
||||
import androidx.annotation.DimenRes;
|
||||
import androidx.annotation.NonNull;
|
||||
import androidx.core.content.ContextCompat;
|
||||
import androidx.appcompat.content.res.AppCompatResources;
|
||||
import android.util.TypedValue;
|
||||
|
||||
public class ResUtil {
|
||||
|
||||
public static int getColor(Context context, @AttrRes int attr) {
|
||||
final TypedArray styledAttributes = context.obtainStyledAttributes(new int[]{attr});
|
||||
final int result = styledAttributes.getColor(0, -1);
|
||||
styledAttributes.recycle();
|
||||
return result;
|
||||
}
|
||||
|
||||
public static int getDrawableRes(Context c, @AttrRes int attr) {
|
||||
return getDrawableRes(c.getTheme(), attr);
|
||||
}
|
||||
|
||||
public static int getDrawableRes(Theme theme, @AttrRes int attr) {
|
||||
final TypedValue out = new TypedValue();
|
||||
theme.resolveAttribute(attr, out, true);
|
||||
return out.resourceId;
|
||||
}
|
||||
|
||||
public static Drawable getDrawable(Context c, @AttrRes int attr) {
|
||||
return AppCompatResources.getDrawable(c, getDrawableRes(c, attr));
|
||||
}
|
||||
|
||||
public static int[] getResourceIds(Context c, @ArrayRes int array) {
|
||||
final TypedArray typedArray = c.getResources().obtainTypedArray(array);
|
||||
final int[] resourceIds = new int[typedArray.length()];
|
||||
for (int i = 0; i < typedArray.length(); i++) {
|
||||
resourceIds[i] = typedArray.getResourceId(i, 0);
|
||||
}
|
||||
typedArray.recycle();
|
||||
return resourceIds;
|
||||
}
|
||||
|
||||
public static float getFloat(@NonNull Context context, @DimenRes int resId) {
|
||||
TypedValue value = new TypedValue();
|
||||
context.getResources().getValue(resId, value, true);
|
||||
return value.getFloat();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
/*
|
||||
* Copyright (C) 2015 Open Whisper Systems
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
package org.thoughtcrime.securesms.util;
|
||||
|
||||
import java.net.URISyntaxException;
|
||||
import java.net.URLDecoder;
|
||||
import java.util.HashMap;
|
||||
import java.util.Map;
|
||||
|
||||
public class Rfc5724Uri {
|
||||
|
||||
private final String uri;
|
||||
private final String schema;
|
||||
private final String path;
|
||||
private final Map<String, String> queryParams;
|
||||
|
||||
public Rfc5724Uri(String uri) throws URISyntaxException {
|
||||
this.uri = uri;
|
||||
this.schema = parseSchema();
|
||||
this.path = parsePath();
|
||||
this.queryParams = parseQueryParams();
|
||||
}
|
||||
|
||||
private String parseSchema() throws URISyntaxException {
|
||||
String[] parts = uri.split(":");
|
||||
|
||||
if (parts.length < 1 || parts[0].isEmpty()) throw new URISyntaxException(uri, "invalid schema");
|
||||
else return parts[0];
|
||||
}
|
||||
|
||||
private String parsePath() throws URISyntaxException {
|
||||
String[] parts = uri.split("\\?")[0].split(":", 2);
|
||||
|
||||
if (parts.length < 2 || parts[1].isEmpty()) throw new URISyntaxException(uri, "invalid path");
|
||||
else return parts[1];
|
||||
}
|
||||
|
||||
private Map<String, String> parseQueryParams() throws URISyntaxException {
|
||||
Map<String, String> queryParams = new HashMap<>();
|
||||
if (uri.split("\\?").length < 2) {
|
||||
return queryParams;
|
||||
}
|
||||
|
||||
for (String keyValue : uri.split("\\?")[1].split("&")) {
|
||||
String[] parts = keyValue.split("=");
|
||||
|
||||
if (parts.length == 1) queryParams.put(parts[0], "");
|
||||
else queryParams.put(parts[0], URLDecoder.decode(parts[1]));
|
||||
}
|
||||
|
||||
return queryParams;
|
||||
}
|
||||
|
||||
public String getSchema() {
|
||||
return schema;
|
||||
}
|
||||
|
||||
public String getPath() {
|
||||
return path;
|
||||
}
|
||||
|
||||
public Map<String, String> getQueryParams() {
|
||||
return queryParams;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,238 @@
|
||||
package org.thoughtcrime.securesms.util;
|
||||
|
||||
import android.content.Context;
|
||||
import android.content.DialogInterface.OnClickListener;
|
||||
import android.media.MediaScannerConnection;
|
||||
import android.net.Uri;
|
||||
import androidx.annotation.NonNull;
|
||||
import androidx.annotation.Nullable;
|
||||
import androidx.appcompat.app.AlertDialog;
|
||||
import android.text.TextUtils;
|
||||
import android.webkit.MimeTypeMap;
|
||||
import android.widget.Toast;
|
||||
|
||||
import org.thoughtcrime.securesms.R;
|
||||
import org.thoughtcrime.securesms.database.NoExternalStorageException;
|
||||
import org.thoughtcrime.securesms.logging.Log;
|
||||
import org.thoughtcrime.securesms.mms.PartAuthority;
|
||||
import org.thoughtcrime.securesms.util.task.ProgressDialogAsyncTask;
|
||||
import org.whispersystems.libsignal.util.Pair;
|
||||
|
||||
import java.io.File;
|
||||
import java.io.FileOutputStream;
|
||||
import java.io.IOException;
|
||||
import java.io.InputStream;
|
||||
import java.io.OutputStream;
|
||||
import java.lang.ref.WeakReference;
|
||||
import java.text.SimpleDateFormat;
|
||||
|
||||
public class SaveAttachmentTask extends ProgressDialogAsyncTask<SaveAttachmentTask.Attachment, Void, Pair<Integer, String>> {
|
||||
private static final String TAG = SaveAttachmentTask.class.getSimpleName();
|
||||
|
||||
static final int SUCCESS = 0;
|
||||
private static final int FAILURE = 1;
|
||||
private static final int WRITE_ACCESS_FAILURE = 2;
|
||||
|
||||
private final WeakReference<Context> contextReference;
|
||||
|
||||
private final int attachmentCount;
|
||||
|
||||
public SaveAttachmentTask(Context context) {
|
||||
this(context, 1);
|
||||
}
|
||||
|
||||
public SaveAttachmentTask(Context context, int count) {
|
||||
super(context,
|
||||
context.getResources().getQuantityString(R.plurals.ConversationFragment_saving_n_attachments, count, count),
|
||||
context.getResources().getQuantityString(R.plurals.ConversationFragment_saving_n_attachments_to_sd_card, count, count));
|
||||
this.contextReference = new WeakReference<>(context);
|
||||
this.attachmentCount = count;
|
||||
}
|
||||
|
||||
@Override
|
||||
protected Pair<Integer, String> doInBackground(SaveAttachmentTask.Attachment... attachments) {
|
||||
if (attachments == null || attachments.length == 0) {
|
||||
throw new AssertionError("must pass in at least one attachment");
|
||||
}
|
||||
|
||||
try {
|
||||
Context context = contextReference.get();
|
||||
String directory = null;
|
||||
|
||||
if (!StorageUtil.canWriteInSignalStorageDir()) {
|
||||
return new Pair<>(WRITE_ACCESS_FAILURE, null);
|
||||
}
|
||||
|
||||
if (context == null) {
|
||||
return new Pair<>(FAILURE, null);
|
||||
}
|
||||
|
||||
for (Attachment attachment : attachments) {
|
||||
if (attachment != null) {
|
||||
directory = saveAttachment(context, attachment);
|
||||
if (directory == null) return new Pair<>(FAILURE, null);
|
||||
}
|
||||
}
|
||||
|
||||
if (attachments.length > 1) return new Pair<>(SUCCESS, null);
|
||||
else return new Pair<>(SUCCESS, directory);
|
||||
} catch (NoExternalStorageException|IOException ioe) {
|
||||
Log.w(TAG, ioe);
|
||||
return new Pair<>(FAILURE, null);
|
||||
}
|
||||
}
|
||||
|
||||
private @Nullable String saveAttachment(Context context, Attachment attachment)
|
||||
throws NoExternalStorageException, IOException
|
||||
{
|
||||
String contentType = MediaUtil.getCorrectedMimeType(attachment.contentType);
|
||||
String fileName = attachment.fileName;
|
||||
|
||||
if (fileName == null) fileName = generateOutputFileName(contentType, attachment.date);
|
||||
fileName = sanitizeOutputFileName(fileName);
|
||||
|
||||
File outputDirectory = createOutputDirectoryFromContentType(contentType);
|
||||
File mediaFile = createOutputFile(outputDirectory, fileName);
|
||||
InputStream inputStream = PartAuthority.getAttachmentStream(context, attachment.uri);
|
||||
|
||||
if (inputStream == null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
OutputStream outputStream = new FileOutputStream(mediaFile);
|
||||
Util.copy(inputStream, outputStream);
|
||||
|
||||
MediaScannerConnection.scanFile(context, new String[]{mediaFile.getAbsolutePath()},
|
||||
new String[]{contentType}, null);
|
||||
|
||||
return outputDirectory.getName();
|
||||
}
|
||||
|
||||
private File createOutputDirectoryFromContentType(@NonNull String contentType)
|
||||
throws NoExternalStorageException
|
||||
{
|
||||
File outputDirectory;
|
||||
|
||||
if (contentType.startsWith("video/")) {
|
||||
outputDirectory = StorageUtil.getVideoDir();
|
||||
} else if (contentType.startsWith("audio/")) {
|
||||
outputDirectory = StorageUtil.getAudioDir();
|
||||
} else if (contentType.startsWith("image/")) {
|
||||
outputDirectory = StorageUtil.getImageDir();
|
||||
} else {
|
||||
outputDirectory = StorageUtil.getDownloadDir();
|
||||
}
|
||||
|
||||
if (!outputDirectory.mkdirs()) Log.w(TAG, "mkdirs() returned false, attempting to continue");
|
||||
return outputDirectory;
|
||||
}
|
||||
|
||||
private String generateOutputFileName(@NonNull String contentType, long timestamp) {
|
||||
MimeTypeMap mimeTypeMap = MimeTypeMap.getSingleton();
|
||||
String extension = mimeTypeMap.getExtensionFromMimeType(contentType);
|
||||
SimpleDateFormat dateFormatter = new SimpleDateFormat("yyyy-MM-dd-HHmmss");
|
||||
String base = "signal-" + dateFormatter.format(timestamp);
|
||||
|
||||
if (extension == null) extension = "attach";
|
||||
|
||||
return base + "." + extension;
|
||||
}
|
||||
|
||||
private String sanitizeOutputFileName(@NonNull String fileName) {
|
||||
return new File(fileName).getName();
|
||||
}
|
||||
|
||||
private File createOutputFile(@NonNull File outputDirectory, @NonNull String fileName)
|
||||
throws IOException
|
||||
{
|
||||
String[] fileParts = getFileNameParts(fileName);
|
||||
String base = fileParts[0];
|
||||
String extension = fileParts[1];
|
||||
|
||||
File outputFile = new File(outputDirectory, base + "." + extension);
|
||||
|
||||
int i = 0;
|
||||
while (outputFile.exists()) {
|
||||
outputFile = new File(outputDirectory, base + "-" + (++i) + "." + extension);
|
||||
}
|
||||
|
||||
if (outputFile.isHidden()) {
|
||||
throw new IOException("Specified name would not be visible");
|
||||
}
|
||||
|
||||
return outputFile;
|
||||
}
|
||||
|
||||
private String[] getFileNameParts(String fileName) {
|
||||
String[] result = new String[2];
|
||||
String[] tokens = fileName.split("\\.(?=[^\\.]+$)");
|
||||
|
||||
result[0] = tokens[0];
|
||||
|
||||
if (tokens.length > 1) result[1] = tokens[1];
|
||||
else result[1] = "";
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void onPostExecute(final Pair<Integer, String> result) {
|
||||
super.onPostExecute(result);
|
||||
final Context context = contextReference.get();
|
||||
if (context == null) return;
|
||||
|
||||
switch (result.first()) {
|
||||
case FAILURE:
|
||||
Toast.makeText(context,
|
||||
context.getResources().getQuantityText(R.plurals.ConversationFragment_error_while_saving_attachments_to_sd_card,
|
||||
attachmentCount),
|
||||
Toast.LENGTH_LONG).show();
|
||||
break;
|
||||
case SUCCESS:
|
||||
String message = !TextUtils.isEmpty(result.second()) ? context.getResources().getString(R.string.SaveAttachmentTask_saved_to, result.second())
|
||||
: context.getResources().getString(R.string.SaveAttachmentTask_saved);
|
||||
Toast.makeText(context, message, Toast.LENGTH_LONG).show();
|
||||
break;
|
||||
case WRITE_ACCESS_FAILURE:
|
||||
Toast.makeText(context, R.string.ConversationFragment_unable_to_write_to_sd_card_exclamation,
|
||||
Toast.LENGTH_LONG).show();
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
public static class Attachment {
|
||||
public Uri uri;
|
||||
public String fileName;
|
||||
public String contentType;
|
||||
public long date;
|
||||
|
||||
public Attachment(@NonNull Uri uri, @NonNull String contentType,
|
||||
long date, @Nullable String fileName)
|
||||
{
|
||||
if (uri == null || contentType == null || date < 0) {
|
||||
throw new AssertionError("uri, content type, and date must all be specified");
|
||||
}
|
||||
this.uri = uri;
|
||||
this.fileName = fileName;
|
||||
this.contentType = contentType;
|
||||
this.date = date;
|
||||
}
|
||||
}
|
||||
|
||||
public static void showWarningDialog(Context context, OnClickListener onAcceptListener) {
|
||||
showWarningDialog(context, onAcceptListener, 1);
|
||||
}
|
||||
|
||||
public static void showWarningDialog(Context context, OnClickListener onAcceptListener, int count) {
|
||||
AlertDialog.Builder builder = new AlertDialog.Builder(context);
|
||||
builder.setTitle(R.string.ConversationFragment_save_to_sd_card);
|
||||
builder.setIconAttribute(R.attr.dialog_alert_icon);
|
||||
builder.setCancelable(true);
|
||||
builder.setMessage(context.getResources().getQuantityString(R.plurals.ConversationFragment_saving_n_media_to_storage_warning,
|
||||
count, count));
|
||||
builder.setPositiveButton(R.string.yes, onAcceptListener);
|
||||
builder.setNegativeButton(R.string.no, null);
|
||||
builder.show();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,102 @@
|
||||
package org.thoughtcrime.securesms.util;
|
||||
|
||||
import androidx.annotation.NonNull;
|
||||
import androidx.annotation.Nullable;
|
||||
import android.text.Spannable;
|
||||
import android.text.SpannableString;
|
||||
import android.text.TextUtils;
|
||||
import android.text.style.CharacterStyle;
|
||||
|
||||
import com.annimon.stream.Stream;
|
||||
|
||||
import org.whispersystems.libsignal.util.Pair;
|
||||
|
||||
import java.util.Collections;
|
||||
import java.util.LinkedList;
|
||||
import java.util.List;
|
||||
import java.util.Locale;
|
||||
|
||||
public class SearchUtil {
|
||||
|
||||
public static Spannable getHighlightedSpan(@NonNull Locale locale,
|
||||
@NonNull StyleFactory styleFactory,
|
||||
@Nullable String text,
|
||||
@Nullable String highlight)
|
||||
{
|
||||
if (TextUtils.isEmpty(text)) {
|
||||
return new SpannableString("");
|
||||
}
|
||||
|
||||
text = text.replaceAll("\n", " ");
|
||||
|
||||
return getHighlightedSpan(locale, styleFactory, new SpannableString(text), highlight);
|
||||
}
|
||||
|
||||
public static Spannable getHighlightedSpan(@NonNull Locale locale,
|
||||
@NonNull StyleFactory styleFactory,
|
||||
@Nullable Spannable text,
|
||||
@Nullable String highlight)
|
||||
{
|
||||
if (TextUtils.isEmpty(text)) {
|
||||
return new SpannableString("");
|
||||
}
|
||||
|
||||
|
||||
if (TextUtils.isEmpty(highlight)) {
|
||||
return text;
|
||||
}
|
||||
|
||||
List<Pair<Integer, Integer>> ranges = getHighlightRanges(locale, text.toString(), highlight);
|
||||
SpannableString spanned = new SpannableString(text);
|
||||
|
||||
for (Pair<Integer, Integer> range : ranges) {
|
||||
spanned.setSpan(styleFactory.create(), range.first(), range.second(), Spannable.SPAN_INCLUSIVE_EXCLUSIVE);
|
||||
}
|
||||
|
||||
return spanned;
|
||||
}
|
||||
|
||||
static List<Pair<Integer, Integer>> getHighlightRanges(@NonNull Locale locale,
|
||||
@NonNull String text,
|
||||
@NonNull String highlight)
|
||||
{
|
||||
if (text.length() == 0) {
|
||||
return Collections.emptyList();
|
||||
}
|
||||
|
||||
String normalizedText = text.toLowerCase(locale);
|
||||
String normalizedHighlight = highlight.toLowerCase(locale);
|
||||
List<String> highlightTokens = Stream.of(normalizedHighlight.split("\\s")).filter(s -> s.trim().length() > 0).toList();
|
||||
|
||||
List<Pair<Integer, Integer>> ranges = new LinkedList<>();
|
||||
|
||||
int lastHighlightEndIndex = 0;
|
||||
|
||||
for (String highlightToken : highlightTokens) {
|
||||
int index;
|
||||
|
||||
do {
|
||||
index = normalizedText.indexOf(highlightToken, lastHighlightEndIndex);
|
||||
lastHighlightEndIndex = index + highlightToken.length();
|
||||
} while (index > 0 && !Character.isWhitespace(normalizedText.charAt(index - 1)));
|
||||
|
||||
if (index >= 0) {
|
||||
ranges.add(new Pair<>(index, lastHighlightEndIndex));
|
||||
}
|
||||
|
||||
if (index < 0 || lastHighlightEndIndex >= normalizedText.length()) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (ranges.size() != highlightTokens.size()) {
|
||||
return Collections.emptyList();
|
||||
}
|
||||
|
||||
return ranges;
|
||||
}
|
||||
|
||||
public interface StyleFactory {
|
||||
CharacterStyle create();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,165 @@
|
||||
package org.thoughtcrime.securesms.util;
|
||||
|
||||
import android.content.Context;
|
||||
import androidx.annotation.NonNull;
|
||||
import androidx.annotation.Nullable;
|
||||
import android.view.LayoutInflater;
|
||||
import android.view.View;
|
||||
import android.view.ViewGroup;
|
||||
import android.widget.BaseAdapter;
|
||||
import android.widget.ImageButton;
|
||||
import android.widget.TextView;
|
||||
|
||||
import org.thoughtcrime.securesms.R;
|
||||
import org.thoughtcrime.securesms.recipients.Recipient;
|
||||
import org.whispersystems.libsignal.util.guava.Optional;
|
||||
|
||||
import java.util.Collection;
|
||||
import java.util.Collections;
|
||||
import java.util.HashSet;
|
||||
import java.util.LinkedList;
|
||||
import java.util.List;
|
||||
import java.util.Set;
|
||||
|
||||
public class SelectedRecipientsAdapter extends BaseAdapter {
|
||||
@NonNull private Context context;
|
||||
@Nullable private OnRecipientDeletedListener onRecipientDeletedListener;
|
||||
@NonNull private List<RecipientWrapper> recipients;
|
||||
|
||||
public SelectedRecipientsAdapter(@NonNull Context context) {
|
||||
this(context, Collections.<Recipient>emptyList());
|
||||
}
|
||||
|
||||
public SelectedRecipientsAdapter(@NonNull Context context,
|
||||
@NonNull Collection<Recipient> existingRecipients)
|
||||
{
|
||||
this.context = context;
|
||||
this.recipients = wrapExistingMembers(existingRecipients);
|
||||
}
|
||||
|
||||
public void add(@NonNull Recipient recipient, boolean isPush) {
|
||||
if (!find(recipient).isPresent()) {
|
||||
RecipientWrapper wrapper = new RecipientWrapper(recipient, true, isPush);
|
||||
this.recipients.add(0, wrapper);
|
||||
notifyDataSetChanged();
|
||||
}
|
||||
}
|
||||
|
||||
public Optional<RecipientWrapper> find(@NonNull Recipient recipient) {
|
||||
RecipientWrapper found = null;
|
||||
for (RecipientWrapper wrapper : recipients) {
|
||||
if (wrapper.getRecipient().equals(recipient)) found = wrapper;
|
||||
}
|
||||
return Optional.fromNullable(found);
|
||||
}
|
||||
|
||||
public void remove(@NonNull Recipient recipient) {
|
||||
Optional<RecipientWrapper> match = find(recipient);
|
||||
if (match.isPresent()) {
|
||||
recipients.remove(match.get());
|
||||
notifyDataSetChanged();
|
||||
}
|
||||
}
|
||||
|
||||
public Set<Recipient> getRecipients() {
|
||||
final Set<Recipient> recipientSet = new HashSet<>(recipients.size());
|
||||
for (RecipientWrapper wrapper : recipients) {
|
||||
recipientSet.add(wrapper.getRecipient());
|
||||
}
|
||||
return recipientSet;
|
||||
}
|
||||
|
||||
@Override
|
||||
public int getCount() {
|
||||
return recipients.size();
|
||||
}
|
||||
|
||||
public boolean hasNonPushMembers() {
|
||||
for (RecipientWrapper wrapper : recipients) {
|
||||
if (!wrapper.isPush()) return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
@Override
|
||||
public Object getItem(int position) {
|
||||
return recipients.get(position);
|
||||
}
|
||||
|
||||
@Override
|
||||
public long getItemId(int position) {
|
||||
return position;
|
||||
}
|
||||
|
||||
@Override
|
||||
public View getView(final int position, View v, final ViewGroup parent) {
|
||||
if (v == null) {
|
||||
v = LayoutInflater.from(context).inflate(R.layout.selected_recipient_list_item, parent, false);
|
||||
}
|
||||
|
||||
final RecipientWrapper rw = (RecipientWrapper)getItem(position);
|
||||
final Recipient p = rw.getRecipient();
|
||||
final boolean modifiable = rw.isModifiable();
|
||||
|
||||
TextView name = (TextView) v.findViewById(R.id.name);
|
||||
TextView phone = (TextView) v.findViewById(R.id.phone);
|
||||
ImageButton delete = (ImageButton) v.findViewById(R.id.delete);
|
||||
|
||||
name.setText(p.getDisplayName(v.getContext()));
|
||||
phone.setText(p.getE164().or(""));
|
||||
delete.setVisibility(modifiable ? View.VISIBLE : View.GONE);
|
||||
delete.setOnClickListener(new View.OnClickListener() {
|
||||
@Override
|
||||
public void onClick(View view) {
|
||||
if (onRecipientDeletedListener != null) {
|
||||
onRecipientDeletedListener.onRecipientDeleted(recipients.get(position).getRecipient());
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
return v;
|
||||
}
|
||||
|
||||
private static List<RecipientWrapper> wrapExistingMembers(Collection<Recipient> recipients) {
|
||||
final LinkedList<RecipientWrapper> wrapperList = new LinkedList<>();
|
||||
for (Recipient recipient : recipients) {
|
||||
wrapperList.add(new RecipientWrapper(recipient, false, true));
|
||||
}
|
||||
return wrapperList;
|
||||
}
|
||||
|
||||
public void setOnRecipientDeletedListener(@Nullable OnRecipientDeletedListener listener) {
|
||||
onRecipientDeletedListener = listener;
|
||||
}
|
||||
|
||||
public interface OnRecipientDeletedListener {
|
||||
void onRecipientDeleted(Recipient recipient);
|
||||
}
|
||||
|
||||
public static class RecipientWrapper {
|
||||
private final Recipient recipient;
|
||||
private final boolean modifiable;
|
||||
private final boolean push;
|
||||
|
||||
public RecipientWrapper(final @NonNull Recipient recipient,
|
||||
final boolean modifiable,
|
||||
final boolean push)
|
||||
{
|
||||
this.recipient = recipient;
|
||||
this.modifiable = modifiable;
|
||||
this.push = push;
|
||||
}
|
||||
|
||||
public @NonNull Recipient getRecipient() {
|
||||
return recipient;
|
||||
}
|
||||
|
||||
public boolean isModifiable() {
|
||||
return modifiable;
|
||||
}
|
||||
|
||||
public boolean isPush() {
|
||||
return push;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,89 @@
|
||||
package org.thoughtcrime.securesms.util;
|
||||
|
||||
import android.app.Activity;
|
||||
import android.app.ActivityManager;
|
||||
import android.app.AlarmManager;
|
||||
import android.app.NotificationManager;
|
||||
import android.app.job.JobScheduler;
|
||||
import android.content.Context;
|
||||
import android.hardware.display.DisplayManager;
|
||||
import android.location.LocationManager;
|
||||
import android.media.AudioManager;
|
||||
import android.net.ConnectivityManager;
|
||||
import android.os.Build;
|
||||
import android.os.PowerManager;
|
||||
import android.os.Vibrator;
|
||||
import androidx.annotation.NonNull;
|
||||
import androidx.annotation.Nullable;
|
||||
import androidx.annotation.RequiresApi;
|
||||
import androidx.core.content.ContextCompat;
|
||||
|
||||
import android.telephony.SubscriptionManager;
|
||||
import android.telephony.TelephonyManager;
|
||||
import android.view.WindowManager;
|
||||
import android.view.accessibility.AccessibilityManager;
|
||||
import android.view.inputmethod.InputMethodManager;
|
||||
|
||||
public class ServiceUtil {
|
||||
public static InputMethodManager getInputMethodManager(Context context) {
|
||||
return (InputMethodManager)context.getSystemService(Context.INPUT_METHOD_SERVICE);
|
||||
}
|
||||
|
||||
public static WindowManager getWindowManager(Context context) {
|
||||
return (WindowManager) context.getSystemService(Activity.WINDOW_SERVICE);
|
||||
}
|
||||
|
||||
public static ConnectivityManager getConnectivityManager(Context context) {
|
||||
return (ConnectivityManager) context.getSystemService(Activity.CONNECTIVITY_SERVICE);
|
||||
}
|
||||
|
||||
public static NotificationManager getNotificationManager(Context context) {
|
||||
return (NotificationManager) context.getSystemService(Context.NOTIFICATION_SERVICE);
|
||||
}
|
||||
|
||||
public static TelephonyManager getTelephonyManager(Context context) {
|
||||
return (TelephonyManager)context.getSystemService(Context.TELEPHONY_SERVICE);
|
||||
}
|
||||
|
||||
public static AudioManager getAudioManager(Context context) {
|
||||
return (AudioManager)context.getSystemService(Context.AUDIO_SERVICE);
|
||||
}
|
||||
|
||||
public static PowerManager getPowerManager(Context context) {
|
||||
return (PowerManager)context.getSystemService(Context.POWER_SERVICE);
|
||||
}
|
||||
|
||||
public static AlarmManager getAlarmManager(Context context) {
|
||||
return (AlarmManager)context.getSystemService(Context.ALARM_SERVICE);
|
||||
}
|
||||
|
||||
public static Vibrator getVibrator(Context context) {
|
||||
return (Vibrator)context.getSystemService(Context.VIBRATOR_SERVICE);
|
||||
}
|
||||
|
||||
public static DisplayManager getDisplayManager(@NonNull Context context) {
|
||||
return (DisplayManager) context.getSystemService(Context.DISPLAY_SERVICE);
|
||||
}
|
||||
|
||||
public static AccessibilityManager getAccessibilityManager(@NonNull Context context) {
|
||||
return (AccessibilityManager) context.getSystemService(Context.ACCESSIBILITY_SERVICE);
|
||||
}
|
||||
|
||||
@RequiresApi(26)
|
||||
public static JobScheduler getJobScheduler(Context context) {
|
||||
return (JobScheduler) context.getSystemService(JobScheduler.class);
|
||||
}
|
||||
|
||||
@RequiresApi(api = Build.VERSION_CODES.LOLLIPOP_MR1)
|
||||
public static @Nullable SubscriptionManager getSubscriptionManager(@NonNull Context context) {
|
||||
return (SubscriptionManager) context.getSystemService(Context.TELEPHONY_SUBSCRIPTION_SERVICE);
|
||||
}
|
||||
|
||||
public static ActivityManager getActivityManager(@NonNull Context context) {
|
||||
return (ActivityManager) context.getSystemService(Context.ACTIVITY_SERVICE);
|
||||
}
|
||||
|
||||
public static LocationManager getLocationManager(@NonNull Context context) {
|
||||
return ContextCompat.getSystemService(context, LocationManager.class);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
package org.thoughtcrime.securesms.util;
|
||||
|
||||
import java.util.HashSet;
|
||||
import java.util.LinkedHashSet;
|
||||
import java.util.Set;
|
||||
|
||||
public final class SetUtil {
|
||||
private SetUtil() {}
|
||||
|
||||
public static <E> Set<E> intersection(Set<E> a, Set<E> b) {
|
||||
Set<E> intersection = new LinkedHashSet<>(a);
|
||||
intersection.retainAll(b);
|
||||
return intersection;
|
||||
}
|
||||
|
||||
public static <E> Set<E> difference(Set<E> a, Set<E> b) {
|
||||
Set<E> difference = new LinkedHashSet<>(a);
|
||||
difference.removeAll(b);
|
||||
return difference;
|
||||
}
|
||||
|
||||
public static <E> Set<E> union(Set<E>... sets) {
|
||||
Set<E> result = new LinkedHashSet<>();
|
||||
|
||||
for (Set<E> set : sets) {
|
||||
result.addAll(set);
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
package org.thoughtcrime.securesms.util;
|
||||
|
||||
import androidx.annotation.NonNull;
|
||||
|
||||
import com.google.i18n.phonenumbers.NumberParseException;
|
||||
import com.google.i18n.phonenumbers.PhoneNumberUtil;
|
||||
import com.google.i18n.phonenumbers.Phonenumber;
|
||||
import com.google.i18n.phonenumbers.ShortNumberInfo;
|
||||
|
||||
import org.thoughtcrime.securesms.logging.Log;
|
||||
|
||||
import java.util.HashSet;
|
||||
import java.util.Set;
|
||||
|
||||
public class ShortCodeUtil {
|
||||
|
||||
private static final String TAG = ShortCodeUtil.class.getSimpleName();
|
||||
|
||||
private static final Set<String> SHORT_COUNTRIES = new HashSet<String>() {{
|
||||
add("NU");
|
||||
add("TK");
|
||||
add("NC");
|
||||
add("AC");
|
||||
}};
|
||||
|
||||
public static boolean isShortCode(@NonNull String localNumber, @NonNull String number) {
|
||||
try {
|
||||
PhoneNumberUtil util = PhoneNumberUtil.getInstance();
|
||||
Phonenumber.PhoneNumber localNumberObject = util.parse(localNumber, null);
|
||||
String localCountryCode = util.getRegionCodeForNumber(localNumberObject);
|
||||
String bareNumber = number.replaceAll("[^0-9+]", "");
|
||||
|
||||
// libphonenumber seems incorrect for Russia and a few other countries with 4 digit short codes.
|
||||
if (bareNumber.length() <= 4 && !SHORT_COUNTRIES.contains(localCountryCode)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
Phonenumber.PhoneNumber shortCode = util.parse(number, localCountryCode);
|
||||
return ShortNumberInfo.getInstance().isPossibleShortNumberForRegion(shortCode, localCountryCode);
|
||||
} catch (NumberParseException e) {
|
||||
Log.w(TAG, e);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
/*
|
||||
* Copyright 2017 Google Inc.
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package org.thoughtcrime.securesms.util;
|
||||
|
||||
import androidx.lifecycle.LifecycleOwner;
|
||||
import androidx.lifecycle.MutableLiveData;
|
||||
import androidx.lifecycle.Observer;
|
||||
import androidx.annotation.MainThread;
|
||||
import androidx.annotation.NonNull;
|
||||
import androidx.annotation.Nullable;
|
||||
|
||||
import org.thoughtcrime.securesms.logging.Log;
|
||||
|
||||
import java.util.concurrent.atomic.AtomicBoolean;
|
||||
|
||||
/**
|
||||
* A lifecycle-aware observable that sends only new updates after subscription, used for events like
|
||||
* navigation and Snackbar messages.
|
||||
* <p>
|
||||
* This avoids a common problem with events: on configuration change (like rotation) an update
|
||||
* can be emitted if the observer is active. This LiveData only calls the observable if there's an
|
||||
* explicit call to setValue() or call().
|
||||
* <p>
|
||||
* Note that only one observer is going to be notified of changes.
|
||||
*/
|
||||
public class SingleLiveEvent<T> extends MutableLiveData<T> {
|
||||
|
||||
private static final String TAG = SingleLiveEvent.class.getSimpleName();
|
||||
|
||||
private final AtomicBoolean mPending = new AtomicBoolean(false);
|
||||
|
||||
@MainThread
|
||||
public void observe(@NonNull LifecycleOwner owner, @NonNull final Observer<? super T> observer) {
|
||||
if (hasActiveObservers()) {
|
||||
Log.w(TAG, "Multiple observers registered but only one will be notified of changes.");
|
||||
}
|
||||
|
||||
// Observe the internal MutableLiveData
|
||||
super.observe(owner, t -> {
|
||||
if (mPending.compareAndSet(true, false)) {
|
||||
observer.onChanged(t);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@MainThread
|
||||
public void setValue(@Nullable T t) {
|
||||
mPending.set(true);
|
||||
super.setValue(t);
|
||||
}
|
||||
|
||||
/**
|
||||
* Used for cases where T is Void, to make calls cleaner.
|
||||
*/
|
||||
@MainThread
|
||||
public void call() {
|
||||
setValue(null);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
/**
|
||||
* Copyright (C) 2011 Whisper Systems
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
*/
|
||||
package org.thoughtcrime.securesms.util;
|
||||
|
||||
import android.telephony.SmsMessage;
|
||||
import org.thoughtcrime.securesms.logging.Log;
|
||||
|
||||
public class SmsCharacterCalculator extends CharacterCalculator {
|
||||
|
||||
private static final String TAG = SmsCharacterCalculator.class.getSimpleName();
|
||||
|
||||
@Override
|
||||
public CharacterState calculateCharacters(String messageBody) {
|
||||
int[] length;
|
||||
int messagesSpent;
|
||||
int charactersSpent;
|
||||
int charactersRemaining;
|
||||
|
||||
try {
|
||||
length = SmsMessage.calculateLength(messageBody, false);
|
||||
messagesSpent = length[0];
|
||||
charactersSpent = length[1];
|
||||
charactersRemaining = length[2];
|
||||
} catch (NullPointerException e) {
|
||||
Log.w(TAG, e);
|
||||
messagesSpent = 1;
|
||||
charactersSpent = messageBody.length();
|
||||
charactersRemaining = 1000;
|
||||
}
|
||||
|
||||
int maxMessageSize;
|
||||
|
||||
if (messagesSpent > 0) {
|
||||
maxMessageSize = (charactersSpent + charactersRemaining) / messagesSpent;
|
||||
} else {
|
||||
maxMessageSize = (charactersSpent + charactersRemaining);
|
||||
}
|
||||
|
||||
return new CharacterState(messagesSpent, charactersRemaining, maxMessageSize, maxMessageSize);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,321 @@
|
||||
/*
|
||||
* Licensed to the Apache Software Foundation (ASF) under one
|
||||
* or more contributor license agreements. See the NOTICE file
|
||||
* distributed with this work for additional information
|
||||
* regarding copyright ownership. The ASF licenses this file
|
||||
* to you under the Apache License, Version 2.0 (the
|
||||
* "License"); you may not use this file except in compliance
|
||||
* with the License. You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing,
|
||||
* software distributed under the License is distributed on an
|
||||
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||
* KIND, either express or implied. See the License for the
|
||||
* specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
|
||||
package org.thoughtcrime.securesms.util;
|
||||
|
||||
import androidx.annotation.NonNull;
|
||||
|
||||
import java.lang.ref.ReferenceQueue;
|
||||
import java.lang.ref.SoftReference;
|
||||
import java.util.*;
|
||||
import java.util.concurrent.ConcurrentHashMap;
|
||||
import java.util.concurrent.ConcurrentLinkedQueue;
|
||||
import java.util.concurrent.locks.ReentrantLock;
|
||||
|
||||
/**
|
||||
* A <code><em>Soft</em>HashMap</code> is a memory-constrained map that stores its <em>values</em> in
|
||||
* {@link SoftReference SoftReference}s. (Contrast this with the JDK's
|
||||
* {@link WeakHashMap WeakHashMap}, which uses weak references for its <em>keys</em>, which is of little value if you
|
||||
* want the cache to auto-resize itself based on memory constraints).
|
||||
* <p/>
|
||||
* Having the values wrapped by soft references allows the cache to automatically reduce its size based on memory
|
||||
* limitations and garbage collection. This ensures that the cache will not cause memory leaks by holding strong
|
||||
* references to all of its values.
|
||||
* <p/>
|
||||
* This class is a generics-enabled Map based on initial ideas from Heinz Kabutz's and Sydney Redelinghuys's
|
||||
* <a href="http://www.javaspecialists.eu/archive/Issue015.html">publicly posted version (with their approval)</a>, with
|
||||
* continued modifications.
|
||||
* <p/>
|
||||
* This implementation is thread-safe and usable in concurrent environments.
|
||||
*
|
||||
* @since 1.0
|
||||
*/
|
||||
public class SoftHashMap<K, V> implements Map<K, V> {
|
||||
|
||||
/**
|
||||
* The default value of the RETENTION_SIZE attribute, equal to 100.
|
||||
*/
|
||||
private static final int DEFAULT_RETENTION_SIZE = 100;
|
||||
|
||||
/**
|
||||
* The internal HashMap that will hold the SoftReference.
|
||||
*/
|
||||
private final Map<K, SoftValue<V, K>> map;
|
||||
|
||||
/**
|
||||
* The number of strong references to hold internally, that is, the number of instances to prevent
|
||||
* from being garbage collected automatically (unlike other soft references).
|
||||
*/
|
||||
private final int RETENTION_SIZE;
|
||||
|
||||
/**
|
||||
* The FIFO list of strong references (not to be garbage collected), order of last access.
|
||||
*/
|
||||
private final Queue<V> strongReferences; //guarded by 'strongReferencesLock'
|
||||
private final ReentrantLock strongReferencesLock;
|
||||
|
||||
/**
|
||||
* Reference queue for cleared SoftReference objects.
|
||||
*/
|
||||
private final ReferenceQueue<? super V> queue;
|
||||
|
||||
/**
|
||||
* Creates a new SoftHashMap with a default retention size size of
|
||||
* {@link #DEFAULT_RETENTION_SIZE DEFAULT_RETENTION_SIZE} (100 entries).
|
||||
*
|
||||
* @see #SoftHashMap(int)
|
||||
*/
|
||||
public SoftHashMap() {
|
||||
this(DEFAULT_RETENTION_SIZE);
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a new SoftHashMap with the specified retention size.
|
||||
* <p/>
|
||||
* The retention size (n) is the total number of most recent entries in the map that will be strongly referenced
|
||||
* (ie 'retained') to prevent them from being eagerly garbage collected. That is, the point of a SoftHashMap is to
|
||||
* allow the garbage collector to remove as many entries from this map as it desires, but there will always be (n)
|
||||
* elements retained after a GC due to the strong references.
|
||||
* <p/>
|
||||
* Note that in a highly concurrent environments the exact total number of strong references may differ slightly
|
||||
* than the actual <code>retentionSize</code> value. This number is intended to be a best-effort retention low
|
||||
* water mark.
|
||||
*
|
||||
* @param retentionSize the total number of most recent entries in the map that will be strongly referenced
|
||||
* (retained), preventing them from being eagerly garbage collected by the JVM.
|
||||
*/
|
||||
@SuppressWarnings({"unchecked"})
|
||||
public SoftHashMap(int retentionSize) {
|
||||
super();
|
||||
RETENTION_SIZE = Math.max(0, retentionSize);
|
||||
queue = new ReferenceQueue<V>();
|
||||
strongReferencesLock = new ReentrantLock();
|
||||
map = new ConcurrentHashMap<K, SoftValue<V, K>>();
|
||||
strongReferences = new ConcurrentLinkedQueue<V>();
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a {@code SoftHashMap} backed by the specified {@code source}, with a default retention
|
||||
* size of {@link #DEFAULT_RETENTION_SIZE DEFAULT_RETENTION_SIZE} (100 entries).
|
||||
*
|
||||
* @param source the backing map to populate this {@code SoftHashMap}
|
||||
* @see #SoftHashMap(Map,int)
|
||||
*/
|
||||
public SoftHashMap(Map<K, V> source) {
|
||||
this(DEFAULT_RETENTION_SIZE);
|
||||
putAll(source);
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a {@code SoftHashMap} backed by the specified {@code source}, with the specified retention size.
|
||||
* <p/>
|
||||
* The retention size (n) is the total number of most recent entries in the map that will be strongly referenced
|
||||
* (ie 'retained') to prevent them from being eagerly garbage collected. That is, the point of a SoftHashMap is to
|
||||
* allow the garbage collector to remove as many entries from this map as it desires, but there will always be (n)
|
||||
* elements retained after a GC due to the strong references.
|
||||
* <p/>
|
||||
* Note that in a highly concurrent environments the exact total number of strong references may differ slightly
|
||||
* than the actual <code>retentionSize</code> value. This number is intended to be a best-effort retention low
|
||||
* water mark.
|
||||
*
|
||||
* @param source the backing map to populate this {@code SoftHashMap}
|
||||
* @param retentionSize the total number of most recent entries in the map that will be strongly referenced
|
||||
* (retained), preventing them from being eagerly garbage collected by the JVM.
|
||||
*/
|
||||
public SoftHashMap(Map<K, V> source, int retentionSize) {
|
||||
this(retentionSize);
|
||||
putAll(source);
|
||||
}
|
||||
|
||||
public V get(Object key) {
|
||||
processQueue();
|
||||
|
||||
V result = null;
|
||||
SoftValue<V, K> value = map.get(key);
|
||||
|
||||
if (value != null) {
|
||||
//unwrap the 'real' value from the SoftReference
|
||||
result = value.get();
|
||||
if (result == null) {
|
||||
//The wrapped value was garbage collected, so remove this entry from the backing map:
|
||||
//noinspection SuspiciousMethodCalls
|
||||
map.remove(key);
|
||||
} else {
|
||||
//Add this value to the beginning of the strong reference queue (FIFO).
|
||||
addToStrongReferences(result);
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
private void addToStrongReferences(V result) {
|
||||
strongReferencesLock.lock();
|
||||
try {
|
||||
strongReferences.add(result);
|
||||
trimStrongReferencesIfNecessary();
|
||||
} finally {
|
||||
strongReferencesLock.unlock();
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
//Guarded by the strongReferencesLock in the addToStrongReferences method
|
||||
|
||||
private void trimStrongReferencesIfNecessary() {
|
||||
//trim the strong ref queue if necessary:
|
||||
while (strongReferences.size() > RETENTION_SIZE) {
|
||||
strongReferences.poll();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Traverses the ReferenceQueue and removes garbage-collected SoftValue objects from the backing map
|
||||
* by looking them up using the SoftValue.key data member.
|
||||
*/
|
||||
private void processQueue() {
|
||||
SoftValue sv;
|
||||
while ((sv = (SoftValue) queue.poll()) != null) {
|
||||
//noinspection SuspiciousMethodCalls
|
||||
map.remove(sv.key); // we can access private data!
|
||||
}
|
||||
}
|
||||
|
||||
public boolean isEmpty() {
|
||||
processQueue();
|
||||
return map.isEmpty();
|
||||
}
|
||||
|
||||
public boolean containsKey(Object key) {
|
||||
processQueue();
|
||||
return map.containsKey(key);
|
||||
}
|
||||
|
||||
public boolean containsValue(Object value) {
|
||||
processQueue();
|
||||
Collection values = values();
|
||||
return values != null && values.contains(value);
|
||||
}
|
||||
|
||||
public void putAll(@NonNull Map<? extends K, ? extends V> m) {
|
||||
if (m == null || m.isEmpty()) {
|
||||
processQueue();
|
||||
return;
|
||||
}
|
||||
for (Map.Entry<? extends K, ? extends V> entry : m.entrySet()) {
|
||||
put(entry.getKey(), entry.getValue());
|
||||
}
|
||||
}
|
||||
|
||||
public @NonNull Set<K> keySet() {
|
||||
processQueue();
|
||||
return map.keySet();
|
||||
}
|
||||
|
||||
public @NonNull Collection<V> values() {
|
||||
processQueue();
|
||||
Collection<K> keys = map.keySet();
|
||||
if (keys.isEmpty()) {
|
||||
//noinspection unchecked
|
||||
return Collections.EMPTY_SET;
|
||||
}
|
||||
Collection<V> values = new ArrayList<V>(keys.size());
|
||||
for (K key : keys) {
|
||||
V v = get(key);
|
||||
if (v != null) {
|
||||
values.add(v);
|
||||
}
|
||||
}
|
||||
return values;
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a new entry, but wraps the value in a SoftValue instance to enable auto garbage collection.
|
||||
*/
|
||||
public V put(@NonNull K key, @NonNull V value) {
|
||||
processQueue(); // throw out garbage collected values first
|
||||
SoftValue<V, K> sv = new SoftValue<V, K>(value, key, queue);
|
||||
SoftValue<V, K> previous = map.put(key, sv);
|
||||
addToStrongReferences(value);
|
||||
return previous != null ? previous.get() : null;
|
||||
}
|
||||
|
||||
public V remove(Object key) {
|
||||
processQueue(); // throw out garbage collected values first
|
||||
SoftValue<V, K> raw = map.remove(key);
|
||||
return raw != null ? raw.get() : null;
|
||||
}
|
||||
|
||||
public void clear() {
|
||||
strongReferencesLock.lock();
|
||||
try {
|
||||
strongReferences.clear();
|
||||
} finally {
|
||||
strongReferencesLock.unlock();
|
||||
}
|
||||
processQueue(); // throw out garbage collected values
|
||||
map.clear();
|
||||
}
|
||||
|
||||
public int size() {
|
||||
processQueue(); // throw out garbage collected values first
|
||||
return map.size();
|
||||
}
|
||||
|
||||
public @NonNull Set<Map.Entry<K, V>> entrySet() {
|
||||
processQueue(); // throw out garbage collected values first
|
||||
Collection<K> keys = map.keySet();
|
||||
if (keys.isEmpty()) {
|
||||
//noinspection unchecked
|
||||
return Collections.EMPTY_SET;
|
||||
}
|
||||
|
||||
Map<K, V> kvPairs = new HashMap<K, V>(keys.size());
|
||||
for (K key : keys) {
|
||||
V v = get(key);
|
||||
if (v != null) {
|
||||
kvPairs.put(key, v);
|
||||
}
|
||||
}
|
||||
return kvPairs.entrySet();
|
||||
}
|
||||
|
||||
/**
|
||||
* We define our own subclass of SoftReference which contains
|
||||
* not only the value but also the key to make it easier to find
|
||||
* the entry in the HashMap after it's been garbage collected.
|
||||
*/
|
||||
private static class SoftValue<V, K> extends SoftReference<V> {
|
||||
|
||||
private final K key;
|
||||
|
||||
/**
|
||||
* Constructs a new instance, wrapping the value, key, and queue, as
|
||||
* required by the superclass.
|
||||
*
|
||||
* @param value the map value
|
||||
* @param key the map key
|
||||
* @param queue the soft reference queue to poll to determine if the entry had been reaped by the GC.
|
||||
*/
|
||||
private SoftValue(V value, K key, ReferenceQueue<? super V> queue) {
|
||||
super(value, queue);
|
||||
this.key = key;
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
package org.thoughtcrime.securesms.util;
|
||||
|
||||
import android.graphics.Typeface;
|
||||
import android.text.Spannable;
|
||||
import android.text.SpannableString;
|
||||
import android.text.style.AbsoluteSizeSpan;
|
||||
import android.text.style.ForegroundColorSpan;
|
||||
import android.text.style.RelativeSizeSpan;
|
||||
import android.text.style.StyleSpan;
|
||||
|
||||
public class SpanUtil {
|
||||
|
||||
public static CharSequence italic(CharSequence sequence) {
|
||||
return italic(sequence, sequence.length());
|
||||
}
|
||||
|
||||
public static CharSequence italic(CharSequence sequence, int length) {
|
||||
SpannableString spannable = new SpannableString(sequence);
|
||||
spannable.setSpan(new StyleSpan(android.graphics.Typeface.ITALIC), 0, length, Spannable.SPAN_EXCLUSIVE_EXCLUSIVE);
|
||||
return spannable;
|
||||
}
|
||||
|
||||
public static CharSequence small(CharSequence sequence) {
|
||||
SpannableString spannable = new SpannableString(sequence);
|
||||
spannable.setSpan(new RelativeSizeSpan(0.9f), 0, sequence.length(), Spannable.SPAN_EXCLUSIVE_EXCLUSIVE);
|
||||
return spannable;
|
||||
}
|
||||
|
||||
public static CharSequence ofSize(CharSequence sequence, int size) {
|
||||
SpannableString spannable = new SpannableString(sequence);
|
||||
spannable.setSpan(new AbsoluteSizeSpan(size, true), 0, sequence.length(), Spannable.SPAN_EXCLUSIVE_EXCLUSIVE);
|
||||
return spannable;
|
||||
}
|
||||
|
||||
public static CharSequence bold(CharSequence sequence) {
|
||||
SpannableString spannable = new SpannableString(sequence);
|
||||
spannable.setSpan(new StyleSpan(Typeface.BOLD), 0, sequence.length(), Spannable.SPAN_EXCLUSIVE_EXCLUSIVE);
|
||||
return spannable;
|
||||
}
|
||||
|
||||
public static CharSequence color(int color, CharSequence sequence) {
|
||||
SpannableString spannable = new SpannableString(sequence);
|
||||
spannable.setSpan(new ForegroundColorSpan(color), 0, sequence.length(), Spannable.SPAN_EXCLUSIVE_EXCLUSIVE);
|
||||
return spannable;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
package org.thoughtcrime.securesms.util;
|
||||
|
||||
import android.content.ContentValues;
|
||||
import android.database.Cursor;
|
||||
|
||||
import androidx.annotation.NonNull;
|
||||
|
||||
import net.sqlcipher.database.SQLiteDatabase;
|
||||
|
||||
import org.whispersystems.libsignal.util.Pair;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.Arrays;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Set;
|
||||
|
||||
public final class SqlUtil {
|
||||
private SqlUtil() {}
|
||||
|
||||
|
||||
public static boolean tableExists(@NonNull SQLiteDatabase db, @NonNull String table) {
|
||||
try (Cursor cursor = db.rawQuery("SELECT name FROM sqlite_master WHERE type=? AND name=?", new String[] { "table", table })) {
|
||||
return cursor != null && cursor.moveToNext();
|
||||
}
|
||||
}
|
||||
|
||||
public static boolean columnExists(@NonNull SQLiteDatabase db, @NonNull String table, @NonNull String column) {
|
||||
try (Cursor cursor = db.rawQuery("PRAGMA table_info(" + table + ")", null)) {
|
||||
int nameColumnIndex = cursor.getColumnIndexOrThrow("name");
|
||||
|
||||
while (cursor.moveToNext()) {
|
||||
String name = cursor.getString(nameColumnIndex);
|
||||
|
||||
if (name.equals(column)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns an updated query and args pairing that will only update rows that would *actually*
|
||||
* change. In other words, if {@link SQLiteDatabase#update(String, ContentValues, String, String[])}
|
||||
* returns > 0, then you know something *actually* changed.
|
||||
*/
|
||||
public static @NonNull Pair<String, String[]> buildTrueUpdateQuery(@NonNull String selection,
|
||||
@NonNull String[] args,
|
||||
@NonNull ContentValues contentValues)
|
||||
{
|
||||
StringBuilder qualifier = new StringBuilder();
|
||||
Set<Map.Entry<String, Object>> valueSet = contentValues.valueSet();
|
||||
List<String> fullArgs = new ArrayList<>(args.length + valueSet.size());
|
||||
|
||||
fullArgs.addAll(Arrays.asList(args));
|
||||
|
||||
int i = 0;
|
||||
|
||||
for (Map.Entry<String, Object> entry : valueSet) {
|
||||
if (entry.getValue() != null) {
|
||||
qualifier.append(entry.getKey()).append(" != ? OR ").append(entry.getKey()).append(" IS NULL");
|
||||
fullArgs.add(String.valueOf(entry.getValue()));
|
||||
} else {
|
||||
qualifier.append(entry.getKey()).append(" NOT NULL");
|
||||
}
|
||||
|
||||
if (i != valueSet.size() - 1) {
|
||||
qualifier.append(" OR ");
|
||||
}
|
||||
|
||||
i++;
|
||||
}
|
||||
|
||||
return new Pair<>("(" + selection + ") AND (" + qualifier + ")", fullArgs.toArray(new String[0]));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,236 @@
|
||||
package org.thoughtcrime.securesms.util;
|
||||
|
||||
import android.graphics.Canvas;
|
||||
import android.graphics.Rect;
|
||||
import android.view.LayoutInflater;
|
||||
import android.view.View;
|
||||
import android.view.ViewGroup;
|
||||
|
||||
import androidx.annotation.NonNull;
|
||||
import androidx.recyclerview.widget.LinearLayoutManager;
|
||||
import androidx.recyclerview.widget.RecyclerView;
|
||||
import androidx.recyclerview.widget.RecyclerView.ViewHolder;
|
||||
|
||||
import org.thoughtcrime.securesms.R;
|
||||
import org.thoughtcrime.securesms.logging.Log;
|
||||
|
||||
import java.util.HashMap;
|
||||
import java.util.Map;
|
||||
|
||||
/**
|
||||
* A sticky header decoration for android's RecyclerView.
|
||||
* Currently only supports LinearLayoutManager in VERTICAL orientation.
|
||||
*/
|
||||
public class StickyHeaderDecoration extends RecyclerView.ItemDecoration {
|
||||
|
||||
@SuppressWarnings("unused")
|
||||
private static final String TAG = Log.tag(StickyHeaderDecoration.class);
|
||||
|
||||
private final Map<Long, ViewHolder> headerCache;
|
||||
private final StickyHeaderAdapter adapter;
|
||||
private final boolean renderInline;
|
||||
private final boolean sticky;
|
||||
|
||||
/**
|
||||
* @param adapter the sticky header adapter to use
|
||||
*/
|
||||
public StickyHeaderDecoration(StickyHeaderAdapter adapter, boolean renderInline, boolean sticky) {
|
||||
this.adapter = adapter;
|
||||
this.headerCache = new HashMap<>();
|
||||
this.renderInline = renderInline;
|
||||
this.sticky = sticky;
|
||||
}
|
||||
|
||||
/**
|
||||
* {@inheritDoc}
|
||||
*/
|
||||
@Override
|
||||
public void getItemOffsets(@NonNull Rect outRect, @NonNull View view, @NonNull RecyclerView parent,
|
||||
@NonNull RecyclerView.State state)
|
||||
{
|
||||
int position = parent.getChildAdapterPosition(view);
|
||||
int headerHeight = 0;
|
||||
|
||||
if (position != RecyclerView.NO_POSITION && hasHeader(parent, adapter, position)) {
|
||||
View header = getHeader(parent, adapter, position).itemView;
|
||||
headerHeight = getHeaderHeightForLayout(header);
|
||||
}
|
||||
|
||||
outRect.set(0, headerHeight, 0, 0);
|
||||
}
|
||||
|
||||
protected boolean hasHeader(RecyclerView parent, StickyHeaderAdapter adapter, int adapterPos) {
|
||||
long headerId = adapter.getHeaderId(adapterPos);
|
||||
|
||||
if (headerId == StickyHeaderAdapter.NO_HEADER_ID) {
|
||||
return false;
|
||||
}
|
||||
|
||||
boolean isReverse = isReverseLayout(parent);
|
||||
int itemCount = ((RecyclerView.Adapter)adapter).getItemCount();
|
||||
|
||||
if ((isReverse && adapterPos == itemCount - 1 && adapter.getHeaderId(adapterPos) != -1) ||
|
||||
(!isReverse && adapterPos == 0))
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
int previous = adapterPos + (isReverse ? 1 : -1);
|
||||
long previousHeaderId = adapter.getHeaderId(previous);
|
||||
|
||||
return previousHeaderId != StickyHeaderAdapter.NO_HEADER_ID && headerId != previousHeaderId;
|
||||
}
|
||||
|
||||
protected @NonNull ViewHolder getHeader(RecyclerView parent, StickyHeaderAdapter adapter, int position) {
|
||||
final long key = adapter.getHeaderId(position);
|
||||
|
||||
ViewHolder headerHolder = headerCache.get(key);
|
||||
if (headerHolder == null) {
|
||||
|
||||
if (key != StickyHeaderAdapter.NO_HEADER_ID) {
|
||||
headerHolder = adapter.onCreateHeaderViewHolder(parent, position);
|
||||
//noinspection unchecked
|
||||
adapter.onBindHeaderViewHolder(headerHolder, position);
|
||||
}
|
||||
|
||||
if (headerHolder == null) {
|
||||
headerHolder = new RecyclerView.ViewHolder(LayoutInflater.from(parent.getContext()).inflate(R.layout.null_recyclerview_header, parent, false)) {
|
||||
};
|
||||
}
|
||||
|
||||
headerCache.put(key, headerHolder);
|
||||
}
|
||||
|
||||
final View header = headerHolder.itemView;
|
||||
|
||||
int widthSpec = View.MeasureSpec.makeMeasureSpec(parent.getWidth(), View.MeasureSpec.EXACTLY);
|
||||
int heightSpec = View.MeasureSpec.makeMeasureSpec(parent.getHeight(), View.MeasureSpec.UNSPECIFIED);
|
||||
|
||||
int childWidth = ViewGroup.getChildMeasureSpec(widthSpec,
|
||||
parent.getPaddingLeft() + parent.getPaddingRight(), header.getLayoutParams().width);
|
||||
int childHeight = ViewGroup.getChildMeasureSpec(heightSpec,
|
||||
parent.getPaddingTop() + parent.getPaddingBottom(), header.getLayoutParams().height);
|
||||
|
||||
header.measure(childWidth, childHeight);
|
||||
header.layout(0, 0, header.getMeasuredWidth(), header.getMeasuredHeight());
|
||||
|
||||
return headerHolder;
|
||||
}
|
||||
|
||||
/**
|
||||
* {@inheritDoc}
|
||||
*/
|
||||
@Override
|
||||
public void onDrawOver(@NonNull Canvas c, @NonNull RecyclerView parent, @NonNull RecyclerView.State state) {
|
||||
final int count = parent.getChildCount();
|
||||
|
||||
int start = 0;
|
||||
for (int layoutPos = 0; layoutPos < count; layoutPos++) {
|
||||
final View child = parent.getChildAt(translatedChildPosition(parent, layoutPos));
|
||||
|
||||
final int adapterPos = parent.getChildAdapterPosition(child);
|
||||
|
||||
final long key = adapter.getHeaderId(adapterPos);
|
||||
if (key == StickyHeaderAdapter.NO_HEADER_ID) {
|
||||
start = layoutPos + 1;
|
||||
}
|
||||
|
||||
if (adapterPos != RecyclerView.NO_POSITION && ((layoutPos == start && sticky) || hasHeader(parent, adapter, adapterPos))) {
|
||||
View header = getHeader(parent, adapter, adapterPos).itemView;
|
||||
c.save();
|
||||
final int left = child.getLeft();
|
||||
final int top = getHeaderTop(parent, child, header, adapterPos, layoutPos);
|
||||
c.translate(left, top);
|
||||
header.draw(c);
|
||||
c.restore();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
protected int getHeaderTop(RecyclerView parent, View child, View header, int adapterPos,
|
||||
int layoutPos)
|
||||
{
|
||||
int headerHeight = getHeaderHeightForLayout(header);
|
||||
int top = getChildY(child) - headerHeight;
|
||||
if (sticky && layoutPos == 0) {
|
||||
final int count = parent.getChildCount();
|
||||
final long currentId = adapter.getHeaderId(adapterPos);
|
||||
// find next view with header and compute the offscreen push if needed
|
||||
for (int i = 1; i < count; i++) {
|
||||
int adapterPosHere = parent.getChildAdapterPosition(parent.getChildAt(translatedChildPosition(parent, i)));
|
||||
if (adapterPosHere != RecyclerView.NO_POSITION) {
|
||||
long nextId = adapter.getHeaderId(adapterPosHere);
|
||||
if (nextId != currentId) {
|
||||
final View next = parent.getChildAt(translatedChildPosition(parent, i));
|
||||
final int offset = getChildY(next) - (headerHeight + getHeader(parent, adapter, adapterPosHere).itemView.getHeight());
|
||||
if (offset < 0) {
|
||||
return offset;
|
||||
} else {
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (sticky) top = Math.max(0, top);
|
||||
}
|
||||
|
||||
return top;
|
||||
}
|
||||
|
||||
private static int translatedChildPosition(RecyclerView parent, int position) {
|
||||
return isReverseLayout(parent) ? parent.getChildCount() - 1 - position : position;
|
||||
}
|
||||
|
||||
private static int getChildY(@NonNull View child) {
|
||||
return (int) child.getY();
|
||||
}
|
||||
|
||||
private int getHeaderHeightForLayout(View header) {
|
||||
return renderInline ? 0 : header.getHeight();
|
||||
}
|
||||
|
||||
private static boolean isReverseLayout(final RecyclerView parent) {
|
||||
return (parent.getLayoutManager() instanceof LinearLayoutManager) &&
|
||||
((LinearLayoutManager)parent.getLayoutManager()).getReverseLayout();
|
||||
}
|
||||
|
||||
/**
|
||||
* The adapter to assist the {@link StickyHeaderDecoration} in creating and binding the header views.
|
||||
*/
|
||||
public interface StickyHeaderAdapter<T extends RecyclerView.ViewHolder> {
|
||||
|
||||
long NO_HEADER_ID = -1L;
|
||||
|
||||
/**
|
||||
* Returns the header id for the item at the given position.
|
||||
* <p>
|
||||
* Return {@link #NO_HEADER_ID} if it does not have one.
|
||||
*
|
||||
* @param position the item position
|
||||
* @return the header id
|
||||
*/
|
||||
long getHeaderId(int position);
|
||||
|
||||
/**
|
||||
* Creates a new header ViewHolder.
|
||||
* <p>
|
||||
* Only called if getHeaderId returns {@link #NO_HEADER_ID}.
|
||||
*
|
||||
* @param parent the header's view parent
|
||||
* @param position position in the adapter
|
||||
* @return a view holder for the created view
|
||||
*/
|
||||
T onCreateHeaderViewHolder(ViewGroup parent, int position);
|
||||
|
||||
/**
|
||||
* Updates the header view to reflect the header data for the given position.
|
||||
*
|
||||
* @param viewHolder the header view holder
|
||||
* @param position the header's item position
|
||||
*/
|
||||
void onBindHeaderViewHolder(T viewHolder, int position);
|
||||
|
||||
int getItemCount();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
package org.thoughtcrime.securesms.util;
|
||||
|
||||
import androidx.annotation.NonNull;
|
||||
|
||||
import org.thoughtcrime.securesms.logging.Log;
|
||||
|
||||
import java.util.LinkedList;
|
||||
import java.util.List;
|
||||
|
||||
public class Stopwatch {
|
||||
|
||||
private final long startTime;
|
||||
private final String title;
|
||||
private final List<Split> splits;
|
||||
|
||||
public Stopwatch(@NonNull String title) {
|
||||
this.startTime = System.currentTimeMillis();
|
||||
this.title = title;
|
||||
this.splits = new LinkedList<>();
|
||||
}
|
||||
|
||||
public void split(@NonNull String label) {
|
||||
splits.add(new Split(System.currentTimeMillis(), label));
|
||||
}
|
||||
|
||||
public void stop(@NonNull String tag) {
|
||||
StringBuilder out = new StringBuilder();
|
||||
out.append("[").append(title).append("] ");
|
||||
|
||||
if (splits.size() > 0) {
|
||||
out.append(splits.get(0).label).append(": ");
|
||||
out.append(splits.get(0).time - startTime);
|
||||
out.append(" ");
|
||||
}
|
||||
|
||||
if (splits.size() > 1) {
|
||||
for (int i = 1; i < splits.size(); i++) {
|
||||
out.append(splits.get(i).label).append(": ");
|
||||
out.append(splits.get(i).time - splits.get(i - 1).time);
|
||||
out.append(" ");
|
||||
}
|
||||
|
||||
out.append("total: ").append(splits.get(splits.size() - 1).time - startTime);
|
||||
}
|
||||
|
||||
Log.d(tag, out.toString());
|
||||
}
|
||||
|
||||
private static class Split {
|
||||
final long time;
|
||||
final String label;
|
||||
|
||||
Split(long time, String label) {
|
||||
this.time = time;
|
||||
this.label = label;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
package org.thoughtcrime.securesms.util;
|
||||
|
||||
import android.content.Context;
|
||||
import android.os.Environment;
|
||||
import androidx.annotation.Nullable;
|
||||
|
||||
import org.thoughtcrime.securesms.database.NoExternalStorageException;
|
||||
|
||||
import java.io.File;
|
||||
|
||||
public class StorageUtil {
|
||||
|
||||
public static File getBackupDirectory() throws NoExternalStorageException {
|
||||
File storage = Environment.getExternalStorageDirectory();
|
||||
|
||||
if (!storage.canWrite()) {
|
||||
throw new NoExternalStorageException();
|
||||
}
|
||||
|
||||
File signal = new File(storage, "Signal");
|
||||
File backups = new File(signal, "Backups");
|
||||
|
||||
if (!backups.exists()) {
|
||||
if (!backups.mkdirs()) {
|
||||
throw new NoExternalStorageException("Unable to create backup directory...");
|
||||
}
|
||||
}
|
||||
|
||||
return backups;
|
||||
}
|
||||
|
||||
public static File getBackupCacheDirectory(Context context) {
|
||||
return context.getExternalCacheDir();
|
||||
}
|
||||
|
||||
private static File getSignalStorageDir() throws NoExternalStorageException {
|
||||
final File storage = Environment.getExternalStorageDirectory();
|
||||
|
||||
if (!storage.canWrite()) {
|
||||
throw new NoExternalStorageException();
|
||||
}
|
||||
|
||||
return storage;
|
||||
}
|
||||
|
||||
public static boolean canWriteInSignalStorageDir() {
|
||||
File storage;
|
||||
|
||||
try {
|
||||
storage = getSignalStorageDir();
|
||||
} catch (NoExternalStorageException e) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return storage.canWrite();
|
||||
}
|
||||
|
||||
public static File getLegacyBackupDirectory() throws NoExternalStorageException {
|
||||
return getSignalStorageDir();
|
||||
}
|
||||
|
||||
public static File getVideoDir() throws NoExternalStorageException {
|
||||
return new File(getSignalStorageDir(), Environment.DIRECTORY_MOVIES);
|
||||
}
|
||||
|
||||
public static File getAudioDir() throws NoExternalStorageException {
|
||||
return new File(getSignalStorageDir(), Environment.DIRECTORY_MUSIC);
|
||||
}
|
||||
|
||||
public static File getImageDir() throws NoExternalStorageException {
|
||||
return new File(getSignalStorageDir(), Environment.DIRECTORY_PICTURES);
|
||||
}
|
||||
|
||||
public static File getDownloadDir() throws NoExternalStorageException {
|
||||
return new File(getSignalStorageDir(), Environment.DIRECTORY_DOWNLOADS);
|
||||
}
|
||||
|
||||
public static @Nullable String getCleanFileName(@Nullable String fileName) {
|
||||
if (fileName == null) return null;
|
||||
|
||||
fileName = fileName.replace('\u202D', '\uFFFD');
|
||||
fileName = fileName.replace('\u202E', '\uFFFD');
|
||||
|
||||
return fileName;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
/**
|
||||
* Copyright (C) 2014 Open Whisper Systems
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
package org.thoughtcrime.securesms.util;
|
||||
|
||||
import java.util.concurrent.Callable;
|
||||
import java.util.concurrent.FutureTask;
|
||||
|
||||
/**
|
||||
* FutureTask with a reference identifier tag.
|
||||
*
|
||||
* @author Jake McGinty
|
||||
*/
|
||||
public class TaggedFutureTask<V> extends FutureTask<V> {
|
||||
private final Object tag;
|
||||
public TaggedFutureTask(Runnable runnable, V result, Object tag) {
|
||||
super(runnable, result);
|
||||
this.tag = tag;
|
||||
}
|
||||
|
||||
public TaggedFutureTask(Callable<V> callable, Object tag) {
|
||||
super(callable);
|
||||
this.tag = tag;
|
||||
}
|
||||
|
||||
public Object getTag() {
|
||||
return tag;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
package org.thoughtcrime.securesms.util;
|
||||
|
||||
import android.content.Context;
|
||||
import android.content.res.Configuration;
|
||||
import android.net.ConnectivityManager;
|
||||
import androidx.annotation.NonNull;
|
||||
import android.telephony.TelephonyManager;
|
||||
|
||||
import org.thoughtcrime.securesms.logging.Log;
|
||||
|
||||
import java.util.Locale;
|
||||
|
||||
public class TelephonyUtil {
|
||||
private static final String TAG = TelephonyUtil.class.getSimpleName();
|
||||
|
||||
public static TelephonyManager getManager(final Context context) {
|
||||
return (TelephonyManager)context.getSystemService(Context.TELEPHONY_SERVICE);
|
||||
}
|
||||
|
||||
public static String getMccMnc(final Context context) {
|
||||
final TelephonyManager tm = (TelephonyManager) context.getSystemService(Context.TELEPHONY_SERVICE);
|
||||
final int configMcc = context.getResources().getConfiguration().mcc;
|
||||
final int configMnc = context.getResources().getConfiguration().mnc;
|
||||
if (tm.getSimState() == TelephonyManager.SIM_STATE_READY) {
|
||||
Log.i(TAG, "Choosing MCC+MNC info from TelephonyManager.getSimOperator()");
|
||||
return tm.getSimOperator();
|
||||
} else if (tm.getPhoneType() != TelephonyManager.PHONE_TYPE_CDMA) {
|
||||
Log.i(TAG, "Choosing MCC+MNC info from TelephonyManager.getNetworkOperator()");
|
||||
return tm.getNetworkOperator();
|
||||
} else if (configMcc != 0 && configMnc != 0) {
|
||||
Log.i(TAG, "Choosing MCC+MNC info from current context's Configuration");
|
||||
return String.format(Locale.ROOT, "%03d%d",
|
||||
configMcc,
|
||||
configMnc == Configuration.MNC_ZERO ? 0 : configMnc);
|
||||
} else {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
public static String getApn(final Context context) {
|
||||
final ConnectivityManager cm = (ConnectivityManager) context.getSystemService(Context.CONNECTIVITY_SERVICE);
|
||||
return cm.getNetworkInfo(ConnectivityManager.TYPE_MOBILE_MMS).getExtraInfo();
|
||||
}
|
||||
|
||||
public static boolean isAnyPstnLineBusy(@NonNull Context context) {
|
||||
return getManager(context).getCallState() != TelephonyManager.CALL_STATE_IDLE;
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,86 @@
|
||||
package org.thoughtcrime.securesms.util;
|
||||
|
||||
import android.content.Context;
|
||||
import android.content.res.Resources;
|
||||
import android.graphics.Color;
|
||||
import androidx.annotation.AttrRes;
|
||||
import androidx.annotation.DimenRes;
|
||||
import androidx.annotation.NonNull;
|
||||
import androidx.annotation.Nullable;
|
||||
import androidx.annotation.StyleRes;
|
||||
import androidx.appcompat.view.ContextThemeWrapper;
|
||||
import androidx.core.content.ContextCompat;
|
||||
|
||||
import android.graphics.drawable.Drawable;
|
||||
import android.util.TypedValue;
|
||||
import android.view.LayoutInflater;
|
||||
|
||||
import org.thoughtcrime.securesms.R;
|
||||
|
||||
public class ThemeUtil {
|
||||
|
||||
public static boolean isDarkTheme(@NonNull Context context) {
|
||||
return getAttribute(context, R.attr.theme_type, "light").equals("dark");
|
||||
}
|
||||
|
||||
public static boolean getThemedBoolean(@NonNull Context context, @AttrRes int attr) {
|
||||
TypedValue typedValue = new TypedValue();
|
||||
Resources.Theme theme = context.getTheme();
|
||||
|
||||
if (theme.resolveAttribute(attr, typedValue, true)) {
|
||||
return typedValue.data != 0;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
public static int getThemedColor(@NonNull Context context, @AttrRes int attr) {
|
||||
TypedValue typedValue = new TypedValue();
|
||||
Resources.Theme theme = context.getTheme();
|
||||
|
||||
if (theme.resolveAttribute(attr, typedValue, true)) {
|
||||
return typedValue.data;
|
||||
}
|
||||
return Color.RED;
|
||||
}
|
||||
|
||||
public static @Nullable Drawable getThemedDrawable(@NonNull Context context, @AttrRes int attr) {
|
||||
TypedValue typedValue = new TypedValue();
|
||||
Resources.Theme theme = context.getTheme();
|
||||
|
||||
if (theme.resolveAttribute(attr, typedValue, true)) {
|
||||
return ContextCompat.getDrawable(context, typedValue.resourceId);
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
public static LayoutInflater getThemedInflater(@NonNull Context context, @NonNull LayoutInflater inflater, @StyleRes int theme) {
|
||||
Context contextThemeWrapper = new ContextThemeWrapper(context, theme);
|
||||
return inflater.cloneInContext(contextThemeWrapper);
|
||||
}
|
||||
|
||||
public static float getThemedDimen(@NonNull Context context, @AttrRes int attr) {
|
||||
TypedValue typedValue = new TypedValue();
|
||||
Resources.Theme theme = context.getTheme();
|
||||
|
||||
if (theme.resolveAttribute(attr, typedValue, true)) {
|
||||
return typedValue.getDimension(context.getResources().getDisplayMetrics());
|
||||
}
|
||||
|
||||
return 0;
|
||||
}
|
||||
|
||||
private static String getAttribute(Context context, int attribute, String defaultValue) {
|
||||
TypedValue outValue = new TypedValue();
|
||||
|
||||
if (context.getTheme().resolveAttribute(attribute, outValue, true)) {
|
||||
CharSequence charSequence = outValue.coerceToString();
|
||||
if (charSequence != null) {
|
||||
return charSequence.toString();
|
||||
}
|
||||
}
|
||||
|
||||
return defaultValue;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
package org.thoughtcrime.securesms.util;
|
||||
|
||||
import android.os.Handler;
|
||||
|
||||
/**
|
||||
* A class that will throttle the number of runnables executed to be at most once every specified
|
||||
* interval.
|
||||
*
|
||||
* Useful for performing actions in response to rapid user input where you want to take action on
|
||||
* the initial input but prevent follow-up spam.
|
||||
*
|
||||
* This is different from {@link Debouncer} in that it will run the first runnable immediately
|
||||
* instead of waiting for input to die down.
|
||||
*
|
||||
* See http://rxmarbles.com/#throttle
|
||||
*/
|
||||
public class Throttler {
|
||||
|
||||
private static final int WHAT = 8675309;
|
||||
|
||||
private final Handler handler;
|
||||
private final long threshold;
|
||||
|
||||
/**
|
||||
* @param threshold Only one runnable will be executed via {@link #publish(Runnable)} every
|
||||
* {@code threshold} milliseconds.
|
||||
*/
|
||||
public Throttler(long threshold) {
|
||||
this.handler = new Handler();
|
||||
this.threshold = threshold;
|
||||
}
|
||||
|
||||
public void publish(Runnable runnable) {
|
||||
if (handler.hasMessages(WHAT)) {
|
||||
return;
|
||||
}
|
||||
|
||||
runnable.run();
|
||||
handler.sendMessageDelayed(handler.obtainMessage(WHAT), threshold);
|
||||
}
|
||||
|
||||
public void clear() {
|
||||
handler.removeCallbacksAndMessages(null);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
package org.thoughtcrime.securesms.util;
|
||||
|
||||
import android.app.ProgressDialog;
|
||||
import android.content.Context;
|
||||
import android.os.AsyncTask;
|
||||
import android.widget.Toast;
|
||||
|
||||
import org.thoughtcrime.securesms.R;
|
||||
import org.thoughtcrime.securesms.database.DatabaseFactory;
|
||||
import org.thoughtcrime.securesms.database.ThreadDatabase;
|
||||
|
||||
public class Trimmer {
|
||||
|
||||
public static void trimAllThreads(Context context, int threadLengthLimit) {
|
||||
new TrimmingProgressTask(context).executeOnExecutor(AsyncTask.THREAD_POOL_EXECUTOR, threadLengthLimit);
|
||||
}
|
||||
|
||||
private static class TrimmingProgressTask extends AsyncTask<Integer, Integer, Void> implements ThreadDatabase.ProgressListener {
|
||||
private ProgressDialog progressDialog;
|
||||
private Context context;
|
||||
|
||||
public TrimmingProgressTask(Context context) {
|
||||
this.context = context;
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void onPreExecute() {
|
||||
progressDialog = new ProgressDialog(context);
|
||||
progressDialog.setProgressStyle(ProgressDialog.STYLE_HORIZONTAL);
|
||||
progressDialog.setCancelable(false);
|
||||
progressDialog.setIndeterminate(false);
|
||||
progressDialog.setTitle(R.string.trimmer__deleting);
|
||||
progressDialog.setMessage(context.getString(R.string.trimmer__deleting_old_messages));
|
||||
progressDialog.setMax(100);
|
||||
progressDialog.show();
|
||||
}
|
||||
|
||||
@Override
|
||||
protected Void doInBackground(Integer... params) {
|
||||
DatabaseFactory.getThreadDatabase(context).trimAllThreads(params[0], this);
|
||||
return null;
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void onProgressUpdate(Integer... progress) {
|
||||
double count = progress[1];
|
||||
double index = progress[0];
|
||||
|
||||
progressDialog.setProgress((int)Math.round((index / count) * 100.0));
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void onPostExecute(Void result) {
|
||||
progressDialog.dismiss();
|
||||
Toast.makeText(context,
|
||||
R.string.trimmer__old_messages_successfully_deleted,
|
||||
Toast.LENGTH_LONG).show();
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onProgress(int complete, int total) {
|
||||
this.publishProgress(complete, total);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
package org.thoughtcrime.securesms.util;
|
||||
|
||||
import android.content.Context;
|
||||
import android.text.TextUtils;
|
||||
|
||||
import androidx.annotation.NonNull;
|
||||
import androidx.annotation.Nullable;
|
||||
import androidx.annotation.WorkerThread;
|
||||
|
||||
import org.thoughtcrime.securesms.database.DatabaseFactory;
|
||||
import org.thoughtcrime.securesms.dependencies.ApplicationDependencies;
|
||||
import org.thoughtcrime.securesms.logging.Log;
|
||||
import org.thoughtcrime.securesms.recipients.Recipient;
|
||||
import org.thoughtcrime.securesms.recipients.RecipientId;
|
||||
import org.whispersystems.libsignal.util.guava.Optional;
|
||||
import org.whispersystems.signalservice.api.profiles.SignalServiceProfile;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.util.UUID;
|
||||
import java.util.regex.Pattern;
|
||||
|
||||
public class UsernameUtil {
|
||||
|
||||
private static final String TAG = Log.tag(UsernameUtil.class);
|
||||
|
||||
public static final int MIN_LENGTH = 4;
|
||||
public static final int MAX_LENGTH = 26;
|
||||
|
||||
private static final Pattern FULL_PATTERN = Pattern.compile("^[a-z_][a-z0-9_]{3,25}$", Pattern.CASE_INSENSITIVE);
|
||||
private static final Pattern DIGIT_START_PATTERN = Pattern.compile("^[0-9].*$");
|
||||
|
||||
public static boolean isValidUsernameForSearch(@Nullable String value) {
|
||||
return !TextUtils.isEmpty(value) && !DIGIT_START_PATTERN.matcher(value).matches();
|
||||
}
|
||||
|
||||
public static Optional<InvalidReason> checkUsername(@Nullable String value) {
|
||||
if (value == null) {
|
||||
return Optional.of(InvalidReason.TOO_SHORT);
|
||||
} else if (value.length() < MIN_LENGTH) {
|
||||
return Optional.of(InvalidReason.TOO_SHORT);
|
||||
} else if (value.length() > MAX_LENGTH) {
|
||||
return Optional.of(InvalidReason.TOO_LONG);
|
||||
} else if (DIGIT_START_PATTERN.matcher(value).matches()) {
|
||||
return Optional.of(InvalidReason.STARTS_WITH_NUMBER);
|
||||
} else if (!FULL_PATTERN.matcher(value).matches()) {
|
||||
return Optional.of(InvalidReason.INVALID_CHARACTERS);
|
||||
} else {
|
||||
return Optional.absent();
|
||||
}
|
||||
}
|
||||
|
||||
@WorkerThread
|
||||
public static @NonNull Optional<UUID> fetchUuidForUsername(@NonNull Context context, @NonNull String username) {
|
||||
Optional<RecipientId> localId = DatabaseFactory.getRecipientDatabase(context).getByUsername(username);
|
||||
|
||||
if (localId.isPresent()) {
|
||||
Recipient recipient = Recipient.resolved(localId.get());
|
||||
|
||||
if (recipient.getUuid().isPresent()) {
|
||||
Log.i(TAG, "Found username locally -- using associated UUID.");
|
||||
return recipient.getUuid();
|
||||
} else {
|
||||
Log.w(TAG, "Found username locally, but it had no associated UUID! Clearing it.");
|
||||
DatabaseFactory.getRecipientDatabase(context).clearUsernameIfExists(username);
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
Log.d(TAG, "No local user with this username. Searching remotely.");
|
||||
SignalServiceProfile profile = ApplicationDependencies.getSignalServiceMessageReceiver().retrieveProfileByUsername(username, Optional.absent());
|
||||
return Optional.fromNullable(profile.getUuid());
|
||||
} catch (IOException e) {
|
||||
return Optional.absent();
|
||||
}
|
||||
}
|
||||
|
||||
public enum InvalidReason {
|
||||
TOO_SHORT, TOO_LONG, INVALID_CHARACTERS, STARTS_WITH_NUMBER
|
||||
}
|
||||
}
|
||||
575
app/src/main/java/org/thoughtcrime/securesms/util/Util.java
Normal file
575
app/src/main/java/org/thoughtcrime/securesms/util/Util.java
Normal file
@@ -0,0 +1,575 @@
|
||||
/*
|
||||
* Copyright (C) 2011 Whisper Systems
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
*/
|
||||
package org.thoughtcrime.securesms.util;
|
||||
|
||||
import android.annotation.SuppressLint;
|
||||
import android.annotation.TargetApi;
|
||||
import android.app.ActivityManager;
|
||||
import android.content.ClipData;
|
||||
import android.content.ClipboardManager;
|
||||
import android.content.Context;
|
||||
import android.content.pm.PackageManager;
|
||||
import android.graphics.Typeface;
|
||||
import android.net.Uri;
|
||||
import android.os.Build.VERSION;
|
||||
import android.os.Build.VERSION_CODES;
|
||||
import android.os.Handler;
|
||||
import android.os.Looper;
|
||||
import android.provider.Telephony;
|
||||
import androidx.annotation.NonNull;
|
||||
import androidx.annotation.Nullable;
|
||||
import androidx.annotation.RequiresPermission;
|
||||
import android.telephony.TelephonyManager;
|
||||
import android.text.Spannable;
|
||||
import android.text.SpannableString;
|
||||
import android.text.TextUtils;
|
||||
import android.text.style.StyleSpan;
|
||||
|
||||
import com.google.android.mms.pdu_alt.CharacterSets;
|
||||
import com.google.android.mms.pdu_alt.EncodedStringValue;
|
||||
import com.google.i18n.phonenumbers.NumberParseException;
|
||||
import com.google.i18n.phonenumbers.PhoneNumberUtil;
|
||||
import com.google.i18n.phonenumbers.Phonenumber;
|
||||
|
||||
import org.thoughtcrime.securesms.BuildConfig;
|
||||
import org.thoughtcrime.securesms.components.ComposeText;
|
||||
import org.thoughtcrime.securesms.logging.Log;
|
||||
import org.thoughtcrime.securesms.mms.OutgoingLegacyMmsConnection;
|
||||
import org.whispersystems.libsignal.util.guava.Optional;
|
||||
|
||||
import java.io.ByteArrayOutputStream;
|
||||
import java.io.Closeable;
|
||||
import java.io.EOFException;
|
||||
import java.io.IOException;
|
||||
import java.io.InputStream;
|
||||
import java.io.OutputStream;
|
||||
import java.io.UnsupportedEncodingException;
|
||||
import java.security.SecureRandom;
|
||||
import java.text.DecimalFormat;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Arrays;
|
||||
import java.util.Collection;
|
||||
import java.util.Collections;
|
||||
import java.util.LinkedList;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.concurrent.CountDownLatch;
|
||||
import java.util.concurrent.ExecutorService;
|
||||
import java.util.concurrent.ThreadPoolExecutor;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
|
||||
public class Util {
|
||||
private static final String TAG = Util.class.getSimpleName();
|
||||
|
||||
private static volatile Handler handler;
|
||||
|
||||
public static <T> List<T> asList(T... elements) {
|
||||
List<T> result = new LinkedList<>();
|
||||
Collections.addAll(result, elements);
|
||||
return result;
|
||||
}
|
||||
|
||||
public static String join(String[] list, String delimiter) {
|
||||
return join(Arrays.asList(list), delimiter);
|
||||
}
|
||||
|
||||
public static String join(Collection<String> list, String delimiter) {
|
||||
StringBuilder result = new StringBuilder();
|
||||
int i = 0;
|
||||
|
||||
for (String item : list) {
|
||||
result.append(item);
|
||||
|
||||
if (++i < list.size())
|
||||
result.append(delimiter);
|
||||
}
|
||||
|
||||
return result.toString();
|
||||
}
|
||||
|
||||
public static String join(long[] list, String delimeter) {
|
||||
List<Long> boxed = new ArrayList<>(list.length);
|
||||
|
||||
for (int i = 0; i < list.length; i++) {
|
||||
boxed.add(list[i]);
|
||||
}
|
||||
|
||||
return join(boxed, delimeter);
|
||||
}
|
||||
|
||||
public static String join(List<Long> list, String delimeter) {
|
||||
StringBuilder sb = new StringBuilder();
|
||||
|
||||
for (int j = 0; j < list.size(); j++) {
|
||||
if (j != 0) sb.append(delimeter);
|
||||
sb.append(list.get(j));
|
||||
}
|
||||
|
||||
return sb.toString();
|
||||
}
|
||||
|
||||
public static ExecutorService newSingleThreadedLifoExecutor() {
|
||||
ThreadPoolExecutor executor = new ThreadPoolExecutor(1, 1, 0L, TimeUnit.MILLISECONDS, new LinkedBlockingLifoQueue<Runnable>());
|
||||
|
||||
executor.execute(() -> {
|
||||
// Process.setThreadPriority(Process.THREAD_PRIORITY_BACKGROUND);
|
||||
Thread.currentThread().setPriority(Thread.MIN_PRIORITY);
|
||||
});
|
||||
|
||||
return executor;
|
||||
}
|
||||
|
||||
public static boolean isEmpty(EncodedStringValue[] value) {
|
||||
return value == null || value.length == 0;
|
||||
}
|
||||
|
||||
public static boolean isEmpty(ComposeText value) {
|
||||
return value == null || value.getText() == null || TextUtils.isEmpty(value.getTextTrimmed());
|
||||
}
|
||||
|
||||
public static boolean isEmpty(Collection collection) {
|
||||
return collection == null || collection.isEmpty();
|
||||
}
|
||||
|
||||
public static <K, V> V getOrDefault(@NonNull Map<K, V> map, K key, V defaultValue) {
|
||||
return map.containsKey(key) ? map.get(key) : defaultValue;
|
||||
}
|
||||
|
||||
public static String getFirstNonEmpty(String... values) {
|
||||
for (String value : values) {
|
||||
if (!TextUtils.isEmpty(value)) {
|
||||
return value;
|
||||
}
|
||||
}
|
||||
return "";
|
||||
}
|
||||
|
||||
public static <E> List<List<E>> chunk(@NonNull List<E> list, int chunkSize) {
|
||||
List<List<E>> chunks = new ArrayList<>(list.size() / chunkSize);
|
||||
|
||||
for (int i = 0; i < list.size(); i += chunkSize) {
|
||||
List<E> chunk = list.subList(i, Math.min(list.size(), i + chunkSize));
|
||||
chunks.add(chunk);
|
||||
}
|
||||
|
||||
return chunks;
|
||||
}
|
||||
|
||||
public static CharSequence getBoldedString(String value) {
|
||||
SpannableString spanned = new SpannableString(value);
|
||||
spanned.setSpan(new StyleSpan(Typeface.BOLD), 0,
|
||||
spanned.length(),
|
||||
Spannable.SPAN_EXCLUSIVE_EXCLUSIVE);
|
||||
|
||||
return spanned;
|
||||
}
|
||||
|
||||
public static @NonNull String toIsoString(byte[] bytes) {
|
||||
try {
|
||||
return new String(bytes, CharacterSets.MIMENAME_ISO_8859_1);
|
||||
} catch (UnsupportedEncodingException e) {
|
||||
throw new AssertionError("ISO_8859_1 must be supported!");
|
||||
}
|
||||
}
|
||||
|
||||
public static byte[] toIsoBytes(String isoString) {
|
||||
try {
|
||||
return isoString.getBytes(CharacterSets.MIMENAME_ISO_8859_1);
|
||||
} catch (UnsupportedEncodingException e) {
|
||||
throw new AssertionError("ISO_8859_1 must be supported!");
|
||||
}
|
||||
}
|
||||
|
||||
public static byte[] toUtf8Bytes(String utf8String) {
|
||||
try {
|
||||
return utf8String.getBytes(CharacterSets.MIMENAME_UTF_8);
|
||||
} catch (UnsupportedEncodingException e) {
|
||||
throw new AssertionError("UTF_8 must be supported!");
|
||||
}
|
||||
}
|
||||
|
||||
public static void wait(Object lock, long timeout) {
|
||||
try {
|
||||
lock.wait(timeout);
|
||||
} catch (InterruptedException ie) {
|
||||
throw new AssertionError(ie);
|
||||
}
|
||||
}
|
||||
|
||||
public static void close(Closeable closeable) {
|
||||
try {
|
||||
closeable.close();
|
||||
} catch (IOException e) {
|
||||
Log.w(TAG, e);
|
||||
}
|
||||
}
|
||||
|
||||
public static long getStreamLength(InputStream in) throws IOException {
|
||||
byte[] buffer = new byte[4096];
|
||||
int totalSize = 0;
|
||||
|
||||
int read;
|
||||
|
||||
while ((read = in.read(buffer)) != -1) {
|
||||
totalSize += read;
|
||||
}
|
||||
|
||||
return totalSize;
|
||||
}
|
||||
|
||||
public static void readFully(InputStream in, byte[] buffer) throws IOException {
|
||||
readFully(in, buffer, buffer.length);
|
||||
}
|
||||
|
||||
public static void readFully(InputStream in, byte[] buffer, int len) throws IOException {
|
||||
int offset = 0;
|
||||
|
||||
for (;;) {
|
||||
int read = in.read(buffer, offset, len - offset);
|
||||
if (read == -1) throw new EOFException("Stream ended early");
|
||||
|
||||
if (read + offset < len) offset += read;
|
||||
else return;
|
||||
}
|
||||
}
|
||||
|
||||
public static byte[] readFully(InputStream in) throws IOException {
|
||||
ByteArrayOutputStream bout = new ByteArrayOutputStream();
|
||||
byte[] buffer = new byte[4096];
|
||||
int read;
|
||||
|
||||
while ((read = in.read(buffer)) != -1) {
|
||||
bout.write(buffer, 0, read);
|
||||
}
|
||||
|
||||
in.close();
|
||||
|
||||
return bout.toByteArray();
|
||||
}
|
||||
|
||||
public static String readFullyAsString(InputStream in) throws IOException {
|
||||
return new String(readFully(in));
|
||||
}
|
||||
|
||||
public static long copy(InputStream in, OutputStream out) throws IOException {
|
||||
byte[] buffer = new byte[8192];
|
||||
int read;
|
||||
long total = 0;
|
||||
|
||||
while ((read = in.read(buffer)) != -1) {
|
||||
out.write(buffer, 0, read);
|
||||
total += read;
|
||||
}
|
||||
|
||||
in.close();
|
||||
out.close();
|
||||
|
||||
return total;
|
||||
}
|
||||
|
||||
@RequiresPermission(anyOf = {
|
||||
android.Manifest.permission.READ_PHONE_STATE,
|
||||
android.Manifest.permission.READ_SMS,
|
||||
android.Manifest.permission.READ_PHONE_NUMBERS
|
||||
})
|
||||
@SuppressLint("MissingPermission")
|
||||
public static Optional<Phonenumber.PhoneNumber> getDeviceNumber(Context context) {
|
||||
try {
|
||||
final String localNumber = ((TelephonyManager)context.getSystemService(Context.TELEPHONY_SERVICE)).getLine1Number();
|
||||
final Optional<String> countryIso = getSimCountryIso(context);
|
||||
|
||||
if (TextUtils.isEmpty(localNumber)) return Optional.absent();
|
||||
if (!countryIso.isPresent()) return Optional.absent();
|
||||
|
||||
return Optional.fromNullable(PhoneNumberUtil.getInstance().parse(localNumber, countryIso.get()));
|
||||
} catch (NumberParseException e) {
|
||||
Log.w(TAG, e);
|
||||
return Optional.absent();
|
||||
}
|
||||
}
|
||||
|
||||
public static Optional<String> getSimCountryIso(Context context) {
|
||||
String simCountryIso = ((TelephonyManager)context.getSystemService(Context.TELEPHONY_SERVICE)).getSimCountryIso();
|
||||
return Optional.fromNullable(simCountryIso != null ? simCountryIso.toUpperCase() : null);
|
||||
}
|
||||
|
||||
public static <T> List<List<T>> partition(List<T> list, int partitionSize) {
|
||||
List<List<T>> results = new LinkedList<>();
|
||||
|
||||
for (int index=0;index<list.size();index+=partitionSize) {
|
||||
int subListSize = Math.min(partitionSize, list.size() - index);
|
||||
|
||||
results.add(list.subList(index, index + subListSize));
|
||||
}
|
||||
|
||||
return results;
|
||||
}
|
||||
|
||||
public static List<String> split(String source, String delimiter) {
|
||||
List<String> results = new LinkedList<>();
|
||||
|
||||
if (TextUtils.isEmpty(source)) {
|
||||
return results;
|
||||
}
|
||||
|
||||
String[] elements = source.split(delimiter);
|
||||
Collections.addAll(results, elements);
|
||||
|
||||
return results;
|
||||
}
|
||||
|
||||
public static byte[][] split(byte[] input, int firstLength, int secondLength) {
|
||||
byte[][] parts = new byte[2][];
|
||||
|
||||
parts[0] = new byte[firstLength];
|
||||
System.arraycopy(input, 0, parts[0], 0, firstLength);
|
||||
|
||||
parts[1] = new byte[secondLength];
|
||||
System.arraycopy(input, firstLength, parts[1], 0, secondLength);
|
||||
|
||||
return parts;
|
||||
}
|
||||
|
||||
public static byte[] combine(byte[]... elements) {
|
||||
try {
|
||||
ByteArrayOutputStream baos = new ByteArrayOutputStream();
|
||||
|
||||
for (byte[] element : elements) {
|
||||
baos.write(element);
|
||||
}
|
||||
|
||||
return baos.toByteArray();
|
||||
} catch (IOException e) {
|
||||
throw new AssertionError(e);
|
||||
}
|
||||
}
|
||||
|
||||
public static byte[] trim(byte[] input, int length) {
|
||||
byte[] result = new byte[length];
|
||||
System.arraycopy(input, 0, result, 0, result.length);
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
@SuppressLint("NewApi")
|
||||
public static boolean isDefaultSmsProvider(Context context){
|
||||
return context.getPackageName().equals(Telephony.Sms.getDefaultSmsPackage(context));
|
||||
}
|
||||
|
||||
/**
|
||||
* The app version.
|
||||
* <p>
|
||||
* This code should be used in all places that compare app versions rather than
|
||||
* {@link #getManifestApkVersion(Context)} or {@link BuildConfig#VERSION_CODE}.
|
||||
*/
|
||||
public static int getCanonicalVersionCode() {
|
||||
return BuildConfig.CANONICAL_VERSION_CODE;
|
||||
}
|
||||
|
||||
/**
|
||||
* {@link BuildConfig#VERSION_CODE} may not be the actual version due to ABI split code adding a
|
||||
* postfix after BuildConfig is generated.
|
||||
* <p>
|
||||
* However, in most cases you want to use {@link BuildConfig#CANONICAL_VERSION_CODE} via
|
||||
* {@link #getCanonicalVersionCode()}
|
||||
*/
|
||||
public static int getManifestApkVersion(Context context) {
|
||||
try {
|
||||
return context.getPackageManager().getPackageInfo(context.getPackageName(), 0).versionCode;
|
||||
} catch (PackageManager.NameNotFoundException e) {
|
||||
throw new AssertionError(e);
|
||||
}
|
||||
}
|
||||
|
||||
public static String getSecret(int size) {
|
||||
byte[] secret = getSecretBytes(size);
|
||||
return Base64.encodeBytes(secret);
|
||||
}
|
||||
|
||||
public static byte[] getSecretBytes(int size) {
|
||||
byte[] secret = new byte[size];
|
||||
getSecureRandom().nextBytes(secret);
|
||||
return secret;
|
||||
}
|
||||
|
||||
public static SecureRandom getSecureRandom() {
|
||||
return new SecureRandom();
|
||||
}
|
||||
|
||||
public static int getDaysTillBuildExpiry() {
|
||||
int age = (int) TimeUnit.MILLISECONDS.toDays(System.currentTimeMillis() - BuildConfig.BUILD_TIMESTAMP);
|
||||
return 90 - age;
|
||||
}
|
||||
|
||||
@TargetApi(VERSION_CODES.LOLLIPOP)
|
||||
public static boolean isMmsCapable(Context context) {
|
||||
return (VERSION.SDK_INT >= VERSION_CODES.LOLLIPOP) || OutgoingLegacyMmsConnection.isConnectionPossible(context);
|
||||
}
|
||||
|
||||
public static boolean isMainThread() {
|
||||
return Looper.myLooper() == Looper.getMainLooper();
|
||||
}
|
||||
|
||||
public static void assertMainThread() {
|
||||
if (!isMainThread()) {
|
||||
throw new AssertionError("Main-thread assertion failed.");
|
||||
}
|
||||
}
|
||||
|
||||
public static void postToMain(final @NonNull Runnable runnable) {
|
||||
getHandler().post(runnable);
|
||||
}
|
||||
|
||||
public static void runOnMain(final @NonNull Runnable runnable) {
|
||||
if (isMainThread()) runnable.run();
|
||||
else getHandler().post(runnable);
|
||||
}
|
||||
|
||||
public static void runOnMainDelayed(final @NonNull Runnable runnable, long delayMillis) {
|
||||
getHandler().postDelayed(runnable, delayMillis);
|
||||
}
|
||||
|
||||
public static void cancelRunnableOnMain(@NonNull Runnable runnable) {
|
||||
getHandler().removeCallbacks(runnable);
|
||||
}
|
||||
|
||||
public static void runOnMainSync(final @NonNull Runnable runnable) {
|
||||
if (isMainThread()) {
|
||||
runnable.run();
|
||||
} else {
|
||||
final CountDownLatch sync = new CountDownLatch(1);
|
||||
runOnMain(() -> {
|
||||
try {
|
||||
runnable.run();
|
||||
} finally {
|
||||
sync.countDown();
|
||||
}
|
||||
});
|
||||
try {
|
||||
sync.await();
|
||||
} catch (InterruptedException ie) {
|
||||
throw new AssertionError(ie);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public static <T> T getRandomElement(T[] elements) {
|
||||
return elements[new SecureRandom().nextInt(elements.length)];
|
||||
}
|
||||
|
||||
public static boolean equals(@Nullable Object a, @Nullable Object b) {
|
||||
return a == b || (a != null && a.equals(b));
|
||||
}
|
||||
|
||||
public static int hashCode(@Nullable Object... objects) {
|
||||
return Arrays.hashCode(objects);
|
||||
}
|
||||
|
||||
public static @Nullable Uri uri(@Nullable String uri) {
|
||||
if (uri == null) return null;
|
||||
else return Uri.parse(uri);
|
||||
}
|
||||
|
||||
@TargetApi(VERSION_CODES.KITKAT)
|
||||
public static boolean isLowMemory(Context context) {
|
||||
ActivityManager activityManager = (ActivityManager) context.getSystemService(Context.ACTIVITY_SERVICE);
|
||||
|
||||
return (VERSION.SDK_INT >= VERSION_CODES.KITKAT && activityManager.isLowRamDevice()) ||
|
||||
activityManager.getLargeMemoryClass() <= 64;
|
||||
}
|
||||
|
||||
public static int clamp(int value, int min, int max) {
|
||||
return Math.min(Math.max(value, min), max);
|
||||
}
|
||||
|
||||
public static float clamp(float value, float min, float max) {
|
||||
return Math.min(Math.max(value, min), max);
|
||||
}
|
||||
|
||||
public static @Nullable String readTextFromClipboard(@NonNull Context context) {
|
||||
{
|
||||
ClipboardManager clipboardManager = (ClipboardManager)context.getSystemService(Context.CLIPBOARD_SERVICE);
|
||||
|
||||
if (clipboardManager.hasPrimaryClip() && clipboardManager.getPrimaryClip().getItemCount() > 0) {
|
||||
return clipboardManager.getPrimaryClip().getItemAt(0).getText().toString();
|
||||
} else {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public static void writeTextToClipboard(@NonNull Context context, @NonNull String text) {
|
||||
{
|
||||
ClipboardManager clipboardManager = (ClipboardManager)context.getSystemService(Context.CLIPBOARD_SERVICE);
|
||||
clipboardManager.setPrimaryClip(ClipData.newPlainText("Safety numbers", text));
|
||||
}
|
||||
}
|
||||
|
||||
public static int toIntExact(long value) {
|
||||
if ((int)value != value) {
|
||||
throw new ArithmeticException("integer overflow");
|
||||
}
|
||||
return (int)value;
|
||||
}
|
||||
|
||||
public static boolean isStringEquals(String first, String second) {
|
||||
if (first == null) return second == null;
|
||||
return first.equals(second);
|
||||
}
|
||||
|
||||
public static boolean isEquals(@Nullable Long first, long second) {
|
||||
return first != null && first == second;
|
||||
}
|
||||
|
||||
public static String getPrettyFileSize(long sizeBytes) {
|
||||
if (sizeBytes <= 0) return "0";
|
||||
|
||||
String[] units = new String[]{"B", "kB", "MB", "GB", "TB"};
|
||||
int digitGroups = (int) (Math.log10(sizeBytes) / 3);
|
||||
|
||||
return new DecimalFormat("#,##0.#").format(sizeBytes/Math.pow(1000, digitGroups)) + " " + units[digitGroups];
|
||||
}
|
||||
|
||||
public static void sleep(long millis) {
|
||||
try {
|
||||
Thread.sleep(millis);
|
||||
} catch (InterruptedException e) {
|
||||
throw new AssertionError(e);
|
||||
}
|
||||
}
|
||||
|
||||
private static Handler getHandler() {
|
||||
if (handler == null) {
|
||||
synchronized (Util.class) {
|
||||
if (handler == null) {
|
||||
handler = new Handler(Looper.getMainLooper());
|
||||
}
|
||||
}
|
||||
}
|
||||
return handler;
|
||||
}
|
||||
|
||||
public static <T> List<T> concatenatedList(List<T> first, List<T> second) {
|
||||
final List<T> concat = new ArrayList<>(first.size() + second.size());
|
||||
|
||||
concat.addAll(first);
|
||||
concat.addAll(second);
|
||||
|
||||
return concat;
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
package org.thoughtcrime.securesms.util;
|
||||
|
||||
import android.content.Context;
|
||||
import android.content.Intent;
|
||||
import androidx.annotation.NonNull;
|
||||
import android.text.style.ClickableSpan;
|
||||
import android.view.View;
|
||||
|
||||
import org.thoughtcrime.securesms.VerifyIdentityActivity;
|
||||
import org.thoughtcrime.securesms.crypto.IdentityKeyParcelable;
|
||||
import org.thoughtcrime.securesms.database.documents.IdentityKeyMismatch;
|
||||
import org.thoughtcrime.securesms.recipients.RecipientId;
|
||||
import org.whispersystems.libsignal.IdentityKey;
|
||||
|
||||
public class VerifySpan extends ClickableSpan {
|
||||
|
||||
private final Context context;
|
||||
private final RecipientId recipientId;
|
||||
private final IdentityKey identityKey;
|
||||
|
||||
public VerifySpan(@NonNull Context context, @NonNull IdentityKeyMismatch mismatch) {
|
||||
this.context = context;
|
||||
this.recipientId = mismatch.getRecipientId(context);
|
||||
this.identityKey = mismatch.getIdentityKey();
|
||||
}
|
||||
|
||||
public VerifySpan(@NonNull Context context, @NonNull RecipientId recipientId, @NonNull IdentityKey identityKey) {
|
||||
this.context = context;
|
||||
this.recipientId = recipientId;
|
||||
this.identityKey = identityKey;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onClick(@NonNull View widget) {
|
||||
Intent intent = new Intent(context, VerifyIdentityActivity.class);
|
||||
intent.putExtra(VerifyIdentityActivity.RECIPIENT_EXTRA, recipientId);
|
||||
intent.putExtra(VerifyIdentityActivity.IDENTITY_EXTRA, new IdentityKeyParcelable(identityKey));
|
||||
intent.putExtra(VerifyIdentityActivity.VERIFIED_EXTRA, false);
|
||||
context.startActivity(intent);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
package org.thoughtcrime.securesms.util;
|
||||
|
||||
import android.content.Context;
|
||||
import androidx.annotation.NonNull;
|
||||
|
||||
import java.io.IOException;
|
||||
|
||||
public class VersionTracker {
|
||||
|
||||
public static int getLastSeenVersion(@NonNull Context context) {
|
||||
return TextSecurePreferences.getLastVersionCode(context);
|
||||
}
|
||||
|
||||
public static void updateLastSeenVersion(@NonNull Context context) {
|
||||
try {
|
||||
int currentVersionCode = Util.getCanonicalVersionCode();
|
||||
TextSecurePreferences.setLastVersionCode(context, currentVersionCode);
|
||||
} catch (IOException ioe) {
|
||||
throw new AssertionError(ioe);
|
||||
}
|
||||
}
|
||||
}
|
||||
264
app/src/main/java/org/thoughtcrime/securesms/util/ViewUtil.java
Normal file
264
app/src/main/java/org/thoughtcrime/securesms/util/ViewUtil.java
Normal file
@@ -0,0 +1,264 @@
|
||||
/**
|
||||
* Copyright (C) 2015 Open Whisper Systems
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
*/
|
||||
package org.thoughtcrime.securesms.util;
|
||||
|
||||
import android.annotation.SuppressLint;
|
||||
import android.app.Activity;
|
||||
import android.content.Context;
|
||||
import android.graphics.drawable.Drawable;
|
||||
import android.os.Build.VERSION;
|
||||
import android.os.Build.VERSION_CODES;
|
||||
import androidx.annotation.IdRes;
|
||||
import androidx.annotation.LayoutRes;
|
||||
import androidx.annotation.NonNull;
|
||||
import androidx.annotation.Nullable;
|
||||
import androidx.core.view.ViewCompat;
|
||||
import androidx.interpolator.view.animation.FastOutSlowInInterpolator;
|
||||
import android.view.Gravity;
|
||||
import android.view.LayoutInflater;
|
||||
import android.view.View;
|
||||
import android.view.ViewGroup;
|
||||
import android.view.ViewStub;
|
||||
import android.view.animation.AlphaAnimation;
|
||||
import android.view.animation.Animation;
|
||||
import android.widget.LinearLayout.LayoutParams;
|
||||
import android.widget.TextView;
|
||||
|
||||
import org.thoughtcrime.securesms.util.concurrent.ListenableFuture;
|
||||
import org.thoughtcrime.securesms.util.concurrent.SettableFuture;
|
||||
import org.thoughtcrime.securesms.util.views.Stub;
|
||||
|
||||
public class ViewUtil {
|
||||
@SuppressWarnings("deprecation")
|
||||
public static void setBackground(final @NonNull View v, final @Nullable Drawable drawable) {
|
||||
if (VERSION.SDK_INT >= VERSION_CODES.JELLY_BEAN) {
|
||||
v.setBackground(drawable);
|
||||
} else {
|
||||
v.setBackgroundDrawable(drawable);
|
||||
}
|
||||
}
|
||||
|
||||
public static void setY(final @NonNull View v, final int y) {
|
||||
if (VERSION.SDK_INT >= 11) {
|
||||
ViewCompat.setY(v, y);
|
||||
} else {
|
||||
ViewGroup.MarginLayoutParams params = (ViewGroup.MarginLayoutParams)v.getLayoutParams();
|
||||
params.topMargin = y;
|
||||
v.setLayoutParams(params);
|
||||
}
|
||||
}
|
||||
|
||||
public static float getY(final @NonNull View v) {
|
||||
if (VERSION.SDK_INT >= 11) {
|
||||
return ViewCompat.getY(v);
|
||||
} else {
|
||||
return ((ViewGroup.MarginLayoutParams)v.getLayoutParams()).topMargin;
|
||||
}
|
||||
}
|
||||
|
||||
public static void setX(final @NonNull View v, final int x) {
|
||||
if (VERSION.SDK_INT >= 11) {
|
||||
ViewCompat.setX(v, x);
|
||||
} else {
|
||||
ViewGroup.MarginLayoutParams params = (ViewGroup.MarginLayoutParams)v.getLayoutParams();
|
||||
params.leftMargin = x;
|
||||
v.setLayoutParams(params);
|
||||
}
|
||||
}
|
||||
|
||||
public static float getX(final @NonNull View v) {
|
||||
if (VERSION.SDK_INT >= 11) {
|
||||
return ViewCompat.getX(v);
|
||||
} else {
|
||||
return ((LayoutParams)v.getLayoutParams()).leftMargin;
|
||||
}
|
||||
}
|
||||
|
||||
public static void swapChildInPlace(ViewGroup parent, View toRemove, View toAdd, int defaultIndex) {
|
||||
int childIndex = parent.indexOfChild(toRemove);
|
||||
if (childIndex > -1) parent.removeView(toRemove);
|
||||
parent.addView(toAdd, childIndex > -1 ? childIndex : defaultIndex);
|
||||
}
|
||||
|
||||
@SuppressWarnings("unchecked")
|
||||
public static <T extends View> T inflateStub(@NonNull View parent, @IdRes int stubId) {
|
||||
return (T)((ViewStub)parent.findViewById(stubId)).inflate();
|
||||
}
|
||||
|
||||
@SuppressWarnings("unchecked")
|
||||
public static <T extends View> T findById(@NonNull View parent, @IdRes int resId) {
|
||||
return (T) parent.findViewById(resId);
|
||||
}
|
||||
|
||||
@SuppressWarnings("unchecked")
|
||||
public static <T extends View> T findById(@NonNull Activity parent, @IdRes int resId) {
|
||||
return (T) parent.findViewById(resId);
|
||||
}
|
||||
|
||||
public static <T extends View> Stub<T> findStubById(@NonNull Activity parent, @IdRes int resId) {
|
||||
return new Stub<T>((ViewStub)parent.findViewById(resId));
|
||||
}
|
||||
|
||||
private static Animation getAlphaAnimation(float from, float to, int duration) {
|
||||
final Animation anim = new AlphaAnimation(from, to);
|
||||
anim.setInterpolator(new FastOutSlowInInterpolator());
|
||||
anim.setDuration(duration);
|
||||
return anim;
|
||||
}
|
||||
|
||||
public static void fadeIn(final @NonNull View view, final int duration) {
|
||||
animateIn(view, getAlphaAnimation(0f, 1f, duration));
|
||||
}
|
||||
|
||||
public static ListenableFuture<Boolean> fadeOut(final @NonNull View view, final int duration) {
|
||||
return fadeOut(view, duration, View.GONE);
|
||||
}
|
||||
|
||||
public static ListenableFuture<Boolean> fadeOut(@NonNull View view, int duration, int visibility) {
|
||||
return animateOut(view, getAlphaAnimation(1f, 0f, duration), visibility);
|
||||
}
|
||||
|
||||
public static ListenableFuture<Boolean> animateOut(final @NonNull View view, final @NonNull Animation animation, final int visibility) {
|
||||
final SettableFuture future = new SettableFuture();
|
||||
if (view.getVisibility() == visibility) {
|
||||
future.set(true);
|
||||
} else {
|
||||
view.clearAnimation();
|
||||
animation.reset();
|
||||
animation.setStartTime(0);
|
||||
animation.setAnimationListener(new Animation.AnimationListener() {
|
||||
@Override
|
||||
public void onAnimationStart(Animation animation) {}
|
||||
|
||||
@Override
|
||||
public void onAnimationRepeat(Animation animation) {}
|
||||
|
||||
@Override
|
||||
public void onAnimationEnd(Animation animation) {
|
||||
view.setVisibility(visibility);
|
||||
future.set(true);
|
||||
}
|
||||
});
|
||||
view.startAnimation(animation);
|
||||
}
|
||||
return future;
|
||||
}
|
||||
|
||||
public static void animateIn(final @NonNull View view, final @NonNull Animation animation) {
|
||||
if (view.getVisibility() == View.VISIBLE) return;
|
||||
|
||||
view.clearAnimation();
|
||||
animation.reset();
|
||||
animation.setStartTime(0);
|
||||
view.setVisibility(View.VISIBLE);
|
||||
view.startAnimation(animation);
|
||||
}
|
||||
|
||||
@SuppressWarnings("unchecked")
|
||||
public static <T extends View> T inflate(@NonNull LayoutInflater inflater,
|
||||
@NonNull ViewGroup parent,
|
||||
@LayoutRes int layoutResId)
|
||||
{
|
||||
return (T)(inflater.inflate(layoutResId, parent, false));
|
||||
}
|
||||
|
||||
@SuppressLint("RtlHardcoded")
|
||||
public static void setTextViewGravityStart(final @NonNull TextView textView, @NonNull Context context) {
|
||||
if (VERSION.SDK_INT >= VERSION_CODES.JELLY_BEAN_MR1) {
|
||||
if (DynamicLanguage.getLayoutDirection(context) == View.LAYOUT_DIRECTION_RTL) {
|
||||
textView.setGravity(Gravity.RIGHT);
|
||||
} else {
|
||||
textView.setGravity(Gravity.LEFT);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public static void mirrorIfRtl(View view, Context context) {
|
||||
if (VERSION.SDK_INT >= VERSION_CODES.JELLY_BEAN_MR1 &&
|
||||
DynamicLanguage.getLayoutDirection(context) == View.LAYOUT_DIRECTION_RTL) {
|
||||
view.setScaleX(-1.0f);
|
||||
}
|
||||
}
|
||||
|
||||
public static int dpToPx(Context context, int dp) {
|
||||
return (int)((dp * context.getResources().getDisplayMetrics().density) + 0.5);
|
||||
}
|
||||
|
||||
public static void updateLayoutParams(@NonNull View view, int width, int height) {
|
||||
view.getLayoutParams().width = width;
|
||||
view.getLayoutParams().height = height;
|
||||
view.requestLayout();
|
||||
}
|
||||
|
||||
public static int getLeftMargin(@NonNull View view) {
|
||||
if (ViewCompat.getLayoutDirection(view) == ViewCompat.LAYOUT_DIRECTION_LTR) {
|
||||
return ((ViewGroup.MarginLayoutParams) view.getLayoutParams()).leftMargin;
|
||||
}
|
||||
return ((ViewGroup.MarginLayoutParams) view.getLayoutParams()).rightMargin;
|
||||
}
|
||||
|
||||
public static int getRightMargin(@NonNull View view) {
|
||||
if (ViewCompat.getLayoutDirection(view) == ViewCompat.LAYOUT_DIRECTION_LTR) {
|
||||
return ((ViewGroup.MarginLayoutParams) view.getLayoutParams()).rightMargin;
|
||||
}
|
||||
return ((ViewGroup.MarginLayoutParams) view.getLayoutParams()).leftMargin;
|
||||
}
|
||||
|
||||
public static void setLeftMargin(@NonNull View view, int margin) {
|
||||
if (ViewCompat.getLayoutDirection(view) == ViewCompat.LAYOUT_DIRECTION_LTR) {
|
||||
((ViewGroup.MarginLayoutParams) view.getLayoutParams()).leftMargin = margin;
|
||||
} else {
|
||||
((ViewGroup.MarginLayoutParams) view.getLayoutParams()).rightMargin = margin;
|
||||
}
|
||||
view.forceLayout();
|
||||
view.requestLayout();
|
||||
}
|
||||
|
||||
public static void setTopMargin(@NonNull View view, int margin) {
|
||||
((ViewGroup.MarginLayoutParams) view.getLayoutParams()).topMargin = margin;
|
||||
view.requestLayout();
|
||||
}
|
||||
|
||||
public static void setPaddingTop(@NonNull View view, int padding) {
|
||||
view.setPadding(view.getPaddingLeft(), padding, view.getPaddingRight(), view.getPaddingBottom());
|
||||
}
|
||||
|
||||
public static void setPaddingBottom(@NonNull View view, int padding) {
|
||||
view.setPadding(view.getPaddingLeft(), view.getPaddingTop(), view.getPaddingRight(), padding);
|
||||
}
|
||||
|
||||
public static boolean isPointInsideView(@NonNull View view, float x, float y) {
|
||||
int[] location = new int[2];
|
||||
|
||||
view.getLocationOnScreen(location);
|
||||
|
||||
int viewX = location[0];
|
||||
int viewY = location[1];
|
||||
|
||||
return x > viewX && x < viewX + view.getWidth() &&
|
||||
y > viewY && y < viewY + view.getHeight();
|
||||
}
|
||||
|
||||
public static int getStatusBarHeight(@NonNull View view) {
|
||||
int result = 0;
|
||||
int resourceId = view.getResources().getIdentifier("status_bar_height", "dimen", "android");
|
||||
if (resourceId > 0) {
|
||||
result = view.getResources().getDimensionPixelSize(resourceId);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
package org.thoughtcrime.securesms.util;
|
||||
|
||||
import android.content.Context;
|
||||
import android.os.PowerManager;
|
||||
import android.os.PowerManager.WakeLock;
|
||||
import androidx.annotation.NonNull;
|
||||
import androidx.annotation.Nullable;
|
||||
|
||||
import org.thoughtcrime.securesms.logging.Log;
|
||||
|
||||
public class WakeLockUtil {
|
||||
|
||||
private static final String TAG = WakeLockUtil.class.getSimpleName();
|
||||
|
||||
/**
|
||||
* Run a runnable with a wake lock. Ensures that the lock is safely acquired and released.
|
||||
*
|
||||
* @param tag will be prefixed with "signal:" if it does not already start with it.
|
||||
*/
|
||||
public static void runWithLock(@NonNull Context context, int lockType, long timeout, @NonNull String tag, @NonNull Runnable task) {
|
||||
WakeLock wakeLock = null;
|
||||
try {
|
||||
wakeLock = acquire(context, lockType, timeout, tag);
|
||||
task.run();
|
||||
} finally {
|
||||
if (wakeLock != null) {
|
||||
release(wakeLock, tag);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @param tag will be prefixed with "signal:" if it does not already start with it.
|
||||
*/
|
||||
public static WakeLock acquire(@NonNull Context context, int lockType, long timeout, @NonNull String tag) {
|
||||
tag = prefixTag(tag);
|
||||
try {
|
||||
PowerManager powerManager = ServiceUtil.getPowerManager(context);
|
||||
WakeLock wakeLock = powerManager.newWakeLock(lockType, tag);
|
||||
|
||||
wakeLock.acquire(timeout);
|
||||
Log.d(TAG, "Acquired wakelock with tag: " + tag);
|
||||
|
||||
return wakeLock;
|
||||
} catch (Exception e) {
|
||||
Log.w(TAG, "Failed to acquire wakelock with tag: " + tag, e);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @param tag will be prefixed with "signal:" if it does not already start with it.
|
||||
*/
|
||||
public static void release(@Nullable WakeLock wakeLock, @NonNull String tag) {
|
||||
tag = prefixTag(tag);
|
||||
try {
|
||||
if (wakeLock == null) {
|
||||
Log.d(TAG, "Wakelock was null. Skipping. Tag: " + tag);
|
||||
} else if (wakeLock.isHeld()) {
|
||||
wakeLock.release();
|
||||
Log.d(TAG, "Released wakelock with tag: " + tag);
|
||||
} else {
|
||||
Log.d(TAG, "Wakelock wasn't held at time of release: " + tag);
|
||||
}
|
||||
} catch (Exception e) {
|
||||
Log.w(TAG, "Failed to release wakelock with tag: " + tag, e);
|
||||
}
|
||||
}
|
||||
|
||||
private static String prefixTag(@NonNull String tag) {
|
||||
return tag.startsWith("signal:") ? tag : "signal:" + tag;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
package org.thoughtcrime.securesms.util;
|
||||
|
||||
import android.app.Activity;
|
||||
import android.os.Build;
|
||||
import android.view.View;
|
||||
import android.view.Window;
|
||||
|
||||
import androidx.annotation.NonNull;
|
||||
|
||||
public final class WindowUtil {
|
||||
|
||||
private WindowUtil() {
|
||||
}
|
||||
|
||||
public static void setLightNavigationBarFromTheme(@NonNull Activity activity) {
|
||||
if (Build.VERSION.SDK_INT < 27) return;
|
||||
|
||||
final boolean isLightNavigationBar = ThemeUtil.getThemedBoolean(activity, android.R.attr.windowLightNavigationBar);
|
||||
|
||||
if (isLightNavigationBar) setLightNavigationBar(activity.getWindow());
|
||||
else clearLightNavigationBar(activity.getWindow());
|
||||
}
|
||||
|
||||
public static void clearLightNavigationBar(@NonNull Window window) {
|
||||
if (Build.VERSION.SDK_INT < 27) return;
|
||||
|
||||
clearSystemUiFlags(window, View.SYSTEM_UI_FLAG_LIGHT_NAVIGATION_BAR);
|
||||
}
|
||||
|
||||
public static void setLightNavigationBar(@NonNull Window window) {
|
||||
if (Build.VERSION.SDK_INT < 27) return;
|
||||
|
||||
setSystemUiFlags(window, View.SYSTEM_UI_FLAG_LIGHT_NAVIGATION_BAR);
|
||||
}
|
||||
|
||||
public static void setLightStatusBarFromTheme(@NonNull Activity activity) {
|
||||
if (Build.VERSION.SDK_INT < 23) return;
|
||||
|
||||
final boolean isLightStatusBar = ThemeUtil.getThemedBoolean(activity, android.R.attr.windowLightStatusBar);
|
||||
|
||||
if (isLightStatusBar) setLightStatusBar(activity.getWindow());
|
||||
else clearLightStatusBar(activity.getWindow());
|
||||
}
|
||||
|
||||
public static void clearLightStatusBar(@NonNull Window window) {
|
||||
if (Build.VERSION.SDK_INT < 23) return;
|
||||
|
||||
clearSystemUiFlags(window, View.SYSTEM_UI_FLAG_LIGHT_STATUS_BAR);
|
||||
}
|
||||
|
||||
public static void setLightStatusBar(@NonNull Window window) {
|
||||
if (Build.VERSION.SDK_INT < 23) return;
|
||||
|
||||
setSystemUiFlags(window, View.SYSTEM_UI_FLAG_LIGHT_STATUS_BAR);
|
||||
}
|
||||
|
||||
private static void clearSystemUiFlags(@NonNull Window window, int flags) {
|
||||
View view = window.getDecorView();
|
||||
int uiFlags = view.getSystemUiVisibility();
|
||||
|
||||
uiFlags &= ~flags;
|
||||
view.setSystemUiVisibility(uiFlags);
|
||||
}
|
||||
|
||||
private static void setSystemUiFlags(@NonNull Window window, int flags) {
|
||||
View view = window.getDecorView();
|
||||
int uiFlags = view.getSystemUiVisibility();
|
||||
|
||||
uiFlags |= flags;
|
||||
view.setSystemUiVisibility(uiFlags);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
/**
|
||||
* Copyright (C) 2011 Whisper Systems
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
*/
|
||||
package org.thoughtcrime.securesms.util;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
public class WorkerThread extends Thread {
|
||||
|
||||
private final List<Runnable> workQueue;
|
||||
|
||||
public WorkerThread(List<Runnable> workQueue, String name) {
|
||||
super(name);
|
||||
this.workQueue = workQueue;
|
||||
}
|
||||
|
||||
private Runnable getWork() {
|
||||
synchronized (workQueue) {
|
||||
try {
|
||||
while (workQueue.isEmpty())
|
||||
workQueue.wait();
|
||||
|
||||
return workQueue.remove(0);
|
||||
} catch (InterruptedException ie) {
|
||||
throw new AssertionError(ie);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public void run() {
|
||||
for (;;)
|
||||
getWork().run();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
package org.thoughtcrime.securesms.util.adapter;
|
||||
|
||||
import android.view.View;
|
||||
import android.view.ViewGroup;
|
||||
|
||||
import androidx.annotation.NonNull;
|
||||
import androidx.recyclerview.widget.RecyclerView;
|
||||
|
||||
import java.util.Arrays;
|
||||
import java.util.List;
|
||||
|
||||
public final class FixedViewsAdapter extends RecyclerView.Adapter<RecyclerView.ViewHolder> {
|
||||
|
||||
private final List<View> viewList;
|
||||
|
||||
private boolean hidden;
|
||||
|
||||
public FixedViewsAdapter(@NonNull View... viewList) {
|
||||
this.viewList = Arrays.asList(viewList);
|
||||
}
|
||||
|
||||
@Override
|
||||
public int getItemCount() {
|
||||
return hidden ? 0 : viewList.size();
|
||||
}
|
||||
|
||||
/**
|
||||
* @return View type is the index.
|
||||
*/
|
||||
@Override
|
||||
public int getItemViewType(int position) {
|
||||
return position;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param viewType The index in the list of views.
|
||||
*/
|
||||
@Override
|
||||
public RecyclerView.ViewHolder onCreateViewHolder(@NonNull ViewGroup viewGroup, int viewType) {
|
||||
return new RecyclerView.ViewHolder(viewList.get(viewType)) {
|
||||
};
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onBindViewHolder(@NonNull RecyclerView.ViewHolder viewHolder, int position) {
|
||||
}
|
||||
|
||||
@Override
|
||||
public long getItemId(int position) {
|
||||
return position;
|
||||
}
|
||||
|
||||
public void hide() {
|
||||
setHidden(true);
|
||||
}
|
||||
|
||||
public void show() {
|
||||
setHidden(false);
|
||||
}
|
||||
|
||||
private void setHidden(boolean hidden) {
|
||||
if (this.hidden != hidden) {
|
||||
this.hidden = hidden;
|
||||
notifyDataSetChanged();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,292 @@
|
||||
/*
|
||||
* Copyright (C) 2017 Martijn van der Woude
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*
|
||||
* Original source: https://github.com/martijnvdwoude/recycler-view-merge-adapter
|
||||
*
|
||||
* This file has been modified by Signal.
|
||||
*/
|
||||
|
||||
package org.thoughtcrime.securesms.util.adapter;
|
||||
|
||||
import android.util.LongSparseArray;
|
||||
import android.util.SparseIntArray;
|
||||
import android.view.ViewGroup;
|
||||
|
||||
import androidx.annotation.NonNull;
|
||||
import androidx.recyclerview.widget.RecyclerView;
|
||||
|
||||
import java.util.LinkedList;
|
||||
import java.util.List;
|
||||
|
||||
public class RecyclerViewConcatenateAdapter extends RecyclerView.Adapter<RecyclerView.ViewHolder> {
|
||||
|
||||
private final List<ChildAdapter> adapters = new LinkedList<>();
|
||||
|
||||
private long nextUnassignedItemId;
|
||||
|
||||
/**
|
||||
* Map of global view type to local adapter.
|
||||
* <p>
|
||||
* Not the same as {@link #adapters}, it may have duplicates and may be in a different order.
|
||||
*/
|
||||
private final List<ChildAdapter> viewTypes = new LinkedList<>();
|
||||
|
||||
/** Observes a single sub adapter and maps the positions on the events to global positions. */
|
||||
private static class AdapterDataObserver extends RecyclerView.AdapterDataObserver {
|
||||
|
||||
private final RecyclerViewConcatenateAdapter mergeAdapter;
|
||||
private final RecyclerView.Adapter<RecyclerView.ViewHolder> adapter;
|
||||
|
||||
AdapterDataObserver(RecyclerViewConcatenateAdapter mergeAdapter, RecyclerView.Adapter<RecyclerView.ViewHolder> adapter) {
|
||||
this.mergeAdapter = mergeAdapter;
|
||||
this.adapter = adapter;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onChanged() {
|
||||
mergeAdapter.notifyDataSetChanged();
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onItemRangeChanged(int positionStart, int itemCount) {
|
||||
int subAdapterOffset = mergeAdapter.getSubAdapterFirstGlobalPosition(adapter);
|
||||
|
||||
mergeAdapter.notifyItemRangeChanged(subAdapterOffset + positionStart, itemCount);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onItemRangeInserted(int positionStart, int itemCount) {
|
||||
int subAdapterOffset = mergeAdapter.getSubAdapterFirstGlobalPosition(adapter);
|
||||
|
||||
mergeAdapter.notifyItemRangeInserted(subAdapterOffset + positionStart, itemCount);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onItemRangeRemoved(int positionStart, int itemCount) {
|
||||
int subAdapterOffset = mergeAdapter.getSubAdapterFirstGlobalPosition(adapter);
|
||||
|
||||
mergeAdapter.notifyItemRangeRemoved(subAdapterOffset + positionStart, itemCount);
|
||||
}
|
||||
}
|
||||
|
||||
private static class ChildAdapter {
|
||||
|
||||
final RecyclerView.Adapter<RecyclerView.ViewHolder> adapter;
|
||||
|
||||
/** Map of global view types to local view types */
|
||||
private final SparseIntArray globalViewTypesMap = new SparseIntArray();
|
||||
|
||||
/** Map of local view types to global view types */
|
||||
private final SparseIntArray localViewTypesMap = new SparseIntArray();
|
||||
|
||||
private final AdapterDataObserver adapterDataObserver;
|
||||
|
||||
/** Map of local ids to global ids. */
|
||||
private final LongSparseArray<Long> localItemIdMap = new LongSparseArray<>();
|
||||
|
||||
ChildAdapter(@NonNull RecyclerView.Adapter<RecyclerView.ViewHolder> adapter, @NonNull AdapterDataObserver adapterDataObserver) {
|
||||
this.adapter = adapter;
|
||||
this.adapterDataObserver = adapterDataObserver;
|
||||
|
||||
this.adapter.registerAdapterDataObserver(this.adapterDataObserver);
|
||||
}
|
||||
|
||||
int getGlobalItemViewType(int localPosition, int defaultValue) {
|
||||
int localViewType = adapter.getItemViewType(localPosition);
|
||||
int globalViewType = localViewTypesMap.get(localViewType, defaultValue);
|
||||
|
||||
if (globalViewType == defaultValue) {
|
||||
globalViewTypesMap.append(globalViewType, localViewType);
|
||||
localViewTypesMap.append(localViewType, globalViewType);
|
||||
}
|
||||
|
||||
return globalViewType;
|
||||
}
|
||||
|
||||
long getGlobalItemId(int localPosition, long defaultGlobalValue) {
|
||||
final long localItemId = adapter.getItemId(localPosition);
|
||||
|
||||
if (RecyclerView.NO_ID == localItemId) {
|
||||
return RecyclerView.NO_ID;
|
||||
}
|
||||
|
||||
final Long globalItemId = localItemIdMap.get(localItemId);
|
||||
|
||||
if (globalItemId == null) {
|
||||
localItemIdMap.put(localItemId, defaultGlobalValue);
|
||||
return defaultGlobalValue;
|
||||
}
|
||||
|
||||
return globalItemId;
|
||||
}
|
||||
|
||||
void unregister() {
|
||||
adapter.unregisterAdapterDataObserver(adapterDataObserver);
|
||||
}
|
||||
|
||||
RecyclerView.ViewHolder onCreateViewHolder(@NonNull ViewGroup viewGroup, int globalViewType) {
|
||||
int localViewType = globalViewTypesMap.get(globalViewType);
|
||||
|
||||
return adapter.onCreateViewHolder(viewGroup, localViewType);
|
||||
}
|
||||
}
|
||||
|
||||
static class ChildAdapterPositionPair {
|
||||
|
||||
final ChildAdapter childAdapter;
|
||||
final int localPosition;
|
||||
|
||||
ChildAdapterPositionPair(@NonNull ChildAdapter adapter, int position) {
|
||||
childAdapter = adapter;
|
||||
localPosition = position;
|
||||
}
|
||||
|
||||
RecyclerView.Adapter<RecyclerView.ViewHolder> getAdapter() {
|
||||
return childAdapter.adapter;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @param adapter Append an adapter to the list of adapters.
|
||||
*/
|
||||
public void addAdapter(@NonNull RecyclerView.Adapter<RecyclerView.ViewHolder> adapter) {
|
||||
addAdapter(adapters.size(), adapter);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param index The index at which to add an adapter to the list of adapters.
|
||||
* @param adapter The adapter to add.
|
||||
*/
|
||||
public void addAdapter(int index, @NonNull RecyclerView.Adapter<RecyclerView.ViewHolder> adapter) {
|
||||
AdapterDataObserver adapterDataObserver = new AdapterDataObserver(this, adapter);
|
||||
adapters.add(index, new ChildAdapter(adapter, adapterDataObserver));
|
||||
notifyDataSetChanged();
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear all adapters from the list of adapters.
|
||||
*/
|
||||
public void clearAdapters() {
|
||||
for (ChildAdapter childAdapter : adapters) {
|
||||
childAdapter.unregister();
|
||||
}
|
||||
|
||||
adapters.clear();
|
||||
notifyDataSetChanged();
|
||||
}
|
||||
|
||||
/**
|
||||
* Return a childAdapterPositionPair object for a given global position.
|
||||
*
|
||||
* @param globalPosition The global position in the entire set of items.
|
||||
* @return A childAdapterPositionPair object containing a reference to the adapter and the local
|
||||
* position in that adapter that corresponds to the given global position.
|
||||
*/
|
||||
@NonNull
|
||||
ChildAdapterPositionPair getLocalPosition(final int globalPosition) {
|
||||
int count = 0;
|
||||
|
||||
for (ChildAdapter childAdapter : adapters) {
|
||||
int newCount = count + childAdapter.adapter.getItemCount();
|
||||
|
||||
if (globalPosition < newCount) {
|
||||
return new ChildAdapterPositionPair(childAdapter, globalPosition - count);
|
||||
}
|
||||
|
||||
count = newCount;
|
||||
}
|
||||
|
||||
throw new AssertionError("Position out of range");
|
||||
}
|
||||
|
||||
@Override
|
||||
@NonNull
|
||||
public RecyclerView.ViewHolder onCreateViewHolder(@NonNull ViewGroup viewGroup, int viewType) {
|
||||
ChildAdapter childAdapter = viewTypes.get(viewType);
|
||||
if (childAdapter == null) {
|
||||
throw new AssertionError("Unknown view type");
|
||||
}
|
||||
|
||||
return childAdapter.onCreateViewHolder(viewGroup, viewType);
|
||||
}
|
||||
|
||||
/**
|
||||
* Return the first global position in the entire set of items for a given adapter.
|
||||
*
|
||||
* @param adapter The adapter for which to the return the first global position.
|
||||
* @return The first global position for the given adapter, or -1 if no such position could be found.
|
||||
*/
|
||||
private int getSubAdapterFirstGlobalPosition(@NonNull RecyclerView.Adapter adapter) {
|
||||
int count = 0;
|
||||
|
||||
for (ChildAdapter childAdapterWrapper : adapters) {
|
||||
RecyclerView.Adapter childAdapter = childAdapterWrapper.adapter;
|
||||
|
||||
if (childAdapter == adapter) {
|
||||
return count;
|
||||
}
|
||||
|
||||
count += childAdapter.getItemCount();
|
||||
}
|
||||
|
||||
throw new AssertionError("Adapter not found in list of adapters");
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onBindViewHolder(@NonNull RecyclerView.ViewHolder viewHolder, int position) {
|
||||
ChildAdapterPositionPair childAdapterPositionPair = getLocalPosition(position);
|
||||
RecyclerView.Adapter adapter = childAdapterPositionPair.getAdapter();
|
||||
//noinspection unchecked
|
||||
adapter.onBindViewHolder(viewHolder, childAdapterPositionPair.localPosition);
|
||||
}
|
||||
|
||||
@Override
|
||||
public int getItemViewType(int position) {
|
||||
int nextUnassignedViewType = viewTypes.size();
|
||||
ChildAdapterPositionPair localPosition = getLocalPosition(position);
|
||||
|
||||
int viewType = localPosition.childAdapter.getGlobalItemViewType(localPosition.localPosition, nextUnassignedViewType);
|
||||
|
||||
if (viewType == nextUnassignedViewType) {
|
||||
viewTypes.add(viewType, localPosition.childAdapter);
|
||||
}
|
||||
|
||||
return viewType;
|
||||
}
|
||||
|
||||
@Override
|
||||
public long getItemId(int position) {
|
||||
ChildAdapterPositionPair localPosition = getLocalPosition(position);
|
||||
|
||||
long itemId = localPosition.childAdapter.getGlobalItemId(localPosition.localPosition, nextUnassignedItemId);
|
||||
|
||||
if (itemId == nextUnassignedItemId) {
|
||||
nextUnassignedItemId++;
|
||||
}
|
||||
|
||||
return itemId;
|
||||
}
|
||||
|
||||
@Override
|
||||
public int getItemCount() {
|
||||
int count = 0;
|
||||
|
||||
for (ChildAdapter adapter : adapters) {
|
||||
count += adapter.adapter.getItemCount();
|
||||
}
|
||||
|
||||
return count;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
package org.thoughtcrime.securesms.util.adapter;
|
||||
|
||||
import android.view.ViewGroup;
|
||||
|
||||
import androidx.recyclerview.widget.RecyclerView;
|
||||
|
||||
import org.thoughtcrime.securesms.components.RecyclerViewFastScroller;
|
||||
import org.thoughtcrime.securesms.util.StickyHeaderDecoration;
|
||||
import org.whispersystems.libsignal.util.Pair;
|
||||
import org.whispersystems.libsignal.util.guava.Optional;
|
||||
|
||||
public final class RecyclerViewConcatenateAdapterStickyHeader extends RecyclerViewConcatenateAdapter
|
||||
implements StickyHeaderDecoration.StickyHeaderAdapter,
|
||||
RecyclerViewFastScroller.FastScrollAdapter
|
||||
{
|
||||
|
||||
@Override
|
||||
public long getHeaderId(int position) {
|
||||
return getForPosition(position).transform(p -> p.first().getHeaderId(p.second())).or(-1L);
|
||||
}
|
||||
|
||||
@Override
|
||||
public RecyclerView.ViewHolder onCreateHeaderViewHolder(ViewGroup parent, int position) {
|
||||
return getForPosition(position).transform(p -> p.first().onCreateHeaderViewHolder(parent, p.second())).orNull();
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onBindHeaderViewHolder(RecyclerView.ViewHolder viewHolder, int position) {
|
||||
Optional<Pair<StickyHeaderDecoration.StickyHeaderAdapter, Integer>> forPosition = getForPosition(position);
|
||||
|
||||
if (forPosition.isPresent()) {
|
||||
Pair<StickyHeaderDecoration.StickyHeaderAdapter, Integer> stickyHeaderAdapterIntegerPair = forPosition.get();
|
||||
//noinspection unchecked
|
||||
stickyHeaderAdapterIntegerPair.first().onBindHeaderViewHolder(viewHolder, stickyHeaderAdapterIntegerPair.second());
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public CharSequence getBubbleText(int position) {
|
||||
Optional<Pair<StickyHeaderDecoration.StickyHeaderAdapter, Integer>> forPosition = getForPosition(position);
|
||||
|
||||
return forPosition.transform(a -> {
|
||||
if (a.first() instanceof RecyclerViewFastScroller.FastScrollAdapter) {
|
||||
return ((RecyclerViewFastScroller.FastScrollAdapter) a.first()).getBubbleText(a.second());
|
||||
} else {
|
||||
return "";
|
||||
}
|
||||
}).or("");
|
||||
}
|
||||
|
||||
private Optional<Pair<StickyHeaderDecoration.StickyHeaderAdapter, Integer>> getForPosition(int position) {
|
||||
ChildAdapterPositionPair localAdapterPosition = getLocalPosition(position);
|
||||
RecyclerView.Adapter<? extends RecyclerView.ViewHolder> adapter = localAdapterPosition.getAdapter();
|
||||
|
||||
if (adapter instanceof StickyHeaderDecoration.StickyHeaderAdapter) {
|
||||
StickyHeaderDecoration.StickyHeaderAdapter sticky = (StickyHeaderDecoration.StickyHeaderAdapter) adapter;
|
||||
return Optional.of(new Pair<>(sticky, localAdapterPosition.localPosition));
|
||||
}
|
||||
return Optional.absent();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,142 @@
|
||||
package org.thoughtcrime.securesms.util.adapter;
|
||||
|
||||
import android.view.ViewGroup;
|
||||
|
||||
import androidx.annotation.NonNull;
|
||||
import androidx.annotation.Nullable;
|
||||
import androidx.recyclerview.widget.RecyclerView;
|
||||
|
||||
import com.annimon.stream.Stream;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* A {@link RecyclerView.Adapter} subclass that makes it easier to have sectioned content, where
|
||||
* you have header rows and content rows.
|
||||
*
|
||||
* @param <IdType> The type you'll use to generate stable IDs.
|
||||
* @param <SectionImpl> The subclass of {@link Section} you're using.
|
||||
*/
|
||||
public abstract class SectionedRecyclerViewAdapter<IdType, SectionImpl extends SectionedRecyclerViewAdapter.Section<IdType>> extends RecyclerView.Adapter {
|
||||
|
||||
private static final int TYPE_HEADER = 1;
|
||||
private static final int TYPE_CONTENT = 2;
|
||||
private static final int TYPE_EMPTY = 3;
|
||||
|
||||
private final StableIdGenerator<IdType> stableIdGenerator;
|
||||
|
||||
public SectionedRecyclerViewAdapter() {
|
||||
this.stableIdGenerator = new StableIdGenerator<>();
|
||||
setHasStableIds(true);
|
||||
}
|
||||
|
||||
protected @NonNull abstract List<SectionImpl> getSections();
|
||||
protected @NonNull abstract RecyclerView.ViewHolder createHeaderViewHolder(@NonNull ViewGroup parent);
|
||||
protected @NonNull abstract RecyclerView.ViewHolder createContentViewHolder(@NonNull ViewGroup parent);
|
||||
protected @Nullable abstract RecyclerView.ViewHolder createEmptyViewHolder(@NonNull ViewGroup viewGroup);
|
||||
protected abstract void bindViewHolder(@NonNull RecyclerView.ViewHolder holder, @NonNull SectionImpl section, int localPosition);
|
||||
|
||||
@Override
|
||||
public @NonNull RecyclerView.ViewHolder onCreateViewHolder(@NonNull ViewGroup viewGroup, int viewType) {
|
||||
switch (viewType) {
|
||||
case TYPE_HEADER:
|
||||
return createHeaderViewHolder(viewGroup);
|
||||
case TYPE_CONTENT:
|
||||
return createContentViewHolder(viewGroup);
|
||||
case TYPE_EMPTY:
|
||||
RecyclerView.ViewHolder holder = createEmptyViewHolder(viewGroup);
|
||||
if (holder == null) {
|
||||
throw new IllegalStateException("Expected an empty view holder, but got none!");
|
||||
}
|
||||
return holder;
|
||||
default:
|
||||
throw new AssertionError("Unexpected viewType! " + viewType);
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public long getItemId(int globalPosition) {
|
||||
for (SectionImpl section: getSections()) {
|
||||
if (section.handles(globalPosition)) {
|
||||
return section.getItemId(stableIdGenerator, globalPosition);
|
||||
}
|
||||
}
|
||||
throw new NoSectionException();
|
||||
}
|
||||
|
||||
@Override
|
||||
public int getItemViewType(int globalPosition) {
|
||||
for (SectionImpl section : getSections()) {
|
||||
if (section.handles(globalPosition)) {
|
||||
return section.getViewType(globalPosition);
|
||||
}
|
||||
}
|
||||
throw new NoSectionException();
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onBindViewHolder(@NonNull RecyclerView.ViewHolder holder, int globalPosition) {
|
||||
for (SectionImpl section : getSections()) {
|
||||
if (section.handles(globalPosition)) {
|
||||
bindViewHolder(holder, section, section.getLocalPosition(globalPosition));
|
||||
return;
|
||||
}
|
||||
}
|
||||
throw new NoSectionException();
|
||||
}
|
||||
|
||||
@Override
|
||||
public int getItemCount() {
|
||||
return Stream.of(getSections()).reduce(0, (sum, section) -> sum + section.size());
|
||||
}
|
||||
|
||||
/**
|
||||
* Represents a section of content in the adapter. Has a header and content.
|
||||
* @param <E> The type you'll use to generate stable IDs.
|
||||
*/
|
||||
public static abstract class Section<E> {
|
||||
|
||||
private final int offset;
|
||||
|
||||
public Section(int offset) {
|
||||
this.offset = offset;
|
||||
}
|
||||
|
||||
public abstract boolean hasEmptyState();
|
||||
public abstract int getContentSize();
|
||||
public abstract long getItemId(@NonNull StableIdGenerator<E> idGenerator, int globalPosition);
|
||||
|
||||
protected final int getLocalPosition(int globalPosition) {
|
||||
return globalPosition - offset;
|
||||
}
|
||||
|
||||
final int getViewType(int globalPosition) {
|
||||
int localPosition = getLocalPosition(globalPosition);
|
||||
|
||||
if (localPosition == 0) {
|
||||
return TYPE_HEADER;
|
||||
} else if (getContentSize() == 0) {
|
||||
return TYPE_EMPTY;
|
||||
} else {
|
||||
return TYPE_CONTENT;
|
||||
}
|
||||
}
|
||||
|
||||
final boolean handles(int globalPosition) {
|
||||
int localPosition = getLocalPosition(globalPosition);
|
||||
return localPosition >= 0 && localPosition < size();
|
||||
}
|
||||
|
||||
public final int size() {
|
||||
if (getContentSize() == 0 && hasEmptyState()) {
|
||||
return 2;
|
||||
} else if (getContentSize() == 0) {
|
||||
return 0;
|
||||
} else {
|
||||
return getContentSize() + 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static class NoSectionException extends IllegalStateException {}
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
package org.thoughtcrime.securesms.util.adapter;
|
||||
|
||||
import androidx.annotation.MainThread;
|
||||
import androidx.annotation.NonNull;
|
||||
|
||||
import androidx.recyclerview.widget.RecyclerView;
|
||||
|
||||
import java.util.HashMap;
|
||||
import java.util.Map;
|
||||
|
||||
/**
|
||||
* Useful for generate ID's to be used with
|
||||
* {@link RecyclerView.Adapter#getItemId(int)} when you otherwise don't
|
||||
* have a good way to generate an ID.
|
||||
*/
|
||||
public class StableIdGenerator<E> {
|
||||
|
||||
private final Map<E, Long> keys = new HashMap<>();
|
||||
|
||||
private long index = 1;
|
||||
|
||||
@MainThread
|
||||
public long getId(@NonNull E item) {
|
||||
if (keys.containsKey(item)) {
|
||||
return keys.get(item);
|
||||
}
|
||||
|
||||
long key = index++;
|
||||
keys.put(item, key);
|
||||
|
||||
return key;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
package org.thoughtcrime.securesms.util.concurrent;
|
||||
|
||||
import org.thoughtcrime.securesms.util.concurrent.ListenableFuture.Listener;
|
||||
|
||||
import java.util.concurrent.ExecutionException;
|
||||
|
||||
public abstract class AssertedSuccessListener<T> implements Listener<T> {
|
||||
@Override
|
||||
public void onFailure(ExecutionException e) {
|
||||
throw new AssertionError(e);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
package org.thoughtcrime.securesms.util.concurrent;
|
||||
|
||||
import java.util.concurrent.ExecutionException;
|
||||
import java.util.concurrent.Future;
|
||||
|
||||
public interface ListenableFuture<T> extends Future<T> {
|
||||
void addListener(Listener<T> listener);
|
||||
|
||||
public interface Listener<T> {
|
||||
public void onSuccess(T result);
|
||||
public void onFailure(ExecutionException e);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,137 @@
|
||||
package org.thoughtcrime.securesms.util.concurrent;
|
||||
|
||||
import java.util.LinkedList;
|
||||
import java.util.List;
|
||||
import java.util.concurrent.ExecutionException;
|
||||
import java.util.concurrent.Future;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
import java.util.concurrent.TimeoutException;
|
||||
|
||||
public class SettableFuture<T> implements ListenableFuture<T> {
|
||||
|
||||
private final List<Listener<T>> listeners = new LinkedList<>();
|
||||
|
||||
private boolean completed;
|
||||
private boolean canceled;
|
||||
private volatile T result;
|
||||
private volatile Throwable exception;
|
||||
|
||||
public SettableFuture() { }
|
||||
|
||||
public SettableFuture(T value) {
|
||||
this.result = value;
|
||||
this.completed = true;
|
||||
}
|
||||
|
||||
@Override
|
||||
public synchronized boolean cancel(boolean mayInterruptIfRunning) {
|
||||
if (!completed && !canceled) {
|
||||
canceled = true;
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
@Override
|
||||
public synchronized boolean isCancelled() {
|
||||
return canceled;
|
||||
}
|
||||
|
||||
@Override
|
||||
public synchronized boolean isDone() {
|
||||
return completed;
|
||||
}
|
||||
|
||||
public boolean set(T result) {
|
||||
synchronized (this) {
|
||||
if (completed || canceled) return false;
|
||||
|
||||
this.result = result;
|
||||
this.completed = true;
|
||||
|
||||
notifyAll();
|
||||
}
|
||||
|
||||
notifyAllListeners();
|
||||
return true;
|
||||
}
|
||||
|
||||
public boolean setException(Throwable throwable) {
|
||||
synchronized (this) {
|
||||
if (completed || canceled) return false;
|
||||
|
||||
this.exception = throwable;
|
||||
this.completed = true;
|
||||
|
||||
notifyAll();
|
||||
}
|
||||
|
||||
notifyAllListeners();
|
||||
return true;
|
||||
}
|
||||
|
||||
public void deferTo(ListenableFuture<T> other) {
|
||||
other.addListener(new Listener<T>() {
|
||||
@Override
|
||||
public void onSuccess(T result) {
|
||||
SettableFuture.this.set(result);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onFailure(ExecutionException e) {
|
||||
SettableFuture.this.setException(e.getCause());
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@Override
|
||||
public synchronized T get() throws InterruptedException, ExecutionException {
|
||||
while (!completed) wait();
|
||||
|
||||
if (exception != null) throw new ExecutionException(exception);
|
||||
else return result;
|
||||
}
|
||||
|
||||
@Override
|
||||
public synchronized T get(long timeout, TimeUnit unit)
|
||||
throws InterruptedException, ExecutionException, TimeoutException
|
||||
{
|
||||
long startTime = System.currentTimeMillis();
|
||||
|
||||
while (!completed && System.currentTimeMillis() - startTime > unit.toMillis(timeout)) {
|
||||
wait(unit.toMillis(timeout));
|
||||
}
|
||||
|
||||
if (!completed) throw new TimeoutException();
|
||||
else return get();
|
||||
}
|
||||
|
||||
@Override
|
||||
public void addListener(Listener<T> listener) {
|
||||
synchronized (this) {
|
||||
listeners.add(listener);
|
||||
|
||||
if (!completed) return;
|
||||
}
|
||||
|
||||
notifyListener(listener);
|
||||
}
|
||||
|
||||
private void notifyAllListeners() {
|
||||
List<Listener<T>> localListeners;
|
||||
|
||||
synchronized (this) {
|
||||
localListeners = new LinkedList<>(listeners);
|
||||
}
|
||||
|
||||
for (Listener<T> listener : localListeners) {
|
||||
notifyListener(listener);
|
||||
}
|
||||
}
|
||||
|
||||
private void notifyListener(Listener<T> listener) {
|
||||
if (exception != null) listener.onFailure(new ExecutionException(exception));
|
||||
else listener.onSuccess(result);
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user