mirror of
https://github.com/oxen-io/session-android.git
synced 2024-12-28 02:37:46 +00:00
Merge branch 'dev' into add-unregister
This commit is contained in:
commit
5c9dc36460
@ -399,12 +399,6 @@
|
||||
<action android:name="network.loki.securesms.RESTART" />
|
||||
</intent-filter>
|
||||
</receiver>
|
||||
<receiver android:name="org.thoughtcrime.securesms.service.LocalBackupListener"
|
||||
android:exported="false">
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.BOOT_COMPLETED" />
|
||||
</intent-filter>
|
||||
</receiver>
|
||||
<receiver android:name="org.thoughtcrime.securesms.service.PersistentConnectionBootListener"
|
||||
android:exported="true">
|
||||
<intent-filter>
|
||||
@ -438,17 +432,9 @@
|
||||
<action android:name="android.intent.action.BOOT_COMPLETED" />
|
||||
</intent-filter>
|
||||
</receiver>
|
||||
<service
|
||||
android:name="org.thoughtcrime.securesms.jobmanager.JobSchedulerScheduler$SystemService"
|
||||
android:enabled="@bool/enable_job_service"
|
||||
android:permission="android.permission.BIND_JOB_SERVICE"
|
||||
tools:targetApi="26" />
|
||||
<service
|
||||
android:name="org.thoughtcrime.securesms.jobmanager.KeepAliveService"
|
||||
android:enabled="@bool/enable_alarm_manager" />
|
||||
<receiver
|
||||
android:name="org.thoughtcrime.securesms.jobmanager.AlarmManagerScheduler$RetryReceiver"
|
||||
android:enabled="@bool/enable_alarm_manager" /> <!-- Probably don't need this one -->
|
||||
<uses-library
|
||||
android:name="com.sec.android.app.multiwindow"
|
||||
android:required="false" />
|
||||
|
@ -56,7 +56,6 @@ import org.signal.aesgcmprovider.AesGcmProvider;
|
||||
import org.thoughtcrime.securesms.components.TypingStatusSender;
|
||||
import org.thoughtcrime.securesms.crypto.KeyPairUtilities;
|
||||
import org.thoughtcrime.securesms.database.EmojiSearchDatabase;
|
||||
import org.thoughtcrime.securesms.database.JobDatabase;
|
||||
import org.thoughtcrime.securesms.database.LokiAPIDatabase;
|
||||
import org.thoughtcrime.securesms.database.Storage;
|
||||
import org.thoughtcrime.securesms.database.helpers.SQLCipherOpenHelper;
|
||||
@ -66,11 +65,7 @@ import org.thoughtcrime.securesms.dependencies.DatabaseModule;
|
||||
import org.thoughtcrime.securesms.emoji.EmojiSource;
|
||||
import org.thoughtcrime.securesms.groups.OpenGroupManager;
|
||||
import org.thoughtcrime.securesms.home.HomeActivity;
|
||||
import org.thoughtcrime.securesms.jobmanager.JobManager;
|
||||
import org.thoughtcrime.securesms.jobmanager.impl.JsonDataSerializer;
|
||||
import org.thoughtcrime.securesms.jobmanager.impl.NetworkConstraint;
|
||||
import org.thoughtcrime.securesms.jobs.FastJobStorage;
|
||||
import org.thoughtcrime.securesms.jobs.JobManagerFactories;
|
||||
import org.thoughtcrime.securesms.logging.AndroidLogger;
|
||||
import org.thoughtcrime.securesms.logging.PersistentLogger;
|
||||
import org.thoughtcrime.securesms.logging.UncaughtExceptionLogger;
|
||||
@ -111,7 +106,6 @@ import dagger.hilt.EntryPoints;
|
||||
import dagger.hilt.android.HiltAndroidApp;
|
||||
import kotlin.Unit;
|
||||
import kotlinx.coroutines.Job;
|
||||
import network.loki.messenger.BuildConfig;
|
||||
|
||||
/**
|
||||
* Will be called once when the TextSecure process is created.
|
||||
@ -131,7 +125,6 @@ public class ApplicationContext extends Application implements DefaultLifecycleO
|
||||
private ExpiringMessageManager expiringMessageManager;
|
||||
private TypingStatusRepository typingStatusRepository;
|
||||
private TypingStatusSender typingStatusSender;
|
||||
private JobManager jobManager;
|
||||
private ReadReceiptManager readReceiptManager;
|
||||
private ProfileManager profileManager;
|
||||
public MessageNotifier messageNotifier = null;
|
||||
@ -146,7 +139,6 @@ public class ApplicationContext extends Application implements DefaultLifecycleO
|
||||
@Inject LokiAPIDatabase lokiAPIDatabase;
|
||||
@Inject Storage storage;
|
||||
@Inject MessageDataProvider messageDataProvider;
|
||||
@Inject JobDatabase jobDatabase;
|
||||
@Inject TextSecurePreferences textSecurePreferences;
|
||||
@Inject PushManager pushManager;
|
||||
CallMessageProcessor callMessageProcessor;
|
||||
@ -166,10 +158,6 @@ public class ApplicationContext extends Application implements DefaultLifecycleO
|
||||
return (ApplicationContext) context.getApplicationContext();
|
||||
}
|
||||
|
||||
public TextSecurePreferences getPrefs() {
|
||||
return textSecurePreferences;
|
||||
}
|
||||
|
||||
public DatabaseComponent getDatabaseComponent() {
|
||||
return EntryPoints.get(getApplicationContext(), DatabaseComponent.class);
|
||||
}
|
||||
@ -225,7 +213,6 @@ public class ApplicationContext extends Application implements DefaultLifecycleO
|
||||
initializeProfileManager();
|
||||
initializePeriodicTasks();
|
||||
SSKEnvironment.Companion.configure(getTypingStatusRepository(), getReadReceiptManager(), getProfileManager(), messageNotifier, getExpiringMessageManager());
|
||||
initializeJobManager();
|
||||
initializeWebRtc();
|
||||
initializeBlobProvider();
|
||||
resubmitProfilePictureIfNeeded();
|
||||
@ -282,10 +269,6 @@ public class ApplicationContext extends Application implements DefaultLifecycleO
|
||||
LocaleParser.Companion.configure(new LocaleParseHelper());
|
||||
}
|
||||
|
||||
public JobManager getJobManager() {
|
||||
return jobManager;
|
||||
}
|
||||
|
||||
public ExpiringMessageManager getExpiringMessageManager() {
|
||||
return expiringMessageManager;
|
||||
}
|
||||
@ -348,16 +331,6 @@ public class ApplicationContext extends Application implements DefaultLifecycleO
|
||||
Thread.setDefaultUncaughtExceptionHandler(new UncaughtExceptionLogger(originalHandler));
|
||||
}
|
||||
|
||||
private void initializeJobManager() {
|
||||
this.jobManager = new JobManager(this, new JobManager.Configuration.Builder()
|
||||
.setDataSerializer(new JsonDataSerializer())
|
||||
.setJobFactories(JobManagerFactories.getJobFactories(this))
|
||||
.setConstraintFactories(JobManagerFactories.getConstraintFactories(this))
|
||||
.setConstraintObservers(JobManagerFactories.getConstraintObservers(this))
|
||||
.setJobStorage(new FastJobStorage(jobDatabase))
|
||||
.build());
|
||||
}
|
||||
|
||||
private void initializeExpiringMessageManager() {
|
||||
this.expiringMessageManager = new ExpiringMessageManager(this);
|
||||
}
|
||||
@ -380,10 +353,6 @@ public class ApplicationContext extends Application implements DefaultLifecycleO
|
||||
|
||||
private void initializePeriodicTasks() {
|
||||
BackgroundPollWorker.schedulePeriodic(this);
|
||||
|
||||
if (BuildConfig.PLAY_STORE_DISABLED) {
|
||||
// possibly add update apk job
|
||||
}
|
||||
}
|
||||
|
||||
private void initializeWebRtc() {
|
||||
|
@ -1,5 +1,6 @@
|
||||
package org.thoughtcrime.securesms;
|
||||
|
||||
import static android.os.Build.VERSION.SDK_INT;
|
||||
import static org.session.libsession.utilities.TextSecurePreferences.SELECTED_ACCENT_COLOR;
|
||||
|
||||
import android.app.ActivityManager;
|
||||
@ -18,6 +19,7 @@ import androidx.appcompat.app.AppCompatActivity;
|
||||
import org.session.libsession.utilities.TextSecurePreferences;
|
||||
import org.session.libsession.utilities.dynamiclanguage.DynamicLanguageActivityHelper;
|
||||
import org.session.libsession.utilities.dynamiclanguage.DynamicLanguageContextWrapper;
|
||||
import org.thoughtcrime.securesms.conversation.v2.WindowUtil;
|
||||
import org.thoughtcrime.securesms.util.ActivityUtilitiesKt;
|
||||
import org.thoughtcrime.securesms.util.ThemeState;
|
||||
import org.thoughtcrime.securesms.util.UiModeUtilities;
|
||||
@ -92,6 +94,11 @@ public abstract class BaseActionBarActivity extends AppCompatActivity {
|
||||
if (!currentThemeState.equals(ActivityUtilitiesKt.themeState(getPreferences()))) {
|
||||
recreate();
|
||||
}
|
||||
|
||||
// apply lightStatusBar manually as API 26 does not update properly via applyTheme
|
||||
// https://issuetracker.google.com/issues/65883460?pli=1
|
||||
if (SDK_INT >= 26 && SDK_INT <= 27) WindowUtil.setLightStatusBarFromTheme(this);
|
||||
if (SDK_INT == 27) WindowUtil.setLightNavigationBarFromTheme(this);
|
||||
}
|
||||
|
||||
@Override
|
||||
|
@ -7,6 +7,10 @@ import android.os.Build
|
||||
import android.os.Handler
|
||||
import android.provider.MediaStore
|
||||
import androidx.annotation.RequiresApi
|
||||
import org.session.libsignal.utilities.Log
|
||||
import org.thoughtcrime.securesms.jobmanager.impl.JsonDataSerializer
|
||||
|
||||
private const val TAG = "ScreenshotObserver"
|
||||
|
||||
class ScreenshotObserver(private val context: Context, handler: Handler, private val screenshotTriggered: ()->Unit): ContentObserver(handler) {
|
||||
|
||||
@ -31,22 +35,26 @@ class ScreenshotObserver(private val context: Context, handler: Handler, private
|
||||
val projection = arrayOf(
|
||||
MediaStore.Images.Media.DATA
|
||||
)
|
||||
context.contentResolver.query(
|
||||
uri,
|
||||
projection,
|
||||
null,
|
||||
null,
|
||||
null
|
||||
)?.use { cursor ->
|
||||
val dataColumn = cursor.getColumnIndex(MediaStore.Images.Media.DATA)
|
||||
while (cursor.moveToNext()) {
|
||||
val path = cursor.getString(dataColumn)
|
||||
if (path.contains("screenshot", true)) {
|
||||
if (cache.add(uri.hashCode())) {
|
||||
screenshotTriggered()
|
||||
try {
|
||||
context.contentResolver.query(
|
||||
uri,
|
||||
projection,
|
||||
null,
|
||||
null,
|
||||
null
|
||||
)?.use { cursor ->
|
||||
val dataColumn = cursor.getColumnIndex(MediaStore.Images.Media.DATA)
|
||||
while (cursor.moveToNext()) {
|
||||
val path = cursor.getString(dataColumn)
|
||||
if (path.contains("screenshot", true)) {
|
||||
if (cache.add(uri.hashCode())) {
|
||||
screenshotTriggered()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (e: SecurityException) {
|
||||
Log.e(TAG, e)
|
||||
}
|
||||
}
|
||||
|
||||
@ -56,28 +64,32 @@ class ScreenshotObserver(private val context: Context, handler: Handler, private
|
||||
MediaStore.Images.Media.DISPLAY_NAME,
|
||||
MediaStore.Images.Media.RELATIVE_PATH
|
||||
)
|
||||
context.contentResolver.query(
|
||||
uri,
|
||||
projection,
|
||||
null,
|
||||
null,
|
||||
null
|
||||
)?.use { cursor ->
|
||||
val relativePathColumn =
|
||||
cursor.getColumnIndex(MediaStore.Images.Media.RELATIVE_PATH)
|
||||
val displayNameColumn =
|
||||
cursor.getColumnIndex(MediaStore.Images.Media.DISPLAY_NAME)
|
||||
while (cursor.moveToNext()) {
|
||||
val name = cursor.getString(displayNameColumn)
|
||||
val relativePath = cursor.getString(relativePathColumn)
|
||||
if (name.contains("screenshot", true) or
|
||||
relativePath.contains("screenshot", true)) {
|
||||
if (cache.add(uri.hashCode())) {
|
||||
screenshotTriggered()
|
||||
|
||||
try {
|
||||
context.contentResolver.query(
|
||||
uri,
|
||||
projection,
|
||||
null,
|
||||
null,
|
||||
null
|
||||
)?.use { cursor ->
|
||||
val relativePathColumn =
|
||||
cursor.getColumnIndex(MediaStore.Images.Media.RELATIVE_PATH)
|
||||
val displayNameColumn =
|
||||
cursor.getColumnIndex(MediaStore.Images.Media.DISPLAY_NAME)
|
||||
while (cursor.moveToNext()) {
|
||||
val name = cursor.getString(displayNameColumn)
|
||||
val relativePath = cursor.getString(relativePathColumn)
|
||||
if (name.contains("screenshot", true) or
|
||||
relativePath.contains("screenshot", true)) {
|
||||
if (cache.add(uri.hashCode())) {
|
||||
screenshotTriggered()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (e: IllegalStateException) {
|
||||
Log.e(TAG, e)
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
|
@ -1,14 +0,0 @@
|
||||
package org.thoughtcrime.securesms.backup
|
||||
|
||||
data class BackupEvent constructor(val type: Type, val count: Int, val exception: Exception?) {
|
||||
|
||||
enum class Type {
|
||||
PROGRESS, FINISHED
|
||||
}
|
||||
|
||||
companion object {
|
||||
@JvmStatic fun createProgress(count: Int) = BackupEvent(Type.PROGRESS, count, null)
|
||||
@JvmStatic fun createFinished() = BackupEvent(Type.FINISHED, 0, null)
|
||||
@JvmStatic fun createFinished(e: Exception?) = BackupEvent(Type.FINISHED, 0, e)
|
||||
}
|
||||
}
|
@ -1,47 +0,0 @@
|
||||
package org.thoughtcrime.securesms.backup;
|
||||
|
||||
import android.content.Context;
|
||||
import android.os.Build;
|
||||
import androidx.annotation.NonNull;
|
||||
import androidx.annotation.Nullable;
|
||||
|
||||
import org.thoughtcrime.securesms.crypto.KeyStoreHelper;
|
||||
import org.session.libsignal.utilities.Log;
|
||||
import org.session.libsession.utilities.TextSecurePreferences;
|
||||
|
||||
/**
|
||||
* Allows the getting and setting of the backup passphrase, which is stored encrypted on API >= 23.
|
||||
*/
|
||||
public class BackupPassphrase {
|
||||
|
||||
private static final String TAG = BackupPassphrase.class.getSimpleName();
|
||||
|
||||
public static @Nullable String get(@NonNull Context context) {
|
||||
String passphrase = TextSecurePreferences.getBackupPassphrase(context);
|
||||
String encryptedPassphrase = TextSecurePreferences.getEncryptedBackupPassphrase(context);
|
||||
|
||||
if (Build.VERSION.SDK_INT < 23 || (passphrase == null && encryptedPassphrase == null)) {
|
||||
return passphrase;
|
||||
}
|
||||
|
||||
if (encryptedPassphrase == null) {
|
||||
Log.i(TAG, "Migrating to encrypted passphrase.");
|
||||
set(context, passphrase);
|
||||
encryptedPassphrase = TextSecurePreferences.getEncryptedBackupPassphrase(context);
|
||||
}
|
||||
|
||||
KeyStoreHelper.SealedData data = KeyStoreHelper.SealedData.fromString(encryptedPassphrase);
|
||||
return new String(KeyStoreHelper.unseal(data));
|
||||
}
|
||||
|
||||
public static void set(@NonNull Context context, @Nullable String passphrase) {
|
||||
if (passphrase == null || Build.VERSION.SDK_INT < 23) {
|
||||
TextSecurePreferences.setBackupPassphrase(context, passphrase);
|
||||
TextSecurePreferences.setEncryptedBackupPassphrase(context, null);
|
||||
} else {
|
||||
KeyStoreHelper.SealedData encryptedPassphrase = KeyStoreHelper.seal(passphrase.getBytes());
|
||||
TextSecurePreferences.setEncryptedBackupPassphrase(context, encryptedPassphrase.serialize());
|
||||
TextSecurePreferences.setBackupPassphrase(context, null);
|
||||
}
|
||||
}
|
||||
}
|
@ -1,101 +0,0 @@
|
||||
package org.thoughtcrime.securesms.backup
|
||||
|
||||
import android.content.Context
|
||||
import android.content.SharedPreferences
|
||||
import android.os.Build
|
||||
import android.preference.PreferenceManager
|
||||
import android.preference.PreferenceManager.getDefaultSharedPreferencesName
|
||||
import org.session.libsession.utilities.TextSecurePreferences
|
||||
import org.session.libsignal.utilities.Log
|
||||
import org.thoughtcrime.securesms.backup.FullBackupImporter.PREF_PREFIX_TYPE_BOOLEAN
|
||||
import org.thoughtcrime.securesms.backup.FullBackupImporter.PREF_PREFIX_TYPE_INT
|
||||
import java.util.*
|
||||
|
||||
object BackupPreferences {
|
||||
// region Backup related
|
||||
fun getBackupRecords(context: Context): List<BackupProtos.SharedPreference> {
|
||||
val preferences = PreferenceManager.getDefaultSharedPreferences(context)
|
||||
val prefsFileName: String
|
||||
prefsFileName = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) {
|
||||
getDefaultSharedPreferencesName(context)
|
||||
} else {
|
||||
context.packageName + "_preferences"
|
||||
}
|
||||
val prefList: LinkedList<BackupProtos.SharedPreference> = LinkedList<BackupProtos.SharedPreference>()
|
||||
addBackupEntryInt(prefList, preferences, prefsFileName, TextSecurePreferences.LOCAL_REGISTRATION_ID_PREF)
|
||||
addBackupEntryString(prefList, preferences, prefsFileName, TextSecurePreferences.LOCAL_NUMBER_PREF)
|
||||
addBackupEntryString(prefList, preferences, prefsFileName, TextSecurePreferences.PROFILE_NAME_PREF)
|
||||
addBackupEntryString(prefList, preferences, prefsFileName, TextSecurePreferences.PROFILE_AVATAR_URL_PREF)
|
||||
addBackupEntryInt(prefList, preferences, prefsFileName, TextSecurePreferences.PROFILE_AVATAR_ID_PREF)
|
||||
addBackupEntryString(prefList, preferences, prefsFileName, TextSecurePreferences.PROFILE_KEY_PREF)
|
||||
addBackupEntryBoolean(prefList, preferences, prefsFileName, TextSecurePreferences.IS_USING_FCM)
|
||||
return prefList
|
||||
}
|
||||
|
||||
private fun addBackupEntryString(
|
||||
outPrefList: MutableList<BackupProtos.SharedPreference>,
|
||||
prefs: SharedPreferences,
|
||||
prefFileName: String,
|
||||
prefKey: String,
|
||||
) {
|
||||
val value = prefs.getString(prefKey, null)
|
||||
if (value == null) {
|
||||
logBackupEntry(prefKey, false)
|
||||
return
|
||||
}
|
||||
outPrefList.add(BackupProtos.SharedPreference.newBuilder()
|
||||
.setFile(prefFileName)
|
||||
.setKey(prefKey)
|
||||
.setValue(value)
|
||||
.build())
|
||||
logBackupEntry(prefKey, true)
|
||||
}
|
||||
|
||||
private fun addBackupEntryInt(
|
||||
outPrefList: MutableList<BackupProtos.SharedPreference>,
|
||||
prefs: SharedPreferences,
|
||||
prefFileName: String,
|
||||
prefKey: String,
|
||||
) {
|
||||
val value = prefs.getInt(prefKey, -1)
|
||||
if (value == -1) {
|
||||
logBackupEntry(prefKey, false)
|
||||
return
|
||||
}
|
||||
outPrefList.add(BackupProtos.SharedPreference.newBuilder()
|
||||
.setFile(prefFileName)
|
||||
.setKey(PREF_PREFIX_TYPE_INT + prefKey) // The prefix denotes the type of the preference.
|
||||
.setValue(value.toString())
|
||||
.build())
|
||||
logBackupEntry(prefKey, true)
|
||||
}
|
||||
|
||||
private fun addBackupEntryBoolean(
|
||||
outPrefList: MutableList<BackupProtos.SharedPreference>,
|
||||
prefs: SharedPreferences,
|
||||
prefFileName: String,
|
||||
prefKey: String,
|
||||
) {
|
||||
if (!prefs.contains(prefKey)) {
|
||||
logBackupEntry(prefKey, false)
|
||||
return
|
||||
}
|
||||
outPrefList.add(BackupProtos.SharedPreference.newBuilder()
|
||||
.setFile(prefFileName)
|
||||
.setKey(PREF_PREFIX_TYPE_BOOLEAN + prefKey) // The prefix denotes the type of the preference.
|
||||
.setValue(prefs.getBoolean(prefKey, false).toString())
|
||||
.build())
|
||||
logBackupEntry(prefKey, true)
|
||||
}
|
||||
|
||||
private fun logBackupEntry(prefName: String, wasIncluded: Boolean) {
|
||||
val sb = StringBuilder()
|
||||
sb.append("Backup preference ")
|
||||
sb.append(if (wasIncluded) "+ " else "- ")
|
||||
sb.append('\"').append(prefName).append("\" ")
|
||||
if (!wasIncluded) {
|
||||
sb.append("(is empty and not included)")
|
||||
}
|
||||
Log.d("Loki", sb.toString())
|
||||
} // endregion
|
||||
}
|
File diff suppressed because it is too large
Load Diff
@ -1,447 +0,0 @@
|
||||
package org.thoughtcrime.securesms.backup
|
||||
|
||||
import android.content.Context
|
||||
import android.database.Cursor
|
||||
import android.net.Uri
|
||||
import android.text.TextUtils
|
||||
import androidx.annotation.WorkerThread
|
||||
import com.annimon.stream.function.Consumer
|
||||
import com.annimon.stream.function.Predicate
|
||||
import com.google.protobuf.ByteString
|
||||
import net.zetetic.database.sqlcipher.SQLiteDatabase
|
||||
import org.greenrobot.eventbus.EventBus
|
||||
import org.session.libsession.avatars.AvatarHelper
|
||||
import org.session.libsession.messaging.sending_receiving.attachments.AttachmentId
|
||||
import org.session.libsession.utilities.Conversions
|
||||
import org.session.libsession.utilities.Util
|
||||
import org.session.libsignal.crypto.kdf.HKDFv3
|
||||
import org.session.libsignal.utilities.ByteUtil
|
||||
import org.session.libsignal.utilities.Log
|
||||
import org.thoughtcrime.securesms.backup.BackupProtos.Attachment
|
||||
import org.thoughtcrime.securesms.backup.BackupProtos.Avatar
|
||||
import org.thoughtcrime.securesms.backup.BackupProtos.BackupFrame
|
||||
import org.thoughtcrime.securesms.backup.BackupProtos.DatabaseVersion
|
||||
import org.thoughtcrime.securesms.backup.BackupProtos.Header
|
||||
import org.thoughtcrime.securesms.backup.BackupProtos.SharedPreference
|
||||
import org.thoughtcrime.securesms.backup.BackupProtos.SqlStatement
|
||||
import org.thoughtcrime.securesms.backup.BackupProtos.Sticker
|
||||
import org.thoughtcrime.securesms.crypto.AttachmentSecret
|
||||
import org.thoughtcrime.securesms.crypto.ClassicDecryptingPartInputStream
|
||||
import org.thoughtcrime.securesms.crypto.ModernDecryptingPartInputStream
|
||||
import org.thoughtcrime.securesms.database.AttachmentDatabase
|
||||
import org.thoughtcrime.securesms.database.GroupReceiptDatabase
|
||||
import org.thoughtcrime.securesms.database.JobDatabase
|
||||
import org.thoughtcrime.securesms.database.LokiAPIDatabase
|
||||
import org.thoughtcrime.securesms.database.LokiBackupFilesDatabase
|
||||
import org.thoughtcrime.securesms.database.MmsDatabase
|
||||
import org.thoughtcrime.securesms.database.MmsSmsColumns
|
||||
import org.thoughtcrime.securesms.database.PushDatabase
|
||||
import org.thoughtcrime.securesms.database.SearchDatabase
|
||||
import org.thoughtcrime.securesms.database.SmsDatabase
|
||||
import org.thoughtcrime.securesms.util.BackupUtil
|
||||
import java.io.Closeable
|
||||
import java.io.File
|
||||
import java.io.FileInputStream
|
||||
import java.io.Flushable
|
||||
import java.io.IOException
|
||||
import java.io.InputStream
|
||||
import java.io.OutputStream
|
||||
import java.security.InvalidAlgorithmParameterException
|
||||
import java.security.InvalidKeyException
|
||||
import java.security.NoSuchAlgorithmException
|
||||
import java.util.LinkedList
|
||||
import javax.crypto.BadPaddingException
|
||||
import javax.crypto.Cipher
|
||||
import javax.crypto.IllegalBlockSizeException
|
||||
import javax.crypto.Mac
|
||||
import javax.crypto.NoSuchPaddingException
|
||||
import javax.crypto.spec.IvParameterSpec
|
||||
import javax.crypto.spec.SecretKeySpec
|
||||
|
||||
object FullBackupExporter {
|
||||
private val TAG = FullBackupExporter::class.java.simpleName
|
||||
|
||||
@JvmStatic
|
||||
@WorkerThread
|
||||
@Throws(IOException::class)
|
||||
fun export(context: Context,
|
||||
attachmentSecret: AttachmentSecret,
|
||||
input: SQLiteDatabase,
|
||||
fileUri: Uri,
|
||||
passphrase: String) {
|
||||
|
||||
val baseOutputStream = context.contentResolver.openOutputStream(fileUri)
|
||||
?: throw IOException("Cannot open an output stream for the file URI: $fileUri")
|
||||
|
||||
var count = 0
|
||||
try {
|
||||
BackupFrameOutputStream(baseOutputStream, passphrase).use { outputStream ->
|
||||
outputStream.writeDatabaseVersion(input.version)
|
||||
val tables = exportSchema(input, outputStream)
|
||||
for (table in tables) if (shouldExportTable(table)) {
|
||||
count = when (table) {
|
||||
SmsDatabase.TABLE_NAME, MmsDatabase.TABLE_NAME -> {
|
||||
exportTable(table, input, outputStream,
|
||||
{ cursor: Cursor ->
|
||||
cursor.getInt(cursor.getColumnIndexOrThrow(MmsSmsColumns.EXPIRES_IN)) <= 0
|
||||
},
|
||||
null,
|
||||
count)
|
||||
}
|
||||
GroupReceiptDatabase.TABLE_NAME -> {
|
||||
exportTable(table, input, outputStream,
|
||||
{ cursor: Cursor ->
|
||||
isForNonExpiringMessage(input, cursor.getLong(cursor.getColumnIndexOrThrow(GroupReceiptDatabase.MMS_ID)))
|
||||
},
|
||||
null,
|
||||
count)
|
||||
}
|
||||
AttachmentDatabase.TABLE_NAME -> {
|
||||
exportTable(table, input, outputStream,
|
||||
{ cursor: Cursor ->
|
||||
isForNonExpiringMessage(input, cursor.getLong(cursor.getColumnIndexOrThrow(AttachmentDatabase.MMS_ID)))
|
||||
},
|
||||
{ cursor: Cursor ->
|
||||
exportAttachment(attachmentSecret, cursor, outputStream)
|
||||
},
|
||||
count)
|
||||
}
|
||||
else -> {
|
||||
exportTable(table, input, outputStream, null, null, count)
|
||||
}
|
||||
}
|
||||
}
|
||||
for (preference in BackupUtil.getBackupRecords(context)) {
|
||||
EventBus.getDefault().post(BackupEvent.createProgress(++count))
|
||||
outputStream.writePreferenceEntry(preference)
|
||||
}
|
||||
for (preference in BackupPreferences.getBackupRecords(context)) {
|
||||
EventBus.getDefault().post(BackupEvent.createProgress(++count))
|
||||
outputStream.writePreferenceEntry(preference)
|
||||
}
|
||||
for (avatar in AvatarHelper.getAvatarFiles(context)) {
|
||||
EventBus.getDefault().post(BackupEvent.createProgress(++count))
|
||||
outputStream.writeAvatar(avatar.name, FileInputStream(avatar), avatar.length())
|
||||
}
|
||||
outputStream.writeEnd()
|
||||
}
|
||||
EventBus.getDefault().post(BackupEvent.createFinished())
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "Failed to make full backup.", e)
|
||||
EventBus.getDefault().post(BackupEvent.createFinished(e))
|
||||
throw e
|
||||
}
|
||||
}
|
||||
|
||||
private inline fun shouldExportTable(table: String): Boolean {
|
||||
return table != PushDatabase.TABLE_NAME &&
|
||||
|
||||
table != LokiBackupFilesDatabase.TABLE_NAME &&
|
||||
table != LokiAPIDatabase.openGroupProfilePictureTable &&
|
||||
|
||||
table != JobDatabase.Jobs.TABLE_NAME &&
|
||||
table != JobDatabase.Constraints.TABLE_NAME &&
|
||||
table != JobDatabase.Dependencies.TABLE_NAME &&
|
||||
|
||||
!table.startsWith(SearchDatabase.SMS_FTS_TABLE_NAME) &&
|
||||
!table.startsWith(SearchDatabase.MMS_FTS_TABLE_NAME) &&
|
||||
!table.startsWith("sqlite_")
|
||||
}
|
||||
|
||||
@Throws(IOException::class)
|
||||
private fun exportSchema(input: SQLiteDatabase, outputStream: BackupFrameOutputStream): List<String> {
|
||||
val tables: MutableList<String> = LinkedList()
|
||||
input.rawQuery("SELECT sql, name, type FROM sqlite_master", null).use { cursor ->
|
||||
while (cursor != null && cursor.moveToNext()) {
|
||||
val sql = cursor.getString(0)
|
||||
val name = cursor.getString(1)
|
||||
val type = cursor.getString(2)
|
||||
if (sql != null) {
|
||||
val isSmsFtsSecretTable = name != null && name != SearchDatabase.SMS_FTS_TABLE_NAME && name.startsWith(SearchDatabase.SMS_FTS_TABLE_NAME)
|
||||
val isMmsFtsSecretTable = name != null && name != SearchDatabase.MMS_FTS_TABLE_NAME && name.startsWith(SearchDatabase.MMS_FTS_TABLE_NAME)
|
||||
if (!isSmsFtsSecretTable && !isMmsFtsSecretTable) {
|
||||
if ("table" == type) {
|
||||
tables.add(name)
|
||||
}
|
||||
outputStream.writeSql(SqlStatement.newBuilder().setStatement(cursor.getString(0)).build())
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return tables
|
||||
}
|
||||
|
||||
@Throws(IOException::class)
|
||||
private fun exportTable(table: String,
|
||||
input: SQLiteDatabase,
|
||||
outputStream: BackupFrameOutputStream,
|
||||
predicate: Predicate<Cursor>?,
|
||||
postProcess: Consumer<Cursor>?,
|
||||
count: Int): Int {
|
||||
var count = count
|
||||
val template = "INSERT INTO $table VALUES "
|
||||
input.rawQuery("SELECT * FROM $table", null).use { cursor ->
|
||||
while (cursor != null && cursor.moveToNext()) {
|
||||
EventBus.getDefault().post(BackupEvent.createProgress(++count))
|
||||
if (predicate != null && !predicate.test(cursor)) continue
|
||||
|
||||
val statement = StringBuilder(template)
|
||||
val statementBuilder = SqlStatement.newBuilder()
|
||||
statement.append('(')
|
||||
for (i in 0 until cursor.columnCount) {
|
||||
statement.append('?')
|
||||
when (cursor.getType(i)) {
|
||||
Cursor.FIELD_TYPE_STRING -> {
|
||||
statementBuilder.addParameters(SqlStatement.SqlParameter.newBuilder()
|
||||
.setStringParamter(cursor.getString(i)))
|
||||
}
|
||||
Cursor.FIELD_TYPE_FLOAT -> {
|
||||
statementBuilder.addParameters(SqlStatement.SqlParameter.newBuilder()
|
||||
.setDoubleParameter(cursor.getDouble(i)))
|
||||
}
|
||||
Cursor.FIELD_TYPE_INTEGER -> {
|
||||
statementBuilder.addParameters(SqlStatement.SqlParameter.newBuilder()
|
||||
.setIntegerParameter(cursor.getLong(i)))
|
||||
}
|
||||
Cursor.FIELD_TYPE_BLOB -> {
|
||||
statementBuilder.addParameters(SqlStatement.SqlParameter.newBuilder()
|
||||
.setBlobParameter(ByteString.copyFrom(cursor.getBlob(i))))
|
||||
}
|
||||
Cursor.FIELD_TYPE_NULL -> {
|
||||
statementBuilder.addParameters(SqlStatement.SqlParameter.newBuilder()
|
||||
.setNullparameter(true))
|
||||
}
|
||||
else -> {
|
||||
throw AssertionError("unknown type?" + cursor.getType(i))
|
||||
}
|
||||
}
|
||||
if (i < cursor.columnCount - 1) {
|
||||
statement.append(',')
|
||||
}
|
||||
}
|
||||
statement.append(')')
|
||||
outputStream.writeSql(statementBuilder.setStatement(statement.toString()).build())
|
||||
postProcess?.accept(cursor)
|
||||
}
|
||||
}
|
||||
return count
|
||||
}
|
||||
|
||||
private fun exportAttachment(attachmentSecret: AttachmentSecret, cursor: Cursor, outputStream: BackupFrameOutputStream) {
|
||||
try {
|
||||
val rowId = cursor.getLong(cursor.getColumnIndexOrThrow(AttachmentDatabase.ROW_ID))
|
||||
val uniqueId = cursor.getLong(cursor.getColumnIndexOrThrow(AttachmentDatabase.UNIQUE_ID))
|
||||
var size = cursor.getLong(cursor.getColumnIndexOrThrow(AttachmentDatabase.SIZE))
|
||||
val data = cursor.getString(cursor.getColumnIndexOrThrow(AttachmentDatabase.DATA))
|
||||
val random = cursor.getBlob(cursor.getColumnIndexOrThrow(AttachmentDatabase.DATA_RANDOM))
|
||||
if (!TextUtils.isEmpty(data) && size <= 0) {
|
||||
size = calculateVeryOldStreamLength(attachmentSecret, random, data)
|
||||
}
|
||||
if (!TextUtils.isEmpty(data) && size > 0) {
|
||||
val inputStream: InputStream = if (random != null && random.size == 32) {
|
||||
ModernDecryptingPartInputStream.createFor(attachmentSecret, random, File(data), 0)
|
||||
} else {
|
||||
ClassicDecryptingPartInputStream.createFor(attachmentSecret, File(data))
|
||||
}
|
||||
outputStream.writeAttachment(AttachmentId(rowId, uniqueId), inputStream, size)
|
||||
}
|
||||
} catch (e: IOException) {
|
||||
Log.w(TAG, e)
|
||||
}
|
||||
}
|
||||
|
||||
@Throws(IOException::class)
|
||||
private fun calculateVeryOldStreamLength(attachmentSecret: AttachmentSecret, random: ByteArray?, data: String): Long {
|
||||
var result: Long = 0
|
||||
val inputStream: InputStream = if (random != null && random.size == 32) {
|
||||
ModernDecryptingPartInputStream.createFor(attachmentSecret, random, File(data), 0)
|
||||
} else {
|
||||
ClassicDecryptingPartInputStream.createFor(attachmentSecret, File(data))
|
||||
}
|
||||
var read: Int
|
||||
val buffer = ByteArray(8192)
|
||||
while (inputStream.read(buffer, 0, buffer.size).also { read = it } != -1) {
|
||||
result += read.toLong()
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
private fun isForNonExpiringMessage(db: SQLiteDatabase, mmsId: Long): Boolean {
|
||||
val columns = arrayOf(MmsSmsColumns.EXPIRES_IN)
|
||||
val where = MmsSmsColumns.ID + " = ?"
|
||||
val args = arrayOf(mmsId.toString())
|
||||
db.query(MmsDatabase.TABLE_NAME, columns, where, args, null, null, null).use { mmsCursor ->
|
||||
if (mmsCursor != null && mmsCursor.moveToFirst()) {
|
||||
return mmsCursor.getLong(0) == 0L
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
private class BackupFrameOutputStream : Closeable, Flushable {
|
||||
|
||||
private val outputStream: OutputStream
|
||||
private var cipher: Cipher
|
||||
private var mac: Mac
|
||||
private val cipherKey: ByteArray
|
||||
private val macKey: ByteArray
|
||||
private val iv: ByteArray
|
||||
|
||||
private var counter: Int = 0
|
||||
|
||||
constructor(outputStream: OutputStream, passphrase: String) : super() {
|
||||
try {
|
||||
val salt = Util.getSecretBytes(32)
|
||||
val key = BackupUtil.computeBackupKey(passphrase, salt)
|
||||
val derived = HKDFv3().deriveSecrets(key, "Backup Export".toByteArray(), 64)
|
||||
val split = ByteUtil.split(derived, 32, 32)
|
||||
cipherKey = split[0]
|
||||
macKey = split[1]
|
||||
cipher = Cipher.getInstance("AES/CTR/NoPadding")
|
||||
mac = Mac.getInstance("HmacSHA256")
|
||||
this.outputStream = outputStream
|
||||
iv = Util.getSecretBytes(16)
|
||||
counter = Conversions.byteArrayToInt(iv)
|
||||
mac.init(SecretKeySpec(macKey, "HmacSHA256"))
|
||||
val header = BackupFrame.newBuilder().setHeader(Header.newBuilder()
|
||||
.setIv(ByteString.copyFrom(iv))
|
||||
.setSalt(ByteString.copyFrom(salt)))
|
||||
.build().toByteArray()
|
||||
outputStream.write(Conversions.intToByteArray(header.size))
|
||||
outputStream.write(header)
|
||||
} catch (e: Exception) {
|
||||
when (e) {
|
||||
is NoSuchAlgorithmException,
|
||||
is NoSuchPaddingException,
|
||||
is InvalidKeyException -> {
|
||||
throw AssertionError(e)
|
||||
}
|
||||
else -> throw e
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Throws(IOException::class)
|
||||
fun writeSql(statement: SqlStatement) {
|
||||
write(outputStream, BackupFrame.newBuilder().setStatement(statement).build())
|
||||
}
|
||||
|
||||
@Throws(IOException::class)
|
||||
fun writePreferenceEntry(preference: SharedPreference?) {
|
||||
write(outputStream, BackupFrame.newBuilder().setPreference(preference).build())
|
||||
}
|
||||
|
||||
@Throws(IOException::class)
|
||||
fun writeAvatar(avatarName: String, inputStream: InputStream, size: Long) {
|
||||
write(outputStream, BackupFrame.newBuilder()
|
||||
.setAvatar(Avatar.newBuilder()
|
||||
.setName(avatarName)
|
||||
.setLength(Util.toIntExact(size))
|
||||
.build())
|
||||
.build())
|
||||
writeStream(inputStream)
|
||||
}
|
||||
|
||||
@Throws(IOException::class)
|
||||
fun writeAttachment(attachmentId: AttachmentId, inputStream: InputStream, size: Long) {
|
||||
write(outputStream, BackupFrame.newBuilder()
|
||||
.setAttachment(Attachment.newBuilder()
|
||||
.setRowId(attachmentId.rowId)
|
||||
.setAttachmentId(attachmentId.uniqueId)
|
||||
.setLength(Util.toIntExact(size))
|
||||
.build())
|
||||
.build())
|
||||
writeStream(inputStream)
|
||||
}
|
||||
|
||||
@Throws(IOException::class)
|
||||
fun writeSticker(rowId: Long, inputStream: InputStream, size: Long) {
|
||||
write(outputStream, BackupFrame.newBuilder()
|
||||
.setSticker(Sticker.newBuilder()
|
||||
.setRowId(rowId)
|
||||
.setLength(Util.toIntExact(size))
|
||||
.build())
|
||||
.build())
|
||||
writeStream(inputStream)
|
||||
}
|
||||
|
||||
@Throws(IOException::class)
|
||||
fun writeDatabaseVersion(version: Int) {
|
||||
write(outputStream, BackupFrame.newBuilder()
|
||||
.setVersion(DatabaseVersion.newBuilder().setVersion(version))
|
||||
.build())
|
||||
}
|
||||
|
||||
@Throws(IOException::class)
|
||||
fun writeEnd() {
|
||||
write(outputStream, BackupFrame.newBuilder().setEnd(true).build())
|
||||
}
|
||||
|
||||
@Throws(IOException::class)
|
||||
private fun writeStream(inputStream: InputStream) {
|
||||
try {
|
||||
Conversions.intToByteArray(iv, 0, counter++)
|
||||
cipher.init(Cipher.ENCRYPT_MODE, SecretKeySpec(cipherKey, "AES"), IvParameterSpec(iv))
|
||||
mac.update(iv)
|
||||
val buffer = ByteArray(8192)
|
||||
var read: Int
|
||||
while (inputStream.read(buffer).also { read = it } != -1) {
|
||||
val ciphertext = cipher.update(buffer, 0, read)
|
||||
if (ciphertext != null) {
|
||||
outputStream.write(ciphertext)
|
||||
mac.update(ciphertext)
|
||||
}
|
||||
}
|
||||
val remainder = cipher.doFinal()
|
||||
outputStream.write(remainder)
|
||||
mac.update(remainder)
|
||||
val attachmentDigest = mac.doFinal()
|
||||
outputStream.write(attachmentDigest, 0, 10)
|
||||
} catch (e: Exception) {
|
||||
when (e) {
|
||||
is InvalidKeyException,
|
||||
is InvalidAlgorithmParameterException,
|
||||
is IllegalBlockSizeException,
|
||||
is BadPaddingException -> {
|
||||
throw AssertionError(e)
|
||||
}
|
||||
else -> throw e
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Throws(IOException::class)
|
||||
private fun write(out: OutputStream, frame: BackupFrame) {
|
||||
try {
|
||||
Conversions.intToByteArray(iv, 0, counter++)
|
||||
cipher.init(Cipher.ENCRYPT_MODE, SecretKeySpec(cipherKey, "AES"), IvParameterSpec(iv))
|
||||
val frameCiphertext = cipher.doFinal(frame.toByteArray())
|
||||
val frameMac = mac.doFinal(frameCiphertext)
|
||||
val length = Conversions.intToByteArray(frameCiphertext.size + 10)
|
||||
out.write(length)
|
||||
out.write(frameCiphertext)
|
||||
out.write(frameMac, 0, 10)
|
||||
} catch (e: Exception) {
|
||||
when (e) {
|
||||
is InvalidKeyException,
|
||||
is InvalidAlgorithmParameterException,
|
||||
is IllegalBlockSizeException,
|
||||
is BadPaddingException -> {
|
||||
throw AssertionError(e)
|
||||
}
|
||||
else -> throw e
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Throws(IOException::class)
|
||||
override fun flush() {
|
||||
outputStream.flush()
|
||||
}
|
||||
|
||||
@Throws(IOException::class)
|
||||
override fun close() {
|
||||
outputStream.close()
|
||||
}
|
||||
}
|
||||
}
|
@ -1,352 +0,0 @@
|
||||
package org.thoughtcrime.securesms.backup
|
||||
|
||||
import android.annotation.SuppressLint
|
||||
import android.content.ContentValues
|
||||
import android.content.Context
|
||||
import android.net.Uri
|
||||
import androidx.annotation.WorkerThread
|
||||
import net.zetetic.database.sqlcipher.SQLiteDatabase
|
||||
import org.greenrobot.eventbus.EventBus
|
||||
import org.session.libsession.avatars.AvatarHelper
|
||||
import org.session.libsession.messaging.sending_receiving.attachments.AttachmentId
|
||||
import org.session.libsession.utilities.Address
|
||||
import org.session.libsession.utilities.Conversions
|
||||
import org.session.libsession.utilities.Util
|
||||
import org.session.libsignal.crypto.kdf.HKDFv3
|
||||
import org.session.libsignal.utilities.ByteUtil
|
||||
import org.session.libsignal.utilities.Log
|
||||
import org.thoughtcrime.securesms.backup.BackupProtos.Attachment
|
||||
import org.thoughtcrime.securesms.backup.BackupProtos.Avatar
|
||||
import org.thoughtcrime.securesms.backup.BackupProtos.BackupFrame
|
||||
import org.thoughtcrime.securesms.backup.BackupProtos.DatabaseVersion
|
||||
import org.thoughtcrime.securesms.backup.BackupProtos.SharedPreference
|
||||
import org.thoughtcrime.securesms.backup.BackupProtos.SqlStatement
|
||||
import org.thoughtcrime.securesms.crypto.AttachmentSecret
|
||||
import org.thoughtcrime.securesms.crypto.ModernEncryptingPartOutputStream
|
||||
import org.thoughtcrime.securesms.database.AttachmentDatabase
|
||||
import org.thoughtcrime.securesms.database.GroupReceiptDatabase
|
||||
import org.thoughtcrime.securesms.database.MmsDatabase
|
||||
import org.thoughtcrime.securesms.database.MmsSmsColumns
|
||||
import org.thoughtcrime.securesms.database.SearchDatabase
|
||||
import org.thoughtcrime.securesms.database.ThreadDatabase
|
||||
import org.thoughtcrime.securesms.dependencies.DatabaseComponent
|
||||
import org.thoughtcrime.securesms.util.BackupUtil
|
||||
import java.io.Closeable
|
||||
import java.io.File
|
||||
import java.io.FileOutputStream
|
||||
import java.io.IOException
|
||||
import java.io.InputStream
|
||||
import java.io.OutputStream
|
||||
import java.security.InvalidAlgorithmParameterException
|
||||
import java.security.InvalidKeyException
|
||||
import java.security.MessageDigest
|
||||
import java.security.NoSuchAlgorithmException
|
||||
import java.util.LinkedList
|
||||
import java.util.Locale
|
||||
import javax.crypto.BadPaddingException
|
||||
import javax.crypto.Cipher
|
||||
import javax.crypto.IllegalBlockSizeException
|
||||
import javax.crypto.Mac
|
||||
import javax.crypto.NoSuchPaddingException
|
||||
import javax.crypto.spec.IvParameterSpec
|
||||
import javax.crypto.spec.SecretKeySpec
|
||||
|
||||
object FullBackupImporter {
|
||||
/**
|
||||
* Because BackupProtos.SharedPreference was made only to serialize string values,
|
||||
* we use these 3-char prefixes to explicitly cast the values before inserting to a preference file.
|
||||
*/
|
||||
const val PREF_PREFIX_TYPE_INT = "i__"
|
||||
const val PREF_PREFIX_TYPE_BOOLEAN = "b__"
|
||||
|
||||
private val TAG = FullBackupImporter::class.java.simpleName
|
||||
|
||||
@JvmStatic
|
||||
@WorkerThread
|
||||
@Throws(IOException::class)
|
||||
fun importFromUri(context: Context,
|
||||
attachmentSecret: AttachmentSecret,
|
||||
db: SQLiteDatabase,
|
||||
fileUri: Uri,
|
||||
passphrase: String) {
|
||||
|
||||
val baseInputStream = context.contentResolver.openInputStream(fileUri)
|
||||
?: throw IOException("Cannot open an input stream for the file URI: $fileUri")
|
||||
|
||||
var count = 0
|
||||
try {
|
||||
BackupRecordInputStream(baseInputStream, passphrase).use { inputStream ->
|
||||
db.beginTransaction()
|
||||
dropAllTables(db)
|
||||
var frame: BackupFrame
|
||||
while (!inputStream.readFrame().also { frame = it }.end) {
|
||||
if (count++ % 100 == 0) EventBus.getDefault().post(BackupEvent.createProgress(count))
|
||||
when {
|
||||
frame.hasVersion() -> processVersion(db, frame.version)
|
||||
frame.hasStatement() -> processStatement(db, frame.statement)
|
||||
frame.hasPreference() -> processPreference(context, frame.preference)
|
||||
frame.hasAttachment() -> processAttachment(context, attachmentSecret, db, frame.attachment, inputStream)
|
||||
frame.hasAvatar() -> processAvatar(context, frame.avatar, inputStream)
|
||||
}
|
||||
}
|
||||
trimEntriesForExpiredMessages(context, db)
|
||||
db.setTransactionSuccessful()
|
||||
}
|
||||
} finally {
|
||||
if (db.inTransaction()) {
|
||||
db.endTransaction()
|
||||
}
|
||||
}
|
||||
EventBus.getDefault().post(BackupEvent.createFinished())
|
||||
}
|
||||
|
||||
@Throws(IOException::class)
|
||||
private fun processVersion(db: SQLiteDatabase, version: DatabaseVersion) {
|
||||
if (version.version > db.version) {
|
||||
throw DatabaseDowngradeException(db.version, version.version)
|
||||
}
|
||||
db.version = version.version
|
||||
}
|
||||
|
||||
private fun processStatement(db: SQLiteDatabase, statement: SqlStatement) {
|
||||
val isForSmsFtsSecretTable = statement.statement.contains(SearchDatabase.SMS_FTS_TABLE_NAME + "_")
|
||||
val isForMmsFtsSecretTable = statement.statement.contains(SearchDatabase.MMS_FTS_TABLE_NAME + "_")
|
||||
val isForSqliteSecretTable = statement.statement.toLowerCase(Locale.ENGLISH).startsWith("create table sqlite_")
|
||||
if (isForSmsFtsSecretTable || isForMmsFtsSecretTable || isForSqliteSecretTable) {
|
||||
Log.i(TAG, "Ignoring import for statement: " + statement.statement)
|
||||
return
|
||||
}
|
||||
val parameters: MutableList<Any?> = LinkedList()
|
||||
for (parameter in statement.parametersList) {
|
||||
when {
|
||||
parameter.hasStringParamter() -> parameters.add(parameter.stringParamter)
|
||||
parameter.hasDoubleParameter() -> parameters.add(parameter.doubleParameter)
|
||||
parameter.hasIntegerParameter() -> parameters.add(parameter.integerParameter)
|
||||
parameter.hasBlobParameter() -> parameters.add(parameter.blobParameter.toByteArray())
|
||||
parameter.hasNullparameter() -> parameters.add(null)
|
||||
}
|
||||
}
|
||||
if (parameters.size > 0) {
|
||||
db.execSQL(statement.statement, parameters.toTypedArray())
|
||||
} else {
|
||||
db.execSQL(statement.statement)
|
||||
}
|
||||
}
|
||||
|
||||
@Throws(IOException::class)
|
||||
private fun processAttachment(context: Context, attachmentSecret: AttachmentSecret,
|
||||
db: SQLiteDatabase, attachment: Attachment,
|
||||
inputStream: BackupRecordInputStream) {
|
||||
val partsDirectory = context.getDir(AttachmentDatabase.DIRECTORY, Context.MODE_PRIVATE)
|
||||
val dataFile = File.createTempFile("part", ".mms", partsDirectory)
|
||||
val output = ModernEncryptingPartOutputStream.createFor(attachmentSecret, dataFile, false)
|
||||
inputStream.readAttachmentTo(output.second, attachment.length)
|
||||
val contentValues = ContentValues()
|
||||
contentValues.put(AttachmentDatabase.DATA, dataFile.absolutePath)
|
||||
contentValues.put(AttachmentDatabase.THUMBNAIL, null as String?)
|
||||
contentValues.put(AttachmentDatabase.DATA_RANDOM, output.first)
|
||||
db.update(AttachmentDatabase.TABLE_NAME, contentValues,
|
||||
"${AttachmentDatabase.ROW_ID} = ? AND ${AttachmentDatabase.UNIQUE_ID} = ?",
|
||||
arrayOf(attachment.rowId.toString(), attachment.attachmentId.toString()))
|
||||
}
|
||||
|
||||
@Throws(IOException::class)
|
||||
private fun processAvatar(context: Context, avatar: Avatar, inputStream: BackupRecordInputStream) {
|
||||
inputStream.readAttachmentTo(FileOutputStream(
|
||||
AvatarHelper.getAvatarFile(context, Address.fromExternal(context, avatar.name))), avatar.length)
|
||||
}
|
||||
|
||||
@SuppressLint("ApplySharedPref")
|
||||
private fun processPreference(context: Context, preference: SharedPreference) {
|
||||
val preferences = context.getSharedPreferences(preference.file, 0)
|
||||
val key = preference.key
|
||||
val value = preference.value
|
||||
|
||||
// See the comment next to PREF_PREFIX_TYPE_* constants.
|
||||
when {
|
||||
key.startsWith(PREF_PREFIX_TYPE_INT) ->
|
||||
preferences.edit().putInt(
|
||||
key.substring(PREF_PREFIX_TYPE_INT.length),
|
||||
value.toInt()
|
||||
).commit()
|
||||
key.startsWith(PREF_PREFIX_TYPE_BOOLEAN) ->
|
||||
preferences.edit().putBoolean(
|
||||
key.substring(PREF_PREFIX_TYPE_BOOLEAN.length),
|
||||
value.toBoolean()
|
||||
).commit()
|
||||
else ->
|
||||
preferences.edit().putString(key, value).commit()
|
||||
}
|
||||
}
|
||||
|
||||
private fun dropAllTables(db: SQLiteDatabase) {
|
||||
db.rawQuery("SELECT name, type FROM sqlite_master", null).use { cursor ->
|
||||
while (cursor != null && cursor.moveToNext()) {
|
||||
val name = cursor.getString(0)
|
||||
val type = cursor.getString(1)
|
||||
if ("table" == type && !name.startsWith("sqlite_")) {
|
||||
db.execSQL("DROP TABLE IF EXISTS $name")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun trimEntriesForExpiredMessages(context: Context, db: SQLiteDatabase) {
|
||||
val trimmedCondition = " NOT IN (SELECT ${MmsSmsColumns.ID} FROM ${MmsDatabase.TABLE_NAME})"
|
||||
db.delete(GroupReceiptDatabase.TABLE_NAME, GroupReceiptDatabase.MMS_ID + trimmedCondition, null)
|
||||
val columns = arrayOf(AttachmentDatabase.ROW_ID, AttachmentDatabase.UNIQUE_ID)
|
||||
val where = AttachmentDatabase.MMS_ID + trimmedCondition
|
||||
db.query(AttachmentDatabase.TABLE_NAME, columns, where, null, null, null, null).use { cursor ->
|
||||
while (cursor != null && cursor.moveToNext()) {
|
||||
DatabaseComponent.get(context).attachmentDatabase()
|
||||
.deleteAttachment(AttachmentId(cursor.getLong(0), cursor.getLong(1)))
|
||||
}
|
||||
}
|
||||
db.query(ThreadDatabase.TABLE_NAME, arrayOf(ThreadDatabase.ID),
|
||||
ThreadDatabase.EXPIRES_IN + " > 0", null, null, null, null).use { cursor ->
|
||||
while (cursor != null && cursor.moveToNext()) {
|
||||
DatabaseComponent.get(context).threadDatabase().update(cursor.getLong(0), false)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private class BackupRecordInputStream : Closeable {
|
||||
private val inputStream: InputStream
|
||||
private val cipher: Cipher
|
||||
private val mac: Mac
|
||||
private val cipherKey: ByteArray
|
||||
private val macKey: ByteArray
|
||||
private val iv: ByteArray
|
||||
|
||||
private var counter = 0
|
||||
|
||||
@Throws(IOException::class)
|
||||
constructor(inputStream: InputStream, passphrase: String) : super() {
|
||||
try {
|
||||
this.inputStream = inputStream
|
||||
val headerLengthBytes = ByteArray(4)
|
||||
Util.readFully(this.inputStream, headerLengthBytes)
|
||||
val headerLength = Conversions.byteArrayToInt(headerLengthBytes)
|
||||
val headerFrame = ByteArray(headerLength)
|
||||
Util.readFully(this.inputStream, headerFrame)
|
||||
val frame = BackupFrame.parseFrom(headerFrame)
|
||||
if (!frame.hasHeader()) {
|
||||
throw IOException("Backup stream does not start with header!")
|
||||
}
|
||||
val header = frame.header
|
||||
iv = header.iv.toByteArray()
|
||||
if (iv.size != 16) {
|
||||
throw IOException("Invalid IV length!")
|
||||
}
|
||||
val key = BackupUtil.computeBackupKey(passphrase, if (header.hasSalt()) header.salt.toByteArray() else null)
|
||||
val derived = HKDFv3().deriveSecrets(key, "Backup Export".toByteArray(), 64)
|
||||
val split = ByteUtil.split(derived, 32, 32)
|
||||
cipherKey = split[0]
|
||||
macKey = split[1]
|
||||
cipher = Cipher.getInstance("AES/CTR/NoPadding")
|
||||
mac = Mac.getInstance("HmacSHA256")
|
||||
mac.init(SecretKeySpec(macKey, "HmacSHA256"))
|
||||
counter = Conversions.byteArrayToInt(iv)
|
||||
} catch (e: Exception) {
|
||||
when (e) {
|
||||
is NoSuchAlgorithmException,
|
||||
is NoSuchPaddingException,
|
||||
is InvalidKeyException -> {
|
||||
throw AssertionError(e)
|
||||
}
|
||||
else -> throw e
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Throws(IOException::class)
|
||||
fun readFrame(): BackupFrame {
|
||||
return readFrame(inputStream)
|
||||
}
|
||||
|
||||
@Throws(IOException::class)
|
||||
fun readAttachmentTo(out: OutputStream, length: Int) {
|
||||
var length = length
|
||||
try {
|
||||
Conversions.intToByteArray(iv, 0, counter++)
|
||||
cipher.init(Cipher.DECRYPT_MODE, SecretKeySpec(cipherKey, "AES"), IvParameterSpec(iv))
|
||||
mac.update(iv)
|
||||
val buffer = ByteArray(8192)
|
||||
while (length > 0) {
|
||||
val read = inputStream.read(buffer, 0, Math.min(buffer.size, length))
|
||||
if (read == -1) throw IOException("File ended early!")
|
||||
mac.update(buffer, 0, read)
|
||||
val plaintext = cipher.update(buffer, 0, read)
|
||||
if (plaintext != null) {
|
||||
out.write(plaintext, 0, plaintext.size)
|
||||
}
|
||||
length -= read
|
||||
}
|
||||
val plaintext = cipher.doFinal()
|
||||
if (plaintext != null) {
|
||||
out.write(plaintext, 0, plaintext.size)
|
||||
}
|
||||
out.close()
|
||||
val ourMac = ByteUtil.trim(mac.doFinal(), 10)
|
||||
val theirMac = ByteArray(10)
|
||||
try {
|
||||
Util.readFully(inputStream, theirMac)
|
||||
} catch (e: IOException) {
|
||||
throw IOException(e)
|
||||
}
|
||||
if (!MessageDigest.isEqual(ourMac, theirMac)) {
|
||||
throw IOException("Bad MAC")
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
when (e) {
|
||||
is InvalidKeyException,
|
||||
is InvalidAlgorithmParameterException,
|
||||
is IllegalBlockSizeException,
|
||||
is BadPaddingException -> {
|
||||
throw AssertionError(e)
|
||||
}
|
||||
else -> throw e
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Throws(IOException::class)
|
||||
private fun readFrame(`in`: InputStream?): BackupFrame {
|
||||
return try {
|
||||
val length = ByteArray(4)
|
||||
Util.readFully(`in`, length)
|
||||
val frame = ByteArray(Conversions.byteArrayToInt(length))
|
||||
Util.readFully(`in`, frame)
|
||||
val theirMac = ByteArray(10)
|
||||
System.arraycopy(frame, frame.size - 10, theirMac, 0, theirMac.size)
|
||||
mac.update(frame, 0, frame.size - 10)
|
||||
val ourMac = ByteUtil.trim(mac.doFinal(), 10)
|
||||
if (!MessageDigest.isEqual(ourMac, theirMac)) {
|
||||
throw IOException("Bad MAC")
|
||||
}
|
||||
Conversions.intToByteArray(iv, 0, counter++)
|
||||
cipher.init(Cipher.DECRYPT_MODE, SecretKeySpec(cipherKey, "AES"), IvParameterSpec(iv))
|
||||
val plaintext = cipher.doFinal(frame, 0, frame.size - 10)
|
||||
BackupFrame.parseFrom(plaintext)
|
||||
} catch (e: Exception) {
|
||||
when (e) {
|
||||
is InvalidKeyException,
|
||||
is InvalidAlgorithmParameterException,
|
||||
is IllegalBlockSizeException,
|
||||
is BadPaddingException -> {
|
||||
throw AssertionError(e)
|
||||
}
|
||||
else -> throw e
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Throws(IOException::class)
|
||||
override fun close() {
|
||||
inputStream.close()
|
||||
}
|
||||
}
|
||||
|
||||
class DatabaseDowngradeException internal constructor(currentVersion: Int, backupVersion: Int) :
|
||||
IOException("Tried to import a backup with version $backupVersion into a database with version $currentVersion")
|
||||
}
|
@ -1,70 +0,0 @@
|
||||
package org.thoughtcrime.securesms.components
|
||||
|
||||
import android.content.Context
|
||||
import android.graphics.Canvas
|
||||
import android.graphics.Paint
|
||||
import android.graphics.Path
|
||||
import android.util.AttributeSet
|
||||
import android.view.LayoutInflater
|
||||
import android.widget.RelativeLayout
|
||||
import network.loki.messenger.R
|
||||
import network.loki.messenger.databinding.ViewSeparatorBinding
|
||||
import org.thoughtcrime.securesms.util.toPx
|
||||
import org.session.libsession.utilities.ThemeUtil
|
||||
|
||||
class LabeledSeparatorView : RelativeLayout {
|
||||
|
||||
private lateinit var binding: ViewSeparatorBinding
|
||||
private val path = Path()
|
||||
|
||||
private val paint: Paint by lazy {
|
||||
val result = Paint()
|
||||
result.style = Paint.Style.STROKE
|
||||
result.color = ThemeUtil.getThemedColor(context, R.attr.dividerHorizontal)
|
||||
result.strokeWidth = toPx(1, resources).toFloat()
|
||||
result.isAntiAlias = true
|
||||
result
|
||||
}
|
||||
|
||||
// region Lifecycle
|
||||
constructor(context: Context) : super(context) {
|
||||
setUpViewHierarchy()
|
||||
}
|
||||
|
||||
constructor(context: Context, attrs: AttributeSet) : super(context, attrs) {
|
||||
setUpViewHierarchy()
|
||||
}
|
||||
|
||||
constructor(context: Context, attrs: AttributeSet, defStyleAttr: Int) : super(context, attrs, defStyleAttr) {
|
||||
setUpViewHierarchy()
|
||||
}
|
||||
|
||||
constructor(context: Context, attrs: AttributeSet, defStyleAttr: Int, defStyleRes: Int) : super(context, attrs, defStyleAttr, defStyleRes) {
|
||||
setUpViewHierarchy()
|
||||
}
|
||||
|
||||
private fun setUpViewHierarchy() {
|
||||
binding = ViewSeparatorBinding.inflate(LayoutInflater.from(context))
|
||||
val layoutParams = LayoutParams(LayoutParams.MATCH_PARENT, LayoutParams.MATCH_PARENT)
|
||||
addView(binding.root, layoutParams)
|
||||
setWillNotDraw(false)
|
||||
}
|
||||
// endregion
|
||||
|
||||
// region Updating
|
||||
override fun onDraw(c: Canvas) {
|
||||
super.onDraw(c)
|
||||
val w = width.toFloat()
|
||||
val h = height.toFloat()
|
||||
val hMargin = toPx(16, resources).toFloat()
|
||||
path.reset()
|
||||
path.moveTo(0.0f, h / 2)
|
||||
path.lineTo(binding.titleTextView.left - hMargin, h / 2)
|
||||
path.addRoundRect(binding.titleTextView.left - hMargin, toPx(1, resources).toFloat(), binding.titleTextView.right + hMargin, h - toPx(1, resources).toFloat(), h / 2, h / 2, Path.Direction.CCW)
|
||||
path.moveTo(binding.titleTextView.right + hMargin, h / 2)
|
||||
path.lineTo(w, h / 2)
|
||||
path.close()
|
||||
c.drawPath(path, paint)
|
||||
}
|
||||
// endregion
|
||||
}
|
@ -0,0 +1,30 @@
|
||||
package org.thoughtcrime.securesms.components
|
||||
|
||||
import android.annotation.SuppressLint
|
||||
import android.content.Context
|
||||
import android.util.AttributeSet
|
||||
import android.view.MotionEvent
|
||||
import androidx.viewpager.widget.ViewPager
|
||||
|
||||
/**
|
||||
* An extension of ViewPager to swallow erroneous multi-touch exceptions.
|
||||
*
|
||||
* @see https://stackoverflow.com/questions/6919292/pointerindex-out-of-range-android-multitouch
|
||||
*/
|
||||
class SafeViewPager @JvmOverloads constructor(
|
||||
context: Context,
|
||||
attrs: AttributeSet? = null
|
||||
) : ViewPager(context, attrs) {
|
||||
@SuppressLint("ClickableViewAccessibility")
|
||||
override fun onTouchEvent(event: MotionEvent?): Boolean = try {
|
||||
super.onTouchEvent(event)
|
||||
} catch (e: IllegalArgumentException) {
|
||||
false
|
||||
}
|
||||
|
||||
override fun onInterceptTouchEvent(event: MotionEvent?): Boolean = try {
|
||||
super.onInterceptTouchEvent(event)
|
||||
} catch (e: IllegalArgumentException) {
|
||||
false
|
||||
}
|
||||
}
|
@ -21,7 +21,6 @@ import android.widget.Toast
|
||||
import androidx.activity.viewModels
|
||||
import androidx.annotation.DimenRes
|
||||
import androidx.appcompat.app.AlertDialog
|
||||
import androidx.core.view.drawToBitmap
|
||||
import androidx.core.view.isVisible
|
||||
import androidx.lifecycle.Observer
|
||||
import androidx.lifecycle.ViewModelProvider
|
||||
@ -210,11 +209,10 @@ class ConversationActivityV2 : PassphraseRequiredActionBarActivity(), InputBarDe
|
||||
val searchViewModel: SearchViewModel by viewModels()
|
||||
var searchViewItem: MenuItem? = null
|
||||
|
||||
private var emojiPickerVisible = false
|
||||
|
||||
private val isScrolledToBottom: Boolean
|
||||
get() {
|
||||
val position = layoutManager?.findFirstCompletelyVisibleItemPosition() ?: 0
|
||||
return position == 0
|
||||
}
|
||||
get() = binding?.conversationRecyclerView?.isScrolledToBottom ?: true
|
||||
|
||||
private val layoutManager: LinearLayoutManager?
|
||||
get() { return binding?.conversationRecyclerView?.layoutManager as LinearLayoutManager? }
|
||||
@ -344,6 +342,7 @@ class ConversationActivityV2 : PassphraseRequiredActionBarActivity(), InputBarDe
|
||||
updateSubtitle()
|
||||
setUpBlockedBanner()
|
||||
binding!!.searchBottomBar.setEventListener(this)
|
||||
updateSendAfterApprovalText()
|
||||
showOrHideInputIfNeeded()
|
||||
setUpMessageRequestsBar()
|
||||
|
||||
@ -443,17 +442,22 @@ class ConversationActivityV2 : PassphraseRequiredActionBarActivity(), InputBarDe
|
||||
handleRecyclerViewScrolled()
|
||||
}
|
||||
})
|
||||
|
||||
binding!!.conversationRecyclerView.addOnLayoutChangeListener { _, _, _, _, _, _, _, _, _ ->
|
||||
showScrollToBottomButtonIfApplicable()
|
||||
}
|
||||
}
|
||||
|
||||
// called from onCreate
|
||||
private fun setUpToolBar() {
|
||||
setSupportActionBar(binding?.toolbar)
|
||||
val binding = binding ?: return
|
||||
setSupportActionBar(binding.toolbar)
|
||||
val actionBar = supportActionBar ?: return
|
||||
val recipient = viewModel.recipient ?: return
|
||||
actionBar.title = ""
|
||||
actionBar.setDisplayHomeAsUpEnabled(true)
|
||||
actionBar.setHomeButtonEnabled(true)
|
||||
binding!!.toolbarContent.conversationTitleView.text = when {
|
||||
binding.toolbarContent.conversationTitleView.text = when {
|
||||
recipient.isLocalNumber -> getString(R.string.note_to_self)
|
||||
else -> recipient.toShortString()
|
||||
}
|
||||
@ -463,13 +467,11 @@ class ConversationActivityV2 : PassphraseRequiredActionBarActivity(), InputBarDe
|
||||
R.dimen.small_profile_picture_size
|
||||
}
|
||||
val size = resources.getDimension(sizeID).roundToInt()
|
||||
binding!!.toolbarContent.profilePictureView.root.layoutParams = LinearLayout.LayoutParams(size, size)
|
||||
binding!!.toolbarContent.profilePictureView.root.glide = glide
|
||||
binding.toolbarContent.profilePictureView.root.layoutParams = LinearLayout.LayoutParams(size, size)
|
||||
binding.toolbarContent.profilePictureView.root.glide = glide
|
||||
MentionManagerUtilities.populateUserPublicKeyCacheIfNeeded(viewModel.threadId, this)
|
||||
val profilePictureView = binding!!.toolbarContent.profilePictureView.root
|
||||
viewModel.recipient?.let { recipient ->
|
||||
profilePictureView.update(recipient)
|
||||
}
|
||||
val profilePictureView = binding.toolbarContent.profilePictureView.root
|
||||
viewModel.recipient?.let(profilePictureView::update)
|
||||
}
|
||||
|
||||
// called from onCreate
|
||||
@ -574,7 +576,7 @@ class ConversationActivityV2 : PassphraseRequiredActionBarActivity(), InputBarDe
|
||||
val name = contact?.displayName(Contact.ContactContext.REGULAR) ?: sessionID
|
||||
binding?.blockedBannerTextView?.text = resources.getString(R.string.activity_conversation_blocked_banner_text, name)
|
||||
binding?.blockedBanner?.isVisible = recipient.isBlocked
|
||||
binding?.blockedBanner?.setOnClickListener { viewModel.unblock() }
|
||||
binding?.blockedBanner?.setOnClickListener { viewModel.unblock(this@ConversationActivityV2) }
|
||||
}
|
||||
|
||||
private fun setUpLinkPreviewObserver() {
|
||||
@ -653,7 +655,9 @@ class ConversationActivityV2 : PassphraseRequiredActionBarActivity(), InputBarDe
|
||||
setUpMessageRequestsBar()
|
||||
invalidateOptionsMenu()
|
||||
updateSubtitle()
|
||||
updateSendAfterApprovalText()
|
||||
showOrHideInputIfNeeded()
|
||||
|
||||
binding?.toolbarContent?.profilePictureView?.root?.update(threadRecipient)
|
||||
binding?.toolbarContent?.conversationTitleView?.text = when {
|
||||
threadRecipient.isLocalNumber -> getString(R.string.note_to_self)
|
||||
@ -662,6 +666,10 @@ class ConversationActivityV2 : PassphraseRequiredActionBarActivity(), InputBarDe
|
||||
}
|
||||
}
|
||||
|
||||
private fun updateSendAfterApprovalText() {
|
||||
binding?.textSendAfterApproval?.isVisible = viewModel.showSendAfterApprovalText
|
||||
}
|
||||
|
||||
private fun showOrHideInputIfNeeded() {
|
||||
val recipient = viewModel.recipient
|
||||
if (recipient != null && recipient.isClosedGroupRecipient) {
|
||||
@ -900,15 +908,14 @@ class ConversationActivityV2 : PassphraseRequiredActionBarActivity(), InputBarDe
|
||||
val binding = binding ?: return
|
||||
val wasTypingIndicatorVisibleBefore = binding.typingIndicatorViewContainer.isVisible
|
||||
binding.typingIndicatorViewContainer.isVisible = wasTypingIndicatorVisibleBefore && isScrolledToBottom
|
||||
binding.typingIndicatorViewContainer.isVisible
|
||||
showOrHidScrollToBottomButton()
|
||||
showScrollToBottomButtonIfApplicable()
|
||||
val firstVisiblePosition = layoutManager?.findFirstVisibleItemPosition() ?: -1
|
||||
unreadCount = min(unreadCount, firstVisiblePosition).coerceAtLeast(0)
|
||||
updateUnreadCountIndicator()
|
||||
}
|
||||
|
||||
private fun showOrHidScrollToBottomButton(show: Boolean = true) {
|
||||
binding?.scrollToBottomButton?.isVisible = show && !isScrolledToBottom && adapter.itemCount > 0
|
||||
private fun showScrollToBottomButtonIfApplicable() {
|
||||
binding?.scrollToBottomButton?.isVisible = !emojiPickerVisible && !isScrolledToBottom && adapter.itemCount > 0
|
||||
}
|
||||
|
||||
private fun updateUnreadCountIndicator() {
|
||||
@ -965,7 +972,7 @@ class ConversationActivityV2 : PassphraseRequiredActionBarActivity(), InputBarDe
|
||||
.setMessage(message)
|
||||
.setNegativeButton(android.R.string.cancel, null)
|
||||
.setPositiveButton(R.string.RecipientPreferenceActivity_block) { _, _ ->
|
||||
viewModel.block()
|
||||
viewModel.block(this@ConversationActivityV2)
|
||||
if (deleteThread) {
|
||||
viewModel.deleteThread()
|
||||
finish()
|
||||
@ -1019,7 +1026,7 @@ class ConversationActivityV2 : PassphraseRequiredActionBarActivity(), InputBarDe
|
||||
.setMessage(message)
|
||||
.setNegativeButton(android.R.string.cancel, null)
|
||||
.setPositiveButton(R.string.ConversationActivity_unblock) { _, _ ->
|
||||
viewModel.unblock()
|
||||
viewModel.unblock(this@ConversationActivityV2)
|
||||
}.show()
|
||||
}
|
||||
|
||||
@ -1080,33 +1087,37 @@ class ConversationActivityV2 : PassphraseRequiredActionBarActivity(), InputBarDe
|
||||
Log.e("Loki", "Failed to show emoji picker", e)
|
||||
return
|
||||
}
|
||||
|
||||
val binding = binding ?: return
|
||||
|
||||
emojiPickerVisible = true
|
||||
ViewUtil.hideKeyboard(this, visibleMessageView)
|
||||
binding?.reactionsShade?.isVisible = true
|
||||
showOrHidScrollToBottomButton(false)
|
||||
binding?.conversationRecyclerView?.suppressLayout(true)
|
||||
binding.reactionsShade.isVisible = true
|
||||
binding.scrollToBottomButton.isVisible = false
|
||||
binding.conversationRecyclerView.suppressLayout(true)
|
||||
reactionDelegate.setOnActionSelectedListener(ReactionsToolbarListener(message))
|
||||
reactionDelegate.setOnHideListener(object: ConversationReactionOverlay.OnHideListener {
|
||||
override fun startHide() {
|
||||
binding?.reactionsShade?.let {
|
||||
emojiPickerVisible = false
|
||||
binding.reactionsShade.let {
|
||||
ViewUtil.fadeOut(it, resources.getInteger(R.integer.reaction_scrubber_hide_duration), View.GONE)
|
||||
}
|
||||
showOrHidScrollToBottomButton(true)
|
||||
showScrollToBottomButtonIfApplicable()
|
||||
}
|
||||
|
||||
override fun onHide() {
|
||||
binding?.conversationRecyclerView?.suppressLayout(false)
|
||||
binding.conversationRecyclerView.suppressLayout(false)
|
||||
|
||||
WindowUtil.setLightStatusBarFromTheme(this@ConversationActivityV2);
|
||||
WindowUtil.setLightNavigationBarFromTheme(this@ConversationActivityV2);
|
||||
}
|
||||
|
||||
})
|
||||
val contentBounds = Rect()
|
||||
visibleMessageView.messageContentView.getGlobalVisibleRect(contentBounds)
|
||||
val topLeft = intArrayOf(0, 0).also { visibleMessageView.messageContentView.getLocationInWindow(it) }
|
||||
val selectedConversationModel = SelectedConversationModel(
|
||||
messageContentBitmap,
|
||||
contentBounds.left.toFloat(),
|
||||
contentBounds.top.toFloat(),
|
||||
topLeft[0].toFloat(),
|
||||
topLeft[1].toFloat(),
|
||||
visibleMessageView.messageContentView.width,
|
||||
message.isOutgoing,
|
||||
visibleMessageView.messageContentView
|
||||
@ -1356,7 +1367,7 @@ class ConversationActivityV2 : PassphraseRequiredActionBarActivity(), InputBarDe
|
||||
override fun sendMessage() {
|
||||
val recipient = viewModel.recipient ?: return
|
||||
if (recipient.isContactRecipient && recipient.isBlocked) {
|
||||
BlockedDialog(recipient).show(supportFragmentManager, "Blocked Dialog")
|
||||
BlockedDialog(recipient, this).show(supportFragmentManager, "Blocked Dialog")
|
||||
return
|
||||
}
|
||||
val binding = binding ?: return
|
||||
@ -1747,6 +1758,13 @@ class ConversationActivityV2 : PassphraseRequiredActionBarActivity(), InputBarDe
|
||||
endActionMode()
|
||||
}
|
||||
|
||||
override fun resyncMessage(messages: Set<MessageRecord>) {
|
||||
messages.iterator().forEach { messageRecord ->
|
||||
ResendMessageUtilities.resend(this, messageRecord, viewModel.blindedPublicKey, isResync = true)
|
||||
}
|
||||
endActionMode()
|
||||
}
|
||||
|
||||
override fun resendMessage(messages: Set<MessageRecord>) {
|
||||
messages.iterator().forEach { messageRecord ->
|
||||
ResendMessageUtilities.resend(this, messageRecord, viewModel.blindedPublicKey)
|
||||
@ -1907,6 +1925,7 @@ class ConversationActivityV2 : PassphraseRequiredActionBarActivity(), InputBarDe
|
||||
val selectedItems = setOf(message)
|
||||
when (action) {
|
||||
ConversationReactionOverlay.Action.REPLY -> reply(selectedItems)
|
||||
ConversationReactionOverlay.Action.RESYNC -> resyncMessage(selectedItems)
|
||||
ConversationReactionOverlay.Action.RESEND -> resendMessage(selectedItems)
|
||||
ConversationReactionOverlay.Action.DOWNLOAD -> saveAttachment(selectedItems)
|
||||
ConversationReactionOverlay.Action.COPY_MESSAGE -> copyMessages(selectedItems)
|
||||
|
@ -81,6 +81,8 @@ public final class ConversationReactionOverlay extends FrameLayout {
|
||||
|
||||
private View dropdownAnchor;
|
||||
private LinearLayout conversationItem;
|
||||
private View conversationBubble;
|
||||
private TextView conversationTimestamp;
|
||||
private View backgroundView;
|
||||
private ConstraintLayout foregroundView;
|
||||
private EmojiImageView[] emojiViews;
|
||||
@ -116,6 +118,8 @@ public final class ConversationReactionOverlay extends FrameLayout {
|
||||
|
||||
dropdownAnchor = findViewById(R.id.dropdown_anchor);
|
||||
conversationItem = findViewById(R.id.conversation_item);
|
||||
conversationBubble = conversationItem.findViewById(R.id.conversation_item_bubble);
|
||||
conversationTimestamp = conversationItem.findViewById(R.id.conversation_item_timestamp);
|
||||
backgroundView = findViewById(R.id.conversation_reaction_scrubber_background);
|
||||
foregroundView = findViewById(R.id.conversation_reaction_scrubber_foreground);
|
||||
|
||||
@ -165,10 +169,8 @@ public final class ConversationReactionOverlay extends FrameLayout {
|
||||
|
||||
Bitmap conversationItemSnapshot = selectedConversationModel.getBitmap();
|
||||
|
||||
View conversationBubble = conversationItem.findViewById(R.id.conversation_item_bubble);
|
||||
conversationBubble.setLayoutParams(new LinearLayout.LayoutParams(conversationItemSnapshot.getWidth(), conversationItemSnapshot.getHeight()));
|
||||
conversationBubble.setBackground(new BitmapDrawable(getResources(), conversationItemSnapshot));
|
||||
TextView conversationTimestamp = conversationItem.findViewById(R.id.conversation_item_timestamp);
|
||||
conversationTimestamp.setText(DateUtils.getDisplayFormattedTimeSpanString(getContext(), Locale.getDefault(), messageRecord.getTimestamp()));
|
||||
|
||||
updateConversationTimestamp(messageRecord);
|
||||
@ -190,12 +192,8 @@ public final class ConversationReactionOverlay extends FrameLayout {
|
||||
}
|
||||
|
||||
private void updateConversationTimestamp(MessageRecord message) {
|
||||
View bubble = conversationItem.findViewById(R.id.conversation_item_bubble);
|
||||
View timestamp = conversationItem.findViewById(R.id.conversation_item_timestamp);
|
||||
conversationItem.removeAllViewsInLayout();
|
||||
conversationItem.addView(message.isOutgoing() ? timestamp : bubble);
|
||||
conversationItem.addView(message.isOutgoing() ? bubble : timestamp);
|
||||
conversationItem.requestLayout();
|
||||
if (message.isOutgoing()) conversationBubble.bringToFront();
|
||||
else conversationTimestamp.bringToFront();
|
||||
}
|
||||
|
||||
private void showAfterLayout(@NonNull MessageRecord messageRecord,
|
||||
@ -203,10 +201,11 @@ public final class ConversationReactionOverlay extends FrameLayout {
|
||||
boolean isMessageOnLeft) {
|
||||
contextMenu = new ConversationContextMenu(dropdownAnchor, getMenuActionItems(messageRecord));
|
||||
|
||||
float itemX = isMessageOnLeft ? scrubberHorizontalMargin :
|
||||
float endX = isMessageOnLeft ? scrubberHorizontalMargin :
|
||||
selectedConversationModel.getBubbleX() - conversationItem.getWidth() + selectedConversationModel.getBubbleWidth();
|
||||
conversationItem.setX(itemX);
|
||||
conversationItem.setY(selectedConversationModel.getBubbleY() - statusBarHeight);
|
||||
float endY = selectedConversationModel.getBubbleY() - statusBarHeight;
|
||||
conversationItem.setX(endX);
|
||||
conversationItem.setY(endY);
|
||||
|
||||
Bitmap conversationItemSnapshot = selectedConversationModel.getBitmap();
|
||||
boolean isWideLayout = contextMenu.getMaxWidth() + scrubberWidth < getWidth();
|
||||
@ -214,8 +213,6 @@ public final class ConversationReactionOverlay extends FrameLayout {
|
||||
int overlayHeight = getHeight();
|
||||
int bubbleWidth = selectedConversationModel.getBubbleWidth();
|
||||
|
||||
float endX = itemX;
|
||||
float endY = conversationItem.getY();
|
||||
float endApparentTop = endY;
|
||||
float endScale = 1f;
|
||||
|
||||
@ -265,9 +262,7 @@ public final class ConversationReactionOverlay extends FrameLayout {
|
||||
}
|
||||
} else {
|
||||
endY = overlayHeight - contextMenu.getMaxHeight() - menuPadding - conversationItemSnapshot.getHeight();
|
||||
|
||||
float contextMenuTop = endY + conversationItemSnapshot.getHeight();
|
||||
reactionBarBackgroundY = getReactionBarOffsetForTouch(selectedConversationModel.getBubbleY(), contextMenuTop, menuPadding, reactionBarOffset, reactionBarHeight, reactionBarTopPadding, endY);
|
||||
reactionBarBackgroundY = endY - reactionBarHeight - menuPadding;
|
||||
}
|
||||
|
||||
endApparentTop = endY;
|
||||
@ -354,11 +349,14 @@ public final class ConversationReactionOverlay extends FrameLayout {
|
||||
|
||||
int revealDuration = getContext().getResources().getInteger(R.integer.reaction_scrubber_reveal_duration);
|
||||
|
||||
conversationBubble.animate()
|
||||
.scaleX(endScale)
|
||||
.scaleY(endScale)
|
||||
.setDuration(revealDuration);
|
||||
|
||||
conversationItem.animate()
|
||||
.x(endX)
|
||||
.y(endY)
|
||||
.scaleX(endScale)
|
||||
.scaleY(endScale)
|
||||
.setDuration(revealDuration);
|
||||
}
|
||||
|
||||
@ -663,7 +661,8 @@ public final class ConversationReactionOverlay extends FrameLayout {
|
||||
items.add(new ActionItem(R.attr.menu_select_icon, getContext().getResources().getString(R.string.conversation_context__menu_select), () -> handleActionItemClicked(Action.SELECT),
|
||||
getContext().getResources().getString(R.string.AccessibilityId_select)));
|
||||
// Reply
|
||||
if (!message.isPending() && !message.isFailed()) {
|
||||
boolean canWrite = openGroup == null || openGroup.getCanWrite();
|
||||
if (canWrite && !message.isPending() && !message.isFailed()) {
|
||||
items.add(
|
||||
new ActionItem(R.attr.menu_reply_icon, getContext().getResources().getString(R.string.conversation_context__menu_reply), () -> handleActionItemClicked(Action.REPLY),
|
||||
getContext().getResources().getString(R.string.AccessibilityId_reply_message))
|
||||
@ -703,6 +702,10 @@ public final class ConversationReactionOverlay extends FrameLayout {
|
||||
if (message.isFailed()) {
|
||||
items.add(new ActionItem(R.attr.menu_reply_icon, getContext().getResources().getString(R.string.conversation_context__menu_resend_message), () -> handleActionItemClicked(Action.RESEND)));
|
||||
}
|
||||
// Resync
|
||||
if (message.isSyncFailed()) {
|
||||
items.add(new ActionItem(R.attr.menu_reply_icon, getContext().getResources().getString(R.string.conversation_context__menu_resync_message), () -> handleActionItemClicked(Action.RESYNC)));
|
||||
}
|
||||
// Save media
|
||||
if (message.isMms() && ((MediaMmsMessageRecord)message).containsMediaSlide()) {
|
||||
items.add(new ActionItem(R.attr.menu_save_icon, getContext().getResources().getString(R.string.conversation_context_image__save_attachment), () -> handleActionItemClicked(Action.DOWNLOAD),
|
||||
@ -888,6 +891,7 @@ public final class ConversationReactionOverlay extends FrameLayout {
|
||||
public enum Action {
|
||||
REPLY,
|
||||
RESEND,
|
||||
RESYNC,
|
||||
DOWNLOAD,
|
||||
COPY_MESSAGE,
|
||||
COPY_SESSION_ID,
|
||||
|
@ -1,7 +1,9 @@
|
||||
package org.thoughtcrime.securesms.conversation.v2
|
||||
|
||||
import android.content.Context
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.ViewModelProvider
|
||||
import androidx.lifecycle.lifecycleScope
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import com.goterl.lazysodium.utils.KeyPair
|
||||
import dagger.assisted.Assisted
|
||||
@ -22,6 +24,7 @@ import org.session.libsignal.utilities.Log
|
||||
import org.thoughtcrime.securesms.database.Storage
|
||||
import org.thoughtcrime.securesms.database.model.MessageRecord
|
||||
import org.thoughtcrime.securesms.repository.ConversationRepository
|
||||
import org.thoughtcrime.securesms.util.ConfigurationMessageUtilities
|
||||
import java.util.UUID
|
||||
|
||||
class ConversationViewModel(
|
||||
@ -31,6 +34,9 @@ class ConversationViewModel(
|
||||
private val storage: Storage
|
||||
) : ViewModel() {
|
||||
|
||||
val showSendAfterApprovalText: Boolean
|
||||
get() = recipient?.run { isContactRecipient && !isLocalNumber && !hasApprovedMe() } ?: false
|
||||
|
||||
private val _uiState = MutableStateFlow(ConversationUiState())
|
||||
val uiState: StateFlow<ConversationUiState> = _uiState
|
||||
|
||||
@ -75,17 +81,27 @@ class ConversationViewModel(
|
||||
repository.inviteContacts(threadId, contacts)
|
||||
}
|
||||
|
||||
fun block() {
|
||||
fun block(context: Context) {
|
||||
val recipient = recipient ?: return Log.w("Loki", "Recipient was null for block action")
|
||||
if (recipient.isContactRecipient) {
|
||||
repository.setBlocked(recipient, true)
|
||||
|
||||
// TODO: Remove in UserConfig branch
|
||||
GlobalScope.launch(Dispatchers.IO) {
|
||||
ConfigurationMessageUtilities.forceSyncConfigurationNowIfNeeded(context)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun unblock() {
|
||||
fun unblock(context: Context) {
|
||||
val recipient = recipient ?: return Log.w("Loki", "Recipient was null for unblock action")
|
||||
if (recipient.isContactRecipient) {
|
||||
repository.setBlocked(recipient, false)
|
||||
|
||||
// TODO: Remove in UserConfig branch
|
||||
GlobalScope.launch(Dispatchers.IO) {
|
||||
ConfigurationMessageUtilities.forceSyncConfigurationNowIfNeeded(context)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -69,7 +69,6 @@ class DeleteOptionsBottomSheet : BottomSheetDialogFragment(), View.OnClickListen
|
||||
override fun onStart() {
|
||||
super.onStart()
|
||||
val window = dialog?.window ?: return
|
||||
val isLightMode = UiModeUtilities.isDayUiMode(requireContext())
|
||||
window.setDimAmount(if (isLightMode) 0.1f else 0.75f)
|
||||
window.setDimAmount(0.6f)
|
||||
}
|
||||
}
|
@ -60,8 +60,7 @@ class ModalUrlBottomSheet(private val url: String): BottomSheetDialogFragment(),
|
||||
override fun onStart() {
|
||||
super.onStart()
|
||||
val window = dialog?.window ?: return
|
||||
val isLightMode = UiModeUtilities.isDayUiMode(requireContext())
|
||||
window.setDimAmount(if (isLightMode) 0.1f else 0.75f)
|
||||
window.setDimAmount(0.6f)
|
||||
}
|
||||
|
||||
override fun onClick(v: View?) {
|
||||
|
@ -38,14 +38,10 @@ public final class WindowUtil {
|
||||
}
|
||||
|
||||
public static void setNavigationBarColor(@NonNull Window window, @ColorInt int color) {
|
||||
if (Build.VERSION.SDK_INT < 21) return;
|
||||
|
||||
window.setNavigationBarColor(color);
|
||||
}
|
||||
|
||||
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());
|
||||
@ -53,20 +49,14 @@ public final class WindowUtil {
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
|
||||
public static void setStatusBarColor(@NonNull Window window, @ColorInt int color) {
|
||||
if (Build.VERSION.SDK_INT < 21) return;
|
||||
|
||||
window.setStatusBarColor(color);
|
||||
}
|
||||
|
||||
|
@ -1,20 +1,25 @@
|
||||
package org.thoughtcrime.securesms.conversation.v2.dialogs
|
||||
|
||||
import android.content.Context
|
||||
import android.graphics.Typeface
|
||||
import android.text.Spannable
|
||||
import android.text.SpannableStringBuilder
|
||||
import android.text.style.StyleSpan
|
||||
import android.view.LayoutInflater
|
||||
import androidx.appcompat.app.AlertDialog
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.GlobalScope
|
||||
import kotlinx.coroutines.launch
|
||||
import network.loki.messenger.R
|
||||
import network.loki.messenger.databinding.DialogBlockedBinding
|
||||
import org.session.libsession.messaging.contacts.Contact
|
||||
import org.session.libsession.utilities.recipients.Recipient
|
||||
import org.thoughtcrime.securesms.conversation.v2.utilities.BaseDialog
|
||||
import org.thoughtcrime.securesms.dependencies.DatabaseComponent
|
||||
import org.thoughtcrime.securesms.util.ConfigurationMessageUtilities
|
||||
|
||||
/** Shown upon sending a message to a user that's blocked. */
|
||||
class BlockedDialog(private val recipient: Recipient) : BaseDialog() {
|
||||
class BlockedDialog(private val recipient: Recipient, private val context: Context) : BaseDialog() {
|
||||
|
||||
override fun setContentView(builder: AlertDialog.Builder) {
|
||||
val binding = DialogBlockedBinding.inflate(LayoutInflater.from(requireContext()))
|
||||
@ -37,5 +42,10 @@ class BlockedDialog(private val recipient: Recipient) : BaseDialog() {
|
||||
private fun unblock() {
|
||||
DatabaseComponent.get(requireContext()).recipientDatabase().setBlocked(recipient, false)
|
||||
dismiss()
|
||||
|
||||
// TODO: Remove in UserConfig branch
|
||||
GlobalScope.launch(Dispatchers.IO) {
|
||||
ConfigurationMessageUtilities.forceSyncConfigurationNowIfNeeded(context)
|
||||
}
|
||||
}
|
||||
}
|
@ -70,6 +70,8 @@ class ConversationActionModeCallback(private val adapter: ConversationAdapter, p
|
||||
menu.findItem(R.id.menu_message_details).isVisible = (selectedItems.size == 1 && firstMessage.isOutgoing)
|
||||
// Resend
|
||||
menu.findItem(R.id.menu_context_resend).isVisible = (selectedItems.size == 1 && firstMessage.isFailed)
|
||||
// Resync
|
||||
menu.findItem(R.id.menu_context_resync).isVisible = (selectedItems.size == 1 && firstMessage.isSyncFailed)
|
||||
// Save media
|
||||
menu.findItem(R.id.menu_context_save_attachment).isVisible = (selectedItems.size == 1
|
||||
&& firstMessage.isMms && (firstMessage as MediaMmsMessageRecord).containsMediaSlide())
|
||||
@ -90,6 +92,7 @@ class ConversationActionModeCallback(private val adapter: ConversationAdapter, p
|
||||
R.id.menu_context_ban_and_delete_all -> delegate?.banAndDeleteAll(selectedItems)
|
||||
R.id.menu_context_copy -> delegate?.copyMessages(selectedItems)
|
||||
R.id.menu_context_copy_public_key -> delegate?.copySessionID(selectedItems)
|
||||
R.id.menu_context_resync -> delegate?.resyncMessage(selectedItems)
|
||||
R.id.menu_context_resend -> delegate?.resendMessage(selectedItems)
|
||||
R.id.menu_message_details -> delegate?.showMessageDetail(selectedItems)
|
||||
R.id.menu_context_save_attachment -> delegate?.saveAttachment(selectedItems)
|
||||
@ -113,6 +116,7 @@ interface ConversationActionModeCallbackDelegate {
|
||||
fun banAndDeleteAll(messages: Set<MessageRecord>)
|
||||
fun copyMessages(messages: Set<MessageRecord>)
|
||||
fun copySessionID(messages: Set<MessageRecord>)
|
||||
fun resyncMessage(messages: Set<MessageRecord>)
|
||||
fun resendMessage(messages: Set<MessageRecord>)
|
||||
fun showMessageDetail(messages: Set<MessageRecord>)
|
||||
fun saveAttachment(messages: Set<MessageRecord>)
|
||||
|
@ -292,50 +292,55 @@ class VisibleMessageView : LinearLayout {
|
||||
@StringRes val messageText: Int?,
|
||||
val contentDescription: String?)
|
||||
|
||||
private fun getMessageStatusImage(message: MessageRecord): MessageStatusInfo {
|
||||
return when {
|
||||
!message.isOutgoing -> MessageStatusInfo(null,
|
||||
null,
|
||||
null,
|
||||
null)
|
||||
message.isFailed ->
|
||||
MessageStatusInfo(
|
||||
R.drawable.ic_delivery_status_failed,
|
||||
resources.getColor(R.color.destructive, context.theme),
|
||||
R.string.delivery_status_failed,
|
||||
null
|
||||
)
|
||||
message.isPending ->
|
||||
MessageStatusInfo(
|
||||
R.drawable.ic_delivery_status_sending,
|
||||
context.getColorFromAttr(R.attr.message_status_color), R.string.delivery_status_sending,
|
||||
context.getString(R.string.AccessibilityId_message_sent_status_pending)
|
||||
)
|
||||
message.isRead ->
|
||||
MessageStatusInfo(
|
||||
R.drawable.ic_delivery_status_read,
|
||||
context.getColorFromAttr(R.attr.message_status_color), R.string.delivery_status_read,
|
||||
null
|
||||
)
|
||||
else ->
|
||||
MessageStatusInfo(
|
||||
R.drawable.ic_delivery_status_sent,
|
||||
context.getColorFromAttr(R.attr.message_status_color),
|
||||
R.string.delivery_status_sent,
|
||||
context.getString(R.string.AccessibilityId_message_sent_status_tick)
|
||||
)
|
||||
}
|
||||
private fun getMessageStatusImage(message: MessageRecord): MessageStatusInfo = when {
|
||||
message.isFailed ->
|
||||
MessageStatusInfo(
|
||||
R.drawable.ic_delivery_status_failed,
|
||||
resources.getColor(R.color.destructive, context.theme),
|
||||
R.string.delivery_status_failed,
|
||||
null
|
||||
)
|
||||
message.isSyncFailed ->
|
||||
MessageStatusInfo(
|
||||
R.drawable.ic_delivery_status_failed,
|
||||
context.getColor(R.color.accent_orange),
|
||||
R.string.delivery_status_sync_failed,
|
||||
null
|
||||
)
|
||||
message.isPending ->
|
||||
MessageStatusInfo(
|
||||
R.drawable.ic_delivery_status_sending,
|
||||
context.getColorFromAttr(R.attr.message_status_color), R.string.delivery_status_sending,
|
||||
context.getString(R.string.AccessibilityId_message_sent_status_pending)
|
||||
)
|
||||
message.isResyncing ->
|
||||
MessageStatusInfo(
|
||||
R.drawable.ic_delivery_status_sending,
|
||||
context.getColor(R.color.accent_orange), R.string.delivery_status_syncing,
|
||||
context.getString(R.string.AccessibilityId_message_sent_status_syncing)
|
||||
)
|
||||
message.isRead ->
|
||||
MessageStatusInfo(
|
||||
R.drawable.ic_delivery_status_read,
|
||||
context.getColorFromAttr(R.attr.message_status_color), R.string.delivery_status_read,
|
||||
null
|
||||
)
|
||||
else ->
|
||||
MessageStatusInfo(
|
||||
R.drawable.ic_delivery_status_sent,
|
||||
context.getColorFromAttr(R.attr.message_status_color),
|
||||
R.string.delivery_status_sent,
|
||||
context.getString(R.string.AccessibilityId_message_sent_status_tick)
|
||||
)
|
||||
}
|
||||
|
||||
private fun updateExpirationTimer(message: MessageRecord) {
|
||||
val container = binding.messageInnerContainer
|
||||
val content = binding.messageContentView.root
|
||||
val expiration = binding.expirationTimerView
|
||||
val spacing = binding.messageContentSpacing
|
||||
container.removeAllViewsInLayout()
|
||||
container.addView(if (message.isOutgoing) expiration else content)
|
||||
container.addView(if (message.isOutgoing) content else expiration)
|
||||
container.addView(spacing, if (message.isOutgoing) 0 else 2)
|
||||
val containerParams = container.layoutParams as ConstraintLayout.LayoutParams
|
||||
containerParams.horizontalBias = if (message.isOutgoing) 1f else 0f
|
||||
container.layoutParams = containerParams
|
||||
|
@ -14,9 +14,7 @@ open class BaseDialog : DialogFragment() {
|
||||
val builder = AlertDialog.Builder(requireContext())
|
||||
setContentView(builder)
|
||||
val result = builder.create()
|
||||
result.window?.setBackgroundDrawable(ColorDrawable(Color.TRANSPARENT))
|
||||
val isLightMode = UiModeUtilities.isDayUiMode(requireContext())
|
||||
result.window?.setDimAmount(if (isLightMode) 0.1f else 0.75f)
|
||||
result.window?.setDimAmount(0.6f)
|
||||
return result
|
||||
}
|
||||
|
||||
|
@ -2,6 +2,7 @@ package org.thoughtcrime.securesms.conversation.v2.utilities
|
||||
|
||||
import android.content.Context
|
||||
import org.session.libsession.messaging.MessagingModuleConfiguration
|
||||
import org.session.libsession.messaging.messages.Destination
|
||||
import org.session.libsession.messaging.messages.visible.LinkPreview
|
||||
import org.session.libsession.messaging.messages.visible.OpenGroupInvitation
|
||||
import org.session.libsession.messaging.messages.visible.Quote
|
||||
@ -15,7 +16,7 @@ import org.thoughtcrime.securesms.database.model.MmsMessageRecord
|
||||
|
||||
object ResendMessageUtilities {
|
||||
|
||||
fun resend(context: Context, messageRecord: MessageRecord, userBlindedKey: String?) {
|
||||
fun resend(context: Context, messageRecord: MessageRecord, userBlindedKey: String?, isResync: Boolean = false) {
|
||||
val recipient: Recipient = messageRecord.recipient
|
||||
val message = VisibleMessage()
|
||||
message.id = messageRecord.getId()
|
||||
@ -55,8 +56,13 @@ object ResendMessageUtilities {
|
||||
val sentTimestamp = message.sentTimestamp
|
||||
val sender = MessagingModuleConfiguration.shared.storage.getUserPublicKey()
|
||||
if (sentTimestamp != null && sender != null) {
|
||||
MessagingModuleConfiguration.shared.storage.markAsSending(sentTimestamp, sender)
|
||||
if (isResync) {
|
||||
MessagingModuleConfiguration.shared.storage.markAsResyncing(sentTimestamp, sender)
|
||||
MessageSender.send(message, Destination.from(recipient.address), isSyncMessage = true)
|
||||
} else {
|
||||
MessagingModuleConfiguration.shared.storage.markAsSending(sentTimestamp, sender)
|
||||
MessageSender.send(message, recipient.address)
|
||||
}
|
||||
}
|
||||
MessageSender.send(message, recipient.address)
|
||||
}
|
||||
}
|
@ -38,13 +38,12 @@ object TextUtilities {
|
||||
fun TextView.getIntersectedModalSpans(hitRect: Rect): List<ModalURLSpan> {
|
||||
val textLayout = layout ?: return emptyList()
|
||||
val lineRect = Rect()
|
||||
val bodyTextRect = Rect()
|
||||
getGlobalVisibleRect(bodyTextRect)
|
||||
val offset = intArrayOf(0, 0).also { getLocationOnScreen(it) }
|
||||
val textSpannable = text.toSpannable()
|
||||
return (0 until textLayout.lineCount).flatMap { line ->
|
||||
textLayout.getLineBounds(line, lineRect)
|
||||
lineRect.offset(bodyTextRect.left + totalPaddingLeft, bodyTextRect.top + totalPaddingTop)
|
||||
if ((Rect(lineRect)).contains(hitRect)) {
|
||||
lineRect.offset(offset[0] + totalPaddingLeft, offset[1] + totalPaddingTop)
|
||||
if (lineRect.contains(hitRect)) {
|
||||
// calculate the url span intersected with (if any)
|
||||
val off = textLayout.getOffsetForHorizontal(line, hitRect.left.toFloat()) // left and right will be the same
|
||||
textSpannable.getSpans<ModalURLSpan>(off, off).toList()
|
||||
|
@ -126,10 +126,9 @@ open class ThumbnailView: FrameLayout {
|
||||
buildThumbnailGlideRequest(glide, slide).into(GlideDrawableListeningTarget(binding.thumbnailImage, binding.thumbnailLoadIndicator, result))
|
||||
}
|
||||
slide.hasPlaceholder() -> {
|
||||
buildPlaceholderGlideRequest(glide, slide).into(GlideBitmapListeningTarget(binding.thumbnailImage, binding.thumbnailLoadIndicator, result))
|
||||
buildPlaceholderGlideRequest(glide, slide).into(GlideBitmapListeningTarget(binding.thumbnailImage, null, result))
|
||||
}
|
||||
else -> {
|
||||
binding.thumbnailLoadIndicator.isVisible = false
|
||||
glide.clear(binding.thumbnailImage)
|
||||
result.set(false)
|
||||
}
|
||||
|
@ -1,13 +1,13 @@
|
||||
package org.thoughtcrime.securesms.crypto;
|
||||
|
||||
|
||||
import android.os.Build;
|
||||
import static org.session.libsignal.crypto.CipherUtil.CIPHER_LOCK;
|
||||
|
||||
import android.security.keystore.KeyGenParameterSpec;
|
||||
import android.security.keystore.KeyProperties;
|
||||
import android.util.Base64;
|
||||
|
||||
import androidx.annotation.NonNull;
|
||||
import androidx.annotation.RequiresApi;
|
||||
|
||||
import com.fasterxml.jackson.annotation.JsonProperty;
|
||||
import com.fasterxml.jackson.core.JsonGenerator;
|
||||
@ -45,44 +45,50 @@ public final class KeyStoreHelper {
|
||||
private static final String ANDROID_KEY_STORE = "AndroidKeyStore";
|
||||
private static final String KEY_ALIAS = "SignalSecret";
|
||||
|
||||
@RequiresApi(Build.VERSION_CODES.M)
|
||||
public static SealedData seal(@NonNull byte[] input) {
|
||||
SecretKey secretKey = getOrCreateKeyStoreEntry();
|
||||
|
||||
try {
|
||||
Cipher cipher = Cipher.getInstance("AES/GCM/NoPadding");
|
||||
cipher.init(Cipher.ENCRYPT_MODE, secretKey);
|
||||
// Cipher operations are not thread-safe so we synchronize over them through doFinal to
|
||||
// prevent crashes with quickly repeated encrypt/decrypt operations
|
||||
// https://github.com/mozilla-mobile/android-components/issues/5342
|
||||
synchronized (CIPHER_LOCK) {
|
||||
Cipher cipher = Cipher.getInstance("AES/GCM/NoPadding");
|
||||
cipher.init(Cipher.ENCRYPT_MODE, secretKey);
|
||||
|
||||
byte[] iv = cipher.getIV();
|
||||
byte[] data = cipher.doFinal(input);
|
||||
byte[] iv = cipher.getIV();
|
||||
byte[] data = cipher.doFinal(input);
|
||||
|
||||
return new SealedData(iv, data);
|
||||
return new SealedData(iv, data);
|
||||
}
|
||||
} catch (NoSuchAlgorithmException | NoSuchPaddingException | InvalidKeyException | IllegalBlockSizeException | BadPaddingException e) {
|
||||
throw new AssertionError(e);
|
||||
}
|
||||
}
|
||||
|
||||
@RequiresApi(Build.VERSION_CODES.M)
|
||||
public static byte[] unseal(@NonNull SealedData sealedData) {
|
||||
SecretKey secretKey = getKeyStoreEntry();
|
||||
|
||||
try {
|
||||
Cipher cipher = Cipher.getInstance("AES/GCM/NoPadding");
|
||||
cipher.init(Cipher.DECRYPT_MODE, secretKey, new GCMParameterSpec(128, sealedData.iv));
|
||||
// Cipher operations are not thread-safe so we synchronize over them through doFinal to
|
||||
// prevent crashes with quickly repeated encrypt/decrypt operations
|
||||
// https://github.com/mozilla-mobile/android-components/issues/5342
|
||||
synchronized (CIPHER_LOCK) {
|
||||
Cipher cipher = Cipher.getInstance("AES/GCM/NoPadding");
|
||||
cipher.init(Cipher.DECRYPT_MODE, secretKey, new GCMParameterSpec(128, sealedData.iv));
|
||||
|
||||
return cipher.doFinal(sealedData.data);
|
||||
return cipher.doFinal(sealedData.data);
|
||||
}
|
||||
} catch (NoSuchAlgorithmException | NoSuchPaddingException | InvalidKeyException | InvalidAlgorithmParameterException | IllegalBlockSizeException | BadPaddingException e) {
|
||||
throw new AssertionError(e);
|
||||
}
|
||||
}
|
||||
|
||||
@RequiresApi(Build.VERSION_CODES.M)
|
||||
private static SecretKey getOrCreateKeyStoreEntry() {
|
||||
if (hasKeyStoreEntry()) return getKeyStoreEntry();
|
||||
else return createKeyStoreEntry();
|
||||
}
|
||||
|
||||
@RequiresApi(Build.VERSION_CODES.M)
|
||||
private static SecretKey createKeyStoreEntry() {
|
||||
try {
|
||||
KeyGenerator keyGenerator = KeyGenerator.getInstance(KeyProperties.KEY_ALGORITHM_AES, ANDROID_KEY_STORE);
|
||||
@ -99,7 +105,6 @@ public final class KeyStoreHelper {
|
||||
}
|
||||
}
|
||||
|
||||
@RequiresApi(Build.VERSION_CODES.M)
|
||||
private static SecretKey getKeyStoreEntry() {
|
||||
KeyStore keyStore = getKeyStore();
|
||||
|
||||
@ -137,7 +142,6 @@ public final class KeyStoreHelper {
|
||||
}
|
||||
}
|
||||
|
||||
@RequiresApi(Build.VERSION_CODES.M)
|
||||
private static boolean hasKeyStoreEntry() {
|
||||
try {
|
||||
KeyStore ks = KeyStore.getInstance(ANDROID_KEY_STORE);
|
||||
@ -202,7 +206,5 @@ public final class KeyStoreHelper {
|
||||
return Base64.decode(p.getValueAsString(), Base64.NO_WRAP | Base64.NO_PADDING);
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
|
@ -1,249 +0,0 @@
|
||||
package org.thoughtcrime.securesms.database;
|
||||
|
||||
import android.content.ContentValues;
|
||||
import android.content.Context;
|
||||
import android.database.Cursor;
|
||||
import androidx.annotation.NonNull;
|
||||
|
||||
import net.zetetic.database.sqlcipher.SQLiteDatabase;
|
||||
|
||||
import org.thoughtcrime.securesms.database.helpers.SQLCipherOpenHelper;
|
||||
import org.thoughtcrime.securesms.jobmanager.persistence.ConstraintSpec;
|
||||
import org.thoughtcrime.securesms.jobmanager.persistence.DependencySpec;
|
||||
import org.thoughtcrime.securesms.jobmanager.persistence.FullSpec;
|
||||
import org.thoughtcrime.securesms.jobmanager.persistence.JobSpec;
|
||||
|
||||
import java.util.LinkedList;
|
||||
import java.util.List;
|
||||
|
||||
public class JobDatabase extends Database {
|
||||
|
||||
public static final String[] CREATE_TABLE = new String[] { Jobs.CREATE_TABLE,
|
||||
Constraints.CREATE_TABLE,
|
||||
Dependencies.CREATE_TABLE };
|
||||
|
||||
public static final class Jobs {
|
||||
public static final String TABLE_NAME = "job_spec";
|
||||
private static final String ID = "_id";
|
||||
private static final String JOB_SPEC_ID = "job_spec_id";
|
||||
private static final String FACTORY_KEY = "factory_key";
|
||||
private static final String QUEUE_KEY = "queue_key";
|
||||
private static final String CREATE_TIME = "create_time";
|
||||
private static final String NEXT_RUN_ATTEMPT_TIME = "next_run_attempt_time";
|
||||
private static final String RUN_ATTEMPT = "run_attempt";
|
||||
private static final String MAX_ATTEMPTS = "max_attempts";
|
||||
private static final String MAX_BACKOFF = "max_backoff";
|
||||
private static final String MAX_INSTANCES = "max_instances";
|
||||
private static final String LIFESPAN = "lifespan";
|
||||
private static final String SERIALIZED_DATA = "serialized_data";
|
||||
private static final String IS_RUNNING = "is_running";
|
||||
|
||||
private static final String CREATE_TABLE = "CREATE TABLE " + TABLE_NAME + "(" + ID + " INTEGER PRIMARY KEY AUTOINCREMENT, " +
|
||||
JOB_SPEC_ID + " TEXT UNIQUE, " +
|
||||
FACTORY_KEY + " TEXT, " +
|
||||
QUEUE_KEY + " TEXT, " +
|
||||
CREATE_TIME + " INTEGER, " +
|
||||
NEXT_RUN_ATTEMPT_TIME + " INTEGER, " +
|
||||
RUN_ATTEMPT + " INTEGER, " +
|
||||
MAX_ATTEMPTS + " INTEGER, " +
|
||||
MAX_BACKOFF + " INTEGER, " +
|
||||
MAX_INSTANCES + " INTEGER, " +
|
||||
LIFESPAN + " INTEGER, " +
|
||||
SERIALIZED_DATA + " TEXT, " +
|
||||
IS_RUNNING + " INTEGER)";
|
||||
}
|
||||
|
||||
public static final class Constraints {
|
||||
public static final String TABLE_NAME = "constraint_spec";
|
||||
private static final String ID = "_id";
|
||||
private static final String JOB_SPEC_ID = "job_spec_id";
|
||||
private static final String FACTORY_KEY = "factory_key";
|
||||
|
||||
private static final String CREATE_TABLE = "CREATE TABLE " + TABLE_NAME + "(" + ID + " INTEGER PRIMARY KEY AUTOINCREMENT, " +
|
||||
JOB_SPEC_ID + " TEXT, " +
|
||||
FACTORY_KEY + " TEXT, " +
|
||||
"UNIQUE(" + JOB_SPEC_ID + ", " + FACTORY_KEY + "))";
|
||||
}
|
||||
|
||||
public static final class Dependencies {
|
||||
public static final String TABLE_NAME = "dependency_spec";
|
||||
private static final String ID = "_id";
|
||||
private static final String JOB_SPEC_ID = "job_spec_id";
|
||||
private static final String DEPENDS_ON_JOB_SPEC_ID = "depends_on_job_spec_id";
|
||||
|
||||
private static final String CREATE_TABLE = "CREATE TABLE " + TABLE_NAME + "(" + ID + " INTEGER PRIMARY KEY AUTOINCREMENT, " +
|
||||
JOB_SPEC_ID + " TEXT, " +
|
||||
DEPENDS_ON_JOB_SPEC_ID + " TEXT, " +
|
||||
"UNIQUE(" + JOB_SPEC_ID + ", " + DEPENDS_ON_JOB_SPEC_ID + "))";
|
||||
}
|
||||
|
||||
|
||||
public JobDatabase(Context context, SQLCipherOpenHelper databaseHelper) {
|
||||
super(context, databaseHelper);
|
||||
}
|
||||
|
||||
public synchronized void insertJobs(@NonNull List<FullSpec> fullSpecs) {
|
||||
SQLiteDatabase db = databaseHelper.getWritableDatabase();
|
||||
|
||||
db.beginTransaction();
|
||||
|
||||
try {
|
||||
for (FullSpec fullSpec : fullSpecs) {
|
||||
insertJobSpec(db, fullSpec.getJobSpec());
|
||||
insertConstraintSpecs(db, fullSpec.getConstraintSpecs());
|
||||
insertDependencySpecs(db, fullSpec.getDependencySpecs());
|
||||
}
|
||||
|
||||
db.setTransactionSuccessful();
|
||||
} finally {
|
||||
db.endTransaction();
|
||||
}
|
||||
}
|
||||
|
||||
public synchronized @NonNull List<JobSpec> getAllJobSpecs() {
|
||||
List<JobSpec> jobs = new LinkedList<>();
|
||||
|
||||
try (Cursor cursor = databaseHelper.getReadableDatabase().query(Jobs.TABLE_NAME, null, null, null, null, null, Jobs.CREATE_TIME + ", " + Jobs.ID + " ASC")) {
|
||||
while (cursor != null && cursor.moveToNext()) {
|
||||
jobs.add(jobSpecFromCursor(cursor));
|
||||
}
|
||||
}
|
||||
|
||||
return jobs;
|
||||
}
|
||||
|
||||
public synchronized void updateJobRunningState(@NonNull String id, boolean isRunning) {
|
||||
ContentValues contentValues = new ContentValues();
|
||||
contentValues.put(Jobs.IS_RUNNING, isRunning ? 1 : 0);
|
||||
|
||||
String query = Jobs.JOB_SPEC_ID + " = ?";
|
||||
String[] args = new String[]{ id };
|
||||
|
||||
databaseHelper.getWritableDatabase().update(Jobs.TABLE_NAME, contentValues, query, args);
|
||||
}
|
||||
|
||||
public synchronized void updateJobAfterRetry(@NonNull String id, boolean isRunning, int runAttempt, long nextRunAttemptTime) {
|
||||
ContentValues contentValues = new ContentValues();
|
||||
contentValues.put(Jobs.IS_RUNNING, isRunning ? 1 : 0);
|
||||
contentValues.put(Jobs.RUN_ATTEMPT, runAttempt);
|
||||
contentValues.put(Jobs.NEXT_RUN_ATTEMPT_TIME, nextRunAttemptTime);
|
||||
|
||||
String query = Jobs.JOB_SPEC_ID + " = ?";
|
||||
String[] args = new String[]{ id };
|
||||
|
||||
databaseHelper.getWritableDatabase().update(Jobs.TABLE_NAME, contentValues, query, args);
|
||||
}
|
||||
|
||||
public synchronized void updateAllJobsToBePending() {
|
||||
ContentValues contentValues = new ContentValues();
|
||||
contentValues.put(Jobs.IS_RUNNING, 0);
|
||||
|
||||
databaseHelper.getWritableDatabase().update(Jobs.TABLE_NAME, contentValues, null, null);
|
||||
}
|
||||
|
||||
public synchronized void deleteJobs(@NonNull List<String> jobIds) {
|
||||
SQLiteDatabase db = databaseHelper.getWritableDatabase();
|
||||
|
||||
db.beginTransaction();
|
||||
|
||||
try {
|
||||
for (String jobId : jobIds) {
|
||||
String[] arg = new String[]{jobId};
|
||||
|
||||
db.delete(Jobs.TABLE_NAME, Jobs.JOB_SPEC_ID + " = ?", arg);
|
||||
db.delete(Constraints.TABLE_NAME, Constraints.JOB_SPEC_ID + " = ?", arg);
|
||||
db.delete(Dependencies.TABLE_NAME, Dependencies.JOB_SPEC_ID + " = ?", arg);
|
||||
db.delete(Dependencies.TABLE_NAME, Dependencies.DEPENDS_ON_JOB_SPEC_ID + " = ?", arg);
|
||||
}
|
||||
|
||||
db.setTransactionSuccessful();
|
||||
} finally {
|
||||
db.endTransaction();
|
||||
}
|
||||
}
|
||||
|
||||
public synchronized @NonNull List<ConstraintSpec> getAllConstraintSpecs() {
|
||||
List<ConstraintSpec> constraints = new LinkedList<>();
|
||||
|
||||
try (Cursor cursor = databaseHelper.getReadableDatabase().query(Constraints.TABLE_NAME, null, null, null, null, null, null)) {
|
||||
while (cursor != null && cursor.moveToNext()) {
|
||||
constraints.add(constraintSpecFromCursor(cursor));
|
||||
}
|
||||
}
|
||||
|
||||
return constraints;
|
||||
}
|
||||
|
||||
public synchronized @NonNull List<DependencySpec> getAllDependencySpecs() {
|
||||
List<DependencySpec> dependencies = new LinkedList<>();
|
||||
|
||||
try (Cursor cursor = databaseHelper.getReadableDatabase().query(Dependencies.TABLE_NAME, null, null, null, null, null, null)) {
|
||||
while (cursor != null && cursor.moveToNext()) {
|
||||
dependencies.add(dependencySpecFromCursor(cursor));
|
||||
}
|
||||
}
|
||||
|
||||
return dependencies;
|
||||
}
|
||||
|
||||
private void insertJobSpec(@NonNull SQLiteDatabase db, @NonNull JobSpec job) {
|
||||
ContentValues contentValues = new ContentValues();
|
||||
contentValues.put(Jobs.JOB_SPEC_ID, job.getId());
|
||||
contentValues.put(Jobs.FACTORY_KEY, job.getFactoryKey());
|
||||
contentValues.put(Jobs.QUEUE_KEY, job.getQueueKey());
|
||||
contentValues.put(Jobs.CREATE_TIME, job.getCreateTime());
|
||||
contentValues.put(Jobs.NEXT_RUN_ATTEMPT_TIME, job.getNextRunAttemptTime());
|
||||
contentValues.put(Jobs.RUN_ATTEMPT, job.getRunAttempt());
|
||||
contentValues.put(Jobs.MAX_ATTEMPTS, job.getMaxAttempts());
|
||||
contentValues.put(Jobs.MAX_BACKOFF, job.getMaxBackoff());
|
||||
contentValues.put(Jobs.MAX_INSTANCES, job.getMaxInstances());
|
||||
contentValues.put(Jobs.LIFESPAN, job.getLifespan());
|
||||
contentValues.put(Jobs.SERIALIZED_DATA, job.getSerializedData());
|
||||
contentValues.put(Jobs.IS_RUNNING, job.isRunning() ? 1 : 0);
|
||||
|
||||
db.insertWithOnConflict(Jobs.TABLE_NAME, null, contentValues, SQLiteDatabase.CONFLICT_IGNORE);
|
||||
}
|
||||
|
||||
private void insertConstraintSpecs(@NonNull SQLiteDatabase db, @NonNull List<ConstraintSpec> constraints) {
|
||||
for (ConstraintSpec constraintSpec : constraints) {
|
||||
ContentValues contentValues = new ContentValues();
|
||||
contentValues.put(Constraints.JOB_SPEC_ID, constraintSpec.getJobSpecId());
|
||||
contentValues.put(Constraints.FACTORY_KEY, constraintSpec.getFactoryKey());
|
||||
db.insertWithOnConflict(Constraints.TABLE_NAME, null ,contentValues, SQLiteDatabase.CONFLICT_IGNORE);
|
||||
}
|
||||
}
|
||||
|
||||
private void insertDependencySpecs(@NonNull SQLiteDatabase db, @NonNull List<DependencySpec> dependencies) {
|
||||
for (DependencySpec dependencySpec : dependencies) {
|
||||
ContentValues contentValues = new ContentValues();
|
||||
contentValues.put(Dependencies.JOB_SPEC_ID, dependencySpec.getJobId());
|
||||
contentValues.put(Dependencies.DEPENDS_ON_JOB_SPEC_ID, dependencySpec.getDependsOnJobId());
|
||||
db.insertWithOnConflict(Dependencies.TABLE_NAME, null, contentValues, SQLiteDatabase.CONFLICT_IGNORE);
|
||||
}
|
||||
}
|
||||
|
||||
private @NonNull JobSpec jobSpecFromCursor(@NonNull Cursor cursor) {
|
||||
return new JobSpec(cursor.getString(cursor.getColumnIndexOrThrow(Jobs.JOB_SPEC_ID)),
|
||||
cursor.getString(cursor.getColumnIndexOrThrow(Jobs.FACTORY_KEY)),
|
||||
cursor.getString(cursor.getColumnIndexOrThrow(Jobs.QUEUE_KEY)),
|
||||
cursor.getLong(cursor.getColumnIndexOrThrow(Jobs.CREATE_TIME)),
|
||||
cursor.getLong(cursor.getColumnIndexOrThrow(Jobs.NEXT_RUN_ATTEMPT_TIME)),
|
||||
cursor.getInt(cursor.getColumnIndexOrThrow(Jobs.RUN_ATTEMPT)),
|
||||
cursor.getInt(cursor.getColumnIndexOrThrow(Jobs.MAX_ATTEMPTS)),
|
||||
cursor.getLong(cursor.getColumnIndexOrThrow(Jobs.MAX_BACKOFF)),
|
||||
cursor.getLong(cursor.getColumnIndexOrThrow(Jobs.LIFESPAN)),
|
||||
cursor.getInt(cursor.getColumnIndexOrThrow(Jobs.MAX_INSTANCES)),
|
||||
cursor.getString(cursor.getColumnIndexOrThrow(Jobs.SERIALIZED_DATA)),
|
||||
cursor.getInt(cursor.getColumnIndexOrThrow(Jobs.IS_RUNNING)) == 1);
|
||||
}
|
||||
|
||||
private @NonNull ConstraintSpec constraintSpecFromCursor(@NonNull Cursor cursor) {
|
||||
return new ConstraintSpec(cursor.getString(cursor.getColumnIndexOrThrow(Constraints.JOB_SPEC_ID)),
|
||||
cursor.getString(cursor.getColumnIndexOrThrow(Constraints.FACTORY_KEY)));
|
||||
}
|
||||
|
||||
private @NonNull DependencySpec dependencySpecFromCursor(@NonNull Cursor cursor) {
|
||||
return new DependencySpec(cursor.getString(cursor.getColumnIndexOrThrow(Dependencies.JOB_SPEC_ID)),
|
||||
cursor.getString(cursor.getColumnIndexOrThrow(Dependencies.DEPENDS_ON_JOB_SPEC_ID)));
|
||||
}
|
||||
}
|
@ -37,6 +37,13 @@ public abstract class MessagingDatabase extends Database implements MmsSmsColumn
|
||||
public abstract void markExpireStarted(long messageId, long startTime);
|
||||
|
||||
public abstract void markAsSent(long messageId, boolean secure);
|
||||
|
||||
public abstract void markAsSyncing(long id);
|
||||
|
||||
public abstract void markAsResyncing(long id);
|
||||
|
||||
public abstract void markAsSyncFailed(long id);
|
||||
|
||||
public abstract void markUnidentified(long messageId, boolean unidentified);
|
||||
|
||||
public abstract void markAsDeleted(long messageId, boolean read, boolean hasMention);
|
||||
@ -199,7 +206,6 @@ public abstract class MessagingDatabase extends Database implements MmsSmsColumn
|
||||
contentValues.put(THREAD_ID, newThreadId);
|
||||
db.update(getTableName(), contentValues, where, args);
|
||||
}
|
||||
|
||||
public static class SyncMessageId {
|
||||
|
||||
private final Address address;
|
||||
|
@ -60,11 +60,13 @@ import org.thoughtcrime.securesms.database.SmsDatabase.InsertListener
|
||||
import org.thoughtcrime.securesms.database.helpers.SQLCipherOpenHelper
|
||||
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.database.model.NotificationMmsMessageRecord
|
||||
import org.thoughtcrime.securesms.database.model.Quote
|
||||
import org.thoughtcrime.securesms.dependencies.DatabaseComponent.Companion.get
|
||||
import org.thoughtcrime.securesms.mms.MmsException
|
||||
import org.thoughtcrime.securesms.mms.SlideDeck
|
||||
import org.thoughtcrime.securesms.util.asSequence
|
||||
import java.io.Closeable
|
||||
import java.io.IOException
|
||||
import java.security.SecureRandom
|
||||
@ -91,54 +93,22 @@ class MmsDatabase(context: Context, databaseHelper: SQLCipherOpenHelper) : Messa
|
||||
return 0
|
||||
}
|
||||
|
||||
fun addFailures(messageId: Long, failure: List<NetworkFailure>) {
|
||||
try {
|
||||
addToDocument(messageId, NETWORK_FAILURE, failure, NetworkFailureList::class.java)
|
||||
} catch (e: IOException) {
|
||||
Log.w(TAG, e)
|
||||
fun isOutgoingMessage(timestamp: Long): Boolean =
|
||||
databaseHelper.writableDatabase.query(
|
||||
TABLE_NAME,
|
||||
arrayOf(ID, THREAD_ID, MESSAGE_BOX, ADDRESS),
|
||||
DATE_SENT + " = ?",
|
||||
arrayOf(timestamp.toString()),
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
null
|
||||
).use { cursor ->
|
||||
cursor.asSequence()
|
||||
.map { cursor.getColumnIndexOrThrow(MESSAGE_BOX) }
|
||||
.map(cursor::getLong)
|
||||
.any { MmsSmsColumns.Types.isOutgoingMessageType(it) }
|
||||
}
|
||||
}
|
||||
|
||||
fun removeFailure(messageId: Long, failure: NetworkFailure?) {
|
||||
try {
|
||||
removeFromDocument(messageId, NETWORK_FAILURE, failure, NetworkFailureList::class.java)
|
||||
} catch (e: IOException) {
|
||||
Log.w(TAG, e)
|
||||
}
|
||||
}
|
||||
|
||||
fun isOutgoingMessage(timestamp: Long): Boolean {
|
||||
val database = databaseHelper.writableDatabase
|
||||
var cursor: Cursor? = null
|
||||
var isOutgoing = false
|
||||
try {
|
||||
cursor = database.query(
|
||||
TABLE_NAME,
|
||||
arrayOf<String>(ID, THREAD_ID, MESSAGE_BOX, ADDRESS),
|
||||
DATE_SENT + " = ?",
|
||||
arrayOf(timestamp.toString()),
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
null
|
||||
)
|
||||
while (cursor.moveToNext()) {
|
||||
if (MmsSmsColumns.Types.isOutgoingMessageType(
|
||||
cursor.getLong(
|
||||
cursor.getColumnIndexOrThrow(
|
||||
MESSAGE_BOX
|
||||
)
|
||||
)
|
||||
)
|
||||
) {
|
||||
isOutgoing = true
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
cursor?.close()
|
||||
}
|
||||
return isOutgoing
|
||||
}
|
||||
|
||||
fun incrementReceiptCount(
|
||||
messageId: SyncMessageId,
|
||||
@ -254,15 +224,6 @@ class MmsDatabase(context: Context, databaseHelper: SQLCipherOpenHelper) : Messa
|
||||
}
|
||||
}
|
||||
|
||||
private fun getThreadIdFor(notification: NotificationInd): Long {
|
||||
val fromString =
|
||||
if (notification.from != null && notification.from.textString != null) toIsoString(
|
||||
notification.from.textString
|
||||
) else ""
|
||||
val recipient = Recipient.from(context, fromExternal(context, fromString), false)
|
||||
return get(context).threadDatabase().getOrCreateThreadIdFor(recipient)
|
||||
}
|
||||
|
||||
private fun rawQuery(where: String, arguments: Array<String>?): Cursor {
|
||||
val database = databaseHelper.readableDatabase
|
||||
return database.rawQuery(
|
||||
@ -273,10 +234,6 @@ class MmsDatabase(context: Context, databaseHelper: SQLCipherOpenHelper) : Messa
|
||||
)
|
||||
}
|
||||
|
||||
fun getMessages(idsAsString: String): Cursor {
|
||||
return rawQuery(idsAsString, null)
|
||||
}
|
||||
|
||||
fun getMessage(messageId: Long): Cursor {
|
||||
val cursor = rawQuery(RAW_ID_WHERE, arrayOf(messageId.toString()))
|
||||
setNotifyConverationListeners(cursor, getThreadIdForMessage(messageId))
|
||||
@ -306,48 +263,40 @@ class MmsDatabase(context: Context, databaseHelper: SQLCipherOpenHelper) : Messa
|
||||
}
|
||||
}
|
||||
|
||||
fun markAsPendingInsecureSmsFallback(messageId: Long) {
|
||||
val threadId = getThreadIdForMessage(messageId)
|
||||
private fun markAs(
|
||||
messageId: Long,
|
||||
baseType: Long,
|
||||
threadId: Long = getThreadIdForMessage(messageId)
|
||||
) {
|
||||
updateMailboxBitmask(
|
||||
messageId,
|
||||
MmsSmsColumns.Types.BASE_TYPE_MASK,
|
||||
MmsSmsColumns.Types.BASE_PENDING_INSECURE_SMS_FALLBACK,
|
||||
baseType,
|
||||
Optional.of(threadId)
|
||||
)
|
||||
notifyConversationListeners(threadId)
|
||||
}
|
||||
|
||||
override fun markAsSyncing(messageId: Long) {
|
||||
markAs(messageId, MmsSmsColumns.Types.BASE_SYNCING_TYPE)
|
||||
}
|
||||
override fun markAsResyncing(messageId: Long) {
|
||||
markAs(messageId, MmsSmsColumns.Types.BASE_RESYNCING_TYPE)
|
||||
}
|
||||
override fun markAsSyncFailed(messageId: Long) {
|
||||
markAs(messageId, MmsSmsColumns.Types.BASE_SYNC_FAILED_TYPE)
|
||||
}
|
||||
|
||||
fun markAsSending(messageId: Long) {
|
||||
val threadId = getThreadIdForMessage(messageId)
|
||||
updateMailboxBitmask(
|
||||
messageId,
|
||||
MmsSmsColumns.Types.BASE_TYPE_MASK,
|
||||
MmsSmsColumns.Types.BASE_SENDING_TYPE,
|
||||
Optional.of(threadId)
|
||||
)
|
||||
notifyConversationListeners(threadId)
|
||||
markAs(messageId, MmsSmsColumns.Types.BASE_SENDING_TYPE)
|
||||
}
|
||||
|
||||
fun markAsSentFailed(messageId: Long) {
|
||||
val threadId = getThreadIdForMessage(messageId)
|
||||
updateMailboxBitmask(
|
||||
messageId,
|
||||
MmsSmsColumns.Types.BASE_TYPE_MASK,
|
||||
MmsSmsColumns.Types.BASE_SENT_FAILED_TYPE,
|
||||
Optional.of(threadId)
|
||||
)
|
||||
notifyConversationListeners(threadId)
|
||||
markAs(messageId, MmsSmsColumns.Types.BASE_SENT_FAILED_TYPE)
|
||||
}
|
||||
|
||||
override fun markAsSent(messageId: Long, secure: Boolean) {
|
||||
val threadId = getThreadIdForMessage(messageId)
|
||||
updateMailboxBitmask(
|
||||
messageId,
|
||||
MmsSmsColumns.Types.BASE_TYPE_MASK,
|
||||
MmsSmsColumns.Types.BASE_SENT_TYPE or if (secure) MmsSmsColumns.Types.PUSH_MESSAGE_BIT or MmsSmsColumns.Types.SECURE_MESSAGE_BIT else 0,
|
||||
Optional.of(threadId)
|
||||
)
|
||||
notifyConversationListeners(threadId)
|
||||
markAs(messageId, MmsSmsColumns.Types.BASE_SENT_TYPE or if (secure) MmsSmsColumns.Types.PUSH_MESSAGE_BIT or MmsSmsColumns.Types.SECURE_MESSAGE_BIT else 0)
|
||||
}
|
||||
|
||||
override fun markUnidentified(messageId: Long, unidentified: Boolean) {
|
||||
@ -371,13 +320,7 @@ class MmsDatabase(context: Context, databaseHelper: SQLCipherOpenHelper) : Messa
|
||||
val mentionChange = if (hasMention) { 1 } else { 0 }
|
||||
get(context).threadDatabase().decrementUnread(threadId, 1, mentionChange)
|
||||
}
|
||||
updateMailboxBitmask(
|
||||
messageId,
|
||||
MmsSmsColumns.Types.BASE_TYPE_MASK,
|
||||
MmsSmsColumns.Types.BASE_DELETED_TYPE,
|
||||
Optional.of(threadId)
|
||||
)
|
||||
notifyConversationListeners(threadId)
|
||||
markAs(messageId, MmsSmsColumns.Types.BASE_DELETED_TYPE, threadId)
|
||||
}
|
||||
|
||||
override fun markExpireStarted(messageId: Long) {
|
||||
@ -407,10 +350,6 @@ class MmsDatabase(context: Context, databaseHelper: SQLCipherOpenHelper) : Messa
|
||||
)
|
||||
}
|
||||
|
||||
fun setAllMessagesRead(): List<MarkedMessageInfo> {
|
||||
return setMessagesRead(READ + " = 0", null)
|
||||
}
|
||||
|
||||
private fun setMessagesRead(where: String, arguments: Array<String>?): List<MarkedMessageInfo> {
|
||||
val database = databaseHelper.writableDatabase
|
||||
val result: MutableList<MarkedMessageInfo> = LinkedList()
|
||||
@ -419,7 +358,7 @@ class MmsDatabase(context: Context, databaseHelper: SQLCipherOpenHelper) : Messa
|
||||
try {
|
||||
cursor = database.query(
|
||||
TABLE_NAME,
|
||||
arrayOf<String>(ID, ADDRESS, DATE_SENT, MESSAGE_BOX, EXPIRES_IN, EXPIRE_STARTED),
|
||||
arrayOf(ID, ADDRESS, DATE_SENT, MESSAGE_BOX, EXPIRES_IN, EXPIRE_STARTED),
|
||||
where,
|
||||
arguments,
|
||||
null,
|
||||
@ -1400,25 +1339,16 @@ class MmsDatabase(context: Context, databaseHelper: SQLCipherOpenHelper) : Messa
|
||||
val attachments = get(context).attachmentDatabase().getAttachment(
|
||||
cursor
|
||||
)
|
||||
val contacts: List<Contact?> = getSharedContacts(
|
||||
cursor, attachments
|
||||
)
|
||||
val contactAttachments =
|
||||
contacts.map { obj: Contact? -> obj!!.avatarAttachment }
|
||||
.filter { a: Attachment? -> a != null }
|
||||
.toSet()
|
||||
val previews: List<LinkPreview?> = getLinkPreviews(
|
||||
cursor, attachments
|
||||
)
|
||||
val previewAttachments =
|
||||
previews.filter { lp: LinkPreview? -> lp!!.getThumbnail().isPresent }
|
||||
.map { lp: LinkPreview? -> lp!!.getThumbnail().get() }
|
||||
.toSet()
|
||||
val contacts: List<Contact?> = getSharedContacts(cursor, attachments)
|
||||
val contactAttachments: Set<Attachment?> =
|
||||
contacts.mapNotNull { it?.avatarAttachment }.toSet()
|
||||
val previews: List<LinkPreview?> = getLinkPreviews(cursor, attachments)
|
||||
val previewAttachments: Set<Attachment?> =
|
||||
previews.mapNotNull { it?.getThumbnail()?.orNull() }.toSet()
|
||||
val slideDeck = getSlideDeck(
|
||||
Stream.of(attachments)
|
||||
.filterNot { o: DatabaseAttachment? -> contactAttachments.contains(o) }
|
||||
.filterNot { o: DatabaseAttachment? -> previewAttachments.contains(o) }
|
||||
.toList()
|
||||
attachments
|
||||
.filterNot { o: DatabaseAttachment? -> o in contactAttachments }
|
||||
.filterNot { o: DatabaseAttachment? -> o in previewAttachments }
|
||||
)
|
||||
val quote = getQuote(cursor)
|
||||
val reactions = get(context).reactionDatabase().getReactions(cursor)
|
||||
@ -1476,11 +1406,13 @@ class MmsDatabase(context: Context, databaseHelper: SQLCipherOpenHelper) : Messa
|
||||
val retrievedQuote = get(context).mmsSmsDatabase().getMessageFor(quoteId, quoteAuthor)
|
||||
val quoteText = retrievedQuote?.body
|
||||
val quoteMissing = retrievedQuote == null
|
||||
val attachments = get(context).attachmentDatabase().getAttachment(cursor)
|
||||
val quoteAttachments: List<Attachment?>? =
|
||||
Stream.of(attachments).filter { obj: DatabaseAttachment? -> obj!!.isQuote }
|
||||
val quoteDeck = (
|
||||
(retrievedQuote as? MmsMessageRecord)?.slideDeck ?:
|
||||
Stream.of(get(context).attachmentDatabase().getAttachment(cursor))
|
||||
.filter { obj: DatabaseAttachment? -> obj!!.isQuote }
|
||||
.toList()
|
||||
val quoteDeck = SlideDeck(context, quoteAttachments!!)
|
||||
.let { SlideDeck(context, it) }
|
||||
)
|
||||
return Quote(
|
||||
quoteId,
|
||||
fromExternal(context, quoteAuthor),
|
||||
@ -1623,4 +1555,4 @@ class MmsDatabase(context: Context, databaseHelper: SQLCipherOpenHelper) : Messa
|
||||
const val CREATE_REACTIONS_LAST_SEEN_COMMAND = "ALTER TABLE $TABLE_NAME ADD COLUMN $REACTIONS_LAST_SEEN INTEGER DEFAULT 0;"
|
||||
const val CREATE_HAS_MENTION_COMMAND = "ALTER TABLE $TABLE_NAME ADD COLUMN $HAS_MENTION INTEGER DEFAULT 0;"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -47,8 +47,13 @@ public interface MmsSmsColumns {
|
||||
protected static final long BASE_PENDING_INSECURE_SMS_FALLBACK = 26;
|
||||
public static final long BASE_DRAFT_TYPE = 27;
|
||||
protected static final long BASE_DELETED_TYPE = 28;
|
||||
protected static final long BASE_SYNCING_TYPE = 29;
|
||||
protected static final long BASE_RESYNCING_TYPE = 30;
|
||||
protected static final long BASE_SYNC_FAILED_TYPE = 31;
|
||||
|
||||
protected static final long[] OUTGOING_MESSAGE_TYPES = {BASE_OUTBOX_TYPE, BASE_SENT_TYPE,
|
||||
BASE_SYNCING_TYPE, BASE_RESYNCING_TYPE,
|
||||
BASE_SYNC_FAILED_TYPE,
|
||||
BASE_SENDING_TYPE, BASE_SENT_FAILED_TYPE,
|
||||
BASE_PENDING_SECURE_SMS_FALLBACK,
|
||||
BASE_PENDING_INSECURE_SMS_FALLBACK,
|
||||
@ -109,6 +114,18 @@ public interface MmsSmsColumns {
|
||||
return (type & BASE_TYPE_MASK) == BASE_DRAFT_TYPE;
|
||||
}
|
||||
|
||||
public static boolean isResyncingType(long type) {
|
||||
return (type & BASE_TYPE_MASK) == BASE_RESYNCING_TYPE;
|
||||
}
|
||||
|
||||
public static boolean isSyncingType(long type) {
|
||||
return (type & BASE_TYPE_MASK) == BASE_SYNCING_TYPE;
|
||||
}
|
||||
|
||||
public static boolean isSyncFailedMessageType(long type) {
|
||||
return (type & BASE_TYPE_MASK) == BASE_SYNC_FAILED_TYPE;
|
||||
}
|
||||
|
||||
public static boolean isFailedMessageType(long type) {
|
||||
return (type & BASE_TYPE_MASK) == BASE_SENT_FAILED_TYPE;
|
||||
}
|
||||
|
@ -276,7 +276,7 @@ public class RecipientDatabase extends Database {
|
||||
notifyRecipientListeners();
|
||||
}
|
||||
|
||||
public void setBlocked(@NonNull List<Recipient> recipients, boolean blocked) {
|
||||
public void setBlocked(@NonNull Iterable<Recipient> recipients, boolean blocked) {
|
||||
SQLiteDatabase db = getWritableDatabase();
|
||||
db.beginTransaction();
|
||||
try {
|
||||
|
@ -46,7 +46,7 @@ class SessionJobDatabase(context: Context, helper: SQLCipherOpenHelper) : Databa
|
||||
databaseHelper.writableDatabase.delete(sessionJobTable, "${Companion.jobID} = ?", arrayOf( jobID ))
|
||||
}
|
||||
|
||||
fun getAllPendingJobs(type: String): Map<String, Job?> {
|
||||
fun getAllJobs(type: String): Map<String, Job?> {
|
||||
val database = databaseHelper.readableDatabase
|
||||
return database.getAll(sessionJobTable, "$jobType = ?", arrayOf( type )) { cursor ->
|
||||
val jobID = cursor.getString(jobID)
|
||||
|
@ -202,6 +202,21 @@ public class SmsDatabase extends MessagingDatabase {
|
||||
updateTypeBitmask(id, Types.BASE_TYPE_MASK, Types.BASE_SENDING_TYPE);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void markAsSyncing(long id) {
|
||||
updateTypeBitmask(id, Types.BASE_TYPE_MASK, Types.BASE_SYNCING_TYPE);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void markAsResyncing(long id) {
|
||||
updateTypeBitmask(id, Types.BASE_TYPE_MASK, Types.BASE_RESYNCING_TYPE);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void markAsSyncFailed(long id) {
|
||||
updateTypeBitmask(id, Types.BASE_TYPE_MASK, Types.BASE_SYNC_FAILED_TYPE);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void markUnidentified(long id, boolean unidentified) {
|
||||
ContentValues contentValues = new ContentValues(1);
|
||||
|
@ -42,13 +42,12 @@ import org.session.libsignal.messages.SignalServiceGroup
|
||||
import org.session.libsignal.utilities.IdPrefix
|
||||
import org.session.libsignal.utilities.KeyHelper
|
||||
import org.session.libsignal.utilities.guava.Optional
|
||||
import org.thoughtcrime.securesms.ApplicationContext
|
||||
import org.thoughtcrime.securesms.database.helpers.SQLCipherOpenHelper
|
||||
import org.thoughtcrime.securesms.database.model.MessageId
|
||||
import org.thoughtcrime.securesms.database.model.ReactionRecord
|
||||
import org.thoughtcrime.securesms.dependencies.DatabaseComponent
|
||||
import org.thoughtcrime.securesms.groups.OpenGroupManager
|
||||
import org.thoughtcrime.securesms.jobs.RetrieveProfileAvatarJob
|
||||
import org.session.libsession.messaging.jobs.RetrieveProfileAvatarJob
|
||||
import org.thoughtcrime.securesms.mms.PartAuthority
|
||||
import org.thoughtcrime.securesms.util.SessionMetaProtocol
|
||||
import java.security.MessageDigest
|
||||
@ -70,13 +69,9 @@ class Storage(context: Context, helper: SQLCipherOpenHelper) : Database(context,
|
||||
return Profile(displayName, profileKey, profilePictureUrl)
|
||||
}
|
||||
|
||||
override fun setUserProfilePictureURL(newValue: String) {
|
||||
val ourRecipient = fromSerialized(getUserPublicKey()!!).let {
|
||||
Recipient.from(context, it, false)
|
||||
}
|
||||
TextSecurePreferences.setProfilePictureURL(context, newValue)
|
||||
RetrieveProfileAvatarJob(ourRecipient, newValue)
|
||||
ApplicationContext.getInstance(context).jobManager.add(RetrieveProfileAvatarJob(ourRecipient, newValue))
|
||||
override fun setProfileAvatar(recipient: Recipient, profileAvatar: String?) {
|
||||
val database = DatabaseComponent.get(context).recipientDatabase()
|
||||
database.setProfileAvatar(recipient, profileAvatar)
|
||||
}
|
||||
|
||||
override fun getOrGenerateRegistrationID(): Int {
|
||||
@ -211,7 +206,7 @@ class Storage(context: Context, helper: SQLCipherOpenHelper) : Database(context,
|
||||
}
|
||||
|
||||
override fun getAllPendingJobs(type: String): Map<String, Job?> {
|
||||
return DatabaseComponent.get(context).sessionJobDatabase().getAllPendingJobs(type)
|
||||
return DatabaseComponent.get(context).sessionJobDatabase().getAllJobs(type)
|
||||
}
|
||||
|
||||
override fun getAttachmentUploadJob(attachmentID: Long): AttachmentUploadJob? {
|
||||
@ -377,6 +372,22 @@ class Storage(context: Context, helper: SQLCipherOpenHelper) : Database(context,
|
||||
}
|
||||
}
|
||||
|
||||
override fun markAsSyncing(timestamp: Long, author: String) {
|
||||
DatabaseComponent.get(context).mmsSmsDatabase()
|
||||
.getMessageFor(timestamp, author)
|
||||
?.run { getMmsDatabaseElseSms(isMms).markAsSyncing(id) }
|
||||
}
|
||||
|
||||
private fun getMmsDatabaseElseSms(isMms: Boolean) =
|
||||
if (isMms) DatabaseComponent.get(context).mmsDatabase()
|
||||
else DatabaseComponent.get(context).smsDatabase()
|
||||
|
||||
override fun markAsResyncing(timestamp: Long, author: String) {
|
||||
DatabaseComponent.get(context).mmsSmsDatabase()
|
||||
.getMessageFor(timestamp, author)
|
||||
?.run { getMmsDatabaseElseSms(isMms).markAsResyncing(id) }
|
||||
}
|
||||
|
||||
override fun markAsSending(timestamp: Long, author: String) {
|
||||
val database = DatabaseComponent.get(context).mmsSmsDatabase()
|
||||
val messageRecord = database.getMessageFor(timestamp, author) ?: return
|
||||
@ -402,7 +413,7 @@ class Storage(context: Context, helper: SQLCipherOpenHelper) : Database(context,
|
||||
}
|
||||
}
|
||||
|
||||
override fun setErrorMessage(timestamp: Long, author: String, error: Exception) {
|
||||
override fun markAsSentFailed(timestamp: Long, author: String, error: Exception) {
|
||||
val database = DatabaseComponent.get(context).mmsSmsDatabase()
|
||||
val messageRecord = database.getMessageFor(timestamp, author) ?: return
|
||||
if (messageRecord.isMms) {
|
||||
@ -425,6 +436,26 @@ class Storage(context: Context, helper: SQLCipherOpenHelper) : Database(context,
|
||||
}
|
||||
}
|
||||
|
||||
override fun markAsSyncFailed(timestamp: Long, author: String, error: Exception) {
|
||||
val database = DatabaseComponent.get(context).mmsSmsDatabase()
|
||||
val messageRecord = database.getMessageFor(timestamp, author) ?: return
|
||||
|
||||
database.getMessageFor(timestamp, author)
|
||||
?.run { getMmsDatabaseElseSms(isMms).markAsSyncFailed(id) }
|
||||
|
||||
if (error.localizedMessage != null) {
|
||||
val message: String
|
||||
if (error is OnionRequestAPI.HTTPRequestFailedAtDestinationException && error.statusCode == 429) {
|
||||
message = "429: Rate limited."
|
||||
} else {
|
||||
message = error.localizedMessage!!
|
||||
}
|
||||
DatabaseComponent.get(context).lokiMessageDatabase().setErrorMessage(messageRecord.getId(), message)
|
||||
} else {
|
||||
DatabaseComponent.get(context).lokiMessageDatabase().setErrorMessage(messageRecord.getId(), error.javaClass.simpleName)
|
||||
}
|
||||
}
|
||||
|
||||
override fun clearErrorMessage(messageID: Long) {
|
||||
val db = DatabaseComponent.get(context).lokiMessageDatabase()
|
||||
db.clearErrorMessage(messageID)
|
||||
@ -677,8 +708,10 @@ class Storage(context: Context, helper: SQLCipherOpenHelper) : Database(context,
|
||||
recipientDatabase.setApproved(recipient, true)
|
||||
threadDatabase.setHasSent(threadId, true)
|
||||
}
|
||||
if (contact.isBlocked == true) {
|
||||
recipientDatabase.setBlocked(recipient, true)
|
||||
|
||||
val contactIsBlocked: Boolean? = contact.isBlocked
|
||||
if (contactIsBlocked != null && recipient.isBlocked != contactIsBlocked) {
|
||||
recipientDatabase.setBlocked(recipient, contactIsBlocked)
|
||||
threadDatabase.deleteConversation(threadId)
|
||||
}
|
||||
}
|
||||
@ -707,6 +740,11 @@ class Storage(context: Context, helper: SQLCipherOpenHelper) : Database(context,
|
||||
return mmsSmsDb.getConversationCount(threadID)
|
||||
}
|
||||
|
||||
override fun deleteConversation(threadId: Long) {
|
||||
val threadDB = DatabaseComponent.get(context).threadDatabase()
|
||||
threadDB.deleteConversation(threadId)
|
||||
}
|
||||
|
||||
|
||||
|
||||
override fun getAttachmentDataUri(attachmentId: AttachmentId): Uri {
|
||||
@ -974,7 +1012,7 @@ class Storage(context: Context, helper: SQLCipherOpenHelper) : Database(context,
|
||||
DatabaseComponent.get(context).reactionDatabase().deleteMessageReactions(MessageId(messageId, mms))
|
||||
}
|
||||
|
||||
override fun unblock(toUnblock: List<Recipient>) {
|
||||
override fun unblock(toUnblock: Iterable<Recipient>) {
|
||||
val recipientDb = DatabaseComponent.get(context).recipientDatabase()
|
||||
recipientDb.setBlocked(toUnblock, false)
|
||||
}
|
||||
@ -983,5 +1021,4 @@ class Storage(context: Context, helper: SQLCipherOpenHelper) : Database(context,
|
||||
val recipientDb = DatabaseComponent.get(context).recipientDatabase()
|
||||
return recipientDb.blockedContacts
|
||||
}
|
||||
|
||||
}
|
@ -24,7 +24,6 @@ import org.thoughtcrime.securesms.database.EmojiSearchDatabase;
|
||||
import org.thoughtcrime.securesms.database.GroupDatabase;
|
||||
import org.thoughtcrime.securesms.database.GroupMemberDatabase;
|
||||
import org.thoughtcrime.securesms.database.GroupReceiptDatabase;
|
||||
import org.thoughtcrime.securesms.database.JobDatabase;
|
||||
import org.thoughtcrime.securesms.database.LokiAPIDatabase;
|
||||
import org.thoughtcrime.securesms.database.LokiBackupFilesDatabase;
|
||||
import org.thoughtcrime.securesms.database.LokiMessageDatabase;
|
||||
@ -284,9 +283,6 @@ public class SQLCipherOpenHelper extends SQLiteOpenHelper {
|
||||
for (String sql : SearchDatabase.CREATE_TABLE) {
|
||||
db.execSQL(sql);
|
||||
}
|
||||
for (String sql : JobDatabase.CREATE_TABLE) {
|
||||
db.execSQL(sql);
|
||||
}
|
||||
db.execSQL(LokiAPIDatabase.getCreateSnodePoolTableCommand());
|
||||
db.execSQL(LokiAPIDatabase.getCreateOnionRequestPathTableCommand());
|
||||
db.execSQL(LokiAPIDatabase.getCreateSwarmTableCommand());
|
||||
|
@ -80,6 +80,18 @@ public abstract class DisplayRecord {
|
||||
return !isFailed() && !isPending();
|
||||
}
|
||||
|
||||
public boolean isSyncing() {
|
||||
return MmsSmsColumns.Types.isSyncingType(type);
|
||||
}
|
||||
|
||||
public boolean isResyncing() {
|
||||
return MmsSmsColumns.Types.isResyncingType(type);
|
||||
}
|
||||
|
||||
public boolean isSyncFailed() {
|
||||
return MmsSmsColumns.Types.isSyncFailedMessageType(type);
|
||||
}
|
||||
|
||||
public boolean isFailed() {
|
||||
return MmsSmsColumns.Types.isFailedMessageType(type)
|
||||
|| MmsSmsColumns.Types.isPendingSecureSmsFallbackType(type)
|
||||
|
@ -32,7 +32,6 @@ interface DatabaseComponent {
|
||||
fun recipientDatabase(): RecipientDatabase
|
||||
fun groupReceiptDatabase(): GroupReceiptDatabase
|
||||
fun searchDatabase(): SearchDatabase
|
||||
fun jobDatabase(): JobDatabase
|
||||
fun lokiAPIDatabase(): LokiAPIDatabase
|
||||
fun lokiMessageDatabase(): LokiMessageDatabase
|
||||
fun lokiThreadDatabase(): LokiThreadDatabase
|
||||
|
@ -86,10 +86,6 @@ object DatabaseModule {
|
||||
@Singleton
|
||||
fun searchDatabase(@ApplicationContext context: Context, openHelper: SQLCipherOpenHelper) = SearchDatabase(context,openHelper)
|
||||
|
||||
@Provides
|
||||
@Singleton
|
||||
fun provideJobDatabase(@ApplicationContext context: Context, openHelper: SQLCipherOpenHelper) = JobDatabase(context, openHelper)
|
||||
|
||||
@Provides
|
||||
@Singleton
|
||||
fun provideLokiApiDatabase(@ApplicationContext context: Context, openHelper: SQLCipherOpenHelper) = LokiAPIDatabase(context,openHelper)
|
||||
|
@ -15,7 +15,7 @@ import java.util.concurrent.Executors
|
||||
|
||||
object OpenGroupManager {
|
||||
private val executorService = Executors.newScheduledThreadPool(4)
|
||||
private var pollers = mutableMapOf<String, OpenGroupPoller>() // One for each server
|
||||
private val pollers = mutableMapOf<String, OpenGroupPoller>() // One for each server
|
||||
private var isPolling = false
|
||||
private val pollUpdaterLock = Any()
|
||||
|
||||
@ -41,11 +41,11 @@ object OpenGroupManager {
|
||||
isPolling = true
|
||||
val storage = MessagingModuleConfiguration.shared.storage
|
||||
val servers = storage.getAllOpenGroups().values.map { it.server }.toSet()
|
||||
servers.forEach { server ->
|
||||
pollers[server]?.stop() // Shouldn't be necessary
|
||||
val poller = OpenGroupPoller(server, executorService)
|
||||
poller.startIfNeeded()
|
||||
pollers[server] = poller
|
||||
synchronized(pollUpdaterLock) {
|
||||
servers.forEach { server ->
|
||||
pollers[server]?.stop() // Shouldn't be necessary
|
||||
pollers[server] = OpenGroupPoller(server, executorService).apply { startIfNeeded() }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -60,7 +60,7 @@ object OpenGroupManager {
|
||||
@WorkerThread
|
||||
fun add(server: String, room: String, publicKey: String, context: Context): OpenGroupApi.RoomInfo? {
|
||||
val openGroupID = "$server.$room"
|
||||
var threadID = GroupManager.getOpenGroupThreadID(openGroupID, context)
|
||||
val threadID = GroupManager.getOpenGroupThreadID(openGroupID, context)
|
||||
val storage = MessagingModuleConfiguration.shared.storage
|
||||
val threadDB = DatabaseComponent.get(context).lokiThreadDatabase()
|
||||
// Check it it's added already
|
||||
@ -76,13 +76,16 @@ object OpenGroupManager {
|
||||
// Get capabilities & room info
|
||||
val (capabilities, info) = OpenGroupApi.getCapabilitiesAndRoomInfo(room, server).get()
|
||||
storage.setServerCapabilities(server, capabilities.capabilities)
|
||||
storage.setUserCount(room, server, info.activeUsers)
|
||||
// Create the group locally if not available already
|
||||
if (threadID < 0) {
|
||||
threadID = GroupManager.createOpenGroup(openGroupID, context, null, info.name).threadId
|
||||
GroupManager.createOpenGroup(openGroupID, context, null, info.name)
|
||||
}
|
||||
val openGroup = OpenGroup(server = server, room = room, publicKey = publicKey, name = info.name, imageId = info.imageId, canWrite = info.write, infoUpdates = info.infoUpdates)
|
||||
threadDB.setOpenGroupChat(openGroup, threadID)
|
||||
OpenGroupPoller.handleRoomPollInfo(
|
||||
server = server,
|
||||
roomToken = room,
|
||||
pollInfo = info.toPollInfo(),
|
||||
createGroupIfMissingWithPublicKey = publicKey
|
||||
)
|
||||
return info
|
||||
}
|
||||
|
||||
|
@ -88,7 +88,6 @@ class ConversationOptionsBottomSheet(private val parentContext: Context) : Botto
|
||||
override fun onStart() {
|
||||
super.onStart()
|
||||
val window = dialog?.window ?: return
|
||||
val isLightMode = UiModeUtilities.isDayUiMode(requireContext())
|
||||
window.setDimAmount(if (isLightMode) 0.1f else 0.75f)
|
||||
window.setDimAmount(0.6f)
|
||||
}
|
||||
}
|
@ -495,6 +495,8 @@ class HomeActivity : PassphraseRequiredActionBarActivity(),
|
||||
.setPositiveButton(R.string.RecipientPreferenceActivity_block) { dialog, _ ->
|
||||
lifecycleScope.launch(Dispatchers.IO) {
|
||||
recipientDatabase.setBlocked(thread.recipient, true)
|
||||
// TODO: Remove in UserConfig branch
|
||||
ConfigurationMessageUtilities.forceSyncConfigurationNowIfNeeded(this@HomeActivity)
|
||||
withContext(Dispatchers.Main) {
|
||||
binding.recyclerView.adapter!!.notifyDataSetChanged()
|
||||
dialog.dismiss()
|
||||
@ -511,6 +513,8 @@ class HomeActivity : PassphraseRequiredActionBarActivity(),
|
||||
.setPositiveButton(R.string.RecipientPreferenceActivity_unblock) { dialog, _ ->
|
||||
lifecycleScope.launch(Dispatchers.IO) {
|
||||
recipientDatabase.setBlocked(thread.recipient, false)
|
||||
// TODO: Remove in UserConfig branch
|
||||
ConfigurationMessageUtilities.forceSyncConfigurationNowIfNeeded(this@HomeActivity)
|
||||
withContext(Dispatchers.Main) {
|
||||
binding.recyclerView.adapter!!.notifyDataSetChanged()
|
||||
dialog.dismiss()
|
||||
|
@ -117,8 +117,7 @@ class UserDetailsBottomSheet: BottomSheetDialogFragment() {
|
||||
override fun onStart() {
|
||||
super.onStart()
|
||||
val window = dialog?.window ?: return
|
||||
val isLightMode = UiModeUtilities.isDayUiMode(requireContext())
|
||||
window.setDimAmount(if (isLightMode) 0.1f else 0.75f)
|
||||
window.setDimAmount(0.6f)
|
||||
}
|
||||
|
||||
fun saveNickName(recipient: Recipient) = with(binding) {
|
||||
|
@ -1,69 +0,0 @@
|
||||
package org.thoughtcrime.securesms.jobmanager;
|
||||
|
||||
import android.app.AlarmManager;
|
||||
import android.app.Application;
|
||||
import android.app.PendingIntent;
|
||||
import android.content.BroadcastReceiver;
|
||||
import android.content.Context;
|
||||
import android.content.Intent;
|
||||
|
||||
import androidx.annotation.NonNull;
|
||||
|
||||
import com.annimon.stream.Stream;
|
||||
|
||||
import org.session.libsignal.utilities.Log;
|
||||
import org.thoughtcrime.securesms.ApplicationContext;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.UUID;
|
||||
|
||||
import network.loki.messenger.BuildConfig;
|
||||
|
||||
/**
|
||||
* Schedules tasks using the {@link AlarmManager}.
|
||||
*
|
||||
* Given that this scheduler is only used when {@link KeepAliveService} is also used (which keeps
|
||||
* all of the {@link ConstraintObserver}s running), this only needs to schedule future runs in
|
||||
* situations where all constraints are already met. Otherwise, the {@link ConstraintObserver}s will
|
||||
* trigger future runs when the constraints are met.
|
||||
*
|
||||
* For the same reason, this class also doesn't have to schedule jobs that don't have delays.
|
||||
*
|
||||
* Important: Only use on API < 26.
|
||||
*/
|
||||
public class AlarmManagerScheduler implements Scheduler {
|
||||
|
||||
private static final String TAG = AlarmManagerScheduler.class.getSimpleName();
|
||||
|
||||
private final Application application;
|
||||
|
||||
AlarmManagerScheduler(@NonNull Application application) {
|
||||
this.application = application;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void schedule(long delay, @NonNull List<Constraint> constraints) {
|
||||
if (delay > 0 && Stream.of(constraints).allMatch(Constraint::isMet)) {
|
||||
setUniqueAlarm(application, System.currentTimeMillis() + delay);
|
||||
}
|
||||
}
|
||||
|
||||
private void setUniqueAlarm(@NonNull Context context, long time) {
|
||||
AlarmManager alarmManager = (AlarmManager) context.getSystemService(Context.ALARM_SERVICE);
|
||||
Intent intent = new Intent(context, RetryReceiver.class);
|
||||
|
||||
intent.setAction(BuildConfig.APPLICATION_ID + UUID.randomUUID().toString());
|
||||
alarmManager.set(AlarmManager.RTC_WAKEUP, time, PendingIntent.getBroadcast(context, 0, intent, PendingIntent.FLAG_IMMUTABLE));
|
||||
|
||||
Log.i(TAG, "Set an alarm to retry a job in " + (time - System.currentTimeMillis()) + " ms.");
|
||||
}
|
||||
|
||||
public static class RetryReceiver extends BroadcastReceiver {
|
||||
|
||||
@Override
|
||||
public void onReceive(Context context, Intent intent) {
|
||||
Log.i(TAG, "Received an alarm to retry a job.");
|
||||
ApplicationContext.getInstance(context).getJobManager().wakeUp();
|
||||
}
|
||||
}
|
||||
}
|
@ -1,22 +0,0 @@
|
||||
package org.thoughtcrime.securesms.jobmanager;
|
||||
|
||||
import androidx.annotation.NonNull;
|
||||
|
||||
import java.util.Arrays;
|
||||
import java.util.List;
|
||||
|
||||
class CompositeScheduler implements Scheduler {
|
||||
|
||||
private final List<Scheduler> schedulers;
|
||||
|
||||
CompositeScheduler(@NonNull Scheduler... schedulers) {
|
||||
this.schedulers = Arrays.asList(schedulers);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void schedule(long delay, @NonNull List<Constraint> constraints) {
|
||||
for (Scheduler scheduler : schedulers) {
|
||||
scheduler.schedule(delay, constraints);
|
||||
}
|
||||
}
|
||||
}
|
@ -1,23 +0,0 @@
|
||||
package org.thoughtcrime.securesms.jobmanager;
|
||||
|
||||
import androidx.annotation.NonNull;
|
||||
|
||||
import java.util.HashMap;
|
||||
import java.util.Map;
|
||||
|
||||
public class ConstraintInstantiator {
|
||||
|
||||
private final Map<String, Constraint.Factory> constraintFactories;
|
||||
|
||||
ConstraintInstantiator(@NonNull Map<String, Constraint.Factory> constraintFactories) {
|
||||
this.constraintFactories = new HashMap<>(constraintFactories);
|
||||
}
|
||||
|
||||
public @NonNull Constraint instantiate(@NonNull String constraintFactoryKey) {
|
||||
if (constraintFactories.containsKey(constraintFactoryKey)) {
|
||||
return constraintFactories.get(constraintFactoryKey).create();
|
||||
} else {
|
||||
throw new IllegalStateException("Tried to instantiate a constraint with key '" + constraintFactoryKey + "', but no matching factory was found.");
|
||||
}
|
||||
}
|
||||
}
|
@ -1,12 +0,0 @@
|
||||
package org.thoughtcrime.securesms.jobmanager;
|
||||
|
||||
import androidx.annotation.NonNull;
|
||||
|
||||
public interface ConstraintObserver {
|
||||
|
||||
void register(@NonNull Notifier notifier);
|
||||
|
||||
interface Notifier {
|
||||
void onConstraintMet(@NonNull String reason);
|
||||
}
|
||||
}
|
@ -1,24 +0,0 @@
|
||||
/**
|
||||
* 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.jobmanager;
|
||||
|
||||
/**
|
||||
* Interface responsible for injecting dependencies into Jobs.
|
||||
*/
|
||||
public interface DependencyInjector {
|
||||
void injectDependencies(Object object);
|
||||
}
|
@ -1,9 +0,0 @@
|
||||
package org.thoughtcrime.securesms.jobmanager;
|
||||
|
||||
import androidx.annotation.NonNull;
|
||||
|
||||
import java.util.concurrent.ExecutorService;
|
||||
|
||||
public interface ExecutorFactory {
|
||||
@NonNull ExecutorService newSingleThreadExecutor(@NonNull String name);
|
||||
}
|
@ -1,49 +0,0 @@
|
||||
package org.thoughtcrime.securesms.jobmanager;
|
||||
|
||||
import android.os.Handler;
|
||||
import android.os.HandlerThread;
|
||||
import androidx.annotation.NonNull;
|
||||
|
||||
import com.annimon.stream.Stream;
|
||||
|
||||
import org.session.libsignal.utilities.Log;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* Schedules future runs on an in-app handler. Intended to be used in combination with a persistent
|
||||
* {@link Scheduler} to improve responsiveness when the app is open.
|
||||
*
|
||||
* This should only schedule runs when all constraints are met. Because this only works when the
|
||||
* app is foregrounded, jobs that don't have their constraints met will be run when the relevant
|
||||
* {@link ConstraintObserver} is triggered.
|
||||
*
|
||||
* Similarly, this does not need to schedule retries with no delay, as this doesn't provide any
|
||||
* persistence, and other mechanisms will take care of that.
|
||||
*/
|
||||
class InAppScheduler implements Scheduler {
|
||||
|
||||
private static final String TAG = InAppScheduler.class.getSimpleName();
|
||||
|
||||
private final JobManager jobManager;
|
||||
private final Handler handler;
|
||||
|
||||
InAppScheduler(@NonNull JobManager jobManager) {
|
||||
HandlerThread handlerThread = new HandlerThread("InAppScheduler");
|
||||
handlerThread.start();
|
||||
|
||||
this.jobManager = jobManager;
|
||||
this.handler = new Handler(handlerThread.getLooper());
|
||||
}
|
||||
|
||||
@Override
|
||||
public void schedule(long delay, @NonNull List<Constraint> constraints) {
|
||||
if (delay > 0 && Stream.of(constraints).allMatch(Constraint::isMet)) {
|
||||
Log.i(TAG, "Scheduling a retry in " + delay + " ms.");
|
||||
handler.postDelayed(() -> {
|
||||
Log.i(TAG, "Triggering a job retry.");
|
||||
jobManager.wakeUp();
|
||||
}, delay);
|
||||
}
|
||||
}
|
||||
}
|
@ -1,286 +0,0 @@
|
||||
package org.thoughtcrime.securesms.jobmanager;
|
||||
|
||||
import android.content.Context;
|
||||
import androidx.annotation.NonNull;
|
||||
import androidx.annotation.Nullable;
|
||||
import androidx.annotation.WorkerThread;
|
||||
|
||||
import org.session.libsession.messaging.utilities.Data;
|
||||
import org.session.libsignal.utilities.Log;
|
||||
|
||||
import java.util.LinkedList;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
|
||||
/**
|
||||
* A durable unit of work.
|
||||
*
|
||||
* Jobs have {@link Parameters} that describe the conditions upon when you'd like them to run, how
|
||||
* often they should be retried, and how long they should be retried for.
|
||||
*
|
||||
* Never rely on a specific instance of this class being run. It can be created and destroyed as the
|
||||
* job is retried. State that you want to save is persisted to a {@link Data} object in
|
||||
* {@link #serialize()}. Your job is then recreated using a {@link Factory} that you register in
|
||||
* {@link JobManager.Configuration.Builder#setJobFactories(Map)}, which is given the saved
|
||||
* {@link Data} bundle.
|
||||
*
|
||||
* @deprecated
|
||||
* use <a href="https://developer.android.com/reference/androidx/work/WorkManager">WorkManager</a>
|
||||
* API instead.
|
||||
*/
|
||||
public abstract class Job {
|
||||
|
||||
private static final String TAG = Log.tag(Job.class);
|
||||
|
||||
private final Parameters parameters;
|
||||
|
||||
private String id;
|
||||
private int runAttempt;
|
||||
private long nextRunAttemptTime;
|
||||
|
||||
protected Context context;
|
||||
|
||||
public Job(@NonNull Parameters parameters) {
|
||||
this.parameters = parameters;
|
||||
}
|
||||
|
||||
public final String getId() {
|
||||
return id;
|
||||
}
|
||||
|
||||
public final @NonNull Parameters getParameters() {
|
||||
return parameters;
|
||||
}
|
||||
|
||||
public final int getRunAttempt() {
|
||||
return runAttempt;
|
||||
}
|
||||
|
||||
public final long getNextRunAttemptTime() {
|
||||
return nextRunAttemptTime;
|
||||
}
|
||||
|
||||
/**
|
||||
* This is already called by {@link JobController} during job submission, but if you ever run a
|
||||
* job without submitting it to the {@link JobManager}, then you'll need to invoke this yourself.
|
||||
*/
|
||||
public final void setContext(@NonNull Context context) {
|
||||
this.context = context;
|
||||
}
|
||||
|
||||
/** Should only be invoked by {@link JobController} */
|
||||
final void setId(@NonNull String id) {
|
||||
this.id = id;
|
||||
}
|
||||
|
||||
/** Should only be invoked by {@link JobController} */
|
||||
final void setRunAttempt(int runAttempt) {
|
||||
this.runAttempt = runAttempt;
|
||||
}
|
||||
|
||||
/** Should only be invoked by {@link JobController} */
|
||||
final void setNextRunAttemptTime(long nextRunAttemptTime) {
|
||||
this.nextRunAttemptTime = nextRunAttemptTime;
|
||||
}
|
||||
|
||||
@WorkerThread
|
||||
final void onSubmit() {
|
||||
Log.i(TAG, JobLogger.format(this, "onSubmit()"));
|
||||
onAdded();
|
||||
}
|
||||
|
||||
/**
|
||||
* Called when the job is first submitted to the {@link JobManager}.
|
||||
*/
|
||||
@WorkerThread
|
||||
public void onAdded() {
|
||||
}
|
||||
|
||||
/**
|
||||
* Called after a job has run and its determined that a retry is required.
|
||||
*/
|
||||
@WorkerThread
|
||||
public void onRetry() {
|
||||
}
|
||||
|
||||
/**
|
||||
* Serialize your job state so that it can be recreated in the future.
|
||||
*/
|
||||
public abstract @NonNull Data serialize();
|
||||
|
||||
/**
|
||||
* Returns the key that can be used to find the relevant factory needed to create your job.
|
||||
*/
|
||||
public abstract @NonNull String getFactoryKey();
|
||||
|
||||
/**
|
||||
* Called to do your actual work.
|
||||
*/
|
||||
@WorkerThread
|
||||
public abstract @NonNull Result run();
|
||||
|
||||
/**
|
||||
* Called when your job has completely failed.
|
||||
*/
|
||||
@WorkerThread
|
||||
public abstract void onCanceled();
|
||||
|
||||
public interface Factory<T extends Job> {
|
||||
@NonNull T create(@NonNull Parameters parameters, @NonNull Data data);
|
||||
}
|
||||
|
||||
public enum Result {
|
||||
SUCCESS, FAILURE, RETRY
|
||||
}
|
||||
|
||||
public static final class Parameters {
|
||||
|
||||
public static final int IMMORTAL = -1;
|
||||
public static final int UNLIMITED = -1;
|
||||
|
||||
private final long createTime;
|
||||
private final long lifespan;
|
||||
private final int maxAttempts;
|
||||
private final long maxBackoff;
|
||||
private final int maxInstances;
|
||||
private final String queue;
|
||||
private final List<String> constraintKeys;
|
||||
|
||||
private Parameters(long createTime,
|
||||
long lifespan,
|
||||
int maxAttempts,
|
||||
long maxBackoff,
|
||||
int maxInstances,
|
||||
@Nullable String queue,
|
||||
@NonNull List<String> constraintKeys)
|
||||
{
|
||||
this.createTime = createTime;
|
||||
this.lifespan = lifespan;
|
||||
this.maxAttempts = maxAttempts;
|
||||
this.maxBackoff = maxBackoff;
|
||||
this.maxInstances = maxInstances;
|
||||
this.queue = queue;
|
||||
this.constraintKeys = constraintKeys;
|
||||
}
|
||||
|
||||
public long getCreateTime() {
|
||||
return createTime;
|
||||
}
|
||||
|
||||
public long getLifespan() {
|
||||
return lifespan;
|
||||
}
|
||||
|
||||
public int getMaxAttempts() {
|
||||
return maxAttempts;
|
||||
}
|
||||
|
||||
public long getMaxBackoff() {
|
||||
return maxBackoff;
|
||||
}
|
||||
|
||||
public int getMaxInstances() {
|
||||
return maxInstances;
|
||||
}
|
||||
|
||||
public @Nullable String getQueue() {
|
||||
return queue;
|
||||
}
|
||||
|
||||
public List<String> getConstraintKeys() {
|
||||
return constraintKeys;
|
||||
}
|
||||
|
||||
|
||||
public static final class Builder {
|
||||
|
||||
private long createTime = System.currentTimeMillis();
|
||||
private long maxBackoff = TimeUnit.SECONDS.toMillis(30);
|
||||
private long lifespan = IMMORTAL;
|
||||
private int maxAttempts = 1;
|
||||
private int maxInstances = UNLIMITED;
|
||||
private String queue = null;
|
||||
private List<String> constraintKeys = new LinkedList<>();
|
||||
|
||||
/** Should only be invoked by {@link JobController} */
|
||||
Builder setCreateTime(long createTime) {
|
||||
this.createTime = createTime;
|
||||
return this;
|
||||
}
|
||||
|
||||
/**
|
||||
* Specify the amount of time this job is allowed to be retried. Defaults to {@link #IMMORTAL}.
|
||||
*/
|
||||
public @NonNull Builder setLifespan(long lifespan) {
|
||||
this.lifespan = lifespan;
|
||||
return this;
|
||||
}
|
||||
|
||||
/**
|
||||
* Specify the maximum number of times you want to attempt this job. Defaults to 1.
|
||||
*/
|
||||
public @NonNull Builder setMaxAttempts(int maxAttempts) {
|
||||
this.maxAttempts = maxAttempts;
|
||||
return this;
|
||||
}
|
||||
|
||||
/**
|
||||
* Specify the longest amount of time to wait between retries. No guarantees that this will
|
||||
* be respected on API >= 26.
|
||||
*/
|
||||
public @NonNull Builder setMaxBackoff(long maxBackoff) {
|
||||
this.maxBackoff = maxBackoff;
|
||||
return this;
|
||||
}
|
||||
|
||||
/**
|
||||
* Specify the maximum number of instances you'd want of this job at any given time. If
|
||||
* enqueueing this job would put it over that limit, it will be ignored.
|
||||
*
|
||||
* Duplicates are determined by two jobs having the same {@link Job#getFactoryKey()}.
|
||||
*
|
||||
* This property is ignored if the job is submitted as part of a {@link JobManager.Chain}.
|
||||
*
|
||||
* Defaults to {@link #UNLIMITED}.
|
||||
*/
|
||||
public @NonNull Builder setMaxInstances(int maxInstances) {
|
||||
this.maxInstances = maxInstances;
|
||||
return this;
|
||||
}
|
||||
|
||||
/**
|
||||
* Specify a string representing a queue. All jobs within the same queue are run in a
|
||||
* serialized fashion -- one after the other, in order of insertion. Failure of a job earlier
|
||||
* in the queue has no impact on the execution of jobs later in the queue.
|
||||
*/
|
||||
public @NonNull Builder setQueue(@Nullable String queue) {
|
||||
this.queue = queue;
|
||||
return this;
|
||||
}
|
||||
|
||||
/**
|
||||
* Add a constraint via the key that was used to register its factory in
|
||||
* {@link JobManager.Configuration)};
|
||||
*/
|
||||
public @NonNull Builder addConstraint(@NonNull String constraintKey) {
|
||||
constraintKeys.add(constraintKey);
|
||||
return this;
|
||||
}
|
||||
|
||||
/**
|
||||
* Set constraints via the key that was used to register its factory in
|
||||
* {@link JobManager.Configuration)};
|
||||
*/
|
||||
public @NonNull Builder setConstraints(@NonNull List<String> constraintKeys) {
|
||||
this.constraintKeys.clear();
|
||||
this.constraintKeys.addAll(constraintKeys);
|
||||
return this;
|
||||
}
|
||||
|
||||
public @NonNull Parameters build() {
|
||||
return new Parameters(createTime, lifespan, maxAttempts, maxBackoff, maxInstances, queue, constraintKeys);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
@ -1,354 +0,0 @@
|
||||
package org.thoughtcrime.securesms.jobmanager;
|
||||
|
||||
import android.app.Application;
|
||||
|
||||
import androidx.annotation.NonNull;
|
||||
import androidx.annotation.Nullable;
|
||||
import androidx.annotation.WorkerThread;
|
||||
|
||||
import com.annimon.stream.Stream;
|
||||
|
||||
import org.session.libsession.messaging.utilities.Data;
|
||||
import org.session.libsession.utilities.Debouncer;
|
||||
import org.session.libsignal.utilities.Log;
|
||||
import org.thoughtcrime.securesms.jobmanager.persistence.ConstraintSpec;
|
||||
import org.thoughtcrime.securesms.jobmanager.persistence.DependencySpec;
|
||||
import org.thoughtcrime.securesms.jobmanager.persistence.FullSpec;
|
||||
import org.thoughtcrime.securesms.jobmanager.persistence.JobSpec;
|
||||
import org.thoughtcrime.securesms.jobmanager.persistence.JobStorage;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.Collections;
|
||||
import java.util.HashSet;
|
||||
import java.util.LinkedList;
|
||||
import java.util.List;
|
||||
import java.util.Set;
|
||||
import java.util.UUID;
|
||||
|
||||
/**
|
||||
* Manages the queue of jobs. This is the only class that should write to {@link JobStorage} to
|
||||
* ensure consistency.
|
||||
*/
|
||||
class JobController {
|
||||
|
||||
private static final String TAG = JobController.class.getSimpleName();
|
||||
|
||||
private final Application application;
|
||||
private final JobStorage jobStorage;
|
||||
private final JobInstantiator jobInstantiator;
|
||||
private final ConstraintInstantiator constraintInstantiator;
|
||||
private final Data.Serializer dataSerializer;
|
||||
private final Scheduler scheduler;
|
||||
private final Debouncer debouncer;
|
||||
private final Callback callback;
|
||||
private final Set<String> runningJobs;
|
||||
|
||||
JobController(@NonNull Application application,
|
||||
@NonNull JobStorage jobStorage,
|
||||
@NonNull JobInstantiator jobInstantiator,
|
||||
@NonNull ConstraintInstantiator constraintInstantiator,
|
||||
@NonNull Data.Serializer dataSerializer,
|
||||
@NonNull Scheduler scheduler,
|
||||
@NonNull Debouncer debouncer,
|
||||
@NonNull Callback callback)
|
||||
{
|
||||
this.application = application;
|
||||
this.jobStorage = jobStorage;
|
||||
this.jobInstantiator = jobInstantiator;
|
||||
this.constraintInstantiator = constraintInstantiator;
|
||||
this.dataSerializer = dataSerializer;
|
||||
this.scheduler = scheduler;
|
||||
this.debouncer = debouncer;
|
||||
this.callback = callback;
|
||||
this.runningJobs = new HashSet<>();
|
||||
}
|
||||
|
||||
@WorkerThread
|
||||
synchronized void init() {
|
||||
jobStorage.init();
|
||||
jobStorage.updateAllJobsToBePending();
|
||||
notifyAll();
|
||||
}
|
||||
|
||||
synchronized void wakeUp() {
|
||||
notifyAll();
|
||||
}
|
||||
|
||||
@WorkerThread
|
||||
synchronized void submitNewJobChain(@NonNull List<List<Job>> chain) {
|
||||
chain = Stream.of(chain).filterNot(List::isEmpty).toList();
|
||||
|
||||
if (chain.isEmpty()) {
|
||||
Log.w(TAG, "Tried to submit an empty job chain. Skipping.");
|
||||
return;
|
||||
}
|
||||
|
||||
if (chainExceedsMaximumInstances(chain)) {
|
||||
Job solo = chain.get(0).get(0);
|
||||
Log.w(TAG, JobLogger.format(solo, "Already at the max instance count of " + solo.getParameters().getMaxInstances() + ". Skipping."));
|
||||
return;
|
||||
}
|
||||
|
||||
insertJobChain(chain);
|
||||
scheduleJobs(chain.get(0));
|
||||
triggerOnSubmit(chain);
|
||||
notifyAll();
|
||||
}
|
||||
|
||||
@WorkerThread
|
||||
synchronized void onRetry(@NonNull Job job) {
|
||||
int nextRunAttempt = job.getRunAttempt() + 1;
|
||||
long nextRunAttemptTime = calculateNextRunAttemptTime(System.currentTimeMillis(), nextRunAttempt, job.getParameters().getMaxBackoff());
|
||||
|
||||
jobStorage.updateJobAfterRetry(job.getId(), false, nextRunAttempt, nextRunAttemptTime);
|
||||
|
||||
List<Constraint> constraints = Stream.of(jobStorage.getConstraintSpecs(job.getId()))
|
||||
.map(ConstraintSpec::getFactoryKey)
|
||||
.map(constraintInstantiator::instantiate)
|
||||
.toList();
|
||||
|
||||
|
||||
long delay = Math.max(0, nextRunAttemptTime - System.currentTimeMillis());
|
||||
|
||||
Log.i(TAG, JobLogger.format(job, "Scheduling a retry in " + delay + " ms."));
|
||||
scheduler.schedule(delay, constraints);
|
||||
|
||||
notifyAll();
|
||||
}
|
||||
|
||||
synchronized void onJobFinished(@NonNull Job job) {
|
||||
runningJobs.remove(job.getId());
|
||||
}
|
||||
|
||||
@WorkerThread
|
||||
synchronized void onSuccess(@NonNull Job job) {
|
||||
jobStorage.deleteJob(job.getId());
|
||||
notifyAll();
|
||||
}
|
||||
|
||||
/**
|
||||
* @return The list of all dependent jobs that should also be failed.
|
||||
*/
|
||||
@WorkerThread
|
||||
synchronized @NonNull List<Job> onFailure(@NonNull Job job) {
|
||||
List<Job> dependents = Stream.of(jobStorage.getDependencySpecsThatDependOnJob(job.getId()))
|
||||
.map(DependencySpec::getJobId)
|
||||
.map(jobStorage::getJobSpec)
|
||||
.withoutNulls()
|
||||
.map(jobSpec -> {
|
||||
List<ConstraintSpec> constraintSpecs = jobStorage.getConstraintSpecs(jobSpec.getId());
|
||||
return createJob(jobSpec, constraintSpecs);
|
||||
})
|
||||
.toList();
|
||||
|
||||
List<Job> all = new ArrayList<>(dependents.size() + 1);
|
||||
all.add(job);
|
||||
all.addAll(dependents);
|
||||
|
||||
jobStorage.deleteJobs(Stream.of(all).map(Job::getId).toList());
|
||||
|
||||
return dependents;
|
||||
}
|
||||
|
||||
/**
|
||||
* Retrieves the next job that is eligible for execution. To be 'eligible' means that the job:
|
||||
* - Has no dependencies
|
||||
* - Has no unmet constraints
|
||||
*
|
||||
* This method will block until a job is available.
|
||||
* When the job returned from this method has been run, you must call {@link #onJobFinished(Job)}.
|
||||
*/
|
||||
@WorkerThread
|
||||
synchronized @NonNull Job pullNextEligibleJobForExecution() {
|
||||
try {
|
||||
Job job;
|
||||
|
||||
while ((job = getNextEligibleJobForExecution()) == null) {
|
||||
if (runningJobs.isEmpty()) {
|
||||
debouncer.publish(callback::onEmpty);
|
||||
}
|
||||
|
||||
wait();
|
||||
}
|
||||
|
||||
jobStorage.updateJobRunningState(job.getId(), true);
|
||||
runningJobs.add(job.getId());
|
||||
|
||||
return job;
|
||||
} catch (InterruptedException e) {
|
||||
Log.e(TAG, "Interrupted.");
|
||||
throw new AssertionError(e);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Retrieves a string representing the state of the job queue. Intended for debugging.
|
||||
*/
|
||||
@WorkerThread
|
||||
synchronized @NonNull String getDebugInfo() {
|
||||
List<JobSpec> jobs = jobStorage.getAllJobSpecs();
|
||||
List<ConstraintSpec> constraints = jobStorage.getAllConstraintSpecs();
|
||||
List<DependencySpec> dependencies = jobStorage.getAllDependencySpecs();
|
||||
|
||||
StringBuilder info = new StringBuilder();
|
||||
|
||||
info.append("-- Jobs\n");
|
||||
if (!jobs.isEmpty()) {
|
||||
Stream.of(jobs).forEach(j -> info.append(j.toString()).append('\n'));
|
||||
} else {
|
||||
info.append("None\n");
|
||||
}
|
||||
|
||||
info.append("\n-- Constraints\n");
|
||||
if (!constraints.isEmpty()) {
|
||||
Stream.of(constraints).forEach(c -> info.append(c.toString()).append('\n'));
|
||||
} else {
|
||||
info.append("None\n");
|
||||
}
|
||||
|
||||
info.append("\n-- Dependencies\n");
|
||||
if (!dependencies.isEmpty()) {
|
||||
Stream.of(dependencies).forEach(d -> info.append(d.toString()).append('\n'));
|
||||
} else {
|
||||
info.append("None\n");
|
||||
}
|
||||
|
||||
return info.toString();
|
||||
}
|
||||
|
||||
@WorkerThread
|
||||
private boolean chainExceedsMaximumInstances(@NonNull List<List<Job>> chain) {
|
||||
if (chain.size() == 1 && chain.get(0).size() == 1) {
|
||||
Job solo = chain.get(0).get(0);
|
||||
|
||||
if (solo.getParameters().getMaxInstances() != Job.Parameters.UNLIMITED &&
|
||||
jobStorage.getJobInstanceCount(solo.getFactoryKey()) >= solo.getParameters().getMaxInstances())
|
||||
{
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
@WorkerThread
|
||||
private void triggerOnSubmit(@NonNull List<List<Job>> chain) {
|
||||
Stream.of(chain)
|
||||
.forEach(list -> Stream.of(list).forEach(job -> {
|
||||
job.setContext(application);
|
||||
job.onSubmit();
|
||||
}));
|
||||
}
|
||||
|
||||
@WorkerThread
|
||||
private void insertJobChain(@NonNull List<List<Job>> chain) {
|
||||
List<FullSpec> fullSpecs = new LinkedList<>();
|
||||
List<Job> dependsOn = Collections.emptyList();
|
||||
|
||||
for (List<Job> jobList : chain) {
|
||||
for (Job job : jobList) {
|
||||
fullSpecs.add(buildFullSpec(job, dependsOn));
|
||||
}
|
||||
dependsOn = jobList;
|
||||
}
|
||||
|
||||
jobStorage.insertJobs(fullSpecs);
|
||||
}
|
||||
|
||||
@WorkerThread
|
||||
private @NonNull FullSpec buildFullSpec(@NonNull Job job, @NonNull List<Job> dependsOn) {
|
||||
String id = UUID.randomUUID().toString();
|
||||
|
||||
job.setId(id);
|
||||
job.setRunAttempt(0);
|
||||
|
||||
JobSpec jobSpec = new JobSpec(job.getId(),
|
||||
job.getFactoryKey(),
|
||||
job.getParameters().getQueue(),
|
||||
job.getParameters().getCreateTime(),
|
||||
job.getNextRunAttemptTime(),
|
||||
job.getRunAttempt(),
|
||||
job.getParameters().getMaxAttempts(),
|
||||
job.getParameters().getMaxBackoff(),
|
||||
job.getParameters().getLifespan(),
|
||||
job.getParameters().getMaxInstances(),
|
||||
dataSerializer.serialize(job.serialize()),
|
||||
false);
|
||||
|
||||
List<ConstraintSpec> constraintSpecs = Stream.of(job.getParameters().getConstraintKeys())
|
||||
.map(key -> new ConstraintSpec(jobSpec.getId(), key))
|
||||
.toList();
|
||||
|
||||
List<DependencySpec> dependencySpecs = Stream.of(dependsOn)
|
||||
.map(depends -> new DependencySpec(job.getId(), depends.getId()))
|
||||
.toList();
|
||||
|
||||
return new FullSpec(jobSpec, constraintSpecs, dependencySpecs);
|
||||
}
|
||||
|
||||
@WorkerThread
|
||||
private void scheduleJobs(@NonNull List<Job> jobs) {
|
||||
for (Job job : jobs) {
|
||||
List<Constraint> constraints = Stream.of(job.getParameters().getConstraintKeys())
|
||||
.map(key -> new ConstraintSpec(job.getId(), key))
|
||||
.map(ConstraintSpec::getFactoryKey)
|
||||
.map(constraintInstantiator::instantiate)
|
||||
.toList();
|
||||
|
||||
scheduler.schedule(0, constraints);
|
||||
}
|
||||
}
|
||||
|
||||
@WorkerThread
|
||||
private @Nullable Job getNextEligibleJobForExecution() {
|
||||
List<JobSpec> jobSpecs = jobStorage.getPendingJobsWithNoDependenciesInCreatedOrder(System.currentTimeMillis());
|
||||
|
||||
for (JobSpec jobSpec : jobSpecs) {
|
||||
List<ConstraintSpec> constraintSpecs = jobStorage.getConstraintSpecs(jobSpec.getId());
|
||||
List<Constraint> constraints = Stream.of(constraintSpecs)
|
||||
.map(ConstraintSpec::getFactoryKey)
|
||||
.map(constraintInstantiator::instantiate)
|
||||
.toList();
|
||||
|
||||
if (Stream.of(constraints).allMatch(Constraint::isMet)) {
|
||||
return createJob(jobSpec, constraintSpecs);
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private @NonNull Job createJob(@NonNull JobSpec jobSpec, @NonNull List<ConstraintSpec> constraintSpecs) {
|
||||
Job.Parameters parameters = buildJobParameters(jobSpec, constraintSpecs);
|
||||
Data data = dataSerializer.deserialize(jobSpec.getSerializedData());
|
||||
Job job = jobInstantiator.instantiate(jobSpec.getFactoryKey(), parameters, data);
|
||||
|
||||
job.setId(jobSpec.getId());
|
||||
job.setRunAttempt(jobSpec.getRunAttempt());
|
||||
job.setNextRunAttemptTime(jobSpec.getNextRunAttemptTime());
|
||||
job.setContext(application);
|
||||
|
||||
return job;
|
||||
}
|
||||
|
||||
private @NonNull Job.Parameters buildJobParameters(@NonNull JobSpec jobSpec, @NonNull List<ConstraintSpec> constraintSpecs) {
|
||||
return new Job.Parameters.Builder()
|
||||
.setCreateTime(jobSpec.getCreateTime())
|
||||
.setLifespan(jobSpec.getLifespan())
|
||||
.setMaxAttempts(jobSpec.getMaxAttempts())
|
||||
.setQueue(jobSpec.getQueueKey())
|
||||
.setConstraints(Stream.of(constraintSpecs).map(ConstraintSpec::getFactoryKey).toList())
|
||||
.build();
|
||||
}
|
||||
|
||||
private long calculateNextRunAttemptTime(long currentTime, int nextAttempt, long maxBackoff) {
|
||||
int boundedAttempt = Math.min(nextAttempt, 30);
|
||||
long exponentialBackoff = (long) Math.pow(2, boundedAttempt) * 1000;
|
||||
long actualBackoff = Math.min(exponentialBackoff, maxBackoff);
|
||||
|
||||
return currentTime + actualBackoff;
|
||||
}
|
||||
|
||||
interface Callback {
|
||||
void onEmpty();
|
||||
}
|
||||
}
|
@ -1,25 +0,0 @@
|
||||
package org.thoughtcrime.securesms.jobmanager;
|
||||
|
||||
import androidx.annotation.NonNull;
|
||||
|
||||
import org.session.libsession.messaging.utilities.Data;
|
||||
|
||||
import java.util.HashMap;
|
||||
import java.util.Map;
|
||||
|
||||
class JobInstantiator {
|
||||
|
||||
private final Map<String, Job.Factory> jobFactories;
|
||||
|
||||
JobInstantiator(@NonNull Map<String, Job.Factory> jobFactories) {
|
||||
this.jobFactories = new HashMap<>(jobFactories);
|
||||
}
|
||||
|
||||
public @NonNull Job instantiate(@NonNull String jobFactoryKey, @NonNull Job.Parameters parameters, @NonNull Data data) {
|
||||
if (jobFactories.containsKey(jobFactoryKey)) {
|
||||
return jobFactories.get(jobFactoryKey).create(parameters, data);
|
||||
} else {
|
||||
throw new IllegalStateException("Tried to instantiate a job with key '" + jobFactoryKey + "', but no matching factory was found.");
|
||||
}
|
||||
}
|
||||
}
|
@ -1,24 +0,0 @@
|
||||
package org.thoughtcrime.securesms.jobmanager;
|
||||
|
||||
import androidx.annotation.NonNull;
|
||||
import android.text.TextUtils;
|
||||
|
||||
public class JobLogger {
|
||||
|
||||
public static String format(@NonNull Job job, @NonNull String event) {
|
||||
return format(job, "", event);
|
||||
}
|
||||
|
||||
public static String format(@NonNull Job job, @NonNull String extraTag, @NonNull String event) {
|
||||
String id = job.getId();
|
||||
String tag = TextUtils.isEmpty(extraTag) ? "" : "[" + extraTag + "]";
|
||||
long timeSinceSubmission = System.currentTimeMillis() - job.getParameters().getCreateTime();
|
||||
int runAttempt = job.getRunAttempt() + 1;
|
||||
String maxAttempts = job.getParameters().getMaxAttempts() == Job.Parameters.UNLIMITED ? "Unlimited"
|
||||
: String.valueOf(job.getParameters().getMaxAttempts());
|
||||
String lifespan = job.getParameters().getLifespan() == Job.Parameters.IMMORTAL ? "Immortal"
|
||||
: String.valueOf(job.getParameters().getLifespan()) + " ms";
|
||||
return String.format("[%s][%s]%s %s (Time Since Submission: %d ms, Lifespan: %s, Run Attempt: %d/%s)",
|
||||
id, job.getClass().getSimpleName(), tag, event, timeSinceSubmission, lifespan, runAttempt, maxAttempts);
|
||||
}
|
||||
}
|
@ -1,310 +0,0 @@
|
||||
package org.thoughtcrime.securesms.jobmanager;
|
||||
|
||||
import android.app.Application;
|
||||
import android.content.Intent;
|
||||
import android.os.Build;
|
||||
|
||||
import androidx.annotation.NonNull;
|
||||
|
||||
import org.session.libsession.messaging.utilities.Data;
|
||||
import org.session.libsession.utilities.Debouncer;
|
||||
import org.session.libsignal.utilities.Log;
|
||||
import org.thoughtcrime.securesms.jobmanager.impl.DefaultExecutorFactory;
|
||||
import org.thoughtcrime.securesms.jobmanager.impl.JsonDataSerializer;
|
||||
import org.thoughtcrime.securesms.jobmanager.persistence.JobStorage;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.Collections;
|
||||
import java.util.HashMap;
|
||||
import java.util.LinkedList;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Set;
|
||||
import java.util.concurrent.CopyOnWriteArraySet;
|
||||
import java.util.concurrent.ExecutionException;
|
||||
import java.util.concurrent.ExecutorService;
|
||||
import java.util.concurrent.Future;
|
||||
|
||||
/**
|
||||
* Allows the scheduling of durable jobs that will be run as early as possible.
|
||||
*/
|
||||
public class JobManager implements ConstraintObserver.Notifier {
|
||||
|
||||
private static final String TAG = JobManager.class.getSimpleName();
|
||||
|
||||
private final ExecutorService executor;
|
||||
private final JobController jobController;
|
||||
private final JobRunner[] jobRunners;
|
||||
|
||||
private final Set<EmptyQueueListener> emptyQueueListeners = new CopyOnWriteArraySet<>();
|
||||
|
||||
public JobManager(@NonNull Application application, @NonNull Configuration configuration) {
|
||||
this.executor = configuration.getExecutorFactory().newSingleThreadExecutor("JobManager");
|
||||
this.jobRunners = new JobRunner[configuration.getJobThreadCount()];
|
||||
this.jobController = new JobController(application,
|
||||
configuration.getJobStorage(),
|
||||
configuration.getJobInstantiator(),
|
||||
configuration.getConstraintFactories(),
|
||||
configuration.getDataSerializer(),
|
||||
Build.VERSION.SDK_INT < 26 ? new AlarmManagerScheduler(application)
|
||||
: new CompositeScheduler(new InAppScheduler(this), new JobSchedulerScheduler(application)),
|
||||
new Debouncer(500),
|
||||
this::onEmptyQueue);
|
||||
|
||||
executor.execute(() -> {
|
||||
jobController.init();
|
||||
|
||||
for (int i = 0; i < jobRunners.length; i++) {
|
||||
jobRunners[i] = new JobRunner(application, i + 1, jobController);
|
||||
jobRunners[i].start();
|
||||
}
|
||||
|
||||
for (ConstraintObserver constraintObserver : configuration.getConstraintObservers()) {
|
||||
constraintObserver.register(this);
|
||||
}
|
||||
|
||||
if (Build.VERSION.SDK_INT < 26) {
|
||||
application.startService(new Intent(application, KeepAliveService.class));
|
||||
}
|
||||
|
||||
wakeUp();
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Enqueues a single job to be run.
|
||||
*/
|
||||
public void add(@NonNull Job job) {
|
||||
new Chain(this, Collections.singletonList(job)).enqueue();
|
||||
}
|
||||
|
||||
/**
|
||||
* Begins the creation of a job chain with a single job.
|
||||
* @see Chain
|
||||
*/
|
||||
public Chain startChain(@NonNull Job job) {
|
||||
return new Chain(this, Collections.singletonList(job));
|
||||
}
|
||||
|
||||
/**
|
||||
* Begins the creation of a job chain with a set of jobs that can be run in parallel.
|
||||
* @see Chain
|
||||
*/
|
||||
public Chain startChain(@NonNull List<? extends Job> jobs) {
|
||||
return new Chain(this, jobs);
|
||||
}
|
||||
|
||||
/**
|
||||
* Retrieves a string representing the state of the job queue. Intended for debugging.
|
||||
*/
|
||||
public @NonNull String getDebugInfo() {
|
||||
Future<String> result = executor.submit(jobController::getDebugInfo);
|
||||
try {
|
||||
return result.get();
|
||||
} catch (ExecutionException | InterruptedException e) {
|
||||
Log.w(TAG, "Failed to retrieve Job info.", e);
|
||||
return "Failed to retrieve Job info.";
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Adds a listener to that will be notified when the job queue has been drained.
|
||||
*/
|
||||
void addOnEmptyQueueListener(@NonNull EmptyQueueListener listener) {
|
||||
executor.execute(() -> {
|
||||
emptyQueueListeners.add(listener);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Removes a listener that was added via {@link #addOnEmptyQueueListener(EmptyQueueListener)}.
|
||||
*/
|
||||
void removeOnEmptyQueueListener(@NonNull EmptyQueueListener listener) {
|
||||
executor.execute(() -> {
|
||||
emptyQueueListeners.remove(listener);
|
||||
});
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onConstraintMet(@NonNull String reason) {
|
||||
Log.i(TAG, "onConstraintMet(" + reason + ")");
|
||||
wakeUp();
|
||||
}
|
||||
|
||||
/**
|
||||
* Pokes the system to take another pass at the job queue.
|
||||
*/
|
||||
void wakeUp() {
|
||||
executor.execute(jobController::wakeUp);
|
||||
}
|
||||
|
||||
private void enqueueChain(@NonNull Chain chain) {
|
||||
executor.execute(() -> {
|
||||
jobController.submitNewJobChain(chain.getJobListChain());
|
||||
wakeUp();
|
||||
});
|
||||
}
|
||||
|
||||
private void onEmptyQueue() {
|
||||
executor.execute(() -> {
|
||||
for (EmptyQueueListener listener : emptyQueueListeners) {
|
||||
listener.onQueueEmpty();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
public interface EmptyQueueListener {
|
||||
void onQueueEmpty();
|
||||
}
|
||||
|
||||
/**
|
||||
* Allows enqueuing work that depends on each other. Jobs that appear later in the chain will
|
||||
* only run after all jobs earlier in the chain have been completed. If a job fails, all jobs
|
||||
* that occur later in the chain will also be failed.
|
||||
*/
|
||||
public static class Chain {
|
||||
|
||||
private final JobManager jobManager;
|
||||
private final List<List<Job>> jobs;
|
||||
|
||||
private Chain(@NonNull JobManager jobManager, @NonNull List<? extends Job> jobs) {
|
||||
this.jobManager = jobManager;
|
||||
this.jobs = new LinkedList<>();
|
||||
|
||||
this.jobs.add(new ArrayList<>(jobs));
|
||||
}
|
||||
|
||||
public Chain then(@NonNull Job job) {
|
||||
return then(Collections.singletonList(job));
|
||||
}
|
||||
|
||||
public Chain then(@NonNull List<Job> jobs) {
|
||||
if (!jobs.isEmpty()) {
|
||||
this.jobs.add(new ArrayList<>(jobs));
|
||||
}
|
||||
return this;
|
||||
}
|
||||
|
||||
public void enqueue() {
|
||||
jobManager.enqueueChain(this);
|
||||
}
|
||||
|
||||
private List<List<Job>> getJobListChain() {
|
||||
return jobs;
|
||||
}
|
||||
}
|
||||
|
||||
public static class Configuration {
|
||||
|
||||
private final ExecutorFactory executorFactory;
|
||||
private final int jobThreadCount;
|
||||
private final JobInstantiator jobInstantiator;
|
||||
private final ConstraintInstantiator constraintInstantiator;
|
||||
private final List<ConstraintObserver> constraintObservers;
|
||||
private final Data.Serializer dataSerializer;
|
||||
private final JobStorage jobStorage;
|
||||
|
||||
private Configuration(int jobThreadCount,
|
||||
@NonNull ExecutorFactory executorFactory,
|
||||
@NonNull JobInstantiator jobInstantiator,
|
||||
@NonNull ConstraintInstantiator constraintInstantiator,
|
||||
@NonNull List<ConstraintObserver> constraintObservers,
|
||||
@NonNull Data.Serializer dataSerializer,
|
||||
@NonNull JobStorage jobStorage)
|
||||
{
|
||||
this.executorFactory = executorFactory;
|
||||
this.jobThreadCount = jobThreadCount;
|
||||
this.jobInstantiator = jobInstantiator;
|
||||
this.constraintInstantiator = constraintInstantiator;
|
||||
this.constraintObservers = constraintObservers;
|
||||
this.dataSerializer = dataSerializer;
|
||||
this.jobStorage = jobStorage;
|
||||
}
|
||||
|
||||
int getJobThreadCount() {
|
||||
return jobThreadCount;
|
||||
}
|
||||
|
||||
@NonNull ExecutorFactory getExecutorFactory() {
|
||||
return executorFactory;
|
||||
}
|
||||
|
||||
@NonNull JobInstantiator getJobInstantiator() {
|
||||
return jobInstantiator;
|
||||
}
|
||||
|
||||
@NonNull
|
||||
ConstraintInstantiator getConstraintFactories() {
|
||||
return constraintInstantiator;
|
||||
}
|
||||
|
||||
@NonNull List<ConstraintObserver> getConstraintObservers() {
|
||||
return constraintObservers;
|
||||
}
|
||||
|
||||
@NonNull Data.Serializer getDataSerializer() {
|
||||
return dataSerializer;
|
||||
}
|
||||
|
||||
@NonNull JobStorage getJobStorage() {
|
||||
return jobStorage;
|
||||
}
|
||||
|
||||
|
||||
public static class Builder {
|
||||
|
||||
private ExecutorFactory executorFactory = new DefaultExecutorFactory();
|
||||
private int jobThreadCount = 1;
|
||||
private Map<String, Job.Factory> jobFactories = new HashMap<>();
|
||||
private Map<String, Constraint.Factory> constraintFactories = new HashMap<>();
|
||||
private List<ConstraintObserver> constraintObservers = new ArrayList<>();
|
||||
private Data.Serializer dataSerializer = new JsonDataSerializer();
|
||||
private JobStorage jobStorage = null;
|
||||
|
||||
public @NonNull Builder setJobThreadCount(int jobThreadCount) {
|
||||
this.jobThreadCount = jobThreadCount;
|
||||
return this;
|
||||
}
|
||||
|
||||
public @NonNull Builder setExecutorFactory(@NonNull ExecutorFactory executorFactory) {
|
||||
this.executorFactory = executorFactory;
|
||||
return this;
|
||||
}
|
||||
|
||||
public @NonNull Builder setJobFactories(@NonNull Map<String, Job.Factory> jobFactories) {
|
||||
this.jobFactories = jobFactories;
|
||||
return this;
|
||||
}
|
||||
|
||||
public @NonNull Builder setConstraintFactories(@NonNull Map<String, Constraint.Factory> constraintFactories) {
|
||||
this.constraintFactories = constraintFactories;
|
||||
return this;
|
||||
}
|
||||
|
||||
public @NonNull Builder setConstraintObservers(@NonNull List<ConstraintObserver> constraintObservers) {
|
||||
this.constraintObservers = constraintObservers;
|
||||
return this;
|
||||
}
|
||||
|
||||
public @NonNull Builder setDataSerializer(@NonNull Data.Serializer dataSerializer) {
|
||||
this.dataSerializer = dataSerializer;
|
||||
return this;
|
||||
}
|
||||
|
||||
public @NonNull Builder setJobStorage(@NonNull JobStorage jobStorage) {
|
||||
this.jobStorage = jobStorage;
|
||||
return this;
|
||||
}
|
||||
|
||||
public @NonNull Configuration build() {
|
||||
return new Configuration(jobThreadCount,
|
||||
executorFactory,
|
||||
new JobInstantiator(jobFactories),
|
||||
new ConstraintInstantiator(constraintFactories),
|
||||
new ArrayList<>(constraintObservers),
|
||||
dataSerializer,
|
||||
jobStorage);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
@ -1,110 +0,0 @@
|
||||
package org.thoughtcrime.securesms.jobmanager;
|
||||
|
||||
import android.app.Application;
|
||||
import android.os.PowerManager;
|
||||
import androidx.annotation.NonNull;
|
||||
|
||||
import com.annimon.stream.Stream;
|
||||
|
||||
import org.session.libsignal.utilities.Log;
|
||||
import org.thoughtcrime.securesms.util.WakeLockUtil;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
|
||||
class JobRunner extends Thread {
|
||||
|
||||
private static final String TAG = JobRunner.class.getSimpleName();
|
||||
|
||||
private static long WAKE_LOCK_TIMEOUT = TimeUnit.MINUTES.toMillis(10);
|
||||
|
||||
private final Application application;
|
||||
private final int id;
|
||||
private final JobController jobController;
|
||||
|
||||
JobRunner(@NonNull Application application, int id, @NonNull JobController jobController) {
|
||||
super("JobRunner-" + id);
|
||||
|
||||
this.application = application;
|
||||
this.id = id;
|
||||
this.jobController = jobController;
|
||||
}
|
||||
|
||||
@Override
|
||||
public synchronized void run() {
|
||||
while (true) {
|
||||
Job job = jobController.pullNextEligibleJobForExecution();
|
||||
Job.Result result = run(job);
|
||||
|
||||
jobController.onJobFinished(job);
|
||||
|
||||
switch (result) {
|
||||
case SUCCESS:
|
||||
jobController.onSuccess(job);
|
||||
break;
|
||||
case RETRY:
|
||||
jobController.onRetry(job);
|
||||
job.onRetry();
|
||||
break;
|
||||
case FAILURE:
|
||||
List<Job> dependents = jobController.onFailure(job);
|
||||
job.onCanceled();
|
||||
Stream.of(dependents).forEach(Job::onCanceled);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private Job.Result run(@NonNull Job job) {
|
||||
Log.i(TAG, JobLogger.format(job, String.valueOf(id), "Running job."));
|
||||
|
||||
if (isJobExpired(job)) {
|
||||
Log.w(TAG, JobLogger.format(job, String.valueOf(id), "Failing after surpassing its lifespan."));
|
||||
return Job.Result.FAILURE;
|
||||
}
|
||||
|
||||
Job.Result result = null;
|
||||
PowerManager.WakeLock wakeLock = null;
|
||||
|
||||
try {
|
||||
wakeLock = WakeLockUtil.acquire(application, PowerManager.PARTIAL_WAKE_LOCK, WAKE_LOCK_TIMEOUT, job.getId());
|
||||
result = job.run();
|
||||
} catch (Exception e) {
|
||||
Log.w(TAG, JobLogger.format(job, String.valueOf(id), "Failing due to an unexpected exception."), e);
|
||||
return Job.Result.FAILURE;
|
||||
} finally {
|
||||
if (wakeLock != null) {
|
||||
WakeLockUtil.release(wakeLock, job.getId());
|
||||
}
|
||||
}
|
||||
|
||||
printResult(job, result);
|
||||
|
||||
if (result == Job.Result.RETRY && job.getRunAttempt() + 1 >= job.getParameters().getMaxAttempts() &&
|
||||
job.getParameters().getMaxAttempts() != Job.Parameters.UNLIMITED)
|
||||
{
|
||||
Log.w(TAG, JobLogger.format(job, String.valueOf(id), "Failing after surpassing its max number of attempts."));
|
||||
return Job.Result.FAILURE;
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
private boolean isJobExpired(@NonNull Job job) {
|
||||
long expirationTime = job.getParameters().getCreateTime() + job.getParameters().getLifespan();
|
||||
|
||||
if (expirationTime < 0) {
|
||||
expirationTime = Long.MAX_VALUE;
|
||||
}
|
||||
|
||||
return job.getParameters().getLifespan() != Job.Parameters.IMMORTAL && expirationTime <= System.currentTimeMillis();
|
||||
}
|
||||
|
||||
private void printResult(@NonNull Job job, @NonNull Job.Result result) {
|
||||
if (result == Job.Result.FAILURE) {
|
||||
Log.w(TAG, JobLogger.format(job, String.valueOf(id), "Job failed."));
|
||||
} else {
|
||||
Log.i(TAG, JobLogger.format(job, String.valueOf(id), "Job finished with result: " + result));
|
||||
}
|
||||
}
|
||||
}
|
@ -1,90 +0,0 @@
|
||||
package org.thoughtcrime.securesms.jobmanager;
|
||||
|
||||
import android.app.Application;
|
||||
import android.app.job.JobInfo;
|
||||
import android.app.job.JobParameters;
|
||||
import android.app.job.JobScheduler;
|
||||
import android.app.job.JobService;
|
||||
import android.content.ComponentName;
|
||||
import android.content.Context;
|
||||
import android.content.SharedPreferences;
|
||||
import androidx.annotation.NonNull;
|
||||
import androidx.annotation.RequiresApi;
|
||||
|
||||
import org.thoughtcrime.securesms.ApplicationContext;
|
||||
import org.session.libsignal.utilities.Log;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
@RequiresApi(26)
|
||||
public class JobSchedulerScheduler implements Scheduler {
|
||||
|
||||
private static final String TAG = JobSchedulerScheduler.class.getSimpleName();
|
||||
|
||||
private static final String PREF_NAME = "JobSchedulerScheduler_prefs";
|
||||
private static final String PREF_NEXT_ID = "pref_next_id";
|
||||
|
||||
private static final int MAX_ID = 75;
|
||||
|
||||
private final Application application;
|
||||
|
||||
JobSchedulerScheduler(@NonNull Application application) {
|
||||
this.application = application;
|
||||
}
|
||||
|
||||
@RequiresApi(26)
|
||||
@Override
|
||||
public void schedule(long delay, @NonNull List<Constraint> constraints) {
|
||||
JobInfo.Builder jobInfoBuilder = new JobInfo.Builder(getNextId(), new ComponentName(application, SystemService.class))
|
||||
.setMinimumLatency(delay)
|
||||
.setPersisted(true);
|
||||
|
||||
for (Constraint constraint : constraints) {
|
||||
constraint.applyToJobInfo(jobInfoBuilder);
|
||||
}
|
||||
|
||||
Log.i(TAG, "Scheduling a run in " + delay + " ms.");
|
||||
JobScheduler jobScheduler = application.getSystemService(JobScheduler.class);
|
||||
jobScheduler.schedule(jobInfoBuilder.build());
|
||||
}
|
||||
|
||||
private int getNextId() {
|
||||
SharedPreferences prefs = application.getSharedPreferences(PREF_NAME, Context.MODE_PRIVATE);
|
||||
int returnedId = prefs.getInt(PREF_NEXT_ID, 0);
|
||||
int nextId = returnedId + 1 > MAX_ID ? 0 : returnedId + 1;
|
||||
|
||||
prefs.edit().putInt(PREF_NEXT_ID, nextId).apply();
|
||||
|
||||
return returnedId;
|
||||
}
|
||||
|
||||
@RequiresApi(api = 26)
|
||||
public static class SystemService extends JobService {
|
||||
|
||||
@Override
|
||||
public boolean onStartJob(JobParameters params) {
|
||||
Log.d(TAG, "onStartJob()");
|
||||
|
||||
JobManager jobManager = ApplicationContext.getInstance(getApplicationContext()).getJobManager();
|
||||
|
||||
jobManager.addOnEmptyQueueListener(new JobManager.EmptyQueueListener() {
|
||||
@Override
|
||||
public void onQueueEmpty() {
|
||||
jobManager.removeOnEmptyQueueListener(this);
|
||||
jobFinished(params, false);
|
||||
Log.d(TAG, "jobFinished()");
|
||||
}
|
||||
});
|
||||
|
||||
jobManager.wakeUp();
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean onStopJob(JobParameters params) {
|
||||
Log.d(TAG, "onStopJob()");
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
@ -1,9 +0,0 @@
|
||||
package org.thoughtcrime.securesms.jobmanager;
|
||||
|
||||
import androidx.annotation.NonNull;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
public interface Scheduler {
|
||||
void schedule(long delay, @NonNull List<Constraint> constraints);
|
||||
}
|
@ -1,48 +0,0 @@
|
||||
package org.thoughtcrime.securesms.jobmanager.impl;
|
||||
|
||||
import android.app.Application;
|
||||
import android.app.job.JobInfo;
|
||||
import androidx.annotation.NonNull;
|
||||
|
||||
import org.thoughtcrime.securesms.jobmanager.Constraint;
|
||||
import org.thoughtcrime.securesms.sms.TelephonyServiceState;
|
||||
|
||||
public class CellServiceConstraint implements Constraint {
|
||||
|
||||
public static final String KEY = "CellServiceConstraint";
|
||||
|
||||
private final Application application;
|
||||
|
||||
public CellServiceConstraint(@NonNull Application application) {
|
||||
this.application = application;
|
||||
}
|
||||
|
||||
@Override
|
||||
public @NonNull String getFactoryKey() {
|
||||
return KEY;
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean isMet() {
|
||||
TelephonyServiceState telephonyServiceState = new TelephonyServiceState();
|
||||
return telephonyServiceState.isConnected(application);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void applyToJobInfo(@NonNull JobInfo.Builder jobInfoBuilder) {
|
||||
}
|
||||
|
||||
public static final class Factory implements Constraint.Factory<CellServiceConstraint> {
|
||||
|
||||
private final Application application;
|
||||
|
||||
public Factory(@NonNull Application application) {
|
||||
this.application = application;
|
||||
}
|
||||
|
||||
@Override
|
||||
public CellServiceConstraint create() {
|
||||
return new CellServiceConstraint(application);
|
||||
}
|
||||
}
|
||||
}
|
@ -1,38 +0,0 @@
|
||||
package org.thoughtcrime.securesms.jobmanager.impl;
|
||||
|
||||
import android.app.Application;
|
||||
import android.content.Context;
|
||||
import androidx.annotation.NonNull;
|
||||
import android.telephony.PhoneStateListener;
|
||||
import android.telephony.ServiceState;
|
||||
import android.telephony.TelephonyManager;
|
||||
|
||||
import org.thoughtcrime.securesms.jobmanager.ConstraintObserver;
|
||||
|
||||
public class CellServiceConstraintObserver implements ConstraintObserver {
|
||||
|
||||
private static final String REASON = CellServiceConstraintObserver.class.getSimpleName();
|
||||
|
||||
private Notifier notifier;
|
||||
|
||||
public CellServiceConstraintObserver(@NonNull Application application) {
|
||||
TelephonyManager telephonyManager = (TelephonyManager) application.getSystemService(Context.TELEPHONY_SERVICE);
|
||||
ServiceStateListener serviceStateListener = new ServiceStateListener();
|
||||
|
||||
telephonyManager.listen(serviceStateListener, PhoneStateListener.LISTEN_SERVICE_STATE);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void register(@NonNull Notifier notifier) {
|
||||
this.notifier = notifier;
|
||||
}
|
||||
|
||||
private class ServiceStateListener extends PhoneStateListener {
|
||||
@Override
|
||||
public void onServiceStateChanged(ServiceState serviceState) {
|
||||
if (serviceState.getState() == ServiceState.STATE_IN_SERVICE && notifier != null) {
|
||||
notifier.onConstraintMet(REASON);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
@ -1,15 +0,0 @@
|
||||
package org.thoughtcrime.securesms.jobmanager.impl;
|
||||
|
||||
import androidx.annotation.NonNull;
|
||||
|
||||
import org.thoughtcrime.securesms.jobmanager.ExecutorFactory;
|
||||
|
||||
import java.util.concurrent.ExecutorService;
|
||||
import java.util.concurrent.Executors;
|
||||
|
||||
public class DefaultExecutorFactory implements ExecutorFactory {
|
||||
@Override
|
||||
public @NonNull ExecutorService newSingleThreadExecutor(@NonNull String name) {
|
||||
return Executors.newSingleThreadExecutor(r -> new Thread(r, name));
|
||||
}
|
||||
}
|
@ -1,36 +0,0 @@
|
||||
package org.thoughtcrime.securesms.jobmanager.impl;
|
||||
|
||||
import android.app.Application;
|
||||
import android.content.BroadcastReceiver;
|
||||
import android.content.Context;
|
||||
import android.content.Intent;
|
||||
import android.content.IntentFilter;
|
||||
import android.net.ConnectivityManager;
|
||||
import androidx.annotation.NonNull;
|
||||
|
||||
import org.thoughtcrime.securesms.jobmanager.ConstraintObserver;
|
||||
|
||||
public class NetworkConstraintObserver implements ConstraintObserver {
|
||||
|
||||
private static final String REASON = NetworkConstraintObserver.class.getSimpleName();
|
||||
|
||||
private final Application application;
|
||||
|
||||
public NetworkConstraintObserver(Application application) {
|
||||
this.application = application;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void register(@NonNull Notifier notifier) {
|
||||
application.registerReceiver(new BroadcastReceiver() {
|
||||
@Override
|
||||
public void onReceive(Context context, Intent intent) {
|
||||
NetworkConstraint constraint = new NetworkConstraint.Factory(application).create();
|
||||
|
||||
if (constraint.isMet()) {
|
||||
notifier.onConstraintMet(REASON);
|
||||
}
|
||||
}
|
||||
}, new IntentFilter(ConnectivityManager.CONNECTIVITY_ACTION));
|
||||
}
|
||||
}
|
@ -1,48 +0,0 @@
|
||||
package org.thoughtcrime.securesms.jobmanager.impl;
|
||||
|
||||
import android.app.Application;
|
||||
import android.app.job.JobInfo;
|
||||
import androidx.annotation.NonNull;
|
||||
|
||||
import org.thoughtcrime.securesms.jobmanager.Constraint;
|
||||
|
||||
public class NetworkOrCellServiceConstraint implements Constraint {
|
||||
|
||||
public static final String KEY = "NetworkOrCellServiceConstraint";
|
||||
|
||||
private final NetworkConstraint networkConstraint;
|
||||
private final CellServiceConstraint serviceConstraint;
|
||||
|
||||
public NetworkOrCellServiceConstraint(@NonNull Application application) {
|
||||
networkConstraint = new NetworkConstraint.Factory(application).create();
|
||||
serviceConstraint = new CellServiceConstraint.Factory(application).create();
|
||||
}
|
||||
|
||||
@Override
|
||||
public @NonNull String getFactoryKey() {
|
||||
return KEY;
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean isMet() {
|
||||
return networkConstraint.isMet() || serviceConstraint.isMet();
|
||||
}
|
||||
|
||||
@Override
|
||||
public void applyToJobInfo(@NonNull JobInfo.Builder jobInfoBuilder) {
|
||||
}
|
||||
|
||||
public static class Factory implements Constraint.Factory<NetworkOrCellServiceConstraint> {
|
||||
|
||||
private final Application application;
|
||||
|
||||
public Factory(@NonNull Application application) {
|
||||
this.application = application;
|
||||
}
|
||||
|
||||
@Override
|
||||
public NetworkOrCellServiceConstraint create() {
|
||||
return new NetworkOrCellServiceConstraint(application);
|
||||
}
|
||||
}
|
||||
}
|
@ -1,48 +0,0 @@
|
||||
package org.thoughtcrime.securesms.jobmanager.impl;
|
||||
|
||||
import android.app.Application;
|
||||
import android.app.job.JobInfo;
|
||||
import androidx.annotation.NonNull;
|
||||
|
||||
import org.thoughtcrime.securesms.jobmanager.Constraint;
|
||||
import org.session.libsession.utilities.TextSecurePreferences;
|
||||
|
||||
public class SqlCipherMigrationConstraint implements Constraint {
|
||||
|
||||
public static final String KEY = "SqlCipherMigrationConstraint";
|
||||
|
||||
private final Application application;
|
||||
|
||||
private SqlCipherMigrationConstraint(@NonNull Application application) {
|
||||
this.application = application;
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean isMet() {
|
||||
return !TextSecurePreferences.getNeedsSqlCipherMigration(application);
|
||||
}
|
||||
|
||||
@NonNull
|
||||
@Override
|
||||
public String getFactoryKey() {
|
||||
return KEY;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void applyToJobInfo(@NonNull JobInfo.Builder jobInfoBuilder) {
|
||||
}
|
||||
|
||||
public static final class Factory implements Constraint.Factory<SqlCipherMigrationConstraint> {
|
||||
|
||||
private final Application application;
|
||||
|
||||
public Factory(@NonNull Application application) {
|
||||
this.application = application;
|
||||
}
|
||||
|
||||
@Override
|
||||
public SqlCipherMigrationConstraint create() {
|
||||
return new SqlCipherMigrationConstraint(application);
|
||||
}
|
||||
}
|
||||
}
|
@ -1,32 +0,0 @@
|
||||
package org.thoughtcrime.securesms.jobmanager.impl;
|
||||
|
||||
import androidx.annotation.NonNull;
|
||||
|
||||
import org.greenrobot.eventbus.EventBus;
|
||||
import org.greenrobot.eventbus.Subscribe;
|
||||
import org.greenrobot.eventbus.ThreadMode;
|
||||
import org.thoughtcrime.securesms.jobmanager.ConstraintObserver;
|
||||
|
||||
public class SqlCipherMigrationConstraintObserver implements ConstraintObserver {
|
||||
|
||||
private static final String REASON = SqlCipherMigrationConstraintObserver.class.getSimpleName();
|
||||
|
||||
private Notifier notifier;
|
||||
|
||||
public SqlCipherMigrationConstraintObserver() {
|
||||
EventBus.getDefault().register(this);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void register(@NonNull Notifier notifier) {
|
||||
this.notifier = notifier;
|
||||
}
|
||||
|
||||
@Subscribe(threadMode = ThreadMode.MAIN)
|
||||
public void onEvent(SqlCipherNeedsMigrationEvent event) {
|
||||
if (notifier != null) notifier.onConstraintMet(REASON);
|
||||
}
|
||||
|
||||
public static class SqlCipherNeedsMigrationEvent {
|
||||
}
|
||||
}
|
@ -1,43 +0,0 @@
|
||||
package org.thoughtcrime.securesms.jobmanager.persistence;
|
||||
|
||||
import androidx.annotation.NonNull;
|
||||
|
||||
import java.util.Objects;
|
||||
|
||||
public final class ConstraintSpec {
|
||||
|
||||
private final String jobSpecId;
|
||||
private final String factoryKey;
|
||||
|
||||
public ConstraintSpec(@NonNull String jobSpecId, @NonNull String factoryKey) {
|
||||
this.jobSpecId = jobSpecId;
|
||||
this.factoryKey = factoryKey;
|
||||
}
|
||||
|
||||
public String getJobSpecId() {
|
||||
return jobSpecId;
|
||||
}
|
||||
|
||||
public String getFactoryKey() {
|
||||
return factoryKey;
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean equals(Object o) {
|
||||
if (this == o) return true;
|
||||
if (o == null || getClass() != o.getClass()) return false;
|
||||
ConstraintSpec that = (ConstraintSpec) o;
|
||||
return Objects.equals(jobSpecId, that.jobSpecId) &&
|
||||
Objects.equals(factoryKey, that.factoryKey);
|
||||
}
|
||||
|
||||
@Override
|
||||
public int hashCode() {
|
||||
return Objects.hash(jobSpecId, factoryKey);
|
||||
}
|
||||
|
||||
@Override
|
||||
public @NonNull String toString() {
|
||||
return String.format("jobSpecId: %s | factoryKey: %s", jobSpecId, factoryKey);
|
||||
}
|
||||
}
|
@ -1,43 +0,0 @@
|
||||
package org.thoughtcrime.securesms.jobmanager.persistence;
|
||||
|
||||
import androidx.annotation.NonNull;
|
||||
|
||||
import java.util.Objects;
|
||||
|
||||
public final class DependencySpec {
|
||||
|
||||
private final String jobId;
|
||||
private final String dependsOnJobId;
|
||||
|
||||
public DependencySpec(@NonNull String jobId, @NonNull String dependsOnJobId) {
|
||||
this.jobId = jobId;
|
||||
this.dependsOnJobId = dependsOnJobId;
|
||||
}
|
||||
|
||||
public @NonNull String getJobId() {
|
||||
return jobId;
|
||||
}
|
||||
|
||||
public @NonNull String getDependsOnJobId() {
|
||||
return dependsOnJobId;
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean equals(Object o) {
|
||||
if (this == o) return true;
|
||||
if (o == null || getClass() != o.getClass()) return false;
|
||||
DependencySpec that = (DependencySpec) o;
|
||||
return Objects.equals(jobId, that.jobId) &&
|
||||
Objects.equals(dependsOnJobId, that.dependsOnJobId);
|
||||
}
|
||||
|
||||
@Override
|
||||
public int hashCode() {
|
||||
return Objects.hash(jobId, dependsOnJobId);
|
||||
}
|
||||
|
||||
@Override
|
||||
public @NonNull String toString() {
|
||||
return String.format("jobSpecId: %s | dependsOnJobSpecId: %s", jobId, dependsOnJobId);
|
||||
}
|
||||
}
|
@ -1,50 +0,0 @@
|
||||
package org.thoughtcrime.securesms.jobmanager.persistence;
|
||||
|
||||
import androidx.annotation.NonNull;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Objects;
|
||||
|
||||
public final class FullSpec {
|
||||
|
||||
private final JobSpec jobSpec;
|
||||
private final List<ConstraintSpec> constraintSpecs;
|
||||
private final List<DependencySpec> dependencySpecs;
|
||||
|
||||
public FullSpec(@NonNull JobSpec jobSpec,
|
||||
@NonNull List<ConstraintSpec> constraintSpecs,
|
||||
@NonNull List<DependencySpec> dependencySpecs)
|
||||
{
|
||||
this.jobSpec = jobSpec;
|
||||
this.constraintSpecs = constraintSpecs;
|
||||
this.dependencySpecs = dependencySpecs;
|
||||
}
|
||||
|
||||
public @NonNull JobSpec getJobSpec() {
|
||||
return jobSpec;
|
||||
}
|
||||
|
||||
public @NonNull List<ConstraintSpec> getConstraintSpecs() {
|
||||
return constraintSpecs;
|
||||
}
|
||||
|
||||
public @NonNull List<DependencySpec> getDependencySpecs() {
|
||||
return dependencySpecs;
|
||||
}
|
||||
|
||||
|
||||
@Override
|
||||
public boolean equals(Object o) {
|
||||
if (this == o) return true;
|
||||
if (o == null || getClass() != o.getClass()) return false;
|
||||
FullSpec fullSpec = (FullSpec) o;
|
||||
return Objects.equals(jobSpec, fullSpec.jobSpec) &&
|
||||
Objects.equals(constraintSpecs, fullSpec.constraintSpecs) &&
|
||||
Objects.equals(dependencySpecs, fullSpec.dependencySpecs);
|
||||
}
|
||||
|
||||
@Override
|
||||
public int hashCode() {
|
||||
return Objects.hash(jobSpec, constraintSpecs, dependencySpecs);
|
||||
}
|
||||
}
|
@ -1,129 +0,0 @@
|
||||
package org.thoughtcrime.securesms.jobmanager.persistence;
|
||||
|
||||
import android.annotation.SuppressLint;
|
||||
import androidx.annotation.NonNull;
|
||||
import androidx.annotation.Nullable;
|
||||
|
||||
import java.util.Objects;
|
||||
|
||||
public final class JobSpec {
|
||||
|
||||
private final String id;
|
||||
private final String factoryKey;
|
||||
private final String queueKey;
|
||||
private final long createTime;
|
||||
private final long nextRunAttemptTime;
|
||||
private final int runAttempt;
|
||||
private final int maxAttempts;
|
||||
private final long maxBackoff;
|
||||
private final long lifespan;
|
||||
private final int maxInstances;
|
||||
private final String serializedData;
|
||||
private final boolean isRunning;
|
||||
|
||||
public JobSpec(@NonNull String id,
|
||||
@NonNull String factoryKey,
|
||||
@Nullable String queueKey,
|
||||
long createTime,
|
||||
long nextRunAttemptTime,
|
||||
int runAttempt,
|
||||
int maxAttempts,
|
||||
long maxBackoff,
|
||||
long lifespan,
|
||||
int maxInstances,
|
||||
@NonNull String serializedData,
|
||||
boolean isRunning)
|
||||
{
|
||||
this.id = id;
|
||||
this.factoryKey = factoryKey;
|
||||
this.queueKey = queueKey;
|
||||
this.createTime = createTime;
|
||||
this.nextRunAttemptTime = nextRunAttemptTime;
|
||||
this.maxBackoff = maxBackoff;
|
||||
this.runAttempt = runAttempt;
|
||||
this.maxAttempts = maxAttempts;
|
||||
this.lifespan = lifespan;
|
||||
this.maxInstances = maxInstances;
|
||||
this.serializedData = serializedData;
|
||||
this.isRunning = isRunning;
|
||||
}
|
||||
|
||||
public @NonNull String getId() {
|
||||
return id;
|
||||
}
|
||||
|
||||
public @NonNull String getFactoryKey() {
|
||||
return factoryKey;
|
||||
}
|
||||
|
||||
public @Nullable String getQueueKey() {
|
||||
return queueKey;
|
||||
}
|
||||
|
||||
public long getCreateTime() {
|
||||
return createTime;
|
||||
}
|
||||
|
||||
public long getNextRunAttemptTime() {
|
||||
return nextRunAttemptTime;
|
||||
}
|
||||
|
||||
public int getRunAttempt() {
|
||||
return runAttempt;
|
||||
}
|
||||
|
||||
public int getMaxAttempts() {
|
||||
return maxAttempts;
|
||||
}
|
||||
|
||||
public long getMaxBackoff() {
|
||||
return maxBackoff;
|
||||
}
|
||||
|
||||
public int getMaxInstances() {
|
||||
return maxInstances;
|
||||
}
|
||||
|
||||
public long getLifespan() {
|
||||
return lifespan;
|
||||
}
|
||||
|
||||
public @NonNull String getSerializedData() {
|
||||
return serializedData;
|
||||
}
|
||||
|
||||
public boolean isRunning() {
|
||||
return isRunning;
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean equals(Object o) {
|
||||
if (this == o) return true;
|
||||
if (o == null || getClass() != o.getClass()) return false;
|
||||
JobSpec jobSpec = (JobSpec) o;
|
||||
return createTime == jobSpec.createTime &&
|
||||
nextRunAttemptTime == jobSpec.nextRunAttemptTime &&
|
||||
runAttempt == jobSpec.runAttempt &&
|
||||
maxAttempts == jobSpec.maxAttempts &&
|
||||
maxBackoff == jobSpec.maxBackoff &&
|
||||
lifespan == jobSpec.lifespan &&
|
||||
maxInstances == jobSpec.maxInstances &&
|
||||
isRunning == jobSpec.isRunning &&
|
||||
Objects.equals(id, jobSpec.id) &&
|
||||
Objects.equals(factoryKey, jobSpec.factoryKey) &&
|
||||
Objects.equals(queueKey, jobSpec.queueKey) &&
|
||||
Objects.equals(serializedData, jobSpec.serializedData);
|
||||
}
|
||||
|
||||
@Override
|
||||
public int hashCode() {
|
||||
return Objects.hash(id, factoryKey, queueKey, createTime, nextRunAttemptTime, runAttempt, maxAttempts, maxBackoff, lifespan, maxInstances, serializedData, isRunning);
|
||||
}
|
||||
|
||||
@SuppressLint("DefaultLocale")
|
||||
@Override
|
||||
public @NonNull String toString() {
|
||||
return String.format("id: %s | factoryKey: %s | queueKey: %s | createTime: %d | nextRunAttemptTime: %d | runAttempt: %d | maxAttempts: %d | maxBackoff: %d | maxInstances: %d | lifespan: %d | isRunning: %b | data: %s",
|
||||
id, factoryKey, queueKey, createTime, nextRunAttemptTime, runAttempt, maxAttempts, maxBackoff, maxInstances, lifespan, isRunning, serializedData);
|
||||
}
|
||||
}
|
@ -1,55 +0,0 @@
|
||||
package org.thoughtcrime.securesms.jobmanager.persistence;
|
||||
|
||||
import androidx.annotation.NonNull;
|
||||
import androidx.annotation.Nullable;
|
||||
import androidx.annotation.WorkerThread;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
public interface JobStorage {
|
||||
|
||||
@WorkerThread
|
||||
void init();
|
||||
|
||||
@WorkerThread
|
||||
void insertJobs(@NonNull List<FullSpec> fullSpecs);
|
||||
|
||||
@WorkerThread
|
||||
@Nullable JobSpec getJobSpec(@NonNull String id);
|
||||
|
||||
@WorkerThread
|
||||
@NonNull List<JobSpec> getAllJobSpecs();
|
||||
|
||||
@WorkerThread
|
||||
@NonNull List<JobSpec> getPendingJobsWithNoDependenciesInCreatedOrder(long currentTime);
|
||||
|
||||
@WorkerThread
|
||||
int getJobInstanceCount(@NonNull String factoryKey);
|
||||
|
||||
@WorkerThread
|
||||
void updateJobRunningState(@NonNull String id, boolean isRunning);
|
||||
|
||||
@WorkerThread
|
||||
void updateJobAfterRetry(@NonNull String id, boolean isRunning, int runAttempt, long nextRunAttemptTime);
|
||||
|
||||
@WorkerThread
|
||||
void updateAllJobsToBePending();
|
||||
|
||||
@WorkerThread
|
||||
void deleteJob(@NonNull String id);
|
||||
|
||||
@WorkerThread
|
||||
void deleteJobs(@NonNull List<String> ids);
|
||||
|
||||
@WorkerThread
|
||||
@NonNull List<ConstraintSpec> getConstraintSpecs(@NonNull String jobId);
|
||||
|
||||
@WorkerThread
|
||||
@NonNull List<ConstraintSpec> getAllConstraintSpecs();
|
||||
|
||||
@WorkerThread
|
||||
@NonNull List<DependencySpec> getDependencySpecsThatDependOnJob(@NonNull String jobSpecId);
|
||||
|
||||
@WorkerThread
|
||||
@NonNull List<DependencySpec> getAllDependencySpecs();
|
||||
}
|
@ -1,133 +0,0 @@
|
||||
package org.thoughtcrime.securesms.jobs;
|
||||
|
||||
import android.graphics.Bitmap;
|
||||
|
||||
import androidx.annotation.NonNull;
|
||||
|
||||
import org.session.libsession.messaging.utilities.Data;
|
||||
import org.session.libsession.utilities.DownloadUtilities;
|
||||
import org.session.libsession.utilities.GroupRecord;
|
||||
import org.session.libsignal.exceptions.InvalidMessageException;
|
||||
import org.session.libsignal.exceptions.NonSuccessfulResponseCodeException;
|
||||
import org.session.libsignal.messages.SignalServiceAttachmentPointer;
|
||||
import org.session.libsignal.streams.AttachmentCipherInputStream;
|
||||
import org.session.libsignal.utilities.Hex;
|
||||
import org.session.libsignal.utilities.Log;
|
||||
import org.session.libsignal.utilities.guava.Optional;
|
||||
import org.thoughtcrime.securesms.database.GroupDatabase;
|
||||
import org.thoughtcrime.securesms.dependencies.DatabaseComponent;
|
||||
import org.thoughtcrime.securesms.jobmanager.Job;
|
||||
import org.thoughtcrime.securesms.jobmanager.impl.NetworkConstraint;
|
||||
import org.thoughtcrime.securesms.mms.AttachmentStreamUriLoader.AttachmentModel;
|
||||
import org.thoughtcrime.securesms.util.BitmapDecodingException;
|
||||
import org.thoughtcrime.securesms.util.BitmapUtil;
|
||||
|
||||
import java.io.File;
|
||||
import java.io.FileInputStream;
|
||||
import java.io.IOException;
|
||||
import java.io.InputStream;
|
||||
|
||||
public class AvatarDownloadJob extends BaseJob {
|
||||
|
||||
public static final String KEY = "AvatarDownloadJob";
|
||||
|
||||
private static final String TAG = AvatarDownloadJob.class.getSimpleName();
|
||||
|
||||
private static final int MAX_AVATAR_SIZE = 20 * 1024 * 1024;
|
||||
|
||||
private static final String KEY_GROUP_ID = "group_id";
|
||||
|
||||
private String groupId;
|
||||
|
||||
public AvatarDownloadJob(@NonNull String groupId) {
|
||||
this(new Job.Parameters.Builder()
|
||||
.addConstraint(NetworkConstraint.KEY)
|
||||
.setMaxAttempts(10)
|
||||
.build(),
|
||||
groupId);
|
||||
}
|
||||
|
||||
private AvatarDownloadJob(@NonNull Job.Parameters parameters, @NonNull String groupId) {
|
||||
super(parameters);
|
||||
this.groupId = groupId;
|
||||
}
|
||||
|
||||
@Override
|
||||
public @NonNull Data serialize() {
|
||||
return new Data.Builder().putString(KEY_GROUP_ID, groupId).build();
|
||||
}
|
||||
|
||||
@Override
|
||||
public @NonNull String getFactoryKey() {
|
||||
return KEY;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onRun() throws IOException {
|
||||
GroupDatabase database = DatabaseComponent.get(context).groupDatabase();
|
||||
Optional<GroupRecord> record = database.getGroup(groupId);
|
||||
File attachment = null;
|
||||
|
||||
try {
|
||||
if (record.isPresent()) {
|
||||
long avatarId = record.get().getAvatarId();
|
||||
String contentType = record.get().getAvatarContentType();
|
||||
byte[] key = record.get().getAvatarKey();
|
||||
String relay = record.get().getRelay();
|
||||
Optional<byte[]> digest = Optional.fromNullable(record.get().getAvatarDigest());
|
||||
Optional<String> fileName = Optional.absent();
|
||||
String url = record.get().getUrl();
|
||||
|
||||
if (avatarId == -1 || key == null || url.isEmpty()) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (digest.isPresent()) {
|
||||
Log.i(TAG, "Downloading group avatar with digest: " + Hex.toString(digest.get()));
|
||||
}
|
||||
|
||||
attachment = File.createTempFile("avatar", "tmp", context.getCacheDir());
|
||||
attachment.deleteOnExit();
|
||||
|
||||
SignalServiceAttachmentPointer pointer = new SignalServiceAttachmentPointer(avatarId, contentType, key, Optional.of(0), Optional.absent(), 0, 0, digest, fileName, false, Optional.absent(), url);
|
||||
|
||||
if (pointer.getUrl().isEmpty()) throw new InvalidMessageException("Missing attachment URL.");
|
||||
DownloadUtilities.downloadFile(attachment, pointer.getUrl());
|
||||
|
||||
// Assume we're retrieving an attachment for an open group server if the digest is not set
|
||||
InputStream inputStream;
|
||||
if (!pointer.getDigest().isPresent()) {
|
||||
inputStream = new FileInputStream(attachment);
|
||||
} else {
|
||||
inputStream = AttachmentCipherInputStream.createForAttachment(attachment, pointer.getSize().or(0), pointer.getKey(), pointer.getDigest().get());
|
||||
}
|
||||
|
||||
Bitmap avatar = BitmapUtil.createScaledBitmap(context, new AttachmentModel(attachment, key, 0, digest), 500, 500);
|
||||
|
||||
database.updateProfilePicture(groupId, avatar);
|
||||
inputStream.close();
|
||||
}
|
||||
} catch (BitmapDecodingException | NonSuccessfulResponseCodeException | InvalidMessageException e) {
|
||||
Log.w(TAG, e);
|
||||
} finally {
|
||||
if (attachment != null)
|
||||
attachment.delete();
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onCanceled() {}
|
||||
|
||||
@Override
|
||||
public boolean onShouldRetry(@NonNull Exception exception) {
|
||||
if (exception instanceof IOException) return true;
|
||||
return false;
|
||||
}
|
||||
|
||||
public static final class Factory implements Job.Factory<AvatarDownloadJob> {
|
||||
@Override
|
||||
public @NonNull AvatarDownloadJob create(@NonNull Parameters parameters, @NonNull Data data) {
|
||||
return new AvatarDownloadJob(parameters, data.getString(KEY_GROUP_ID));
|
||||
}
|
||||
}
|
||||
}
|
@ -1,41 +0,0 @@
|
||||
package org.thoughtcrime.securesms.jobs;
|
||||
|
||||
import androidx.annotation.NonNull;
|
||||
|
||||
import org.thoughtcrime.securesms.jobmanager.Job;
|
||||
import org.thoughtcrime.securesms.jobmanager.JobLogger;
|
||||
import org.session.libsignal.utilities.Log;
|
||||
|
||||
/**
|
||||
* @deprecated
|
||||
* use <a href="https://developer.android.com/reference/androidx/work/WorkManager">WorkManager</a>
|
||||
* API instead.
|
||||
*/
|
||||
public abstract class BaseJob extends Job {
|
||||
|
||||
private static final String TAG = BaseJob.class.getSimpleName();
|
||||
|
||||
public BaseJob(@NonNull Parameters parameters) {
|
||||
super(parameters);
|
||||
}
|
||||
|
||||
@Override
|
||||
public @NonNull Result run() {
|
||||
try {
|
||||
onRun();
|
||||
return Result.SUCCESS;
|
||||
} catch (Exception e) {
|
||||
if (onShouldRetry(e)) {
|
||||
Log.i(TAG, JobLogger.format(this, "Encountered a retryable exception."), e);
|
||||
return Result.RETRY;
|
||||
} else {
|
||||
Log.w(TAG, JobLogger.format(this, "Encountered a failing exception."), e);
|
||||
return Result.FAILURE;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
protected abstract void onRun() throws Exception;
|
||||
|
||||
protected abstract boolean onShouldRetry(@NonNull Exception e);
|
||||
}
|
@ -1,261 +0,0 @@
|
||||
package org.thoughtcrime.securesms.jobs;
|
||||
|
||||
import androidx.annotation.NonNull;
|
||||
import androidx.annotation.Nullable;
|
||||
|
||||
import com.annimon.stream.Stream;
|
||||
|
||||
import org.thoughtcrime.securesms.database.JobDatabase;
|
||||
import org.thoughtcrime.securesms.jobmanager.persistence.ConstraintSpec;
|
||||
import org.thoughtcrime.securesms.jobmanager.persistence.DependencySpec;
|
||||
import org.thoughtcrime.securesms.jobmanager.persistence.FullSpec;
|
||||
import org.thoughtcrime.securesms.jobmanager.persistence.JobSpec;
|
||||
import org.thoughtcrime.securesms.jobmanager.persistence.JobStorage;
|
||||
|
||||
import org.session.libsession.utilities.Util;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.Collections;
|
||||
import java.util.HashMap;
|
||||
import java.util.HashSet;
|
||||
import java.util.Iterator;
|
||||
import java.util.LinkedList;
|
||||
import java.util.List;
|
||||
import java.util.ListIterator;
|
||||
import java.util.Map;
|
||||
import java.util.Set;
|
||||
|
||||
public class FastJobStorage implements JobStorage {
|
||||
|
||||
private final JobDatabase jobDatabase;
|
||||
|
||||
private final List<JobSpec> jobs;
|
||||
private final Map<String, List<ConstraintSpec>> constraintsByJobId;
|
||||
private final Map<String, List<DependencySpec>> dependenciesByJobId;
|
||||
|
||||
public FastJobStorage(@NonNull JobDatabase jobDatabase) {
|
||||
this.jobDatabase = jobDatabase;
|
||||
this.jobs = new ArrayList<>();
|
||||
this.constraintsByJobId = new HashMap<>();
|
||||
this.dependenciesByJobId = new HashMap<>();
|
||||
}
|
||||
|
||||
@Override
|
||||
public synchronized void init() {
|
||||
List<JobSpec> jobSpecs = jobDatabase.getAllJobSpecs();
|
||||
List<ConstraintSpec> constraintSpecs = jobDatabase.getAllConstraintSpecs();
|
||||
List<DependencySpec> dependencySpecs = jobDatabase.getAllDependencySpecs();
|
||||
|
||||
jobs.addAll(jobSpecs);
|
||||
|
||||
for (ConstraintSpec constraintSpec: constraintSpecs) {
|
||||
List<ConstraintSpec> jobConstraints = Util.getOrDefault(constraintsByJobId, constraintSpec.getJobSpecId(), new LinkedList<>());
|
||||
jobConstraints.add(constraintSpec);
|
||||
constraintsByJobId.put(constraintSpec.getJobSpecId(), jobConstraints);
|
||||
}
|
||||
|
||||
for (DependencySpec dependencySpec : dependencySpecs) {
|
||||
List<DependencySpec> jobDependencies = Util.getOrDefault(dependenciesByJobId, dependencySpec.getJobId(), new LinkedList<>());
|
||||
jobDependencies.add(dependencySpec);
|
||||
dependenciesByJobId.put(dependencySpec.getJobId(), jobDependencies);
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public synchronized void insertJobs(@NonNull List<FullSpec> fullSpecs) {
|
||||
jobDatabase.insertJobs(fullSpecs);
|
||||
|
||||
for (FullSpec fullSpec : fullSpecs) {
|
||||
jobs.add(fullSpec.getJobSpec());
|
||||
constraintsByJobId.put(fullSpec.getJobSpec().getId(), fullSpec.getConstraintSpecs());
|
||||
dependenciesByJobId.put(fullSpec.getJobSpec().getId(), fullSpec.getDependencySpecs());
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public synchronized @Nullable JobSpec getJobSpec(@NonNull String id) {
|
||||
for (JobSpec jobSpec : jobs) {
|
||||
if (jobSpec.getId().equals(id)) {
|
||||
return jobSpec;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
@Override
|
||||
public synchronized @NonNull List<JobSpec> getAllJobSpecs() {
|
||||
return new ArrayList<>(jobs);
|
||||
}
|
||||
|
||||
@Override
|
||||
public synchronized @NonNull List<JobSpec> getPendingJobsWithNoDependenciesInCreatedOrder(long currentTime) {
|
||||
return Stream.of(jobs)
|
||||
.filter(j -> JobManagerFactories.hasFactoryForKey(j.getFactoryKey()))
|
||||
.filterNot(JobSpec::isRunning)
|
||||
.filter(this::firstInQueue)
|
||||
.filter(j -> !dependenciesByJobId.containsKey(j.getId()) || dependenciesByJobId.get(j.getId()).isEmpty())
|
||||
.filter(j -> j.getNextRunAttemptTime() <= currentTime)
|
||||
.sorted((j1, j2) -> Long.compare(j1.getCreateTime(), j2.getCreateTime()))
|
||||
.toList();
|
||||
}
|
||||
|
||||
private boolean firstInQueue(@NonNull JobSpec job) {
|
||||
if (job.getQueueKey() == null) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return Stream.of(jobs)
|
||||
.filter(j -> Util.equals(j.getQueueKey(), job.getQueueKey()))
|
||||
.sorted((j1, j2) -> Long.compare(j1.getCreateTime(), j2.getCreateTime()))
|
||||
.toList()
|
||||
.get(0)
|
||||
.equals(job);
|
||||
}
|
||||
|
||||
@Override
|
||||
public synchronized int getJobInstanceCount(@NonNull String factoryKey) {
|
||||
return (int) Stream.of(jobs)
|
||||
.filter(j -> j.getFactoryKey().equals(factoryKey))
|
||||
.count();
|
||||
}
|
||||
|
||||
@Override
|
||||
public synchronized void updateJobRunningState(@NonNull String id, boolean isRunning) {
|
||||
jobDatabase.updateJobRunningState(id, isRunning);
|
||||
|
||||
ListIterator<JobSpec> iter = jobs.listIterator();
|
||||
|
||||
while (iter.hasNext()) {
|
||||
JobSpec existing = iter.next();
|
||||
if (existing.getId().equals(id)) {
|
||||
JobSpec updated = new JobSpec(existing.getId(),
|
||||
existing.getFactoryKey(),
|
||||
existing.getQueueKey(),
|
||||
existing.getCreateTime(),
|
||||
existing.getNextRunAttemptTime(),
|
||||
existing.getRunAttempt(),
|
||||
existing.getMaxAttempts(),
|
||||
existing.getMaxBackoff(),
|
||||
existing.getLifespan(),
|
||||
existing.getMaxInstances(),
|
||||
existing.getSerializedData(),
|
||||
isRunning);
|
||||
iter.set(updated);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public synchronized void updateJobAfterRetry(@NonNull String id, boolean isRunning, int runAttempt, long nextRunAttemptTime) {
|
||||
jobDatabase.updateJobAfterRetry(id, isRunning, runAttempt, nextRunAttemptTime);
|
||||
|
||||
ListIterator<JobSpec> iter = jobs.listIterator();
|
||||
|
||||
while (iter.hasNext()) {
|
||||
JobSpec existing = iter.next();
|
||||
if (existing.getId().equals(id)) {
|
||||
JobSpec updated = new JobSpec(existing.getId(),
|
||||
existing.getFactoryKey(),
|
||||
existing.getQueueKey(),
|
||||
existing.getCreateTime(),
|
||||
nextRunAttemptTime,
|
||||
runAttempt,
|
||||
existing.getMaxAttempts(),
|
||||
existing.getMaxBackoff(),
|
||||
existing.getLifespan(),
|
||||
existing.getMaxInstances(),
|
||||
existing.getSerializedData(),
|
||||
isRunning);
|
||||
iter.set(updated);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public synchronized void updateAllJobsToBePending() {
|
||||
jobDatabase.updateAllJobsToBePending();
|
||||
|
||||
ListIterator<JobSpec> iter = jobs.listIterator();
|
||||
|
||||
while (iter.hasNext()) {
|
||||
JobSpec existing = iter.next();
|
||||
JobSpec updated = new JobSpec(existing.getId(),
|
||||
existing.getFactoryKey(),
|
||||
existing.getQueueKey(),
|
||||
existing.getCreateTime(),
|
||||
existing.getNextRunAttemptTime(),
|
||||
existing.getRunAttempt(),
|
||||
existing.getMaxAttempts(),
|
||||
existing.getMaxBackoff(),
|
||||
existing.getLifespan(),
|
||||
existing.getMaxInstances(),
|
||||
existing.getSerializedData(),
|
||||
false);
|
||||
iter.set(updated);
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public synchronized void deleteJob(@NonNull String jobId) {
|
||||
deleteJobs(Collections.singletonList(jobId));
|
||||
}
|
||||
|
||||
@Override
|
||||
public synchronized void deleteJobs(@NonNull List<String> jobIds) {
|
||||
jobDatabase.deleteJobs(jobIds);
|
||||
|
||||
Set<String> deleteIds = new HashSet<>(jobIds);
|
||||
|
||||
Iterator<JobSpec> jobIter = jobs.iterator();
|
||||
while (jobIter.hasNext()) {
|
||||
if (deleteIds.contains(jobIter.next().getId())) {
|
||||
jobIter.remove();
|
||||
}
|
||||
}
|
||||
|
||||
for (String jobId : jobIds) {
|
||||
constraintsByJobId.remove(jobId);
|
||||
dependenciesByJobId.remove(jobId);
|
||||
|
||||
for (Map.Entry<String, List<DependencySpec>> entry : dependenciesByJobId.entrySet()) {
|
||||
Iterator<DependencySpec> depedencyIter = entry.getValue().iterator();
|
||||
|
||||
while (depedencyIter.hasNext()) {
|
||||
if (depedencyIter.next().getDependsOnJobId().equals(jobId)) {
|
||||
depedencyIter.remove();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public synchronized @NonNull List<ConstraintSpec> getConstraintSpecs(@NonNull String jobId) {
|
||||
return Util.getOrDefault(constraintsByJobId, jobId, new LinkedList<>());
|
||||
}
|
||||
|
||||
@Override
|
||||
public synchronized @NonNull List<ConstraintSpec> getAllConstraintSpecs() {
|
||||
return Stream.of(constraintsByJobId)
|
||||
.map(Map.Entry::getValue)
|
||||
.flatMap(Stream::of)
|
||||
.toList();
|
||||
}
|
||||
|
||||
@Override
|
||||
public synchronized @NonNull List<DependencySpec> getDependencySpecsThatDependOnJob(@NonNull String jobSpecId) {
|
||||
return Stream.of(dependenciesByJobId.entrySet())
|
||||
.map(Map.Entry::getValue)
|
||||
.flatMap(Stream::of)
|
||||
.filter(j -> j.getDependsOnJobId().equals(jobSpecId))
|
||||
.toList();
|
||||
}
|
||||
|
||||
@Override
|
||||
public @NonNull List<DependencySpec> getAllDependencySpecs() {
|
||||
return Stream.of(dependenciesByJobId)
|
||||
.map(Map.Entry::getValue)
|
||||
.flatMap(Stream::of)
|
||||
.toList();
|
||||
}
|
||||
}
|
@ -1,58 +0,0 @@
|
||||
package org.thoughtcrime.securesms.jobs;
|
||||
|
||||
import android.app.Application;
|
||||
|
||||
import androidx.annotation.NonNull;
|
||||
|
||||
import org.thoughtcrime.securesms.jobmanager.Constraint;
|
||||
import org.thoughtcrime.securesms.jobmanager.ConstraintObserver;
|
||||
import org.thoughtcrime.securesms.jobmanager.Job;
|
||||
import org.thoughtcrime.securesms.jobmanager.impl.CellServiceConstraint;
|
||||
import org.thoughtcrime.securesms.jobmanager.impl.CellServiceConstraintObserver;
|
||||
import org.thoughtcrime.securesms.jobmanager.impl.NetworkConstraint;
|
||||
import org.thoughtcrime.securesms.jobmanager.impl.NetworkConstraintObserver;
|
||||
import org.thoughtcrime.securesms.jobmanager.impl.NetworkOrCellServiceConstraint;
|
||||
import org.thoughtcrime.securesms.jobmanager.impl.SqlCipherMigrationConstraint;
|
||||
import org.thoughtcrime.securesms.jobmanager.impl.SqlCipherMigrationConstraintObserver;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.Arrays;
|
||||
import java.util.Collection;
|
||||
import java.util.HashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
public final class JobManagerFactories {
|
||||
|
||||
private static Collection<String> factoryKeys = new ArrayList<>();
|
||||
|
||||
public static Map<String, Job.Factory> getJobFactories(@NonNull Application application) {
|
||||
HashMap<String, Job.Factory> factoryHashMap = new HashMap<String, Job.Factory>() {{
|
||||
put(AvatarDownloadJob.KEY, new AvatarDownloadJob.Factory());
|
||||
put(LocalBackupJob.KEY, new LocalBackupJob.Factory());
|
||||
put(RetrieveProfileAvatarJob.KEY, new RetrieveProfileAvatarJob.Factory(application));
|
||||
put(PrepareAttachmentAudioExtrasJob.KEY, new PrepareAttachmentAudioExtrasJob.Factory());
|
||||
}};
|
||||
factoryKeys.addAll(factoryHashMap.keySet());
|
||||
return factoryHashMap;
|
||||
}
|
||||
|
||||
public static Map<String, Constraint.Factory> getConstraintFactories(@NonNull Application application) {
|
||||
return new HashMap<String, Constraint.Factory>() {{
|
||||
put(CellServiceConstraint.KEY, new CellServiceConstraint.Factory(application));
|
||||
put(NetworkConstraint.KEY, new NetworkConstraint.Factory(application));
|
||||
put(NetworkOrCellServiceConstraint.KEY, new NetworkOrCellServiceConstraint.Factory(application));
|
||||
put(SqlCipherMigrationConstraint.KEY, new SqlCipherMigrationConstraint.Factory(application));
|
||||
}};
|
||||
}
|
||||
|
||||
public static List<ConstraintObserver> getConstraintObservers(@NonNull Application application) {
|
||||
return Arrays.asList(new CellServiceConstraintObserver(application),
|
||||
new NetworkConstraintObserver(application),
|
||||
new SqlCipherMigrationConstraintObserver());
|
||||
}
|
||||
|
||||
public static boolean hasFactoryForKey(String factoryKey) {
|
||||
return factoryKeys.contains(factoryKey);
|
||||
}
|
||||
}
|
@ -1,83 +0,0 @@
|
||||
package org.thoughtcrime.securesms.jobs;
|
||||
|
||||
import androidx.annotation.NonNull;
|
||||
|
||||
import org.session.libsession.messaging.utilities.Data;
|
||||
import org.session.libsignal.utilities.NoExternalStorageException;
|
||||
import org.thoughtcrime.securesms.jobmanager.Job;
|
||||
import org.session.libsignal.utilities.Log;
|
||||
import org.thoughtcrime.securesms.database.BackupFileRecord;
|
||||
import org.thoughtcrime.securesms.notifications.NotificationChannels;
|
||||
import org.thoughtcrime.securesms.service.GenericForegroundService;
|
||||
import org.thoughtcrime.securesms.util.BackupUtil;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.util.Collections;
|
||||
|
||||
import network.loki.messenger.R;
|
||||
|
||||
public class LocalBackupJob extends BaseJob {
|
||||
|
||||
public static final String KEY = "LocalBackupJob";
|
||||
|
||||
private static final String TAG = LocalBackupJob.class.getSimpleName();
|
||||
|
||||
public LocalBackupJob() {
|
||||
this(new Job.Parameters.Builder()
|
||||
.setQueue("__LOCAL_BACKUP__")
|
||||
.setMaxInstances(1)
|
||||
.setMaxAttempts(3)
|
||||
.build());
|
||||
}
|
||||
|
||||
private LocalBackupJob(@NonNull Job.Parameters parameters) {
|
||||
super(parameters);
|
||||
}
|
||||
|
||||
@Override
|
||||
public @NonNull
|
||||
Data serialize() {
|
||||
return Data.EMPTY;
|
||||
}
|
||||
|
||||
@Override
|
||||
public @NonNull String getFactoryKey() {
|
||||
return KEY;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onRun() throws NoExternalStorageException, IOException {
|
||||
Log.i(TAG, "Executing backup job...");
|
||||
|
||||
GenericForegroundService.startForegroundTask(context,
|
||||
context.getString(R.string.LocalBackupJob_creating_backup),
|
||||
NotificationChannels.BACKUPS,
|
||||
R.drawable.ic_launcher_foreground);
|
||||
|
||||
// TODO: Maybe create a new backup icon like ic_signal_backup?
|
||||
|
||||
try {
|
||||
BackupFileRecord record = BackupUtil.createBackupFile(context);
|
||||
BackupUtil.deleteAllBackupFiles(context, Collections.singletonList(record));
|
||||
|
||||
} finally {
|
||||
GenericForegroundService.stopForegroundTask(context);
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean onShouldRetry(@NonNull Exception e) {
|
||||
return false;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onCanceled() {
|
||||
}
|
||||
|
||||
public static class Factory implements Job.Factory<LocalBackupJob> {
|
||||
@Override
|
||||
public @NonNull LocalBackupJob create(@NonNull Parameters parameters, @NonNull Data data) {
|
||||
return new LocalBackupJob(parameters);
|
||||
}
|
||||
}
|
||||
}
|
@ -1,133 +0,0 @@
|
||||
package org.thoughtcrime.securesms.jobs
|
||||
|
||||
import android.os.Build
|
||||
import org.greenrobot.eventbus.EventBus
|
||||
import org.session.libsession.messaging.sending_receiving.attachments.Attachment
|
||||
import org.session.libsession.messaging.sending_receiving.attachments.AttachmentId
|
||||
import org.session.libsession.messaging.sending_receiving.attachments.DatabaseAttachmentAudioExtras
|
||||
import org.session.libsession.messaging.utilities.Data
|
||||
import org.session.libsession.utilities.DecodedAudio
|
||||
import org.session.libsession.utilities.InputStreamMediaDataSource
|
||||
import org.session.libsignal.utilities.Log
|
||||
import org.thoughtcrime.securesms.dependencies.DatabaseComponent
|
||||
import org.thoughtcrime.securesms.jobmanager.Job
|
||||
import org.thoughtcrime.securesms.jobs.PrepareAttachmentAudioExtrasJob.AudioExtrasUpdatedEvent
|
||||
import org.thoughtcrime.securesms.mms.PartAuthority
|
||||
import java.util.*
|
||||
import java.util.concurrent.TimeUnit
|
||||
|
||||
/**
|
||||
* Decodes the audio content of the related attachment entry
|
||||
* and caches the result with [DatabaseAttachmentAudioExtras] data.
|
||||
*
|
||||
* It only process attachments with "audio" mime types.
|
||||
*
|
||||
* Due to [DecodedAudio] implementation limitations, it only works for API 23+.
|
||||
* For any lower targets fake data will be generated.
|
||||
*
|
||||
* You can subscribe to [AudioExtrasUpdatedEvent] to be notified about the successful result.
|
||||
*/
|
||||
//TODO AC: Rewrite to WorkManager API when
|
||||
// https://github.com/loki-project/session-android/pull/354 is merged.
|
||||
class PrepareAttachmentAudioExtrasJob : BaseJob {
|
||||
|
||||
companion object {
|
||||
private const val TAG = "AttachAudioExtrasJob"
|
||||
|
||||
const val KEY = "PrepareAttachmentAudioExtrasJob"
|
||||
const val DATA_ATTACH_ID = "attachment_id"
|
||||
|
||||
const val VISUAL_RMS_FRAMES = 32 // The amount of values to be computed for the visualization.
|
||||
}
|
||||
|
||||
private val attachmentId: AttachmentId
|
||||
|
||||
constructor(attachmentId: AttachmentId) : this(Parameters.Builder()
|
||||
.setQueue(KEY)
|
||||
.setLifespan(TimeUnit.DAYS.toMillis(1))
|
||||
.build(),
|
||||
attachmentId)
|
||||
|
||||
private constructor(parameters: Parameters, attachmentId: AttachmentId) : super(parameters) {
|
||||
this.attachmentId = attachmentId
|
||||
}
|
||||
|
||||
override fun serialize(): Data {
|
||||
return Data.Builder().putParcelable(DATA_ATTACH_ID, attachmentId).build();
|
||||
}
|
||||
|
||||
override fun getFactoryKey(): String { return KEY
|
||||
}
|
||||
|
||||
override fun onShouldRetry(e: Exception): Boolean {
|
||||
return false
|
||||
}
|
||||
|
||||
override fun onCanceled() { }
|
||||
|
||||
override fun onRun() {
|
||||
Log.v(TAG, "Processing attachment: $attachmentId")
|
||||
|
||||
val attachDb = DatabaseComponent.get(context).attachmentDatabase()
|
||||
val attachment = attachDb.getAttachment(attachmentId)
|
||||
|
||||
if (attachment == null) {
|
||||
throw IllegalStateException("Cannot find attachment with the ID $attachmentId")
|
||||
}
|
||||
if (!attachment.contentType.startsWith("audio/")) {
|
||||
throw IllegalStateException("Attachment $attachmentId is not of audio type.")
|
||||
}
|
||||
|
||||
// Check if the audio extras already exist.
|
||||
if (attachDb.getAttachmentAudioExtras(attachmentId) != null) return
|
||||
|
||||
fun extractAttachmentRandomSeed(attachment: Attachment): Int {
|
||||
return when {
|
||||
attachment.digest != null -> attachment.digest!!.sum()
|
||||
attachment.fileName != null -> attachment.fileName.hashCode()
|
||||
else -> attachment.hashCode()
|
||||
}
|
||||
}
|
||||
|
||||
fun generateFakeRms(seed: Int, frames: Int = VISUAL_RMS_FRAMES): ByteArray {
|
||||
return ByteArray(frames).apply { Random(seed.toLong()).nextBytes(this) }
|
||||
}
|
||||
|
||||
var rmsValues: ByteArray
|
||||
var totalDurationMs: Long = DatabaseAttachmentAudioExtras.DURATION_UNDEFINED
|
||||
|
||||
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.M) {
|
||||
// Due to API version incompatibility, we just display some random waveform for older API.
|
||||
rmsValues = generateFakeRms(extractAttachmentRandomSeed(attachment))
|
||||
} else {
|
||||
try {
|
||||
@Suppress("BlockingMethodInNonBlockingContext")
|
||||
val decodedAudio = PartAuthority.getAttachmentStream(context, attachment.dataUri!!).use {
|
||||
DecodedAudio.create(InputStreamMediaDataSource(it))
|
||||
}
|
||||
rmsValues = decodedAudio.calculateRms(VISUAL_RMS_FRAMES)
|
||||
totalDurationMs = (decodedAudio.totalDuration / 1000.0).toLong()
|
||||
} catch (e: Exception) {
|
||||
Log.w(TAG, "Failed to decode sample values for the audio attachment \"${attachment.fileName}\".", e)
|
||||
rmsValues = generateFakeRms(extractAttachmentRandomSeed(attachment))
|
||||
}
|
||||
}
|
||||
|
||||
attachDb.setAttachmentAudioExtras(DatabaseAttachmentAudioExtras(
|
||||
attachmentId,
|
||||
rmsValues,
|
||||
totalDurationMs
|
||||
))
|
||||
|
||||
EventBus.getDefault().post(AudioExtrasUpdatedEvent(attachmentId))
|
||||
}
|
||||
|
||||
class Factory : Job.Factory<PrepareAttachmentAudioExtrasJob> {
|
||||
override fun create(parameters: Parameters, data: Data): PrepareAttachmentAudioExtrasJob {
|
||||
return PrepareAttachmentAudioExtrasJob(parameters, data.getParcelable(DATA_ATTACH_ID, AttachmentId.CREATOR))
|
||||
}
|
||||
}
|
||||
|
||||
/** Gets dispatched once the audio extras have been updated. */
|
||||
data class AudioExtrasUpdatedEvent(val attachmentId: AttachmentId)
|
||||
}
|
@ -1,144 +0,0 @@
|
||||
package org.thoughtcrime.securesms.jobs;
|
||||
|
||||
import android.app.Application;
|
||||
import android.text.TextUtils;
|
||||
|
||||
import androidx.annotation.NonNull;
|
||||
|
||||
import org.session.libsession.avatars.AvatarHelper;
|
||||
import org.session.libsession.messaging.utilities.Data;
|
||||
import org.session.libsession.utilities.Address;
|
||||
import org.session.libsession.utilities.DownloadUtilities;
|
||||
import org.session.libsession.utilities.TextSecurePreferences;
|
||||
import org.session.libsession.utilities.Util;
|
||||
import org.session.libsession.utilities.recipients.Recipient;
|
||||
import org.session.libsignal.exceptions.PushNetworkException;
|
||||
import org.session.libsignal.streams.ProfileCipherInputStream;
|
||||
import org.session.libsignal.utilities.Log;
|
||||
import org.thoughtcrime.securesms.database.RecipientDatabase;
|
||||
import org.thoughtcrime.securesms.dependencies.DatabaseComponent;
|
||||
import org.thoughtcrime.securesms.jobmanager.Job;
|
||||
import org.thoughtcrime.securesms.jobmanager.impl.NetworkConstraint;
|
||||
|
||||
import java.io.File;
|
||||
import java.io.FileInputStream;
|
||||
import java.io.FileOutputStream;
|
||||
import java.io.IOException;
|
||||
import java.io.InputStream;
|
||||
import java.security.SecureRandom;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
|
||||
public class RetrieveProfileAvatarJob extends BaseJob {
|
||||
|
||||
public static final String KEY = "RetrieveProfileAvatarJob";
|
||||
|
||||
private static final String TAG = RetrieveProfileAvatarJob.class.getSimpleName();
|
||||
|
||||
private static final int MAX_PROFILE_SIZE_BYTES = 10 * 1024 * 1024;
|
||||
|
||||
private static final String KEY_PROFILE_AVATAR = "profile_avatar";
|
||||
private static final String KEY_ADDRESS = "address";
|
||||
|
||||
|
||||
private String profileAvatar;
|
||||
private Recipient recipient;
|
||||
|
||||
public RetrieveProfileAvatarJob(Recipient recipient, String profileAvatar) {
|
||||
this(new Job.Parameters.Builder()
|
||||
.setQueue("RetrieveProfileAvatarJob" + recipient.getAddress().serialize())
|
||||
.addConstraint(NetworkConstraint.KEY)
|
||||
.setLifespan(TimeUnit.HOURS.toMillis(1))
|
||||
.setMaxAttempts(2)
|
||||
.setMaxInstances(1)
|
||||
.build(),
|
||||
recipient,
|
||||
profileAvatar);
|
||||
}
|
||||
|
||||
private RetrieveProfileAvatarJob(@NonNull Job.Parameters parameters, @NonNull Recipient recipient, String profileAvatar) {
|
||||
super(parameters);
|
||||
this.recipient = recipient;
|
||||
this.profileAvatar = profileAvatar;
|
||||
}
|
||||
|
||||
@Override
|
||||
public @NonNull
|
||||
Data serialize() {
|
||||
return new Data.Builder()
|
||||
.putString(KEY_PROFILE_AVATAR, profileAvatar)
|
||||
.putString(KEY_ADDRESS, recipient.getAddress().serialize())
|
||||
.build();
|
||||
}
|
||||
|
||||
@Override
|
||||
public @NonNull String getFactoryKey() {
|
||||
return KEY;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onRun() throws IOException {
|
||||
RecipientDatabase database = DatabaseComponent.get(context).recipientDatabase();
|
||||
byte[] profileKey = recipient.resolve().getProfileKey();
|
||||
|
||||
if (profileKey == null || (profileKey.length != 32 && profileKey.length != 16)) {
|
||||
Log.w(TAG, "Recipient profile key is gone!");
|
||||
return;
|
||||
}
|
||||
|
||||
if (AvatarHelper.avatarFileExists(context, recipient.resolve().getAddress()) && Util.equals(profileAvatar, recipient.resolve().getProfileAvatar())) {
|
||||
Log.w(TAG, "Already retrieved profile avatar: " + profileAvatar);
|
||||
return;
|
||||
}
|
||||
|
||||
if (TextUtils.isEmpty(profileAvatar)) {
|
||||
Log.w(TAG, "Removing profile avatar for: " + recipient.getAddress().serialize());
|
||||
AvatarHelper.delete(context, recipient.getAddress());
|
||||
database.setProfileAvatar(recipient, profileAvatar);
|
||||
return;
|
||||
}
|
||||
|
||||
File downloadDestination = File.createTempFile("avatar", ".jpg", context.getCacheDir());
|
||||
|
||||
try {
|
||||
DownloadUtilities.downloadFile(downloadDestination, profileAvatar);
|
||||
InputStream avatarStream = new ProfileCipherInputStream(new FileInputStream(downloadDestination), profileKey);
|
||||
File decryptDestination = File.createTempFile("avatar", ".jpg", context.getCacheDir());
|
||||
|
||||
Util.copy(avatarStream, new FileOutputStream(decryptDestination));
|
||||
decryptDestination.renameTo(AvatarHelper.getAvatarFile(context, recipient.getAddress()));
|
||||
} finally {
|
||||
if (downloadDestination != null) downloadDestination.delete();
|
||||
}
|
||||
|
||||
if (recipient.isLocalNumber()) {
|
||||
TextSecurePreferences.setProfileAvatarId(context, new SecureRandom().nextInt());
|
||||
}
|
||||
database.setProfileAvatar(recipient, profileAvatar);
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean onShouldRetry(@NonNull Exception e) {
|
||||
if (e instanceof PushNetworkException) return true;
|
||||
return false;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onCanceled() {
|
||||
}
|
||||
|
||||
public static final class Factory implements Job.Factory<RetrieveProfileAvatarJob> {
|
||||
|
||||
private final Application application;
|
||||
|
||||
public Factory(Application application) {
|
||||
this.application = application;
|
||||
}
|
||||
|
||||
@Override
|
||||
public @NonNull RetrieveProfileAvatarJob create(@NonNull Parameters parameters, @NonNull Data data) {
|
||||
return new RetrieveProfileAvatarJob(parameters,
|
||||
Recipient.from(application, Address.fromSerialized(data.getString(KEY_ADDRESS)), true),
|
||||
data.getString(KEY_PROFILE_AVATAR));
|
||||
}
|
||||
}
|
||||
}
|
@ -1,5 +1,7 @@
|
||||
package org.thoughtcrime.securesms.logging;
|
||||
|
||||
import static org.session.libsignal.crypto.CipherUtil.CIPHER_LOCK;
|
||||
|
||||
import androidx.annotation.NonNull;
|
||||
|
||||
import org.session.libsession.utilities.Conversions;
|
||||
@ -66,15 +68,17 @@ class LogFile {
|
||||
|
||||
byte[] plaintext = entry.getBytes();
|
||||
try {
|
||||
cipher.init(Cipher.ENCRYPT_MODE, new SecretKeySpec(secret, "AES"), new IvParameterSpec(ivBuffer));
|
||||
synchronized (CIPHER_LOCK) {
|
||||
cipher.init(Cipher.ENCRYPT_MODE, new SecretKeySpec(secret, "AES"), new IvParameterSpec(ivBuffer));
|
||||
|
||||
int cipherLength = cipher.getOutputSize(plaintext.length);
|
||||
byte[] ciphertext = ciphertextBuffer.get(cipherLength);
|
||||
cipherLength = cipher.doFinal(plaintext, 0, plaintext.length, ciphertext);
|
||||
int cipherLength = cipher.getOutputSize(plaintext.length);
|
||||
byte[] ciphertext = ciphertextBuffer.get(cipherLength);
|
||||
cipherLength = cipher.doFinal(plaintext, 0, plaintext.length, ciphertext);
|
||||
|
||||
outputStream.write(ivBuffer);
|
||||
outputStream.write(Conversions.intToByteArray(cipherLength));
|
||||
outputStream.write(ciphertext, 0, cipherLength);
|
||||
outputStream.write(ivBuffer);
|
||||
outputStream.write(Conversions.intToByteArray(cipherLength));
|
||||
outputStream.write(ciphertext, 0, cipherLength);
|
||||
}
|
||||
|
||||
outputStream.flush();
|
||||
} catch (ShortBufferException | InvalidAlgorithmParameterException | InvalidKeyException | BadPaddingException | IllegalBlockSizeException e) {
|
||||
@ -134,10 +138,11 @@ class LogFile {
|
||||
Util.readFully(inputStream, ciphertext, length);
|
||||
|
||||
try {
|
||||
cipher.init(Cipher.DECRYPT_MODE, new SecretKeySpec(secret, "AES"), new IvParameterSpec(ivBuffer));
|
||||
byte[] plaintext = cipher.doFinal(ciphertext, 0, length);
|
||||
|
||||
return new String(plaintext);
|
||||
synchronized (CIPHER_LOCK) {
|
||||
cipher.init(Cipher.DECRYPT_MODE, new SecretKeySpec(secret, "AES"), new IvParameterSpec(ivBuffer));
|
||||
byte[] plaintext = cipher.doFinal(ciphertext, 0, length);
|
||||
return new String(plaintext);
|
||||
}
|
||||
} catch (InvalidKeyException | InvalidAlgorithmParameterException | IllegalBlockSizeException | BadPaddingException e) {
|
||||
throw new AssertionError(e);
|
||||
}
|
||||
|
@ -2,7 +2,6 @@ package org.thoughtcrime.securesms.preferences
|
||||
|
||||
import android.app.AlertDialog
|
||||
import android.os.Bundle
|
||||
import android.view.View
|
||||
import androidx.activity.viewModels
|
||||
import androidx.core.view.isVisible
|
||||
import dagger.hilt.android.AndroidEntryPoint
|
||||
@ -11,58 +10,26 @@ import network.loki.messenger.databinding.ActivityBlockedContactsBinding
|
||||
import org.thoughtcrime.securesms.PassphraseRequiredActionBarActivity
|
||||
|
||||
@AndroidEntryPoint
|
||||
class BlockedContactsActivity: PassphraseRequiredActionBarActivity(), View.OnClickListener {
|
||||
class BlockedContactsActivity: PassphraseRequiredActionBarActivity() {
|
||||
|
||||
lateinit var binding: ActivityBlockedContactsBinding
|
||||
|
||||
val viewModel: BlockedContactsViewModel by viewModels()
|
||||
|
||||
val adapter = BlockedContactsAdapter()
|
||||
val adapter: BlockedContactsAdapter by lazy { BlockedContactsAdapter(viewModel) }
|
||||
|
||||
override fun onClick(v: View?) {
|
||||
if (v === binding.unblockButton && adapter.getSelectedItems().isNotEmpty()) {
|
||||
val contactsToUnblock = adapter.getSelectedItems()
|
||||
// show dialog
|
||||
val title = if (contactsToUnblock.size == 1) {
|
||||
getString(R.string.Unblock_dialog__title_single, contactsToUnblock.first().name)
|
||||
} else {
|
||||
getString(R.string.Unblock_dialog__title_multiple)
|
||||
}
|
||||
fun unblock() {
|
||||
// show dialog
|
||||
val title = viewModel.getTitle(this)
|
||||
|
||||
val message = if (contactsToUnblock.size == 1) {
|
||||
getString(R.string.Unblock_dialog__message, contactsToUnblock.first().name)
|
||||
} else {
|
||||
val stringBuilder = StringBuilder()
|
||||
val iterator = contactsToUnblock.iterator()
|
||||
var numberAdded = 0
|
||||
while (iterator.hasNext() && numberAdded < 3) {
|
||||
val nextRecipient = iterator.next()
|
||||
if (numberAdded > 0) stringBuilder.append(", ")
|
||||
|
||||
stringBuilder.append(nextRecipient.name)
|
||||
numberAdded++
|
||||
}
|
||||
val overflow = contactsToUnblock.size - numberAdded
|
||||
if (overflow > 0) {
|
||||
stringBuilder.append(" ")
|
||||
val string = resources.getQuantityString(R.plurals.Unblock_dialog__message_multiple_overflow, overflow)
|
||||
stringBuilder.append(string.format(overflow))
|
||||
}
|
||||
getString(R.string.Unblock_dialog__message, stringBuilder.toString())
|
||||
}
|
||||
val message = viewModel.getMessage(this)
|
||||
|
||||
AlertDialog.Builder(this)
|
||||
.setTitle(title)
|
||||
.setMessage(message)
|
||||
.setPositiveButton(R.string.continue_2) { d, _ ->
|
||||
viewModel.unblock(contactsToUnblock)
|
||||
d.dismiss()
|
||||
}
|
||||
.setNegativeButton(R.string.cancel) { d, _ ->
|
||||
d.dismiss()
|
||||
}
|
||||
.show()
|
||||
}
|
||||
AlertDialog.Builder(this)
|
||||
.setTitle(title)
|
||||
.setMessage(message)
|
||||
.setPositiveButton(R.string.continue_2) { _, _ -> viewModel.unblock(this@BlockedContactsActivity) }
|
||||
.setNegativeButton(R.string.cancel) { _, _ -> }
|
||||
.show()
|
||||
}
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?, ready: Boolean) {
|
||||
@ -73,15 +40,15 @@ class BlockedContactsActivity: PassphraseRequiredActionBarActivity(), View.OnCli
|
||||
binding.recyclerView.adapter = adapter
|
||||
|
||||
viewModel.subscribe(this)
|
||||
.observe(this) { newState ->
|
||||
adapter.submitList(newState.blockedContacts)
|
||||
val isEmpty = newState.blockedContacts.isEmpty()
|
||||
binding.emptyStateMessageTextView.isVisible = isEmpty
|
||||
binding.nonEmptyStateGroup.isVisible = !isEmpty
|
||||
.observe(this) { state ->
|
||||
adapter.submitList(state.items)
|
||||
binding.emptyStateMessageTextView.isVisible = state.emptyStateMessageTextViewVisible
|
||||
binding.nonEmptyStateGroup.isVisible = state.nonEmptyStateGroupVisible
|
||||
binding.unblockButton.isEnabled = state.unblockButtonEnabled
|
||||
}
|
||||
|
||||
binding.unblockButton.setOnClickListener(this)
|
||||
binding.unblockButton.setOnClickListener { unblock() }
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
|
@ -10,38 +10,30 @@ import network.loki.messenger.R
|
||||
import network.loki.messenger.databinding.BlockedContactLayoutBinding
|
||||
import org.session.libsession.utilities.recipients.Recipient
|
||||
import org.thoughtcrime.securesms.mms.GlideApp
|
||||
import org.thoughtcrime.securesms.util.adapter.SelectableItem
|
||||
|
||||
class BlockedContactsAdapter: ListAdapter<Recipient,BlockedContactsAdapter.ViewHolder>(RecipientDiffer()) {
|
||||
typealias SelectableRecipient = SelectableItem<Recipient>
|
||||
|
||||
class RecipientDiffer: DiffUtil.ItemCallback<Recipient>() {
|
||||
override fun areItemsTheSame(oldItem: Recipient, newItem: Recipient) = oldItem === newItem
|
||||
override fun areContentsTheSame(oldItem: Recipient, newItem: Recipient) = oldItem == newItem
|
||||
class BlockedContactsAdapter(val viewModel: BlockedContactsViewModel) : ListAdapter<SelectableRecipient,BlockedContactsAdapter.ViewHolder>(RecipientDiffer()) {
|
||||
|
||||
class RecipientDiffer: DiffUtil.ItemCallback<SelectableRecipient>() {
|
||||
override fun areItemsTheSame(old: SelectableRecipient, new: SelectableRecipient) = old.item.address == new.item.address
|
||||
override fun areContentsTheSame(old: SelectableRecipient, new: SelectableRecipient) = old.isSelected == new.isSelected
|
||||
override fun getChangePayload(old: SelectableRecipient, new: SelectableRecipient) = new.isSelected
|
||||
}
|
||||
|
||||
private val selectedItems = mutableListOf<Recipient>()
|
||||
|
||||
fun getSelectedItems() = selectedItems
|
||||
|
||||
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): ViewHolder {
|
||||
val itemView = LayoutInflater.from(parent.context).inflate(R.layout.blocked_contact_layout, parent, false)
|
||||
return ViewHolder(itemView)
|
||||
}
|
||||
|
||||
private fun toggleSelection(recipient: Recipient, isSelected: Boolean, position: Int) {
|
||||
if (isSelected) {
|
||||
selectedItems -= recipient
|
||||
} else {
|
||||
selectedItems += recipient
|
||||
}
|
||||
notifyItemChanged(position)
|
||||
}
|
||||
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): ViewHolder =
|
||||
LayoutInflater.from(parent.context)
|
||||
.inflate(R.layout.blocked_contact_layout, parent, false)
|
||||
.let(::ViewHolder)
|
||||
|
||||
override fun onBindViewHolder(holder: ViewHolder, position: Int) {
|
||||
val recipient = getItem(position)
|
||||
val isSelected = recipient in selectedItems
|
||||
holder.bind(recipient, isSelected) {
|
||||
toggleSelection(recipient, isSelected, position)
|
||||
}
|
||||
holder.bind(getItem(position), viewModel::toggle)
|
||||
}
|
||||
|
||||
override fun onBindViewHolder(holder: ViewHolder, position: Int, payloads: MutableList<Any>) {
|
||||
if (payloads.isEmpty()) holder.bind(getItem(position), viewModel::toggle)
|
||||
else holder.select(getItem(position).isSelected)
|
||||
}
|
||||
|
||||
override fun onViewRecycled(holder: ViewHolder) {
|
||||
@ -54,15 +46,18 @@ class BlockedContactsAdapter: ListAdapter<Recipient,BlockedContactsAdapter.ViewH
|
||||
val glide = GlideApp.with(itemView)
|
||||
val binding = BlockedContactLayoutBinding.bind(itemView)
|
||||
|
||||
fun bind(recipient: Recipient, isSelected: Boolean, toggleSelection: () -> Unit) {
|
||||
binding.recipientName.text = recipient.name
|
||||
fun bind(selectable: SelectableRecipient, toggle: (SelectableRecipient) -> Unit) {
|
||||
binding.recipientName.text = selectable.item.name
|
||||
with (binding.profilePictureView.root) {
|
||||
glide = this@ViewHolder.glide
|
||||
update(recipient)
|
||||
update(selectable.item)
|
||||
}
|
||||
binding.root.setOnClickListener { toggleSelection() }
|
||||
binding.root.setOnClickListener { toggle(selectable) }
|
||||
binding.selectButton.isSelected = selectable.isSelected
|
||||
}
|
||||
|
||||
fun select(isSelected: Boolean) {
|
||||
binding.selectButton.isSelected = isSelected
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
|
@ -9,20 +9,15 @@ import androidx.preference.PreferenceViewHolder
|
||||
|
||||
class BlockedContactsPreference @JvmOverloads constructor(
|
||||
context: Context,
|
||||
attributeSet: AttributeSet? = null) : PreferenceCategory(context, attributeSet), View.OnClickListener {
|
||||
|
||||
override fun onClick(v: View?) {
|
||||
if (v is BlockedContactsLayout) {
|
||||
val intent = Intent(context, BlockedContactsActivity::class.java)
|
||||
context.startActivity(intent)
|
||||
}
|
||||
}
|
||||
attributeSet: AttributeSet? = null
|
||||
) : PreferenceCategory(context, attributeSet) {
|
||||
|
||||
override fun onBindViewHolder(holder: PreferenceViewHolder) {
|
||||
super.onBindViewHolder(holder)
|
||||
|
||||
val itemView = holder.itemView
|
||||
itemView.setOnClickListener(this)
|
||||
holder.itemView.setOnClickListener {
|
||||
val intent = Intent(context, BlockedContactsActivity::class.java)
|
||||
context.startActivity(intent)
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
|
@ -7,8 +7,10 @@ import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import app.cash.copper.flow.observeQuery
|
||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.Dispatchers.IO
|
||||
import kotlinx.coroutines.Dispatchers.Main
|
||||
import kotlinx.coroutines.GlobalScope
|
||||
import kotlinx.coroutines.SupervisorJob
|
||||
import kotlinx.coroutines.channels.Channel
|
||||
import kotlinx.coroutines.flow.collect
|
||||
@ -17,9 +19,12 @@ import kotlinx.coroutines.flow.onStart
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.plus
|
||||
import kotlinx.coroutines.withContext
|
||||
import network.loki.messenger.R
|
||||
import org.session.libsession.utilities.recipients.Recipient
|
||||
import org.thoughtcrime.securesms.database.DatabaseContentProviders
|
||||
import org.thoughtcrime.securesms.database.Storage
|
||||
import org.thoughtcrime.securesms.util.ConfigurationMessageUtilities
|
||||
import org.thoughtcrime.securesms.util.adapter.SelectableItem
|
||||
import javax.inject.Inject
|
||||
|
||||
@HiltViewModel
|
||||
@ -29,7 +34,9 @@ class BlockedContactsViewModel @Inject constructor(private val storage: Storage)
|
||||
|
||||
private val listUpdateChannel = Channel<Unit>(capacity = Channel.CONFLATED)
|
||||
|
||||
private val _contacts = MutableLiveData(BlockedContactsViewState(emptyList()))
|
||||
private val _state = MutableLiveData(BlockedContactsViewState())
|
||||
|
||||
val state get() = _state.value!!
|
||||
|
||||
fun subscribe(context: Context): LiveData<BlockedContactsViewState> {
|
||||
executor.launch(IO) {
|
||||
@ -45,21 +52,78 @@ class BlockedContactsViewModel @Inject constructor(private val storage: Storage)
|
||||
}
|
||||
executor.launch(IO) {
|
||||
for (update in listUpdateChannel) {
|
||||
val blockedContactState = BlockedContactsViewState(storage.blockedContacts().sortedBy { it.name })
|
||||
val blockedContactState = state.copy(
|
||||
blockedContacts = storage.blockedContacts().sortedBy { it.name }
|
||||
)
|
||||
withContext(Main) {
|
||||
_contacts.value = blockedContactState
|
||||
_state.value = blockedContactState
|
||||
}
|
||||
}
|
||||
}
|
||||
return _contacts
|
||||
return _state
|
||||
}
|
||||
|
||||
fun unblock(toUnblock: List<Recipient>) {
|
||||
storage.unblock(toUnblock)
|
||||
fun unblock(context: Context) {
|
||||
storage.unblock(state.selectedItems)
|
||||
_state.value = state.copy(selectedItems = emptySet())
|
||||
// TODO: Remove in UserConfig branch
|
||||
GlobalScope.launch(Dispatchers.IO) {
|
||||
ConfigurationMessageUtilities.forceSyncConfigurationNowIfNeeded(context)
|
||||
}
|
||||
}
|
||||
|
||||
fun select(selectedItem: Recipient, isSelected: Boolean) {
|
||||
_state.value = state.run {
|
||||
if (isSelected) copy(selectedItems = selectedItems + selectedItem)
|
||||
else copy(selectedItems = selectedItems - selectedItem)
|
||||
}
|
||||
}
|
||||
|
||||
fun getTitle(context: Context): String =
|
||||
if (state.selectedItems.size == 1) {
|
||||
context.getString(R.string.Unblock_dialog__title_single, state.selectedItems.first().name)
|
||||
} else {
|
||||
context.getString(R.string.Unblock_dialog__title_multiple)
|
||||
}
|
||||
|
||||
fun getMessage(context: Context): String {
|
||||
if (state.selectedItems.size == 1) {
|
||||
return context.getString(R.string.Unblock_dialog__message, state.selectedItems.first().name)
|
||||
}
|
||||
val stringBuilder = StringBuilder()
|
||||
val iterator = state.selectedItems.iterator()
|
||||
var numberAdded = 0
|
||||
while (iterator.hasNext() && numberAdded < 3) {
|
||||
val nextRecipient = iterator.next()
|
||||
if (numberAdded > 0) stringBuilder.append(", ")
|
||||
|
||||
stringBuilder.append(nextRecipient.name)
|
||||
numberAdded++
|
||||
}
|
||||
val overflow = state.selectedItems.size - numberAdded
|
||||
if (overflow > 0) {
|
||||
stringBuilder.append(" ")
|
||||
val string = context.resources.getQuantityString(R.plurals.Unblock_dialog__message_multiple_overflow, overflow)
|
||||
stringBuilder.append(string.format(overflow))
|
||||
}
|
||||
return context.getString(R.string.Unblock_dialog__message, stringBuilder.toString())
|
||||
}
|
||||
|
||||
fun toggle(selectable: SelectableItem<Recipient>) {
|
||||
_state.value = state.run {
|
||||
if (selectable.item in selectedItems) copy(selectedItems = selectedItems - selectable.item)
|
||||
else copy(selectedItems = selectedItems + selectable.item)
|
||||
}
|
||||
}
|
||||
|
||||
data class BlockedContactsViewState(
|
||||
val blockedContacts: List<Recipient>
|
||||
)
|
||||
val blockedContacts: List<Recipient> = emptyList(),
|
||||
val selectedItems: Set<Recipient> = emptySet()
|
||||
) {
|
||||
val items = blockedContacts.map { SelectableItem(it, it in selectedItems) }
|
||||
|
||||
}
|
||||
val unblockButtonEnabled get() = selectedItems.isNotEmpty()
|
||||
val emptyStateMessageTextViewVisible get() = blockedContacts.isEmpty()
|
||||
val nonEmptyStateGroupVisible get() = blockedContacts.isNotEmpty()
|
||||
}
|
||||
}
|
||||
|
@ -42,6 +42,7 @@ class ClearAllDataDialog : BaseDialog() {
|
||||
var selectedOption = device
|
||||
val optionAdapter = RadioOptionAdapter { selectedOption = it }
|
||||
binding.recyclerView.apply {
|
||||
itemAnimator = null
|
||||
adapter = optionAdapter
|
||||
addItemDecoration(DividerItemDecoration(requireContext(), DividerItemDecoration.VERTICAL))
|
||||
setHasFixedSize(true)
|
||||
|
@ -1,42 +1,41 @@
|
||||
package org.thoughtcrime.securesms.preferences
|
||||
|
||||
import android.content.Context
|
||||
import android.view.LayoutInflater
|
||||
import androidx.appcompat.app.AlertDialog
|
||||
import androidx.preference.ListPreference
|
||||
import androidx.recyclerview.widget.DividerItemDecoration
|
||||
import network.loki.messenger.databinding.DialogListPreferenceBinding
|
||||
import org.thoughtcrime.securesms.conversation.v2.utilities.BaseDialog
|
||||
|
||||
class ListPreferenceDialog(
|
||||
private val listPreference: ListPreference,
|
||||
private val dialogListener: () -> Unit
|
||||
) : BaseDialog() {
|
||||
private lateinit var binding: DialogListPreferenceBinding
|
||||
fun listPreferenceDialog(
|
||||
context: Context,
|
||||
listPreference: ListPreference,
|
||||
dialogListener: () -> Unit
|
||||
) : AlertDialog {
|
||||
|
||||
override fun setContentView(builder: AlertDialog.Builder) {
|
||||
binding = DialogListPreferenceBinding.inflate(LayoutInflater.from(requireContext()))
|
||||
binding.titleTextView.text = listPreference.dialogTitle
|
||||
binding.messageTextView.text = listPreference.dialogMessage
|
||||
binding.closeButton.setOnClickListener {
|
||||
dismiss()
|
||||
}
|
||||
val options = listPreference.entryValues.zip(listPreference.entries) { value, title ->
|
||||
RadioOption(value.toString(), title.toString())
|
||||
}
|
||||
val valueIndex = listPreference.findIndexOfValue(listPreference.value)
|
||||
val optionAdapter = RadioOptionAdapter(valueIndex) {
|
||||
listPreference.value = it.value
|
||||
dismiss()
|
||||
dialogListener.invoke()
|
||||
}
|
||||
binding.recyclerView.apply {
|
||||
adapter = optionAdapter
|
||||
addItemDecoration(DividerItemDecoration(requireContext(), DividerItemDecoration.VERTICAL))
|
||||
setHasFixedSize(true)
|
||||
}
|
||||
optionAdapter.submitList(options)
|
||||
builder.setView(binding.root)
|
||||
builder.setCancelable(false)
|
||||
val builder = AlertDialog.Builder(context)
|
||||
|
||||
val binding = DialogListPreferenceBinding.inflate(LayoutInflater.from(context))
|
||||
binding.titleTextView.text = listPreference.dialogTitle
|
||||
binding.messageTextView.text = listPreference.dialogMessage
|
||||
|
||||
builder.setView(binding.root)
|
||||
|
||||
val dialog = builder.show()
|
||||
|
||||
val valueIndex = listPreference.findIndexOfValue(listPreference.value)
|
||||
RadioOptionAdapter(valueIndex) {
|
||||
listPreference.value = it.value
|
||||
dialog.dismiss()
|
||||
dialogListener()
|
||||
}
|
||||
.apply {
|
||||
listPreference.entryValues.zip(listPreference.entries) { value, title ->
|
||||
RadioOption(value.toString(), title.toString())
|
||||
}.let(this::submitList)
|
||||
}
|
||||
.let { binding.recyclerView.adapter = it }
|
||||
|
||||
}
|
||||
binding.closeButton.setOnClickListener { dialog.dismiss() }
|
||||
|
||||
return dialog
|
||||
}
|
||||
|
@ -2,6 +2,8 @@ package org.thoughtcrime.securesms.preferences;
|
||||
|
||||
import static android.app.Activity.RESULT_OK;
|
||||
|
||||
import static org.thoughtcrime.securesms.preferences.ListPreferenceDialogKt.listPreferenceDialog;
|
||||
|
||||
import android.annotation.SuppressLint;
|
||||
import android.content.Context;
|
||||
import android.content.Intent;
|
||||
@ -77,10 +79,10 @@ public class NotificationsPreferenceFragment extends ListSummaryPreferenceFragme
|
||||
.setOnPreferenceClickListener(preference -> {
|
||||
ListPreference listPreference = (ListPreference) preference;
|
||||
listPreference.setDialogMessage(R.string.preferences_notifications__content_message);
|
||||
new ListPreferenceDialog(listPreference, () -> {
|
||||
initializeListSummary((ListPreference) findPreference(TextSecurePreferences.NOTIFICATION_PRIVACY_PREF));
|
||||
listPreferenceDialog(getContext(), listPreference, () -> {
|
||||
initializeListSummary(findPreference(TextSecurePreferences.NOTIFICATION_PRIVACY_PREF));
|
||||
return null;
|
||||
}).show(getChildFragmentManager(), "ListPreferenceDialog");
|
||||
});
|
||||
return true;
|
||||
});
|
||||
|
||||
|
@ -16,8 +16,8 @@ class RadioOptionAdapter(
|
||||
) : ListAdapter<RadioOption, RadioOptionAdapter.ViewHolder>(RadioOptionDiffer()) {
|
||||
|
||||
class RadioOptionDiffer: DiffUtil.ItemCallback<RadioOption>() {
|
||||
override fun areItemsTheSame(oldItem: RadioOption, newItem: RadioOption) = oldItem === newItem
|
||||
override fun areContentsTheSame(oldItem: RadioOption, newItem: RadioOption) = oldItem == newItem
|
||||
override fun areItemsTheSame(oldItem: RadioOption, newItem: RadioOption) = oldItem.title == newItem.title
|
||||
override fun areContentsTheSame(oldItem: RadioOption, newItem: RadioOption) = oldItem.value == newItem.value
|
||||
}
|
||||
|
||||
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): ViewHolder {
|
||||
@ -31,7 +31,7 @@ class RadioOptionAdapter(
|
||||
holder.bind(option, isSelected) {
|
||||
onClickListener(it)
|
||||
selectedOptionPosition = position
|
||||
notifyDataSetChanged()
|
||||
notifyItemRangeChanged(0, itemCount)
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -2,10 +2,7 @@ package org.thoughtcrime.securesms.preferences
|
||||
|
||||
import android.Manifest
|
||||
import android.app.Activity
|
||||
import android.content.ClipData
|
||||
import android.content.ClipboardManager
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.content.*
|
||||
import android.net.Uri
|
||||
import android.os.AsyncTask
|
||||
import android.os.Bundle
|
||||
@ -19,6 +16,7 @@ import android.view.MenuItem
|
||||
import android.view.View
|
||||
import android.view.inputmethod.InputMethodManager
|
||||
import android.widget.Toast
|
||||
import androidx.appcompat.app.AlertDialog
|
||||
import androidx.core.view.isVisible
|
||||
import network.loki.messenger.BuildConfig
|
||||
import network.loki.messenger.R
|
||||
@ -28,13 +26,13 @@ import nl.komponents.kovenant.all
|
||||
import nl.komponents.kovenant.ui.alwaysUi
|
||||
import nl.komponents.kovenant.ui.successUi
|
||||
import org.session.libsession.avatars.AvatarHelper
|
||||
import org.session.libsession.utilities.Address
|
||||
import org.session.libsession.utilities.ProfileKeyUtil
|
||||
import org.session.libsession.utilities.ProfilePictureUtilities
|
||||
import org.session.libsession.avatars.ProfileContactPhoto
|
||||
import org.session.libsession.utilities.*
|
||||
import org.session.libsession.utilities.SSKEnvironment.ProfileManagerProtocol
|
||||
import org.session.libsession.utilities.TextSecurePreferences
|
||||
import org.session.libsession.utilities.recipients.Recipient
|
||||
import org.thoughtcrime.securesms.PassphraseRequiredActionBarActivity
|
||||
import org.thoughtcrime.securesms.avatar.AvatarSelection
|
||||
import org.thoughtcrime.securesms.components.ProfilePictureView
|
||||
import org.thoughtcrime.securesms.home.PathActivity
|
||||
import org.thoughtcrime.securesms.messagerequests.MessageRequestsActivity
|
||||
import org.thoughtcrime.securesms.mms.GlideApp
|
||||
@ -57,8 +55,6 @@ class SettingsActivity : PassphraseRequiredActionBarActivity() {
|
||||
private var displayNameEditActionMode: ActionMode? = null
|
||||
set(value) { field = value; handleDisplayNameEditActionModeChanged() }
|
||||
private lateinit var glide: GlideRequests
|
||||
private var displayNameToBeUploaded: String? = null
|
||||
private var profilePictureToBeUploaded: ByteArray? = null
|
||||
private var tempFile: File? = null
|
||||
|
||||
private val hexEncodedPublicKey: String
|
||||
@ -76,14 +72,10 @@ class SettingsActivity : PassphraseRequiredActionBarActivity() {
|
||||
super.onCreate(savedInstanceState, isReady)
|
||||
binding = ActivitySettingsBinding.inflate(layoutInflater)
|
||||
setContentView(binding.root)
|
||||
val displayName = TextSecurePreferences.getProfileName(this) ?: hexEncodedPublicKey
|
||||
val displayName = getDisplayName()
|
||||
glide = GlideApp.with(this)
|
||||
with(binding) {
|
||||
profilePictureView.root.glide = glide
|
||||
profilePictureView.root.publicKey = hexEncodedPublicKey
|
||||
profilePictureView.root.displayName = displayName
|
||||
profilePictureView.root.isLarge = true
|
||||
profilePictureView.root.update()
|
||||
setupProfilePictureView(profilePictureView.root)
|
||||
profilePictureView.root.setOnClickListener { showEditProfilePictureUI() }
|
||||
ctnGroupNameSection.setOnClickListener { startActionMode(DisplayNameEditActionModeCallback()) }
|
||||
btnGroupNameDisplay.text = displayName
|
||||
@ -105,6 +97,19 @@ class SettingsActivity : PassphraseRequiredActionBarActivity() {
|
||||
}
|
||||
}
|
||||
|
||||
private fun getDisplayName(): String =
|
||||
TextSecurePreferences.getProfileName(this) ?: truncateIdForDisplay(hexEncodedPublicKey)
|
||||
|
||||
private fun setupProfilePictureView(view: ProfilePictureView) {
|
||||
view.glide = glide
|
||||
view.apply {
|
||||
publicKey = hexEncodedPublicKey
|
||||
displayName = getDisplayName()
|
||||
isLarge = true
|
||||
update()
|
||||
}
|
||||
}
|
||||
|
||||
override fun onSaveInstanceState(outState: Bundle) {
|
||||
super.onSaveInstanceState(outState)
|
||||
val scrollBundle = SparseArray<Parcelable>()
|
||||
@ -154,9 +159,9 @@ class SettingsActivity : PassphraseRequiredActionBarActivity() {
|
||||
}
|
||||
AsyncTask.execute {
|
||||
try {
|
||||
profilePictureToBeUploaded = BitmapUtil.createScaledBytes(this@SettingsActivity, AvatarSelection.getResultUri(data), ProfileMediaConstraints()).bitmap
|
||||
val profilePictureToBeUploaded = BitmapUtil.createScaledBytes(this@SettingsActivity, AvatarSelection.getResultUri(data), ProfileMediaConstraints()).bitmap
|
||||
Handler(Looper.getMainLooper()).post {
|
||||
updateProfile(true)
|
||||
updateProfile(true, profilePictureToBeUploaded)
|
||||
}
|
||||
} catch (e: BitmapDecodingException) {
|
||||
e.printStackTrace()
|
||||
@ -190,23 +195,30 @@ class SettingsActivity : PassphraseRequiredActionBarActivity() {
|
||||
}
|
||||
}
|
||||
|
||||
private fun updateProfile(isUpdatingProfilePicture: Boolean) {
|
||||
private fun updateProfile(
|
||||
isUpdatingProfilePicture: Boolean,
|
||||
profilePicture: ByteArray? = null,
|
||||
displayName: String? = null
|
||||
) {
|
||||
binding.loader.isVisible = true
|
||||
val promises = mutableListOf<Promise<*, Exception>>()
|
||||
val displayName = displayNameToBeUploaded
|
||||
if (displayName != null) {
|
||||
TextSecurePreferences.setProfileName(this, displayName)
|
||||
}
|
||||
val profilePicture = profilePictureToBeUploaded
|
||||
val encodedProfileKey = ProfileKeyUtil.generateEncodedProfileKey(this)
|
||||
if (isUpdatingProfilePicture && profilePicture != null) {
|
||||
promises.add(ProfilePictureUtilities.upload(profilePicture, encodedProfileKey, this))
|
||||
if (isUpdatingProfilePicture) {
|
||||
if (profilePicture != null) {
|
||||
promises.add(ProfilePictureUtilities.upload(profilePicture, encodedProfileKey, this))
|
||||
} else {
|
||||
TextSecurePreferences.setLastProfilePictureUpload(this, System.currentTimeMillis())
|
||||
TextSecurePreferences.setProfilePictureURL(this, null)
|
||||
}
|
||||
}
|
||||
val compoundPromise = all(promises)
|
||||
compoundPromise.successUi { // Do this on the UI thread so that it happens before the alwaysUi clause below
|
||||
if (isUpdatingProfilePicture && profilePicture != null) {
|
||||
if (isUpdatingProfilePicture) {
|
||||
AvatarHelper.setAvatar(this, Address.fromSerialized(TextSecurePreferences.getLocalNumber(this)!!), profilePicture)
|
||||
TextSecurePreferences.setProfileAvatarId(this, SecureRandom().nextInt())
|
||||
TextSecurePreferences.setProfileAvatarId(this, profilePicture?.let { SecureRandom().nextInt() } ?: 0 )
|
||||
TextSecurePreferences.setLastProfilePictureUpload(this, Date().time)
|
||||
ProfileKeyUtil.setEncodedProfileKey(this, encodedProfileKey)
|
||||
}
|
||||
@ -218,12 +230,10 @@ class SettingsActivity : PassphraseRequiredActionBarActivity() {
|
||||
if (displayName != null) {
|
||||
binding.btnGroupNameDisplay.text = displayName
|
||||
}
|
||||
if (isUpdatingProfilePicture && profilePicture != null) {
|
||||
if (isUpdatingProfilePicture) {
|
||||
binding.profilePictureView.root.recycle() // Clear the cached image before updating
|
||||
binding.profilePictureView.root.update()
|
||||
}
|
||||
displayNameToBeUploaded = null
|
||||
profilePictureToBeUploaded = null
|
||||
binding.loader.isVisible = false
|
||||
}
|
||||
}
|
||||
@ -244,8 +254,7 @@ class SettingsActivity : PassphraseRequiredActionBarActivity() {
|
||||
Toast.makeText(this, R.string.activity_settings_display_name_too_long_error, Toast.LENGTH_SHORT).show()
|
||||
return false
|
||||
}
|
||||
displayNameToBeUploaded = displayName
|
||||
updateProfile(false)
|
||||
updateProfile(false, displayName = displayName)
|
||||
return true
|
||||
}
|
||||
|
||||
@ -255,6 +264,38 @@ class SettingsActivity : PassphraseRequiredActionBarActivity() {
|
||||
}
|
||||
|
||||
private fun showEditProfilePictureUI() {
|
||||
AlertDialog.Builder(this)
|
||||
.setTitle(R.string.activity_settings_set_display_picture)
|
||||
.setView(R.layout.dialog_change_avatar)
|
||||
.setPositiveButton(R.string.activity_settings_upload) { _, _ ->
|
||||
startAvatarSelection()
|
||||
}
|
||||
.setNegativeButton(R.string.cancel) { _, _ -> }
|
||||
.apply {
|
||||
if (TextSecurePreferences.getProfileAvatarId(context) != 0) {
|
||||
setNeutralButton(R.string.activity_settings_remove) { _, _ -> removeAvatar() }
|
||||
}
|
||||
}
|
||||
.show().apply {
|
||||
val profilePic = findViewById<ProfilePictureView>(R.id.profile_picture_view)
|
||||
?.also(::setupProfilePictureView)
|
||||
|
||||
val pictureIcon = findViewById<View>(R.id.ic_pictures)
|
||||
|
||||
val recipient = Recipient.from(context, Address.fromSerialized(hexEncodedPublicKey), false)
|
||||
|
||||
val photoSet = (recipient.contactPhoto as ProfileContactPhoto).avatarObject !in setOf("0", "")
|
||||
|
||||
profilePic?.isVisible = photoSet
|
||||
pictureIcon?.isVisible = !photoSet
|
||||
}
|
||||
}
|
||||
|
||||
private fun removeAvatar() {
|
||||
updateProfile(true)
|
||||
}
|
||||
|
||||
private fun startAvatarSelection() {
|
||||
// Ask for an optional camera permission.
|
||||
Permissions.with(this)
|
||||
.request(Manifest.permission.CAMERA)
|
||||
|
@ -1,38 +0,0 @@
|
||||
package org.thoughtcrime.securesms.service;
|
||||
|
||||
import android.content.Context;
|
||||
import android.content.Intent;
|
||||
|
||||
import org.thoughtcrime.securesms.ApplicationContext;
|
||||
import org.thoughtcrime.securesms.jobs.LocalBackupJob;
|
||||
import org.session.libsession.utilities.TextSecurePreferences;
|
||||
|
||||
import java.util.concurrent.TimeUnit;
|
||||
|
||||
public class LocalBackupListener extends PersistentAlarmManagerListener {
|
||||
|
||||
private static final long INTERVAL = TimeUnit.DAYS.toMillis(1);
|
||||
|
||||
@Override
|
||||
protected long getNextScheduledExecutionTime(Context context) {
|
||||
return TextSecurePreferences.getNextBackupTime(context);
|
||||
}
|
||||
|
||||
@Override
|
||||
protected long onAlarm(Context context, long scheduledTime) {
|
||||
if (TextSecurePreferences.isBackupEnabled(context)) {
|
||||
ApplicationContext.getInstance(context).getJobManager().add(new LocalBackupJob());
|
||||
}
|
||||
|
||||
long nextTime = System.currentTimeMillis() + INTERVAL;
|
||||
TextSecurePreferences.setNextBackupTime(context, nextTime);
|
||||
|
||||
return nextTime;
|
||||
}
|
||||
|
||||
public static void schedule(Context context) {
|
||||
if (TextSecurePreferences.isBackupEnabled(context)) {
|
||||
new LocalBackupListener().onReceive(context, new Intent());
|
||||
}
|
||||
}
|
||||
}
|
@ -799,6 +799,7 @@ class WebRtcCallService : LifecycleService(), CallManager.WebRtcListener {
|
||||
wantsToAnswerReceiver?.let { receiver ->
|
||||
LocalBroadcastManager.getInstance(this).unregisterReceiver(receiver)
|
||||
}
|
||||
callManager.shutDownAudioManager()
|
||||
powerButtonReceiver = null
|
||||
wiredHeadsetStateReceiver = null
|
||||
networkChangedReceiver = null
|
||||
|
@ -2,11 +2,11 @@ package org.thoughtcrime.securesms.sskenvironment
|
||||
|
||||
import android.content.Context
|
||||
import org.session.libsession.messaging.contacts.Contact
|
||||
import org.session.libsession.messaging.jobs.JobQueue
|
||||
import org.session.libsession.utilities.SSKEnvironment
|
||||
import org.session.libsession.utilities.recipients.Recipient
|
||||
import org.thoughtcrime.securesms.ApplicationContext
|
||||
import org.thoughtcrime.securesms.dependencies.DatabaseComponent
|
||||
import org.thoughtcrime.securesms.jobs.RetrieveProfileAvatarJob
|
||||
import org.session.libsession.messaging.jobs.RetrieveProfileAvatarJob
|
||||
|
||||
class ProfileManager : SSKEnvironment.ProfileManagerProtocol {
|
||||
|
||||
@ -40,9 +40,8 @@ class ProfileManager : SSKEnvironment.ProfileManagerProtocol {
|
||||
}
|
||||
|
||||
override fun setProfilePictureURL(context: Context, recipient: Recipient, profilePictureURL: String) {
|
||||
val job = RetrieveProfileAvatarJob(recipient, profilePictureURL)
|
||||
val jobManager = ApplicationContext.getInstance(context).jobManager
|
||||
jobManager.add(job)
|
||||
val job = RetrieveProfileAvatarJob(profilePictureURL, recipient.address)
|
||||
JobQueue.shared.add(job)
|
||||
val sessionID = recipient.address.serialize()
|
||||
val contactDatabase = DatabaseComponent.get(context).sessionContactDatabase()
|
||||
var contact = contactDatabase.getContactWithSessionID(sessionID)
|
||||
|
@ -1,313 +0,0 @@
|
||||
package org.thoughtcrime.securesms.util
|
||||
|
||||
import android.app.Activity
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.net.Uri
|
||||
import android.os.Build
|
||||
import android.os.Environment
|
||||
import android.provider.DocumentsContract
|
||||
import android.widget.Toast
|
||||
import androidx.annotation.WorkerThread
|
||||
import androidx.documentfile.provider.DocumentFile
|
||||
import androidx.fragment.app.Fragment
|
||||
import network.loki.messenger.R
|
||||
import org.greenrobot.eventbus.EventBus
|
||||
import org.session.libsession.utilities.TextSecurePreferences
|
||||
import org.session.libsignal.utilities.ByteUtil
|
||||
import org.session.libsignal.utilities.Log
|
||||
import org.thoughtcrime.securesms.backup.BackupEvent
|
||||
import org.thoughtcrime.securesms.backup.BackupPassphrase
|
||||
import org.thoughtcrime.securesms.backup.BackupProtos.SharedPreference
|
||||
import org.thoughtcrime.securesms.backup.FullBackupExporter
|
||||
import org.thoughtcrime.securesms.crypto.AttachmentSecretProvider
|
||||
import org.thoughtcrime.securesms.crypto.IdentityKeyUtil
|
||||
import org.thoughtcrime.securesms.database.BackupFileRecord
|
||||
import org.thoughtcrime.securesms.dependencies.DatabaseComponent
|
||||
import java.io.IOException
|
||||
import java.security.MessageDigest
|
||||
import java.security.NoSuchAlgorithmException
|
||||
import java.security.SecureRandom
|
||||
import java.text.SimpleDateFormat
|
||||
import java.util.*
|
||||
|
||||
object BackupUtil {
|
||||
private const val MASTER_SECRET_UTIL_PREFERENCES_NAME = "SecureSMS-Preferences"
|
||||
private const val TAG = "BackupUtil"
|
||||
const val BACKUP_FILE_MIME_TYPE = "application/session-backup"
|
||||
const val BACKUP_PASSPHRASE_LENGTH = 30
|
||||
|
||||
fun getBackupRecords(context: Context): List<SharedPreference> {
|
||||
val prefName = MASTER_SECRET_UTIL_PREFERENCES_NAME
|
||||
val preferences = context.getSharedPreferences(prefName, 0)
|
||||
val prefList = LinkedList<SharedPreference>()
|
||||
prefList.add(SharedPreference.newBuilder()
|
||||
.setFile(prefName)
|
||||
.setKey(IdentityKeyUtil.IDENTITY_PUBLIC_KEY_PREF)
|
||||
.setValue(preferences.getString(IdentityKeyUtil.IDENTITY_PUBLIC_KEY_PREF, null))
|
||||
.build())
|
||||
prefList.add(SharedPreference.newBuilder()
|
||||
.setFile(prefName)
|
||||
.setKey(IdentityKeyUtil.IDENTITY_PRIVATE_KEY_PREF)
|
||||
.setValue(preferences.getString(IdentityKeyUtil.IDENTITY_PRIVATE_KEY_PREF, null))
|
||||
.build())
|
||||
if (preferences.contains(IdentityKeyUtil.ED25519_PUBLIC_KEY)) {
|
||||
prefList.add(SharedPreference.newBuilder()
|
||||
.setFile(prefName)
|
||||
.setKey(IdentityKeyUtil.ED25519_PUBLIC_KEY)
|
||||
.setValue(preferences.getString(IdentityKeyUtil.ED25519_PUBLIC_KEY, null))
|
||||
.build())
|
||||
}
|
||||
if (preferences.contains(IdentityKeyUtil.ED25519_SECRET_KEY)) {
|
||||
prefList.add(SharedPreference.newBuilder()
|
||||
.setFile(prefName)
|
||||
.setKey(IdentityKeyUtil.ED25519_SECRET_KEY)
|
||||
.setValue(preferences.getString(IdentityKeyUtil.ED25519_SECRET_KEY, null))
|
||||
.build())
|
||||
}
|
||||
prefList.add(SharedPreference.newBuilder()
|
||||
.setFile(prefName)
|
||||
.setKey(IdentityKeyUtil.LOKI_SEED)
|
||||
.setValue(preferences.getString(IdentityKeyUtil.LOKI_SEED, null))
|
||||
.build())
|
||||
return prefList
|
||||
}
|
||||
|
||||
@JvmStatic
|
||||
fun getLastBackupTimeString(context: Context, locale: Locale): String {
|
||||
val timestamp = DatabaseComponent.get(context).lokiBackupFilesDatabase().getLastBackupFileTime()
|
||||
if (timestamp == null) {
|
||||
return context.getString(R.string.BackupUtil_never)
|
||||
}
|
||||
return DateUtils.getDisplayFormattedTimeSpanString(context, locale, timestamp.time)
|
||||
}
|
||||
|
||||
@JvmStatic
|
||||
fun getLastBackup(context: Context): BackupFileRecord? {
|
||||
return DatabaseComponent.get(context).lokiBackupFilesDatabase().getLastBackupFile()
|
||||
}
|
||||
|
||||
@JvmStatic
|
||||
fun generateBackupPassphrase(): Array<String> {
|
||||
val random = ByteArray(BACKUP_PASSPHRASE_LENGTH).also { SecureRandom().nextBytes(it) }
|
||||
return Array(6) { i ->
|
||||
String.format("%05d", ByteUtil.byteArray5ToLong(random, i * 5) % 100000)
|
||||
}
|
||||
}
|
||||
|
||||
@JvmStatic
|
||||
fun validateDirAccess(context: Context, dirUri: Uri): Boolean {
|
||||
val hasWritePermission = context.contentResolver.persistedUriPermissions.any {
|
||||
it.isWritePermission && it.uri == dirUri
|
||||
}
|
||||
if (!hasWritePermission) return false
|
||||
|
||||
val document = DocumentFile.fromTreeUri(context, dirUri)
|
||||
if (document == null || !document.exists()) {
|
||||
return false
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
@JvmStatic
|
||||
fun getBackupDirUri(context: Context): Uri? {
|
||||
val dirUriString = TextSecurePreferences.getBackupSaveDir(context) ?: return null
|
||||
return Uri.parse(dirUriString)
|
||||
}
|
||||
|
||||
@JvmStatic
|
||||
fun setBackupDirUri(context: Context, uriString: String?) {
|
||||
TextSecurePreferences.setBackupSaveDir(context, uriString)
|
||||
}
|
||||
|
||||
/**
|
||||
* @return The selected backup directory if it's valid (exists, is writable).
|
||||
*/
|
||||
@JvmStatic
|
||||
fun getSelectedBackupDirIfValid(context: Context): Uri? {
|
||||
val dirUri = getBackupDirUri(context)
|
||||
|
||||
if (dirUri == null) {
|
||||
Log.v(TAG, "The backup dir wasn't selected yet.")
|
||||
return null
|
||||
}
|
||||
if (!validateDirAccess(context, dirUri)) {
|
||||
Log.v(TAG, "Cannot validate the access to the dir $dirUri.")
|
||||
return null
|
||||
}
|
||||
|
||||
return dirUri;
|
||||
}
|
||||
|
||||
@JvmStatic
|
||||
@WorkerThread
|
||||
@Throws(IOException::class)
|
||||
fun createBackupFile(context: Context): BackupFileRecord {
|
||||
val backupPassword = BackupPassphrase.get(context)
|
||||
?: throw IOException("Backup password is null")
|
||||
|
||||
val dirUri = getSelectedBackupDirIfValid(context)
|
||||
?: throw IOException("Backup save directory is not selected or invalid")
|
||||
|
||||
val date = Date()
|
||||
val timestamp = SimpleDateFormat("yyyy-MM-dd-HH-mm-ss", Locale.US).format(date)
|
||||
val fileName = String.format("session-%s.backup", timestamp)
|
||||
|
||||
val fileUri = DocumentsContract.createDocument(
|
||||
context.contentResolver,
|
||||
DocumentFile.fromTreeUri(context, dirUri)!!.uri,
|
||||
BACKUP_FILE_MIME_TYPE,
|
||||
fileName)
|
||||
|
||||
if (fileUri == null) {
|
||||
Toast.makeText(context, "Cannot create writable file in the dir $dirUri", Toast.LENGTH_LONG).show()
|
||||
throw IOException("Cannot create writable file in the dir $dirUri")
|
||||
}
|
||||
|
||||
try {
|
||||
FullBackupExporter.export(context,
|
||||
AttachmentSecretProvider.getInstance(context).orCreateAttachmentSecret,
|
||||
DatabaseComponent.get(context).openHelper().readableDatabase,
|
||||
fileUri,
|
||||
backupPassword)
|
||||
} catch (e: Exception) {
|
||||
// Delete the backup file on any error.
|
||||
DocumentsContract.deleteDocument(context.contentResolver, fileUri)
|
||||
throw e
|
||||
}
|
||||
|
||||
//TODO Use real file size.
|
||||
val record = DatabaseComponent.get(context).lokiBackupFilesDatabase()
|
||||
.insertBackupFile(BackupFileRecord(fileUri, -1, date))
|
||||
|
||||
Log.v(TAG, "A backup file was created: $fileUri")
|
||||
|
||||
return record
|
||||
}
|
||||
|
||||
@JvmStatic
|
||||
@JvmOverloads
|
||||
fun deleteAllBackupFiles(context: Context, except: Collection<BackupFileRecord>? = null) {
|
||||
val db = DatabaseComponent.get(context).lokiBackupFilesDatabase()
|
||||
db.getBackupFiles().iterator().forEach { record ->
|
||||
if (except != null && except.contains(record)) return@forEach
|
||||
|
||||
// Try to delete the related file. The operation may fail in many cases
|
||||
// (the user moved/deleted the file, revoked the write permission, etc), so that's OK.
|
||||
try {
|
||||
val result = DocumentsContract.deleteDocument(context.contentResolver, record.uri)
|
||||
if (!result) {
|
||||
Log.w(TAG, "Failed to delete backup file: ${record.uri}")
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Log.w(TAG, "Failed to delete backup file: ${record.uri}", e)
|
||||
}
|
||||
|
||||
db.deleteBackupFile(record)
|
||||
|
||||
Log.v(TAG, "Backup file was deleted: ${record.uri}")
|
||||
}
|
||||
}
|
||||
|
||||
@JvmStatic
|
||||
fun computeBackupKey(passphrase: String, salt: ByteArray?): ByteArray {
|
||||
return try {
|
||||
EventBus.getDefault().post(BackupEvent.createProgress(0))
|
||||
val digest = MessageDigest.getInstance("SHA-512")
|
||||
val input = passphrase.replace(" ", "").toByteArray()
|
||||
var hash: ByteArray = input
|
||||
if (salt != null) digest.update(salt)
|
||||
for (i in 0..249999) {
|
||||
if (i % 1000 == 0) EventBus.getDefault().post(BackupEvent.createProgress(0))
|
||||
digest.update(hash)
|
||||
hash = digest.digest(input)
|
||||
}
|
||||
ByteUtil.trim(hash, 32)
|
||||
} catch (e: NoSuchAlgorithmException) {
|
||||
throw AssertionError(e)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* An utility class to help perform backup directory selection requests.
|
||||
*
|
||||
* An instance of this class should be created per an [Activity] or [Fragment]
|
||||
* and [onActivityResult] should be called appropriately.
|
||||
*/
|
||||
class BackupDirSelector(private val contextProvider: ContextProvider) {
|
||||
|
||||
companion object {
|
||||
private const val REQUEST_CODE_SAVE_DIR = 7844
|
||||
}
|
||||
|
||||
private val context: Context get() = contextProvider.getContext()
|
||||
|
||||
private var listener: Listener? = null
|
||||
|
||||
constructor(activity: Activity) :
|
||||
this(ActivityContextProvider(activity))
|
||||
|
||||
constructor(fragment: Fragment) :
|
||||
this(FragmentContextProvider(fragment))
|
||||
|
||||
/**
|
||||
* Performs ACTION_OPEN_DOCUMENT_TREE intent to select backup directory URI.
|
||||
* If the directory is already selected and valid, the request will be skipped.
|
||||
* @param force if true, the previous selection is ignored and the user is requested to select another directory.
|
||||
* @param onSelectedListener an optional action to perform once the directory is selected.
|
||||
*/
|
||||
fun selectBackupDir(force: Boolean, onSelectedListener: Listener? = null) {
|
||||
if (!force) {
|
||||
val dirUri = BackupUtil.getSelectedBackupDirIfValid(context)
|
||||
if (dirUri != null && onSelectedListener != null) {
|
||||
onSelectedListener.onBackupDirSelected(dirUri)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// Let user pick the dir.
|
||||
val intent = Intent(Intent.ACTION_OPEN_DOCUMENT_TREE)
|
||||
|
||||
// Request read/write permission grant for the dir.
|
||||
intent.addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION or
|
||||
Intent.FLAG_GRANT_WRITE_URI_PERMISSION or
|
||||
Intent.FLAG_GRANT_PERSISTABLE_URI_PERMISSION)
|
||||
|
||||
// Set the default dir.
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
|
||||
val dirUri = BackupUtil.getBackupDirUri(context)
|
||||
intent.putExtra(DocumentsContract.EXTRA_INITIAL_URI, dirUri
|
||||
?: Uri.fromFile(context.getExternalFilesDir(Environment.DIRECTORY_DOCUMENTS)))
|
||||
}
|
||||
|
||||
if (onSelectedListener != null) {
|
||||
this.listener = onSelectedListener
|
||||
}
|
||||
|
||||
contextProvider.startActivityForResult(intent, REQUEST_CODE_SAVE_DIR)
|
||||
}
|
||||
|
||||
fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?) {
|
||||
if (requestCode != REQUEST_CODE_SAVE_DIR) return
|
||||
|
||||
if (resultCode == Activity.RESULT_OK && data != null && data.data != null) {
|
||||
// Acquire persistent access permissions for the file selected.
|
||||
val persistentFlags: Int = data.flags and
|
||||
(Intent.FLAG_GRANT_READ_URI_PERMISSION or Intent.FLAG_GRANT_WRITE_URI_PERMISSION)
|
||||
context.contentResolver.takePersistableUriPermission(data.data!!, persistentFlags)
|
||||
|
||||
BackupUtil.setBackupDirUri(context, data.dataString)
|
||||
|
||||
listener?.onBackupDirSelected(data.data!!)
|
||||
}
|
||||
|
||||
listener = null
|
||||
}
|
||||
|
||||
@FunctionalInterface
|
||||
interface Listener {
|
||||
fun onBackupDirSelected(uri: Uri)
|
||||
}
|
||||
}
|
@ -0,0 +1,6 @@
|
||||
package org.thoughtcrime.securesms.util
|
||||
|
||||
import android.database.Cursor
|
||||
|
||||
fun Cursor.asSequence(): Sequence<Cursor> =
|
||||
generateSequence { if (moveToNext()) this else null }
|
@ -3,6 +3,8 @@ package org.thoughtcrime.securesms.util
|
||||
import android.content.res.Resources
|
||||
import android.os.Build
|
||||
import androidx.annotation.ColorRes
|
||||
import androidx.recyclerview.widget.RecyclerView
|
||||
import kotlin.math.max
|
||||
import kotlin.math.roundToInt
|
||||
|
||||
fun Resources.getColorWithID(@ColorRes id: Int, theme: Resources.Theme?): Int {
|
||||
@ -30,3 +32,8 @@ fun toDp(px: Float, resources: Resources): Float {
|
||||
val scale = resources.displayMetrics.density
|
||||
return (px / scale)
|
||||
}
|
||||
|
||||
val RecyclerView.isScrolledToBottom: Boolean
|
||||
get() = computeVerticalScrollOffset().coerceAtLeast(0) +
|
||||
computeVerticalScrollExtent() +
|
||||
toPx(50, resources) >= computeVerticalScrollRange()
|
||||
|
@ -5,14 +5,18 @@ import android.animation.AnimatorListenerAdapter
|
||||
import android.animation.FloatEvaluator
|
||||
import android.animation.ValueAnimator
|
||||
import android.content.Context
|
||||
import android.graphics.Bitmap
|
||||
import android.graphics.PointF
|
||||
import android.graphics.Rect
|
||||
import android.util.Size
|
||||
import android.view.View
|
||||
import androidx.annotation.ColorInt
|
||||
import androidx.annotation.DimenRes
|
||||
import network.loki.messenger.R
|
||||
import org.session.libsession.utilities.getColorFromAttr
|
||||
import android.view.inputmethod.InputMethodManager
|
||||
import androidx.core.graphics.applyCanvas
|
||||
import kotlin.math.roundToInt
|
||||
|
||||
fun View.contains(point: PointF): Boolean {
|
||||
return hitRect.contains(point.x.toInt(), point.y.toInt())
|
||||
@ -65,3 +69,24 @@ fun View.hideKeyboard() {
|
||||
val imm = this.context.getSystemService(Context.INPUT_METHOD_SERVICE) as InputMethodManager
|
||||
imm.hideSoftInputFromWindow(this.windowToken, 0)
|
||||
}
|
||||
|
||||
|
||||
fun View.drawToBitmap(config: Bitmap.Config = Bitmap.Config.ARGB_8888, longestWidth: Int = 2000): Bitmap {
|
||||
val size = Size(measuredWidth, measuredHeight).coerceAtMost(longestWidth)
|
||||
val scale = size.width / measuredWidth.toFloat()
|
||||
|
||||
return Bitmap.createBitmap(size.width, size.height, config).applyCanvas {
|
||||
scale(scale, scale)
|
||||
translate(-scrollX.toFloat(), -scrollY.toFloat())
|
||||
draw(this)
|
||||
}
|
||||
}
|
||||
|
||||
fun Size.coerceAtMost(longestWidth: Int): Size =
|
||||
(width.toFloat() / height).let { aspect ->
|
||||
if (aspect > 1) {
|
||||
width.coerceAtMost(longestWidth).let { Size(it, (it / aspect).roundToInt()) }
|
||||
} else {
|
||||
height.coerceAtMost(longestWidth).let { Size((it * aspect).roundToInt(), it) }
|
||||
}
|
||||
}
|
||||
|
@ -0,0 +1,3 @@
|
||||
package org.thoughtcrime.securesms.util.adapter
|
||||
|
||||
data class SelectableItem<T>(val item: T, val isSelected: Boolean)
|
@ -93,6 +93,10 @@ class CallManager(context: Context, audioManager: AudioManagerCompat, private va
|
||||
peerConnectionObservers.remove(listener)
|
||||
}
|
||||
|
||||
fun shutDownAudioManager() {
|
||||
signalAudioManager.shutdown()
|
||||
}
|
||||
|
||||
private val _audioEvents = MutableStateFlow(AudioEnabled(false))
|
||||
val audioEvents = _audioEvents.asSharedFlow()
|
||||
private val _videoEvents = MutableStateFlow(VideoEnabled(false))
|
||||
|
@ -15,7 +15,7 @@ class SignalAudioHandler(looper: Looper) : Handler(looper) {
|
||||
}
|
||||
}
|
||||
|
||||
fun isOnHandler(): Boolean {
|
||||
private fun isOnHandler(): Boolean {
|
||||
return Looper.myLooper() == looper
|
||||
}
|
||||
}
|
||||
|
@ -9,6 +9,7 @@ import android.media.SoundPool
|
||||
import android.os.HandlerThread
|
||||
import network.loki.messenger.R
|
||||
import org.session.libsignal.utilities.Log
|
||||
import org.session.libsignal.utilities.ThreadUtils
|
||||
import org.thoughtcrime.securesms.webrtc.AudioManagerCommand
|
||||
import org.thoughtcrime.securesms.webrtc.audio.SignalBluetoothManager.State as BState
|
||||
|
||||
@ -32,10 +33,10 @@ class SignalAudioManager(private val context: Context,
|
||||
private val eventListener: EventListener?,
|
||||
private val androidAudioManager: AudioManagerCompat) {
|
||||
|
||||
private var commandAndControlThread: HandlerThread? = HandlerThread("call-audio").apply { start() }
|
||||
private var handler: SignalAudioHandler? = null
|
||||
private var commandAndControlThread: HandlerThread? = HandlerThread("call-audio", ThreadUtils.PRIORITY_IMPORTANT_BACKGROUND_THREAD).apply { start() }
|
||||
private var handler: SignalAudioHandler = SignalAudioHandler(commandAndControlThread!!.looper)
|
||||
|
||||
private var signalBluetoothManager: SignalBluetoothManager? = null
|
||||
private var signalBluetoothManager: SignalBluetoothManager = SignalBluetoothManager(context, this, androidAudioManager, handler)
|
||||
|
||||
private var state: State = State.UNINITIALIZED
|
||||
|
||||
@ -62,12 +63,9 @@ class SignalAudioManager(private val context: Context,
|
||||
private var wiredHeadsetReceiver: WiredHeadsetReceiver? = null
|
||||
|
||||
fun handleCommand(command: AudioManagerCommand) {
|
||||
if (command == AudioManagerCommand.Initialize) {
|
||||
initialize()
|
||||
return
|
||||
}
|
||||
handler?.post {
|
||||
handler.post {
|
||||
when (command) {
|
||||
is AudioManagerCommand.Initialize -> initialize()
|
||||
is AudioManagerCommand.UpdateAudioDeviceState -> updateAudioDeviceState()
|
||||
is AudioManagerCommand.Start -> start()
|
||||
is AudioManagerCommand.Stop -> stop(command.playDisconnect)
|
||||
@ -84,34 +82,37 @@ class SignalAudioManager(private val context: Context,
|
||||
Log.i(TAG, "Initializing audio manager state: $state")
|
||||
|
||||
if (state == State.UNINITIALIZED) {
|
||||
commandAndControlThread = HandlerThread("call-audio").apply { start() }
|
||||
handler = SignalAudioHandler(commandAndControlThread!!.looper)
|
||||
savedAudioMode = androidAudioManager.mode
|
||||
savedIsSpeakerPhoneOn = androidAudioManager.isSpeakerphoneOn
|
||||
savedIsMicrophoneMute = androidAudioManager.isMicrophoneMute
|
||||
hasWiredHeadset = androidAudioManager.isWiredHeadsetOn
|
||||
|
||||
signalBluetoothManager = SignalBluetoothManager(context, this, androidAudioManager, handler!!)
|
||||
androidAudioManager.requestCallAudioFocus()
|
||||
|
||||
handler!!.post {
|
||||
setMicrophoneMute(false)
|
||||
|
||||
savedAudioMode = androidAudioManager.mode
|
||||
savedIsSpeakerPhoneOn = androidAudioManager.isSpeakerphoneOn
|
||||
savedIsMicrophoneMute = androidAudioManager.isMicrophoneMute
|
||||
hasWiredHeadset = androidAudioManager.isWiredHeadsetOn
|
||||
audioDevices.clear()
|
||||
|
||||
androidAudioManager.requestCallAudioFocus()
|
||||
signalBluetoothManager.start()
|
||||
|
||||
setMicrophoneMute(false)
|
||||
updateAudioDeviceState()
|
||||
|
||||
audioDevices.clear()
|
||||
wiredHeadsetReceiver = WiredHeadsetReceiver()
|
||||
context.registerReceiver(wiredHeadsetReceiver, IntentFilter(AudioManager.ACTION_HEADSET_PLUG))
|
||||
|
||||
signalBluetoothManager!!.start()
|
||||
state = State.PREINITIALIZED
|
||||
|
||||
updateAudioDeviceState()
|
||||
Log.d(TAG, "Initialized")
|
||||
}
|
||||
}
|
||||
|
||||
wiredHeadsetReceiver = WiredHeadsetReceiver()
|
||||
context.registerReceiver(wiredHeadsetReceiver, IntentFilter(AudioManager.ACTION_HEADSET_PLUG))
|
||||
|
||||
state = State.PREINITIALIZED
|
||||
|
||||
Log.d(TAG, "Initialized")
|
||||
fun shutdown() {
|
||||
handler.post {
|
||||
stop(false)
|
||||
if (commandAndControlThread != null) {
|
||||
Log.i(TAG, "Shutting down command and control")
|
||||
commandAndControlThread?.quitSafely()
|
||||
commandAndControlThread = null
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -138,23 +139,11 @@ class SignalAudioManager(private val context: Context,
|
||||
|
||||
private fun stop(playDisconnect: Boolean) {
|
||||
Log.d(TAG, "Stopping. state: $state")
|
||||
if (state == State.UNINITIALIZED) {
|
||||
Log.i(TAG, "Trying to stop AudioManager in incorrect state: $state")
|
||||
return
|
||||
}
|
||||
|
||||
handler?.post {
|
||||
incomingRinger.stop()
|
||||
outgoingRinger.stop()
|
||||
stop(false)
|
||||
if (commandAndControlThread != null) {
|
||||
Log.i(TAG, "Shutting down command and control")
|
||||
commandAndControlThread?.quitSafely()
|
||||
commandAndControlThread = null
|
||||
}
|
||||
}
|
||||
incomingRinger.stop()
|
||||
outgoingRinger.stop()
|
||||
|
||||
if (playDisconnect) {
|
||||
if (playDisconnect && state != State.UNINITIALIZED) {
|
||||
val volume: Float = androidAudioManager.ringVolumeWithMinimum()
|
||||
soundPool.play(disconnectedSoundId, volume, volume, 0, 0, 1.0f)
|
||||
}
|
||||
@ -170,7 +159,7 @@ class SignalAudioManager(private val context: Context,
|
||||
}
|
||||
wiredHeadsetReceiver = null
|
||||
|
||||
signalBluetoothManager?.stop()
|
||||
signalBluetoothManager.stop()
|
||||
|
||||
setSpeakerphoneOn(savedIsSpeakerPhoneOn)
|
||||
setMicrophoneMute(savedIsMicrophoneMute)
|
||||
@ -183,25 +172,25 @@ class SignalAudioManager(private val context: Context,
|
||||
}
|
||||
|
||||
private fun updateAudioDeviceState() {
|
||||
handler!!.assertHandlerThread()
|
||||
handler.assertHandlerThread()
|
||||
|
||||
Log.i(
|
||||
TAG,
|
||||
"updateAudioDeviceState(): " +
|
||||
"wired: $hasWiredHeadset " +
|
||||
"bt: ${signalBluetoothManager!!.state} " +
|
||||
"bt: ${signalBluetoothManager.state} " +
|
||||
"available: $audioDevices " +
|
||||
"selected: $selectedAudioDevice " +
|
||||
"userSelected: $userSelectedAudioDevice"
|
||||
)
|
||||
|
||||
if (signalBluetoothManager!!.state.shouldUpdate()) {
|
||||
signalBluetoothManager!!.updateDevice()
|
||||
if (signalBluetoothManager.state.shouldUpdate()) {
|
||||
signalBluetoothManager.updateDevice()
|
||||
}
|
||||
|
||||
val newAudioDevices = mutableSetOf(AudioDevice.SPEAKER_PHONE)
|
||||
|
||||
if (signalBluetoothManager!!.state.hasDevice()) {
|
||||
if (signalBluetoothManager.state.hasDevice()) {
|
||||
newAudioDevices += AudioDevice.BLUETOOTH
|
||||
}
|
||||
|
||||
@ -217,7 +206,7 @@ class SignalAudioManager(private val context: Context,
|
||||
var audioDeviceSetUpdated = audioDevices != newAudioDevices
|
||||
audioDevices = newAudioDevices
|
||||
|
||||
if (signalBluetoothManager!!.state == BState.UNAVAILABLE && userSelectedAudioDevice == AudioDevice.BLUETOOTH) {
|
||||
if (signalBluetoothManager.state == BState.UNAVAILABLE && userSelectedAudioDevice == AudioDevice.BLUETOOTH) {
|
||||
userSelectedAudioDevice = AudioDevice.NONE
|
||||
}
|
||||
|
||||
@ -230,7 +219,7 @@ class SignalAudioManager(private val context: Context,
|
||||
userSelectedAudioDevice = AudioDevice.NONE
|
||||
}
|
||||
|
||||
val btState = signalBluetoothManager!!.state
|
||||
val btState = signalBluetoothManager.state
|
||||
val needBluetoothAudioStart = btState == BState.AVAILABLE &&
|
||||
(userSelectedAudioDevice == AudioDevice.NONE || userSelectedAudioDevice == AudioDevice.BLUETOOTH || autoSwitchToBluetooth)
|
||||
|
||||
@ -238,27 +227,27 @@ class SignalAudioManager(private val context: Context,
|
||||
(userSelectedAudioDevice != AudioDevice.NONE && userSelectedAudioDevice != AudioDevice.BLUETOOTH)
|
||||
|
||||
if (btState.hasDevice()) {
|
||||
Log.i(TAG, "Need bluetooth audio: state: ${signalBluetoothManager!!.state} start: $needBluetoothAudioStart stop: $needBluetoothAudioStop")
|
||||
Log.i(TAG, "Need bluetooth audio: state: ${signalBluetoothManager.state} start: $needBluetoothAudioStart stop: $needBluetoothAudioStop")
|
||||
}
|
||||
|
||||
if (needBluetoothAudioStop) {
|
||||
signalBluetoothManager!!.stopScoAudio()
|
||||
signalBluetoothManager!!.updateDevice()
|
||||
signalBluetoothManager.stopScoAudio()
|
||||
signalBluetoothManager.updateDevice()
|
||||
}
|
||||
|
||||
if (!autoSwitchToBluetooth && signalBluetoothManager!!.state == BState.UNAVAILABLE) {
|
||||
if (!autoSwitchToBluetooth && signalBluetoothManager.state == BState.UNAVAILABLE) {
|
||||
autoSwitchToBluetooth = true
|
||||
}
|
||||
|
||||
if (needBluetoothAudioStart && !needBluetoothAudioStop) {
|
||||
if (!signalBluetoothManager!!.startScoAudio()) {
|
||||
if (!needBluetoothAudioStop && needBluetoothAudioStart) {
|
||||
if (!signalBluetoothManager.startScoAudio()) {
|
||||
Log.e(TAG,"Failed to start sco audio")
|
||||
audioDevices.remove(AudioDevice.BLUETOOTH)
|
||||
audioDeviceSetUpdated = true
|
||||
}
|
||||
}
|
||||
|
||||
if (autoSwitchToBluetooth && signalBluetoothManager!!.state == BState.CONNECTED) {
|
||||
if (autoSwitchToBluetooth && signalBluetoothManager.state == BState.CONNECTED) {
|
||||
userSelectedAudioDevice = AudioDevice.BLUETOOTH
|
||||
autoSwitchToBluetooth = false
|
||||
}
|
||||
@ -373,7 +362,7 @@ class SignalAudioManager(private val context: Context,
|
||||
val pluggedIn = intent.getIntExtra("state", 0) == 1
|
||||
val hasMic = intent.getIntExtra("microphone", 0) == 1
|
||||
|
||||
handler?.post { onWiredHeadsetChange(pluggedIn, hasMic) }
|
||||
handler.post { onWiredHeadsetChange(pluggedIn, hasMic) }
|
||||
}
|
||||
}
|
||||
|
||||
|
5
app/src/main/res/color/button_destructive.xml
Normal file
5
app/src/main/res/color/button_destructive.xml
Normal file
@ -0,0 +1,5 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<selector xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<item android:state_enabled="false" android:color="?android:textColorTertiary"/>
|
||||
<item android:color="@color/destructive"/>
|
||||
</selector>
|
5
app/src/main/res/color/prominent_button_color.xml
Normal file
5
app/src/main/res/color/prominent_button_color.xml
Normal file
@ -0,0 +1,5 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<selector xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<item android:state_enabled="false" android:color="@color/grey"/>
|
||||
<item android:color="?prominentButtonColor"/>
|
||||
</selector>
|
@ -0,0 +1,11 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
|
||||
<ripple xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:color="?colorControlHighlight">
|
||||
<item android:id="@id/mask">
|
||||
<shape>
|
||||
<solid android:color="?colorPrimary"/>
|
||||
<corners android:radius="@dimen/medium_button_corner_radius" />
|
||||
</shape>
|
||||
</item>
|
||||
</ripple>
|
Some files were not shown because too many files have changed in this diff Show More
Loading…
x
Reference in New Issue
Block a user