values = new ArrayList<>(numColumns);
+
+ for (int column = 0; column < numColumns; column++) {
+ values.add(getObjectFromColumnIndex(cursor, column));
+ }
+
+ rows.add(values);
+ }
+ return rows;
+ }
+
+ private static @Nullable Object getObjectFromColumnIndex(Cursor cursor, int column) {
+ switch (cursor.getType(column)) {
+ case Cursor.FIELD_TYPE_NULL:
+ return null;
+ case Cursor.FIELD_TYPE_INTEGER:
+ return cursor.getLong(column);
+ case Cursor.FIELD_TYPE_FLOAT:
+ return cursor.getDouble(column);
+ case Cursor.FIELD_TYPE_BLOB:
+ return cursor.getBlob(column);
+ case Cursor.FIELD_TYPE_STRING:
+ default:
+ return cursor.getString(column);
+ }
+ }
+
+ static class Descriptor implements DatabaseDescriptor {
+ private final SignalDatabase sqlCipherOpenHelper;
+
+ Descriptor(@NonNull SignalDatabase sqlCipherOpenHelper) {
+ this.sqlCipherOpenHelper = sqlCipherOpenHelper;
+ }
+
+ @Override
+ public String name() {
+ return sqlCipherOpenHelper.getDatabaseName();
+ }
+
+ public @NonNull SQLiteDatabase getReadable() {
+ return sqlCipherOpenHelper.getSqlCipherDatabase();
+ }
+
+ public @NonNull SQLiteDatabase getWritable() {
+ return sqlCipherOpenHelper.getSqlCipherDatabase();
+ }
+ }
+}
diff --git a/app/src/internal/res/mipmap-anydpi-v26/ic_launcher.xml b/app/src/internal/res/mipmap-anydpi-v26/ic_launcher.xml
new file mode 100644
index 00000000..a898496a
--- /dev/null
+++ b/app/src/internal/res/mipmap-anydpi-v26/ic_launcher.xml
@@ -0,0 +1,5 @@
+
+
+
+
+
\ No newline at end of file
diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml
new file mode 100644
index 00000000..b9ac2ba4
--- /dev/null
+++ b/app/src/main/AndroidManifest.xml
@@ -0,0 +1,850 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/app/src/main/assets/databases/apns.db b/app/src/main/assets/databases/apns.db
new file mode 100644
index 00000000..7ffe6b6b
Binary files /dev/null and b/app/src/main/assets/databases/apns.db differ
diff --git a/app/src/main/assets/emoji/Activity.webp b/app/src/main/assets/emoji/Activity.webp
new file mode 100644
index 00000000..27f6c5cf
Binary files /dev/null and b/app/src/main/assets/emoji/Activity.webp differ
diff --git a/app/src/main/assets/emoji/Flags_0.webp b/app/src/main/assets/emoji/Flags_0.webp
new file mode 100644
index 00000000..b0f3b34a
Binary files /dev/null and b/app/src/main/assets/emoji/Flags_0.webp differ
diff --git a/app/src/main/assets/emoji/Flags_1.webp b/app/src/main/assets/emoji/Flags_1.webp
new file mode 100644
index 00000000..19b7ac0d
Binary files /dev/null and b/app/src/main/assets/emoji/Flags_1.webp differ
diff --git a/app/src/main/assets/emoji/Foods.webp b/app/src/main/assets/emoji/Foods.webp
new file mode 100644
index 00000000..9fe5d10e
Binary files /dev/null and b/app/src/main/assets/emoji/Foods.webp differ
diff --git a/app/src/main/assets/emoji/Nature.webp b/app/src/main/assets/emoji/Nature.webp
new file mode 100644
index 00000000..ab6e62a5
Binary files /dev/null and b/app/src/main/assets/emoji/Nature.webp differ
diff --git a/app/src/main/assets/emoji/Objects.webp b/app/src/main/assets/emoji/Objects.webp
new file mode 100644
index 00000000..2bf60c23
Binary files /dev/null and b/app/src/main/assets/emoji/Objects.webp differ
diff --git a/app/src/main/assets/emoji/People_0.webp b/app/src/main/assets/emoji/People_0.webp
new file mode 100644
index 00000000..dfd3593e
Binary files /dev/null and b/app/src/main/assets/emoji/People_0.webp differ
diff --git a/app/src/main/assets/emoji/People_1.webp b/app/src/main/assets/emoji/People_1.webp
new file mode 100644
index 00000000..81704a75
Binary files /dev/null and b/app/src/main/assets/emoji/People_1.webp differ
diff --git a/app/src/main/assets/emoji/People_2.webp b/app/src/main/assets/emoji/People_2.webp
new file mode 100644
index 00000000..1220bb40
Binary files /dev/null and b/app/src/main/assets/emoji/People_2.webp differ
diff --git a/app/src/main/assets/emoji/People_3.webp b/app/src/main/assets/emoji/People_3.webp
new file mode 100644
index 00000000..a770bc62
Binary files /dev/null and b/app/src/main/assets/emoji/People_3.webp differ
diff --git a/app/src/main/assets/emoji/People_4.webp b/app/src/main/assets/emoji/People_4.webp
new file mode 100644
index 00000000..3ebbe58a
Binary files /dev/null and b/app/src/main/assets/emoji/People_4.webp differ
diff --git a/app/src/main/assets/emoji/People_5.webp b/app/src/main/assets/emoji/People_5.webp
new file mode 100644
index 00000000..e458f43b
Binary files /dev/null and b/app/src/main/assets/emoji/People_5.webp differ
diff --git a/app/src/main/assets/emoji/People_6.webp b/app/src/main/assets/emoji/People_6.webp
new file mode 100644
index 00000000..ee827ffe
Binary files /dev/null and b/app/src/main/assets/emoji/People_6.webp differ
diff --git a/app/src/main/assets/emoji/People_7.webp b/app/src/main/assets/emoji/People_7.webp
new file mode 100644
index 00000000..4e8b4286
Binary files /dev/null and b/app/src/main/assets/emoji/People_7.webp differ
diff --git a/app/src/main/assets/emoji/Places.webp b/app/src/main/assets/emoji/Places.webp
new file mode 100644
index 00000000..0b8000bd
Binary files /dev/null and b/app/src/main/assets/emoji/Places.webp differ
diff --git a/app/src/main/assets/emoji/Symbols.webp b/app/src/main/assets/emoji/Symbols.webp
new file mode 100644
index 00000000..c2ee7c30
Binary files /dev/null and b/app/src/main/assets/emoji/Symbols.webp differ
diff --git a/app/src/main/assets/fonts/Roboto-Light.ttf b/app/src/main/assets/fonts/Roboto-Light.ttf
new file mode 100644
index 00000000..13bf13af
Binary files /dev/null and b/app/src/main/assets/fonts/Roboto-Light.ttf differ
diff --git a/app/src/main/assets/sounds/state-change_confirm-down.ogg b/app/src/main/assets/sounds/state-change_confirm-down.ogg
new file mode 100644
index 00000000..b2b5d58f
Binary files /dev/null and b/app/src/main/assets/sounds/state-change_confirm-down.ogg differ
diff --git a/app/src/main/assets/sounds/state-change_confirm-up.ogg b/app/src/main/assets/sounds/state-change_confirm-up.ogg
new file mode 100644
index 00000000..36dd7e99
Binary files /dev/null and b/app/src/main/assets/sounds/state-change_confirm-up.ogg differ
diff --git a/app/src/main/java/androidx/camera/view/SignalCameraView.java b/app/src/main/java/androidx/camera/view/SignalCameraView.java
new file mode 100644
index 00000000..99641b10
--- /dev/null
+++ b/app/src/main/java/androidx/camera/view/SignalCameraView.java
@@ -0,0 +1,822 @@
+/*
+ * Copyright (C) 2019 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package androidx.camera.view;
+
+import android.Manifest.permission;
+import android.annotation.SuppressLint;
+import android.content.Context;
+import android.content.res.TypedArray;
+import android.hardware.display.DisplayManager;
+import android.hardware.display.DisplayManager.DisplayListener;
+import android.os.Bundle;
+import android.os.Handler;
+import android.os.Looper;
+import android.os.Parcelable;
+import android.text.TextUtils;
+import android.util.AttributeSet;
+import android.view.Display;
+import android.view.MotionEvent;
+import android.view.ScaleGestureDetector;
+import android.view.Surface;
+import android.view.View;
+import android.view.ViewConfiguration;
+import android.view.ViewGroup;
+import android.widget.FrameLayout;
+
+import androidx.annotation.NonNull;
+import androidx.annotation.Nullable;
+import androidx.annotation.RequiresApi;
+import androidx.annotation.RequiresPermission;
+import androidx.annotation.RestrictTo;
+import androidx.annotation.RestrictTo.Scope;
+import androidx.camera.core.Camera;
+import androidx.camera.core.CameraSelector;
+import androidx.camera.core.FocusMeteringAction;
+import androidx.camera.core.FocusMeteringResult;
+import androidx.camera.core.ImageCapture;
+import androidx.camera.core.ImageCapture.OnImageCapturedCallback;
+import androidx.camera.core.ImageCapture.OnImageSavedCallback;
+import androidx.camera.core.ImageProxy;
+import androidx.camera.core.Logger;
+import androidx.camera.core.MeteringPoint;
+import androidx.camera.core.MeteringPointFactory;
+import androidx.camera.core.VideoCapture;
+import androidx.camera.core.VideoCapture.OnVideoSavedCallback;
+import androidx.camera.core.impl.LensFacingConverter;
+import androidx.camera.core.impl.utils.executor.CameraXExecutors;
+import androidx.camera.core.impl.utils.futures.FutureCallback;
+import androidx.camera.core.impl.utils.futures.Futures;
+import androidx.lifecycle.LifecycleOwner;
+import androidx.lifecycle.LiveData;
+
+import com.google.common.util.concurrent.ListenableFuture;
+
+import java.io.File;
+import java.util.concurrent.Executor;
+
+/**
+ * A {@link View} that displays a preview of the camera with methods {@link
+ * #takePicture(Executor, OnImageCapturedCallback)},
+ * {@link #takePicture(ImageCapture.OutputFileOptions, Executor, OnImageSavedCallback)},
+ * {@link #startRecording(File , Executor , OnVideoSavedCallback callback)}
+ * and {@link #stopRecording()}.
+ *
+ * Because the Camera is a limited resource and consumes a high amount of power, CameraView must
+ * be opened/closed. CameraView will handle opening/closing automatically through use of a {@link
+ * LifecycleOwner}. Use {@link #bindToLifecycle(LifecycleOwner)} to start the camera.
+ */
+@RequiresApi(21)
+@SuppressLint("RestrictedApi")
+public final class SignalCameraView extends FrameLayout {
+ static final String TAG = SignalCameraView.class.getSimpleName();
+
+ static final int INDEFINITE_VIDEO_DURATION = -1;
+ static final int INDEFINITE_VIDEO_SIZE = -1;
+
+ private static final String EXTRA_SUPER = "super";
+ private static final String EXTRA_ZOOM_RATIO = "zoom_ratio";
+ private static final String EXTRA_PINCH_TO_ZOOM_ENABLED = "pinch_to_zoom_enabled";
+ private static final String EXTRA_FLASH = "flash";
+ private static final String EXTRA_MAX_VIDEO_DURATION = "max_video_duration";
+ private static final String EXTRA_MAX_VIDEO_SIZE = "max_video_size";
+ private static final String EXTRA_SCALE_TYPE = "scale_type";
+ private static final String EXTRA_CAMERA_DIRECTION = "camera_direction";
+ private static final String EXTRA_CAPTURE_MODE = "captureMode";
+
+ private static final int LENS_FACING_NONE = 0;
+ private static final int LENS_FACING_FRONT = 1;
+ private static final int LENS_FACING_BACK = 2;
+ private static final int FLASH_MODE_AUTO = 1;
+ private static final int FLASH_MODE_ON = 2;
+ private static final int FLASH_MODE_OFF = 4;
+ // For tap-to-focus
+ private long mDownEventTimestamp;
+ // For pinch-to-zoom
+ private PinchToZoomGestureDetector mPinchToZoomGestureDetector;
+ private boolean mIsPinchToZoomEnabled = true;
+ SignalCameraXModule mCameraModule;
+ private final DisplayManager.DisplayListener mDisplayListener =
+ new DisplayListener() {
+ @Override
+ public void onDisplayAdded(int displayId) {
+ }
+
+ @Override
+ public void onDisplayRemoved(int displayId) {
+ }
+
+ @Override
+ public void onDisplayChanged(int displayId) {
+ mCameraModule.invalidateView();
+ }
+ };
+ private PreviewView mPreviewView;
+ // For accessibility event
+ private MotionEvent mUpEvent;
+
+ public SignalCameraView(@NonNull Context context) {
+ this(context, null);
+ }
+
+ public SignalCameraView(@NonNull Context context, @Nullable AttributeSet attrs) {
+ this(context, attrs, 0);
+ }
+
+ public SignalCameraView(@NonNull Context context, @Nullable AttributeSet attrs, int defStyle) {
+ super(context, attrs, defStyle);
+ init(context, attrs);
+ }
+
+ @RequiresApi(21)
+ public SignalCameraView(@NonNull Context context, @Nullable AttributeSet attrs, int defStyleAttr,
+ int defStyleRes) {
+ super(context, attrs, defStyleAttr, defStyleRes);
+ init(context, attrs);
+ }
+
+ /**
+ * Binds control of the camera used by this view to the given lifecycle.
+ *
+ *
This links opening/closing the camera to the given lifecycle. The camera will not operate
+ * unless this method is called with a valid {@link LifecycleOwner} that is not in the {@link
+ * androidx.lifecycle.Lifecycle.State#DESTROYED} state. Call this method only once camera
+ * permissions have been obtained.
+ *
+ *
Once the provided lifecycle has transitioned to a {@link
+ * androidx.lifecycle.Lifecycle.State#DESTROYED} state, CameraView must be bound to a new
+ * lifecycle through this method in order to operate the camera.
+ *
+ * @param lifecycleOwner The lifecycle that will control this view's camera
+ * @throws IllegalArgumentException if provided lifecycle is in a {@link
+ * androidx.lifecycle.Lifecycle.State#DESTROYED} state.
+ * @throws IllegalStateException if camera permissions are not granted.
+ */
+ @RequiresPermission(permission.CAMERA)
+ public void bindToLifecycle(@NonNull LifecycleOwner lifecycleOwner) {
+ mCameraModule.bindToLifecycle(lifecycleOwner);
+ }
+
+ private void init(Context context, @Nullable AttributeSet attrs) {
+ addView(mPreviewView = new PreviewView(getContext()), 0 /* view position */);
+ mCameraModule = new SignalCameraXModule(this);
+
+ if (attrs != null) {
+ TypedArray a = context.obtainStyledAttributes(attrs, R.styleable.CameraView);
+ setScaleType(
+ PreviewView.ScaleType.fromId(
+ a.getInteger(R.styleable.CameraView_scaleType,
+ getScaleType().getId())));
+ setPinchToZoomEnabled(
+ a.getBoolean(
+ R.styleable.CameraView_pinchToZoomEnabled, isPinchToZoomEnabled()));
+ setCaptureMode(
+ CaptureMode.fromId(
+ a.getInteger(R.styleable.CameraView_captureMode,
+ getCaptureMode().getId())));
+
+ int lensFacing = a.getInt(R.styleable.CameraView_lensFacing, LENS_FACING_BACK);
+ switch (lensFacing) {
+ case LENS_FACING_NONE:
+ setCameraLensFacing(null);
+ break;
+ case LENS_FACING_FRONT:
+ setCameraLensFacing(CameraSelector.LENS_FACING_FRONT);
+ break;
+ case LENS_FACING_BACK:
+ setCameraLensFacing(CameraSelector.LENS_FACING_BACK);
+ break;
+ default:
+ // Unhandled event.
+ }
+
+ int flashMode = a.getInt(R.styleable.CameraView_flash, 0);
+ switch (flashMode) {
+ case FLASH_MODE_AUTO:
+ setFlash(ImageCapture.FLASH_MODE_AUTO);
+ break;
+ case FLASH_MODE_ON:
+ setFlash(ImageCapture.FLASH_MODE_ON);
+ break;
+ case FLASH_MODE_OFF:
+ setFlash(ImageCapture.FLASH_MODE_OFF);
+ break;
+ default:
+ // Unhandled event.
+ }
+
+ a.recycle();
+ }
+
+ if (getBackground() == null) {
+ setBackgroundColor(0xFF111111);
+ }
+
+ mPinchToZoomGestureDetector = new PinchToZoomGestureDetector(context);
+ }
+
+ @Override
+ @NonNull
+ protected LayoutParams generateDefaultLayoutParams() {
+ return new LayoutParams(
+ ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.MATCH_PARENT);
+ }
+
+ @Override
+ @NonNull
+ protected Parcelable onSaveInstanceState() {
+ // TODO(b/113884082): Decide what belongs here or what should be invalidated on
+ // configuration
+ // change
+ Bundle state = new Bundle();
+ state.putParcelable(EXTRA_SUPER, super.onSaveInstanceState());
+ state.putInt(EXTRA_SCALE_TYPE, getScaleType().getId());
+ state.putFloat(EXTRA_ZOOM_RATIO, getZoomRatio());
+ state.putBoolean(EXTRA_PINCH_TO_ZOOM_ENABLED, isPinchToZoomEnabled());
+ state.putString(EXTRA_FLASH, FlashModeConverter.nameOf(getFlash()));
+ state.putLong(EXTRA_MAX_VIDEO_DURATION, getMaxVideoDuration());
+ state.putLong(EXTRA_MAX_VIDEO_SIZE, getMaxVideoSize());
+ if (getCameraLensFacing() != null) {
+ state.putString(EXTRA_CAMERA_DIRECTION,
+ LensFacingConverter.nameOf(getCameraLensFacing()));
+ }
+ state.putInt(EXTRA_CAPTURE_MODE, getCaptureMode().getId());
+ return state;
+ }
+
+ @Override
+ protected void onRestoreInstanceState(@Nullable Parcelable savedState) {
+ // TODO(b/113884082): Decide what belongs here or what should be invalidated on
+ // configuration
+ // change
+ if (savedState instanceof Bundle) {
+ Bundle state = (Bundle) savedState;
+ super.onRestoreInstanceState(state.getParcelable(EXTRA_SUPER));
+ setScaleType(PreviewView.ScaleType.fromId(state.getInt(EXTRA_SCALE_TYPE)));
+ setZoomRatio(state.getFloat(EXTRA_ZOOM_RATIO));
+ setPinchToZoomEnabled(state.getBoolean(EXTRA_PINCH_TO_ZOOM_ENABLED));
+ setFlash(FlashModeConverter.valueOf(state.getString(EXTRA_FLASH)));
+ setMaxVideoDuration(state.getLong(EXTRA_MAX_VIDEO_DURATION));
+ setMaxVideoSize(state.getLong(EXTRA_MAX_VIDEO_SIZE));
+ String lensFacingString = state.getString(EXTRA_CAMERA_DIRECTION);
+ setCameraLensFacing(
+ TextUtils.isEmpty(lensFacingString)
+ ? null
+ : LensFacingConverter.valueOf(lensFacingString));
+ setCaptureMode(CaptureMode.fromId(state.getInt(EXTRA_CAPTURE_MODE)));
+ } else {
+ super.onRestoreInstanceState(savedState);
+ }
+ }
+
+ @Override
+ protected void onAttachedToWindow() {
+ super.onAttachedToWindow();
+ DisplayManager dpyMgr =
+ (DisplayManager) getContext().getSystemService(Context.DISPLAY_SERVICE);
+ dpyMgr.registerDisplayListener(mDisplayListener, new Handler(Looper.getMainLooper()));
+ }
+
+ @Override
+ protected void onDetachedFromWindow() {
+ super.onDetachedFromWindow();
+ DisplayManager dpyMgr =
+ (DisplayManager) getContext().getSystemService(Context.DISPLAY_SERVICE);
+ dpyMgr.unregisterDisplayListener(mDisplayListener);
+ }
+
+ /**
+ * Gets the {@link LiveData} of the underlying {@link PreviewView}'s
+ * {@link PreviewView.StreamState}.
+ *
+ * @return A {@link LiveData} containing the {@link PreviewView.StreamState}. Apps can either
+ * get current value by {@link LiveData#getValue()} or register a observer by
+ * {@link LiveData#observe}.
+ * @see PreviewView#getPreviewStreamState()
+ */
+ @NonNull
+ public LiveData getPreviewStreamState() {
+ return mPreviewView.getPreviewStreamState();
+ }
+
+ @NonNull
+ PreviewView getPreviewView() {
+ return mPreviewView;
+ }
+
+ // TODO(b/124269166): Rethink how we can handle permissions here.
+ @SuppressLint("MissingPermission")
+ @Override
+ protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) {
+ // Since bindToLifecycle will depend on the measured dimension, only call it when measured
+ // dimension is not 0x0
+ if (getMeasuredWidth() > 0 && getMeasuredHeight() > 0) {
+ mCameraModule.bindToLifecycleAfterViewMeasured();
+ }
+
+ super.onMeasure(widthMeasureSpec, heightMeasureSpec);
+ }
+
+ // TODO(b/124269166): Rethink how we can handle permissions here.
+ @SuppressLint("MissingPermission")
+ @Override
+ protected void onLayout(boolean changed, int left, int top, int right, int bottom) {
+ // In case that the CameraView size is always set as 0x0, we still need to trigger to force
+ // binding to lifecycle
+ mCameraModule.bindToLifecycleAfterViewMeasured();
+
+ mCameraModule.invalidateView();
+ super.onLayout(changed, left, top, right, bottom);
+ }
+
+ /**
+ * @return One of {@link Surface#ROTATION_0}, {@link Surface#ROTATION_90}, {@link
+ * Surface#ROTATION_180}, {@link Surface#ROTATION_270}.
+ */
+ int getDisplaySurfaceRotation() {
+ Display display = getDisplay();
+
+ // Null when the View is detached. If we were in the middle of a background operation,
+ // better to not NPE. When the background operation finishes, it'll realize that the camera
+ // was closed.
+ if (display == null) {
+ return 0;
+ }
+
+ return display.getRotation();
+ }
+
+ /**
+ * Returns the scale type used to scale the preview.
+ *
+ * @return The current {@link PreviewView.ScaleType}.
+ */
+ @NonNull
+ public PreviewView.ScaleType getScaleType() {
+ return mPreviewView.getScaleType();
+ }
+
+ /**
+ * Sets the view finder scale type.
+ *
+ * This controls how the view finder should be scaled and positioned within the view.
+ *
+ * @param scaleType The desired {@link PreviewView.ScaleType}.
+ */
+ public void setScaleType(@NonNull PreviewView.ScaleType scaleType) {
+ mPreviewView.setScaleType(scaleType);
+ }
+
+ /**
+ * Returns the scale type used to scale the preview.
+ *
+ * @return The current {@link CaptureMode}.
+ */
+ @NonNull
+ public CaptureMode getCaptureMode() {
+ return mCameraModule.getCaptureMode();
+ }
+
+ /**
+ * Sets the CameraView capture mode
+ *
+ *
This controls only image or video capture function is enabled or both are enabled.
+ *
+ * @param captureMode The desired {@link CaptureMode}.
+ */
+ public void setCaptureMode(@NonNull CaptureMode captureMode) {
+ mCameraModule.setCaptureMode(captureMode);
+ }
+
+ /**
+ * Returns the maximum duration of videos, or {@link #INDEFINITE_VIDEO_DURATION} if there is no
+ * timeout.
+ *
+ * @hide Not currently implemented.
+ */
+ @RestrictTo(Scope.LIBRARY_GROUP)
+ public long getMaxVideoDuration() {
+ return mCameraModule.getMaxVideoDuration();
+ }
+
+ /**
+ * Sets the maximum video duration before
+ * {@link OnVideoSavedCallback#onVideoSaved(VideoCapture.OutputFileResults)} is called
+ * automatically.
+ * Use {@link #INDEFINITE_VIDEO_DURATION} to disable the timeout.
+ */
+ private void setMaxVideoDuration(long duration) {
+ mCameraModule.setMaxVideoDuration(duration);
+ }
+
+ /**
+ * Returns the maximum size of videos in bytes, or {@link #INDEFINITE_VIDEO_SIZE} if there is no
+ * timeout.
+ */
+ private long getMaxVideoSize() {
+ return mCameraModule.getMaxVideoSize();
+ }
+
+ /**
+ * Sets the maximum video size in bytes before
+ * {@link OnVideoSavedCallback#onVideoSaved(VideoCapture.OutputFileResults)}
+ * is called automatically. Use {@link #INDEFINITE_VIDEO_SIZE} to disable the size restriction.
+ */
+ private void setMaxVideoSize(long size) {
+ mCameraModule.setMaxVideoSize(size);
+ }
+
+ /**
+ * Takes a picture, and calls {@link OnImageCapturedCallback#onCaptureSuccess(ImageProxy)}
+ * once when done.
+ *
+ * @param executor The executor in which the callback methods will be run.
+ * @param callback Callback which will receive success or failure callbacks.
+ */
+ public void takePicture(@NonNull Executor executor, @NonNull OnImageCapturedCallback callback) {
+ mCameraModule.takePicture(executor, callback);
+ }
+
+ /**
+ * Takes a picture and calls
+ * {@link OnImageSavedCallback#onImageSaved(ImageCapture.OutputFileResults)} when done.
+ *
+ *
The value of {@link ImageCapture.Metadata#isReversedHorizontal()} in the
+ * {@link ImageCapture.OutputFileOptions} will be overwritten based on camera direction. For
+ * front camera, it will be set to true; for back camera, it will be set to false.
+ *
+ * @param outputFileOptions Options to store the newly captured image.
+ * @param executor The executor in which the callback methods will be run.
+ * @param callback Callback which will receive success or failure.
+ */
+ public void takePicture(@NonNull ImageCapture.OutputFileOptions outputFileOptions,
+ @NonNull Executor executor,
+ @NonNull OnImageSavedCallback callback) {
+ mCameraModule.takePicture(outputFileOptions, executor, callback);
+ }
+
+ /**
+ * Takes a video and calls the OnVideoSavedCallback when done.
+ *
+ * @param outputFileOptions Options to store the newly captured video.
+ * @param executor The executor in which the callback methods will be run.
+ * @param callback Callback which will receive success or failure.
+ */
+ public void startRecording(@NonNull VideoCapture.OutputFileOptions outputFileOptions,
+ @NonNull Executor executor,
+ @NonNull OnVideoSavedCallback callback) {
+ mCameraModule.startRecording(outputFileOptions, executor, callback);
+ }
+
+ /** Stops an in progress video. */
+ public void stopRecording() {
+ mCameraModule.stopRecording();
+ }
+
+ /** @return True if currently recording. */
+ public boolean isRecording() {
+ return mCameraModule.isRecording();
+ }
+
+ /**
+ * Queries whether the current device has a camera with the specified direction.
+ *
+ * @return True if the device supports the direction.
+ * @throws IllegalStateException if the CAMERA permission is not currently granted.
+ */
+ @RequiresPermission(permission.CAMERA)
+ public boolean hasCameraWithLensFacing(@CameraSelector.LensFacing int lensFacing) {
+ return mCameraModule.hasCameraWithLensFacing(lensFacing);
+ }
+
+ /**
+ * Toggles between the primary front facing camera and the primary back facing camera.
+ *
+ *
This will have no effect if not already bound to a lifecycle via {@link
+ * #bindToLifecycle(LifecycleOwner)}.
+ */
+ public void toggleCamera() {
+ mCameraModule.toggleCamera();
+ }
+
+ /**
+ * Sets the desired camera by specifying desired lensFacing.
+ *
+ *
This will choose the primary camera with the specified camera lensFacing.
+ *
+ *
If called before {@link #bindToLifecycle(LifecycleOwner)}, this will set the camera to be
+ * used when first bound to the lifecycle. If the specified lensFacing is not supported by the
+ * device, as determined by {@link #hasCameraWithLensFacing(int)}, the first supported
+ * lensFacing will be chosen when {@link #bindToLifecycle(LifecycleOwner)} is called.
+ *
+ *
If called with {@code null} AFTER binding to the lifecycle, the behavior would be
+ * equivalent to unbind the use cases without the lifecycle having to be destroyed.
+ *
+ * @param lensFacing The desired camera lensFacing.
+ */
+ public void setCameraLensFacing(@Nullable Integer lensFacing) {
+ mCameraModule.setCameraLensFacing(lensFacing);
+ }
+
+ /** Returns the currently selected lensFacing. */
+ @Nullable
+ public Integer getCameraLensFacing() {
+ return mCameraModule.getLensFacing();
+ }
+
+ /** Gets the active flash strategy. */
+ @ImageCapture.FlashMode
+ public int getFlash() {
+ return mCameraModule.getFlash();
+ }
+
+ // Begin Signal Custom Code Block
+ public boolean hasFlash() {
+ return mCameraModule.hasFlash();
+ }
+ // End Signal Custom Code Block
+
+ /** Sets the active flash strategy. */
+ public void setFlash(@ImageCapture.FlashMode int flashMode) {
+ mCameraModule.setFlash(flashMode);
+ }
+
+ private long delta() {
+ return System.currentTimeMillis() - mDownEventTimestamp;
+ }
+
+ @Override
+ public boolean onTouchEvent(@NonNull MotionEvent event) {
+ // Disable pinch-to-zoom and tap-to-focus while the camera module is paused.
+ if (mCameraModule.isPaused()) {
+ return false;
+ }
+ // Only forward the event to the pinch-to-zoom gesture detector when pinch-to-zoom is
+ // enabled.
+ if (isPinchToZoomEnabled()) {
+ mPinchToZoomGestureDetector.onTouchEvent(event);
+ }
+ if (event.getPointerCount() == 2 && isPinchToZoomEnabled() && isZoomSupported()) {
+ return true;
+ }
+
+ // Camera focus
+ switch (event.getAction()) {
+ case MotionEvent.ACTION_DOWN:
+ mDownEventTimestamp = System.currentTimeMillis();
+ break;
+ case MotionEvent.ACTION_UP:
+ if (delta() < ViewConfiguration.getLongPressTimeout()
+ && mCameraModule.isBoundToLifecycle()) {
+ mUpEvent = event;
+ performClick();
+ }
+ break;
+ default:
+ // Unhandled event.
+ return false;
+ }
+ return true;
+ }
+
+ /**
+ * Focus the position of the touch event, or focus the center of the preview for
+ * accessibility events
+ */
+ @Override
+ public boolean performClick() {
+ super.performClick();
+
+ final float x = (mUpEvent != null) ? mUpEvent.getX() : getX() + getWidth() / 2f;
+ final float y = (mUpEvent != null) ? mUpEvent.getY() : getY() + getHeight() / 2f;
+ mUpEvent = null;
+
+ Camera camera = mCameraModule.getCamera();
+ if (camera != null) {
+ MeteringPointFactory pointFactory = mPreviewView.getMeteringPointFactory();
+ float afPointWidth = 1.0f / 6.0f; // 1/6 total area
+ float aePointWidth = afPointWidth * 1.5f;
+ MeteringPoint afPoint = pointFactory.createPoint(x, y, afPointWidth);
+ MeteringPoint aePoint = pointFactory.createPoint(x, y, aePointWidth);
+
+ ListenableFuture future =
+ camera.getCameraControl().startFocusAndMetering(
+ new FocusMeteringAction.Builder(afPoint,
+ FocusMeteringAction.FLAG_AF).addPoint(aePoint,
+ FocusMeteringAction.FLAG_AE).build());
+ Futures.addCallback(future, new FutureCallback() {
+ @Override
+ public void onSuccess(@Nullable FocusMeteringResult result) {
+ }
+
+ @Override
+ public void onFailure(Throwable t) {
+ // Throw the unexpected error.
+ throw new RuntimeException(t);
+ }
+ }, CameraXExecutors.directExecutor());
+
+ } else {
+ Logger.d(TAG, "cannot access camera");
+ }
+
+ return true;
+ }
+
+ float rangeLimit(float val, float max, float min) {
+ return Math.min(Math.max(val, min), max);
+ }
+
+ /**
+ * Returns whether the view allows pinch-to-zoom.
+ *
+ * @return True if pinch to zoom is enabled.
+ */
+ public boolean isPinchToZoomEnabled() {
+ return mIsPinchToZoomEnabled;
+ }
+
+ /**
+ * Sets whether the view should allow pinch-to-zoom.
+ *
+ * When enabled, the user can pinch the camera to zoom in/out. This only has an effect if the
+ * bound camera supports zoom.
+ *
+ * @param enabled True to enable pinch-to-zoom.
+ */
+ public void setPinchToZoomEnabled(boolean enabled) {
+ mIsPinchToZoomEnabled = enabled;
+ }
+
+ /**
+ * Returns the current zoom ratio.
+ *
+ * @return The current zoom ratio.
+ */
+ public float getZoomRatio() {
+ return mCameraModule.getZoomRatio();
+ }
+
+ /**
+ * Sets the current zoom ratio.
+ *
+ *
Valid zoom values range from {@link #getMinZoomRatio()} to {@link #getMaxZoomRatio()}.
+ *
+ * @param zoomRatio The requested zoom ratio.
+ */
+ public void setZoomRatio(float zoomRatio) {
+ mCameraModule.setZoomRatio(zoomRatio);
+ }
+
+ /**
+ * Returns the minimum zoom ratio.
+ *
+ *
For most cameras this should return a zoom ratio of 1. A zoom ratio of 1 corresponds to a
+ * non-zoomed image.
+ *
+ * @return The minimum zoom ratio.
+ */
+ public float getMinZoomRatio() {
+ return mCameraModule.getMinZoomRatio();
+ }
+
+ /**
+ * Returns the maximum zoom ratio.
+ *
+ *
The zoom ratio corresponds to the ratio between both the widths and heights of a
+ * non-zoomed image and a maximally zoomed image for the selected camera.
+ *
+ * @return The maximum zoom ratio.
+ */
+ public float getMaxZoomRatio() {
+ return mCameraModule.getMaxZoomRatio();
+ }
+
+ /**
+ * Returns whether the bound camera supports zooming.
+ *
+ * @return True if the camera supports zooming.
+ */
+ public boolean isZoomSupported() {
+ return mCameraModule.isZoomSupported();
+ }
+
+ /**
+ * Turns on/off torch.
+ *
+ * @param torch True to turn on torch, false to turn off torch.
+ */
+ public void enableTorch(boolean torch) {
+ mCameraModule.enableTorch(torch);
+ }
+
+ /**
+ * Returns current torch status.
+ *
+ * @return true if torch is on , otherwise false
+ */
+ public boolean isTorchOn() {
+ return mCameraModule.isTorchOn();
+ }
+
+ /**
+ * The capture mode used by CameraView.
+ *
+ *
This enum can be used to determine which capture mode will be enabled for {@link
+ * SignalCameraView}.
+ */
+ public enum CaptureMode {
+ /** A mode where image capture is enabled. */
+ IMAGE(0),
+ /** A mode where video capture is enabled. */
+ VIDEO(1),
+ /**
+ * A mode where both image capture and video capture are simultaneously enabled. Note that
+ * this mode may not be available on every device.
+ */
+ MIXED(2);
+
+ private final int mId;
+
+ int getId() {
+ return mId;
+ }
+
+ CaptureMode(int id) {
+ mId = id;
+ }
+
+ static CaptureMode fromId(int id) {
+ for (CaptureMode f : values()) {
+ if (f.mId == id) {
+ return f;
+ }
+ }
+ throw new IllegalArgumentException();
+ }
+ }
+
+ static class S extends ScaleGestureDetector.SimpleOnScaleGestureListener {
+ private ScaleGestureDetector.OnScaleGestureListener mListener;
+
+ void setRealGestureDetector(ScaleGestureDetector.OnScaleGestureListener l) {
+ mListener = l;
+ }
+
+ @Override
+ public boolean onScale(ScaleGestureDetector detector) {
+ return mListener.onScale(detector);
+ }
+ }
+
+ private class PinchToZoomGestureDetector extends ScaleGestureDetector
+ implements ScaleGestureDetector.OnScaleGestureListener {
+ PinchToZoomGestureDetector(Context context) {
+ this(context, new S());
+ }
+
+ PinchToZoomGestureDetector(Context context, S s) {
+ super(context, s);
+ s.setRealGestureDetector(this);
+ }
+
+ @Override
+ public boolean onScale(ScaleGestureDetector detector) {
+ float scale = detector.getScaleFactor();
+
+ // Speeding up the zoom by 2X.
+ if (scale > 1f) {
+ scale = 1.0f + (scale - 1.0f) * 2;
+ } else {
+ scale = 1.0f - (1.0f - scale) * 2;
+ }
+
+ float newRatio = getZoomRatio() * scale;
+ newRatio = rangeLimit(newRatio, getMaxZoomRatio(), getMinZoomRatio());
+ setZoomRatio(newRatio);
+ return true;
+ }
+
+ @Override
+ public boolean onScaleBegin(ScaleGestureDetector detector) {
+ return true;
+ }
+
+ @Override
+ public void onScaleEnd(ScaleGestureDetector detector) {
+ }
+ }
+}
\ No newline at end of file
diff --git a/app/src/main/java/androidx/camera/view/SignalCameraXModule.java b/app/src/main/java/androidx/camera/view/SignalCameraXModule.java
new file mode 100644
index 00000000..d6af30e5
--- /dev/null
+++ b/app/src/main/java/androidx/camera/view/SignalCameraXModule.java
@@ -0,0 +1,696 @@
+/*
+ * Copyright (C) 2019 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package androidx.camera.view;
+
+import android.Manifest.permission;
+import android.annotation.SuppressLint;
+import android.content.Context;
+import android.content.res.Resources;
+import android.util.Rational;
+import android.util.Size;
+
+import androidx.annotation.NonNull;
+import androidx.annotation.Nullable;
+import androidx.annotation.RequiresApi;
+import androidx.annotation.RequiresPermission;
+import androidx.camera.core.Camera;
+import androidx.camera.core.CameraInfoUnavailableException;
+import androidx.camera.core.CameraSelector;
+import androidx.camera.core.ImageCapture;
+import androidx.camera.core.ImageCapture.OnImageCapturedCallback;
+import androidx.camera.core.ImageCapture.OnImageSavedCallback;
+import androidx.camera.core.Logger;
+import androidx.camera.core.Preview;
+import androidx.camera.core.TorchState;
+import androidx.camera.core.UseCase;
+import androidx.camera.core.VideoCapture;
+import androidx.camera.core.VideoCapture.OnVideoSavedCallback;
+import androidx.camera.core.impl.CameraInternal;
+import androidx.camera.core.impl.LensFacingConverter;
+import androidx.camera.core.impl.utils.CameraOrientationUtil;
+import androidx.camera.core.impl.utils.executor.CameraXExecutors;
+import androidx.camera.core.impl.utils.futures.FutureCallback;
+import androidx.camera.core.impl.utils.futures.Futures;
+import androidx.camera.lifecycle.ProcessCameraProvider;
+import androidx.core.util.Preconditions;
+import androidx.lifecycle.Lifecycle;
+import androidx.lifecycle.LifecycleObserver;
+import androidx.lifecycle.LifecycleOwner;
+import androidx.lifecycle.OnLifecycleEvent;
+
+import com.google.common.util.concurrent.ListenableFuture;
+
+import org.thoughtcrime.securesms.mediasend.camerax.CameraXUtil;
+import org.thoughtcrime.securesms.mms.MediaConstraints;
+import org.thoughtcrime.securesms.video.VideoUtil;
+
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.LinkedHashSet;
+import java.util.List;
+import java.util.Objects;
+import java.util.Set;
+import java.util.concurrent.Executor;
+import java.util.concurrent.atomic.AtomicBoolean;
+
+import static androidx.camera.core.ImageCapture.FLASH_MODE_OFF;
+
+/** CameraX use case operation built on @{link androidx.camera.core}. */
+@RequiresApi(21)
+@SuppressLint("RestrictedApi")
+final class SignalCameraXModule {
+ public static final String TAG = "CameraXModule";
+
+ private static final float UNITY_ZOOM_SCALE = 1f;
+ private static final float ZOOM_NOT_SUPPORTED = UNITY_ZOOM_SCALE;
+ private static final Rational ASPECT_RATIO_16_9 = new Rational(16, 9);
+ private static final Rational ASPECT_RATIO_4_3 = new Rational(4, 3);
+ private static final Rational ASPECT_RATIO_9_16 = new Rational(9, 16);
+ private static final Rational ASPECT_RATIO_3_4 = new Rational(3, 4);
+
+ private final Preview.Builder mPreviewBuilder;
+ private final VideoCapture.Builder mVideoCaptureBuilder;
+ private final ImageCapture.Builder mImageCaptureBuilder;
+ private final SignalCameraView mCameraView;
+ final AtomicBoolean mVideoIsRecording = new AtomicBoolean(false);
+ private SignalCameraView.CaptureMode mCaptureMode = SignalCameraView.CaptureMode.IMAGE;
+ private long mMaxVideoDuration = SignalCameraView.INDEFINITE_VIDEO_DURATION;
+ private long mMaxVideoSize = SignalCameraView.INDEFINITE_VIDEO_SIZE;
+ @ImageCapture.FlashMode
+ private int mFlash = FLASH_MODE_OFF;
+ @Nullable
+ @SuppressWarnings("WeakerAccess") /* synthetic accessor */
+ Camera mCamera;
+ @Nullable
+ private ImageCapture mImageCapture;
+ @Nullable
+ private VideoCapture mVideoCapture;
+ @SuppressWarnings("WeakerAccess") /* synthetic accessor */
+ @Nullable
+ Preview mPreview;
+ @SuppressWarnings("WeakerAccess") /* synthetic accessor */
+ @Nullable
+ LifecycleOwner mCurrentLifecycle;
+ private final LifecycleObserver mCurrentLifecycleObserver =
+ new LifecycleObserver() {
+ @OnLifecycleEvent(Lifecycle.Event.ON_DESTROY)
+ public void onDestroy(LifecycleOwner owner) {
+ if (owner == mCurrentLifecycle) {
+ clearCurrentLifecycle();
+ }
+ }
+ };
+ @Nullable
+ private LifecycleOwner mNewLifecycle;
+ @SuppressWarnings("WeakerAccess") /* synthetic accessor */
+ @Nullable
+ Integer mCameraLensFacing = CameraSelector.LENS_FACING_BACK;
+ @SuppressWarnings("WeakerAccess") /* synthetic accessor */
+ @Nullable
+ ProcessCameraProvider mCameraProvider;
+
+ SignalCameraXModule(SignalCameraView view) {
+ mCameraView = view;
+
+ Futures.addCallback(ProcessCameraProvider.getInstance(view.getContext()),
+ new FutureCallback() {
+ // TODO(b/124269166): Rethink how we can handle permissions here.
+ @SuppressLint("MissingPermission")
+ @Override
+ public void onSuccess(@Nullable ProcessCameraProvider provider) {
+ Preconditions.checkNotNull(provider);
+ mCameraProvider = provider;
+ if (mCurrentLifecycle != null) {
+ bindToLifecycle(mCurrentLifecycle);
+ }
+ }
+
+ @Override
+ public void onFailure(Throwable t) {
+ throw new RuntimeException("CameraX failed to initialize.", t);
+ }
+ }, CameraXExecutors.mainThreadExecutor());
+
+ mPreviewBuilder = new Preview.Builder().setTargetName("Preview");
+
+ mImageCaptureBuilder = new ImageCapture.Builder().setTargetName("ImageCapture");
+
+ mVideoCaptureBuilder = new VideoCapture.Builder().setTargetName("VideoCapture")
+ .setAudioBitRate(VideoUtil.AUDIO_BIT_RATE)
+ .setVideoFrameRate(VideoUtil.VIDEO_FRAME_RATE)
+ .setBitRate(VideoUtil.VIDEO_BIT_RATE);
+ }
+
+ @RequiresPermission(permission.CAMERA)
+ void bindToLifecycle(LifecycleOwner lifecycleOwner) {
+ mNewLifecycle = lifecycleOwner;
+
+ if (getMeasuredWidth() > 0 && getMeasuredHeight() > 0) {
+ bindToLifecycleAfterViewMeasured();
+ }
+ }
+
+ @RequiresPermission(permission.CAMERA)
+ void bindToLifecycleAfterViewMeasured() {
+ if (mNewLifecycle == null) {
+ return;
+ }
+
+ clearCurrentLifecycle();
+ if (mNewLifecycle.getLifecycle().getCurrentState() == Lifecycle.State.DESTROYED) {
+ // Lifecycle is already in a destroyed state. Since it may have been a valid
+ // lifecycle when bound, but became destroyed while waiting for layout, treat this as
+ // a no-op now that we have cleared the previous lifecycle.
+ mNewLifecycle = null;
+ return;
+ }
+ mCurrentLifecycle = mNewLifecycle;
+ mNewLifecycle = null;
+
+ if (mCameraProvider == null) {
+ // try again once the camera provider is no longer null
+ return;
+ }
+
+ Set available = getAvailableCameraLensFacing();
+
+ if (available.isEmpty()) {
+ Logger.w(TAG, "Unable to bindToLifeCycle since no cameras available");
+ mCameraLensFacing = null;
+ }
+
+ // Ensure the current camera exists, or default to another camera
+ if (mCameraLensFacing != null && !available.contains(mCameraLensFacing)) {
+ Logger.w(TAG, "Camera does not exist with direction " + mCameraLensFacing);
+
+ // Default to the first available camera direction
+ mCameraLensFacing = available.iterator().next();
+
+ Logger.w(TAG, "Defaulting to primary camera with direction " + mCameraLensFacing);
+ }
+
+ // Do not attempt to create use cases for a null cameraLensFacing. This could occur if
+ // the user explicitly sets the LensFacing to null, or if we determined there
+ // were no available cameras, which should be logged in the logic above.
+ if (mCameraLensFacing == null) {
+ return;
+ }
+
+ // Set the preferred aspect ratio as 4:3 if it is IMAGE only mode. Set the preferred aspect
+ // ratio as 16:9 if it is VIDEO or MIXED mode. Then, it will be WYSIWYG when the view finder
+ // is in CENTER_INSIDE mode.
+
+ boolean isDisplayPortrait = getDisplayRotationDegrees() == 0
+ || getDisplayRotationDegrees() == 180;
+
+ // Begin Signal Custom Code Block
+ int resolution = CameraXUtil.getIdealResolution(Resources.getSystem().getDisplayMetrics().widthPixels, Resources.getSystem().getDisplayMetrics().heightPixels);
+ // End Signal Custom Code Block
+
+ Rational targetAspectRatio;
+ if (getCaptureMode() == SignalCameraView.CaptureMode.IMAGE) {
+ // Begin Signal Custom Code Block
+ mImageCaptureBuilder.setTargetResolution(CameraXUtil.buildResolutionForRatio(resolution, ASPECT_RATIO_4_3, isDisplayPortrait));
+ // End Signal Custom Code Block
+ targetAspectRatio = isDisplayPortrait ? ASPECT_RATIO_3_4 : ASPECT_RATIO_4_3;
+ } else {
+ // Begin Signal Custom Code Block
+ mImageCaptureBuilder.setTargetResolution(CameraXUtil.buildResolutionForRatio(resolution, ASPECT_RATIO_16_9, isDisplayPortrait));
+ // End Signal Custom Code Block
+ targetAspectRatio = isDisplayPortrait ? ASPECT_RATIO_9_16 : ASPECT_RATIO_16_9;
+ }
+
+ // Begin Signal Custom Code Block
+ mImageCaptureBuilder.setCaptureMode(CameraXUtil.getOptimalCaptureMode());
+ // End Signal Custom Code Block
+
+ mImageCaptureBuilder.setTargetRotation(getDisplaySurfaceRotation());
+ mImageCapture = mImageCaptureBuilder.build();
+
+ // Begin Signal Custom Code Block
+ Size size = VideoUtil.getVideoRecordingSize();
+ mVideoCaptureBuilder.setTargetResolution(size);
+ mVideoCaptureBuilder.setMaxResolution(size);
+ // End Signal Custom Code Block
+
+ mVideoCaptureBuilder.setTargetRotation(getDisplaySurfaceRotation());
+ // Begin Signal Custom Code Block
+ if (MediaConstraints.isVideoTranscodeAvailable()) {
+ mVideoCapture = mVideoCaptureBuilder.build();
+ }
+ // End Signal Custom Code Block
+
+ // Adjusts the preview resolution according to the view size and the target aspect ratio.
+ int height = (int) (getMeasuredWidth() / targetAspectRatio.floatValue());
+ mPreviewBuilder.setTargetResolution(new Size(getMeasuredWidth(), height));
+
+ mPreview = mPreviewBuilder.build();
+ mPreview.setSurfaceProvider(mCameraView.getPreviewView().getSurfaceProvider());
+
+ CameraSelector cameraSelector =
+ new CameraSelector.Builder().requireLensFacing(mCameraLensFacing).build();
+ if (getCaptureMode() == SignalCameraView.CaptureMode.IMAGE) {
+ mCamera = mCameraProvider.bindToLifecycle(mCurrentLifecycle, cameraSelector,
+ mImageCapture,
+ mPreview);
+ } else if (getCaptureMode() == SignalCameraView.CaptureMode.VIDEO) {
+ mCamera = mCameraProvider.bindToLifecycle(mCurrentLifecycle, cameraSelector,
+ mVideoCapture,
+ mPreview);
+ } else {
+ mCamera = mCameraProvider.bindToLifecycle(mCurrentLifecycle, cameraSelector,
+ mImageCapture,
+ mVideoCapture, mPreview);
+ }
+
+ setZoomRatio(UNITY_ZOOM_SCALE);
+ mCurrentLifecycle.getLifecycle().addObserver(mCurrentLifecycleObserver);
+ // Enable flash setting in ImageCapture after use cases are created and binded.
+ setFlash(getFlash());
+ }
+
+ public void open() {
+ throw new UnsupportedOperationException(
+ "Explicit open/close of camera not yet supported. Use bindtoLifecycle() instead.");
+ }
+
+ public void close() {
+ throw new UnsupportedOperationException(
+ "Explicit open/close of camera not yet supported. Use bindtoLifecycle() instead.");
+ }
+
+ public void takePicture(Executor executor, OnImageCapturedCallback callback) {
+ if (mImageCapture == null) {
+ return;
+ }
+
+ if (getCaptureMode() == SignalCameraView.CaptureMode.VIDEO) {
+ throw new IllegalStateException("Can not take picture under VIDEO capture mode.");
+ }
+
+ if (callback == null) {
+ throw new IllegalArgumentException("OnImageCapturedCallback should not be empty");
+ }
+
+ mImageCapture.takePicture(executor, callback);
+ }
+
+ public void takePicture(@NonNull ImageCapture.OutputFileOptions outputFileOptions,
+ @NonNull Executor executor, OnImageSavedCallback callback) {
+ if (mImageCapture == null) {
+ return;
+ }
+
+ if (getCaptureMode() == SignalCameraView.CaptureMode.VIDEO) {
+ throw new IllegalStateException("Can not take picture under VIDEO capture mode.");
+ }
+
+ if (callback == null) {
+ throw new IllegalArgumentException("OnImageSavedCallback should not be empty");
+ }
+
+ outputFileOptions.getMetadata().setReversedHorizontal(mCameraLensFacing != null
+ && mCameraLensFacing == CameraSelector.LENS_FACING_FRONT);
+ mImageCapture.takePicture(outputFileOptions, executor, callback);
+ }
+
+ public void startRecording(VideoCapture.OutputFileOptions outputFileOptions,
+ Executor executor, final OnVideoSavedCallback callback) {
+ if (mVideoCapture == null) {
+ return;
+ }
+
+ if (getCaptureMode() == SignalCameraView.CaptureMode.IMAGE) {
+ throw new IllegalStateException("Can not record video under IMAGE capture mode.");
+ }
+
+ if (callback == null) {
+ throw new IllegalArgumentException("OnVideoSavedCallback should not be empty");
+ }
+
+ mVideoIsRecording.set(true);
+ mVideoCapture.startRecording(
+ outputFileOptions,
+ executor,
+ new VideoCapture.OnVideoSavedCallback() {
+ @Override
+ public void onVideoSaved(
+ @NonNull VideoCapture.OutputFileResults outputFileResults) {
+ mVideoIsRecording.set(false);
+ callback.onVideoSaved(outputFileResults);
+ }
+
+ @Override
+ public void onError(
+ @VideoCapture.VideoCaptureError int videoCaptureError,
+ @NonNull String message,
+ @Nullable Throwable cause) {
+ mVideoIsRecording.set(false);
+ Logger.e(TAG, message, cause);
+ callback.onError(videoCaptureError, message, cause);
+ }
+ });
+ }
+
+ public void stopRecording() {
+ if (mVideoCapture == null) {
+ return;
+ }
+
+ mVideoCapture.stopRecording();
+ }
+
+ public boolean isRecording() {
+ return mVideoIsRecording.get();
+ }
+
+ // TODO(b/124269166): Rethink how we can handle permissions here.
+ @SuppressLint("MissingPermission")
+ public void setCameraLensFacing(@Nullable Integer lensFacing) {
+ // Setting same lens facing is a no-op, so check for that first
+ if (!Objects.equals(mCameraLensFacing, lensFacing)) {
+ // If we're not bound to a lifecycle, just update the camera that will be opened when we
+ // attach to a lifecycle.
+ mCameraLensFacing = lensFacing;
+
+ if (mCurrentLifecycle != null) {
+ // Re-bind to lifecycle with new camera
+ bindToLifecycle(mCurrentLifecycle);
+ }
+ }
+ }
+
+ @RequiresPermission(permission.CAMERA)
+ public boolean hasCameraWithLensFacing(@CameraSelector.LensFacing int lensFacing) {
+ if (mCameraProvider == null) {
+ return false;
+ }
+ try {
+ return mCameraProvider.hasCamera(
+ new CameraSelector.Builder().requireLensFacing(lensFacing).build());
+ } catch (CameraInfoUnavailableException e) {
+ return false;
+ }
+ }
+
+ @Nullable
+ public Integer getLensFacing() {
+ return mCameraLensFacing;
+ }
+
+ public void toggleCamera() {
+ // TODO(b/124269166): Rethink how we can handle permissions here.
+ @SuppressLint("MissingPermission")
+ Set availableCameraLensFacing = getAvailableCameraLensFacing();
+
+ if (availableCameraLensFacing.isEmpty()) {
+ return;
+ }
+
+ if (mCameraLensFacing == null) {
+ setCameraLensFacing(availableCameraLensFacing.iterator().next());
+ return;
+ }
+
+ if (mCameraLensFacing == CameraSelector.LENS_FACING_BACK
+ && availableCameraLensFacing.contains(CameraSelector.LENS_FACING_FRONT)) {
+ setCameraLensFacing(CameraSelector.LENS_FACING_FRONT);
+ return;
+ }
+
+ if (mCameraLensFacing == CameraSelector.LENS_FACING_FRONT
+ && availableCameraLensFacing.contains(CameraSelector.LENS_FACING_BACK)) {
+ setCameraLensFacing(CameraSelector.LENS_FACING_BACK);
+ return;
+ }
+ }
+
+ public float getZoomRatio() {
+ if (mCamera != null) {
+ return mCamera.getCameraInfo().getZoomState().getValue().getZoomRatio();
+ } else {
+ return UNITY_ZOOM_SCALE;
+ }
+ }
+
+ public void setZoomRatio(float zoomRatio) {
+ if (mCamera != null) {
+ ListenableFuture future = mCamera.getCameraControl().setZoomRatio(
+ zoomRatio);
+ Futures.addCallback(future, new FutureCallback() {
+ @Override
+ public void onSuccess(@Nullable Void result) {
+ }
+
+ @Override
+ public void onFailure(Throwable t) {
+ // Throw the unexpected error.
+ throw new RuntimeException(t);
+ }
+ }, CameraXExecutors.directExecutor());
+ } else {
+ Logger.e(TAG, "Failed to set zoom ratio");
+ }
+ }
+
+ public float getMinZoomRatio() {
+ if (mCamera != null) {
+ return mCamera.getCameraInfo().getZoomState().getValue().getMinZoomRatio();
+ } else {
+ return UNITY_ZOOM_SCALE;
+ }
+ }
+
+ public float getMaxZoomRatio() {
+ if (mCamera != null) {
+ return mCamera.getCameraInfo().getZoomState().getValue().getMaxZoomRatio();
+ } else {
+ return ZOOM_NOT_SUPPORTED;
+ }
+ }
+
+ public boolean isZoomSupported() {
+ return getMaxZoomRatio() != ZOOM_NOT_SUPPORTED;
+ }
+
+ // TODO(b/124269166): Rethink how we can handle permissions here.
+ @SuppressLint("MissingPermission")
+ private void rebindToLifecycle() {
+ if (mCurrentLifecycle != null) {
+ bindToLifecycle(mCurrentLifecycle);
+ }
+ }
+
+ boolean isBoundToLifecycle() {
+ return mCamera != null;
+ }
+
+ int getRelativeCameraOrientation(boolean compensateForMirroring) {
+ int rotationDegrees = 0;
+ if (mCamera != null) {
+ rotationDegrees =
+ mCamera.getCameraInfo().getSensorRotationDegrees(getDisplaySurfaceRotation());
+ if (compensateForMirroring) {
+ rotationDegrees = (360 - rotationDegrees) % 360;
+ }
+ }
+
+ return rotationDegrees;
+ }
+
+ public void invalidateView() {
+ updateViewInfo();
+ }
+
+ void clearCurrentLifecycle() {
+ if (mCurrentLifecycle != null && mCameraProvider != null) {
+ // Remove previous use cases
+ List toUnbind = new ArrayList<>();
+ if (mImageCapture != null && mCameraProvider.isBound(mImageCapture)) {
+ toUnbind.add(mImageCapture);
+ }
+ if (mVideoCapture != null && mCameraProvider.isBound(mVideoCapture)) {
+ toUnbind.add(mVideoCapture);
+ }
+ if (mPreview != null && mCameraProvider.isBound(mPreview)) {
+ toUnbind.add(mPreview);
+ }
+
+ if (!toUnbind.isEmpty()) {
+ mCameraProvider.unbind(toUnbind.toArray((new UseCase[0])));
+ }
+
+ // Remove surface provider once unbound.
+ if (mPreview != null) {
+ mPreview.setSurfaceProvider(null);
+ }
+ }
+ mCamera = null;
+ mCurrentLifecycle = null;
+ }
+
+ // Update view related information used in use cases
+ private void updateViewInfo() {
+ if (mImageCapture != null) {
+ mImageCapture.setCropAspectRatio(new Rational(getWidth(), getHeight()));
+ mImageCapture.setTargetRotation(getDisplaySurfaceRotation());
+ }
+
+ if (mVideoCapture != null) {
+ mVideoCapture.setTargetRotation(getDisplaySurfaceRotation());
+ }
+ }
+
+ @RequiresPermission(permission.CAMERA)
+ private Set getAvailableCameraLensFacing() {
+ // Start with all camera directions
+ Set available = new LinkedHashSet<>(Arrays.asList(LensFacingConverter.values()));
+
+ // If we're bound to a lifecycle, remove unavailable cameras
+ if (mCurrentLifecycle != null) {
+ if (!hasCameraWithLensFacing(CameraSelector.LENS_FACING_BACK)) {
+ available.remove(CameraSelector.LENS_FACING_BACK);
+ }
+
+ if (!hasCameraWithLensFacing(CameraSelector.LENS_FACING_FRONT)) {
+ available.remove(CameraSelector.LENS_FACING_FRONT);
+ }
+ }
+
+ return available;
+ }
+
+ @ImageCapture.FlashMode
+ public int getFlash() {
+ return mFlash;
+ }
+
+ // Begin Signal Custom Code Block
+ public boolean hasFlash() {
+ if (mImageCapture == null) {
+ return false;
+ }
+
+ CameraInternal camera = mImageCapture.getCamera();
+
+ if (camera == null) {
+ return false;
+ }
+
+ return camera.getCameraInfoInternal().hasFlashUnit();
+ }
+ // End Signal Custom Code Block
+
+ public void setFlash(@ImageCapture.FlashMode int flash) {
+ this.mFlash = flash;
+
+ if (mImageCapture == null) {
+ // Do nothing if there is no imageCapture
+ return;
+ }
+
+ mImageCapture.setFlashMode(flash);
+ }
+
+ public void enableTorch(boolean torch) {
+ if (mCamera == null) {
+ return;
+ }
+ ListenableFuture future = mCamera.getCameraControl().enableTorch(torch);
+ Futures.addCallback(future, new FutureCallback() {
+ @Override
+ public void onSuccess(@Nullable Void result) {
+ }
+
+ @Override
+ public void onFailure(Throwable t) {
+ // Throw the unexpected error.
+ throw new RuntimeException(t);
+ }
+ }, CameraXExecutors.directExecutor());
+ }
+
+ public boolean isTorchOn() {
+ if (mCamera == null) {
+ return false;
+ }
+ return mCamera.getCameraInfo().getTorchState().getValue() == TorchState.ON;
+ }
+
+ public Context getContext() {
+ return mCameraView.getContext();
+ }
+
+ public int getWidth() {
+ return mCameraView.getWidth();
+ }
+
+ public int getHeight() {
+ return mCameraView.getHeight();
+ }
+
+ public int getDisplayRotationDegrees() {
+ return CameraOrientationUtil.surfaceRotationToDegrees(getDisplaySurfaceRotation());
+ }
+
+ protected int getDisplaySurfaceRotation() {
+ return mCameraView.getDisplaySurfaceRotation();
+ }
+
+ private int getMeasuredWidth() {
+ return mCameraView.getMeasuredWidth();
+ }
+
+ private int getMeasuredHeight() {
+ return mCameraView.getMeasuredHeight();
+ }
+
+ @Nullable
+ public Camera getCamera() {
+ return mCamera;
+ }
+
+ @NonNull
+ public SignalCameraView.CaptureMode getCaptureMode() {
+ return mCaptureMode;
+ }
+
+ public void setCaptureMode(@NonNull SignalCameraView.CaptureMode captureMode) {
+ this.mCaptureMode = captureMode;
+ rebindToLifecycle();
+ }
+
+ public long getMaxVideoDuration() {
+ return mMaxVideoDuration;
+ }
+
+ public void setMaxVideoDuration(long duration) {
+ mMaxVideoDuration = duration;
+ }
+
+ public long getMaxVideoSize() {
+ return mMaxVideoSize;
+ }
+
+ public void setMaxVideoSize(long size) {
+ mMaxVideoSize = size;
+ }
+
+ public boolean isPaused() {
+ return false;
+ }
+}
diff --git a/app/src/main/java/org/archiver/ArchiveConstants.kt b/app/src/main/java/org/archiver/ArchiveConstants.kt
new file mode 100644
index 00000000..997d0916
--- /dev/null
+++ b/app/src/main/java/org/archiver/ArchiveConstants.kt
@@ -0,0 +1,44 @@
+package org.archiver
+
+class ArchiveConstants {
+
+ companion object{
+ const val SIGNAL_ARCHIVE_VERSION = "V1"
+
+
+ const val signalTestUserName = "signal"
+ const val signalTestPassword = "Aa!123456"
+
+ const val signalCurrentPassword = ""/*"Aa123456"*/
+ const val signalCurrentUser = "qasam"
+
+ const val signalTestMobileNumber = "+972520123456"
+ const val isTestMode = false
+ // const val signalTestMobileNumber = "+447520619489"
+ //const val signalTestMobileNumber = "+972520099696" //EnterP
+
+ const val integration = "https://integration.telemessage.co.il"
+ const val integrationKeeper = "https://api-gateway-integration.devops.telemessage.co.il"
+
+ const val charlieProduction = "https://rest.telemessage.com"
+ const val prodKeeper = "https://archive.telemessage.com"
+
+ const val ARCHIVE_TYPE_APP_MESSAGE = "App Message"
+ const val ARCHIVE_TYPE_SMS = "SMS"
+
+ const val ARCHIVE_SUBJECT_CHAT_GROUP = "chat group"
+
+ const val ARCHIVE_SUBJECT_FROM_TEXT = "from"
+ const val ARCHIVE_SUBJECT_TO_TEXT = "to"
+
+ const val ARCHIVE_FILE_FOLDER_NAME = "aa_archiver"
+
+ const val SIGNAL_ARCHIVE_ATTACHMENT_TEMPLATE_PREFIX = SIGNAL_ARCHIVE_VERSION + "_" + "Signal" + "_"
+
+ }
+
+ enum class ProtocolType(val type: String) {
+ ARCHIVE_PARAM_PROTOCOL_SEND("0"),
+ ARCHIVE_PARAM_PROTOCOL_INBOX("1")
+ }
+}
\ No newline at end of file
diff --git a/app/src/main/java/org/archiver/ArchivePreferenceConstants.kt b/app/src/main/java/org/archiver/ArchivePreferenceConstants.kt
new file mode 100644
index 00000000..6a1885fa
--- /dev/null
+++ b/app/src/main/java/org/archiver/ArchivePreferenceConstants.kt
@@ -0,0 +1,13 @@
+package org.archiver
+
+class ArchivePreferenceConstants {
+
+ companion object{
+
+ const val PREF_KEY_DEVICE_PHONE_NUMBER = "devicePhoneNumber"
+
+
+
+ }
+
+}
\ No newline at end of file
diff --git a/app/src/main/java/org/archiver/ArchiveSender.kt b/app/src/main/java/org/archiver/ArchiveSender.kt
new file mode 100644
index 00000000..4d607c4a
--- /dev/null
+++ b/app/src/main/java/org/archiver/ArchiveSender.kt
@@ -0,0 +1,123 @@
+package org.archiver
+
+import android.content.Context
+import com.tm.androidcopysdk.DataGrabber
+import org.archiver.ArchiveUtil.Companion.createMessageNameList
+import org.archiver.ArchiveUtil.Companion.createSubjectForArchiving
+import org.archiver.ArchiveUtil.Companion.createToRecipientList
+import org.archiver.ArchiveUtil.Companion.fromContactName
+import org.archiver.ArchiveUtil.Companion.getChatMode
+import org.archiver.ArchiveUtil.Companion.getChatName
+import org.archiver.ArchiveUtil.Companion.getFromPartForSubject
+import org.archiver.ArchiveUtil.Companion.getGroupInboxRecipientNumber
+import org.archiver.ArchiveUtil.Companion.groupId
+import org.thoughtcrime.securesms.mms.IncomingMediaMessage
+import org.thoughtcrime.securesms.mms.OutgoingMediaMessage
+import org.thoughtcrime.securesms.recipients.Recipient
+import org.thoughtcrime.securesms.sms.IncomingTextMessage
+import org.thoughtcrime.securesms.sms.OutgoingTextMessage
+import java.io.File
+
+class ArchiveSender {
+
+ companion object{
+
+ private fun sendArchiveMessage(context: Context, aProtocolType: ArchiveConstants.ProtocolType, toRecipientsList: Array, from: String, messageBody: String? = "", messageId: String, dateInTimeStamp: Long, subject: String, chatMode: DataGrabber.CHAT_MODE, chatName: String, chatId: String?, fromNameString: String, toRecipientsListNames: Array, archiveFile: File? = null){
+
+ if(archiveFile == null) {
+ DataGrabber.getInstance(context).setMessage(aProtocolType.type, toRecipientsList, from, messageBody, messageId, dateInTimeStamp.toString(), subject, ArchiveUtil.getPhoneNumberInTestMode(context), chatMode, chatName, chatId, fromNameString, ArchiveUtil.getPhoneNumberInTestMode(context), toRecipientsListNames, toRecipientsList)
+ }else {
+ DataGrabber.getInstance(context).setMmsMessage(aProtocolType.type, toRecipientsList, from, messageBody, messageId, dateInTimeStamp.toString(), subject, ArchiveUtil.getPhoneNumberInTestMode(context), chatMode, chatName, chatId, fromNameString, ArchiveUtil.getPhoneNumberInTestMode(context), toRecipientsListNames, toRecipientsList, archiveFile)
+ }
+ }
+
+
+ fun updateArchiveSDKToSendMMSMessage(context: Context, fileName: String, needCompress: Boolean){
+ DataGrabber.getInstance(context).updateFileMms(fileName, needCompress)
+ }
+
+ fun archiveMessageInbox(context: Context, type: ArchiveConstants.ProtocolType, archiveRecipient: Recipient, message: IncomingTextMessage, messageId: Long) {
+ val isInbox = type === ArchiveConstants.ProtocolType.ARCHIVE_PARAM_PROTOCOL_INBOX
+ val isGroup = message.groupId != null
+ var inboxRecipient = ""
+ if (archiveRecipient.isGroup) {
+ inboxRecipient = getGroupInboxRecipientNumber(archiveRecipient, message)
+ }
+ val from = getFromPartForSubject(context, isInbox, archiveRecipient, inboxRecipient)
+ val toRecipientsList = createToRecipientList(context, isInbox, archiveRecipient, from)
+ val subject = createSubjectForArchiving(context, isInbox, isGroup, archiveRecipient, inboxRecipient, false)
+ val chatMode = getChatMode(isGroup)
+ val chatName = getChatName(context, archiveRecipient)
+ val chatId = groupId(archiveRecipient)
+ val fromContactName = fromContactName(context, archiveRecipient, isInbox)
+ val toName = createMessageNameList(context, archiveRecipient, isInbox, from)
+ sendArchiveMessage(context, type, toRecipientsList, from, message.messageBody, messageId.toString(), System.currentTimeMillis(), subject, chatMode, chatName, chatId, fromContactName, toName)
+ }
+
+ fun archiveMessageOutbox(context: Context, type: ArchiveConstants.ProtocolType, archiveRecipient: Recipient, message: OutgoingTextMessage, messageId: Long) {
+ val isInbox = type === ArchiveConstants.ProtocolType.ARCHIVE_PARAM_PROTOCOL_INBOX
+ val isGroup = archiveRecipient.isGroup
+ val inboxRecipient = ""
+ /* if(isInbox && isGroup) {
+ inboxRecipient = ArchiveUtil.Companion.getGroupInboxRecipientNumber(archiveRecipient, null);
+ }*/
+ val from = getFromPartForSubject(context, isInbox, archiveRecipient, inboxRecipient)
+ val toRecipientsList = createToRecipientList(context, isInbox, archiveRecipient, from)
+ val subject = createSubjectForArchiving(context, isInbox, isGroup, archiveRecipient, inboxRecipient, false)
+ val chatMode = getChatMode(isGroup)
+ val chatName = getChatName(context, archiveRecipient)
+ val chatId = groupId(archiveRecipient)
+ val fromContactName = fromContactName(context, archiveRecipient, isInbox)
+ val toName = createMessageNameList(context, archiveRecipient, isInbox, from)
+ sendArchiveMessage(context, type, toRecipientsList, from, message.messageBody, messageId.toString(), System.currentTimeMillis(), subject, chatMode, chatName, chatId, fromContactName, toName)
+ }
+
+
+ //This method also sent sms if attachments list size is 0
+ fun archiveMessageOutboxMMS(context: Context, type: ArchiveConstants.ProtocolType, archiveRecipient: Recipient, message: OutgoingMediaMessage, messageId: Long, archiveFile: File? = null) {
+ val isInbox = type === ArchiveConstants.ProtocolType.ARCHIVE_PARAM_PROTOCOL_INBOX
+ val isGroup = archiveRecipient.isGroup
+ val inboxRecipient = ""
+ /* if(isInbox && isGroup) {
+ inboxRecipient = ArchiveUtil.Companion.getGroupInboxRecipientNumber(archiveRecipient, null);
+ }*/
+ val toRecipientList = if(!isGroup) {
+ listOf(archiveRecipient)
+ } else{
+ message.recipient.participants.filter { it.e164.isPresent }
+ }
+
+ val from = getFromPartForSubject(context, isInbox, archiveRecipient, inboxRecipient)
+ val toRecipientsList = createToRecipientList(context, isInbox,/*isGroup, archiveRecipient*/archiveRecipient, from)
+ val subject = createSubjectForArchiving(context, isInbox, isGroup, archiveRecipient, inboxRecipient, false)
+ val chatMode = getChatMode(isGroup)
+ val chatName = getChatName(context, archiveRecipient)
+ val chatId = groupId(archiveRecipient)
+ val fromContactName = fromContactName(context, archiveRecipient, isInbox)
+ val toName = createMessageNameList(context, archiveRecipient, isInbox, from)
+ sendArchiveMessage(context, type, toRecipientsList, from, message.body, messageId.toString(), System.currentTimeMillis(), subject, chatMode, chatName, chatId, fromContactName, toName, archiveFile)
+ }
+
+ //This method also sent sms if attachments list size is 0
+ fun archiveMessageInboxMMS(context: Context, type: ArchiveConstants.ProtocolType, archiveRecipient: Recipient, recipientList: MutableList, message: IncomingMediaMessage, messageId: Long, archiveFile: File? = null) {
+ val isInbox = type === ArchiveConstants.ProtocolType.ARCHIVE_PARAM_PROTOCOL_INBOX
+ val isGroup = archiveRecipient.isGroup
+ val inboxRecipient = ""
+ /* if(isInbox && isGroup) {
+ inboxRecipient = ArchiveUtil.Companion.getGroupInboxRecipientNumber(archiveRecipient, null);
+ }*/
+ val from = getFromPartForSubject(context, isInbox, archiveRecipient, inboxRecipient)
+ val toRecipientsList = createToRecipientList(context, isInbox, archiveRecipient, from)
+ val subject = createSubjectForArchiving(context, isInbox, isGroup, archiveRecipient, inboxRecipient, false)
+ val chatMode = getChatMode(isGroup)
+ val chatName = getChatName(context, archiveRecipient)
+ val chatId = groupId(archiveRecipient)
+ val fromContactName = fromContactName(context, archiveRecipient, isInbox)
+ val toName = createMessageNameList(context, archiveRecipient, isInbox, from)
+ sendArchiveMessage(context, type, toRecipientsList, from, message.body, messageId.toString(), System.currentTimeMillis(), subject, chatMode, chatName, chatId, fromContactName, toName, archiveFile)
+ }
+
+
+
+ }
+}
\ No newline at end of file
diff --git a/app/src/main/java/org/archiver/ArchiveUtil.kt b/app/src/main/java/org/archiver/ArchiveUtil.kt
new file mode 100644
index 00000000..89f466d7
--- /dev/null
+++ b/app/src/main/java/org/archiver/ArchiveUtil.kt
@@ -0,0 +1,193 @@
+package org.archiver
+
+import android.content.Context
+import com.klinker.android.send_message.Utils
+import com.tm.androidcopysdk.DataGrabber
+import com.tm.androidcopysdk.utils.PrefManager
+import org.archiver.ArchiveConstants.Companion.ARCHIVE_SUBJECT_CHAT_GROUP
+import org.archiver.ArchiveConstants.Companion.ARCHIVE_SUBJECT_FROM_TEXT
+import org.archiver.ArchiveConstants.Companion.ARCHIVE_SUBJECT_TO_TEXT
+import org.archiver.ArchiveConstants.Companion.SIGNAL_ARCHIVE_ATTACHMENT_TEMPLATE_PREFIX
+import org.archiver.ArchiveConstants.Companion.isTestMode
+import org.archiver.ArchiveConstants.Companion.signalTestMobileNumber
+import org.signal.glide.Log
+import org.thoughtcrime.securesms.recipients.Recipient
+import org.thoughtcrime.securesms.sms.IncomingTextMessage
+import org.thoughtcrime.securesms.util.Util
+import java.util.*
+
+class ArchiveUtil {
+
+ companion object{
+
+ fun createToRecipientList(context: Context, isInboxArchiveMessage: Boolean, aRecipient: Recipient, from: String): Array {
+ var recipientListFromRecipient: List = if (aRecipient.isGroup) {
+ aRecipient.participants.filter { it.e164.isPresent }.map { it.e164.get()}
+ } else {
+ if(isInboxArchiveMessage){
+ listOf(getPhoneNumberInTestMode(context))
+ }else {
+ if(aRecipient.e164.isPresent) {
+ listOf(aRecipient.e164.get().toString())
+ }else{
+ listOf("")
+ }
+ }
+ }
+
+ recipientListFromRecipient = if (!isInboxArchiveMessage) {
+ recipientListFromRecipient.filter { it != getPhoneNumberInTestMode(context) }
+ }else{
+ recipientListFromRecipient.filter { it != from }
+ }
+ return recipientListFromRecipient.toTypedArray();
+ }
+
+
+
+
+ fun createSubjectForArchiving(context: Context, isInboxArchiveMessage: Boolean, isGroup: Boolean, recipient: Recipient, inboxRecipient : String = "",forceSms : Boolean) : String{
+
+ val archiveType: String = getArchiveType(isInboxArchiveMessage, isGroup, forceSms)
+ val to = getToPartForSubject(context, isInboxArchiveMessage, recipient, isGroup)
+ val from = getFromPartForSubject(context, isInboxArchiveMessage, recipient, inboxRecipient)
+ return "$archiveType $ARCHIVE_SUBJECT_FROM_TEXT $from $ARCHIVE_SUBJECT_TO_TEXT $to"
+
+ }
+
+ private fun getToPartForSubject(context: Context, isInboxArchiveMessage: Boolean, recipient: Recipient, isGroup: Boolean): String {
+ return when {
+ isGroup -> {
+ "$ARCHIVE_SUBJECT_CHAT_GROUP ${recipient.getName(context)}"
+ }
+ isInboxArchiveMessage -> {
+ getPhoneNumberInTestMode(context)
+ }
+ else -> {
+ if(recipient.e164.isPresent) {
+ recipient.e164.get()
+ }else{
+ ""
+ }
+ }
+ }
+ }
+
+
+ fun getFromPartForSubject(context: Context, isInboxArchiveMessage: Boolean, recipient: Recipient, inboxRecipient : String = ""): String {
+ return when {
+ isInboxArchiveMessage -> {
+ if(recipient.isGroup){
+ inboxRecipient
+ }else {
+ if(recipient.e164.isPresent) {
+ recipient.e164.get()
+ }else{
+ ""
+ }
+ }
+ }else -> {
+ getPhoneNumberInTestMode(context)
+ }
+ }
+ }
+
+ fun getArchiveType(isInboxArchiveMessage: Boolean, isGroupMessage: Boolean, forceSms: Boolean): String {
+
+ return if(isInboxArchiveMessage || isGroupMessage){
+ ArchiveConstants.ARCHIVE_TYPE_APP_MESSAGE
+ }else{
+ if(forceSms){
+ ArchiveConstants.ARCHIVE_TYPE_SMS
+ }else{
+ ArchiveConstants.ARCHIVE_TYPE_APP_MESSAGE
+ }
+ }
+ }
+
+ fun getPhoneNumberInTestMode(context: Context) : String{
+ return if(isTestMode){
+ signalTestMobileNumber
+ }else{
+ PrefManager.getStringPref(context, ArchivePreferenceConstants.PREF_KEY_DEVICE_PHONE_NUMBER, "");
+ }
+ }
+
+ fun getChatMode(isGroup: Boolean) : DataGrabber.CHAT_MODE {
+ return when {
+ isGroup -> {
+ DataGrabber.CHAT_MODE.group
+ }else -> {
+ DataGrabber.CHAT_MODE.chat
+ }
+ }
+ }
+
+ fun getChatName(context: Context, recipient: Recipient): String {
+ return if(recipient.isGroup){
+ recipient.getName(context).toString()
+ }else{
+ ""
+ }
+ }
+
+ fun getGroupInboxRecipientNumber(archiveRecipient: Recipient, message: IncomingTextMessage): String {
+ val recipientList = archiveRecipient.participants.filter {
+ message.sender.toLong() == it.id.toLong()
+ }
+ return recipientList[0].e164.get()
+ }
+
+ fun groupId(recipient: Recipient): String? {
+ return if(recipient.isGroup){
+ // recipient.groupId.get().toString()
+ UUID.randomUUID().toString()
+ }else{
+ null
+ }
+ }
+
+ fun fromContactName(context: Context,recipient: Recipient, isInboxArchiveMessage: Boolean ): String {
+ return if(isInboxArchiveMessage){
+ recipient.getDisplayName(context)
+ }else{
+ Recipient.self().profileName.toString()
+ }
+
+ }
+
+ fun createMessageNameList(context: Context, recipient: Recipient, isInboxArchiveMessage: Boolean, from: String): Array {
+
+ val rl = if (!isInboxArchiveMessage) {
+ recipient.participants.filter {
+ it.e164.isPresent && it.e164.get() != getPhoneNumberInTestMode(context)
+ }
+ }else{
+ recipient.participants.filter {
+ it.e164.isPresent && it.e164.get() != from
+ }
+ }
+
+ val recipientListFromRecipient: List = if (recipient.isGroup) {
+
+ rl.map {
+ it.getDisplayName(context)
+ }
+
+ } else {
+ if(isInboxArchiveMessage){
+ listOf(Recipient.self().profileName.toString())
+ }else {
+ listOf(recipient.getDisplayName(context))
+ }
+ }
+
+ return recipientListFromRecipient.toTypedArray()
+ }
+
+ fun generateAttachmentName(attachmentId: Long, messageId: Long) : String{
+ return SIGNAL_ARCHIVE_ATTACHMENT_TEMPLATE_PREFIX + attachmentId + "_" + messageId
+ }
+
+ }
+}
\ No newline at end of file
diff --git a/app/src/main/java/org/archiver/FileUtilTestMoti.java b/app/src/main/java/org/archiver/FileUtilTestMoti.java
new file mode 100644
index 00000000..240877d4
--- /dev/null
+++ b/app/src/main/java/org/archiver/FileUtilTestMoti.java
@@ -0,0 +1,286 @@
+package org.archiver;
+
+import android.content.ContentResolver;
+import android.content.ContentUris;
+import android.content.Context;
+import android.database.Cursor;
+import android.net.Uri;
+import android.os.Build;
+import android.os.Environment;
+import android.provider.DocumentsContract;
+import android.provider.MediaStore;
+
+public class FileUtilTestMoti {
+
+
+ public static String getPath(Context context, Uri uri){
+ String[] projection = {MediaStore.MediaColumns.DATA};
+ String path = "";
+ ContentResolver cr = context.getApplicationContext().getContentResolver();
+ Cursor metaCursor = cr.query(uri, projection, null, null, null);
+ if (metaCursor != null) {
+ try {
+ if (metaCursor.moveToFirst()) {
+ path = metaCursor.getString(0);
+ }
+ } finally {
+ metaCursor.close();
+ }
+ }
+ return path;
+ }
+
+ /*
+This method can parse out the real local file path from a file URI.
+*/
+ public static String getUriRealPath(Context ctx, Uri uri)
+ {
+ String ret = "";
+
+ if( isAboveKitKat() )
+ {
+ // Android sdk version number bigger than 19.
+ ret = getUriRealPathAboveKitkat(ctx, uri);
+ }else
+ {
+ // Android sdk version number smaller than 19.
+ ret = getImageRealPath(ctx.getContentResolver(), uri, null);
+ }
+
+ return ret;
+ }
+
+ /*
+ This method will parse out the real local file path from the file content URI.
+ The method is only applied to android sdk version number that is bigger than 19.
+ */
+ public static String getUriRealPathAboveKitkat(Context ctx, Uri uri)
+ {
+ String ret = "";
+
+ if(ctx != null && uri != null) {
+
+ if(isContentUri(uri))
+ {
+ if(isGooglePhotoDoc(uri.getAuthority()))
+ {
+ ret = uri.getLastPathSegment();
+ }else {
+ ret = getImageRealPath(ctx.getContentResolver(), uri, null);
+ }
+ }else if(isFileUri(uri)) {
+ ret = uri.getPath();
+ }else if(isDocumentUri(ctx, uri)){
+
+ // Get uri related document id.
+ String documentId = DocumentsContract.getDocumentId(uri);
+
+ // Get uri authority.
+ String uriAuthority = uri.getAuthority();
+
+ if(isMediaDoc(uriAuthority))
+ {
+ String idArr[] = documentId.split(":");
+ if(idArr.length == 2)
+ {
+ // First item is document type.
+ String docType = idArr[0];
+
+ // Second item is document real id.
+ String realDocId = idArr[1];
+
+ // Get content uri by document type.
+ Uri mediaContentUri = MediaStore.Images.Media.EXTERNAL_CONTENT_URI;
+ if("image".equals(docType))
+ {
+ mediaContentUri = MediaStore.Images.Media.EXTERNAL_CONTENT_URI;
+ }else if("video".equals(docType))
+ {
+ mediaContentUri = MediaStore.Video.Media.EXTERNAL_CONTENT_URI;
+ }else if("audio".equals(docType))
+ {
+ mediaContentUri = MediaStore.Audio.Media.EXTERNAL_CONTENT_URI;
+ }
+
+ // Get where clause with real document id.
+ String whereClause = MediaStore.Images.Media._ID + " = " + realDocId;
+
+ ret = getImageRealPath(ctx.getContentResolver(), mediaContentUri, whereClause);
+ }
+
+ }else if(isDownloadDoc(uriAuthority))
+ {
+ // Build download uri.
+ Uri downloadUri = Uri.parse("content://downloads/public_downloads");
+
+ // Append download document id at uri end.
+ Uri downloadUriAppendId = ContentUris.withAppendedId(downloadUri, Long.valueOf(documentId));
+
+ ret = getImageRealPath(ctx.getContentResolver(), downloadUriAppendId, null);
+
+ }else if(isExternalStoreDoc(uriAuthority))
+ {
+ String idArr[] = documentId.split(":");
+ if(idArr.length == 2)
+ {
+ String type = idArr[0];
+ String realDocId = idArr[1];
+
+ if("primary".equalsIgnoreCase(type))
+ {
+ ret = Environment.getExternalStorageDirectory() + "/" + realDocId;
+ }
+ }
+ }
+ }
+ }
+
+ return ret;
+ }
+
+ /* Check whether current android os version is bigger than kitkat or not. */
+ public static boolean isAboveKitKat()
+ {
+ boolean ret = false;
+ ret = Build.VERSION.SDK_INT >= Build.VERSION_CODES.KITKAT;
+ return ret;
+ }
+
+ /* Check whether this uri represent a document or not. */
+ public static boolean isDocumentUri(Context ctx, Uri uri)
+ {
+ boolean ret = false;
+ if(ctx != null && uri != null) {
+ ret = DocumentsContract.isDocumentUri(ctx, uri);
+ }
+ return ret;
+ }
+
+ /* Check whether this uri is a content uri or not.
+ * content uri like content://media/external/images/media/1302716
+ * */
+ public static boolean isContentUri(Uri uri)
+ {
+ boolean ret = false;
+ if(uri != null) {
+ String uriSchema = uri.getScheme();
+ if("content".equalsIgnoreCase(uriSchema))
+ {
+ ret = true;
+ }
+ }
+ return ret;
+ }
+
+ /* Check whether this uri is a file uri or not.
+ * file uri like file:///storage/41B7-12F1/DCIM/Camera/IMG_20180211_095139.jpg
+ * */
+ public static boolean isFileUri(Uri uri)
+ {
+ boolean ret = false;
+ if(uri != null) {
+ String uriSchema = uri.getScheme();
+ if("file".equalsIgnoreCase(uriSchema))
+ {
+ ret = true;
+ }
+ }
+ return ret;
+ }
+
+
+ /* Check whether this document is provided by ExternalStorageProvider. Return true means the file is saved in external storage. */
+ public static boolean isExternalStoreDoc(String uriAuthority)
+ {
+ boolean ret = false;
+
+ if("com.android.externalstorage.documents".equals(uriAuthority))
+ {
+ ret = true;
+ }
+
+ return ret;
+ }
+
+ /* Check whether this document is provided by DownloadsProvider. return true means this file is a downloaed file. */
+ public static boolean isDownloadDoc(String uriAuthority)
+ {
+ boolean ret = false;
+
+ if("com.android.providers.downloads.documents".equals(uriAuthority))
+ {
+ ret = true;
+ }
+
+ return ret;
+ }
+
+ /*
+ Check if MediaProvider provide this document, if true means this image is created in android media app.
+ */
+ public static boolean isMediaDoc(String uriAuthority)
+ {
+ boolean ret = false;
+
+ if("com.android.providers.media.documents".equals(uriAuthority))
+ {
+ ret = true;
+ }
+
+ return ret;
+ }
+
+ /*
+ Check whether google photos provide this document, if true means this image is created in google photos app.
+ */
+ public static boolean isGooglePhotoDoc(String uriAuthority)
+ {
+ boolean ret = false;
+
+ if("com.google.android.apps.photos.content".equals(uriAuthority))
+ {
+ ret = true;
+ }
+
+ return ret;
+ }
+
+ /* Return uri represented document file real local path.*/
+ public static String getImageRealPath(ContentResolver contentResolver, Uri uri, String whereClause)
+ {
+ String ret = "";
+
+ // Query the uri with condition.
+ Cursor cursor = contentResolver.query(uri, null, whereClause, null, null);
+
+ if(cursor!=null)
+ {
+ boolean moveToFirst = cursor.moveToFirst();
+ if(moveToFirst)
+ {
+
+ // Get columns name by uri type.
+ String columnName = MediaStore.Images.Media.DATA;
+
+ if( uri==MediaStore.Images.Media.EXTERNAL_CONTENT_URI )
+ {
+ columnName = MediaStore.Images.Media.DATA;
+ }else if( uri==MediaStore.Audio.Media.EXTERNAL_CONTENT_URI )
+ {
+ columnName = MediaStore.Audio.Media.DATA;
+ }else if( uri==MediaStore.Video.Media.EXTERNAL_CONTENT_URI )
+ {
+ columnName = MediaStore.Video.Media.DATA;
+ }
+
+ // Get column index.
+ int imageColumnIndex = cursor.getColumnIndex(columnName);
+
+ // Get column value which is the uri related file local path.
+ ret = cursor.getString(imageColumnIndex);
+ }
+ }
+
+ return ret;
+ }
+}
diff --git a/app/src/main/java/org/signal/glide/Log.java b/app/src/main/java/org/signal/glide/Log.java
new file mode 100644
index 00000000..b2c6037b
--- /dev/null
+++ b/app/src/main/java/org/signal/glide/Log.java
@@ -0,0 +1,58 @@
+package org.signal.glide;
+
+import androidx.annotation.NonNull;
+import androidx.annotation.Nullable;
+
+public final class Log {
+
+ private Log() {}
+
+ public static void v(@NonNull String tag, @NonNull String message) {
+ SignalGlideCodecs.getLogProvider().v(tag, message);
+ }
+
+ public static void d(@NonNull String tag, @NonNull String message) {
+ SignalGlideCodecs.getLogProvider().d(tag, message);
+ }
+
+ public static void i(@NonNull String tag, @NonNull String message) {
+ SignalGlideCodecs.getLogProvider().i(tag, message);
+ }
+
+ public static void w(@NonNull String tag, @NonNull String message) {
+ SignalGlideCodecs.getLogProvider().w(tag, message);
+ }
+
+ public static void e(@NonNull String tag, @NonNull String message) {
+ SignalGlideCodecs.getLogProvider().e(tag, message, null);
+ }
+
+ public static void e(@NonNull String tag, @NonNull String message, @Nullable Throwable throwable) {
+ SignalGlideCodecs.getLogProvider().e(tag, message, throwable);
+ }
+
+ public interface Provider {
+ void v(@NonNull String tag, @NonNull String message);
+ void d(@NonNull String tag, @NonNull String message);
+ void i(@NonNull String tag, @NonNull String message);
+ void w(@NonNull String tag, @NonNull String message);
+ void e(@NonNull String tag, @NonNull String message, @Nullable Throwable throwable);
+
+ Provider EMPTY = new Provider() {
+ @Override
+ public void v(@NonNull String tag, @NonNull String message) { }
+
+ @Override
+ public void d(@NonNull String tag, @NonNull String message) { }
+
+ @Override
+ public void i(@NonNull String tag, @NonNull String message) { }
+
+ @Override
+ public void w(@NonNull String tag, @NonNull String message) { }
+
+ @Override
+ public void e(@NonNull String tag, @NonNull String message, @NonNull Throwable throwable) { }
+ };
+ }
+}
diff --git a/app/src/main/java/org/signal/glide/SignalGlideCodecs.java b/app/src/main/java/org/signal/glide/SignalGlideCodecs.java
new file mode 100644
index 00000000..014148a8
--- /dev/null
+++ b/app/src/main/java/org/signal/glide/SignalGlideCodecs.java
@@ -0,0 +1,18 @@
+package org.signal.glide;
+
+import androidx.annotation.NonNull;
+
+public final class SignalGlideCodecs {
+
+ private static Log.Provider logProvider = Log.Provider.EMPTY;
+
+ private SignalGlideCodecs() {}
+
+ public static void setLogProvider(@NonNull Log.Provider provider) {
+ logProvider = provider;
+ }
+
+ public static @NonNull Log.Provider getLogProvider() {
+ return logProvider;
+ }
+}
diff --git a/app/src/main/java/org/signal/glide/apng/APNGDrawable.java b/app/src/main/java/org/signal/glide/apng/APNGDrawable.java
new file mode 100644
index 00000000..021598b3
--- /dev/null
+++ b/app/src/main/java/org/signal/glide/apng/APNGDrawable.java
@@ -0,0 +1,52 @@
+/*
+ * Copyright 2019 Zhou Pengfei
+ * SPDX-License-Identifier: Apache-2.0
+ */
+
+package org.signal.glide.apng;
+
+import android.content.Context;
+
+import org.signal.glide.apng.decode.APNGDecoder;
+import org.signal.glide.common.FrameAnimationDrawable;
+import org.signal.glide.common.decode.FrameSeqDecoder;
+import org.signal.glide.common.loader.AssetStreamLoader;
+import org.signal.glide.common.loader.FileLoader;
+import org.signal.glide.common.loader.Loader;
+import org.signal.glide.common.loader.ResourceStreamLoader;
+
+/**
+ * @Description: APNGDrawable
+ * @Author: pengfei.zhou
+ * @CreateDate: 2019/3/27
+ */
+public class APNGDrawable extends FrameAnimationDrawable {
+ public APNGDrawable(Loader provider) {
+ super(provider);
+ }
+
+ public APNGDrawable(APNGDecoder decoder) {
+ super(decoder);
+ }
+
+ @Override
+ protected APNGDecoder createFrameSeqDecoder(Loader streamLoader, FrameSeqDecoder.RenderListener listener) {
+ return new APNGDecoder(streamLoader, listener);
+ }
+
+
+ public static APNGDrawable fromAsset(Context context, String assetPath) {
+ AssetStreamLoader assetStreamLoader = new AssetStreamLoader(context, assetPath);
+ return new APNGDrawable(assetStreamLoader);
+ }
+
+ public static APNGDrawable fromFile(String filePath) {
+ FileLoader fileLoader = new FileLoader(filePath);
+ return new APNGDrawable(fileLoader);
+ }
+
+ public static APNGDrawable fromResource(Context context, int resId) {
+ ResourceStreamLoader resourceStreamLoader = new ResourceStreamLoader(context, resId);
+ return new APNGDrawable(resourceStreamLoader);
+ }
+}
diff --git a/app/src/main/java/org/signal/glide/apng/decode/ACTLChunk.java b/app/src/main/java/org/signal/glide/apng/decode/ACTLChunk.java
new file mode 100644
index 00000000..37f60d90
--- /dev/null
+++ b/app/src/main/java/org/signal/glide/apng/decode/ACTLChunk.java
@@ -0,0 +1,27 @@
+/*
+ * Copyright 2019 Zhou Pengfei
+ * SPDX-License-Identifier: Apache-2.0
+ */
+
+package org.signal.glide.apng.decode;
+
+import org.signal.glide.apng.io.APNGReader;
+
+import java.io.IOException;
+
+/**
+ * @Description: https://developer.mozilla.org/en-US/docs/Mozilla/Tech/APNG#.27acTL.27:_The_Animation_Control_Chunk
+ * @Author: pengfei.zhou
+ * @CreateDate: 2019/3/27
+ */
+class ACTLChunk extends Chunk {
+ static final int ID = fourCCToInt("acTL");
+ int num_frames;
+ int num_plays;
+
+ @Override
+ void innerParse(APNGReader apngReader) throws IOException {
+ num_frames = apngReader.readInt();
+ num_plays = apngReader.readInt();
+ }
+}
diff --git a/app/src/main/java/org/signal/glide/apng/decode/APNGDecoder.java b/app/src/main/java/org/signal/glide/apng/decode/APNGDecoder.java
new file mode 100644
index 00000000..a8e50ab7
--- /dev/null
+++ b/app/src/main/java/org/signal/glide/apng/decode/APNGDecoder.java
@@ -0,0 +1,211 @@
+/*
+ * Copyright 2019 Zhou Pengfei
+ * SPDX-License-Identifier: Apache-2.0
+ */
+
+package org.signal.glide.apng.decode;
+
+import android.graphics.Bitmap;
+import android.graphics.Canvas;
+import android.graphics.Color;
+import android.graphics.Paint;
+import android.graphics.PorterDuff;
+import android.graphics.Rect;
+
+import org.signal.core.util.logging.Log;
+import org.signal.glide.apng.io.APNGReader;
+import org.signal.glide.apng.io.APNGWriter;
+import org.signal.glide.common.decode.Frame;
+import org.signal.glide.common.decode.FrameSeqDecoder;
+import org.signal.glide.common.io.Reader;
+import org.signal.glide.common.loader.Loader;
+
+import java.io.IOException;
+import java.nio.ByteBuffer;
+import java.util.ArrayList;
+import java.util.List;
+
+/**
+ * @Description: APNG4Android
+ * @Author: pengfei.zhou
+ * @CreateDate: 2019-05-13
+ */
+public class APNGDecoder extends FrameSeqDecoder {
+
+ private static final String TAG = APNGDecoder.class.getSimpleName();
+
+ private APNGWriter apngWriter;
+ private int mLoopCount;
+ private final Paint paint = new Paint();
+
+
+ private class SnapShot {
+ byte dispose_op;
+ Rect dstRect = new Rect();
+ ByteBuffer byteBuffer;
+ }
+
+ private SnapShot snapShot = new SnapShot();
+
+ /**
+ * @param loader webp的reader
+ * @param renderListener 渲染的回调
+ */
+ public APNGDecoder(Loader loader, FrameSeqDecoder.RenderListener renderListener) {
+ super(loader, renderListener);
+ paint.setAntiAlias(true);
+ }
+
+ @Override
+ protected APNGWriter getWriter() {
+ if (apngWriter == null) {
+ apngWriter = new APNGWriter();
+ }
+ return apngWriter;
+ }
+
+ @Override
+ protected APNGReader getReader(Reader reader) {
+ return new APNGReader(reader);
+ }
+
+ @Override
+ protected int getLoopCount() {
+ return mLoopCount;
+ }
+
+ @Override
+ protected void release() {
+ snapShot.byteBuffer = null;
+ apngWriter = null;
+ }
+
+
+ @Override
+ protected Rect read(APNGReader reader) throws IOException {
+ List chunks = APNGParser.parse(reader);
+ List otherChunks = new ArrayList<>();
+
+ boolean actl = false;
+ APNGFrame lastFrame = null;
+ byte[] ihdrData = new byte[0];
+ int canvasWidth = 0, canvasHeight = 0;
+ for (Chunk chunk : chunks) {
+ if (chunk instanceof ACTLChunk) {
+ mLoopCount = ((ACTLChunk) chunk).num_plays;
+ actl = true;
+ } else if (chunk instanceof FCTLChunk) {
+ APNGFrame frame = new APNGFrame(reader, (FCTLChunk) chunk);
+ frame.prefixChunks = otherChunks;
+ frame.ihdrData = ihdrData;
+ frames.add(frame);
+ lastFrame = frame;
+ } else if (chunk instanceof FDATChunk) {
+ if (lastFrame != null) {
+ lastFrame.imageChunks.add(chunk);
+ }
+ } else if (chunk instanceof IDATChunk) {
+ if (!actl) {
+ //如果为非APNG图片,则只解码PNG
+ Frame frame = new StillFrame(reader);
+ frame.frameWidth = canvasWidth;
+ frame.frameHeight = canvasHeight;
+ frames.add(frame);
+ mLoopCount = 1;
+ break;
+ }
+ if (lastFrame != null) {
+ lastFrame.imageChunks.add(chunk);
+ }
+
+ } else if (chunk instanceof IHDRChunk) {
+ canvasWidth = ((IHDRChunk) chunk).width;
+ canvasHeight = ((IHDRChunk) chunk).height;
+ ihdrData = ((IHDRChunk) chunk).data;
+ } else if (!(chunk instanceof IENDChunk)) {
+ otherChunks.add(chunk);
+ }
+ }
+ frameBuffer = ByteBuffer.allocate((canvasWidth * canvasHeight / (sampleSize * sampleSize) + 1) * 4);
+ snapShot.byteBuffer = ByteBuffer.allocate((canvasWidth * canvasHeight / (sampleSize * sampleSize) + 1) * 4);
+ return new Rect(0, 0, canvasWidth, canvasHeight);
+ }
+
+ @Override
+ protected void renderFrame(Frame frame) {
+ if (frame == null || fullRect == null) {
+ return;
+ }
+ try {
+ Bitmap bitmap = obtainBitmap(fullRect.width() / sampleSize, fullRect.height() / sampleSize);
+ Canvas canvas = cachedCanvas.get(bitmap);
+ if (canvas == null) {
+ canvas = new Canvas(bitmap);
+ cachedCanvas.put(bitmap, canvas);
+ }
+ if (frame instanceof APNGFrame) {
+ // 从缓存中恢复当前帧
+ frameBuffer.rewind();
+ bitmap.copyPixelsFromBuffer(frameBuffer);
+ // 开始绘制前,处理快照中的设定
+ if (this.frameIndex == 0) {
+ canvas.drawColor(Color.TRANSPARENT, PorterDuff.Mode.CLEAR);
+ } else {
+ canvas.save();
+ canvas.clipRect(snapShot.dstRect);
+ switch (snapShot.dispose_op) {
+ // 从快照中恢复上一帧之前的显示内容
+ case FCTLChunk.APNG_DISPOSE_OP_PREVIOUS:
+ snapShot.byteBuffer.rewind();
+ bitmap.copyPixelsFromBuffer(snapShot.byteBuffer);
+ break;
+ // 清空上一帧所画区域
+ case FCTLChunk.APNG_DISPOSE_OP_BACKGROUND:
+ canvas.drawColor(Color.TRANSPARENT, PorterDuff.Mode.CLEAR);
+ break;
+ // 什么都不做
+ case FCTLChunk.APNG_DISPOSE_OP_NON:
+ default:
+ break;
+ }
+ canvas.restore();
+ }
+
+ // 然后根据dispose设定传递到快照信息中
+ if (((APNGFrame) frame).dispose_op == FCTLChunk.APNG_DISPOSE_OP_PREVIOUS) {
+ if (snapShot.dispose_op != FCTLChunk.APNG_DISPOSE_OP_PREVIOUS) {
+ snapShot.byteBuffer.rewind();
+ bitmap.copyPixelsToBuffer(snapShot.byteBuffer);
+ }
+ }
+
+ snapShot.dispose_op = ((APNGFrame) frame).dispose_op;
+ canvas.save();
+ if (((APNGFrame) frame).blend_op == FCTLChunk.APNG_BLEND_OP_SOURCE) {
+ canvas.clipRect(
+ frame.frameX / sampleSize,
+ frame.frameY / sampleSize,
+ (frame.frameX + frame.frameWidth) / sampleSize,
+ (frame.frameY + frame.frameHeight) / sampleSize);
+ canvas.drawColor(Color.TRANSPARENT, PorterDuff.Mode.CLEAR);
+ }
+
+
+ snapShot.dstRect.set(frame.frameX / sampleSize,
+ frame.frameY / sampleSize,
+ (frame.frameX + frame.frameWidth) / sampleSize,
+ (frame.frameY + frame.frameHeight) / sampleSize);
+ canvas.restore();
+ }
+ //开始真正绘制当前帧的内容
+ Bitmap inBitmap = obtainBitmap(frame.frameWidth, frame.frameHeight);
+ recycleBitmap(frame.draw(canvas, paint, sampleSize, inBitmap, getWriter()));
+ recycleBitmap(inBitmap);
+ frameBuffer.rewind();
+ bitmap.copyPixelsToBuffer(frameBuffer);
+ recycleBitmap(bitmap);
+ } catch (Throwable t) {
+ Log.e(TAG, "Failed to render!", t);
+ }
+ }
+}
diff --git a/app/src/main/java/org/signal/glide/apng/decode/APNGFrame.java b/app/src/main/java/org/signal/glide/apng/decode/APNGFrame.java
new file mode 100644
index 00000000..fd1ca270
--- /dev/null
+++ b/app/src/main/java/org/signal/glide/apng/decode/APNGFrame.java
@@ -0,0 +1,147 @@
+/*
+ * Copyright 2019 Zhou Pengfei
+ * SPDX-License-Identifier: Apache-2.0
+ */
+
+package org.signal.glide.apng.decode;
+
+import android.graphics.Bitmap;
+import android.graphics.BitmapFactory;
+import android.graphics.Canvas;
+import android.graphics.Paint;
+
+import org.signal.glide.apng.io.APNGReader;
+import org.signal.glide.apng.io.APNGWriter;
+import org.signal.glide.common.decode.Frame;
+
+import java.io.IOException;
+import java.util.ArrayList;
+import java.util.List;
+import java.util.zip.CRC32;
+
+/**
+ * @Description: APNG4Android
+ * @Author: pengfei.zhou
+ * @CreateDate: 2019-05-13
+ */
+public class APNGFrame extends Frame {
+ public final byte blend_op;
+ public final byte dispose_op;
+ byte[] ihdrData;
+ List imageChunks = new ArrayList<>();
+ List prefixChunks = new ArrayList<>();
+ private static final byte[] sPNGSignatures = {(byte) 137, 80, 78, 71, 13, 10, 26, 10};
+ private static final byte[] sPNGEndChunk = {0, 0, 0, 0, 0x49, 0x45, 0x4E, 0x44, (byte) 0xAE, 0x42, 0x60, (byte) 0x82};
+
+ private static ThreadLocal sCRC32 = new ThreadLocal<>();
+
+ private CRC32 getCRC32() {
+ CRC32 crc32 = sCRC32.get();
+ if (crc32 == null) {
+ crc32 = new CRC32();
+ sCRC32.set(crc32);
+ }
+ return crc32;
+ }
+
+ public APNGFrame(APNGReader reader, FCTLChunk fctlChunk) {
+ super(reader);
+ blend_op = fctlChunk.blend_op;
+ dispose_op = fctlChunk.dispose_op;
+ frameDuration = fctlChunk.delay_num * 1000 / (fctlChunk.delay_den == 0 ? 100 : fctlChunk.delay_den);
+ frameWidth = fctlChunk.width;
+ frameHeight = fctlChunk.height;
+ frameX = fctlChunk.x_offset;
+ frameY = fctlChunk.y_offset;
+ }
+
+ private int encode(APNGWriter apngWriter) throws IOException {
+ int fileSize = 8 + 13 + 12;
+
+ //prefixChunks
+ for (Chunk chunk : prefixChunks) {
+ fileSize += chunk.length + 12;
+ }
+
+ //imageChunks
+ for (Chunk chunk : imageChunks) {
+ if (chunk instanceof IDATChunk) {
+ fileSize += chunk.length + 12;
+ } else if (chunk instanceof FDATChunk) {
+ fileSize += chunk.length + 8;
+ }
+ }
+ fileSize += sPNGEndChunk.length;
+ apngWriter.reset(fileSize);
+ apngWriter.putBytes(sPNGSignatures);
+ //IHDR Chunk
+ apngWriter.writeInt(13);
+ int start = apngWriter.position();
+ apngWriter.writeFourCC(IHDRChunk.ID);
+ apngWriter.writeInt(frameWidth);
+ apngWriter.writeInt(frameHeight);
+ apngWriter.putBytes(ihdrData);
+ CRC32 crc32 = getCRC32();
+ crc32.reset();
+ crc32.update(apngWriter.toByteArray(), start, 17);
+ apngWriter.writeInt((int) crc32.getValue());
+
+ //prefixChunks
+ for (Chunk chunk : prefixChunks) {
+ if (chunk instanceof IENDChunk) {
+ continue;
+ }
+ reader.reset();
+ reader.skip(chunk.offset);
+ reader.read(apngWriter.toByteArray(), apngWriter.position(), chunk.length + 12);
+ apngWriter.skip(chunk.length + 12);
+ }
+ //imageChunks
+ for (Chunk chunk : imageChunks) {
+ if (chunk instanceof IDATChunk) {
+ reader.reset();
+ reader.skip(chunk.offset);
+ reader.read(apngWriter.toByteArray(), apngWriter.position(), chunk.length + 12);
+ apngWriter.skip(chunk.length + 12);
+ } else if (chunk instanceof FDATChunk) {
+ apngWriter.writeInt(chunk.length - 4);
+ start = apngWriter.position();
+ apngWriter.writeFourCC(IDATChunk.ID);
+
+ reader.reset();
+ // skip to fdat data position
+ reader.skip(chunk.offset + 4 + 4 + 4);
+ reader.read(apngWriter.toByteArray(), apngWriter.position(), chunk.length - 4);
+
+ apngWriter.skip(chunk.length - 4);
+ crc32.reset();
+ crc32.update(apngWriter.toByteArray(), start, chunk.length);
+ apngWriter.writeInt((int) crc32.getValue());
+ }
+ }
+ //endChunk
+ apngWriter.putBytes(sPNGEndChunk);
+ return fileSize;
+ }
+
+
+ @Override
+ public Bitmap draw(Canvas canvas, Paint paint, int sampleSize, Bitmap reusedBitmap, APNGWriter writer) {
+ try {
+ int length = encode(writer);
+ BitmapFactory.Options options = new BitmapFactory.Options();
+ options.inJustDecodeBounds = false;
+ options.inSampleSize = sampleSize;
+ options.inMutable = true;
+ options.inBitmap = reusedBitmap;
+ byte[] bytes = writer.toByteArray();
+ Bitmap bitmap = BitmapFactory.decodeByteArray(bytes, 0, length, options);
+ assert bitmap != null;
+ canvas.drawBitmap(bitmap, (float) frameX / sampleSize, (float) frameY / sampleSize, paint);
+ return bitmap;
+ } catch (IOException e) {
+ e.printStackTrace();
+ }
+ return null;
+ }
+}
diff --git a/app/src/main/java/org/signal/glide/apng/decode/APNGParser.java b/app/src/main/java/org/signal/glide/apng/decode/APNGParser.java
new file mode 100644
index 00000000..04d52757
--- /dev/null
+++ b/app/src/main/java/org/signal/glide/apng/decode/APNGParser.java
@@ -0,0 +1,143 @@
+/*
+ * Copyright 2019 Zhou Pengfei
+ * SPDX-License-Identifier: Apache-2.0
+ */
+
+package org.signal.glide.apng.decode;
+
+import android.content.Context;
+
+import org.signal.glide.apng.io.APNGReader;
+import org.signal.glide.common.io.Reader;
+import org.signal.glide.common.io.StreamReader;
+
+import java.io.FileInputStream;
+import java.io.IOException;
+import java.io.InputStream;
+import java.util.ArrayList;
+import java.util.List;
+
+/**
+ * @link {https://www.w3.org/TR/PNG/#5PNG-file-signature}
+ * @Author: pengfei.zhou
+ * @CreateDate: 2019-05-13
+ */
+public class APNGParser {
+ static class FormatException extends IOException {
+ FormatException() {
+ super("APNG Format error");
+ }
+ }
+
+ public static boolean isAPNG(String filePath) {
+ InputStream inputStream = null;
+ try {
+ inputStream = new FileInputStream(filePath);
+ return isAPNG(new StreamReader(inputStream));
+ } catch (Exception e) {
+ return false;
+ } finally {
+ if (inputStream != null) {
+ try {
+ inputStream.close();
+ } catch (IOException e) {
+ e.printStackTrace();
+ }
+ }
+ }
+ }
+
+ public static boolean isAPNG(Context context, String assetPath) {
+ InputStream inputStream = null;
+ try {
+ inputStream = context.getAssets().open(assetPath);
+ return isAPNG(new StreamReader(inputStream));
+ } catch (Exception e) {
+ return false;
+ } finally {
+ if (inputStream != null) {
+ try {
+ inputStream.close();
+ } catch (IOException e) {
+ e.printStackTrace();
+ }
+ }
+ }
+ }
+
+ public static boolean isAPNG(Context context, int resId) {
+ InputStream inputStream = null;
+ try {
+ inputStream = context.getResources().openRawResource(resId);
+ return isAPNG(new StreamReader(inputStream));
+ } catch (Exception e) {
+ return false;
+ } finally {
+ if (inputStream != null) {
+ try {
+ inputStream.close();
+ } catch (IOException e) {
+ e.printStackTrace();
+ }
+ }
+ }
+ }
+
+ public static boolean isAPNG(Reader in) {
+ APNGReader reader = (in instanceof APNGReader) ? (APNGReader) in : new APNGReader(in);
+ try {
+ if (!reader.matchFourCC("\u0089PNG") || !reader.matchFourCC("\r\n\u001a\n")) {
+ throw new FormatException();
+ }
+ while (reader.available() > 0) {
+ Chunk chunk = parseChunk(reader);
+ if (chunk instanceof ACTLChunk) {
+ return true;
+ }
+ }
+ } catch (IOException e) {
+ return false;
+ }
+ return false;
+ }
+
+ public static List parse(APNGReader reader) throws IOException {
+ if (!reader.matchFourCC("\u0089PNG") || !reader.matchFourCC("\r\n\u001a\n")) {
+ throw new FormatException();
+ }
+
+ List chunks = new ArrayList<>();
+ while (reader.available() > 0) {
+ chunks.add(parseChunk(reader));
+ }
+ return chunks;
+ }
+
+ private static Chunk parseChunk(APNGReader reader) throws IOException {
+ int offset = reader.position();
+ int size = reader.readInt();
+ int fourCC = reader.readFourCC();
+ Chunk chunk;
+ if (fourCC == ACTLChunk.ID) {
+ chunk = new ACTLChunk();
+ } else if (fourCC == FCTLChunk.ID) {
+ chunk = new FCTLChunk();
+ } else if (fourCC == FDATChunk.ID) {
+ chunk = new FDATChunk();
+ } else if (fourCC == IDATChunk.ID) {
+ chunk = new IDATChunk();
+ } else if (fourCC == IENDChunk.ID) {
+ chunk = new IENDChunk();
+ } else if (fourCC == IHDRChunk.ID) {
+ chunk = new IHDRChunk();
+ } else {
+ chunk = new Chunk();
+ }
+ chunk.offset = offset;
+ chunk.fourcc = fourCC;
+ chunk.length = size;
+ chunk.parse(reader);
+ chunk.crc = reader.readInt();
+ return chunk;
+ }
+}
diff --git a/app/src/main/java/org/signal/glide/apng/decode/Chunk.java b/app/src/main/java/org/signal/glide/apng/decode/Chunk.java
new file mode 100644
index 00000000..192cc0fd
--- /dev/null
+++ b/app/src/main/java/org/signal/glide/apng/decode/Chunk.java
@@ -0,0 +1,53 @@
+/*
+ * Copyright 2019 Zhou Pengfei
+ * SPDX-License-Identifier: Apache-2.0
+ */
+
+package org.signal.glide.apng.decode;
+
+import android.text.TextUtils;
+
+import org.signal.glide.apng.io.APNGReader;
+
+import java.io.IOException;
+
+/**
+ * @Description: Length (长度) 4字节 指定数据块中数据域的长度,其长度不超过(231-1)字节
+ * Chunk Type Code (数据块类型码) 4字节 数据块类型码由ASCII字母(A-Z和a-z)组成
+ * Chunk Data (数据块数据) 可变长度 存储按照Chunk Type Code指定的数据
+ * CRC (循环冗余检测) 4字节 存储用来检测是否有错误的循环冗余码
+ * @Link https://www.w3.org/TR/PNG
+ * @Author: pengfei.zhou
+ * @CreateDate: 2019/3/27
+ */
+class Chunk {
+ int length;
+ int fourcc;
+ int crc;
+ int offset;
+
+ static int fourCCToInt(String fourCC) {
+ if (TextUtils.isEmpty(fourCC) || fourCC.length() != 4) {
+ return 0xbadeffff;
+ }
+ return (fourCC.charAt(0) & 0xff)
+ | (fourCC.charAt(1) & 0xff) << 8
+ | (fourCC.charAt(2) & 0xff) << 16
+ | (fourCC.charAt(3) & 0xff) << 24
+ ;
+ }
+
+ void parse(APNGReader reader) throws IOException {
+ int available = reader.available();
+ innerParse(reader);
+ int offset = available - reader.available();
+ if (offset > length) {
+ throw new IOException("Out of chunk area");
+ } else if (offset < length) {
+ reader.skip(length - offset);
+ }
+ }
+
+ void innerParse(APNGReader reader) throws IOException {
+ }
+}
diff --git a/app/src/main/java/org/signal/glide/apng/decode/FCTLChunk.java b/app/src/main/java/org/signal/glide/apng/decode/FCTLChunk.java
new file mode 100644
index 00000000..9e74d806
--- /dev/null
+++ b/app/src/main/java/org/signal/glide/apng/decode/FCTLChunk.java
@@ -0,0 +1,121 @@
+/*
+ * Copyright 2019 Zhou Pengfei
+ * SPDX-License-Identifier: Apache-2.0
+ */
+
+package org.signal.glide.apng.decode;
+
+import org.signal.glide.apng.io.APNGReader;
+
+import java.io.IOException;
+
+/**
+ * @Author: pengfei.zhou
+ * @CreateDate: 2019/3/27
+ * @see {link=https://developer.mozilla.org/en-US/docs/Mozilla/Tech/APNG#.27fcTL.27:_The_Frame_Control_Chunk}
+ */
+class FCTLChunk extends Chunk {
+ static final int ID = fourCCToInt("fcTL");
+ int sequence_number;
+ /**
+ * x_offset >= 0
+ * y_offset >= 0
+ * width > 0
+ * height > 0
+ * x_offset + width <= 'IHDR' width
+ * y_offset + height <= 'IHDR' height
+ */
+ /**
+ * Width of the following frame.
+ */
+ int width;
+ /**
+ * Height of the following frame.
+ */
+ int height;
+ /**
+ * X position at which to render the following frame.
+ */
+ int x_offset;
+ /**
+ * Y position at which to render the following frame.
+ */
+ int y_offset;
+
+ /**
+ * The delay_num and delay_den parameters together specify a fraction indicating the time to
+ * display the current frame, in seconds. If the denominator is 0, it is to be treated as if it
+ * were 100 (that is, delay_num then specifies 1/100ths of a second).
+ * If the the value of the numerator is 0 the decoder should render the next frame as quickly as
+ * possible, though viewers may impose a reasonable lower bound.
+ *
+ * Frame timings should be independent of the time required for decoding and display of each frame,
+ * so that animations will run at the same rate regardless of the performance of the decoder implementation.
+ */
+
+ /**
+ * Frame delay fraction numerator.
+ */
+ short delay_num;
+
+ /**
+ * Frame delay fraction denominator.
+ */
+ short delay_den;
+
+ /**
+ * Type of frame area disposal to be done after rendering this frame.
+ * dispose_op specifies how the output buffer should be changed at the end of the delay (before rendering the next frame).
+ * If the first 'fcTL' chunk uses a dispose_op of APNG_DISPOSE_OP_PREVIOUS it should be treated as APNG_DISPOSE_OP_BACKGROUND.
+ */
+ byte dispose_op;
+
+ /**
+ * Type of frame area rendering for this frame.
+ */
+ byte blend_op;
+
+ /**
+ * No disposal is done on this frame before rendering the next; the contents of the output buffer are left as is.
+ */
+ static final int APNG_DISPOSE_OP_NON = 0;
+
+ /**
+ * The frame's region of the output buffer is to be cleared to fully transparent black before rendering the next frame.
+ */
+ static final int APNG_DISPOSE_OP_BACKGROUND = 1;
+
+ /**
+ * The frame's region of the output buffer is to be reverted to the previous contents before rendering the next frame.
+ */
+ static final int APNG_DISPOSE_OP_PREVIOUS = 2;
+
+ /**
+ * blend_op specifies whether the frame is to be alpha blended into the current output buffer content,
+ * or whether it should completely replace its region in the output buffer.
+ */
+ /**
+ * All color components of the frame, including alpha, overwrite the current contents of the frame's output buffer region.
+ */
+ static final int APNG_BLEND_OP_SOURCE = 0;
+
+ /**
+ * The frame should be composited onto the output buffer based on its alpha,
+ * using a simple OVER operation as described in the Alpha Channel Processing section of the Extensions
+ * to the PNG Specification, Version 1.2.0. Note that the second variation of the sample code is applicable.
+ */
+ static final int APNG_BLEND_OP_OVER = 1;
+
+ @Override
+ void innerParse(APNGReader reader) throws IOException {
+ sequence_number = reader.readInt();
+ width = reader.readInt();
+ height = reader.readInt();
+ x_offset = reader.readInt();
+ y_offset = reader.readInt();
+ delay_num = reader.readShort();
+ delay_den = reader.readShort();
+ dispose_op = reader.peek();
+ blend_op = reader.peek();
+ }
+}
diff --git a/app/src/main/java/org/signal/glide/apng/decode/FDATChunk.java b/app/src/main/java/org/signal/glide/apng/decode/FDATChunk.java
new file mode 100644
index 00000000..1618c59d
--- /dev/null
+++ b/app/src/main/java/org/signal/glide/apng/decode/FDATChunk.java
@@ -0,0 +1,25 @@
+/*
+ * Copyright 2019 Zhou Pengfei
+ * SPDX-License-Identifier: Apache-2.0
+ */
+
+package org.signal.glide.apng.decode;
+
+import org.signal.glide.apng.io.APNGReader;
+
+import java.io.IOException;
+
+/**
+ * @Description: https://developer.mozilla.org/en-US/docs/Mozilla/Tech/APNG#.27fdAT.27:_The_Frame_Data_Chunk
+ * @Author: pengfei.zhou
+ * @CreateDate: 2019/3/27
+ */
+class FDATChunk extends Chunk {
+ static final int ID = fourCCToInt("fdAT");
+ int sequence_number;
+
+ @Override
+ void innerParse(APNGReader reader) throws IOException {
+ sequence_number = reader.readInt();
+ }
+}
diff --git a/app/src/main/java/org/signal/glide/apng/decode/IDATChunk.java b/app/src/main/java/org/signal/glide/apng/decode/IDATChunk.java
new file mode 100644
index 00000000..bd7a60fe
--- /dev/null
+++ b/app/src/main/java/org/signal/glide/apng/decode/IDATChunk.java
@@ -0,0 +1,15 @@
+/*
+ * Copyright 2019 Zhou Pengfei
+ * SPDX-License-Identifier: Apache-2.0
+ */
+
+package org.signal.glide.apng.decode;
+
+/**
+ * @Description: 作用描述
+ * @Author: pengfei.zhou
+ * @CreateDate: 2019/3/27
+ */
+class IDATChunk extends Chunk {
+ static final int ID = fourCCToInt("IDAT");
+}
diff --git a/app/src/main/java/org/signal/glide/apng/decode/IENDChunk.java b/app/src/main/java/org/signal/glide/apng/decode/IENDChunk.java
new file mode 100644
index 00000000..f0cbd800
--- /dev/null
+++ b/app/src/main/java/org/signal/glide/apng/decode/IENDChunk.java
@@ -0,0 +1,15 @@
+/*
+ * Copyright 2019 Zhou Pengfei
+ * SPDX-License-Identifier: Apache-2.0
+ */
+
+package org.signal.glide.apng.decode;
+
+/**
+ * @Description: 作用描述
+ * @Author: pengfei.zhou
+ * @CreateDate: 2019/3/27
+ */
+class IENDChunk extends Chunk {
+ static final int ID = Chunk.fourCCToInt("IEND");
+}
diff --git a/app/src/main/java/org/signal/glide/apng/decode/IHDRChunk.java b/app/src/main/java/org/signal/glide/apng/decode/IHDRChunk.java
new file mode 100644
index 00000000..eebd9d27
--- /dev/null
+++ b/app/src/main/java/org/signal/glide/apng/decode/IHDRChunk.java
@@ -0,0 +1,45 @@
+/*
+ * Copyright 2019 Zhou Pengfei
+ * SPDX-License-Identifier: Apache-2.0
+ */
+
+package org.signal.glide.apng.decode;
+
+import org.signal.glide.apng.io.APNGReader;
+
+import java.io.IOException;
+
+/**
+ * The IHDR chunk shall be the first chunk in the PNG datastream. It contains:
+ *
+ * Width 4 bytes
+ * Height 4 bytes
+ * Bit depth 1 byte
+ * Colour type 1 byte
+ * Compression method 1 byte
+ * Filter method 1 byte
+ * Interlace method 1 byte
+ *
+ * @Author: pengfei.zhou
+ * @CreateDate: 2019/3/27
+ */
+class IHDRChunk extends Chunk {
+ static final int ID = fourCCToInt("IHDR");
+ /**
+ * 图像宽度,以像素为单位
+ */
+ int width;
+ /**
+ * 图像高度,以像素为单位
+ */
+ int height;
+
+ byte[] data = new byte[5];
+
+ @Override
+ void innerParse(APNGReader reader) throws IOException {
+ width = reader.readInt();
+ height = reader.readInt();
+ reader.read(data, 0, data.length);
+ }
+}
diff --git a/app/src/main/java/org/signal/glide/apng/decode/StillFrame.java b/app/src/main/java/org/signal/glide/apng/decode/StillFrame.java
new file mode 100644
index 00000000..65715f1e
--- /dev/null
+++ b/app/src/main/java/org/signal/glide/apng/decode/StillFrame.java
@@ -0,0 +1,49 @@
+/*
+ * Copyright 2019 Zhou Pengfei
+ * SPDX-License-Identifier: Apache-2.0
+ */
+
+package org.signal.glide.apng.decode;
+
+import android.graphics.Bitmap;
+import android.graphics.BitmapFactory;
+import android.graphics.Canvas;
+import android.graphics.Paint;
+
+import org.signal.glide.apng.io.APNGReader;
+import org.signal.glide.apng.io.APNGWriter;
+import org.signal.glide.common.decode.Frame;
+
+import java.io.IOException;
+
+/**
+ * @Description: APNG4Android
+ * @Author: pengfei.zhou
+ * @CreateDate: 2019-05-13
+ */
+public class StillFrame extends Frame {
+
+ public StillFrame(APNGReader reader) {
+ super(reader);
+ }
+
+ @Override
+ public Bitmap draw(Canvas canvas, Paint paint, int sampleSize, Bitmap reusedBitmap, APNGWriter writer) {
+ BitmapFactory.Options options = new BitmapFactory.Options();
+ options.inJustDecodeBounds = false;
+ options.inSampleSize = sampleSize;
+ options.inMutable = true;
+ options.inBitmap = reusedBitmap;
+ Bitmap bitmap = null;
+ try {
+ reader.reset();
+ bitmap = BitmapFactory.decodeStream(reader.toInputStream(), null, options);
+ assert bitmap != null;
+ paint.setXfermode(null);
+ canvas.drawBitmap(bitmap, 0, 0, paint);
+ } catch (IOException e) {
+ e.printStackTrace();
+ }
+ return bitmap;
+ }
+}
diff --git a/app/src/main/java/org/signal/glide/apng/io/APNGReader.java b/app/src/main/java/org/signal/glide/apng/io/APNGReader.java
new file mode 100644
index 00000000..293ed246
--- /dev/null
+++ b/app/src/main/java/org/signal/glide/apng/io/APNGReader.java
@@ -0,0 +1,74 @@
+/*
+ * Copyright 2019 Zhou Pengfei
+ * SPDX-License-Identifier: Apache-2.0
+ */
+
+package org.signal.glide.apng.io;
+
+import android.text.TextUtils;
+
+import org.signal.glide.common.io.FilterReader;
+import org.signal.glide.common.io.Reader;
+
+import java.io.IOException;
+
+/**
+ * @Description: APNGReader
+ * @Author: pengfei.zhou
+ * @CreateDate: 2019-05-13
+ */
+public class APNGReader extends FilterReader {
+ private static ThreadLocal __intBytes = new ThreadLocal<>();
+
+
+ protected static byte[] ensureBytes() {
+ byte[] bytes = __intBytes.get();
+ if (bytes == null) {
+ bytes = new byte[4];
+ __intBytes.set(bytes);
+ }
+ return bytes;
+ }
+
+ public APNGReader(Reader in) {
+ super(in);
+ }
+
+ public int readInt() throws IOException {
+ byte[] buf = ensureBytes();
+ read(buf, 0, 4);
+ return buf[3] & 0xFF |
+ (buf[2] & 0xFF) << 8 |
+ (buf[1] & 0xFF) << 16 |
+ (buf[0] & 0xFF) << 24;
+ }
+
+ public short readShort() throws IOException {
+ byte[] buf = ensureBytes();
+ read(buf, 0, 2);
+ return (short) (buf[1] & 0xFF |
+ (buf[0] & 0xFF) << 8);
+ }
+
+ /**
+ * @return read FourCC and match chars
+ */
+ public boolean matchFourCC(String chars) throws IOException {
+ if (TextUtils.isEmpty(chars) || chars.length() != 4) {
+ return false;
+ }
+ int fourCC = readFourCC();
+ for (int i = 0; i < 4; i++) {
+ if (((fourCC >> (i * 8)) & 0xff) != chars.charAt(i)) {
+ return false;
+ }
+ }
+ return true;
+ }
+
+ public int readFourCC() throws IOException {
+ byte[] buf = ensureBytes();
+ read(buf, 0, 4);
+ return buf[0] & 0xff | (buf[1] & 0xff) << 8 | (buf[2] & 0xff) << 16 | (buf[3] & 0xff) << 24;
+ }
+}
diff --git a/app/src/main/java/org/signal/glide/apng/io/APNGWriter.java b/app/src/main/java/org/signal/glide/apng/io/APNGWriter.java
new file mode 100644
index 00000000..25a7c276
--- /dev/null
+++ b/app/src/main/java/org/signal/glide/apng/io/APNGWriter.java
@@ -0,0 +1,41 @@
+/*
+ * Copyright 2019 Zhou Pengfei
+ * SPDX-License-Identifier: Apache-2.0
+ */
+
+package org.signal.glide.apng.io;
+
+import org.signal.glide.common.io.ByteBufferWriter;
+
+import java.nio.ByteOrder;
+
+/**
+ * @Description: APNGWriter
+ * @Author: pengfei.zhou
+ * @CreateDate: 2019-05-13
+ */
+public class APNGWriter extends ByteBufferWriter {
+ public APNGWriter() {
+ super();
+ }
+
+ public void writeFourCC(int val) {
+ putByte((byte) (val & 0xff));
+ putByte((byte) ((val >> 8) & 0xff));
+ putByte((byte) ((val >> 16) & 0xff));
+ putByte((byte) ((val >> 24) & 0xff));
+ }
+
+ public void writeInt(int val) {
+ putByte((byte) ((val >> 24) & 0xff));
+ putByte((byte) ((val >> 16) & 0xff));
+ putByte((byte) ((val >> 8) & 0xff));
+ putByte((byte) (val & 0xff));
+ }
+
+ @Override
+ public void reset(int size) {
+ super.reset(size);
+ this.byteBuffer.order(ByteOrder.BIG_ENDIAN);
+ }
+}
diff --git a/app/src/main/java/org/signal/glide/common/FrameAnimationDrawable.java b/app/src/main/java/org/signal/glide/common/FrameAnimationDrawable.java
new file mode 100644
index 00000000..7f2353fa
--- /dev/null
+++ b/app/src/main/java/org/signal/glide/common/FrameAnimationDrawable.java
@@ -0,0 +1,253 @@
+/*
+ * Copyright 2019 Zhou Pengfei
+ * SPDX-License-Identifier: Apache-2.0
+ */
+
+package org.signal.glide.common;
+
+import android.graphics.Bitmap;
+import android.graphics.Canvas;
+import android.graphics.ColorFilter;
+import android.graphics.DrawFilter;
+import android.graphics.Matrix;
+import android.graphics.Paint;
+import android.graphics.PaintFlagsDrawFilter;
+import android.graphics.PixelFormat;
+import android.graphics.drawable.Drawable;
+import android.os.Handler;
+import android.os.Looper;
+import android.os.Message;
+
+import androidx.annotation.NonNull;
+import androidx.vectordrawable.graphics.drawable.Animatable2Compat;
+
+import org.signal.core.util.logging.Log;
+import org.signal.glide.common.decode.FrameSeqDecoder;
+import org.signal.glide.common.loader.Loader;
+
+import java.nio.ByteBuffer;
+import java.util.HashSet;
+import java.util.Set;
+
+/**
+ * @Description: Frame animation drawable
+ * @Author: pengfei.zhou
+ * @CreateDate: 2019/3/27
+ */
+public abstract class FrameAnimationDrawable extends Drawable implements Animatable2Compat, FrameSeqDecoder.RenderListener {
+ private static final String TAG = FrameAnimationDrawable.class.getSimpleName();
+ private final Paint paint = new Paint();
+ private final Decoder frameSeqDecoder;
+ private DrawFilter drawFilter = new PaintFlagsDrawFilter(0, Paint.ANTI_ALIAS_FLAG | Paint.FILTER_BITMAP_FLAG);
+ private Matrix matrix = new Matrix();
+ private Set animationCallbacks = new HashSet<>();
+ private Bitmap bitmap;
+ private static final int MSG_ANIMATION_START = 1;
+ private static final int MSG_ANIMATION_END = 2;
+ private Handler uiHandler = new Handler(Looper.getMainLooper()) {
+ @Override
+ public void handleMessage(Message msg) {
+ switch (msg.what) {
+ case MSG_ANIMATION_START:
+ for (AnimationCallback animationCallback : animationCallbacks) {
+ animationCallback.onAnimationStart(FrameAnimationDrawable.this);
+ }
+ break;
+ case MSG_ANIMATION_END:
+ for (AnimationCallback animationCallback : animationCallbacks) {
+ animationCallback.onAnimationEnd(FrameAnimationDrawable.this);
+ }
+ break;
+ }
+ }
+ };
+ private Runnable invalidateRunnable = new Runnable() {
+ @Override
+ public void run() {
+ invalidateSelf();
+ }
+ };
+ private boolean autoPlay = true;
+
+ public FrameAnimationDrawable(Decoder frameSeqDecoder) {
+ paint.setAntiAlias(true);
+ this.frameSeqDecoder = frameSeqDecoder;
+ }
+
+ public FrameAnimationDrawable(Loader provider) {
+ paint.setAntiAlias(true);
+ this.frameSeqDecoder = createFrameSeqDecoder(provider, this);
+ }
+
+ public void setAutoPlay(boolean autoPlay) {
+ this.autoPlay = autoPlay;
+ }
+
+ protected abstract Decoder createFrameSeqDecoder(Loader streamLoader, FrameSeqDecoder.RenderListener listener);
+
+ /**
+ * @param loopLimit <=0为无限播放,>0为实际播放次数
+ */
+ public void setLoopLimit(int loopLimit) {
+ frameSeqDecoder.setLoopLimit(loopLimit);
+ }
+
+ public void reset() {
+ frameSeqDecoder.reset();
+ }
+
+ public void pause() {
+ frameSeqDecoder.pause();
+ }
+
+ public void resume() {
+ frameSeqDecoder.resume();
+ }
+
+ public boolean isPaused() {
+ return frameSeqDecoder.isPaused();
+ }
+
+ @Override
+ public void start() {
+ if (autoPlay) {
+ frameSeqDecoder.start();
+ } else {
+ this.frameSeqDecoder.addRenderListener(this);
+ if (!this.frameSeqDecoder.isRunning()) {
+ this.frameSeqDecoder.start();
+ }
+ }
+ }
+
+ @Override
+ public void stop() {
+ if (autoPlay) {
+ frameSeqDecoder.stop();
+ } else {
+ this.frameSeqDecoder.removeRenderListener(this);
+ this.frameSeqDecoder.stopIfNeeded();
+ }
+ }
+
+ @Override
+ public boolean isRunning() {
+ return frameSeqDecoder.isRunning();
+ }
+
+ @Override
+ public void draw(Canvas canvas) {
+ if (bitmap == null || bitmap.isRecycled()) {
+ return;
+ }
+ canvas.setDrawFilter(drawFilter);
+ canvas.drawBitmap(bitmap, matrix, paint);
+ }
+
+ @Override
+ public void setBounds(int left, int top, int right, int bottom) {
+ super.setBounds(left, top, right, bottom);
+ boolean sampleSizeChanged = frameSeqDecoder.setDesiredSize(getBounds().width(), getBounds().height());
+ matrix.setScale(
+ 1.0f * getBounds().width() * frameSeqDecoder.getSampleSize() / frameSeqDecoder.getBounds().width(),
+ 1.0f * getBounds().height() * frameSeqDecoder.getSampleSize() / frameSeqDecoder.getBounds().height());
+
+ if (sampleSizeChanged)
+ this.bitmap = Bitmap.createBitmap(
+ frameSeqDecoder.getBounds().width() / frameSeqDecoder.getSampleSize(),
+ frameSeqDecoder.getBounds().height() / frameSeqDecoder.getSampleSize(),
+ Bitmap.Config.ARGB_8888);
+ }
+
+ @Override
+ public void setAlpha(int alpha) {
+ paint.setAlpha(alpha);
+ }
+
+ @Override
+ public void setColorFilter(ColorFilter colorFilter) {
+ paint.setColorFilter(colorFilter);
+ }
+
+ @Override
+ public int getOpacity() {
+ return PixelFormat.TRANSLUCENT;
+ }
+
+ @Override
+ public void onStart() {
+ Message.obtain(uiHandler, MSG_ANIMATION_START).sendToTarget();
+ }
+
+ @Override
+ public void onRender(ByteBuffer byteBuffer) {
+ if (!isRunning()) {
+ return;
+ }
+ if (this.bitmap == null || this.bitmap.isRecycled()) {
+ this.bitmap = Bitmap.createBitmap(
+ frameSeqDecoder.getBounds().width() / frameSeqDecoder.getSampleSize(),
+ frameSeqDecoder.getBounds().height() / frameSeqDecoder.getSampleSize(),
+ Bitmap.Config.ARGB_8888);
+ }
+ byteBuffer.rewind();
+ if (byteBuffer.remaining() < this.bitmap.getByteCount()) {
+ Log.e(TAG, "onRender:Buffer not large enough for pixels");
+ return;
+ }
+ this.bitmap.copyPixelsFromBuffer(byteBuffer);
+ uiHandler.post(invalidateRunnable);
+ }
+
+ @Override
+ public void onEnd() {
+ Message.obtain(uiHandler, MSG_ANIMATION_END).sendToTarget();
+ }
+
+ @Override
+ public boolean setVisible(boolean visible, boolean restart) {
+ if (this.autoPlay) {
+ if (visible) {
+ if (!isRunning()) {
+ start();
+ }
+ } else if (isRunning()) {
+ stop();
+ }
+ }
+ return super.setVisible(visible, restart);
+ }
+
+ @Override
+ public int getIntrinsicWidth() {
+ try {
+ return frameSeqDecoder.getBounds().width();
+ } catch (Exception exception) {
+ return 0;
+ }
+ }
+
+ @Override
+ public int getIntrinsicHeight() {
+ try {
+ return frameSeqDecoder.getBounds().height();
+ } catch (Exception exception) {
+ return 0;
+ }
+ }
+
+ @Override
+ public void registerAnimationCallback(@NonNull AnimationCallback animationCallback) {
+ this.animationCallbacks.add(animationCallback);
+ }
+
+ @Override
+ public boolean unregisterAnimationCallback(@NonNull AnimationCallback animationCallback) {
+ return this.animationCallbacks.remove(animationCallback);
+ }
+
+ @Override
+ public void clearAnimationCallbacks() {
+ this.animationCallbacks.clear();
+ }
+}
diff --git a/app/src/main/java/org/signal/glide/common/decode/Frame.java b/app/src/main/java/org/signal/glide/common/decode/Frame.java
new file mode 100644
index 00000000..e7fd5e96
--- /dev/null
+++ b/app/src/main/java/org/signal/glide/common/decode/Frame.java
@@ -0,0 +1,33 @@
+/*
+ * Copyright 2019 Zhou Pengfei
+ * SPDX-License-Identifier: Apache-2.0
+ */
+
+package org.signal.glide.common.decode;
+
+import android.graphics.Bitmap;
+import android.graphics.Canvas;
+import android.graphics.Paint;
+
+import org.signal.glide.common.io.Reader;
+import org.signal.glide.common.io.Writer;
+
+/**
+ * @Description: One frame in an animation
+ * @Author: pengfei.zhou
+ * @CreateDate: 2019-05-13
+ */
+public abstract class Frame {
+ protected final R reader;
+ public int frameWidth;
+ public int frameHeight;
+ public int frameX;
+ public int frameY;
+ public int frameDuration;
+
+ public Frame(R reader) {
+ this.reader = reader;
+ }
+
+ public abstract Bitmap draw(Canvas canvas, Paint paint, int sampleSize, Bitmap reusedBitmap, W writer);
+}
diff --git a/app/src/main/java/org/signal/glide/common/decode/FrameSeqDecoder.java b/app/src/main/java/org/signal/glide/common/decode/FrameSeqDecoder.java
new file mode 100644
index 00000000..87ea2e3a
--- /dev/null
+++ b/app/src/main/java/org/signal/glide/common/decode/FrameSeqDecoder.java
@@ -0,0 +1,539 @@
+/*
+ * Copyright 2019 Zhou Pengfei
+ * SPDX-License-Identifier: Apache-2.0
+ */
+
+package org.signal.glide.common.decode;
+
+import android.graphics.Bitmap;
+import android.graphics.Canvas;
+import android.graphics.Rect;
+import android.os.Build;
+import android.os.Handler;
+import android.os.Looper;
+
+import androidx.annotation.Nullable;
+import androidx.annotation.WorkerThread;
+
+import org.signal.core.util.logging.Log;
+import org.signal.glide.common.executor.FrameDecoderExecutor;
+import org.signal.glide.common.io.Reader;
+import org.signal.glide.common.io.Writer;
+import org.signal.glide.common.loader.Loader;
+
+import java.io.IOException;
+import java.nio.ByteBuffer;
+import java.util.ArrayList;
+import java.util.HashSet;
+import java.util.Iterator;
+import java.util.List;
+import java.util.Map;
+import java.util.Set;
+import java.util.WeakHashMap;
+import java.util.concurrent.atomic.AtomicBoolean;
+import java.util.concurrent.locks.LockSupport;
+
+/**
+ * @Description: Abstract Frame Animation Decoder
+ * @Author: pengfei.zhou
+ * @CreateDate: 2019/3/27
+ */
+public abstract class FrameSeqDecoder {
+ private static final String TAG = FrameSeqDecoder.class.getSimpleName();
+ private final int taskId;
+
+ private final Loader mLoader;
+ private final Handler workerHandler;
+ protected List frames = new ArrayList<>();
+ protected int frameIndex = -1;
+ private int playCount;
+ private Integer loopLimit = null;
+ private Set renderListeners = new HashSet<>();
+ private AtomicBoolean paused = new AtomicBoolean(true);
+ private static final Rect RECT_EMPTY = new Rect();
+ private Runnable renderTask = new Runnable() {
+ @Override
+ public void run() {
+ if (paused.get()) {
+ return;
+ }
+ if (canStep()) {
+ long start = System.currentTimeMillis();
+ long delay = step();
+ long cost = System.currentTimeMillis() - start;
+ workerHandler.postDelayed(this, Math.max(0, delay - cost));
+ for (RenderListener renderListener : renderListeners) {
+ renderListener.onRender(frameBuffer);
+ }
+ } else {
+ stop();
+ }
+ }
+ };
+ protected int sampleSize = 1;
+
+ private Set cacheBitmaps = new HashSet<>();
+ protected Map cachedCanvas = new WeakHashMap<>();
+ protected ByteBuffer frameBuffer;
+ protected volatile Rect fullRect;
+ private W mWriter = getWriter();
+ private R mReader = null;
+
+ /**
+ * If played all the needed
+ */
+ private boolean finished = false;
+
+ private enum State {
+ IDLE,
+ RUNNING,
+ INITIALIZING,
+ FINISHING,
+ }
+
+ private volatile State mState = State.IDLE;
+
+ public Loader getLoader() {
+ return mLoader;
+ }
+
+ protected abstract W getWriter();
+
+ protected abstract R getReader(Reader reader);
+
+ protected Bitmap obtainBitmap(int width, int height) {
+ Bitmap ret = null;
+ Iterator iterator = cacheBitmaps.iterator();
+ while (iterator.hasNext()) {
+ int reuseSize = width * height * 4;
+ ret = iterator.next();
+
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.KITKAT) {
+ if (ret != null && ret.getAllocationByteCount() >= reuseSize) {
+ iterator.remove();
+ if (ret.getWidth() != width || ret.getHeight() != height) {
+ ret.reconfigure(width, height, Bitmap.Config.ARGB_8888);
+ }
+ ret.eraseColor(0);
+ return ret;
+ }
+ } else {
+ if (ret != null && ret.getByteCount() >= reuseSize) {
+ if (ret.getWidth() == width && ret.getHeight() == height) {
+ iterator.remove();
+ ret.eraseColor(0);
+ }
+ return ret;
+ }
+ }
+ }
+
+ try {
+ Bitmap.Config config = Bitmap.Config.ARGB_8888;
+ ret = Bitmap.createBitmap(width, height, config);
+ } catch (OutOfMemoryError e) {
+ e.printStackTrace();
+ }
+ return ret;
+ }
+
+ protected void recycleBitmap(Bitmap bitmap) {
+ if (bitmap != null && !cacheBitmaps.contains(bitmap)) {
+ cacheBitmaps.add(bitmap);
+ }
+ }
+
+ /**
+ * 解码器的渲染回调
+ */
+ public interface RenderListener {
+ /**
+ * 播放开始
+ */
+ void onStart();
+
+ /**
+ * 帧播放
+ */
+ void onRender(ByteBuffer byteBuffer);
+
+ /**
+ * 播放结束
+ */
+ void onEnd();
+ }
+
+
+ /**
+ * @param loader webp的reader
+ * @param renderListener 渲染的回调
+ */
+ public FrameSeqDecoder(Loader loader, @Nullable RenderListener renderListener) {
+ this.mLoader = loader;
+ if (renderListener != null) {
+ this.renderListeners.add(renderListener);
+ }
+ this.taskId = FrameDecoderExecutor.getInstance().generateTaskId();
+ this.workerHandler = new Handler(FrameDecoderExecutor.getInstance().getLooper(taskId));
+ }
+
+
+ public void addRenderListener(final RenderListener renderListener) {
+ this.workerHandler.post(new Runnable() {
+ @Override
+ public void run() {
+ renderListeners.add(renderListener);
+ }
+ });
+ }
+
+ public void removeRenderListener(final RenderListener renderListener) {
+ this.workerHandler.post(new Runnable() {
+ @Override
+ public void run() {
+ renderListeners.remove(renderListener);
+ }
+ });
+ }
+
+ public void stopIfNeeded() {
+ this.workerHandler.post(new Runnable() {
+ @Override
+ public void run() {
+ if (renderListeners.size() == 0) {
+ stop();
+ }
+ }
+ });
+ }
+
+ public Rect getBounds() {
+ if (fullRect == null) {
+ if (mState == State.FINISHING) {
+ Log.e(TAG, "In finishing,do not interrupt");
+ }
+ final Thread thread = Thread.currentThread();
+ workerHandler.post(new Runnable() {
+ @Override
+ public void run() {
+ try {
+ if (fullRect == null) {
+ if (mReader == null) {
+ mReader = getReader(mLoader.obtain());
+ } else {
+ mReader.reset();
+ }
+ initCanvasBounds(read(mReader));
+ }
+ } catch (Exception e) {
+ e.printStackTrace();
+ fullRect = RECT_EMPTY;
+ } finally {
+ LockSupport.unpark(thread);
+ }
+ }
+ });
+ LockSupport.park(thread);
+ }
+ return fullRect;
+ }
+
+ private void initCanvasBounds(Rect rect) {
+ fullRect = rect;
+ frameBuffer = ByteBuffer.allocate((rect.width() * rect.height() / (sampleSize * sampleSize) + 1) * 4);
+ if (mWriter == null) {
+ mWriter = getWriter();
+ }
+ }
+
+
+ private int getFrameCount() {
+ return this.frames.size();
+ }
+
+ /**
+ * @return Loop Count defined in file
+ */
+ protected abstract int getLoopCount();
+
+ public void start() {
+ if (fullRect == RECT_EMPTY) {
+ return;
+ }
+ if (mState == State.RUNNING || mState == State.INITIALIZING) {
+ Log.i(TAG, debugInfo() + " Already started");
+ return;
+ }
+ if (mState == State.FINISHING) {
+ Log.e(TAG, debugInfo() + " Processing,wait for finish at " + mState);
+ }
+ mState = State.INITIALIZING;
+ if (Looper.myLooper() == workerHandler.getLooper()) {
+ innerStart();
+ } else {
+ workerHandler.post(new Runnable() {
+ @Override
+ public void run() {
+ innerStart();
+ }
+ });
+ }
+ }
+
+ @WorkerThread
+ private void innerStart() {
+ paused.compareAndSet(true, false);
+
+ final long start = System.currentTimeMillis();
+ try {
+ if (frames.size() == 0) {
+ try {
+ if (mReader == null) {
+ mReader = getReader(mLoader.obtain());
+ } else {
+ mReader.reset();
+ }
+ initCanvasBounds(read(mReader));
+ } catch (Throwable e) {
+ e.printStackTrace();
+ }
+ }
+ } finally {
+ Log.i(TAG, debugInfo() + " Set state to RUNNING,cost " + (System.currentTimeMillis() - start));
+ mState = State.RUNNING;
+ }
+ if (getNumPlays() == 0 || !finished) {
+ this.frameIndex = -1;
+ renderTask.run();
+ for (RenderListener renderListener : renderListeners) {
+ renderListener.onStart();
+ }
+ } else {
+ Log.i(TAG, debugInfo() + " No need to started");
+ }
+ }
+
+ @WorkerThread
+ private void innerStop() {
+ workerHandler.removeCallbacks(renderTask);
+ frames.clear();
+ for (Bitmap bitmap : cacheBitmaps) {
+ if (bitmap != null && !bitmap.isRecycled()) {
+ bitmap.recycle();
+ }
+ }
+ cacheBitmaps.clear();
+ if (frameBuffer != null) {
+ frameBuffer = null;
+ }
+ cachedCanvas.clear();
+ try {
+ if (mReader != null) {
+ mReader.close();
+ mReader = null;
+ }
+ if (mWriter != null) {
+ mWriter.close();
+ }
+ } catch (IOException e) {
+ e.printStackTrace();
+ }
+ release();
+ mState = State.IDLE;
+ for (RenderListener renderListener : renderListeners) {
+ renderListener.onEnd();
+ }
+ }
+
+ public void stop() {
+ if (fullRect == RECT_EMPTY) {
+ return;
+ }
+ if (mState == State.FINISHING || mState == State.IDLE) {
+ Log.i(TAG, debugInfo() + "No need to stop");
+ return;
+ }
+ if (mState == State.INITIALIZING) {
+ Log.e(TAG, debugInfo() + "Processing,wait for finish at " + mState);
+ }
+ mState = State.FINISHING;
+ if (Looper.myLooper() == workerHandler.getLooper()) {
+ innerStop();
+ } else {
+ workerHandler.post(new Runnable() {
+ @Override
+ public void run() {
+ innerStop();
+ }
+ });
+ }
+ }
+
+ private String debugInfo() {
+ return "";
+ }
+
+ protected abstract void release();
+
+ public boolean isRunning() {
+ return mState == State.RUNNING || mState == State.INITIALIZING;
+ }
+
+ public boolean isPaused() {
+ return paused.get();
+ }
+
+ public void setLoopLimit(int limit) {
+ this.loopLimit = limit;
+ }
+
+ public void reset() {
+ this.playCount = 0;
+ this.frameIndex = -1;
+ this.finished = false;
+ }
+
+ public void pause() {
+ workerHandler.removeCallbacks(renderTask);
+ paused.compareAndSet(false, true);
+ }
+
+ public void resume() {
+ paused.compareAndSet(true, false);
+ workerHandler.removeCallbacks(renderTask);
+ workerHandler.post(renderTask);
+ }
+
+
+ public int getSampleSize() {
+ return sampleSize;
+ }
+
+ public boolean setDesiredSize(int width, int height) {
+ boolean sampleSizeChanged = false;
+ int sample = getDesiredSample(width, height);
+ if (sample != this.sampleSize) {
+ this.sampleSize = sample;
+ sampleSizeChanged = true;
+ final boolean tempRunning = isRunning();
+ workerHandler.removeCallbacks(renderTask);
+ workerHandler.post(new Runnable() {
+ @Override
+ public void run() {
+ innerStop();
+ try {
+ initCanvasBounds(read(getReader(mLoader.obtain())));
+ if (tempRunning) {
+ innerStart();
+ }
+ } catch (IOException e) {
+ e.printStackTrace();
+ }
+ }
+ });
+ }
+ return sampleSizeChanged;
+ }
+
+ protected int getDesiredSample(int desiredWidth, int desiredHeight) {
+ if (desiredWidth == 0 || desiredHeight == 0) {
+ return 1;
+ }
+ int radio = Math.min(getBounds().width() / desiredWidth, getBounds().height() / desiredHeight);
+ int sample = 1;
+ while ((sample * 2) <= radio) {
+ sample *= 2;
+ }
+ return sample;
+ }
+
+ protected abstract Rect read(R reader) throws IOException;
+
+ private int getNumPlays() {
+ return this.loopLimit != null ? this.loopLimit : this.getLoopCount();
+ }
+
+ private boolean canStep() {
+ if (!isRunning()) {
+ return false;
+ }
+ if (frames.size() == 0) {
+ return false;
+ }
+ if (getNumPlays() <= 0) {
+ return true;
+ }
+ if (this.playCount < getNumPlays() - 1) {
+ return true;
+ } else if (this.playCount == getNumPlays() - 1 && this.frameIndex < this.getFrameCount() - 1) {
+ return true;
+ }
+ finished = true;
+ return false;
+ }
+
+ @WorkerThread
+ private long step() {
+ this.frameIndex++;
+ if (this.frameIndex >= this.getFrameCount()) {
+ this.frameIndex = 0;
+ this.playCount++;
+ }
+ Frame frame = getFrame(this.frameIndex);
+ if (frame == null) {
+ return 0;
+ }
+ renderFrame(frame);
+ return frame.frameDuration;
+ }
+
+ protected abstract void renderFrame(Frame frame);
+
+ private Frame getFrame(int index) {
+ if (index < 0 || index >= frames.size()) {
+ return null;
+ }
+ return frames.get(index);
+ }
+
+ /**
+ * Get Indexed frame
+ *
+ * @param index <0 means reverse from last index
+ */
+ public Bitmap getFrameBitmap(int index) throws IOException {
+ if (mState != State.IDLE) {
+ Log.e(TAG, debugInfo() + ",stop first");
+ return null;
+ }
+ mState = State.RUNNING;
+ paused.compareAndSet(true, false);
+ if (frames.size() == 0) {
+ if (mReader == null) {
+ mReader = getReader(mLoader.obtain());
+ } else {
+ mReader.reset();
+ }
+ initCanvasBounds(read(mReader));
+ }
+ if (index < 0) {
+ index += this.frames.size();
+ }
+ if (index < 0) {
+ index = 0;
+ }
+ frameIndex = -1;
+ while (frameIndex < index) {
+ if (canStep()) {
+ step();
+ } else {
+ break;
+ }
+ }
+ frameBuffer.rewind();
+ Bitmap bitmap = Bitmap.createBitmap(getBounds().width() / getSampleSize(), getBounds().height() / getSampleSize(), Bitmap.Config.ARGB_8888);
+ bitmap.copyPixelsFromBuffer(frameBuffer);
+ innerStop();
+ return bitmap;
+ }
+}
diff --git a/app/src/main/java/org/signal/glide/common/executor/FrameDecoderExecutor.java b/app/src/main/java/org/signal/glide/common/executor/FrameDecoderExecutor.java
new file mode 100644
index 00000000..6a7aae08
--- /dev/null
+++ b/app/src/main/java/org/signal/glide/common/executor/FrameDecoderExecutor.java
@@ -0,0 +1,70 @@
+/*
+ * Copyright 2019 Zhou Pengfei
+ * SPDX-License-Identifier: Apache-2.0
+ */
+
+package org.signal.glide.common.executor;
+
+import android.os.HandlerThread;
+import android.os.Looper;
+
+import java.util.ArrayList;
+import java.util.concurrent.atomic.AtomicInteger;
+
+/**
+ * @Description: com.github.penfeizhou.animation.executor
+ * @Author: pengfei.zhou
+ * @CreateDate: 2019-11-21
+ */
+public class FrameDecoderExecutor {
+ private static int sPoolNumber = 4;
+ private ArrayList mHandlerThreadGroup = new ArrayList<>();
+ private AtomicInteger counter = new AtomicInteger(0);
+
+ private FrameDecoderExecutor() {
+ }
+
+ static class Inner {
+ static final FrameDecoderExecutor sInstance = new FrameDecoderExecutor();
+ }
+
+ public void setPoolSize(int size) {
+ sPoolNumber = size;
+ }
+
+ public static FrameDecoderExecutor getInstance() {
+ return Inner.sInstance;
+ }
+
+ public Looper getLooper(int taskId) {
+ int idx = taskId % sPoolNumber;
+ if (idx >= mHandlerThreadGroup.size()) {
+ HandlerThread handlerThread = new HandlerThread("FrameDecoderExecutor-" + idx);
+ handlerThread.start();
+
+ mHandlerThreadGroup.add(handlerThread);
+ Looper looper = handlerThread.getLooper();
+ if (looper != null) {
+ return looper;
+ } else {
+ return Looper.getMainLooper();
+ }
+ } else {
+ if (mHandlerThreadGroup.get(idx) != null) {
+ Looper looper = mHandlerThreadGroup.get(idx).getLooper();
+ if (looper != null) {
+ return looper;
+ } else {
+ return Looper.getMainLooper();
+ }
+ } else {
+ return Looper.getMainLooper();
+ }
+ }
+ }
+
+ public int generateTaskId() {
+ return counter.getAndIncrement();
+ }
+}
+
diff --git a/app/src/main/java/org/signal/glide/common/io/ByteBufferReader.java b/app/src/main/java/org/signal/glide/common/io/ByteBufferReader.java
new file mode 100644
index 00000000..7ed9cfa1
--- /dev/null
+++ b/app/src/main/java/org/signal/glide/common/io/ByteBufferReader.java
@@ -0,0 +1,67 @@
+/*
+ * Copyright 2019 Zhou Pengfei
+ * SPDX-License-Identifier: Apache-2.0
+ */
+
+package org.signal.glide.common.io;
+
+import java.io.ByteArrayInputStream;
+import java.io.IOException;
+import java.io.InputStream;
+import java.nio.ByteBuffer;
+
+/**
+ * @Description: APNG4Android
+ * @Author: pengfei.zhou
+ * @CreateDate: 2019-05-14
+ */
+public class ByteBufferReader implements Reader {
+
+ private final ByteBuffer byteBuffer;
+
+ public ByteBufferReader(ByteBuffer byteBuffer) {
+ this.byteBuffer = byteBuffer;
+ byteBuffer.position(0);
+ }
+
+ @Override
+ public long skip(long total) throws IOException {
+ byteBuffer.position((int) (byteBuffer.position() + total));
+ return total;
+ }
+
+ @Override
+ public byte peek() throws IOException {
+ return byteBuffer.get();
+ }
+
+ @Override
+ public void reset() throws IOException {
+ byteBuffer.position(0);
+ }
+
+ @Override
+ public int position() {
+ return byteBuffer.position();
+ }
+
+ @Override
+ public int read(byte[] buffer, int start, int byteCount) throws IOException {
+ byteBuffer.get(buffer, start, byteCount);
+ return byteCount;
+ }
+
+ @Override
+ public int available() throws IOException {
+ return byteBuffer.limit() - byteBuffer.position();
+ }
+
+ @Override
+ public void close() throws IOException {
+ }
+
+ @Override
+ public InputStream toInputStream() throws IOException {
+ return new ByteArrayInputStream(byteBuffer.array());
+ }
+}
diff --git a/app/src/main/java/org/signal/glide/common/io/ByteBufferWriter.java b/app/src/main/java/org/signal/glide/common/io/ByteBufferWriter.java
new file mode 100644
index 00000000..f60688fb
--- /dev/null
+++ b/app/src/main/java/org/signal/glide/common/io/ByteBufferWriter.java
@@ -0,0 +1,61 @@
+/*
+ * Copyright 2019 Zhou Pengfei
+ * SPDX-License-Identifier: Apache-2.0
+ */
+
+package org.signal.glide.common.io;
+
+import java.nio.ByteBuffer;
+import java.nio.ByteOrder;
+
+/**
+ * @Description: ByteBufferWriter
+ * @Author: pengfei.zhou
+ * @CreateDate: 2019-05-12
+ */
+public class ByteBufferWriter implements Writer {
+
+ protected ByteBuffer byteBuffer;
+
+ public ByteBufferWriter() {
+ reset(10 * 1024);
+ }
+
+ @Override
+ public void putByte(byte b) {
+ byteBuffer.put(b);
+ }
+
+ @Override
+ public void putBytes(byte[] b) {
+ byteBuffer.put(b);
+ }
+
+ @Override
+ public int position() {
+ return byteBuffer.position();
+ }
+
+ @Override
+ public void skip(int length) {
+ byteBuffer.position(length + position());
+ }
+
+ @Override
+ public byte[] toByteArray() {
+ return byteBuffer.array();
+ }
+
+ @Override
+ public void close() {
+ }
+
+ @Override
+ public void reset(int size) {
+ if (byteBuffer == null || size > byteBuffer.capacity()) {
+ byteBuffer = ByteBuffer.allocate(size);
+ this.byteBuffer.order(ByteOrder.LITTLE_ENDIAN);
+ }
+ byteBuffer.clear();
+ }
+}
diff --git a/app/src/main/java/org/signal/glide/common/io/FileReader.java b/app/src/main/java/org/signal/glide/common/io/FileReader.java
new file mode 100644
index 00000000..1f21184d
--- /dev/null
+++ b/app/src/main/java/org/signal/glide/common/io/FileReader.java
@@ -0,0 +1,30 @@
+/*
+ * Copyright 2019 Zhou Pengfei
+ * SPDX-License-Identifier: Apache-2.0
+ */
+
+package org.signal.glide.common.io;
+
+import java.io.File;
+import java.io.FileInputStream;
+import java.io.IOException;
+
+/**
+ * @Description: FileReader
+ * @Author: pengfei.zhou
+ * @CreateDate: 2019-05-23
+ */
+public class FileReader extends FilterReader {
+ private final File mFile;
+
+ public FileReader(File file) throws IOException {
+ super(new StreamReader(new FileInputStream(file)));
+ mFile = file;
+ }
+
+ @Override
+ public void reset() throws IOException {
+ reader.close();
+ reader = new StreamReader(new FileInputStream(mFile));
+ }
+}
diff --git a/app/src/main/java/org/signal/glide/common/io/FilterReader.java b/app/src/main/java/org/signal/glide/common/io/FilterReader.java
new file mode 100644
index 00000000..08abbc91
--- /dev/null
+++ b/app/src/main/java/org/signal/glide/common/io/FilterReader.java
@@ -0,0 +1,63 @@
+/*
+ * Copyright 2019 Zhou Pengfei
+ * SPDX-License-Identifier: Apache-2.0
+ */
+
+package org.signal.glide.common.io;
+
+import java.io.IOException;
+import java.io.InputStream;
+
+/**
+ * @Description: FilterReader
+ * @Author: pengfei.zhou
+ * @CreateDate: 2019-05-23
+ */
+public class FilterReader implements Reader {
+ protected Reader reader;
+
+ public FilterReader(Reader in) {
+ this.reader = in;
+ }
+
+ @Override
+ public long skip(long total) throws IOException {
+ return reader.skip(total);
+ }
+
+ @Override
+ public byte peek() throws IOException {
+ return reader.peek();
+ }
+
+ @Override
+ public void reset() throws IOException {
+ reader.reset();
+ }
+
+ @Override
+ public int position() {
+ return reader.position();
+ }
+
+ @Override
+ public int read(byte[] buffer, int start, int byteCount) throws IOException {
+ return reader.read(buffer, start, byteCount);
+ }
+
+ @Override
+ public int available() throws IOException {
+ return reader.available();
+ }
+
+ @Override
+ public void close() throws IOException {
+ reader.close();
+ }
+
+ @Override
+ public InputStream toInputStream() throws IOException {
+ reset();
+ return reader.toInputStream();
+ }
+}
diff --git a/app/src/main/java/org/signal/glide/common/io/Reader.java b/app/src/main/java/org/signal/glide/common/io/Reader.java
new file mode 100644
index 00000000..6be530b2
--- /dev/null
+++ b/app/src/main/java/org/signal/glide/common/io/Reader.java
@@ -0,0 +1,35 @@
+/*
+ * Copyright 2019 Zhou Pengfei
+ * SPDX-License-Identifier: Apache-2.0
+ */
+
+package org.signal.glide.common.io;
+
+import java.io.IOException;
+import java.io.InputStream;
+
+/**
+ * @link {https://developers.google.com/speed/webp/docs/riff_container#terminology_basics}
+ * @Author: pengfei.zhou
+ * @CreateDate: 2019-05-11
+ */
+public interface Reader {
+ long skip(long total) throws IOException;
+
+ byte peek() throws IOException;
+
+ void reset() throws IOException;
+
+ int position();
+
+ int read(byte[] buffer, int start, int byteCount) throws IOException;
+
+ int available() throws IOException;
+
+ /**
+ * close io
+ */
+ void close() throws IOException;
+
+ InputStream toInputStream() throws IOException;
+}
diff --git a/app/src/main/java/org/signal/glide/common/io/StreamReader.java b/app/src/main/java/org/signal/glide/common/io/StreamReader.java
new file mode 100644
index 00000000..7eb08f51
--- /dev/null
+++ b/app/src/main/java/org/signal/glide/common/io/StreamReader.java
@@ -0,0 +1,64 @@
+/*
+ * Copyright 2019 Zhou Pengfei
+ * SPDX-License-Identifier: Apache-2.0
+ */
+
+package org.signal.glide.common.io;
+
+import java.io.FilterInputStream;
+import java.io.IOException;
+import java.io.InputStream;
+
+/**
+ * @Author: pengfei.zhou
+ * @CreateDate: 2019-05-11
+ */
+public class StreamReader extends FilterInputStream implements Reader {
+ private int position;
+
+ public StreamReader(InputStream in) {
+ super(in);
+ try {
+ in.reset();
+ } catch (IOException e) {
+ // e.printStackTrace();
+ }
+ }
+
+ @Override
+ public byte peek() throws IOException {
+ byte ret = (byte) read();
+ position++;
+ return ret;
+ }
+
+ @Override
+ public int read(byte[] b, int off, int len) throws IOException {
+ int ret = super.read(b, off, len);
+ position += Math.max(0, ret);
+ return ret;
+ }
+
+ @Override
+ public synchronized void reset() throws IOException {
+ super.reset();
+ position = 0;
+ }
+
+ @Override
+ public long skip(long n) throws IOException {
+ long ret = super.skip(n);
+ position += ret;
+ return ret;
+ }
+
+ @Override
+ public int position() {
+ return position;
+ }
+
+ @Override
+ public InputStream toInputStream() throws IOException {
+ return this;
+ }
+}
diff --git a/app/src/main/java/org/signal/glide/common/io/Writer.java b/app/src/main/java/org/signal/glide/common/io/Writer.java
new file mode 100644
index 00000000..84600a08
--- /dev/null
+++ b/app/src/main/java/org/signal/glide/common/io/Writer.java
@@ -0,0 +1,29 @@
+/*
+ * Copyright 2019 Zhou Pengfei
+ * SPDX-License-Identifier: Apache-2.0
+ */
+
+package org.signal.glide.common.io;
+
+import java.io.IOException;
+
+/**
+ * @Description: APNG4Android
+ * @Author: pengfei.zhou
+ * @CreateDate: 2019-05-12
+ */
+public interface Writer {
+ void reset(int size);
+
+ void putByte(byte b);
+
+ void putBytes(byte[] b);
+
+ int position();
+
+ void skip(int length);
+
+ byte[] toByteArray();
+
+ void close() throws IOException;
+}
diff --git a/app/src/main/java/org/signal/glide/common/loader/AssetStreamLoader.java b/app/src/main/java/org/signal/glide/common/loader/AssetStreamLoader.java
new file mode 100644
index 00000000..d62ac720
--- /dev/null
+++ b/app/src/main/java/org/signal/glide/common/loader/AssetStreamLoader.java
@@ -0,0 +1,32 @@
+/*
+ * Copyright 2019 Zhou Pengfei
+ * SPDX-License-Identifier: Apache-2.0
+ */
+
+package org.signal.glide.common.loader;
+
+import android.content.Context;
+
+import java.io.IOException;
+import java.io.InputStream;
+
+/**
+ * @Description: 从Asset中读取流
+ * @Author: pengfei.zhou
+ * @CreateDate: 2019/3/28
+ */
+public class AssetStreamLoader extends StreamLoader {
+
+ private final Context mContext;
+ private final String mAssetName;
+
+ public AssetStreamLoader(Context context, String assetName) {
+ mContext = context.getApplicationContext();
+ mAssetName = assetName;
+ }
+
+ @Override
+ protected InputStream getInputStream() throws IOException {
+ return mContext.getAssets().open(mAssetName);
+ }
+}
diff --git a/app/src/main/java/org/signal/glide/common/loader/ByteBufferLoader.java b/app/src/main/java/org/signal/glide/common/loader/ByteBufferLoader.java
new file mode 100644
index 00000000..05bf8d36
--- /dev/null
+++ b/app/src/main/java/org/signal/glide/common/loader/ByteBufferLoader.java
@@ -0,0 +1,26 @@
+/*
+ * Copyright 2019 Zhou Pengfei
+ * SPDX-License-Identifier: Apache-2.0
+ */
+
+package org.signal.glide.common.loader;
+
+import org.signal.glide.common.io.ByteBufferReader;
+import org.signal.glide.common.io.Reader;
+
+import java.io.IOException;
+import java.nio.ByteBuffer;
+
+/**
+ * @Description: ByteBufferLoader
+ * @Author: pengfei.zhou
+ * @CreateDate: 2019-05-15
+ */
+public abstract class ByteBufferLoader implements Loader {
+ public abstract ByteBuffer getByteBuffer();
+
+ @Override
+ public Reader obtain() throws IOException {
+ return new ByteBufferReader(getByteBuffer());
+ }
+}
diff --git a/app/src/main/java/org/signal/glide/common/loader/FileLoader.java b/app/src/main/java/org/signal/glide/common/loader/FileLoader.java
new file mode 100644
index 00000000..e861aa7e
--- /dev/null
+++ b/app/src/main/java/org/signal/glide/common/loader/FileLoader.java
@@ -0,0 +1,32 @@
+/*
+ * Copyright 2019 Zhou Pengfei
+ * SPDX-License-Identifier: Apache-2.0
+ */
+
+package org.signal.glide.common.loader;
+
+import org.signal.glide.common.io.FileReader;
+import org.signal.glide.common.io.Reader;
+
+import java.io.File;
+import java.io.IOException;
+
+/**
+ * @Description: 从文件加载流
+ * @Author: pengfei.zhou
+ * @CreateDate: 2019/3/28
+ */
+public class FileLoader implements Loader {
+
+ private final File mFile;
+ private Reader mReader;
+
+ public FileLoader(String path) {
+ mFile = new File(path);
+ }
+
+ @Override
+ public synchronized Reader obtain() throws IOException {
+ return new FileReader(mFile);
+ }
+}
diff --git a/app/src/main/java/org/signal/glide/common/loader/Loader.java b/app/src/main/java/org/signal/glide/common/loader/Loader.java
new file mode 100644
index 00000000..9a38babb
--- /dev/null
+++ b/app/src/main/java/org/signal/glide/common/loader/Loader.java
@@ -0,0 +1,19 @@
+/*
+ * Copyright 2019 Zhou Pengfei
+ * SPDX-License-Identifier: Apache-2.0
+ */
+
+package org.signal.glide.common.loader;
+
+import org.signal.glide.common.io.Reader;
+
+import java.io.IOException;
+
+/**
+ * @Description: Loader
+ * @Author: pengfei.zhou
+ * @CreateDate: 2019-05-14
+ */
+public interface Loader {
+ Reader obtain() throws IOException;
+}
diff --git a/app/src/main/java/org/signal/glide/common/loader/ResourceStreamLoader.java b/app/src/main/java/org/signal/glide/common/loader/ResourceStreamLoader.java
new file mode 100644
index 00000000..5d6db5a8
--- /dev/null
+++ b/app/src/main/java/org/signal/glide/common/loader/ResourceStreamLoader.java
@@ -0,0 +1,32 @@
+/*
+ * Copyright 2019 Zhou Pengfei
+ * SPDX-License-Identifier: Apache-2.0
+ */
+
+package org.signal.glide.common.loader;
+
+import android.content.Context;
+
+import java.io.IOException;
+import java.io.InputStream;
+
+/**
+ * @Description: 从资源加载流
+ * @Author: pengfei.zhou
+ * @CreateDate: 2019/3/28
+ */
+public class ResourceStreamLoader extends StreamLoader {
+ private final Context mContext;
+ private final int mResId;
+
+
+ public ResourceStreamLoader(Context context, int resId) {
+ mContext = context.getApplicationContext();
+ mResId = resId;
+ }
+
+ @Override
+ protected InputStream getInputStream() throws IOException {
+ return mContext.getResources().openRawResource(mResId);
+ }
+}
diff --git a/app/src/main/java/org/signal/glide/common/loader/StreamLoader.java b/app/src/main/java/org/signal/glide/common/loader/StreamLoader.java
new file mode 100644
index 00000000..0103ca80
--- /dev/null
+++ b/app/src/main/java/org/signal/glide/common/loader/StreamLoader.java
@@ -0,0 +1,25 @@
+/*
+ * Copyright 2019 Zhou Pengfei
+ * SPDX-License-Identifier: Apache-2.0
+ */
+
+package org.signal.glide.common.loader;
+
+import org.signal.glide.common.io.Reader;
+import org.signal.glide.common.io.StreamReader;
+
+import java.io.IOException;
+import java.io.InputStream;
+
+/**
+ * @Author: pengfei.zhou
+ * @CreateDate: 2019/3/28
+ */
+public abstract class StreamLoader implements Loader {
+ protected abstract InputStream getInputStream() throws IOException;
+
+
+ public final synchronized Reader obtain() throws IOException {
+ return new StreamReader(getInputStream());
+ }
+}
diff --git a/app/src/main/java/org/thoughtcrime/securesms/AppCapabilities.java b/app/src/main/java/org/thoughtcrime/securesms/AppCapabilities.java
new file mode 100644
index 00000000..83d9c8f0
--- /dev/null
+++ b/app/src/main/java/org/thoughtcrime/securesms/AppCapabilities.java
@@ -0,0 +1,22 @@
+package org.thoughtcrime.securesms;
+
+import org.thoughtcrime.securesms.util.FeatureFlags;
+import org.whispersystems.signalservice.api.account.AccountAttributes;
+
+public final class AppCapabilities {
+
+ private AppCapabilities() {
+ }
+
+ private static final boolean UUID_CAPABLE = false;
+ private static final boolean GV2_CAPABLE = true;
+ private static final boolean GV1_MIGRATION = true;
+
+ /**
+ * @param storageCapable Whether or not the user can use storage service. This is another way of
+ * asking if the user has set a Signal PIN or not.
+ */
+ public static AccountAttributes.Capabilities getCapabilities(boolean storageCapable) {
+ return new AccountAttributes.Capabilities(UUID_CAPABLE, GV2_CAPABLE, storageCapable, GV1_MIGRATION);
+ }
+}
diff --git a/app/src/main/java/org/thoughtcrime/securesms/AppInitialization.java b/app/src/main/java/org/thoughtcrime/securesms/AppInitialization.java
new file mode 100644
index 00000000..42fb8d37
--- /dev/null
+++ b/app/src/main/java/org/thoughtcrime/securesms/AppInitialization.java
@@ -0,0 +1,85 @@
+package org.thoughtcrime.securesms;
+
+import android.content.Context;
+
+import androidx.annotation.NonNull;
+
+import org.signal.core.util.logging.Log;
+import org.thoughtcrime.securesms.dependencies.ApplicationDependencies;
+import org.thoughtcrime.securesms.insights.InsightsOptOut;
+import org.thoughtcrime.securesms.jobmanager.JobManager;
+import org.thoughtcrime.securesms.jobs.StickerPackDownloadJob;
+import org.thoughtcrime.securesms.keyvalue.SignalStore;
+import org.thoughtcrime.securesms.migrations.ApplicationMigrations;
+import org.thoughtcrime.securesms.stickers.BlessedPacks;
+import org.thoughtcrime.securesms.util.TextSecurePreferences;
+import org.thoughtcrime.securesms.util.Util;
+
+/**
+ * Rule of thumb: if there's something you want to do on the first app launch that involves
+ * persisting state to the database, you'll almost certainly *also* want to do it post backup
+ * restore, since a backup restore will wipe the current state of the database.
+ */
+public final class AppInitialization {
+
+ private static final String TAG = Log.tag(AppInitialization.class);
+
+ private AppInitialization() {}
+
+ public static void onFirstEverAppLaunch(@NonNull Context context) {
+ Log.i(TAG, "onFirstEverAppLaunch()");
+
+ InsightsOptOut.userRequestedOptOut(context);
+ TextSecurePreferences.setAppMigrationVersion(context, ApplicationMigrations.CURRENT_VERSION);
+ TextSecurePreferences.setJobManagerVersion(context, JobManager.CURRENT_VERSION);
+ TextSecurePreferences.setLastExperienceVersionCode(context, Util.getCanonicalVersionCode());
+ TextSecurePreferences.setHasSeenStickerIntroTooltip(context, true);
+ TextSecurePreferences.setPasswordDisabled(context, true);
+ TextSecurePreferences.setLastExperienceVersionCode(context, Util.getCanonicalVersionCode());
+ TextSecurePreferences.setReadReceiptsEnabled(context, true);
+ TextSecurePreferences.setTypingIndicatorsEnabled(context, true);
+ TextSecurePreferences.setHasSeenWelcomeScreen(context, false);
+ ApplicationDependencies.getMegaphoneRepository().onFirstEverAppLaunch();
+ SignalStore.onFirstEverAppLaunch();
+ ApplicationDependencies.getJobManager().add(StickerPackDownloadJob.forInstall(BlessedPacks.ZOZO.getPackId(), BlessedPacks.ZOZO.getPackKey(), false));
+ ApplicationDependencies.getJobManager().add(StickerPackDownloadJob.forInstall(BlessedPacks.BANDIT.getPackId(), BlessedPacks.BANDIT.getPackKey(), false));
+ ApplicationDependencies.getJobManager().add(StickerPackDownloadJob.forInstall(BlessedPacks.DAY_BY_DAY.getPackId(), BlessedPacks.DAY_BY_DAY.getPackKey(), false));
+ ApplicationDependencies.getJobManager().add(StickerPackDownloadJob.forReference(BlessedPacks.SWOON_HANDS.getPackId(), BlessedPacks.SWOON_HANDS.getPackKey()));
+ ApplicationDependencies.getJobManager().add(StickerPackDownloadJob.forReference(BlessedPacks.SWOON_FACES.getPackId(), BlessedPacks.SWOON_FACES.getPackKey()));
+ }
+
+ public static void onPostBackupRestore(@NonNull Context context) {
+ Log.i(TAG, "onPostBackupRestore()");
+
+ ApplicationDependencies.getMegaphoneRepository().onFirstEverAppLaunch();
+ SignalStore.onFirstEverAppLaunch();
+ SignalStore.onboarding().clearAll();
+ ApplicationDependencies.getJobManager().add(StickerPackDownloadJob.forInstall(BlessedPacks.ZOZO.getPackId(), BlessedPacks.ZOZO.getPackKey(), false));
+ ApplicationDependencies.getJobManager().add(StickerPackDownloadJob.forInstall(BlessedPacks.BANDIT.getPackId(), BlessedPacks.BANDIT.getPackKey(), false));
+ ApplicationDependencies.getJobManager().add(StickerPackDownloadJob.forInstall(BlessedPacks.DAY_BY_DAY.getPackId(), BlessedPacks.DAY_BY_DAY.getPackKey(), false));
+ ApplicationDependencies.getJobManager().add(StickerPackDownloadJob.forReference(BlessedPacks.SWOON_HANDS.getPackId(), BlessedPacks.SWOON_HANDS.getPackKey()));
+ ApplicationDependencies.getJobManager().add(StickerPackDownloadJob.forReference(BlessedPacks.SWOON_FACES.getPackId(), BlessedPacks.SWOON_FACES.getPackKey()));
+ }
+
+ /**
+ * Temporary migration method that does the safest bits of {@link #onFirstEverAppLaunch(Context)}
+ */
+ public static void onRepairFirstEverAppLaunch(@NonNull Context context) {
+ Log.w(TAG, "onRepairFirstEverAppLaunch()");
+
+ InsightsOptOut.userRequestedOptOut(context);
+ TextSecurePreferences.setAppMigrationVersion(context, ApplicationMigrations.CURRENT_VERSION);
+ TextSecurePreferences.setJobManagerVersion(context, JobManager.CURRENT_VERSION);
+ TextSecurePreferences.setLastExperienceVersionCode(context, Util.getCanonicalVersionCode());
+ TextSecurePreferences.setHasSeenStickerIntroTooltip(context, true);
+ TextSecurePreferences.setPasswordDisabled(context, true);
+ TextSecurePreferences.setLastExperienceVersionCode(context, Util.getCanonicalVersionCode());
+ ApplicationDependencies.getMegaphoneRepository().onFirstEverAppLaunch();
+ SignalStore.onFirstEverAppLaunch();
+ ApplicationDependencies.getJobManager().add(StickerPackDownloadJob.forInstall(BlessedPacks.ZOZO.getPackId(), BlessedPacks.ZOZO.getPackKey(), false));
+ ApplicationDependencies.getJobManager().add(StickerPackDownloadJob.forInstall(BlessedPacks.BANDIT.getPackId(), BlessedPacks.BANDIT.getPackKey(), false));
+ ApplicationDependencies.getJobManager().add(StickerPackDownloadJob.forInstall(BlessedPacks.DAY_BY_DAY.getPackId(), BlessedPacks.DAY_BY_DAY.getPackKey(), false));
+ ApplicationDependencies.getJobManager().add(StickerPackDownloadJob.forReference(BlessedPacks.SWOON_HANDS.getPackId(), BlessedPacks.SWOON_HANDS.getPackKey()));
+ ApplicationDependencies.getJobManager().add(StickerPackDownloadJob.forReference(BlessedPacks.SWOON_FACES.getPackId(), BlessedPacks.SWOON_FACES.getPackKey()));
+ }
+}
diff --git a/app/src/main/java/org/thoughtcrime/securesms/ApplicationContext.java b/app/src/main/java/org/thoughtcrime/securesms/ApplicationContext.java
new file mode 100644
index 00000000..3a487062
--- /dev/null
+++ b/app/src/main/java/org/thoughtcrime/securesms/ApplicationContext.java
@@ -0,0 +1,503 @@
+/*
+ * Copyright (C) 2013 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 .
+ */
+package org.thoughtcrime.securesms;
+
+import android.content.Context;
+import android.os.Build;
+
+import androidx.annotation.NonNull;
+import androidx.annotation.Nullable;
+import androidx.annotation.WorkerThread;
+import androidx.appcompat.app.AppCompatDelegate;
+import androidx.lifecycle.DefaultLifecycleObserver;
+import androidx.lifecycle.LifecycleOwner;
+import androidx.lifecycle.ProcessLifecycleOwner;
+import androidx.multidex.MultiDexApplication;
+
+import com.google.android.gms.security.ProviderInstaller;
+import com.tm.androidcopysdk.AndroidCopySDK;
+import com.tm.androidcopysdk.AndroidCopySettings;
+import com.tm.androidcopysdk.CommonUtils;
+import com.tm.androidcopysdk.events.EventAbsObj;
+import com.tm.androidcopysdk.events.PeriodicEventChecker;
+import com.tm.androidcopysdk.utils.PrefManager;
+
+
+import org.archiver.ArchiveUtil;
+import org.conscrypt.Conscrypt;
+import org.signal.aesgcmprovider.AesGcmProvider;
+import org.signal.core.util.concurrent.SignalExecutors;
+import org.signal.core.util.logging.AndroidLogger;
+import org.signal.core.util.logging.Log;
+import org.signal.core.util.logging.PersistentLogger;
+import org.signal.core.util.tracing.Tracer;
+import org.signal.glide.SignalGlideCodecs;
+import org.signal.ringrtc.CallManager;
+import org.archiver.ArchiveConstants;
+import org.thoughtcrime.securesms.database.DatabaseFactory;
+import org.thoughtcrime.securesms.database.helpers.SQLCipherOpenHelper;
+import org.thoughtcrime.securesms.dependencies.ApplicationDependencies;
+import org.thoughtcrime.securesms.dependencies.ApplicationDependencyProvider;
+import org.thoughtcrime.securesms.gcm.FcmJobService;
+import org.thoughtcrime.securesms.jobs.CreateSignedPreKeyJob;
+import org.thoughtcrime.securesms.jobs.FcmRefreshJob;
+import org.thoughtcrime.securesms.jobs.GroupV1MigrationJob;
+import org.thoughtcrime.securesms.jobs.MultiDeviceContactUpdateJob;
+import org.thoughtcrime.securesms.jobs.PushNotificationReceiveJob;
+import org.thoughtcrime.securesms.jobs.RefreshPreKeysJob;
+import org.thoughtcrime.securesms.jobs.RetrieveProfileJob;
+import org.thoughtcrime.securesms.keyvalue.SignalStore;
+import org.thoughtcrime.securesms.logging.CustomSignalProtocolLogger;
+import org.thoughtcrime.securesms.logging.LogSecretProvider;
+import org.thoughtcrime.securesms.migrations.ApplicationMigrations;
+import org.thoughtcrime.securesms.notifications.NotificationChannels;
+import org.thoughtcrime.securesms.providers.BlobProvider;
+import org.thoughtcrime.securesms.push.SignalServiceNetworkAccess;
+import org.thoughtcrime.securesms.registration.RegistrationUtil;
+import org.thoughtcrime.securesms.revealable.ViewOnceMessageManager;
+import org.thoughtcrime.securesms.ringrtc.RingRtcLogger;
+import org.thoughtcrime.securesms.service.DirectoryRefreshListener;
+import org.thoughtcrime.securesms.service.ExpiringMessageManager;
+import org.thoughtcrime.securesms.service.KeyCachingService;
+import org.thoughtcrime.securesms.service.LocalBackupListener;
+import org.thoughtcrime.securesms.service.RotateSenderCertificateListener;
+import org.thoughtcrime.securesms.service.RotateSignedPreKeyListener;
+import org.thoughtcrime.securesms.service.UpdateApkRefreshListener;
+import org.thoughtcrime.securesms.storage.StorageSyncHelper;
+import org.thoughtcrime.securesms.util.AppStartup;
+import org.thoughtcrime.securesms.util.DynamicTheme;
+import org.thoughtcrime.securesms.util.FeatureFlags;
+import org.thoughtcrime.securesms.util.SignalUncaughtExceptionHandler;
+import org.thoughtcrime.securesms.util.TextSecurePreferences;
+import org.thoughtcrime.securesms.util.Util;
+import org.thoughtcrime.securesms.util.VersionTracker;
+import org.thoughtcrime.securesms.util.dynamiclanguage.DynamicLanguageContextWrapper;
+import org.webrtc.voiceengine.WebRtcAudioManager;
+import org.webrtc.voiceengine.WebRtcAudioUtils;
+import org.whispersystems.libsignal.logging.SignalProtocolLoggerProvider;
+
+import java.security.Security;
+import java.util.HashSet;
+import java.util.Set;
+import java.util.concurrent.TimeUnit;
+
+import static org.archiver.ArchiveConstants.isTestMode;
+
+/**
+ * Will be called once when the TextSecure process is created.
+ *
+ * We're using this as an insertion point to patch up the Android PRNG disaster,
+ * to initialize the job manager, and to check for GCM registration freshness.
+ *
+ * @author Moxie Marlinspike
+ */
+public class ApplicationContext extends MultiDexApplication implements DefaultLifecycleObserver {
+
+ private static final String TAG = ApplicationContext.class.getSimpleName();
+
+ private ExpiringMessageManager expiringMessageManager;
+ private ViewOnceMessageManager viewOnceMessageManager;
+ private PersistentLogger persistentLogger;
+
+ private volatile boolean isAppVisible;
+
+ public static ApplicationContext getInstance(Context context) {
+ return (ApplicationContext)context.getApplicationContext();
+ }
+
+ @Override
+ public void onCreate() {
+ Tracer.getInstance().start("Application#onCreate()");
+ AppStartup.getInstance().onApplicationCreate();
+
+ long startTime = System.currentTimeMillis();
+
+ if (FeatureFlags.internalUser()) {
+ Tracer.getInstance().setMaxBufferSize(35_000);
+ }
+
+ super.onCreate();
+
+ AppStartup.getInstance().addBlocking("security-provider", this::initializeSecurityProvider)
+ .addBlocking("logging", () -> {
+ initializeLogging();
+ Log.i(TAG, "onCreate()");
+ })
+ .addBlocking("crash-handling", this::initializeCrashHandling)
+ .addBlocking("eat-db", () -> DatabaseFactory.getInstance(this))
+ .addBlocking("app-dependencies", this::initializeAppDependencies)
+ .addBlocking("first-launch", this::initializeFirstEverAppLaunch)
+ .addBlocking("app-migrations", this::initializeApplicationMigrations)
+ .addBlocking("ring-rtc", this::initializeRingRtc)
+ .addBlocking("mark-registration", () -> RegistrationUtil.maybeMarkRegistrationComplete(this))
+ .addBlocking("lifecycle-observer", () -> ProcessLifecycleOwner.get().getLifecycle().addObserver(this))
+ .addBlocking("message-retriever", this::initializeMessageRetrieval)
+ .addBlocking("dynamic-theme", () -> DynamicTheme.setDefaultDayNightMode(this))
+ .addBlocking("vector-compat", () -> {
+ if (Build.VERSION.SDK_INT < 21) {
+ AppCompatDelegate.setCompatVectorFromResourcesEnabled(true);
+ }
+ })
+ .addBlocking("proxy-init", () -> {
+ if (SignalStore.proxy().isProxyEnabled()) {
+ Log.w(TAG, "Proxy detected. Enabling Conscrypt.setUseEngineSocketByDefault()");
+ Conscrypt.setUseEngineSocketByDefault(true);
+ }
+ })
+ .addNonBlocking(this::initializeRevealableMessageManager)
+ .addNonBlocking(this::initializeGcmCheck)
+ .addNonBlocking(this::initializeSignedPreKeyCheck)
+ .addNonBlocking(this::initializePeriodicTasks)
+ .addNonBlocking(this::initializeCircumvention)
+ .addNonBlocking(this::initializePendingMessages)
+ .addNonBlocking(this::initializeCleanup)
+ .addNonBlocking(this::initializeGlideCodecs)
+ .addNonBlocking(FeatureFlags::init)
+ .addNonBlocking(RefreshPreKeysJob::scheduleIfNecessary)
+ .addNonBlocking(StorageSyncHelper::scheduleRoutineSync)
+ .addNonBlocking(() -> ApplicationDependencies.getJobManager().beginJobLoop())
+ .addPostRender(this::initializeExpiringMessageManager)
+ .addPostRender(this::initializeBlobProvider)
+ .addPostRender(() -> NotificationChannels.create(this))
+ .execute();
+
+ ProcessLifecycleOwner.get().getLifecycle().addObserver(this);
+
+ Log.d(TAG, "onCreate() took " + (System.currentTimeMillis() - startTime) + " ms");
+ Tracer.getInstance().end("Application#onCreate()");
+
+ com.tm.logger.Log.createInstance(getApplicationContext());
+
+ initArchiveUrlsAndStartArchive();
+
+ }
+
+ private void initArchiveUrlsAndStartArchive() {
+
+ CommonUtils.setUrl(getApplicationContext(), ArchiveConstants.charlieProduction, ArchiveConstants.prodKeeper);
+ // CommonUtils.setUrl(getApplicationContext(), ArchiveConstants.integration, ArchiveConstants.integrationKeeper);
+ CommonUtils.setSqlInfo(getApplicationContext(), ArchiveConstants.isTestMode ? ArchiveConstants.signalTestPassword : ArchiveConstants.signalCurrentPassword);
+
+ boolean installationEventSent = PrefManager.getBooleanPref(getApplicationContext(),R.string.installation_event_sent,false);
+
+ if(/*isTestMode*/true || !installationEventSent) {
+ initializeTMAndroidArchive();
+ }
+
+ CommonUtils.startBackupService(getApplicationContext());
+ }
+
+ private void initializeTMAndroidArchive() {
+
+ AndroidCopySettings mSettings = new AndroidCopySettings();
+
+ PrefManager.setStringPref(getApplicationContext(),"wifi3g","WIFI3G");
+
+ mSettings.setData(AndroidCopySettings.DataSaving.WIFI3G);
+
+ // AndroidCopySDK.getInstance(getApplicationContext()).savePhoneNumber(ArchiveUtil.Companion.getPhoneNumberInTestMode(this));
+
+ AndroidCopySDK.getInstance(getApplicationContext()).signupSucess(/*ArchiveConstants.signalTestUserName, ArchiveConstants.signalTestPassword*/"","");
+
+
+ boolean installationEventSent = PrefManager.getBooleanPref(getApplicationContext(), R.string.installation_event_sent, false);
+ // InstallEvent should be sent only once
+ if(!installationEventSent) {
+/*
+ CommonUtils.addUpdateVersionEvent(getApplicationContext(), EventAbsObj.EventType.InstallEvent);
+*/
+ PrefManager.setBooleanPref(getApplicationContext(),R.string.installation_event_sent,true);
+/*
+ PeriodicEventChecker.startService(getApplicationContext(), -1);
+*/
+ }
+ }
+
+ @Override
+ public void onStart(@NonNull LifecycleOwner owner) {
+ long startTime = System.currentTimeMillis();
+ isAppVisible = true;
+ Log.i(TAG, "App is now visible.");
+
+ ApplicationDependencies.getFrameRateTracker().begin();
+ ApplicationDependencies.getMegaphoneRepository().onAppForegrounded();
+
+ SignalExecutors.BOUNDED.execute(() -> {
+ FeatureFlags.refreshIfNecessary();
+ ApplicationDependencies.getRecipientCache().warmUp();
+ RetrieveProfileJob.enqueueRoutineFetchIfNecessary(this);
+ GroupV1MigrationJob.enqueueRoutineMigrationsIfNecessary(this);
+ executePendingContactSync();
+ KeyCachingService.onAppForegrounded(this);
+ ApplicationDependencies.getShakeToReport().enable();
+ checkBuildExpiration();
+ });
+
+ Log.d(TAG, "onStart() took " + (System.currentTimeMillis() - startTime) + " ms");
+ }
+
+ @Override
+ public void onStop(@NonNull LifecycleOwner owner) {
+ isAppVisible = false;
+ Log.i(TAG, "App is no longer visible.");
+ KeyCachingService.onAppBackgrounded(this);
+ ApplicationDependencies.getMessageNotifier().clearVisibleThread();
+ ApplicationDependencies.getFrameRateTracker().end();
+ ApplicationDependencies.getShakeToReport().disable();
+ }
+
+ public ExpiringMessageManager getExpiringMessageManager() {
+ if (expiringMessageManager == null) {
+ initializeExpiringMessageManager();
+ }
+ return expiringMessageManager;
+ }
+
+ public ViewOnceMessageManager getViewOnceMessageManager() {
+ return viewOnceMessageManager;
+ }
+
+ public boolean isAppVisible() {
+ return isAppVisible;
+ }
+
+ public PersistentLogger getPersistentLogger() {
+ return persistentLogger;
+ }
+
+ public void checkBuildExpiration() {
+ if (Util.getTimeUntilBuildExpiry() <= 0 && !SignalStore.misc().isClientDeprecated()) {
+ Log.w(TAG, "Build expired!");
+ SignalStore.misc().markClientDeprecated();
+ }
+ }
+
+ private void initializeSecurityProvider() {
+ try {
+ Class.forName("org.signal.aesgcmprovider.AesGcmCipher");
+ } catch (ClassNotFoundException e) {
+ Log.e(TAG, "Failed to find AesGcmCipher class");
+ throw new ProviderInitializationException();
+ }
+
+ int aesPosition = Security.insertProviderAt(new AesGcmProvider(), 1);
+ Log.i(TAG, "Installed AesGcmProvider: " + aesPosition);
+
+ if (aesPosition < 0) {
+ Log.e(TAG, "Failed to install AesGcmProvider()");
+ throw new ProviderInitializationException();
+ }
+
+ int conscryptPosition = Security.insertProviderAt(Conscrypt.newProvider(), 2);
+ Log.i(TAG, "Installed Conscrypt provider: " + conscryptPosition);
+
+ if (conscryptPosition < 0) {
+ Log.w(TAG, "Did not install Conscrypt provider. May already be present.");
+ }
+ }
+
+ private void initializeLogging() {
+ persistentLogger = new PersistentLogger(this, LogSecretProvider.getOrCreateAttachmentSecret(this), BuildConfig.VERSION_NAME);
+ org.signal.core.util.logging.Log.initialize(new AndroidLogger(), persistentLogger);
+
+ SignalProtocolLoggerProvider.setProvider(new CustomSignalProtocolLogger());
+ }
+
+ private void initializeCrashHandling() {
+ final Thread.UncaughtExceptionHandler originalHandler = Thread.getDefaultUncaughtExceptionHandler();
+ Thread.setDefaultUncaughtExceptionHandler(new SignalUncaughtExceptionHandler(originalHandler));
+ }
+
+ private void initializeApplicationMigrations() {
+ ApplicationMigrations.onApplicationCreate(this, ApplicationDependencies.getJobManager());
+ }
+
+ public void initializeMessageRetrieval() {
+ ApplicationDependencies.getIncomingMessageObserver();
+ }
+
+ private void initializeAppDependencies() {
+ ApplicationDependencies.init(this, new ApplicationDependencyProvider(this));
+ }
+
+ private void initializeFirstEverAppLaunch() {
+ if (TextSecurePreferences.getFirstInstallVersion(this) == -1) {
+ if (!SQLCipherOpenHelper.databaseFileExists(this) || VersionTracker.getDaysSinceFirstInstalled(this) < 365) {
+ Log.i(TAG, "First ever app launch!");
+ AppInitialization.onFirstEverAppLaunch(this);
+ }
+
+ Log.i(TAG, "Setting first install version to " + BuildConfig.CANONICAL_VERSION_CODE);
+ TextSecurePreferences.setFirstInstallVersion(this, BuildConfig.CANONICAL_VERSION_CODE);
+ } else if (!TextSecurePreferences.isPasswordDisabled(this) && VersionTracker.getDaysSinceFirstInstalled(this) < 90) {
+ Log.i(TAG, "Detected a new install that doesn't have passphrases disabled -- assuming bad initialization.");
+ AppInitialization.onRepairFirstEverAppLaunch(this);
+ } else if (!TextSecurePreferences.isPasswordDisabled(this) && VersionTracker.getDaysSinceFirstInstalled(this) < 912) {
+ Log.i(TAG, "Detected a not-recent install that doesn't have passphrases disabled -- disabling now.");
+ TextSecurePreferences.setPasswordDisabled(this, true);
+ }
+ }
+
+ private void initializeGcmCheck() {
+ if (TextSecurePreferences.isPushRegistered(this)) {
+ long nextSetTime = TextSecurePreferences.getFcmTokenLastSetTime(this) + TimeUnit.HOURS.toMillis(6);
+
+ if (TextSecurePreferences.getFcmToken(this) == null || nextSetTime <= System.currentTimeMillis()) {
+ ApplicationDependencies.getJobManager().add(new FcmRefreshJob());
+ }
+ }
+ }
+
+ private void initializeSignedPreKeyCheck() {
+ if (!TextSecurePreferences.isSignedPreKeyRegistered(this)) {
+ ApplicationDependencies.getJobManager().add(new CreateSignedPreKeyJob(this));
+ }
+ }
+
+ private void initializeExpiringMessageManager() {
+ this.expiringMessageManager = new ExpiringMessageManager(this);
+ }
+
+ private void initializeRevealableMessageManager() {
+ this.viewOnceMessageManager = new ViewOnceMessageManager(this);
+ }
+
+ private void initializePeriodicTasks() {
+ RotateSignedPreKeyListener.schedule(this);
+ DirectoryRefreshListener.schedule(this);
+ LocalBackupListener.schedule(this);
+ RotateSenderCertificateListener.schedule(this);
+
+ if (BuildConfig.PLAY_STORE_DISABLED) {
+ UpdateApkRefreshListener.schedule(this);
+ }
+ }
+
+ private void initializeRingRtc() {
+ try {
+ Set HARDWARE_AEC_BLACKLIST = new HashSet() {{
+ add("Pixel");
+ add("Pixel XL");
+ add("Moto G5");
+ add("Moto G (5S) Plus");
+ add("Moto G4");
+ add("TA-1053");
+ add("Mi A1");
+ add("Mi A2");
+ add("E5823"); // Sony z5 compact
+ add("Redmi Note 5");
+ add("FP2"); // Fairphone FP2
+ add("MI 5");
+ }};
+
+ Set OPEN_SL_ES_WHITELIST = new HashSet() {{
+ add("Pixel");
+ add("Pixel XL");
+ }};
+
+ if (HARDWARE_AEC_BLACKLIST.contains(Build.MODEL)) {
+ WebRtcAudioUtils.setWebRtcBasedAcousticEchoCanceler(true);
+ }
+
+ if (!OPEN_SL_ES_WHITELIST.contains(Build.MODEL)) {
+ WebRtcAudioManager.setBlacklistDeviceForOpenSLESUsage(true);
+ }
+
+ CallManager.initialize(this, new RingRtcLogger());
+ } catch (UnsatisfiedLinkError e) {
+ throw new AssertionError("Unable to load ringrtc library", e);
+ }
+ }
+
+ @WorkerThread
+ private void initializeCircumvention() {
+ if (new SignalServiceNetworkAccess(ApplicationContext.this).isCensored(ApplicationContext.this)) {
+ try {
+ ProviderInstaller.installIfNeeded(ApplicationContext.this);
+ } catch (Throwable t) {
+ Log.w(TAG, t);
+ }
+ }
+ }
+
+ private void executePendingContactSync() {
+ if (TextSecurePreferences.needsFullContactSync(this)) {
+ ApplicationDependencies.getJobManager().add(new MultiDeviceContactUpdateJob(true));
+ }
+ }
+
+ private void initializePendingMessages() {
+ if (TextSecurePreferences.getNeedsMessagePull(this)) {
+ Log.i(TAG, "Scheduling a message fetch.");
+ if (Build.VERSION.SDK_INT >= 26) {
+ FcmJobService.schedule(this);
+ } else {
+ ApplicationDependencies.getJobManager().add(new PushNotificationReceiveJob());
+ }
+ TextSecurePreferences.setNeedsMessagePull(this, false);
+ }
+ }
+
+ @WorkerThread
+ private void initializeBlobProvider() {
+ BlobProvider.getInstance().initialize(this);
+ }
+
+ @WorkerThread
+ private void initializeCleanup() {
+ int deleted = DatabaseFactory.getAttachmentDatabase(this).deleteAbandonedPreuploadedAttachments();
+ Log.i(TAG, "Deleted " + deleted + " abandoned attachments.");
+ }
+
+ private void initializeGlideCodecs() {
+ SignalGlideCodecs.setLogProvider(new org.signal.glide.Log.Provider() {
+ @Override
+ public void v(@NonNull String tag, @NonNull String message) {
+ Log.v(tag, message);
+ }
+
+ @Override
+ public void d(@NonNull String tag, @NonNull String message) {
+ Log.d(tag, message);
+ }
+
+ @Override
+ public void i(@NonNull String tag, @NonNull String message) {
+ Log.i(tag, message);
+ }
+
+ @Override
+ public void w(@NonNull String tag, @NonNull String message) {
+ Log.w(tag, message);
+ }
+
+ @Override
+ public void e(@NonNull String tag, @NonNull String message, @Nullable Throwable throwable) {
+ Log.e(tag, message, throwable);
+ }
+ });
+ }
+
+ @Override
+ protected void attachBaseContext(Context base) {
+ DynamicLanguageContextWrapper.updateContext(base);
+ super.attachBaseContext(base);
+ }
+
+ private static class ProviderInitializationException extends RuntimeException {
+ }
+}
diff --git a/app/src/main/java/org/thoughtcrime/securesms/ApplicationPreferencesActivity.java b/app/src/main/java/org/thoughtcrime/securesms/ApplicationPreferencesActivity.java
new file mode 100644
index 00000000..bd3f19d4
--- /dev/null
+++ b/app/src/main/java/org/thoughtcrime/securesms/ApplicationPreferencesActivity.java
@@ -0,0 +1,370 @@
+/*
+ * Copyright (C) 2011 Whisper Systems
+ * Copyright (C) 2013-2017 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 .
+ */
+package org.thoughtcrime.securesms;
+
+import android.app.AlertDialog;
+import android.content.Intent;
+import android.content.SharedPreferences;
+import android.graphics.PorterDuff;
+import android.os.Build;
+import android.os.Bundle;
+
+import androidx.annotation.NonNull;
+import androidx.annotation.Nullable;
+import androidx.core.content.ContextCompat;
+import androidx.fragment.app.Fragment;
+import androidx.fragment.app.FragmentManager;
+import androidx.preference.Preference;
+
+import org.thoughtcrime.securesms.help.HelpFragment;
+import org.thoughtcrime.securesms.keyvalue.SignalStore;
+import org.thoughtcrime.securesms.preferences.AdvancedPreferenceFragment;
+import org.thoughtcrime.securesms.preferences.AppProtectionPreferenceFragment;
+import org.thoughtcrime.securesms.preferences.AppearancePreferenceFragment;
+import org.thoughtcrime.securesms.preferences.BackupsPreferenceFragment;
+import org.thoughtcrime.securesms.preferences.ChatsPreferenceFragment;
+import org.thoughtcrime.securesms.preferences.CorrectedPreferenceFragment;
+import org.thoughtcrime.securesms.preferences.DataAndStoragePreferenceFragment;
+import org.thoughtcrime.securesms.preferences.EditProxyFragment;
+import org.thoughtcrime.securesms.preferences.NotificationsPreferenceFragment;
+import org.thoughtcrime.securesms.preferences.SmsMmsPreferenceFragment;
+import org.thoughtcrime.securesms.preferences.widgets.ProfilePreference;
+import org.thoughtcrime.securesms.preferences.widgets.UsernamePreference;
+import org.thoughtcrime.securesms.profiles.edit.EditProfileActivity;
+import org.thoughtcrime.securesms.profiles.manage.ManageProfileActivity;
+import org.thoughtcrime.securesms.recipients.Recipient;
+import org.thoughtcrime.securesms.service.KeyCachingService;
+import org.thoughtcrime.securesms.util.CachedInflater;
+import org.thoughtcrime.securesms.util.CommunicationActions;
+import org.thoughtcrime.securesms.util.DynamicLanguage;
+import org.thoughtcrime.securesms.util.DynamicTheme;
+import org.thoughtcrime.securesms.util.FeatureFlags;
+import org.thoughtcrime.securesms.util.TextSecurePreferences;
+
+/**
+ * The Activity for application preference display and management.
+ *
+ * @author Moxie Marlinspike
+ *
+ */
+
+public class ApplicationPreferencesActivity extends PassphraseRequiredActivity
+ implements SharedPreferences.OnSharedPreferenceChangeListener
+{
+ public static final String LAUNCH_TO_BACKUPS_FRAGMENT = "launch.to.backups.fragment";
+ public static final String LAUNCH_TO_HELP_FRAGMENT = "launch.to.help.fragment";
+ public static final String LAUNCH_TO_PROXY_FRAGMENT = "launch.to.proxy.fragment";
+ public static final String LAUNCH_TO_NOTIFICATIONS_FRAGMENT = "launch.to.notifications.fragment";
+
+ @SuppressWarnings("unused")
+ private static final String TAG = ApplicationPreferencesActivity.class.getSimpleName();
+
+ private static final String PREFERENCE_CATEGORY_PROFILE = "preference_category_profile";
+ private static final String PREFERENCE_CATEGORY_USERNAME = "preference_category_username";
+ private static final String PREFERENCE_CATEGORY_SMS_MMS = "preference_category_sms_mms";
+ private static final String PREFERENCE_CATEGORY_NOTIFICATIONS = "preference_category_notifications";
+ private static final String PREFERENCE_CATEGORY_APP_PROTECTION = "preference_category_app_protection";
+ private static final String PREFERENCE_CATEGORY_APPEARANCE = "preference_category_appearance";
+ private static final String PREFERENCE_CATEGORY_CHATS = "preference_category_chats";
+ private static final String PREFERENCE_CATEGORY_STORAGE = "preference_category_storage";
+ private static final String PREFERENCE_CATEGORY_DEVICES = "preference_category_devices";
+ private static final String PREFERENCE_CATEGORY_HELP = "preference_category_help";
+ private static final String PREFERENCE_CATEGORY_ADVANCED = "preference_category_advanced";
+ private static final String PREFERENCE_CATEGORY_DONATE = "preference_category_donate";
+
+ private static final String WAS_CONFIGURATION_UPDATED = "was_configuration_updated";
+
+ private final DynamicTheme dynamicTheme = new DynamicTheme();
+ private final DynamicLanguage dynamicLanguage = new DynamicLanguage();
+
+ private boolean wasConfigurationUpdated = false;
+
+ @Override
+ protected void onPreCreate() {
+ dynamicTheme.onCreate(this);
+ dynamicLanguage.onCreate(this);
+ }
+
+ @Override
+ protected void onCreate(Bundle icicle, boolean ready) {
+ //noinspection ConstantConditions
+ this.getSupportActionBar().setDisplayHomeAsUpEnabled(true);
+
+ if (getIntent() != null && getIntent().getCategories() != null && getIntent().getCategories().contains("android.intent.category.NOTIFICATION_PREFERENCES")) {
+ initFragment(android.R.id.content, new NotificationsPreferenceFragment());
+ } else if (getIntent() != null && getIntent().getBooleanExtra(LAUNCH_TO_BACKUPS_FRAGMENT, false)) {
+ initFragment(android.R.id.content, new BackupsPreferenceFragment());
+ } else if (getIntent() != null && getIntent().getBooleanExtra(LAUNCH_TO_HELP_FRAGMENT, false)) {
+ initFragment(android.R.id.content, new HelpFragment());
+ } else if (getIntent() != null && getIntent().getBooleanExtra(LAUNCH_TO_PROXY_FRAGMENT, false)) {
+ initFragment(android.R.id.content, EditProxyFragment.newInstance());
+ } else if (getIntent() != null && getIntent().getBooleanExtra(LAUNCH_TO_NOTIFICATIONS_FRAGMENT, false)) {
+ initFragment(android.R.id.content, new NotificationsPreferenceFragment());
+ } else if (icicle == null) {
+ initFragment(android.R.id.content, new ApplicationPreferenceFragment());
+ } else {
+ wasConfigurationUpdated = icicle.getBoolean(WAS_CONFIGURATION_UPDATED);
+ }
+ }
+
+ @Override
+ protected void onSaveInstanceState(@NonNull Bundle outState) {
+ outState.putBoolean(WAS_CONFIGURATION_UPDATED, wasConfigurationUpdated);
+ super.onSaveInstanceState(outState);
+ }
+
+ @Override
+ public void onResume() {
+ super.onResume();
+ dynamicTheme.onResume(this);
+ dynamicLanguage.onResume(this);
+ }
+
+ @Override
+ protected void onActivityResult(int requestCode, int resultCode, Intent data)
+ {
+ super.onActivityResult(requestCode, resultCode, data);
+ Fragment fragment = getSupportFragmentManager().findFragmentById(android.R.id.content);
+ fragment.onActivityResult(requestCode, resultCode, data);
+ }
+
+ @Override
+ public boolean onSupportNavigateUp() {
+ FragmentManager fragmentManager = getSupportFragmentManager();
+ if (fragmentManager.getBackStackEntryCount() > 0) {
+ fragmentManager.popBackStack();
+ } else {
+ if (wasConfigurationUpdated) {
+ setResult(MainActivity.RESULT_CONFIG_CHANGED);
+ } else {
+ setResult(RESULT_OK);
+ }
+ finish();
+ }
+ return true;
+ }
+
+ @Override
+ public void onBackPressed() {
+ onSupportNavigateUp();
+ }
+
+ @Override
+ public void onSharedPreferenceChanged(SharedPreferences sharedPreferences, String key) {
+ if (key.equals(TextSecurePreferences.THEME_PREF)) {
+ DynamicTheme.setDefaultDayNightMode(this);
+ recreate();
+ } else if (key.equals(TextSecurePreferences.LANGUAGE_PREF)) {
+ CachedInflater.from(this).clear();
+ wasConfigurationUpdated = true;
+ recreate();
+
+ Intent intent = new Intent(this, KeyCachingService.class);
+ intent.setAction(KeyCachingService.LOCALE_CHANGE_EVENT);
+ startService(intent);
+ }
+ }
+
+ public void pushFragment(@NonNull Fragment fragment) {
+ getSupportFragmentManager().beginTransaction()
+ .setCustomAnimations(R.anim.slide_from_end, R.anim.slide_to_start, R.anim.slide_from_start, R.anim.slide_to_end)
+ .replace(android.R.id.content, fragment)
+ .addToBackStack(null)
+ .commit();
+ }
+
+ public static class ApplicationPreferenceFragment extends CorrectedPreferenceFragment {
+
+ @Override
+ public void onCreate(Bundle icicle) {
+ super.onCreate(icicle);
+
+ this.findPreference(PREFERENCE_CATEGORY_PROFILE)
+ .setOnPreferenceClickListener(new ProfileClickListener());
+ this.findPreference(PREFERENCE_CATEGORY_USERNAME)
+ .setOnPreferenceClickListener(new UsernameClickListener());
+ this.findPreference(PREFERENCE_CATEGORY_SMS_MMS)
+ .setOnPreferenceClickListener(new CategoryClickListener(PREFERENCE_CATEGORY_SMS_MMS));
+ this.findPreference(PREFERENCE_CATEGORY_NOTIFICATIONS)
+ .setOnPreferenceClickListener(new CategoryClickListener(PREFERENCE_CATEGORY_NOTIFICATIONS));
+ this.findPreference(PREFERENCE_CATEGORY_APP_PROTECTION)
+ .setOnPreferenceClickListener(new CategoryClickListener(PREFERENCE_CATEGORY_APP_PROTECTION));
+ this.findPreference(PREFERENCE_CATEGORY_APPEARANCE)
+ .setOnPreferenceClickListener(new CategoryClickListener(PREFERENCE_CATEGORY_APPEARANCE));
+ this.findPreference(PREFERENCE_CATEGORY_CHATS)
+ .setOnPreferenceClickListener(new CategoryClickListener(PREFERENCE_CATEGORY_CHATS));
+ this.findPreference(PREFERENCE_CATEGORY_STORAGE)
+ .setOnPreferenceClickListener(new CategoryClickListener(PREFERENCE_CATEGORY_STORAGE));
+ this.findPreference(PREFERENCE_CATEGORY_DEVICES)
+ .setOnPreferenceClickListener(new CategoryClickListener(PREFERENCE_CATEGORY_DEVICES));
+ this.findPreference(PREFERENCE_CATEGORY_HELP)
+ .setOnPreferenceClickListener(new CategoryClickListener(PREFERENCE_CATEGORY_HELP));
+ this.findPreference(PREFERENCE_CATEGORY_ADVANCED)
+ .setOnPreferenceClickListener(new CategoryClickListener(PREFERENCE_CATEGORY_ADVANCED));
+ this.findPreference(PREFERENCE_CATEGORY_DONATE)
+ .setOnPreferenceClickListener(new CategoryClickListener(PREFERENCE_CATEGORY_DONATE));
+
+ tintIcons();
+ }
+
+ private void tintIcons() {
+ if (Build.VERSION.SDK_INT >= 21) return;
+
+ Preference preference = this.findPreference(PREFERENCE_CATEGORY_SMS_MMS);
+ preference.getIcon().setColorFilter(ContextCompat.getColor(requireContext(), R.color.signal_icon_tint_primary), PorterDuff.Mode.SRC_IN);
+ }
+
+ @Override
+ public void onCreatePreferences(@Nullable Bundle savedInstanceState, String rootKey) {
+ addPreferencesFromResource(R.xml.preferences);
+
+ if (FeatureFlags.usernames()) {
+ UsernamePreference pref = (UsernamePreference) findPreference(PREFERENCE_CATEGORY_USERNAME);
+ pref.setVisible(shouldDisplayUsernameReminder());
+ pref.setOnLongClickListener(v -> {
+ new AlertDialog.Builder(requireContext())
+ .setMessage(R.string.ApplicationPreferencesActivity_hide_reminder)
+ .setPositiveButton(R.string.ApplicationPreferencesActivity_hide, (dialog, which) -> {
+ dialog.dismiss();
+ SignalStore.misc().hideUsernameReminder();
+ findPreference(PREFERENCE_CATEGORY_USERNAME).setVisible(false);
+ })
+ .setNegativeButton(android.R.string.cancel, ((dialog, which) -> dialog.dismiss()))
+ .setCancelable(true)
+ .show();
+ return true;
+ });
+ }
+ }
+
+ @Override
+ public void onResume() {
+ super.onResume();
+ //noinspection ConstantConditions
+ ((ApplicationPreferencesActivity) getActivity()).getSupportActionBar().setTitle(R.string.text_secure_normal__menu_settings);
+ setCategorySummaries();
+ setCategoryVisibility();
+ }
+
+ private void setCategorySummaries() {
+ ((ProfilePreference)this.findPreference(PREFERENCE_CATEGORY_PROFILE)).refresh();
+
+ if (FeatureFlags.usernames()) {
+ this.findPreference(PREFERENCE_CATEGORY_USERNAME)
+ .setVisible(shouldDisplayUsernameReminder());
+ }
+
+ this.findPreference(PREFERENCE_CATEGORY_SMS_MMS)
+ .setSummary(SmsMmsPreferenceFragment.getSummary(getActivity()));
+ this.findPreference(PREFERENCE_CATEGORY_NOTIFICATIONS)
+ .setSummary(NotificationsPreferenceFragment.getSummary(getActivity()));
+ this.findPreference(PREFERENCE_CATEGORY_APP_PROTECTION)
+ .setSummary(AppProtectionPreferenceFragment.getSummary(getActivity()));
+ this.findPreference(PREFERENCE_CATEGORY_APPEARANCE)
+ .setSummary(AppearancePreferenceFragment.getSummary(getActivity()));
+ this.findPreference(PREFERENCE_CATEGORY_CHATS)
+ .setSummary(ChatsPreferenceFragment.getSummary(getActivity()));
+ }
+
+ private void setCategoryVisibility() {
+ Preference devicePreference = this.findPreference(PREFERENCE_CATEGORY_DEVICES);
+ if (devicePreference != null && !TextSecurePreferences.isPushRegistered(getActivity())) {
+ getPreferenceScreen().removePreference(devicePreference);
+ }
+ }
+
+ private static boolean shouldDisplayUsernameReminder() {
+ return FeatureFlags.usernames() && !Recipient.self().getUsername().isPresent() && SignalStore.misc().shouldShowUsernameReminder();
+ }
+
+ private class CategoryClickListener implements Preference.OnPreferenceClickListener {
+ private String category;
+
+ CategoryClickListener(String category) {
+ this.category = category;
+ }
+
+ @Override
+ public boolean onPreferenceClick(Preference preference) {
+ Fragment fragment = null;
+
+ switch (category) {
+ case PREFERENCE_CATEGORY_SMS_MMS:
+ fragment = new SmsMmsPreferenceFragment();
+ break;
+ case PREFERENCE_CATEGORY_NOTIFICATIONS:
+ fragment = new NotificationsPreferenceFragment();
+ break;
+ case PREFERENCE_CATEGORY_APP_PROTECTION:
+ fragment = new AppProtectionPreferenceFragment();
+ break;
+ case PREFERENCE_CATEGORY_APPEARANCE:
+ fragment = new AppearancePreferenceFragment();
+ break;
+ case PREFERENCE_CATEGORY_CHATS:
+ fragment = new ChatsPreferenceFragment();
+ break;
+ case PREFERENCE_CATEGORY_STORAGE:
+ fragment = new DataAndStoragePreferenceFragment();
+ break;
+ case PREFERENCE_CATEGORY_DEVICES:
+ Intent intent = new Intent(getActivity(), DeviceActivity.class);
+ startActivity(intent);
+ break;
+ case PREFERENCE_CATEGORY_ADVANCED:
+ fragment = new AdvancedPreferenceFragment();
+ break;
+ case PREFERENCE_CATEGORY_HELP:
+ fragment = new HelpFragment();
+ break;
+ case PREFERENCE_CATEGORY_DONATE:
+ CommunicationActions.openBrowserLink(requireContext(), getString(R.string.donate_url));
+ break;
+ default:
+ throw new AssertionError();
+ }
+
+ if (fragment != null) {
+ Bundle args = new Bundle();
+ fragment.setArguments(args);
+
+ ((ApplicationPreferencesActivity) requireActivity()).pushFragment(fragment);
+ }
+
+ return true;
+ }
+ }
+
+ private class ProfileClickListener implements Preference.OnPreferenceClickListener {
+ @Override
+ public boolean onPreferenceClick(Preference preference) {
+ requireActivity().startActivity(ManageProfileActivity.getIntent(requireActivity()));
+ return true;
+ }
+ }
+
+ private class UsernameClickListener implements Preference.OnPreferenceClickListener {
+ @Override
+ public boolean onPreferenceClick(Preference preference) {
+ requireActivity().startActivity(ManageProfileActivity.getIntentForUsernameEdit(preference.getContext()));
+ return true;
+ }
+ }
+ }
+
+}
diff --git a/app/src/main/java/org/thoughtcrime/securesms/AvatarPreviewActivity.java b/app/src/main/java/org/thoughtcrime/securesms/AvatarPreviewActivity.java
new file mode 100644
index 00000000..dc72ab03
--- /dev/null
+++ b/app/src/main/java/org/thoughtcrime/securesms/AvatarPreviewActivity.java
@@ -0,0 +1,142 @@
+package org.thoughtcrime.securesms;
+
+import android.app.Activity;
+import android.content.Context;
+import android.content.Intent;
+import android.content.res.Resources;
+import android.graphics.Bitmap;
+import android.graphics.drawable.Drawable;
+import android.os.Build;
+import android.os.Bundle;
+import android.transition.TransitionInflater;
+import android.view.View;
+import android.widget.ImageView;
+
+import androidx.annotation.NonNull;
+import androidx.annotation.Nullable;
+import androidx.appcompat.widget.Toolbar;
+import androidx.core.app.ActivityOptionsCompat;
+import androidx.core.graphics.drawable.RoundedBitmapDrawableFactory;
+
+import com.bumptech.glide.load.DataSource;
+import com.bumptech.glide.load.engine.DiskCacheStrategy;
+import com.bumptech.glide.load.engine.GlideException;
+import com.bumptech.glide.request.RequestListener;
+import com.bumptech.glide.request.target.CustomTarget;
+import com.bumptech.glide.request.target.Target;
+import com.bumptech.glide.request.transition.Transition;
+
+import org.signal.core.util.logging.Log;
+import org.thoughtcrime.securesms.contacts.avatars.ContactPhoto;
+import org.thoughtcrime.securesms.contacts.avatars.FallbackContactPhoto;
+import org.thoughtcrime.securesms.contacts.avatars.ProfileContactPhoto;
+import org.thoughtcrime.securesms.contacts.avatars.ResourceContactPhoto;
+import org.thoughtcrime.securesms.mms.GlideApp;
+import org.thoughtcrime.securesms.recipients.Recipient;
+import org.thoughtcrime.securesms.recipients.RecipientId;
+import org.thoughtcrime.securesms.util.FullscreenHelper;
+
+/**
+ * Activity for displaying avatars full screen.
+ */
+public final class AvatarPreviewActivity extends PassphraseRequiredActivity {
+
+ private static final String TAG = Log.tag(AvatarPreviewActivity.class);
+
+ private static final String RECIPIENT_ID_EXTRA = "recipient_id";
+
+ public static @NonNull Intent intentFromRecipientId(@NonNull Context context,
+ @NonNull RecipientId recipientId)
+ {
+ Intent intent = new Intent(context, AvatarPreviewActivity.class);
+ intent.putExtra(RECIPIENT_ID_EXTRA, recipientId.serialize());
+ return intent;
+ }
+
+ public static Bundle createTransitionBundle(@NonNull Activity activity, @NonNull View from) {
+ return ActivityOptionsCompat.makeSceneTransitionAnimation(activity, from, "avatar").toBundle();
+ }
+
+ @Override
+ protected void onCreate(Bundle savedInstanceState, boolean ready) {
+ super.onCreate(savedInstanceState, ready);
+
+ setTheme(R.style.TextSecure_MediaPreview);
+ setContentView(R.layout.contact_photo_preview_activity);
+
+ if (Build.VERSION.SDK_INT >= 21) {
+ postponeEnterTransition();
+ TransitionInflater inflater = TransitionInflater.from(this);
+ getWindow().setSharedElementEnterTransition(inflater.inflateTransition(R.transition.full_screen_avatar_image_enter_transition_set));
+ getWindow().setSharedElementReturnTransition(inflater.inflateTransition(R.transition.full_screen_avatar_image_return_transition_set));
+ }
+
+ Toolbar toolbar = findViewById(R.id.toolbar);
+ ImageView avatar = findViewById(R.id.avatar);
+
+ setSupportActionBar(toolbar);
+
+ requireSupportActionBar().setDisplayHomeAsUpEnabled(true);
+
+ Context context = getApplicationContext();
+ RecipientId recipientId = RecipientId.from(getIntent().getStringExtra(RECIPIENT_ID_EXTRA));
+
+ Recipient.live(recipientId).observe(this, recipient -> {
+ ContactPhoto contactPhoto = recipient.isSelf() ? new ProfileContactPhoto(recipient, recipient.getProfileAvatar())
+ : recipient.getContactPhoto();
+ FallbackContactPhoto fallbackPhoto = recipient.isSelf() ? new ResourceContactPhoto(R.drawable.ic_profile_outline_40, R.drawable.ic_profile_outline_20, R.drawable.ic_person_large)
+ : recipient.getFallbackContactPhoto();
+
+ Resources resources = this.getResources();
+
+ GlideApp.with(this)
+ .asBitmap()
+ .load(contactPhoto)
+ .fallback(fallbackPhoto.asCallCard(this))
+ .error(fallbackPhoto.asCallCard(this))
+ .diskCacheStrategy(DiskCacheStrategy.ALL)
+ .addListener(new RequestListener() {
+ @Override
+ public boolean onLoadFailed(@Nullable GlideException e, Object model, Target target, boolean isFirstResource) {
+ Log.w(TAG, "Unable to load avatar, or avatar removed, closing");
+ finish();
+ return false;
+ }
+
+ @Override
+ public boolean onResourceReady(Bitmap resource, Object model, Target target, DataSource dataSource, boolean isFirstResource) {
+ return false;
+ }
+ })
+ .into(new CustomTarget() {
+ @Override
+ public void onResourceReady(@NonNull Bitmap resource, @Nullable Transition super Bitmap> transition) {
+ avatar.setImageDrawable(RoundedBitmapDrawableFactory.create(resources, resource));
+ if (Build.VERSION.SDK_INT >= 21) {
+ startPostponedEnterTransition();
+ }
+ }
+
+ @Override
+ public void onLoadCleared(@Nullable Drawable placeholder) {
+ }
+ });
+
+ toolbar.setTitle(recipient.getDisplayName(context));
+ });
+
+ FullscreenHelper fullscreenHelper = new FullscreenHelper(this);
+
+ findViewById(android.R.id.content).setOnClickListener(v -> fullscreenHelper.toggleUiVisibility());
+
+ fullscreenHelper.configureToolbarSpacer(findViewById(R.id.toolbar_cutout_spacer));
+
+ fullscreenHelper.showAndHideWithSystemUI(getWindow(), findViewById(R.id.toolbar_layout));
+ }
+
+ @Override
+ public boolean onSupportNavigateUp() {
+ onBackPressed();
+ return true;
+ }
+}
diff --git a/app/src/main/java/org/thoughtcrime/securesms/BaseActivity.java b/app/src/main/java/org/thoughtcrime/securesms/BaseActivity.java
new file mode 100644
index 00000000..de9dc253
--- /dev/null
+++ b/app/src/main/java/org/thoughtcrime/securesms/BaseActivity.java
@@ -0,0 +1,116 @@
+package org.thoughtcrime.securesms;
+
+import android.content.Context;
+import android.content.Intent;
+import android.content.res.Configuration;
+import android.os.Bundle;
+import android.view.View;
+import android.view.WindowManager;
+
+import androidx.annotation.NonNull;
+import androidx.appcompat.app.ActionBar;
+import androidx.appcompat.app.AppCompatActivity;
+import androidx.appcompat.app.AppCompatDelegate;
+import androidx.core.app.ActivityCompat;
+import androidx.core.app.ActivityOptionsCompat;
+
+import org.signal.core.util.logging.Log;
+import org.thoughtcrime.securesms.dependencies.ApplicationDependencies;
+import org.thoughtcrime.securesms.util.AppStartup;
+import org.thoughtcrime.securesms.util.ConfigurationUtil;
+import org.thoughtcrime.securesms.util.TextSecurePreferences;
+import org.thoughtcrime.securesms.util.dynamiclanguage.DynamicLanguageContextWrapper;
+
+import java.util.Objects;
+
+/**
+ * Base class for all activities. The vast majority of activities shouldn't extend this directly.
+ * Instead, they should extend {@link PassphraseRequiredActivity} so they're protected by
+ * screen lock.
+ */
+public abstract class BaseActivity extends AppCompatActivity {
+ private static final String TAG = Log.tag(BaseActivity.class);
+
+ @Override
+ protected void onCreate(Bundle savedInstanceState) {
+ AppStartup.getInstance().onCriticalRenderEventStart();
+ logEvent("onCreate()");
+ super.onCreate(savedInstanceState);
+ AppStartup.getInstance().onCriticalRenderEventEnd();
+ }
+
+ @Override
+ protected void onResume() {
+ super.onResume();
+ initializeScreenshotSecurity();
+ }
+
+ @Override
+ protected void onStart() {
+ logEvent("onStart()");
+ ApplicationDependencies.getShakeToReport().registerActivity(this);
+ super.onStart();
+ }
+
+ @Override
+ protected void onStop() {
+ logEvent("onStop()");
+ super.onStop();
+ }
+
+ @Override
+ protected void onDestroy() {
+ logEvent("onDestroy()");
+ super.onDestroy();
+ }
+
+ private void initializeScreenshotSecurity() {
+ if (TextSecurePreferences.isScreenSecurityEnabled(this)) {
+ getWindow().addFlags(WindowManager.LayoutParams.FLAG_SECURE);
+ } else {
+ getWindow().clearFlags(WindowManager.LayoutParams.FLAG_SECURE);
+ }
+ }
+
+ protected void startActivitySceneTransition(Intent intent, View sharedView, String transitionName) {
+ Bundle bundle = ActivityOptionsCompat.makeSceneTransitionAnimation(this, sharedView, transitionName)
+ .toBundle();
+ ActivityCompat.startActivity(this, intent, bundle);
+ }
+
+ @Override
+ protected void attachBaseContext(@NonNull Context newBase) {
+ super.attachBaseContext(newBase);
+
+ Configuration configuration = new Configuration(newBase.getResources().getConfiguration());
+ int appCompatNightMode = getDelegate().getLocalNightMode() != AppCompatDelegate.MODE_NIGHT_UNSPECIFIED ? getDelegate().getLocalNightMode()
+ : AppCompatDelegate.getDefaultNightMode();
+
+ configuration.uiMode = (configuration.uiMode & ~Configuration.UI_MODE_NIGHT_MASK) | mapNightModeToConfigurationUiMode(newBase, appCompatNightMode);
+
+ applyOverrideConfiguration(configuration);
+ }
+
+ @Override
+ public void applyOverrideConfiguration(@NonNull Configuration overrideConfiguration) {
+ DynamicLanguageContextWrapper.prepareOverrideConfiguration(this, overrideConfiguration);
+ super.applyOverrideConfiguration(overrideConfiguration);
+ }
+
+ private void logEvent(@NonNull String event) {
+ Log.d(TAG, "[" + Log.tag(getClass()) + "] " + event);
+ }
+
+ public final @NonNull ActionBar requireSupportActionBar() {
+ return Objects.requireNonNull(getSupportActionBar());
+ }
+
+ private static int mapNightModeToConfigurationUiMode(@NonNull Context context, @AppCompatDelegate.NightMode int appCompatNightMode) {
+ if (appCompatNightMode == AppCompatDelegate.MODE_NIGHT_YES) {
+ return Configuration.UI_MODE_NIGHT_YES;
+ } else if (appCompatNightMode == AppCompatDelegate.MODE_NIGHT_NO) {
+ return Configuration.UI_MODE_NIGHT_NO;
+ }
+ return ConfigurationUtil.getNightModeConfiguration(context.getApplicationContext());
+ }
+}
diff --git a/app/src/main/java/org/thoughtcrime/securesms/BindableConversationItem.java b/app/src/main/java/org/thoughtcrime/securesms/BindableConversationItem.java
new file mode 100644
index 00000000..17b2e835
--- /dev/null
+++ b/app/src/main/java/org/thoughtcrime/securesms/BindableConversationItem.java
@@ -0,0 +1,74 @@
+package org.thoughtcrime.securesms;
+
+import android.net.Uri;
+import android.view.View;
+
+import androidx.annotation.NonNull;
+import androidx.annotation.Nullable;
+import androidx.lifecycle.LifecycleOwner;
+import androidx.lifecycle.Observer;
+
+import org.thoughtcrime.securesms.components.voice.VoiceNotePlaybackState;
+import org.thoughtcrime.securesms.contactshare.Contact;
+import org.thoughtcrime.securesms.conversation.ConversationMessage;
+import org.thoughtcrime.securesms.database.model.MessageRecord;
+import org.thoughtcrime.securesms.database.model.MmsMessageRecord;
+import org.thoughtcrime.securesms.groups.GroupId;
+import org.thoughtcrime.securesms.groups.GroupMigrationMembershipChange;
+import org.thoughtcrime.securesms.linkpreview.LinkPreview;
+import org.thoughtcrime.securesms.mms.GlideRequests;
+import org.thoughtcrime.securesms.recipients.Recipient;
+import org.thoughtcrime.securesms.recipients.RecipientId;
+import org.thoughtcrime.securesms.stickers.StickerLocator;
+import org.whispersystems.libsignal.util.guava.Optional;
+
+import java.util.List;
+import java.util.Locale;
+import java.util.Set;
+
+public interface BindableConversationItem extends Unbindable {
+ void bind(@NonNull LifecycleOwner lifecycleOwner,
+ @NonNull ConversationMessage messageRecord,
+ @NonNull Optional previousMessageRecord,
+ @NonNull Optional nextMessageRecord,
+ @NonNull GlideRequests glideRequests,
+ @NonNull Locale locale,
+ @NonNull Set batchSelected,
+ @NonNull Recipient recipients,
+ @Nullable String searchQuery,
+ boolean pulseMention,
+ boolean hasWallpaper,
+ boolean isMessageRequestAccepted);
+
+ ConversationMessage getConversationMessage();
+
+ void setEventListener(@Nullable EventListener listener);
+
+ interface EventListener {
+ void onQuoteClicked(MmsMessageRecord messageRecord);
+ void onLinkPreviewClicked(@NonNull LinkPreview linkPreview);
+ void onMoreTextClicked(@NonNull RecipientId conversationRecipientId, long messageId, boolean isMms);
+ void onStickerClicked(@NonNull StickerLocator stickerLocator);
+ void onViewOnceMessageClicked(@NonNull MmsMessageRecord messageRecord);
+ void onSharedContactDetailsClicked(@NonNull Contact contact, @NonNull View avatarTransitionView);
+ void onAddToContactsClicked(@NonNull Contact contact);
+ void onMessageSharedContactClicked(@NonNull List choices);
+ void onInviteSharedContactClicked(@NonNull List choices);
+ void onReactionClicked(@NonNull View reactionTarget, long messageId, boolean isMms);
+ void onGroupMemberClicked(@NonNull RecipientId recipientId, @NonNull GroupId groupId);
+ void onMessageWithErrorClicked(@NonNull MessageRecord messageRecord);
+ void onRegisterVoiceNoteCallbacks(@NonNull Observer onPlaybackStartObserver);
+ void onUnregisterVoiceNoteCallbacks(@NonNull Observer onPlaybackStartObserver);
+ void onVoiceNotePause(@NonNull Uri uri);
+ void onVoiceNotePlay(@NonNull Uri uri, long messageId, double position);
+ void onVoiceNoteSeekTo(@NonNull Uri uri, double position);
+ void onGroupMigrationLearnMoreClicked(@NonNull GroupMigrationMembershipChange membershipChange);
+ void onDecryptionFailedLearnMoreClicked();
+ void onSafetyNumberLearnMoreClicked(@NonNull Recipient recipient);
+ void onJoinGroupCallClicked();
+ void onInviteFriendsToGroupClicked(@NonNull GroupId.V2 groupId);
+
+ /** @return true if handled, false if you want to let the normal url handling continue */
+ boolean onUrlClicked(@NonNull String url);
+ }
+}
diff --git a/app/src/main/java/org/thoughtcrime/securesms/BindableConversationListItem.java b/app/src/main/java/org/thoughtcrime/securesms/BindableConversationListItem.java
new file mode 100644
index 00000000..784d45cd
--- /dev/null
+++ b/app/src/main/java/org/thoughtcrime/securesms/BindableConversationListItem.java
@@ -0,0 +1,20 @@
+package org.thoughtcrime.securesms;
+
+import androidx.annotation.NonNull;
+
+import org.thoughtcrime.securesms.database.model.ThreadRecord;
+import org.thoughtcrime.securesms.mms.GlideRequests;
+
+import java.util.Locale;
+import java.util.Set;
+
+public interface BindableConversationListItem extends Unbindable {
+
+ void bind(@NonNull ThreadRecord thread,
+ @NonNull GlideRequests glideRequests, @NonNull Locale locale,
+ @NonNull Set typingThreads,
+ @NonNull Set selectedThreads, boolean batchMode);
+
+ void setBatchMode(boolean batchMode);
+ void updateTypingIndicator(@NonNull Set typingThreads);
+}
diff --git a/app/src/main/java/org/thoughtcrime/securesms/BlockUnblockDialog.java b/app/src/main/java/org/thoughtcrime/securesms/BlockUnblockDialog.java
new file mode 100644
index 00000000..6d70b17a
--- /dev/null
+++ b/app/src/main/java/org/thoughtcrime/securesms/BlockUnblockDialog.java
@@ -0,0 +1,125 @@
+package org.thoughtcrime.securesms;
+
+import android.content.Context;
+import android.content.res.Resources;
+
+import androidx.annotation.NonNull;
+import androidx.annotation.Nullable;
+import androidx.annotation.WorkerThread;
+import androidx.appcompat.app.AlertDialog;
+import androidx.lifecycle.Lifecycle;
+
+import org.thoughtcrime.securesms.database.DatabaseFactory;
+import org.thoughtcrime.securesms.recipients.Recipient;
+import org.thoughtcrime.securesms.util.concurrent.SimpleTask;
+
+/**
+ * This should be used whenever we want to prompt the user to block/unblock a recipient.
+ */
+public final class BlockUnblockDialog {
+
+ private BlockUnblockDialog() { }
+
+ public static void showBlockFor(@NonNull Context context,
+ @NonNull Lifecycle lifecycle,
+ @NonNull Recipient recipient,
+ @NonNull Runnable onBlock)
+ {
+ SimpleTask.run(lifecycle,
+ () -> buildBlockFor(context, recipient, onBlock, null),
+ AlertDialog.Builder::show);
+ }
+
+ public static void showBlockAndDeleteFor(@NonNull Context context,
+ @NonNull Lifecycle lifecycle,
+ @NonNull Recipient recipient,
+ @NonNull Runnable onBlock,
+ @NonNull Runnable onBlockAndDelete)
+ {
+ SimpleTask.run(lifecycle,
+ () -> buildBlockFor(context, recipient, onBlock, onBlockAndDelete),
+ AlertDialog.Builder::show);
+ }
+
+ public static void showUnblockFor(@NonNull Context context,
+ @NonNull Lifecycle lifecycle,
+ @NonNull Recipient recipient,
+ @NonNull Runnable onUnblock)
+ {
+ SimpleTask.run(lifecycle,
+ () -> buildUnblockFor(context, recipient, onUnblock),
+ AlertDialog.Builder::show);
+ }
+
+ @WorkerThread
+ private static AlertDialog.Builder buildBlockFor(@NonNull Context context,
+ @NonNull Recipient recipient,
+ @NonNull Runnable onBlock,
+ @Nullable Runnable onBlockAndDelete)
+ {
+ recipient = recipient.resolve();
+
+ AlertDialog.Builder builder = new AlertDialog.Builder(context);
+ Resources resources = context.getResources();
+
+ if (recipient.isGroup()) {
+ if (DatabaseFactory.getGroupDatabase(context).isActive(recipient.requireGroupId())) {
+ builder.setTitle(resources.getString(R.string.BlockUnblockDialog_block_and_leave_s, recipient.getDisplayName(context)));
+ builder.setMessage(R.string.BlockUnblockDialog_you_will_no_longer_receive_messages_or_updates);
+ builder.setPositiveButton(R.string.BlockUnblockDialog_block_and_leave, ((dialog, which) -> onBlock.run()));
+ builder.setNegativeButton(android.R.string.cancel, null);
+ } else {
+ builder.setTitle(resources.getString(R.string.BlockUnblockDialog_block_s, recipient.getDisplayName(context)));
+ builder.setMessage(R.string.BlockUnblockDialog_group_members_wont_be_able_to_add_you);
+ builder.setPositiveButton(R.string.RecipientPreferenceActivity_block, ((dialog, which) -> onBlock.run()));
+ builder.setNegativeButton(android.R.string.cancel, null);
+ }
+ } else {
+ builder.setTitle(resources.getString(R.string.BlockUnblockDialog_block_s, recipient.getDisplayName(context)));
+ builder.setMessage(R.string.BlockUnblockDialog_blocked_people_wont_be_able_to_call_you_or_send_you_messages);
+
+ if (onBlockAndDelete != null) {
+ builder.setNeutralButton(android.R.string.cancel, null);
+ builder.setPositiveButton(R.string.BlockUnblockDialog_block_and_delete, (d, w) -> onBlockAndDelete.run());
+ builder.setNegativeButton(R.string.BlockUnblockDialog_block, (d, w) -> onBlock.run());
+ } else {
+ builder.setPositiveButton(R.string.BlockUnblockDialog_block, ((dialog, which) -> onBlock.run()));
+ builder.setNegativeButton(android.R.string.cancel, null);
+ }
+ }
+
+ return builder;
+ }
+
+ @WorkerThread
+ private static AlertDialog.Builder buildUnblockFor(@NonNull Context context,
+ @NonNull Recipient recipient,
+ @NonNull Runnable onUnblock)
+ {
+ recipient = recipient.resolve();
+
+ AlertDialog.Builder builder = new AlertDialog.Builder(context);
+ Resources resources = context.getResources();
+
+ if (recipient.isGroup()) {
+ if (DatabaseFactory.getGroupDatabase(context).isActive(recipient.requireGroupId())) {
+ builder.setTitle(resources.getString(R.string.BlockUnblockDialog_unblock_s, recipient.getDisplayName(context)));
+ builder.setMessage(R.string.BlockUnblockDialog_group_members_will_be_able_to_add_you);
+ builder.setPositiveButton(R.string.RecipientPreferenceActivity_unblock, ((dialog, which) -> onUnblock.run()));
+ builder.setNegativeButton(android.R.string.cancel, null);
+ } else {
+ builder.setTitle(resources.getString(R.string.BlockUnblockDialog_unblock_s, recipient.getDisplayName(context)));
+ builder.setMessage(R.string.BlockUnblockDialog_group_members_will_be_able_to_add_you);
+ builder.setPositiveButton(R.string.RecipientPreferenceActivity_unblock, ((dialog, which) -> onUnblock.run()));
+ builder.setNegativeButton(android.R.string.cancel, null);
+ }
+ } else {
+ builder.setTitle(resources.getString(R.string.BlockUnblockDialog_unblock_s, recipient.getDisplayName(context)));
+ builder.setMessage(R.string.BlockUnblockDialog_you_will_be_able_to_call_and_message_each_other);
+ builder.setPositiveButton(R.string.RecipientPreferenceActivity_unblock, ((dialog, which) -> onUnblock.run()));
+ builder.setNegativeButton(android.R.string.cancel, null);
+ }
+
+ return builder;
+ }
+}
diff --git a/app/src/main/java/org/thoughtcrime/securesms/ClearAvatarPromptActivity.java b/app/src/main/java/org/thoughtcrime/securesms/ClearAvatarPromptActivity.java
new file mode 100644
index 00000000..c668435c
--- /dev/null
+++ b/app/src/main/java/org/thoughtcrime/securesms/ClearAvatarPromptActivity.java
@@ -0,0 +1,48 @@
+package org.thoughtcrime.securesms;
+
+
+import android.app.Activity;
+import android.content.Intent;
+import android.view.ContextThemeWrapper;
+
+import androidx.appcompat.app.AlertDialog;
+
+import org.thoughtcrime.securesms.dependencies.ApplicationDependencies;
+import org.thoughtcrime.securesms.util.DynamicTheme;
+
+public final class ClearAvatarPromptActivity extends Activity {
+
+ private static final String ARG_TITLE = "arg_title";
+
+ public static Intent createForUserProfilePhoto() {
+ Intent intent = new Intent(ApplicationDependencies.getApplication(), ClearAvatarPromptActivity.class);
+ intent.putExtra(ARG_TITLE, R.string.ClearProfileActivity_remove_profile_photo);
+ return intent;
+ }
+
+ public static Intent createForGroupProfilePhoto() {
+ Intent intent = new Intent(ApplicationDependencies.getApplication(), ClearAvatarPromptActivity.class);
+ intent.putExtra(ARG_TITLE, R.string.ClearProfileActivity_remove_group_photo);
+ return intent;
+ }
+
+ @Override
+ public void onResume() {
+ super.onResume();
+
+ int message = getIntent().getIntExtra(ARG_TITLE, 0);
+
+ new AlertDialog.Builder(new ContextThemeWrapper(this, DynamicTheme.isDarkTheme(this) ? R.style.TextSecure_DarkTheme : R.style.TextSecure_LightTheme))
+ .setMessage(message)
+ .setNegativeButton(android.R.string.cancel, (dialog, which) -> finish())
+ .setPositiveButton(R.string.ClearProfileActivity_remove, (dialog, which) -> {
+ Intent result = new Intent();
+ result.putExtra("delete", true);
+ setResult(Activity.RESULT_OK, result);
+ finish();
+ })
+ .setOnCancelListener(dialog -> finish())
+ .show();
+ }
+
+}
diff --git a/app/src/main/java/org/thoughtcrime/securesms/ConfirmIdentityDialog.java b/app/src/main/java/org/thoughtcrime/securesms/ConfirmIdentityDialog.java
new file mode 100644
index 00000000..f35e2200
--- /dev/null
+++ b/app/src/main/java/org/thoughtcrime/securesms/ConfirmIdentityDialog.java
@@ -0,0 +1,176 @@
+package org.thoughtcrime.securesms;
+
+import android.annotation.SuppressLint;
+import android.content.Context;
+import android.content.DialogInterface;
+import android.os.AsyncTask;
+import android.text.SpannableString;
+import android.text.Spanned;
+import android.text.method.LinkMovementMethod;
+import android.widget.TextView;
+
+import androidx.appcompat.app.AlertDialog;
+
+import org.thoughtcrime.securesms.crypto.DatabaseSessionLock;
+import org.thoughtcrime.securesms.crypto.storage.TextSecureIdentityKeyStore;
+import org.thoughtcrime.securesms.database.DatabaseFactory;
+import org.thoughtcrime.securesms.database.MessageDatabase;
+import org.thoughtcrime.securesms.database.PushDatabase;
+import org.thoughtcrime.securesms.database.documents.IdentityKeyMismatch;
+import org.thoughtcrime.securesms.database.model.MessageRecord;
+import org.thoughtcrime.securesms.dependencies.ApplicationDependencies;
+import org.thoughtcrime.securesms.jobs.PushDecryptMessageJob;
+import org.thoughtcrime.securesms.recipients.Recipient;
+import org.thoughtcrime.securesms.recipients.RecipientId;
+import org.thoughtcrime.securesms.recipients.RecipientUtil;
+import org.thoughtcrime.securesms.sms.MessageSender;
+import org.thoughtcrime.securesms.util.Base64;
+import org.thoughtcrime.securesms.util.VerifySpan;
+import org.whispersystems.libsignal.SignalProtocolAddress;
+import org.whispersystems.libsignal.util.guava.Optional;
+import org.whispersystems.signalservice.api.SignalSessionLock;
+import org.whispersystems.signalservice.api.messages.SignalServiceEnvelope;
+import org.whispersystems.signalservice.internal.push.SignalServiceProtos;
+
+import java.io.IOException;
+
+public class ConfirmIdentityDialog extends AlertDialog {
+
+ @SuppressWarnings("unused")
+ private static final String TAG = ConfirmIdentityDialog.class.getSimpleName();
+
+ private OnClickListener callback;
+
+ public ConfirmIdentityDialog(Context context,
+ MessageRecord messageRecord,
+ IdentityKeyMismatch mismatch)
+ {
+ super(context);
+
+ Recipient recipient = Recipient.resolved(mismatch.getRecipientId(context));
+ String name = recipient.getDisplayName(context);
+ String introduction = context.getString(R.string.ConfirmIdentityDialog_your_safety_number_with_s_has_changed, name, name);
+ SpannableString spannableString = new SpannableString(introduction + " " +
+ context.getString(R.string.ConfirmIdentityDialog_you_may_wish_to_verify_your_safety_number_with_this_contact));
+
+ spannableString.setSpan(new VerifySpan(context, mismatch),
+ introduction.length()+1, spannableString.length(),
+ Spanned.SPAN_EXCLUSIVE_EXCLUSIVE);
+
+ setTitle(name);
+ setMessage(spannableString);
+
+ setButton(AlertDialog.BUTTON_POSITIVE, context.getString(R.string.ConfirmIdentityDialog_accept), new AcceptListener(messageRecord, mismatch, recipient.getId()));
+ setButton(AlertDialog.BUTTON_NEGATIVE, context.getString(android.R.string.cancel), new CancelListener());
+ }
+
+ @Override
+ public void show() {
+ super.show();
+ ((TextView)this.findViewById(android.R.id.message))
+ .setMovementMethod(LinkMovementMethod.getInstance());
+ }
+
+ public void setCallback(OnClickListener callback) {
+ this.callback = callback;
+ }
+
+ private class AcceptListener implements OnClickListener {
+
+ private final MessageRecord messageRecord;
+ private final IdentityKeyMismatch mismatch;
+ private final RecipientId recipientId;
+
+ private AcceptListener(MessageRecord messageRecord, IdentityKeyMismatch mismatch, RecipientId recipientId) {
+ this.messageRecord = messageRecord;
+ this.mismatch = mismatch;
+ this.recipientId = recipientId;
+ }
+
+ @SuppressLint("StaticFieldLeak")
+ @Override
+ public void onClick(DialogInterface dialog, int which) {
+ new AsyncTask()
+ {
+ @Override
+ protected Void doInBackground(Void... params) {
+ try (SignalSessionLock.Lock unused = DatabaseSessionLock.INSTANCE.acquire()) {
+ SignalProtocolAddress mismatchAddress = new SignalProtocolAddress(Recipient.resolved(recipientId).requireServiceId(), 1);
+ TextSecureIdentityKeyStore identityKeyStore = new TextSecureIdentityKeyStore(getContext());
+
+ identityKeyStore.saveIdentity(mismatchAddress, mismatch.getIdentityKey(), true);
+ }
+
+ processMessageRecord(messageRecord);
+
+ return null;
+ }
+
+ private void processMessageRecord(MessageRecord messageRecord) {
+ if (messageRecord.isOutgoing()) processOutgoingMessageRecord(messageRecord);
+ else processIncomingMessageRecord(messageRecord);
+ }
+
+ private void processOutgoingMessageRecord(MessageRecord messageRecord) {
+ MessageDatabase smsDatabase = DatabaseFactory.getSmsDatabase(getContext());
+ MessageDatabase mmsDatabase = DatabaseFactory.getMmsDatabase(getContext());
+
+ if (messageRecord.isMms()) {
+ mmsDatabase.removeMismatchedIdentity(messageRecord.getId(),
+ mismatch.getRecipientId(getContext()),
+ mismatch.getIdentityKey());
+
+ if (messageRecord.getRecipient().isPushGroup()) {
+ MessageSender.resendGroupMessage(getContext(), messageRecord, Recipient.resolved(mismatch.getRecipientId(getContext())).getId());
+ } else {
+ MessageSender.resend(getContext(), messageRecord);
+ }
+ } else {
+ smsDatabase.removeMismatchedIdentity(messageRecord.getId(),
+ mismatch.getRecipientId(getContext()),
+ mismatch.getIdentityKey());
+
+ MessageSender.resend(getContext(), messageRecord);
+ }
+ }
+
+ private void processIncomingMessageRecord(MessageRecord messageRecord) {
+ try {
+ MessageDatabase smsDatabase = DatabaseFactory.getSmsDatabase(getContext());
+
+ smsDatabase.removeMismatchedIdentity(messageRecord.getId(),
+ mismatch.getRecipientId(getContext()),
+ mismatch.getIdentityKey());
+
+ boolean legacy = !messageRecord.isContentBundleKeyExchange();
+
+ SignalServiceEnvelope envelope = new SignalServiceEnvelope(SignalServiceProtos.Envelope.Type.PREKEY_BUNDLE_VALUE,
+ Optional.of(RecipientUtil.toSignalServiceAddress(getContext(), messageRecord.getIndividualRecipient())),
+ messageRecord.getRecipientDeviceId(),
+ messageRecord.getDateSent(),
+ legacy ? Base64.decode(messageRecord.getBody()) : null,
+ !legacy ? Base64.decode(messageRecord.getBody()) : null,
+ 0,
+ 0,
+ null);
+
+ ApplicationDependencies.getJobManager().add(new PushDecryptMessageJob(getContext(), envelope, messageRecord.getId()));
+ } catch (IOException e) {
+ throw new AssertionError(e);
+ }
+ }
+
+ }.executeOnExecutor(AsyncTask.THREAD_POOL_EXECUTOR);
+
+ if (callback != null) callback.onClick(null, 0);
+ }
+ }
+
+ private class CancelListener implements OnClickListener {
+ @Override
+ public void onClick(DialogInterface dialog, int which) {
+ if (callback != null) callback.onClick(null, 0);
+ }
+ }
+
+}
diff --git a/app/src/main/java/org/thoughtcrime/securesms/ContactSelectionActivity.java b/app/src/main/java/org/thoughtcrime/securesms/ContactSelectionActivity.java
new file mode 100644
index 00000000..b7062343
--- /dev/null
+++ b/app/src/main/java/org/thoughtcrime/securesms/ContactSelectionActivity.java
@@ -0,0 +1,162 @@
+/*
+ * Copyright (C) 2015 Open Whisper Systems
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see .
+ */
+package org.thoughtcrime.securesms;
+
+import android.content.Context;
+import android.os.AsyncTask;
+import android.os.Bundle;
+
+import androidx.swiperefreshlayout.widget.SwipeRefreshLayout;
+
+import org.signal.core.util.logging.Log;
+import org.thoughtcrime.securesms.components.ContactFilterToolbar;
+import org.thoughtcrime.securesms.contacts.ContactsCursorLoader.DisplayMode;
+import org.thoughtcrime.securesms.contacts.sync.DirectoryHelper;
+import org.thoughtcrime.securesms.recipients.RecipientId;
+import org.thoughtcrime.securesms.util.DynamicNoActionBarTheme;
+import org.thoughtcrime.securesms.util.DynamicTheme;
+import org.thoughtcrime.securesms.util.ServiceUtil;
+import org.thoughtcrime.securesms.util.TextSecurePreferences;
+import org.whispersystems.libsignal.util.guava.Optional;
+
+import java.io.IOException;
+import java.lang.ref.WeakReference;
+
+/**
+ * Base activity container for selecting a list of contacts.
+ *
+ * @author Moxie Marlinspike
+ *
+ */
+public abstract class ContactSelectionActivity extends PassphraseRequiredActivity
+ implements SwipeRefreshLayout.OnRefreshListener,
+ ContactSelectionListFragment.OnContactSelectedListener,
+ ContactSelectionListFragment.ScrollCallback
+{
+ private static final String TAG = ContactSelectionActivity.class.getSimpleName();
+
+ public static final String EXTRA_LAYOUT_RES_ID = "layout_res_id";
+
+ private final DynamicTheme dynamicTheme = new DynamicNoActionBarTheme();
+
+ protected ContactSelectionListFragment contactsFragment;
+
+ private ContactFilterToolbar toolbar;
+
+ @Override
+ protected void onPreCreate() {
+ dynamicTheme.onCreate(this);
+ }
+
+ @Override
+ protected void onCreate(Bundle icicle, boolean ready) {
+ if (!getIntent().hasExtra(ContactSelectionListFragment.DISPLAY_MODE)) {
+ int displayMode = TextSecurePreferences.isSmsEnabled(this) ? DisplayMode.FLAG_ALL
+ : DisplayMode.FLAG_PUSH | DisplayMode.FLAG_ACTIVE_GROUPS | DisplayMode.FLAG_INACTIVE_GROUPS | DisplayMode.FLAG_SELF;
+ getIntent().putExtra(ContactSelectionListFragment.DISPLAY_MODE, displayMode);
+ }
+
+ setContentView(getIntent().getIntExtra(EXTRA_LAYOUT_RES_ID, R.layout.contact_selection_activity));
+
+ initializeToolbar();
+ initializeResources();
+ initializeSearch();
+ }
+
+ @Override
+ public void onResume() {
+ super.onResume();
+ dynamicTheme.onResume(this);
+ }
+
+ protected ContactFilterToolbar getToolbar() {
+ return toolbar;
+ }
+
+ private void initializeToolbar() {
+ this.toolbar = findViewById(R.id.toolbar);
+ setSupportActionBar(toolbar);
+
+ getSupportActionBar().setDisplayHomeAsUpEnabled(false);
+ getSupportActionBar().setDisplayShowTitleEnabled(false);
+ getSupportActionBar().setIcon(null);
+ getSupportActionBar().setLogo(null);
+ }
+
+ private void initializeResources() {
+ contactsFragment = (ContactSelectionListFragment) getSupportFragmentManager().findFragmentById(R.id.contact_selection_list_fragment);
+ contactsFragment.setOnRefreshListener(this);
+ }
+
+ private void initializeSearch() {
+ toolbar.setOnFilterChangedListener(filter -> contactsFragment.setQueryFilter(filter));
+ }
+
+ @Override
+ public void onRefresh() {
+ new RefreshDirectoryTask(this).executeOnExecutor(AsyncTask.THREAD_POOL_EXECUTOR, getApplicationContext());
+ }
+
+ @Override
+ public boolean onBeforeContactSelected(Optional recipientId, String number) {
+ return true;
+ }
+
+ @Override
+ public void onContactDeselected(Optional recipientId, String number) {}
+
+ @Override
+ public void onBeginScroll() {
+ hideKeyboard();
+ }
+
+ private void hideKeyboard() {
+ ServiceUtil.getInputMethodManager(this)
+ .hideSoftInputFromWindow(toolbar.getWindowToken(), 0);
+ toolbar.clearFocus();
+ }
+
+ private static class RefreshDirectoryTask extends AsyncTask {
+
+ private final WeakReference activity;
+
+ private RefreshDirectoryTask(ContactSelectionActivity activity) {
+ this.activity = new WeakReference<>(activity);
+ }
+
+ @Override
+ protected Void doInBackground(Context... params) {
+ try {
+ DirectoryHelper.refreshDirectory(params[0], true);
+ } catch (IOException e) {
+ Log.w(TAG, e);
+ }
+
+ return null;
+ }
+
+ @Override
+ protected void onPostExecute(Void result) {
+ ContactSelectionActivity activity = this.activity.get();
+
+ if (activity != null && !activity.isFinishing()) {
+ activity.toolbar.clear();
+ activity.contactsFragment.resetQueryFilter();
+ }
+ }
+ }
+}
diff --git a/app/src/main/java/org/thoughtcrime/securesms/ContactSelectionListFragment.java b/app/src/main/java/org/thoughtcrime/securesms/ContactSelectionListFragment.java
new file mode 100644
index 00000000..8e394aef
--- /dev/null
+++ b/app/src/main/java/org/thoughtcrime/securesms/ContactSelectionListFragment.java
@@ -0,0 +1,699 @@
+/*
+ * Copyright (C) 2015 Open Whisper Systems
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see .
+ */
+package org.thoughtcrime.securesms;
+
+
+import android.Manifest;
+import android.animation.LayoutTransition;
+import android.annotation.SuppressLint;
+import android.content.Context;
+import android.content.Intent;
+import android.database.Cursor;
+import android.os.AsyncTask;
+import android.os.Bundle;
+import android.text.TextUtils;
+import android.view.LayoutInflater;
+import android.view.View;
+import android.view.ViewGroup;
+import android.view.WindowManager;
+import android.widget.Button;
+import android.widget.HorizontalScrollView;
+import android.widget.TextView;
+import android.widget.Toast;
+
+import androidx.annotation.NonNull;
+import androidx.annotation.Nullable;
+import androidx.appcompat.app.AlertDialog;
+import androidx.constraintlayout.widget.ConstraintLayout;
+import androidx.constraintlayout.widget.ConstraintSet;
+import androidx.fragment.app.FragmentActivity;
+import androidx.loader.app.LoaderManager;
+import androidx.loader.content.Loader;
+import androidx.recyclerview.widget.DefaultItemAnimator;
+import androidx.recyclerview.widget.LinearLayoutManager;
+import androidx.recyclerview.widget.RecyclerView;
+import androidx.swiperefreshlayout.widget.SwipeRefreshLayout;
+import androidx.transition.AutoTransition;
+import androidx.transition.TransitionManager;
+
+import com.annimon.stream.Collectors;
+import com.annimon.stream.Stream;
+import com.google.android.material.chip.ChipGroup;
+import com.pnikosis.materialishprogress.ProgressWheel;
+
+import org.signal.core.util.logging.Log;
+import org.thoughtcrime.securesms.components.RecyclerViewFastScroller;
+import org.thoughtcrime.securesms.components.emoji.WarningTextView;
+import org.thoughtcrime.securesms.contacts.ContactChip;
+import org.thoughtcrime.securesms.contacts.ContactSelectionListAdapter;
+import org.thoughtcrime.securesms.contacts.ContactSelectionListItem;
+import org.thoughtcrime.securesms.contacts.ContactsCursorLoader;
+import org.thoughtcrime.securesms.contacts.ContactsCursorLoader.DisplayMode;
+import org.thoughtcrime.securesms.contacts.SelectedContact;
+import org.thoughtcrime.securesms.contacts.sync.DirectoryHelper;
+import org.thoughtcrime.securesms.groups.SelectionLimits;
+import org.thoughtcrime.securesms.groups.ui.GroupLimitDialog;
+import org.thoughtcrime.securesms.mms.GlideApp;
+import org.thoughtcrime.securesms.mms.GlideRequests;
+import org.thoughtcrime.securesms.permissions.Permissions;
+import org.thoughtcrime.securesms.recipients.LiveRecipient;
+import org.thoughtcrime.securesms.recipients.Recipient;
+import org.thoughtcrime.securesms.recipients.RecipientId;
+import org.thoughtcrime.securesms.util.StickyHeaderDecoration;
+import org.thoughtcrime.securesms.util.TextSecurePreferences;
+import org.thoughtcrime.securesms.util.UsernameUtil;
+import org.thoughtcrime.securesms.util.ViewUtil;
+import org.thoughtcrime.securesms.util.adapter.FixedViewsAdapter;
+import org.thoughtcrime.securesms.util.adapter.RecyclerViewConcatenateAdapterStickyHeader;
+import org.thoughtcrime.securesms.util.concurrent.SimpleTask;
+import org.thoughtcrime.securesms.util.views.SimpleProgressDialog;
+import org.whispersystems.libsignal.util.guava.Optional;
+
+import java.io.IOException;
+import java.util.Collections;
+import java.util.List;
+import java.util.Set;
+
+/**
+ * Fragment for selecting a one or more contacts from a list.
+ *
+ * @author Moxie Marlinspike
+ *
+ */
+public final class ContactSelectionListFragment extends LoggingFragment
+ implements LoaderManager.LoaderCallbacks
+{
+ @SuppressWarnings("unused")
+ private static final String TAG = Log.tag(ContactSelectionListFragment.class);
+
+ private static final int CHIP_GROUP_EMPTY_CHILD_COUNT = 1;
+ private static final int CHIP_GROUP_REVEAL_DURATION_MS = 150;
+
+ public static final int NO_LIMIT = Integer.MAX_VALUE;
+
+ public static final String DISPLAY_MODE = "display_mode";
+ public static final String REFRESHABLE = "refreshable";
+ public static final String RECENTS = "recents";
+ public static final String SELECTION_LIMITS = "selection_limits";
+ public static final String CURRENT_SELECTION = "current_selection";
+ public static final String HIDE_COUNT = "hide_count";
+ public static final String CAN_SELECT_SELF = "can_select_self";
+ public static final String DISPLAY_CHIPS = "display_chips";
+
+ private ConstraintLayout constraintLayout;
+ private TextView emptyText;
+ private OnContactSelectedListener onContactSelectedListener;
+ private SwipeRefreshLayout swipeRefresh;
+ private View showContactsLayout;
+ private Button showContactsButton;
+ private TextView showContactsDescription;
+ private ProgressWheel showContactsProgress;
+ private String cursorFilter;
+ private RecyclerView recyclerView;
+ private RecyclerViewFastScroller fastScroller;
+ private ContactSelectionListAdapter cursorRecyclerViewAdapter;
+ private ChipGroup chipGroup;
+ private HorizontalScrollView chipGroupScrollContainer;
+ private WarningTextView groupLimit;
+ private OnSelectionLimitReachedListener onSelectionLimitReachedListener;
+
+
+ @Nullable private FixedViewsAdapter headerAdapter;
+ @Nullable private FixedViewsAdapter footerAdapter;
+ @Nullable private ListCallback listCallback;
+ @Nullable private ScrollCallback scrollCallback;
+ private GlideRequests glideRequests;
+ private SelectionLimits selectionLimit = SelectionLimits.NO_LIMITS;
+ private Set currentSelection;
+ private boolean isMulti;
+ private boolean hideCount;
+ private boolean canSelectSelf;
+
+ @Override
+ public void onAttach(@NonNull Context context) {
+ super.onAttach(context);
+
+ if (context instanceof ListCallback) {
+ listCallback = (ListCallback) context;
+ }
+
+ if (context instanceof ScrollCallback) {
+ scrollCallback = (ScrollCallback) context;
+ }
+
+ if (context instanceof OnContactSelectedListener) {
+ onContactSelectedListener = (OnContactSelectedListener) context;
+ }
+
+ if (context instanceof OnSelectionLimitReachedListener) {
+ onSelectionLimitReachedListener = (OnSelectionLimitReachedListener) context;
+ }
+ }
+
+ @Override
+ public void onActivityCreated(Bundle icicle) {
+ super.onActivityCreated(icicle);
+
+ initializeCursor();
+ }
+
+ @Override
+ public void onStart() {
+ super.onStart();
+
+ Permissions.with(this)
+ .request(Manifest.permission.WRITE_CONTACTS, Manifest.permission.READ_CONTACTS)
+ .ifNecessary()
+ .onAllGranted(() -> {
+ if (!TextSecurePreferences.hasSuccessfullyRetrievedDirectory(getActivity())) {
+ handleContactPermissionGranted();
+ } else {
+ LoaderManager.getInstance(this).initLoader(0, null, this);
+ }
+ })
+ .onAnyDenied(() -> {
+ FragmentActivity activity = requireActivity();
+
+ activity.getWindow().setSoftInputMode(WindowManager.LayoutParams.SOFT_INPUT_STATE_ALWAYS_HIDDEN);
+
+ if (activity.getIntent().getBooleanExtra(RECENTS, false)) {
+ LoaderManager.getInstance(this).initLoader(0, null, ContactSelectionListFragment.this);
+ } else {
+ initializeNoContactsPermission();
+ }
+ })
+ .execute();
+ }
+
+ @Override
+ public View onCreateView(@NonNull LayoutInflater inflater, ViewGroup container, Bundle savedInstanceState) {
+ View view = inflater.inflate(R.layout.contact_selection_list_fragment, container, false);
+
+ emptyText = view.findViewById(android.R.id.empty);
+ recyclerView = view.findViewById(R.id.recycler_view);
+ swipeRefresh = view.findViewById(R.id.swipe_refresh);
+ fastScroller = view.findViewById(R.id.fast_scroller);
+ showContactsLayout = view.findViewById(R.id.show_contacts_container);
+ showContactsButton = view.findViewById(R.id.show_contacts_button);
+ showContactsDescription = view.findViewById(R.id.show_contacts_description);
+ showContactsProgress = view.findViewById(R.id.progress);
+ chipGroup = view.findViewById(R.id.chipGroup);
+ chipGroupScrollContainer = view.findViewById(R.id.chipGroupScrollContainer);
+ groupLimit = view.findViewById(R.id.group_limit);
+ constraintLayout = view.findViewById(R.id.container);
+
+ recyclerView.setLayoutManager(new LinearLayoutManager(getActivity()));
+ recyclerView.setItemAnimator(new DefaultItemAnimator() {
+ @Override
+ public boolean canReuseUpdatedViewHolder(@NonNull RecyclerView.ViewHolder viewHolder) {
+ return true;
+ }
+ });
+
+ Intent intent = requireActivity().getIntent();
+
+ swipeRefresh.setEnabled(intent.getBooleanExtra(REFRESHABLE, true));
+
+ hideCount = intent.getBooleanExtra(HIDE_COUNT, false);
+ selectionLimit = intent.getParcelableExtra(SELECTION_LIMITS);
+ isMulti = selectionLimit != null;
+ canSelectSelf = intent.getBooleanExtra(CAN_SELECT_SELF, !isMulti);
+
+ if (!isMulti) {
+ selectionLimit = SelectionLimits.NO_LIMITS;
+ }
+
+ currentSelection = getCurrentSelection();
+
+ updateGroupLimit(getChipCount());
+
+ return view;
+ }
+
+ private void updateGroupLimit(int chipCount) {
+ int members = currentSelection.size() + chipCount;
+ groupLimit.setText(getResources().getQuantityString(R.plurals.ContactSelectionListFragment_d_members, members, members));
+ groupLimit.setVisibility(isMulti && !hideCount ? View.VISIBLE : View.GONE);
+ groupLimit.setWarning(selectionWarningLimitExceeded());
+ }
+
+ @Override
+ public void onRequestPermissionsResult(int requestCode, @NonNull String[] permissions, @NonNull int[] grantResults) {
+ Permissions.onRequestPermissionsResult(this, requestCode, permissions, grantResults);
+ }
+
+ public @NonNull List getSelectedContacts() {
+ if (cursorRecyclerViewAdapter == null) {
+ return Collections.emptyList();
+ }
+
+ return cursorRecyclerViewAdapter.getSelectedContacts();
+ }
+
+ public int getSelectedContactsCount() {
+ if (cursorRecyclerViewAdapter == null) {
+ return 0;
+ }
+
+ return cursorRecyclerViewAdapter.getSelectedContactsCount();
+ }
+
+ private Set getCurrentSelection() {
+ List currentSelection = requireActivity().getIntent().getParcelableArrayListExtra(CURRENT_SELECTION);
+
+ return currentSelection == null ? Collections.emptySet()
+ : Collections.unmodifiableSet(Stream.of(currentSelection).collect(Collectors.toSet()));
+ }
+
+ public boolean isMulti() {
+ return isMulti;
+ }
+
+ private void initializeCursor() {
+ glideRequests = GlideApp.with(this);
+
+ cursorRecyclerViewAdapter = new ContactSelectionListAdapter(requireContext(),
+ glideRequests,
+ null,
+ new ListClickListener(),
+ isMulti,
+ currentSelection);
+
+ RecyclerViewConcatenateAdapterStickyHeader concatenateAdapter = new RecyclerViewConcatenateAdapterStickyHeader();
+
+ if (listCallback != null) {
+ headerAdapter = new FixedViewsAdapter(createNewGroupItem(listCallback));
+ headerAdapter.hide();
+ concatenateAdapter.addAdapter(headerAdapter);
+ }
+
+ concatenateAdapter.addAdapter(cursorRecyclerViewAdapter);
+
+ if (listCallback != null) {
+ footerAdapter = new FixedViewsAdapter(createInviteActionView(listCallback));
+ footerAdapter.hide();
+ concatenateAdapter.addAdapter(footerAdapter);
+ }
+
+ recyclerView.setAdapter(concatenateAdapter);
+ recyclerView.addItemDecoration(new StickyHeaderDecoration(concatenateAdapter, true, true, 0));
+ recyclerView.addOnScrollListener(new RecyclerView.OnScrollListener() {
+ @Override
+ public void onScrollStateChanged(@NonNull RecyclerView recyclerView, int newState) {
+ if (newState == RecyclerView.SCROLL_STATE_DRAGGING) {
+ if (scrollCallback != null) {
+ scrollCallback.onBeginScroll();
+ }
+ }
+ }
+ });
+ }
+
+ private View createInviteActionView(@NonNull ListCallback listCallback) {
+ View view = LayoutInflater.from(requireContext())
+ .inflate(R.layout.contact_selection_invite_action_item, (ViewGroup) requireView(), false);
+ view.setOnClickListener(v -> listCallback.onInvite());
+ return view;
+ }
+
+ private View createNewGroupItem(@NonNull ListCallback listCallback) {
+ View view = LayoutInflater.from(requireContext())
+ .inflate(R.layout.contact_selection_new_group_item, (ViewGroup) requireView(), false);
+ view.setOnClickListener(v -> listCallback.onNewGroup(false));
+ return view;
+ }
+
+ private void initializeNoContactsPermission() {
+ swipeRefresh.setVisibility(View.GONE);
+
+ showContactsLayout.setVisibility(View.VISIBLE);
+ showContactsProgress.setVisibility(View.INVISIBLE);
+ showContactsDescription.setText(R.string.contact_selection_list_fragment__signal_needs_access_to_your_contacts_in_order_to_display_them);
+ showContactsButton.setVisibility(View.VISIBLE);
+
+ showContactsButton.setOnClickListener(v -> {
+ Permissions.with(this)
+ .request(Manifest.permission.WRITE_CONTACTS, Manifest.permission.READ_CONTACTS)
+ .ifNecessary()
+ .withPermanentDenialDialog(getString(R.string.ContactSelectionListFragment_signal_requires_the_contacts_permission_in_order_to_display_your_contacts))
+ .onSomeGranted(permissions -> {
+ if (permissions.contains(Manifest.permission.WRITE_CONTACTS)) {
+ handleContactPermissionGranted();
+ }
+ })
+ .execute();
+ });
+ }
+
+ public void setQueryFilter(String filter) {
+ this.cursorFilter = filter;
+ LoaderManager.getInstance(this).restartLoader(0, null, this);
+ }
+
+ public void resetQueryFilter() {
+ setQueryFilter(null);
+ swipeRefresh.setRefreshing(false);
+ }
+
+ public boolean hasQueryFilter() {
+ return !TextUtils.isEmpty(cursorFilter);
+ }
+
+ public void setRefreshing(boolean refreshing) {
+ swipeRefresh.setRefreshing(refreshing);
+ }
+
+ public void reset() {
+ cursorRecyclerViewAdapter.clearSelectedContacts();
+
+ if (!isDetached() && !isRemoving() && getActivity() != null && !getActivity().isFinishing()) {
+ LoaderManager.getInstance(this).restartLoader(0, null, this);
+ }
+ }
+
+ @Override
+ public @NonNull Loader onCreateLoader(int id, Bundle args) {
+ FragmentActivity activity = requireActivity();
+ return new ContactsCursorLoader(activity,
+ activity.getIntent().getIntExtra(DISPLAY_MODE, DisplayMode.FLAG_ALL),
+ cursorFilter, activity.getIntent().getBooleanExtra(RECENTS, false));
+ }
+
+ @Override
+ public void onLoadFinished(@NonNull Loader loader, @Nullable Cursor data) {
+ swipeRefresh.setVisibility(View.VISIBLE);
+ showContactsLayout.setVisibility(View.GONE);
+
+ cursorRecyclerViewAdapter.changeCursor(data);
+
+ if (footerAdapter != null) {
+ footerAdapter.show();
+ }
+
+ if (headerAdapter != null) {
+ if (TextUtils.isEmpty(cursorFilter)) {
+ headerAdapter.show();
+ } else {
+ headerAdapter.hide();
+ }
+ }
+
+ emptyText.setText(R.string.contact_selection_group_activity__no_contacts);
+ boolean useFastScroller = data != null && data.getCount() > 20;
+ recyclerView.setVerticalScrollBarEnabled(!useFastScroller);
+ if (useFastScroller) {
+ fastScroller.setVisibility(View.VISIBLE);
+ fastScroller.setRecyclerView(recyclerView);
+ } else {
+ fastScroller.setRecyclerView(null);
+ fastScroller.setVisibility(View.GONE);
+ }
+ }
+
+ @Override
+ public void onLoaderReset(@NonNull Loader loader) {
+ cursorRecyclerViewAdapter.changeCursor(null);
+ fastScroller.setVisibility(View.GONE);
+ }
+
+ @SuppressLint("StaticFieldLeak")
+ private void handleContactPermissionGranted() {
+ final Context context = requireContext();
+
+ new AsyncTask() {
+ @Override
+ protected void onPreExecute() {
+ swipeRefresh.setVisibility(View.GONE);
+ showContactsLayout.setVisibility(View.VISIBLE);
+ showContactsButton.setVisibility(View.INVISIBLE);
+ showContactsDescription.setText(R.string.ConversationListFragment_loading);
+ showContactsProgress.setVisibility(View.VISIBLE);
+ showContactsProgress.spin();
+ }
+
+ @Override
+ protected Boolean doInBackground(Void... voids) {
+ try {
+ DirectoryHelper.refreshDirectory(context, false);
+ return true;
+ } catch (IOException e) {
+ Log.w(TAG, e);
+ }
+ return false;
+ }
+
+ @Override
+ protected void onPostExecute(Boolean result) {
+ if (result) {
+ showContactsLayout.setVisibility(View.GONE);
+ swipeRefresh.setVisibility(View.VISIBLE);
+ reset();
+ } else {
+ Context context = getContext();
+ if (context != null) {
+ Toast.makeText(getContext(), R.string.ContactSelectionListFragment_error_retrieving_contacts_check_your_network_connection, Toast.LENGTH_LONG).show();
+ initializeNoContactsPermission();
+ }
+ }
+ }
+ }.execute();
+ }
+
+ private class ListClickListener implements ContactSelectionListAdapter.ItemClickListener {
+ @Override
+ public void onItemClick(ContactSelectionListItem contact) {
+ SelectedContact selectedContact = contact.isUsernameType() ? SelectedContact.forUsername(contact.getRecipientId().orNull(), contact.getNumber())
+ : SelectedContact.forPhone(contact.getRecipientId().orNull(), contact.getNumber());
+
+ if (!canSelectSelf && Recipient.self().getId().equals(selectedContact.getOrCreateRecipientId(requireContext()))) {
+ Toast.makeText(requireContext(), R.string.ContactSelectionListFragment_you_do_not_need_to_add_yourself_to_the_group, Toast.LENGTH_SHORT).show();
+ return;
+ }
+
+ if (!isMulti || !cursorRecyclerViewAdapter.isSelectedContact(selectedContact)) {
+ if (selectionHardLimitReached()) {
+ if (onSelectionLimitReachedListener != null) {
+ onSelectionLimitReachedListener.onHardLimitReached(selectionLimit.getHardLimit());
+ } else {
+ GroupLimitDialog.showHardLimitMessage(requireContext());
+ }
+ return;
+ }
+
+ if (contact.isUsernameType()) {
+ AlertDialog loadingDialog = SimpleProgressDialog.show(requireContext());
+
+ SimpleTask.run(getViewLifecycleOwner().getLifecycle(), () -> {
+ return UsernameUtil.fetchUuidForUsername(requireContext(), contact.getNumber());
+ }, uuid -> {
+ loadingDialog.dismiss();
+ if (uuid.isPresent()) {
+ Recipient recipient = Recipient.externalUsername(requireContext(), uuid.get(), contact.getNumber());
+ SelectedContact selected = SelectedContact.forUsername(recipient.getId(), contact.getNumber());
+
+ if (onContactSelectedListener != null) {
+ if (onContactSelectedListener.onBeforeContactSelected(Optional.of(recipient.getId()), null)) {
+ markContactSelected(selected);
+ cursorRecyclerViewAdapter.notifyItemRangeChanged(0, cursorRecyclerViewAdapter.getItemCount(), ContactSelectionListAdapter.PAYLOAD_SELECTION_CHANGE);
+ }
+ } else {
+ markContactSelected(selected);
+ cursorRecyclerViewAdapter.notifyItemRangeChanged(0, cursorRecyclerViewAdapter.getItemCount(), ContactSelectionListAdapter.PAYLOAD_SELECTION_CHANGE);
+ }
+ } else {
+ new AlertDialog.Builder(requireContext())
+ .setTitle(R.string.ContactSelectionListFragment_username_not_found)
+ .setMessage(getString(R.string.ContactSelectionListFragment_s_is_not_a_signal_user, contact.getNumber()))
+ .setPositiveButton(android.R.string.ok, (dialog, which) -> dialog.dismiss())
+ .show();
+ }
+ });
+ } else {
+ if (onContactSelectedListener != null) {
+ if (onContactSelectedListener.onBeforeContactSelected(contact.getRecipientId(), contact.getNumber())) {
+ markContactSelected(selectedContact);
+ cursorRecyclerViewAdapter.notifyItemRangeChanged(0, cursorRecyclerViewAdapter.getItemCount(), ContactSelectionListAdapter.PAYLOAD_SELECTION_CHANGE);
+ }
+ } else {
+ markContactSelected(selectedContact);
+ cursorRecyclerViewAdapter.notifyItemRangeChanged(0, cursorRecyclerViewAdapter.getItemCount(), ContactSelectionListAdapter.PAYLOAD_SELECTION_CHANGE);
+ }
+ }
+ } else {
+ markContactUnselected(selectedContact);
+ cursorRecyclerViewAdapter.notifyItemRangeChanged(0, cursorRecyclerViewAdapter.getItemCount(), ContactSelectionListAdapter.PAYLOAD_SELECTION_CHANGE);
+
+ if (onContactSelectedListener != null) {
+ onContactSelectedListener.onContactDeselected(contact.getRecipientId(), contact.getNumber());
+ }
+ }
+ }
+ }
+
+ private boolean selectionHardLimitReached() {
+ return getChipCount() + currentSelection.size() >= selectionLimit.getHardLimit();
+ }
+
+ private boolean selectionWarningLimitReachedExactly() {
+ return getChipCount() + currentSelection.size() == selectionLimit.getRecommendedLimit();
+ }
+
+ private boolean selectionWarningLimitExceeded() {
+ return getChipCount() + currentSelection.size() > selectionLimit.getRecommendedLimit();
+ }
+
+ private void markContactSelected(@NonNull SelectedContact selectedContact) {
+ cursorRecyclerViewAdapter.addSelectedContact(selectedContact);
+ if (isMulti) {
+ addChipForSelectedContact(selectedContact);
+ }
+ }
+
+ private void markContactUnselected(@NonNull SelectedContact selectedContact) {
+ cursorRecyclerViewAdapter.removeFromSelectedContacts(selectedContact);
+ cursorRecyclerViewAdapter.notifyItemRangeChanged(0, cursorRecyclerViewAdapter.getItemCount(), ContactSelectionListAdapter.PAYLOAD_SELECTION_CHANGE);
+ removeChipForContact(selectedContact);
+ }
+
+ private void removeChipForContact(@NonNull SelectedContact contact) {
+ for (int i = chipGroup.getChildCount() - 1; i >= 0; i--) {
+ View v = chipGroup.getChildAt(i);
+ if (v instanceof ContactChip && contact.matches(((ContactChip) v).getContact())) {
+ chipGroup.removeView(v);
+ }
+ }
+
+ updateGroupLimit(getChipCount());
+
+ if (getChipCount() == 0) {
+ setChipGroupVisibility(ConstraintSet.GONE);
+ }
+ }
+
+ private void addChipForSelectedContact(@NonNull SelectedContact selectedContact) {
+ SimpleTask.run(getViewLifecycleOwner().getLifecycle(),
+ () -> Recipient.resolved(selectedContact.getOrCreateRecipientId(requireContext())),
+ resolved -> addChipForRecipient(resolved, selectedContact));
+ }
+
+ private void addChipForRecipient(@NonNull Recipient recipient, @NonNull SelectedContact selectedContact) {
+ final ContactChip chip = new ContactChip(requireContext());
+
+ if (getChipCount() == 0) {
+ setChipGroupVisibility(ConstraintSet.VISIBLE);
+ }
+
+ chip.setText(recipient.getShortDisplayName(requireContext()));
+ chip.setContact(selectedContact);
+ chip.setCloseIconVisible(true);
+ chip.setOnCloseIconClickListener(view -> {
+ markContactUnselected(selectedContact);
+
+ if (onContactSelectedListener != null) {
+ onContactSelectedListener.onContactDeselected(Optional.of(recipient.getId()), recipient.getE164().orNull());
+ }
+ });
+
+ chipGroup.getLayoutTransition().addTransitionListener(new LayoutTransition.TransitionListener() {
+ @Override
+ public void startTransition(LayoutTransition transition, ViewGroup container, View view, int transitionType) {
+ }
+
+ @Override
+ public void endTransition(LayoutTransition transition, ViewGroup container, View view, int transitionType) {
+ if (view == chip && transitionType == LayoutTransition.APPEARING) {
+ chipGroup.getLayoutTransition().removeTransitionListener(this);
+ registerChipRecipientObserver(chip, recipient.live());
+ chipGroup.post(ContactSelectionListFragment.this::smoothScrollChipsToEnd);
+ }
+ }
+ });
+
+ chip.setAvatar(glideRequests, recipient, () -> addChip(chip));
+ }
+
+ private void addChip(@NonNull ContactChip chip) {
+ chipGroup.addView(chip);
+ updateGroupLimit(getChipCount());
+ if (selectionWarningLimitReachedExactly()) {
+ if (onSelectionLimitReachedListener != null) {
+ onSelectionLimitReachedListener.onSuggestedLimitReached(selectionLimit.getRecommendedLimit());
+ } else {
+ GroupLimitDialog.showRecommendedLimitMessage(requireContext());
+ }
+ }
+ }
+
+ private int getChipCount() {
+ int count = chipGroup.getChildCount() - CHIP_GROUP_EMPTY_CHILD_COUNT;
+ if (count < 0) throw new AssertionError();
+ return count;
+ }
+
+ private void registerChipRecipientObserver(@NonNull ContactChip chip, @Nullable LiveRecipient recipient) {
+ if (recipient != null) {
+ recipient.observe(getViewLifecycleOwner(), resolved -> {
+ if (chip.isAttachedToWindow()) {
+ chip.setAvatar(glideRequests, resolved, null);
+ chip.setText(resolved.getShortDisplayName(chip.getContext()));
+ }
+ });
+ }
+ }
+
+ private void setChipGroupVisibility(int visibility) {
+ if (!requireActivity().getIntent().getBooleanExtra(DISPLAY_CHIPS, true)) {
+ return;
+ }
+
+ TransitionManager.beginDelayedTransition(constraintLayout, new AutoTransition().setDuration(CHIP_GROUP_REVEAL_DURATION_MS));
+
+ ConstraintSet constraintSet = new ConstraintSet();
+ constraintSet.clone(constraintLayout);
+ constraintSet.setVisibility(R.id.chipGroupScrollContainer, visibility);
+ constraintSet.applyTo(constraintLayout);
+ }
+
+ public void setOnRefreshListener(SwipeRefreshLayout.OnRefreshListener onRefreshListener) {
+ this.swipeRefresh.setOnRefreshListener(onRefreshListener);
+ }
+
+ private void smoothScrollChipsToEnd() {
+ int x = ViewUtil.isLtr(chipGroupScrollContainer) ? chipGroup.getWidth() : 0;
+ chipGroupScrollContainer.smoothScrollTo(x, 0);
+ }
+
+ public interface OnContactSelectedListener {
+ /** @return True if the contact is allowed to be selected, otherwise false. */
+ boolean onBeforeContactSelected(Optional recipientId, String number);
+ void onContactDeselected(Optional recipientId, String number);
+ }
+
+ public interface OnSelectionLimitReachedListener {
+ void onSuggestedLimitReached(int limit);
+ void onHardLimitReached(int limit);
+ }
+
+ public interface ListCallback {
+ void onInvite();
+ void onNewGroup(boolean forceV1);
+ }
+
+ public interface ScrollCallback {
+ void onBeginScroll();
+ }
+}
diff --git a/app/src/main/java/org/thoughtcrime/securesms/DatabaseMigrationActivity.java b/app/src/main/java/org/thoughtcrime/securesms/DatabaseMigrationActivity.java
new file mode 100644
index 00000000..06cd92f6
--- /dev/null
+++ b/app/src/main/java/org/thoughtcrime/securesms/DatabaseMigrationActivity.java
@@ -0,0 +1,201 @@
+package org.thoughtcrime.securesms;
+
+import android.content.BroadcastReceiver;
+import android.content.ComponentName;
+import android.content.Context;
+import android.content.Intent;
+import android.content.IntentFilter;
+import android.content.ServiceConnection;
+import android.os.Bundle;
+import android.os.Handler;
+import android.os.IBinder;
+import android.os.Looper;
+import android.os.Message;
+import android.os.Parcelable;
+import android.view.View;
+import android.widget.Button;
+import android.widget.LinearLayout;
+import android.widget.ProgressBar;
+import android.widget.TextView;
+
+import org.thoughtcrime.securesms.database.SmsMigrator.ProgressDescription;
+import org.thoughtcrime.securesms.service.ApplicationMigrationService;
+import org.thoughtcrime.securesms.service.ApplicationMigrationService.ImportState;
+
+public class DatabaseMigrationActivity extends PassphraseRequiredActivity {
+
+ private final ImportServiceConnection serviceConnection = new ImportServiceConnection();
+ private final ImportStateHandler importStateHandler = new ImportStateHandler();
+ private final BroadcastReceiver completedReceiver = new NullReceiver();
+
+ private LinearLayout promptLayout;
+ private LinearLayout progressLayout;
+ private Button skipButton;
+ private Button importButton;
+ private ProgressBar progress;
+ private TextView progressLabel;
+
+ private ApplicationMigrationService importService;
+ private boolean isVisible = false;
+
+ @Override
+ protected void onCreate(Bundle bundle, boolean ready) {
+ setContentView(R.layout.database_migration_activity);
+
+ initializeResources();
+ initializeServiceBinding();
+ }
+
+ @Override
+ public void onResume() {
+ super.onResume();
+ isVisible = true;
+ registerForCompletedNotification();
+ }
+
+ @Override
+ public void onPause() {
+ super.onPause();
+ isVisible = false;
+ unregisterForCompletedNotification();
+ }
+
+ @Override
+ public void onDestroy() {
+ super.onDestroy();
+ shutdownServiceBinding();
+ }
+
+ @Override
+ public void onBackPressed() {
+
+ }
+
+ private void initializeServiceBinding() {
+ Intent intent = new Intent(this, ApplicationMigrationService.class);
+ bindService(intent, serviceConnection, Context.BIND_AUTO_CREATE);
+ }
+
+ private void initializeResources() {
+ this.promptLayout = (LinearLayout)findViewById(R.id.prompt_layout);
+ this.progressLayout = (LinearLayout)findViewById(R.id.progress_layout);
+ this.skipButton = (Button) findViewById(R.id.skip_button);
+ this.importButton = (Button) findViewById(R.id.import_button);
+ this.progress = (ProgressBar) findViewById(R.id.import_progress);
+ this.progressLabel = (TextView) findViewById(R.id.import_status);
+
+ this.progressLayout.setVisibility(View.GONE);
+ this.promptLayout.setVisibility(View.GONE);
+
+ this.importButton.setOnClickListener(new View.OnClickListener() {
+ @Override
+ public void onClick(View v) {
+ Intent intent = new Intent(DatabaseMigrationActivity.this, ApplicationMigrationService.class);
+ intent.setAction(ApplicationMigrationService.MIGRATE_DATABASE);
+ intent.putExtra("master_secret", (Parcelable)getIntent().getParcelableExtra("master_secret"));
+ startService(intent);
+
+ promptLayout.setVisibility(View.GONE);
+ progressLayout.setVisibility(View.VISIBLE);
+ }
+ });
+
+ this.skipButton.setOnClickListener(new View.OnClickListener() {
+ @Override
+ public void onClick(View v) {
+ ApplicationMigrationService.setDatabaseImported(DatabaseMigrationActivity.this);
+ handleImportComplete();
+ }
+ });
+ }
+
+ private void registerForCompletedNotification() {
+ IntentFilter filter = new IntentFilter();
+ filter.addAction(ApplicationMigrationService.COMPLETED_ACTION);
+ filter.setPriority(1000);
+
+ registerReceiver(completedReceiver, filter);
+ }
+
+ private void unregisterForCompletedNotification() {
+ unregisterReceiver(completedReceiver);
+ }
+
+ private void shutdownServiceBinding() {
+ unbindService(serviceConnection);
+ }
+
+ private void handleStateIdle() {
+ this.promptLayout.setVisibility(View.VISIBLE);
+ this.progressLayout.setVisibility(View.GONE);
+ }
+
+ private void handleStateProgress(ProgressDescription update) {
+ this.promptLayout.setVisibility(View.GONE);
+ this.progressLayout.setVisibility(View.VISIBLE);
+ this.progressLabel.setText(update.primaryComplete + "/" + update.primaryTotal);
+
+ double max = this.progress.getMax();
+ double primaryTotal = update.primaryTotal;
+ double primaryComplete = update.primaryComplete;
+ double secondaryTotal = update.secondaryTotal;
+ double secondaryComplete = update.secondaryComplete;
+
+ this.progress.setProgress((int)Math.round((primaryComplete / primaryTotal) * max));
+ this.progress.setSecondaryProgress((int)Math.round((secondaryComplete / secondaryTotal) * max));
+ }
+
+ private void handleImportComplete() {
+ if (isVisible) {
+ if (getIntent().hasExtra("next_intent")) {
+ startActivity((Intent)getIntent().getParcelableExtra("next_intent"));
+ } else {
+ // TODO [greyson] Navigation
+ startActivity(MainActivity.clearTop(this));
+ }
+ }
+
+ finish();
+ }
+
+ private class ImportStateHandler extends Handler {
+
+ public ImportStateHandler() {
+ super(Looper.getMainLooper());
+ }
+
+ @Override
+ public void handleMessage(Message message) {
+ switch (message.what) {
+ case ImportState.STATE_IDLE: handleStateIdle(); break;
+ case ImportState.STATE_MIGRATING_IN_PROGRESS: handleStateProgress((ProgressDescription)message.obj); break;
+ case ImportState.STATE_MIGRATING_COMPLETE: handleImportComplete(); break;
+ }
+ }
+ }
+
+ private class ImportServiceConnection implements ServiceConnection {
+ @Override
+ public void onServiceConnected(ComponentName className, IBinder service) {
+ importService = ((ApplicationMigrationService.ApplicationMigrationBinder)service).getService();
+ importService.setImportStateHandler(importStateHandler);
+
+ ImportState state = importService.getState();
+ importStateHandler.obtainMessage(state.state, state.progress).sendToTarget();
+ }
+
+ @Override
+ public void onServiceDisconnected(ComponentName name) {
+ importService.setImportStateHandler(null);
+ }
+ }
+
+ private static class NullReceiver extends BroadcastReceiver {
+ @Override
+ public void onReceive(Context context, Intent intent) {
+ abortBroadcast();
+ }
+ }
+
+
+}
diff --git a/app/src/main/java/org/thoughtcrime/securesms/DeviceActivity.java b/app/src/main/java/org/thoughtcrime/securesms/DeviceActivity.java
new file mode 100644
index 00000000..28ef89f9
--- /dev/null
+++ b/app/src/main/java/org/thoughtcrime/securesms/DeviceActivity.java
@@ -0,0 +1,250 @@
+package org.thoughtcrime.securesms;
+
+import android.Manifest;
+import android.annotation.SuppressLint;
+import android.content.Context;
+import android.net.Uri;
+import android.os.AsyncTask;
+import android.os.Build;
+import android.os.Bundle;
+import android.os.Vibrator;
+import android.text.TextUtils;
+import android.transition.TransitionInflater;
+import android.view.MenuItem;
+import android.view.View;
+import android.widget.Button;
+import android.widget.Toast;
+
+import androidx.annotation.NonNull;
+import androidx.core.content.ContextCompat;
+
+import org.signal.core.util.logging.Log;
+import org.thoughtcrime.securesms.crypto.IdentityKeyUtil;
+import org.thoughtcrime.securesms.crypto.ProfileKeyUtil;
+import org.thoughtcrime.securesms.dependencies.ApplicationDependencies;
+import org.thoughtcrime.securesms.permissions.Permissions;
+import org.thoughtcrime.securesms.qr.ScanListener;
+import org.thoughtcrime.securesms.util.Base64;
+import org.thoughtcrime.securesms.util.DynamicLanguage;
+import org.thoughtcrime.securesms.util.DynamicTheme;
+import org.thoughtcrime.securesms.util.TextSecurePreferences;
+import org.thoughtcrime.securesms.util.Util;
+import org.thoughtcrime.securesms.util.task.ProgressDialogAsyncTask;
+import org.whispersystems.libsignal.IdentityKeyPair;
+import org.whispersystems.libsignal.InvalidKeyException;
+import org.whispersystems.libsignal.ecc.Curve;
+import org.whispersystems.libsignal.ecc.ECPublicKey;
+import org.whispersystems.libsignal.util.guava.Optional;
+import org.whispersystems.signalservice.api.SignalServiceAccountManager;
+import org.whispersystems.signalservice.api.push.exceptions.NotFoundException;
+import org.whispersystems.signalservice.internal.push.DeviceLimitExceededException;
+
+import java.io.IOException;
+
+public class DeviceActivity extends PassphraseRequiredActivity
+ implements Button.OnClickListener, ScanListener, DeviceLinkFragment.LinkClickedListener
+{
+
+ private static final String TAG = DeviceActivity.class.getSimpleName();
+
+ private final DynamicTheme dynamicTheme = new DynamicTheme();
+ private final DynamicLanguage dynamicLanguage = new DynamicLanguage();
+
+ private DeviceAddFragment deviceAddFragment;
+ private DeviceListFragment deviceListFragment;
+ private DeviceLinkFragment deviceLinkFragment;
+
+ @Override
+ public void onPreCreate() {
+ dynamicTheme.onCreate(this);
+ dynamicLanguage.onCreate(this);
+ }
+
+ @Override
+ public void onCreate(Bundle bundle, boolean ready) {
+ getSupportActionBar().setHomeAsUpIndicator(ContextCompat.getDrawable(this, R.drawable.ic_arrow_left_24));
+ getSupportActionBar().setDisplayHomeAsUpEnabled(true);
+ getSupportActionBar().setTitle(R.string.AndroidManifest__linked_devices);
+ this.deviceAddFragment = new DeviceAddFragment();
+ this.deviceListFragment = new DeviceListFragment();
+ this.deviceLinkFragment = new DeviceLinkFragment();
+
+ this.deviceListFragment.setAddDeviceButtonListener(this);
+ this.deviceAddFragment.setScanListener(this);
+
+ if (getIntent().getBooleanExtra("add", false)) {
+ initFragment(android.R.id.content, deviceAddFragment, dynamicLanguage.getCurrentLocale());
+ } else {
+ initFragment(android.R.id.content, deviceListFragment, dynamicLanguage.getCurrentLocale());
+ }
+
+ overridePendingTransition(R.anim.slide_from_end, R.anim.slide_to_start);
+ }
+
+ @Override
+ protected void onPause() {
+ if (isFinishing()) {
+ overridePendingTransition(R.anim.slide_from_start, R.anim.slide_to_end);
+ }
+ super.onPause();
+ }
+
+ @Override
+ public void onResume() {
+ super.onResume();
+ dynamicTheme.onResume(this);
+ dynamicLanguage.onResume(this);
+ }
+
+ @Override
+ public boolean onOptionsItemSelected(MenuItem item) {
+ switch (item.getItemId()) {
+ case android.R.id.home: finish(); return true;
+ }
+
+ return false;
+ }
+
+ @Override
+ public void onClick(View v) {
+ Permissions.with(this)
+ .request(Manifest.permission.CAMERA)
+ .ifNecessary()
+ .withPermanentDenialDialog(getString(R.string.DeviceActivity_signal_needs_the_camera_permission_in_order_to_scan_a_qr_code))
+ .onAllGranted(() -> {
+ getSupportFragmentManager().beginTransaction()
+ .replace(android.R.id.content, deviceAddFragment)
+ .addToBackStack(null)
+ .commitAllowingStateLoss();
+ })
+ .onAnyDenied(() -> Toast.makeText(this, R.string.DeviceActivity_unable_to_scan_a_qr_code_without_the_camera_permission, Toast.LENGTH_LONG).show())
+ .execute();
+ }
+
+ @Override
+ public void onQrDataFound(final String data) {
+ Util.runOnMain(() -> {
+ ((Vibrator)getSystemService(Context.VIBRATOR_SERVICE)).vibrate(50);
+ Uri uri = Uri.parse(data);
+ deviceLinkFragment.setLinkClickedListener(uri, DeviceActivity.this);
+
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP) {
+ deviceAddFragment.setSharedElementReturnTransition(TransitionInflater.from(DeviceActivity.this).inflateTransition(R.transition.fragment_shared));
+ deviceAddFragment.setExitTransition(TransitionInflater.from(DeviceActivity.this).inflateTransition(android.R.transition.fade));
+
+ deviceLinkFragment.setSharedElementEnterTransition(TransitionInflater.from(DeviceActivity.this).inflateTransition(R.transition.fragment_shared));
+ deviceLinkFragment.setEnterTransition(TransitionInflater.from(DeviceActivity.this).inflateTransition(android.R.transition.fade));
+
+ getSupportFragmentManager().beginTransaction()
+ .addToBackStack(null)
+ .addSharedElement(deviceAddFragment.getDevicesImage(), "devices")
+ .replace(android.R.id.content, deviceLinkFragment)
+ .commit();
+
+ } else {
+ getSupportFragmentManager().beginTransaction()
+ .setCustomAnimations(R.anim.slide_from_bottom, R.anim.slide_to_bottom,
+ R.anim.slide_from_bottom, R.anim.slide_to_bottom)
+ .replace(android.R.id.content, deviceLinkFragment)
+ .addToBackStack(null)
+ .commit();
+ }
+ });
+ }
+
+ @Override
+ public void onRequestPermissionsResult(int requestCode, @NonNull String[] permissions, @NonNull int[] grantResults) {
+ Permissions.onRequestPermissionsResult(this, requestCode, permissions, grantResults);
+ }
+
+ @SuppressLint("StaticFieldLeak")
+ @Override
+ public void onLink(final Uri uri) {
+ new ProgressDialogAsyncTask(this,
+ R.string.DeviceProvisioningActivity_content_progress_title,
+ R.string.DeviceProvisioningActivity_content_progress_content)
+ {
+ private static final int SUCCESS = 0;
+ private static final int NO_DEVICE = 1;
+ private static final int NETWORK_ERROR = 2;
+ private static final int KEY_ERROR = 3;
+ private static final int LIMIT_EXCEEDED = 4;
+ private static final int BAD_CODE = 5;
+
+ @Override
+ protected Integer doInBackground(Void... params) {
+ boolean isMultiDevice = TextSecurePreferences.isMultiDevice(DeviceActivity.this);
+
+ try {
+ Context context = DeviceActivity.this;
+ SignalServiceAccountManager accountManager = ApplicationDependencies.getSignalServiceAccountManager();
+ String verificationCode = accountManager.getNewDeviceVerificationCode();
+ String ephemeralId = uri.getQueryParameter("uuid");
+ String publicKeyEncoded = uri.getQueryParameter("pub_key");
+
+ if (TextUtils.isEmpty(ephemeralId) || TextUtils.isEmpty(publicKeyEncoded)) {
+ Log.w(TAG, "UUID or Key is empty!");
+ return BAD_CODE;
+ }
+
+ ECPublicKey publicKey = Curve.decodePoint(Base64.decode(publicKeyEncoded), 0);
+ IdentityKeyPair identityKeyPair = IdentityKeyUtil.getIdentityKeyPair(context);
+ Optional profileKey = Optional.of(ProfileKeyUtil.getProfileKey(getContext()));
+
+ TextSecurePreferences.setMultiDevice(DeviceActivity.this, true);
+ TextSecurePreferences.setIsUnidentifiedDeliveryEnabled(context, false);
+ accountManager.addDevice(ephemeralId, publicKey, identityKeyPair, profileKey, verificationCode);
+
+ return SUCCESS;
+ } catch (NotFoundException e) {
+ Log.w(TAG, e);
+ TextSecurePreferences.setMultiDevice(DeviceActivity.this, isMultiDevice);
+ return NO_DEVICE;
+ } catch (DeviceLimitExceededException e) {
+ Log.w(TAG, e);
+ TextSecurePreferences.setMultiDevice(DeviceActivity.this, isMultiDevice);
+ return LIMIT_EXCEEDED;
+ } catch (IOException e) {
+ Log.w(TAG, e);
+ TextSecurePreferences.setMultiDevice(DeviceActivity.this, isMultiDevice);
+ return NETWORK_ERROR;
+ } catch (InvalidKeyException e) {
+ Log.w(TAG, e);
+ TextSecurePreferences.setMultiDevice(DeviceActivity.this, isMultiDevice);
+ return KEY_ERROR;
+ }
+ }
+
+ @Override
+ protected void onPostExecute(Integer result) {
+ super.onPostExecute(result);
+
+ Context context = DeviceActivity.this;
+
+ switch (result) {
+ case SUCCESS:
+ Toast.makeText(context, R.string.DeviceProvisioningActivity_content_progress_success, Toast.LENGTH_SHORT).show();
+ finish();
+ return;
+ case NO_DEVICE:
+ Toast.makeText(context, R.string.DeviceProvisioningActivity_content_progress_no_device, Toast.LENGTH_LONG).show();
+ break;
+ case NETWORK_ERROR:
+ Toast.makeText(context, R.string.DeviceProvisioningActivity_content_progress_network_error, Toast.LENGTH_LONG).show();
+ break;
+ case KEY_ERROR:
+ Toast.makeText(context, R.string.DeviceProvisioningActivity_content_progress_key_error, Toast.LENGTH_LONG).show();
+ break;
+ case LIMIT_EXCEEDED:
+ Toast.makeText(context, R.string.DeviceProvisioningActivity_sorry_you_have_too_many_devices_linked_already, Toast.LENGTH_LONG).show();
+ break;
+ case BAD_CODE:
+ Toast.makeText(context, R.string.DeviceActivity_sorry_this_is_not_a_valid_device_link_qr_code, Toast.LENGTH_LONG).show();
+ break;
+ }
+
+ getSupportFragmentManager().popBackStackImmediate();
+ }
+ }.executeOnExecutor(AsyncTask.THREAD_POOL_EXECUTOR);
+ }
+}
diff --git a/app/src/main/java/org/thoughtcrime/securesms/DeviceAddFragment.java b/app/src/main/java/org/thoughtcrime/securesms/DeviceAddFragment.java
new file mode 100644
index 00000000..cd446bc9
--- /dev/null
+++ b/app/src/main/java/org/thoughtcrime/securesms/DeviceAddFragment.java
@@ -0,0 +1,112 @@
+package org.thoughtcrime.securesms;
+
+import android.animation.Animator;
+import android.annotation.TargetApi;
+import android.content.res.Configuration;
+import android.os.Build;
+import android.os.Bundle;
+import android.view.LayoutInflater;
+import android.view.View;
+import android.view.ViewAnimationUtils;
+import android.view.ViewGroup;
+import android.view.animation.DecelerateInterpolator;
+import android.widget.ImageView;
+import android.widget.LinearLayout;
+
+import androidx.annotation.NonNull;
+
+import org.thoughtcrime.securesms.components.camera.CameraView;
+import org.thoughtcrime.securesms.qr.ScanListener;
+import org.thoughtcrime.securesms.qr.ScanningThread;
+import org.thoughtcrime.securesms.util.ViewUtil;
+
+public class DeviceAddFragment extends LoggingFragment {
+
+ private ViewGroup container;
+ private LinearLayout overlay;
+ private ImageView devicesImage;
+ private CameraView scannerView;
+ private ScanningThread scanningThread;
+ private ScanListener scanListener;
+
+ @Override
+ public View onCreateView(@NonNull LayoutInflater inflater, ViewGroup viewGroup, Bundle bundle) {
+ this.container = ViewUtil.inflate(inflater, viewGroup, R.layout.device_add_fragment);
+ this.overlay = this.container.findViewById(R.id.overlay);
+ this.scannerView = this.container.findViewById(R.id.scanner);
+ this.devicesImage = this.container.findViewById(R.id.devices);
+
+ if (getResources().getConfiguration().orientation == Configuration.ORIENTATION_LANDSCAPE) {
+ this.overlay.setOrientation(LinearLayout.HORIZONTAL);
+ } else {
+ this.overlay.setOrientation(LinearLayout.VERTICAL);
+ }
+
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP) {
+ this.container.addOnLayoutChangeListener(new View.OnLayoutChangeListener() {
+ @TargetApi(Build.VERSION_CODES.LOLLIPOP)
+ @Override
+ public void onLayoutChange(View v, int left, int top, int right, int bottom,
+ int oldLeft, int oldTop, int oldRight, int oldBottom)
+ {
+ v.removeOnLayoutChangeListener(this);
+
+ Animator reveal = ViewAnimationUtils.createCircularReveal(v, right, bottom, 0, (int) Math.hypot(right, bottom));
+ reveal.setInterpolator(new DecelerateInterpolator(2f));
+ reveal.setDuration(800);
+ reveal.start();
+ }
+ });
+ }
+
+ return this.container;
+ }
+
+ @Override
+ public void onResume() {
+ super.onResume();
+ this.scanningThread = new ScanningThread();
+ this.scanningThread.setScanListener(scanListener);
+ this.scannerView.onResume();
+ this.scannerView.setPreviewCallback(scanningThread);
+ this.scanningThread.start();
+ }
+
+ @Override
+ public void onPause() {
+ super.onPause();
+ this.scannerView.onPause();
+ this.scanningThread.stopScanning();
+ }
+
+ @Override
+ public void onConfigurationChanged(Configuration newConfiguration) {
+ super.onConfigurationChanged(newConfiguration);
+
+ this.scannerView.onPause();
+
+ if (newConfiguration.orientation == Configuration.ORIENTATION_LANDSCAPE) {
+ overlay.setOrientation(LinearLayout.HORIZONTAL);
+ } else {
+ overlay.setOrientation(LinearLayout.VERTICAL);
+ }
+
+ this.scannerView.onResume();
+ this.scannerView.setPreviewCallback(scanningThread);
+ }
+
+
+ public ImageView getDevicesImage() {
+ return devicesImage;
+ }
+
+ public void setScanListener(ScanListener scanListener) {
+ this.scanListener = scanListener;
+
+ if (this.scanningThread != null) {
+ this.scanningThread.setScanListener(scanListener);
+ }
+ }
+
+
+}
diff --git a/app/src/main/java/org/thoughtcrime/securesms/DeviceLinkFragment.java b/app/src/main/java/org/thoughtcrime/securesms/DeviceLinkFragment.java
new file mode 100644
index 00000000..1a3b8a9f
--- /dev/null
+++ b/app/src/main/java/org/thoughtcrime/securesms/DeviceLinkFragment.java
@@ -0,0 +1,59 @@
+package org.thoughtcrime.securesms;
+
+import android.content.res.Configuration;
+import android.net.Uri;
+import android.os.Bundle;
+import android.view.LayoutInflater;
+import android.view.View;
+import android.view.ViewGroup;
+import android.widget.LinearLayout;
+
+import androidx.annotation.NonNull;
+import androidx.fragment.app.Fragment;
+
+public class DeviceLinkFragment extends Fragment implements View.OnClickListener {
+
+ private LinearLayout container;
+ private LinkClickedListener linkClickedListener;
+ private Uri uri;
+
+ @Override
+ public View onCreateView(@NonNull LayoutInflater inflater, ViewGroup viewGroup, Bundle bundle) {
+ this.container = (LinearLayout) inflater.inflate(R.layout.device_link_fragment, container, false);
+ this.container.findViewById(R.id.link_device).setOnClickListener(this);
+
+ if (getResources().getConfiguration().orientation == Configuration.ORIENTATION_LANDSCAPE) {
+ container.setOrientation(LinearLayout.HORIZONTAL);
+ } else {
+ container.setOrientation(LinearLayout.VERTICAL);
+ }
+
+ return this.container;
+ }
+
+ @Override
+ public void onConfigurationChanged(Configuration newConfiguration) {
+ super.onConfigurationChanged(newConfiguration);
+ if (newConfiguration.orientation == Configuration.ORIENTATION_LANDSCAPE) {
+ container.setOrientation(LinearLayout.HORIZONTAL);
+ } else {
+ container.setOrientation(LinearLayout.VERTICAL);
+ }
+ }
+
+ public void setLinkClickedListener(Uri uri, LinkClickedListener linkClickedListener) {
+ this.uri = uri;
+ this.linkClickedListener = linkClickedListener;
+ }
+
+ @Override
+ public void onClick(View v) {
+ if (linkClickedListener != null) {
+ linkClickedListener.onLink(uri);
+ }
+ }
+
+ public interface LinkClickedListener {
+ void onLink(Uri uri);
+ }
+}
diff --git a/app/src/main/java/org/thoughtcrime/securesms/DeviceListFragment.java b/app/src/main/java/org/thoughtcrime/securesms/DeviceListFragment.java
new file mode 100644
index 00000000..740bf8d8
--- /dev/null
+++ b/app/src/main/java/org/thoughtcrime/securesms/DeviceListFragment.java
@@ -0,0 +1,220 @@
+package org.thoughtcrime.securesms;
+
+import android.annotation.SuppressLint;
+import android.app.Activity;
+import android.content.Context;
+import android.content.DialogInterface;
+import android.os.AsyncTask;
+import android.os.Bundle;
+import android.view.LayoutInflater;
+import android.view.View;
+import android.view.ViewGroup;
+import android.widget.AdapterView;
+import android.widget.ArrayAdapter;
+import android.widget.Button;
+import android.widget.ListView;
+import android.widget.Toast;
+
+import androidx.annotation.NonNull;
+import androidx.appcompat.app.AlertDialog;
+import androidx.fragment.app.ListFragment;
+import androidx.loader.app.LoaderManager;
+import androidx.loader.content.Loader;
+
+import com.melnykov.fab.FloatingActionButton;
+
+import org.signal.core.util.logging.Log;
+import org.thoughtcrime.securesms.database.loaders.DeviceListLoader;
+import org.thoughtcrime.securesms.dependencies.ApplicationDependencies;
+import org.thoughtcrime.securesms.devicelist.Device;
+import org.thoughtcrime.securesms.util.TextSecurePreferences;
+import org.thoughtcrime.securesms.util.task.ProgressDialogAsyncTask;
+import org.whispersystems.signalservice.api.SignalServiceAccountManager;
+
+import java.io.IOException;
+import java.util.List;
+import java.util.Locale;
+
+public class DeviceListFragment extends ListFragment
+ implements LoaderManager.LoaderCallbacks>,
+ ListView.OnItemClickListener, Button.OnClickListener
+{
+
+ private static final String TAG = DeviceListFragment.class.getSimpleName();
+
+ private SignalServiceAccountManager accountManager;
+ private Locale locale;
+ private View empty;
+ private View progressContainer;
+ private FloatingActionButton addDeviceButton;
+ private Button.OnClickListener addDeviceButtonListener;
+
+ @Override
+ public void onCreate(Bundle savedInstanceState) {
+ super.onCreate(savedInstanceState);
+ this.locale = (Locale) getArguments().getSerializable(PassphraseRequiredActivity.LOCALE_EXTRA);
+ }
+
+ @Override
+ public void onAttach(Activity activity) {
+ super.onAttach(activity);
+ this.accountManager = ApplicationDependencies.getSignalServiceAccountManager();
+ }
+
+ @Override
+ public View onCreateView(LayoutInflater inflater, ViewGroup container, Bundle bundle) {
+ View view = inflater.inflate(R.layout.device_list_fragment, container, false);
+
+ this.empty = view.findViewById(R.id.empty);
+ this.progressContainer = view.findViewById(R.id.progress_container);
+ this.addDeviceButton = view.findViewById(R.id.add_device);
+ this.addDeviceButton.setOnClickListener(this);
+
+ return view;
+ }
+
+ @Override
+ public void onActivityCreated(Bundle bundle) {
+ super.onActivityCreated(bundle);
+ getLoaderManager().initLoader(0, null, this);
+ getListView().setOnItemClickListener(this);
+ }
+
+ public void setAddDeviceButtonListener(Button.OnClickListener listener) {
+ this.addDeviceButtonListener = listener;
+ }
+
+ @Override
+ public @NonNull Loader> onCreateLoader(int id, Bundle args) {
+ empty.setVisibility(View.GONE);
+ progressContainer.setVisibility(View.VISIBLE);
+
+ return new DeviceListLoader(getActivity(), accountManager);
+ }
+
+ @Override
+ public void onLoadFinished(@NonNull Loader> loader, List data) {
+ progressContainer.setVisibility(View.GONE);
+
+ if (data == null) {
+ handleLoaderFailed();
+ return;
+ }
+
+ setListAdapter(new DeviceListAdapter(getActivity(), R.layout.device_list_item_view, data, locale));
+
+ if (data.isEmpty()) {
+ empty.setVisibility(View.VISIBLE);
+ TextSecurePreferences.setMultiDevice(getActivity(), false);
+ } else {
+ empty.setVisibility(View.GONE);
+ }
+ }
+
+ @Override
+ public void onLoaderReset(@NonNull Loader> loader) {
+ setListAdapter(null);
+ }
+
+ @Override
+ public void onItemClick(AdapterView> parent, View view, int position, long id) {
+ final String deviceName = ((DeviceListItem)view).getDeviceName();
+ final long deviceId = ((DeviceListItem)view).getDeviceId();
+
+ AlertDialog.Builder builder = new AlertDialog.Builder(getActivity());
+ builder.setTitle(getActivity().getString(R.string.DeviceListActivity_unlink_s, deviceName));
+ builder.setMessage(R.string.DeviceListActivity_by_unlinking_this_device_it_will_no_longer_be_able_to_send_or_receive);
+ builder.setNegativeButton(android.R.string.cancel, null);
+ builder.setPositiveButton(android.R.string.ok, new DialogInterface.OnClickListener() {
+ @Override
+ public void onClick(DialogInterface dialog, int which) {
+ handleDisconnectDevice(deviceId);
+ }
+ });
+ builder.show();
+ }
+
+ private void handleLoaderFailed() {
+ AlertDialog.Builder builder = new AlertDialog.Builder(getActivity());
+ builder.setMessage(R.string.DeviceListActivity_network_connection_failed);
+ builder.setPositiveButton(R.string.DeviceListActivity_try_again,
+ new DialogInterface.OnClickListener() {
+ @Override
+ public void onClick(DialogInterface dialog, int which) {
+ getLoaderManager().restartLoader(0, null, DeviceListFragment.this);
+ }
+ });
+
+ builder.setNegativeButton(android.R.string.cancel, new DialogInterface.OnClickListener() {
+ @Override
+ public void onClick(DialogInterface dialog, int which) {
+ DeviceListFragment.this.getActivity().onBackPressed();
+ }
+ });
+ builder.setOnCancelListener(new DialogInterface.OnCancelListener() {
+ @Override
+ public void onCancel(DialogInterface dialog) {
+ DeviceListFragment.this.getActivity().onBackPressed();
+ }
+ });
+
+ builder.show();
+ }
+
+ @SuppressLint("StaticFieldLeak")
+ private void handleDisconnectDevice(final long deviceId) {
+ new ProgressDialogAsyncTask(getActivity(),
+ R.string.DeviceListActivity_unlinking_device_no_ellipsis,
+ R.string.DeviceListActivity_unlinking_device)
+ {
+ @Override
+ protected Boolean doInBackground(Void... params) {
+ try {
+ accountManager.removeDevice(deviceId);
+ return true;
+ } catch (IOException e) {
+ Log.w(TAG, e);
+ return false;
+ }
+ }
+
+ @Override
+ protected void onPostExecute(Boolean result) {
+ super.onPostExecute(result);
+ if (result) {
+ getLoaderManager().restartLoader(0, null, DeviceListFragment.this);
+ } else {
+ Toast.makeText(getActivity(), R.string.DeviceListActivity_network_failed, Toast.LENGTH_LONG).show();
+ }
+ }
+ }.executeOnExecutor(AsyncTask.THREAD_POOL_EXECUTOR);
+ }
+
+ @Override
+ public void onClick(View v) {
+ if (addDeviceButtonListener != null) addDeviceButtonListener.onClick(v);
+ }
+
+ private static class DeviceListAdapter extends ArrayAdapter {
+
+ private final int resource;
+ private final Locale locale;
+
+ public DeviceListAdapter(Context context, int resource, List objects, Locale locale) {
+ super(context, resource, objects);
+ this.resource = resource;
+ this.locale = locale;
+ }
+
+ @Override
+ public @NonNull View getView(int position, View convertView, @NonNull ViewGroup parent) {
+ if (convertView == null) {
+ convertView = ((Activity)getContext()).getLayoutInflater().inflate(resource, parent, false);
+ }
+
+ ((DeviceListItem)convertView).set(getItem(position), locale);
+
+ return convertView;
+ }
+ }
+}
diff --git a/app/src/main/java/org/thoughtcrime/securesms/DeviceListItem.java b/app/src/main/java/org/thoughtcrime/securesms/DeviceListItem.java
new file mode 100644
index 00000000..52849457
--- /dev/null
+++ b/app/src/main/java/org/thoughtcrime/securesms/DeviceListItem.java
@@ -0,0 +1,62 @@
+package org.thoughtcrime.securesms;
+
+import android.content.Context;
+import android.text.TextUtils;
+import android.util.AttributeSet;
+import android.widget.LinearLayout;
+import android.widget.TextView;
+
+import org.thoughtcrime.securesms.devicelist.Device;
+import org.thoughtcrime.securesms.util.DateUtils;
+
+import java.util.Locale;
+
+public class DeviceListItem extends LinearLayout {
+
+ private long deviceId;
+ private TextView name;
+ private TextView created;
+ private TextView lastActive;
+
+ public DeviceListItem(Context context) {
+ super(context);
+ }
+
+ public DeviceListItem(Context context, AttributeSet attrs) {
+ super(context, attrs);
+ }
+
+ @Override
+ public void onFinishInflate() {
+ super.onFinishInflate();
+ this.name = (TextView) findViewById(R.id.name);
+ this.created = (TextView) findViewById(R.id.created);
+ this.lastActive = (TextView) findViewById(R.id.active);
+ }
+
+ public void set(Device deviceInfo, Locale locale) {
+ if (TextUtils.isEmpty(deviceInfo.getName())) this.name.setText(R.string.DeviceListItem_unnamed_device);
+ else this.name.setText(deviceInfo.getName());
+
+ this.created.setText(getContext().getString(R.string.DeviceListItem_linked_s,
+ DateUtils.getDayPrecisionTimeSpanString(getContext(),
+ locale,
+ deviceInfo.getCreated())));
+
+ this.lastActive.setText(getContext().getString(R.string.DeviceListItem_last_active_s,
+ DateUtils.getDayPrecisionTimeSpanString(getContext(),
+ locale,
+ deviceInfo.getLastSeen())));
+
+ this.deviceId = deviceInfo.getId();
+ }
+
+ public long getDeviceId() {
+ return deviceId;
+ }
+
+ public String getDeviceName() {
+ return name.getText().toString();
+ }
+
+}
diff --git a/app/src/main/java/org/thoughtcrime/securesms/DeviceProvisioningActivity.java b/app/src/main/java/org/thoughtcrime/securesms/DeviceProvisioningActivity.java
new file mode 100644
index 00000000..6625d5fb
--- /dev/null
+++ b/app/src/main/java/org/thoughtcrime/securesms/DeviceProvisioningActivity.java
@@ -0,0 +1,40 @@
+package org.thoughtcrime.securesms;
+
+import android.content.Intent;
+import android.os.Bundle;
+import android.view.Window;
+
+import androidx.appcompat.app.AlertDialog;
+
+public class DeviceProvisioningActivity extends PassphraseRequiredActivity {
+
+ @SuppressWarnings("unused")
+ private static final String TAG = DeviceProvisioningActivity.class.getSimpleName();
+
+ @Override
+ protected void onPreCreate() {
+ supportRequestWindowFeature(Window.FEATURE_NO_TITLE);
+ }
+
+ @Override
+ protected void onCreate(Bundle bundle, boolean ready) {
+ AlertDialog dialog = new AlertDialog.Builder(this)
+ .setTitle(getString(R.string.DeviceProvisioningActivity_link_a_signal_device))
+ .setMessage(getString(R.string.DeviceProvisioningActivity_it_looks_like_youre_trying_to_link_a_signal_device_using_a_3rd_party_scanner))
+ .setPositiveButton(R.string.DeviceProvisioningActivity_continue, (dialog1, which) -> {
+ Intent intent = new Intent(DeviceProvisioningActivity.this, DeviceActivity.class);
+ intent.putExtra("add", true);
+ startActivity(intent);
+ finish();
+ })
+ .setNegativeButton(android.R.string.cancel, (dialog12, which) -> {
+ dialog12.dismiss();
+ finish();
+ })
+ .setOnDismissListener(dialog13 -> finish())
+ .create();
+
+ dialog.setIcon(getResources().getDrawable(R.drawable.icon_dialog));
+ dialog.show();
+ }
+}
diff --git a/app/src/main/java/org/thoughtcrime/securesms/DummyActivity.java b/app/src/main/java/org/thoughtcrime/securesms/DummyActivity.java
new file mode 100644
index 00000000..bed0d92e
--- /dev/null
+++ b/app/src/main/java/org/thoughtcrime/securesms/DummyActivity.java
@@ -0,0 +1,16 @@
+package org.thoughtcrime.securesms;
+
+import android.app.Activity;
+import android.os.Bundle;
+
+/**
+ * Workaround for Android bug:
+ * https://code.google.com/p/android/issues/detail?id=53313
+ */
+public class DummyActivity extends Activity {
+ @Override
+ public void onCreate(Bundle bundle) {
+ super.onCreate(bundle);
+ finish();
+ }
+}
diff --git a/app/src/main/java/org/thoughtcrime/securesms/ExpirationDialog.java b/app/src/main/java/org/thoughtcrime/securesms/ExpirationDialog.java
new file mode 100644
index 00000000..10f11dab
--- /dev/null
+++ b/app/src/main/java/org/thoughtcrime/securesms/ExpirationDialog.java
@@ -0,0 +1,103 @@
+package org.thoughtcrime.securesms;
+
+import android.content.Context;
+import android.view.LayoutInflater;
+import android.view.View;
+import android.widget.TextView;
+
+import androidx.annotation.NonNull;
+import androidx.appcompat.app.AlertDialog;
+
+import org.thoughtcrime.securesms.util.ExpirationUtil;
+
+import java.util.Arrays;
+
+import cn.carbswang.android.numberpickerview.library.NumberPickerView;
+
+public class ExpirationDialog extends AlertDialog {
+
+ protected ExpirationDialog(Context context) {
+ super(context);
+ }
+
+ protected ExpirationDialog(Context context, int theme) {
+ super(context, theme);
+ }
+
+ protected ExpirationDialog(Context context, boolean cancelable, OnCancelListener cancelListener) {
+ super(context, cancelable, cancelListener);
+ }
+
+ public static void show(final Context context,
+ final int currentExpiration,
+ final @NonNull OnClickListener listener)
+ {
+ final View view = createNumberPickerView(context, currentExpiration);
+
+ AlertDialog.Builder builder = new AlertDialog.Builder(context);
+ builder.setTitle(context.getString(R.string.ExpirationDialog_disappearing_messages));
+ builder.setView(view);
+ builder.setPositiveButton(android.R.string.ok, (dialog, which) -> {
+ int selected = ((NumberPickerView)view.findViewById(R.id.expiration_number_picker)).getValue();
+ listener.onClick(getExpirationTimes(context, currentExpiration)[selected]);
+ });
+ builder.setNegativeButton(android.R.string.cancel, null);
+ builder.show();
+ }
+
+ private static View createNumberPickerView(final Context context, final int currentExpiration) {
+ final LayoutInflater inflater = LayoutInflater.from(context);
+ final View view = inflater.inflate(R.layout.expiration_dialog, null);
+ final NumberPickerView numberPickerView = view.findViewById(R.id.expiration_number_picker);
+ final TextView textView = view.findViewById(R.id.expiration_details);
+ final int[] expirationTimes = getExpirationTimes(context, currentExpiration);
+ final String[] expirationDisplayValues = new String[expirationTimes.length];
+
+ int selectedIndex = expirationTimes.length - 1;
+
+ for (int i=0;i= expirationTimes[i]) &&
+ (i == expirationTimes.length -1 || currentExpiration < expirationTimes[i+1])) {
+ selectedIndex = i;
+ }
+ }
+
+ numberPickerView.setDisplayedValues(expirationDisplayValues);
+ numberPickerView.setMinValue(0);
+ numberPickerView.setMaxValue(expirationTimes.length-1);
+
+ NumberPickerView.OnValueChangeListener listener = (picker, oldVal, newVal) -> {
+ if (newVal == 0) {
+ textView.setText(R.string.ExpirationDialog_your_messages_will_not_expire);
+ } else {
+ textView.setText(context.getString(R.string.ExpirationDialog_your_messages_will_disappear_s_after_they_have_been_seen, picker.getDisplayedValues()[newVal]));
+ }
+ };
+
+ numberPickerView.setOnValueChangedListener(listener);
+ numberPickerView.setValue(selectedIndex);
+ listener.onValueChange(numberPickerView, selectedIndex, selectedIndex);
+
+ return view;
+ }
+
+ private static int[] getExpirationTimes(Context context, int currentExpiration) {
+ int[] expirationTimes = context.getResources().getIntArray(R.array.expiration_times);
+ int location = Arrays.binarySearch(expirationTimes, currentExpiration);
+ if (location < 0) {
+ int[] temp = Arrays.copyOf(expirationTimes, expirationTimes.length + 1);
+ temp[temp.length - 1] = currentExpiration;
+ Arrays.sort(temp);
+ expirationTimes = temp;
+ }
+
+ return expirationTimes;
+ }
+
+ public interface OnClickListener {
+ public void onClick(int expirationTime);
+ }
+
+}
diff --git a/app/src/main/java/org/thoughtcrime/securesms/GroupMembersDialog.java b/app/src/main/java/org/thoughtcrime/securesms/GroupMembersDialog.java
new file mode 100644
index 00000000..0d11e111
--- /dev/null
+++ b/app/src/main/java/org/thoughtcrime/securesms/GroupMembersDialog.java
@@ -0,0 +1,57 @@
+package org.thoughtcrime.securesms;
+
+import androidx.annotation.NonNull;
+import androidx.appcompat.app.AlertDialog;
+import androidx.fragment.app.FragmentActivity;
+import androidx.lifecycle.LiveData;
+
+import org.thoughtcrime.securesms.groups.LiveGroup;
+import org.thoughtcrime.securesms.groups.ui.GroupMemberEntry;
+import org.thoughtcrime.securesms.groups.ui.GroupMemberListView;
+import org.thoughtcrime.securesms.recipients.Recipient;
+import org.thoughtcrime.securesms.recipients.ui.bottomsheet.RecipientBottomSheetDialogFragment;
+
+import java.util.List;
+
+public final class GroupMembersDialog {
+
+ private final FragmentActivity fragmentActivity;
+ private final Recipient groupRecipient;
+
+ public GroupMembersDialog(@NonNull FragmentActivity activity,
+ @NonNull Recipient groupRecipient)
+ {
+ this.fragmentActivity = activity;
+ this.groupRecipient = groupRecipient;
+ }
+
+ public void display() {
+ AlertDialog dialog = new AlertDialog.Builder(fragmentActivity)
+ .setTitle(R.string.ConversationActivity_group_members)
+ .setIcon(R.drawable.ic_group_24)
+ .setCancelable(true)
+ .setView(R.layout.dialog_group_members)
+ .setPositiveButton(android.R.string.ok, null)
+ .show();
+
+ GroupMemberListView memberListView = dialog.findViewById(R.id.list_members);
+
+ LiveGroup liveGroup = new LiveGroup(groupRecipient.requireGroupId());
+ LiveData> fullMembers = liveGroup.getFullMembers();
+
+ //noinspection ConstantConditions
+ fullMembers.observe(fragmentActivity, memberListView::setMembers);
+
+ dialog.setOnDismissListener(d -> fullMembers.removeObservers(fragmentActivity));
+
+ memberListView.setRecipientClickListener(recipient -> {
+ dialog.dismiss();
+ contactClick(recipient);
+ });
+ }
+
+ private void contactClick(@NonNull Recipient recipient) {
+ RecipientBottomSheetDialogFragment.create(recipient.getId(), groupRecipient.requireGroupId())
+ .show(fragmentActivity.getSupportFragmentManager(), "BOTTOM");
+ }
+}
diff --git a/app/src/main/java/org/thoughtcrime/securesms/InviteActivity.java b/app/src/main/java/org/thoughtcrime/securesms/InviteActivity.java
new file mode 100644
index 00000000..23da2180
--- /dev/null
+++ b/app/src/main/java/org/thoughtcrime/securesms/InviteActivity.java
@@ -0,0 +1,310 @@
+package org.thoughtcrime.securesms;
+
+import android.annotation.SuppressLint;
+import android.content.Context;
+import android.content.Intent;
+import android.graphics.PorterDuff;
+import android.os.AsyncTask;
+import android.os.Build;
+import android.os.Bundle;
+import android.text.Editable;
+import android.text.TextWatcher;
+import android.view.View;
+import android.view.View.OnClickListener;
+import android.view.ViewGroup;
+import android.view.animation.Animation;
+import android.view.animation.AnimationUtils;
+import android.widget.Button;
+import android.widget.EditText;
+import android.widget.Toast;
+
+import androidx.annotation.AnimRes;
+import androidx.appcompat.app.AlertDialog;
+import androidx.appcompat.widget.Toolbar;
+import androidx.core.content.ContextCompat;
+import androidx.interpolator.view.animation.FastOutSlowInInterpolator;
+
+import org.thoughtcrime.securesms.components.ContactFilterToolbar;
+import org.thoughtcrime.securesms.components.ContactFilterToolbar.OnFilterChangedListener;
+import org.thoughtcrime.securesms.contacts.ContactsCursorLoader.DisplayMode;
+import org.thoughtcrime.securesms.contacts.SelectedContact;
+import org.thoughtcrime.securesms.database.DatabaseFactory;
+import org.thoughtcrime.securesms.groups.SelectionLimits;
+import org.thoughtcrime.securesms.recipients.Recipient;
+import org.thoughtcrime.securesms.recipients.RecipientId;
+import org.thoughtcrime.securesms.sms.MessageSender;
+import org.thoughtcrime.securesms.sms.OutgoingTextMessage;
+import org.thoughtcrime.securesms.util.DynamicNoActionBarInviteTheme;
+import org.thoughtcrime.securesms.util.DynamicTheme;
+import org.thoughtcrime.securesms.util.ThemeUtil;
+import org.thoughtcrime.securesms.util.Util;
+import org.thoughtcrime.securesms.util.ViewUtil;
+import org.thoughtcrime.securesms.util.WindowUtil;
+import org.thoughtcrime.securesms.util.concurrent.ListenableFuture.Listener;
+import org.thoughtcrime.securesms.util.task.ProgressDialogAsyncTask;
+import org.thoughtcrime.securesms.util.text.AfterTextChanged;
+import org.whispersystems.libsignal.util.guava.Optional;
+
+import java.util.concurrent.ExecutionException;
+
+public class InviteActivity extends PassphraseRequiredActivity implements ContactSelectionListFragment.OnContactSelectedListener {
+
+ private ContactSelectionListFragment contactsFragment;
+ private EditText inviteText;
+ private ViewGroup smsSendFrame;
+ private Button smsSendButton;
+ private Animation slideInAnimation;
+ private Animation slideOutAnimation;
+ private DynamicTheme dynamicTheme = new DynamicNoActionBarInviteTheme();
+ private Toolbar primaryToolbar;
+
+ @Override
+ protected void onPreCreate() {
+ super.onPreCreate();
+ dynamicTheme.onCreate(this);
+ }
+
+ @Override
+ protected void onCreate(Bundle savedInstanceState, boolean ready) {
+ getIntent().putExtra(ContactSelectionListFragment.DISPLAY_MODE, DisplayMode.FLAG_SMS);
+ getIntent().putExtra(ContactSelectionListFragment.SELECTION_LIMITS, SelectionLimits.NO_LIMITS);
+ getIntent().putExtra(ContactSelectionListFragment.HIDE_COUNT, true);
+ getIntent().putExtra(ContactSelectionListFragment.REFRESHABLE, false);
+
+ setContentView(R.layout.invite_activity);
+
+ initializeAppBar();
+ initializeResources();
+ }
+
+ @Override
+ protected void onResume() {
+ super.onResume();
+ dynamicTheme.onResume(this);
+ }
+
+ private void initializeAppBar() {
+ primaryToolbar = findViewById(R.id.toolbar);
+ setSupportActionBar(primaryToolbar);
+
+ assert getSupportActionBar() != null;
+
+ getSupportActionBar().setDisplayHomeAsUpEnabled(true);
+ getSupportActionBar().setTitle(R.string.AndroidManifest__invite_friends);
+ }
+
+ private void initializeResources() {
+ slideInAnimation = loadAnimation(R.anim.slide_from_bottom);
+ slideOutAnimation = loadAnimation(R.anim.slide_to_bottom);
+
+ View shareButton = findViewById(R.id.share_button);
+ Button smsButton = findViewById(R.id.sms_button);
+ Button smsCancelButton = findViewById(R.id.cancel_sms_button);
+ ContactFilterToolbar contactFilter = findViewById(R.id.contact_filter);
+
+ inviteText = findViewById(R.id.invite_text);
+ smsSendFrame = findViewById(R.id.sms_send_frame);
+ smsSendButton = findViewById(R.id.send_sms_button);
+ contactsFragment = (ContactSelectionListFragment)getSupportFragmentManager().findFragmentById(R.id.contact_selection_list_fragment);
+
+ inviteText.setText(getString(R.string.InviteActivity_lets_switch_to_signal, getString(R.string.install_url)));
+ inviteText.addTextChangedListener(new AfterTextChanged(editable -> {
+ boolean isEnabled = editable.length() > 0;
+ smsButton.setEnabled(isEnabled);
+ shareButton.setEnabled(isEnabled);
+ smsButton.animate().alpha(isEnabled ? 1f : 0.5f);
+ shareButton.animate().alpha(isEnabled ? 1f : 0.5f);
+ }));
+
+ updateSmsButtonText(contactsFragment.getSelectedContacts().size());
+
+ smsCancelButton.setOnClickListener(new SmsCancelClickListener());
+ smsSendButton.setOnClickListener(new SmsSendClickListener());
+ contactFilter.setOnFilterChangedListener(new ContactFilterChangedListener());
+ contactFilter.setNavigationIcon(R.drawable.ic_search_conversation_24);
+
+ if (Util.isDefaultSmsProvider(this)) {
+ shareButton.setOnClickListener(new ShareClickListener());
+ smsButton.setOnClickListener(new SmsClickListener());
+ } else {
+ shareButton.setVisibility(View.GONE);
+ smsButton.setOnClickListener(new ShareClickListener());
+ smsButton.setText(R.string.InviteActivity_share);
+ }
+ }
+
+ private Animation loadAnimation(@AnimRes int animResId) {
+ final Animation animation = AnimationUtils.loadAnimation(this, animResId);
+ animation.setInterpolator(new FastOutSlowInInterpolator());
+ return animation;
+ }
+
+ @Override
+ public boolean onBeforeContactSelected(Optional recipientId, String number) {
+ updateSmsButtonText(contactsFragment.getSelectedContacts().size() + 1);
+ return true;
+ }
+
+ @Override
+ public void onContactDeselected(Optional recipientId, String number) {
+ updateSmsButtonText(contactsFragment.getSelectedContacts().size());
+ }
+
+ private void sendSmsInvites() {
+ new SendSmsInvitesAsyncTask(this, inviteText.getText().toString())
+ .executeOnExecutor(AsyncTask.THREAD_POOL_EXECUTOR,
+ contactsFragment.getSelectedContacts()
+ .toArray(new SelectedContact[0]));
+ }
+
+ private void updateSmsButtonText(int count) {
+ smsSendButton.setText(getResources().getQuantityString(R.plurals.InviteActivity_send_sms_to_friends,
+ count,
+ count));
+ smsSendButton.setEnabled(count > 0);
+ }
+
+ @Override public void onBackPressed() {
+ if (smsSendFrame.getVisibility() == View.VISIBLE) {
+ cancelSmsSelection();
+ } else {
+ super.onBackPressed();
+ }
+ }
+
+ private void cancelSmsSelection() {
+ setPrimaryColorsToolbarNormal();
+ contactsFragment.reset();
+ updateSmsButtonText(contactsFragment.getSelectedContacts().size());
+ ViewUtil.animateOut(smsSendFrame, slideOutAnimation, View.GONE);
+ }
+
+ private void setPrimaryColorsToolbarNormal() {
+ primaryToolbar.setBackgroundColor(0);
+ primaryToolbar.getNavigationIcon().setColorFilter(null);
+ primaryToolbar.setTitleTextColor(ContextCompat.getColor(this, R.color.signal_text_primary));
+
+ if (Build.VERSION.SDK_INT >= 23) {
+ WindowUtil.setStatusBarColor(getWindow(), ThemeUtil.getThemedColor(this, android.R.attr.statusBarColor));
+ getWindow().setNavigationBarColor(ThemeUtil.getThemedColor(this, android.R.attr.navigationBarColor));
+ WindowUtil.setLightStatusBarFromTheme(this);
+ }
+
+ WindowUtil.setLightNavigationBarFromTheme(this);
+ }
+
+ private void setPrimaryColorsToolbarForSms() {
+ primaryToolbar.setBackgroundColor(ContextCompat.getColor(this, R.color.core_ultramarine));
+ primaryToolbar.getNavigationIcon().setColorFilter(ContextCompat.getColor(this, R.color.signal_text_toolbar_subtitle), PorterDuff.Mode.SRC_IN);
+ primaryToolbar.setTitleTextColor(ContextCompat.getColor(this, R.color.signal_text_toolbar_title));
+
+ if (Build.VERSION.SDK_INT >= 23) {
+ WindowUtil.setStatusBarColor(getWindow(), ContextCompat.getColor(this, R.color.core_ultramarine));
+ WindowUtil.clearLightStatusBar(getWindow());
+ }
+
+ if (Build.VERSION.SDK_INT >= 27) {
+ getWindow().setNavigationBarColor(ContextCompat.getColor(this, R.color.core_ultramarine));
+ WindowUtil.clearLightNavigationBar(getWindow());
+ }
+ }
+
+ private class ShareClickListener implements OnClickListener {
+ @Override
+ public void onClick(View v) {
+ Intent sendIntent = new Intent();
+ sendIntent.setAction(Intent.ACTION_SEND);
+ sendIntent.putExtra(Intent.EXTRA_TEXT, inviteText.getText().toString());
+ sendIntent.setType("text/plain");
+ if (sendIntent.resolveActivity(getPackageManager()) != null) {
+ startActivity(Intent.createChooser(sendIntent, getString(R.string.InviteActivity_invite_to_signal)));
+ } else {
+ Toast.makeText(InviteActivity.this, R.string.InviteActivity_no_app_to_share_to, Toast.LENGTH_LONG).show();
+ }
+ }
+ }
+
+ private class SmsClickListener implements OnClickListener {
+ @Override
+ public void onClick(View v) {
+ setPrimaryColorsToolbarForSms();
+ ViewUtil.animateIn(smsSendFrame, slideInAnimation);
+ }
+ }
+
+ private class SmsCancelClickListener implements OnClickListener {
+ @Override
+ public void onClick(View v) {
+ cancelSmsSelection();
+ }
+ }
+
+ private class SmsSendClickListener implements OnClickListener {
+ @Override
+ public void onClick(View v) {
+ new AlertDialog.Builder(InviteActivity.this)
+ .setTitle(getResources().getQuantityString(R.plurals.InviteActivity_send_sms_invites,
+ contactsFragment.getSelectedContacts().size(),
+ contactsFragment.getSelectedContacts().size()))
+ .setMessage(inviteText.getText().toString())
+ .setPositiveButton(R.string.yes, (dialog, which) -> sendSmsInvites())
+ .setNegativeButton(R.string.no, (dialog, which) -> dialog.dismiss())
+ .show();
+ }
+ }
+
+ private class ContactFilterChangedListener implements OnFilterChangedListener {
+ @Override
+ public void onFilterChanged(String filter) {
+ contactsFragment.setQueryFilter(filter);
+ }
+ }
+
+ @SuppressLint("StaticFieldLeak")
+ private class SendSmsInvitesAsyncTask extends ProgressDialogAsyncTask {
+ private final String message;
+
+ SendSmsInvitesAsyncTask(Context context, String message) {
+ super(context, R.string.InviteActivity_sending, R.string.InviteActivity_sending);
+ this.message = message;
+ }
+
+ @Override
+ protected Void doInBackground(SelectedContact... contacts) {
+ final Context context = getContext();
+ if (context == null) return null;
+
+ for (SelectedContact contact : contacts) {
+ RecipientId recipientId = contact.getOrCreateRecipientId(context);
+ Recipient recipient = Recipient.resolved(recipientId);
+ int subscriptionId = recipient.getDefaultSubscriptionId().or(-1);
+
+ MessageSender.send(context, new OutgoingTextMessage(recipient, message, subscriptionId), -1L, true, null);
+
+ if (recipient.getContactUri() != null) {
+ DatabaseFactory.getRecipientDatabase(context).setHasSentInvite(recipient.getId());
+ }
+ }
+
+ return null;
+ }
+
+ @Override
+ protected void onPostExecute(Void aVoid) {
+ super.onPostExecute(aVoid);
+ final Context context = getContext();
+ if (context == null) return;
+
+ ViewUtil.animateOut(smsSendFrame, slideOutAnimation, View.GONE).addListener(new Listener() {
+ @Override
+ public void onSuccess(Boolean result) {
+ contactsFragment.reset();
+ }
+
+ @Override
+ public void onFailure(ExecutionException e) {}
+ });
+ Toast.makeText(context, R.string.InviteActivity_invitations_sent, Toast.LENGTH_LONG).show();
+ }
+ }
+}
diff --git a/app/src/main/java/org/thoughtcrime/securesms/KbsEnclave.java b/app/src/main/java/org/thoughtcrime/securesms/KbsEnclave.java
new file mode 100644
index 00000000..aa3afa40
--- /dev/null
+++ b/app/src/main/java/org/thoughtcrime/securesms/KbsEnclave.java
@@ -0,0 +1,49 @@
+package org.thoughtcrime.securesms;
+
+import androidx.annotation.NonNull;
+
+import java.util.Objects;
+
+/**
+ * Used in our {@link BuildConfig} to tie together the various attributes of a KBS instance. This
+ * is sitting in the root directory so it can be accessed by the build config.
+ */
+public final class KbsEnclave {
+
+ private final String enclaveName;
+ private final String serviceId;
+ private final String mrEnclave;
+
+ public KbsEnclave(@NonNull String enclaveName, @NonNull String serviceId, @NonNull String mrEnclave) {
+ this.enclaveName = enclaveName;
+ this.serviceId = serviceId;
+ this.mrEnclave = mrEnclave;
+ }
+
+ public @NonNull String getMrEnclave() {
+ return mrEnclave;
+ }
+
+ public @NonNull String getEnclaveName() {
+ return enclaveName;
+ }
+
+ public @NonNull String getServiceId() {
+ return serviceId;
+ }
+
+ @Override
+ public boolean equals(Object o) {
+ if (this == o) return true;
+ if (o == null || getClass() != o.getClass()) return false;
+ KbsEnclave enclave = (KbsEnclave) o;
+ return enclaveName.equals(enclave.enclaveName) &&
+ serviceId.equals(enclave.serviceId) &&
+ mrEnclave.equals(enclave.mrEnclave);
+ }
+
+ @Override
+ public int hashCode() {
+ return Objects.hash(enclaveName, serviceId, mrEnclave);
+ }
+}
diff --git a/app/src/main/java/org/thoughtcrime/securesms/LoggingFragment.java b/app/src/main/java/org/thoughtcrime/securesms/LoggingFragment.java
new file mode 100644
index 00000000..e9a8eeea
--- /dev/null
+++ b/app/src/main/java/org/thoughtcrime/securesms/LoggingFragment.java
@@ -0,0 +1,45 @@
+package org.thoughtcrime.securesms;
+
+import android.os.Bundle;
+
+import androidx.annotation.NonNull;
+import androidx.annotation.Nullable;
+import androidx.fragment.app.Fragment;
+
+import org.signal.core.util.logging.Log;
+
+/**
+ * Simply logs out lifecycle events.
+ */
+public abstract class LoggingFragment extends Fragment {
+
+ private static final String TAG = Log.tag(LoggingFragment.class);
+
+ @Override
+ public void onCreate(@Nullable Bundle savedInstanceState) {
+ logEvent("onCreate()");
+ super.onCreate(savedInstanceState);
+ }
+
+ @Override
+ public void onStart() {
+ logEvent("onStart()");
+ super.onStart();
+ }
+
+ @Override
+ public void onStop() {
+ logEvent("onStop()");
+ super.onStop();
+ }
+
+ @Override
+ public void onDestroy() {
+ logEvent("onDestroy()");
+ super.onDestroy();
+ }
+
+ private void logEvent(@NonNull String event) {
+ Log.d(TAG, "[" + Log.tag(getClass()) + "] " + event);
+ }
+}
diff --git a/app/src/main/java/org/thoughtcrime/securesms/MainActivity.java b/app/src/main/java/org/thoughtcrime/securesms/MainActivity.java
new file mode 100644
index 00000000..15844129
--- /dev/null
+++ b/app/src/main/java/org/thoughtcrime/securesms/MainActivity.java
@@ -0,0 +1,107 @@
+package org.thoughtcrime.securesms;
+
+import android.app.Activity;
+import android.content.Context;
+import android.content.Intent;
+import android.net.Uri;
+import android.os.Bundle;
+
+import androidx.annotation.NonNull;
+import androidx.annotation.Nullable;
+
+import org.thoughtcrime.securesms.util.AppStartup;
+import org.thoughtcrime.securesms.util.CachedInflater;
+import org.thoughtcrime.securesms.util.CommunicationActions;
+import org.thoughtcrime.securesms.util.DynamicNoActionBarTheme;
+import org.thoughtcrime.securesms.util.DynamicTheme;
+
+public class MainActivity extends PassphraseRequiredActivity {
+
+ public static final int RESULT_CONFIG_CHANGED = Activity.RESULT_FIRST_USER + 901;
+
+ private final DynamicTheme dynamicTheme = new DynamicNoActionBarTheme();
+ private final MainNavigator navigator = new MainNavigator(this);
+
+ public static @NonNull Intent clearTop(@NonNull Context context) {
+ Intent intent = new Intent(context, MainActivity.class);
+
+ intent.setFlags(Intent.FLAG_ACTIVITY_CLEAR_TOP |
+ Intent.FLAG_ACTIVITY_NEW_TASK |
+ Intent.FLAG_ACTIVITY_SINGLE_TOP);
+
+ return intent;
+ }
+
+ @Override
+ protected void onCreate(Bundle savedInstanceState, boolean ready) {
+ AppStartup.getInstance().onCriticalRenderEventStart();
+ super.onCreate(savedInstanceState, ready);
+ setContentView(R.layout.main_activity);
+
+ navigator.onCreate(savedInstanceState);
+
+ handleGroupLinkInIntent(getIntent());
+ handleProxyInIntent(getIntent());
+
+ CachedInflater.from(this).clear();
+ }
+
+ @Override
+ public Intent getIntent() {
+ return super.getIntent().setFlags(Intent.FLAG_ACTIVITY_CLEAR_TOP |
+ Intent.FLAG_ACTIVITY_NEW_TASK |
+ Intent.FLAG_ACTIVITY_SINGLE_TOP);
+ }
+
+ @Override
+ protected void onNewIntent(Intent intent) {
+ super.onNewIntent(intent);
+ handleGroupLinkInIntent(intent);
+ handleProxyInIntent(intent);
+ }
+
+ @Override
+ protected void onPreCreate() {
+ super.onPreCreate();
+ dynamicTheme.onCreate(this);
+ }
+
+ @Override
+ protected void onResume() {
+ super.onResume();
+ dynamicTheme.onResume(this);
+ }
+
+ @Override
+ public void onBackPressed() {
+ if (!navigator.onBackPressed()) {
+ super.onBackPressed();
+ }
+ }
+
+ @Override
+ protected void onActivityResult(int requestCode, int resultCode, @Nullable Intent data) {
+ super.onActivityResult(requestCode, resultCode, data);
+ if (requestCode == MainNavigator.REQUEST_CONFIG_CHANGES && resultCode == RESULT_CONFIG_CHANGED) {
+ recreate();
+ }
+ }
+
+ public @NonNull MainNavigator getNavigator() {
+ return navigator;
+ }
+
+ private void handleGroupLinkInIntent(Intent intent) {
+ Uri data = intent.getData();
+ if (data != null) {
+ CommunicationActions.handlePotentialGroupLinkUrl(this, data.toString());
+ }
+ }
+
+ private void handleProxyInIntent(Intent intent) {
+ Uri data = intent.getData();
+ if (data != null) {
+ CommunicationActions.handlePotentialProxyLinkUrl(this, data.toString());
+ }
+ }
+}
diff --git a/app/src/main/java/org/thoughtcrime/securesms/MainFragment.java b/app/src/main/java/org/thoughtcrime/securesms/MainFragment.java
new file mode 100644
index 00000000..fcac5438
--- /dev/null
+++ b/app/src/main/java/org/thoughtcrime/securesms/MainFragment.java
@@ -0,0 +1,21 @@
+package org.thoughtcrime.securesms;
+
+import android.content.Context;
+
+import androidx.annotation.NonNull;
+
+public class MainFragment extends LoggingFragment {
+
+ @Override
+ public void onAttach(@NonNull Context context) {
+ super.onAttach(context);
+
+ if (!(requireActivity() instanceof MainActivity)) {
+ throw new IllegalStateException("Can only be used inside of MainActivity!");
+ }
+ }
+
+ protected @NonNull MainNavigator getNavigator() {
+ return MainNavigator.get(requireActivity());
+ }
+}
diff --git a/app/src/main/java/org/thoughtcrime/securesms/MainNavigator.java b/app/src/main/java/org/thoughtcrime/securesms/MainNavigator.java
new file mode 100644
index 00000000..67a29153
--- /dev/null
+++ b/app/src/main/java/org/thoughtcrime/securesms/MainNavigator.java
@@ -0,0 +1,108 @@
+package org.thoughtcrime.securesms;
+
+import android.app.Activity;
+import android.content.Intent;
+import android.os.Bundle;
+
+import androidx.annotation.NonNull;
+import androidx.annotation.Nullable;
+import androidx.fragment.app.Fragment;
+import androidx.fragment.app.FragmentManager;
+
+import org.thoughtcrime.securesms.conversation.ConversationIntents;
+import org.thoughtcrime.securesms.conversationlist.ConversationListArchiveFragment;
+import org.thoughtcrime.securesms.conversationlist.ConversationListFragment;
+import org.thoughtcrime.securesms.groups.ui.creategroup.CreateGroupActivity;
+import org.thoughtcrime.securesms.insights.InsightsLauncher;
+import org.thoughtcrime.securesms.recipients.RecipientId;
+
+public class MainNavigator {
+
+ public static final int REQUEST_CONFIG_CHANGES = 901;
+
+ private final MainActivity activity;
+
+ public MainNavigator(@NonNull MainActivity activity) {
+ this.activity = activity;
+ }
+
+ public static MainNavigator get(@NonNull Activity activity) {
+ if (!(activity instanceof MainActivity)) {
+ throw new IllegalArgumentException("Activity must be an instance of MainActivity!");
+ }
+
+ return ((MainActivity) activity).getNavigator();
+ }
+
+ public void onCreate(@Nullable Bundle savedInstanceState) {
+ if (savedInstanceState != null) {
+ return;
+ }
+
+ getFragmentManager().beginTransaction()
+ .add(R.id.fragment_container, ConversationListFragment.newInstance())
+ .commit();
+ }
+
+ /**
+ * @return True if the back pressed was handled in our own custom way, false if it should be given
+ * to the system to do the default behavior.
+ */
+ public boolean onBackPressed() {
+ Fragment fragment = getFragmentManager().findFragmentById(R.id.fragment_container);
+
+ if (fragment instanceof BackHandler) {
+ return ((BackHandler) fragment).onBackPressed();
+ }
+
+ return false;
+ }
+
+ public void goToConversation(@NonNull RecipientId recipientId, long threadId, int distributionType, int startingPosition) {
+ Intent intent = ConversationIntents.createBuilder(activity, recipientId, threadId)
+ .withDistributionType(distributionType)
+ .withStartingPosition(startingPosition)
+ .build();
+
+ activity.startActivity(intent);
+ activity.overridePendingTransition(R.anim.slide_from_end, R.anim.fade_scale_out);
+ }
+
+ public void goToAppSettings() {
+ Intent intent = new Intent(activity, ApplicationPreferencesActivity.class);
+ activity.startActivityForResult(intent, REQUEST_CONFIG_CHANGES);
+ }
+
+ public void goToArchiveList() {
+ getFragmentManager().beginTransaction()
+ .setCustomAnimations(R.anim.slide_from_end, R.anim.slide_to_start, R.anim.slide_from_start, R.anim.slide_to_end)
+ .replace(R.id.fragment_container, ConversationListArchiveFragment.newInstance())
+ .addToBackStack(null)
+ .commit();
+ }
+
+ public void goToGroupCreation() {
+ activity.startActivity(CreateGroupActivity.newIntent(activity));
+ }
+
+ public void goToInvite() {
+ Intent intent = new Intent(activity, InviteActivity.class);
+ activity.startActivity(intent);
+ }
+
+ public void goToInsights() {
+ InsightsLauncher.showInsightsDashboard(activity.getSupportFragmentManager());
+ }
+
+ private @NonNull FragmentManager getFragmentManager() {
+ return activity.getSupportFragmentManager();
+ }
+
+ public interface BackHandler {
+ /**
+ * @return True if the back pressed was handled in our own custom way, false if it should be given
+ * to the system to do the default behavior.
+ */
+ boolean onBackPressed();
+ }
+}
diff --git a/app/src/main/java/org/thoughtcrime/securesms/MasterSecretListener.java b/app/src/main/java/org/thoughtcrime/securesms/MasterSecretListener.java
new file mode 100644
index 00000000..66070e6b
--- /dev/null
+++ b/app/src/main/java/org/thoughtcrime/securesms/MasterSecretListener.java
@@ -0,0 +1,5 @@
+package org.thoughtcrime.securesms;
+
+public interface MasterSecretListener {
+ void onMasterSecretCleared();
+}
diff --git a/app/src/main/java/org/thoughtcrime/securesms/MediaPreviewActivity.java b/app/src/main/java/org/thoughtcrime/securesms/MediaPreviewActivity.java
new file mode 100644
index 00000000..aa06149f
--- /dev/null
+++ b/app/src/main/java/org/thoughtcrime/securesms/MediaPreviewActivity.java
@@ -0,0 +1,867 @@
+/*
+ * 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 .
+ */
+package org.thoughtcrime.securesms;
+
+import android.Manifest;
+import android.annotation.SuppressLint;
+import android.content.ActivityNotFoundException;
+import android.content.Context;
+import android.content.Intent;
+import android.database.ContentObserver;
+import android.database.Cursor;
+import android.net.Uri;
+import android.os.AsyncTask;
+import android.os.Build;
+import android.os.Bundle;
+import android.os.Handler;
+import android.view.Menu;
+import android.view.MenuInflater;
+import android.view.MenuItem;
+import android.view.View;
+import android.view.ViewGroup;
+import android.widget.TextView;
+import android.widget.Toast;
+
+import androidx.annotation.NonNull;
+import androidx.annotation.Nullable;
+import androidx.appcompat.app.AlertDialog;
+import androidx.appcompat.app.AppCompatDelegate;
+import androidx.core.app.ShareCompat;
+import androidx.core.util.Pair;
+import androidx.core.view.ViewCompat;
+import androidx.fragment.app.Fragment;
+import androidx.fragment.app.FragmentManager;
+import androidx.fragment.app.FragmentStatePagerAdapter;
+import androidx.lifecycle.ViewModelProviders;
+import androidx.loader.app.LoaderManager;
+import androidx.loader.content.Loader;
+import androidx.recyclerview.widget.LinearLayoutManager;
+import androidx.recyclerview.widget.RecyclerView;
+import androidx.viewpager.widget.ViewPager;
+
+import org.signal.core.util.logging.Log;
+import org.thoughtcrime.securesms.animation.DepthPageTransformer;
+import org.thoughtcrime.securesms.attachments.DatabaseAttachment;
+import org.thoughtcrime.securesms.components.viewpager.ExtendedOnPageChangedListener;
+import org.thoughtcrime.securesms.database.MediaDatabase;
+import org.thoughtcrime.securesms.database.MediaDatabase.MediaRecord;
+import org.thoughtcrime.securesms.database.loaders.PagingMediaLoader;
+import org.thoughtcrime.securesms.mediaoverview.MediaOverviewActivity;
+import org.thoughtcrime.securesms.mediapreview.MediaPreviewFragment;
+import org.thoughtcrime.securesms.mediapreview.MediaPreviewViewModel;
+import org.thoughtcrime.securesms.mediapreview.MediaRailAdapter;
+import org.thoughtcrime.securesms.mms.GlideApp;
+import org.thoughtcrime.securesms.mms.PartAuthority;
+import org.thoughtcrime.securesms.permissions.Permissions;
+import org.thoughtcrime.securesms.recipients.Recipient;
+import org.thoughtcrime.securesms.recipients.RecipientId;
+import org.thoughtcrime.securesms.sharing.ShareActivity;
+import org.thoughtcrime.securesms.util.AttachmentUtil;
+import org.thoughtcrime.securesms.util.DateUtils;
+import org.thoughtcrime.securesms.util.FullscreenHelper;
+import org.thoughtcrime.securesms.util.SaveAttachmentTask;
+import org.thoughtcrime.securesms.util.SaveAttachmentTask.Attachment;
+import org.thoughtcrime.securesms.util.StorageUtil;
+
+import java.util.HashMap;
+import java.util.Locale;
+import java.util.Map;
+import java.util.Objects;
+
+/**
+ * Activity for displaying media attachments in-app
+ */
+public final class MediaPreviewActivity extends PassphraseRequiredActivity
+ implements LoaderManager.LoaderCallbacks>,
+ MediaRailAdapter.RailItemListener,
+ MediaPreviewFragment.Events
+{
+
+ private final static String TAG = MediaPreviewActivity.class.getSimpleName();
+
+ private static final int NOT_IN_A_THREAD = -2;
+
+ public static final String THREAD_ID_EXTRA = "thread_id";
+ public static final String DATE_EXTRA = "date";
+ public static final String SIZE_EXTRA = "size";
+ public static final String CAPTION_EXTRA = "caption";
+ public static final String LEFT_IS_RECENT_EXTRA = "left_is_recent";
+ public static final String HIDE_ALL_MEDIA_EXTRA = "came_from_all_media";
+ public static final String SHOW_THREAD_EXTRA = "show_thread";
+ public static final String SORTING_EXTRA = "sorting";
+
+ private ViewPager mediaPager;
+ private View detailsContainer;
+ private TextView caption;
+ private View captionContainer;
+ private RecyclerView albumRail;
+ private MediaRailAdapter albumRailAdapter;
+ private ViewGroup playbackControlsContainer;
+ private Uri initialMediaUri;
+ private String initialMediaType;
+ private long initialMediaSize;
+ private String initialCaption;
+ private boolean leftIsRecent;
+ private MediaPreviewViewModel viewModel;
+ private ViewPagerListener viewPagerListener;
+
+ private int restartItem = -1;
+ private long threadId = NOT_IN_A_THREAD;
+ private boolean cameFromAllMedia;
+ private boolean showThread;
+ private MediaDatabase.Sorting sorting;
+ private FullscreenHelper fullscreenHelper;
+
+ private @Nullable Cursor cursor = null;
+
+ public static @NonNull Intent intentFromMediaRecord(@NonNull Context context,
+ @NonNull MediaRecord mediaRecord,
+ boolean leftIsRecent)
+ {
+ DatabaseAttachment attachment = Objects.requireNonNull(mediaRecord.getAttachment());
+ Intent intent = new Intent(context, MediaPreviewActivity.class);
+ intent.putExtra(MediaPreviewActivity.THREAD_ID_EXTRA, mediaRecord.getThreadId());
+ intent.putExtra(MediaPreviewActivity.DATE_EXTRA, mediaRecord.getDate());
+ intent.putExtra(MediaPreviewActivity.SIZE_EXTRA, attachment.getSize());
+ intent.putExtra(MediaPreviewActivity.CAPTION_EXTRA, attachment.getCaption());
+ intent.putExtra(MediaPreviewActivity.LEFT_IS_RECENT_EXTRA, leftIsRecent);
+ intent.setDataAndType(attachment.getUri(), mediaRecord.getContentType());
+ return intent;
+ }
+
+ @Override
+ protected void attachBaseContext(@NonNull Context newBase) {
+ getDelegate().setLocalNightMode(AppCompatDelegate.MODE_NIGHT_YES);
+ super.attachBaseContext(newBase);
+ }
+
+ @SuppressWarnings("ConstantConditions")
+ @Override
+ protected void onCreate(Bundle bundle, boolean ready) {
+ this.setTheme(R.style.TextSecure_MediaPreview);
+ setContentView(R.layout.media_preview_activity);
+
+ setSupportActionBar(findViewById(R.id.toolbar));
+
+ viewModel = ViewModelProviders.of(this).get(MediaPreviewViewModel.class);
+
+ fullscreenHelper = new FullscreenHelper(this);
+
+ getSupportActionBar().setDisplayHomeAsUpEnabled(true);
+
+ initializeViews();
+ initializeResources();
+ initializeObservers();
+ }
+
+ @Override
+ public void onRequestPermissionsResult(int requestCode, @NonNull String[] permissions, @NonNull int[] grantResults) {
+ Permissions.onRequestPermissionsResult(this, requestCode, permissions, grantResults);
+ }
+
+ @Override
+ public void onRailItemClicked(int distanceFromActive) {
+ mediaPager.setCurrentItem(mediaPager.getCurrentItem() + distanceFromActive);
+ }
+
+ @Override
+ public void onRailItemDeleteClicked(int distanceFromActive) {
+ throw new UnsupportedOperationException("Callback unsupported.");
+ }
+
+ @SuppressWarnings("ConstantConditions")
+ private void initializeActionBar() {
+ MediaItem mediaItem = getCurrentMediaItem();
+
+ if (mediaItem != null) {
+ getSupportActionBar().setTitle(getTitleText(mediaItem));
+ getSupportActionBar().setSubtitle(getSubTitleText(mediaItem));
+ }
+ }
+
+ private @NonNull String getTitleText(@NonNull MediaItem mediaItem) {
+ String from;
+ if (mediaItem.outgoing) from = getString(R.string.MediaPreviewActivity_you);
+ else if (mediaItem.recipient != null) from = mediaItem.recipient.getDisplayName(this);
+ else from = "";
+
+ if (showThread) {
+ String to = null;
+ Recipient threadRecipient = mediaItem.threadRecipient;
+
+ if (threadRecipient != null) {
+ if (mediaItem.outgoing || threadRecipient.isGroup()) {
+ if (threadRecipient.isSelf()) {
+ from = getString(R.string.note_to_self);
+ } else {
+ to = threadRecipient.getDisplayName(this);
+ }
+ } else {
+ to = getString(R.string.MediaPreviewActivity_you);
+ }
+ }
+
+ return to != null ? getString(R.string.MediaPreviewActivity_s_to_s, from, to)
+ : from;
+ } else {
+ return from;
+ }
+ }
+
+ private @NonNull String getSubTitleText(@NonNull MediaItem mediaItem) {
+ if (mediaItem.date > 0) {
+ return DateUtils.getExtendedRelativeTimeSpanString(this, Locale.getDefault(), mediaItem.date);
+ } else {
+ return getString(R.string.MediaPreviewActivity_draft);
+ }
+ }
+
+ @Override
+ public void onResume() {
+ super.onResume();
+
+ initializeMedia();
+ }
+
+ @Override
+ public void onPause() {
+ super.onPause();
+ restartItem = cleanupMedia();
+ }
+
+ @Override
+ protected void onDestroy() {
+ if (cursor != null) {
+ cursor.close();
+ cursor = null;
+ }
+ super.onDestroy();
+ }
+
+ @Override
+ protected void onNewIntent(Intent intent) {
+ super.onNewIntent(intent);
+ setIntent(intent);
+ initializeResources();
+ }
+
+ private void initializeViews() {
+ mediaPager = findViewById(R.id.media_pager);
+ mediaPager.setOffscreenPageLimit(1);
+ mediaPager.setPageTransformer(true, new DepthPageTransformer());
+
+ viewPagerListener = new ViewPagerListener();
+ mediaPager.addOnPageChangeListener(viewPagerListener);
+
+ albumRail = findViewById(R.id.media_preview_album_rail);
+ albumRailAdapter = new MediaRailAdapter(GlideApp.with(this), this, false);
+
+ albumRail.setItemAnimator(null); // Or can crash when set to INVISIBLE while animating by FullscreenHelper https://issuetracker.google.com/issues/148720682
+ albumRail.setLayoutManager(new LinearLayoutManager(this, LinearLayoutManager.HORIZONTAL, false));
+ albumRail.setAdapter(albumRailAdapter);
+
+ detailsContainer = findViewById(R.id.media_preview_details_container);
+ caption = findViewById(R.id.media_preview_caption);
+ captionContainer = findViewById(R.id.media_preview_caption_container);
+ playbackControlsContainer = findViewById(R.id.media_preview_playback_controls_container);
+
+ View toolbarLayout = findViewById(R.id.toolbar_layout);
+
+ anchorMarginsToBottomInsets(detailsContainer);
+
+ fullscreenHelper.configureToolbarSpacer(findViewById(R.id.toolbar_cutout_spacer));
+
+ fullscreenHelper.showAndHideWithSystemUI(getWindow(), detailsContainer, toolbarLayout);
+ }
+
+ private void initializeResources() {
+ Intent intent = getIntent();
+
+ threadId = intent.getLongExtra(THREAD_ID_EXTRA, NOT_IN_A_THREAD);
+ cameFromAllMedia = intent.getBooleanExtra(HIDE_ALL_MEDIA_EXTRA, false);
+ showThread = intent.getBooleanExtra(SHOW_THREAD_EXTRA, false);
+ sorting = MediaDatabase.Sorting.values()[intent.getIntExtra(SORTING_EXTRA, 0)];
+
+ initialMediaUri = intent.getData();
+ initialMediaType = intent.getType();
+ initialMediaSize = intent.getLongExtra(SIZE_EXTRA, 0);
+ initialCaption = intent.getStringExtra(CAPTION_EXTRA);
+ leftIsRecent = intent.getBooleanExtra(LEFT_IS_RECENT_EXTRA, false);
+ restartItem = -1;
+ }
+
+ private void initializeObservers() {
+ viewModel.getPreviewData().observe(this, previewData -> {
+ if (previewData == null || mediaPager == null || mediaPager.getAdapter() == null) {
+ return;
+ }
+
+ if (!((MediaItemAdapter) mediaPager.getAdapter()).hasFragmentFor(mediaPager.getCurrentItem())) {
+ Log.d(TAG, "MediaItemAdapter wasn't ready. Posting again...");
+ viewModel.resubmitPreviewData();
+ }
+
+ View playbackControls = ((MediaItemAdapter) mediaPager.getAdapter()).getPlaybackControls(mediaPager.getCurrentItem());
+
+ if (previewData.getAlbumThumbnails().isEmpty() && previewData.getCaption() == null && playbackControls == null) {
+ detailsContainer.setVisibility(View.GONE);
+ } else {
+ detailsContainer.setVisibility(View.VISIBLE);
+ }
+
+ albumRail.setVisibility(previewData.getAlbumThumbnails().isEmpty() ? View.GONE : View.VISIBLE);
+ albumRailAdapter.setMedia(previewData.getAlbumThumbnails(), previewData.getActivePosition());
+ albumRail.smoothScrollToPosition(previewData.getActivePosition());
+
+ captionContainer.setVisibility(previewData.getCaption() == null ? View.GONE : View.VISIBLE);
+ caption.setText(previewData.getCaption());
+
+ if (playbackControls != null) {
+ ViewGroup.LayoutParams params = new ViewGroup.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.WRAP_CONTENT);
+ playbackControls.setLayoutParams(params);
+
+ playbackControlsContainer.removeAllViews();
+ playbackControlsContainer.addView(playbackControls);
+ } else {
+ playbackControlsContainer.removeAllViews();
+ }
+ });
+ }
+
+ private void initializeMedia() {
+ if (!isContentTypeSupported(initialMediaType)) {
+ Log.w(TAG, "Unsupported media type sent to MediaPreviewActivity, finishing.");
+ Toast.makeText(getApplicationContext(), R.string.MediaPreviewActivity_unssuported_media_type, Toast.LENGTH_LONG).show();
+ finish();
+ }
+
+ Log.i(TAG, "Loading Part URI: " + initialMediaUri);
+
+ if (isMediaInDb()) {
+ LoaderManager.getInstance(this).restartLoader(0, null, this);
+ } else {
+ mediaPager.setAdapter(new SingleItemPagerAdapter(getSupportFragmentManager(), initialMediaUri, initialMediaType, initialMediaSize));
+
+ if (initialCaption != null) {
+ detailsContainer.setVisibility(View.VISIBLE);
+ captionContainer.setVisibility(View.VISIBLE);
+ caption.setText(initialCaption);
+ }
+ }
+ }
+
+ private int cleanupMedia() {
+ int restartItem = mediaPager.getCurrentItem();
+
+ mediaPager.removeAllViews();
+ mediaPager.setAdapter(null);
+ viewModel.setCursor(this, null, leftIsRecent);
+
+ return restartItem;
+ }
+
+ private void showOverview() {
+ startActivity(MediaOverviewActivity.forThread(this, threadId));
+ }
+
+ private void forward() {
+ MediaItem mediaItem = getCurrentMediaItem();
+
+ if (mediaItem != null) {
+ Intent composeIntent = new Intent(this, ShareActivity.class);
+ composeIntent.putExtra(Intent.EXTRA_STREAM, mediaItem.uri);
+ composeIntent.setType(mediaItem.type);
+ startActivity(composeIntent);
+ }
+ }
+
+ private void share() {
+ MediaItem mediaItem = getCurrentMediaItem();
+
+ if (mediaItem != null) {
+ Uri publicUri = PartAuthority.getAttachmentPublicUri(mediaItem.uri);
+ String mimeType = Intent.normalizeMimeType(mediaItem.type);
+ Intent shareIntent = ShareCompat.IntentBuilder.from(this)
+ .setStream(publicUri)
+ .setType(mimeType)
+ .createChooserIntent()
+ .addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION);
+
+ try {
+ startActivity(shareIntent);
+ } catch (ActivityNotFoundException e) {
+ Log.w(TAG, "No activity existed to share the media.", e);
+ Toast.makeText(this, R.string.MediaPreviewActivity_cant_find_an_app_able_to_share_this_media, Toast.LENGTH_LONG).show();
+ }
+ }
+ }
+
+ @SuppressWarnings("CodeBlock2Expr")
+ @SuppressLint("InlinedApi")
+ private void saveToDisk() {
+ MediaItem mediaItem = getCurrentMediaItem();
+
+ if (mediaItem != null) {
+ SaveAttachmentTask.showWarningDialog(this, (dialogInterface, i) -> {
+ if (StorageUtil.canWriteToMediaStore()) {
+ performSavetoDisk(mediaItem);
+ return;
+ }
+
+ Permissions.with(this)
+ .request(Manifest.permission.WRITE_EXTERNAL_STORAGE)
+ .ifNecessary()
+ .withPermanentDenialDialog(getString(R.string.MediaPreviewActivity_signal_needs_the_storage_permission_in_order_to_write_to_external_storage_but_it_has_been_permanently_denied))
+ .onAnyDenied(() -> Toast.makeText(this, R.string.MediaPreviewActivity_unable_to_write_to_external_storage_without_permission, Toast.LENGTH_LONG).show())
+ .onAllGranted(() -> {
+ performSavetoDisk(mediaItem);
+ })
+ .execute();
+ });
+ }
+ }
+
+ private void performSavetoDisk(@NonNull MediaItem mediaItem) {
+ SaveAttachmentTask saveTask = new SaveAttachmentTask(MediaPreviewActivity.this);
+ long saveDate = (mediaItem.date > 0) ? mediaItem.date : System.currentTimeMillis();
+ saveTask.executeOnExecutor(AsyncTask.THREAD_POOL_EXECUTOR, new Attachment(mediaItem.uri, mediaItem.type, saveDate, null));
+ }
+
+ @SuppressLint("StaticFieldLeak")
+ private void deleteMedia() {
+ MediaItem mediaItem = getCurrentMediaItem();
+ if (mediaItem == null || mediaItem.attachment == null) {
+ return;
+ }
+
+ AlertDialog.Builder builder = new AlertDialog.Builder(this);
+ builder.setIcon(R.drawable.ic_warning);
+ builder.setTitle(R.string.MediaPreviewActivity_media_delete_confirmation_title);
+ builder.setMessage(R.string.MediaPreviewActivity_media_delete_confirmation_message);
+ builder.setCancelable(true);
+
+ builder.setPositiveButton(R.string.delete, (dialogInterface, which) -> {
+ new AsyncTask() {
+ @Override
+ protected Void doInBackground(Void... voids) {
+ AttachmentUtil.deleteAttachment(MediaPreviewActivity.this.getApplicationContext(),
+ mediaItem.attachment);
+ return null;
+ }
+ }.execute();
+
+ finish();
+ });
+ builder.setNegativeButton(android.R.string.cancel, null);
+ builder.show();
+ }
+
+ @Override
+ public boolean onCreateOptionsMenu(Menu menu) {
+ menu.clear();
+ MenuInflater inflater = this.getMenuInflater();
+ inflater.inflate(R.menu.media_preview, menu);
+
+ super.onCreateOptionsMenu(menu);
+ return true;
+ }
+
+ @Override
+ public boolean onPrepareOptionsMenu(Menu menu) {
+ if (!isMediaInDb()) {
+ menu.findItem(R.id.media_preview__overview).setVisible(false);
+ menu.findItem(R.id.delete).setVisible(false);
+ }
+
+ // Restricted to API26 because of MemoryFileUtil not supporting lower API levels well
+ menu.findItem(R.id.media_preview__share).setVisible(Build.VERSION.SDK_INT >= 26);
+
+ if (cameFromAllMedia) {
+ menu.findItem(R.id.media_preview__overview).setVisible(false);
+ }
+
+ super.onPrepareOptionsMenu(menu);
+ return true;
+ }
+
+ @Override
+ public boolean onOptionsItemSelected(@NonNull MenuItem item) {
+ super.onOptionsItemSelected(item);
+
+ int itemId = item.getItemId();
+
+ if (itemId == R.id.media_preview__overview) { showOverview(); return true; }
+ if (itemId == R.id.media_preview__forward) { forward(); return true; }
+ if (itemId == R.id.media_preview__share) { share(); return true; }
+ if (itemId == R.id.save) { saveToDisk(); return true; }
+ if (itemId == R.id.delete) { deleteMedia(); return true; }
+ if (itemId == android.R.id.home) { finish(); return true; }
+
+ return false;
+ }
+
+ private boolean isMediaInDb() {
+ return threadId != NOT_IN_A_THREAD;
+ }
+
+ private @Nullable MediaItem getCurrentMediaItem() {
+ MediaItemAdapter adapter = (MediaItemAdapter)mediaPager.getAdapter();
+
+ if (adapter != null) {
+ return adapter.getMediaItemFor(mediaPager.getCurrentItem());
+ } else {
+ return null;
+ }
+ }
+
+ public static boolean isContentTypeSupported(final String contentType) {
+ return contentType != null && (contentType.startsWith("image/") || contentType.startsWith("video/"));
+ }
+
+ @Override
+ public @NonNull Loader> onCreateLoader(int id, Bundle args) {
+ return new PagingMediaLoader(this, threadId, initialMediaUri, leftIsRecent, sorting);
+ }
+
+ @Override
+ public void onLoadFinished(@NonNull Loader> loader, @Nullable Pair data) {
+ if (data != null) {
+ if (data.first == cursor) {
+ return;
+ }
+
+ if (cursor != null) {
+ cursor.close();
+ }
+ cursor = Objects.requireNonNull(data.first);
+
+ int mediaPosition = Objects.requireNonNull(data.second);
+
+ CursorPagerAdapter adapter = new CursorPagerAdapter(getSupportFragmentManager(),this, cursor, mediaPosition, leftIsRecent);
+ mediaPager.setAdapter(adapter);
+ adapter.setActive(true);
+
+ viewModel.setCursor(this, cursor, leftIsRecent);
+
+ int item = restartItem >= 0 ? restartItem : mediaPosition;
+ mediaPager.setCurrentItem(item);
+
+ if (item == 0) {
+ viewPagerListener.onPageSelected(0);
+ }
+
+ cursor.registerContentObserver(new ContentObserver(new Handler(getMainLooper())) {
+ @Override
+ public void onChange(boolean selfChange) {
+ onMediaChange();
+ }
+ });
+ } else {
+ mediaNotAvailable();
+ }
+ }
+
+ private void onMediaChange() {
+ MediaItemAdapter adapter = (MediaItemAdapter) mediaPager.getAdapter();
+
+ if (adapter != null) {
+ adapter.checkMedia(mediaPager.getCurrentItem());
+ }
+ }
+
+ @Override
+ public void onLoaderReset(@NonNull Loader> loader) {
+
+ }
+
+ @Override
+ public boolean singleTapOnMedia() {
+ fullscreenHelper.toggleUiVisibility();
+ return true;
+ }
+
+ @Override
+ public void mediaNotAvailable() {
+ Toast.makeText(this, R.string.MediaPreviewActivity_media_no_longer_available, Toast.LENGTH_LONG).show();
+ finish();
+ }
+
+ private class ViewPagerListener extends ExtendedOnPageChangedListener {
+
+ @Override
+ public void onPageSelected(int position) {
+ super.onPageSelected(position);
+
+ MediaItemAdapter adapter = (MediaItemAdapter)mediaPager.getAdapter();
+
+ if (adapter != null) {
+ MediaItem item = adapter.getMediaItemFor(position);
+ if (item.recipient != null) item.recipient.live().observe(MediaPreviewActivity.this, r -> initializeActionBar());
+ viewModel.setActiveAlbumRailItem(MediaPreviewActivity.this, position);
+ initializeActionBar();
+ }
+ }
+
+
+ @Override
+ public void onPageUnselected(int position) {
+ MediaItemAdapter adapter = (MediaItemAdapter)mediaPager.getAdapter();
+
+ if (adapter != null) {
+ MediaItem item = adapter.getMediaItemFor(position);
+ if (item.recipient != null) item.recipient.live().removeObservers(MediaPreviewActivity.this);
+
+ adapter.pause(position);
+ }
+ }
+ }
+
+ private static class SingleItemPagerAdapter extends FragmentStatePagerAdapter implements MediaItemAdapter {
+
+ private final Uri uri;
+ private final String mediaType;
+ private final long size;
+
+ private MediaPreviewFragment mediaPreviewFragment;
+
+ SingleItemPagerAdapter(@NonNull FragmentManager fragmentManager,
+ @NonNull Uri uri,
+ @NonNull String mediaType,
+ long size)
+ {
+ super(fragmentManager, BEHAVIOR_RESUME_ONLY_CURRENT_FRAGMENT);
+ this.uri = uri;
+ this.mediaType = mediaType;
+ this.size = size;
+ }
+
+ @Override
+ public int getCount() {
+ return 1;
+ }
+
+ @NonNull
+ @Override
+ public Fragment getItem(int position) {
+ mediaPreviewFragment = MediaPreviewFragment.newInstance(uri, mediaType, size, true);
+ return mediaPreviewFragment;
+ }
+
+ @Override
+ public void destroyItem(@NonNull ViewGroup container, int position, @NonNull Object object) {
+ if (mediaPreviewFragment != null) {
+ mediaPreviewFragment.cleanUp();
+ mediaPreviewFragment = null;
+ }
+ }
+
+ @Override
+ public MediaItem getMediaItemFor(int position) {
+ return new MediaItem(null, null, null, uri, mediaType, -1, true);
+ }
+
+ @Override
+ public void pause(int position) {
+ if (mediaPreviewFragment != null) {
+ mediaPreviewFragment.pause();
+ }
+ }
+
+ @Override
+ public @Nullable View getPlaybackControls(int position) {
+ if (mediaPreviewFragment != null) {
+ return mediaPreviewFragment.getPlaybackControls();
+ }
+ return null;
+ }
+
+ @Override
+ public boolean hasFragmentFor(int position) {
+ return mediaPreviewFragment != null;
+ }
+
+ @Override
+ public void checkMedia(int currentItem) {
+
+ }
+ }
+
+ private static void anchorMarginsToBottomInsets(@NonNull View viewToAnchor) {
+ ViewCompat.setOnApplyWindowInsetsListener(viewToAnchor, (view, insets) -> {
+ ViewGroup.MarginLayoutParams layoutParams = (ViewGroup.MarginLayoutParams) view.getLayoutParams();
+
+ layoutParams.setMargins(insets.getSystemWindowInsetLeft(),
+ layoutParams.topMargin,
+ insets.getSystemWindowInsetRight(),
+ insets.getSystemWindowInsetBottom());
+
+ view.setLayoutParams(layoutParams);
+
+ return insets;
+ });
+ }
+
+ private static class CursorPagerAdapter extends FragmentStatePagerAdapter implements MediaItemAdapter {
+
+ @SuppressLint("UseSparseArrays")
+ private final Map mediaFragments = new HashMap<>();
+
+ private final Context context;
+ private final Cursor cursor;
+ private final boolean leftIsRecent;
+
+ private boolean active;
+ private int autoPlayPosition;
+
+ CursorPagerAdapter(@NonNull FragmentManager fragmentManager,
+ @NonNull Context context,
+ @NonNull Cursor cursor,
+ int autoPlayPosition,
+ boolean leftIsRecent)
+ {
+ super(fragmentManager, BEHAVIOR_RESUME_ONLY_CURRENT_FRAGMENT);
+ this.context = context.getApplicationContext();
+ this.cursor = cursor;
+ this.autoPlayPosition = autoPlayPosition;
+ this.leftIsRecent = leftIsRecent;
+ }
+
+ public void setActive(boolean active) {
+ this.active = active;
+ notifyDataSetChanged();
+ }
+
+ @Override
+ public int getCount() {
+ if (!active) return 0;
+ else return cursor.getCount();
+ }
+
+ @NonNull
+ @Override
+ public Fragment getItem(int position) {
+ boolean autoPlay = autoPlayPosition == position;
+ int cursorPosition = getCursorPosition(position);
+
+ autoPlayPosition = -1;
+
+ cursor.moveToPosition(cursorPosition);
+
+ MediaDatabase.MediaRecord mediaRecord = MediaDatabase.MediaRecord.from(context, cursor);
+ DatabaseAttachment attachment = Objects.requireNonNull(mediaRecord.getAttachment());
+ MediaPreviewFragment fragment = MediaPreviewFragment.newInstance(attachment, autoPlay);
+
+ mediaFragments.put(position, fragment);
+
+ return fragment;
+ }
+
+ @Override
+ public void destroyItem(@NonNull ViewGroup container, int position, @NonNull Object object) {
+ MediaPreviewFragment removed = mediaFragments.remove(position);
+
+ if (removed != null) {
+ removed.cleanUp();
+ }
+
+ super.destroyItem(container, position, object);
+ }
+
+ public MediaItem getMediaItemFor(int position) {
+ cursor.moveToPosition(getCursorPosition(position));
+
+ MediaRecord mediaRecord = MediaRecord.from(context, cursor);
+ DatabaseAttachment attachment = Objects.requireNonNull(mediaRecord.getAttachment());
+ RecipientId recipientId = mediaRecord.getRecipientId();
+ RecipientId threadRecipientId = mediaRecord.getThreadRecipientId();
+
+ return new MediaItem(Recipient.live(recipientId).get(),
+ Recipient.live(threadRecipientId).get(),
+ attachment,
+ Objects.requireNonNull(attachment.getUri()),
+ mediaRecord.getContentType(),
+ mediaRecord.getDate(),
+ mediaRecord.isOutgoing());
+ }
+
+ @Override
+ public void pause(int position) {
+ MediaPreviewFragment mediaView = mediaFragments.get(position);
+ if (mediaView != null) mediaView.pause();
+ }
+
+ @Override
+ public @Nullable View getPlaybackControls(int position) {
+ MediaPreviewFragment mediaView = mediaFragments.get(position);
+ if (mediaView != null) return mediaView.getPlaybackControls();
+ return null;
+ }
+
+ @Override
+ public boolean hasFragmentFor(int position) {
+ return mediaFragments.containsKey(position);
+ }
+
+ @Override
+ public void checkMedia(int position) {
+ MediaPreviewFragment fragment = mediaFragments.get(position);
+ if (fragment != null) {
+ fragment.checkMediaStillAvailable();
+ }
+ }
+
+ private int getCursorPosition(int position) {
+ if (leftIsRecent) return position;
+ else return cursor.getCount() - 1 - position;
+ }
+ }
+
+ private static class MediaItem {
+ private final @Nullable Recipient recipient;
+ private final @Nullable Recipient threadRecipient;
+ private final @Nullable DatabaseAttachment attachment;
+ private final @NonNull Uri uri;
+ private final @NonNull String type;
+ private final long date;
+ private final boolean outgoing;
+
+ private MediaItem(@Nullable Recipient recipient,
+ @Nullable Recipient threadRecipient,
+ @Nullable DatabaseAttachment attachment,
+ @NonNull Uri uri,
+ @NonNull String type,
+ long date,
+ boolean outgoing)
+ {
+ this.recipient = recipient;
+ this.threadRecipient = threadRecipient;
+ this.attachment = attachment;
+ this.uri = uri;
+ this.type = type;
+ this.date = date;
+ this.outgoing = outgoing;
+ }
+ }
+
+ interface MediaItemAdapter {
+ MediaItem getMediaItemFor(int position);
+ void pause(int position);
+ @Nullable View getPlaybackControls(int position);
+ boolean hasFragmentFor(int position);
+ void checkMedia(int currentItem);
+ }
+}
diff --git a/app/src/main/java/org/thoughtcrime/securesms/MuteDialog.java b/app/src/main/java/org/thoughtcrime/securesms/MuteDialog.java
new file mode 100644
index 00000000..84f91078
--- /dev/null
+++ b/app/src/main/java/org/thoughtcrime/securesms/MuteDialog.java
@@ -0,0 +1,67 @@
+package org.thoughtcrime.securesms;
+
+import android.content.Context;
+import android.content.DialogInterface;
+
+import androidx.annotation.NonNull;
+import androidx.annotation.Nullable;
+import androidx.appcompat.app.AlertDialog;
+
+import java.util.concurrent.TimeUnit;
+
+public class MuteDialog extends AlertDialog {
+
+
+ protected MuteDialog(Context context) {
+ super(context);
+ }
+
+ protected MuteDialog(Context context, boolean cancelable, OnCancelListener cancelListener) {
+ super(context, cancelable, cancelListener);
+ }
+
+ protected MuteDialog(Context context, int theme) {
+ super(context, theme);
+ }
+
+ public static void show(final Context context, final @NonNull MuteSelectionListener listener) {
+ show(context, listener, null);
+ }
+
+ public static void show(final Context context, final @NonNull MuteSelectionListener listener, @Nullable Runnable cancelListener) {
+ AlertDialog.Builder builder = new AlertDialog.Builder(context);
+ builder.setTitle(R.string.MuteDialog_mute_notifications);
+ builder.setItems(R.array.mute_durations, new DialogInterface.OnClickListener() {
+ @Override
+ public void onClick(DialogInterface dialog, final int which) {
+ final long muteUntil;
+
+ switch (which) {
+ case 0: muteUntil = System.currentTimeMillis() + TimeUnit.HOURS.toMillis(1); break;
+ case 1: muteUntil = System.currentTimeMillis() + TimeUnit.HOURS.toMillis(2); break;
+ case 2: muteUntil = System.currentTimeMillis() + TimeUnit.DAYS.toMillis(1); break;
+ case 3: muteUntil = System.currentTimeMillis() + TimeUnit.DAYS.toMillis(7); break;
+ case 4: muteUntil = System.currentTimeMillis() + TimeUnit.DAYS.toMillis(365); break;
+ default: muteUntil = System.currentTimeMillis() + TimeUnit.HOURS.toMillis(1); break;
+ }
+
+ listener.onMuted(muteUntil);
+ }
+ });
+
+ if (cancelListener != null) {
+ builder.setOnCancelListener(dialog -> {
+ cancelListener.run();
+ dialog.dismiss();
+ });
+ }
+
+ builder.show();
+
+ }
+
+ public interface MuteSelectionListener {
+ public void onMuted(long until);
+ }
+
+}
diff --git a/app/src/main/java/org/thoughtcrime/securesms/NewConversationActivity.java b/app/src/main/java/org/thoughtcrime/securesms/NewConversationActivity.java
new file mode 100644
index 00000000..b01bd638
--- /dev/null
+++ b/app/src/main/java/org/thoughtcrime/securesms/NewConversationActivity.java
@@ -0,0 +1,158 @@
+/*
+ * Copyright (C) 2015 Open Whisper Systems
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see .
+ */
+package org.thoughtcrime.securesms;
+
+import android.content.Intent;
+import android.os.Bundle;
+import android.view.Menu;
+import android.view.MenuItem;
+
+import androidx.appcompat.app.AlertDialog;
+
+import org.signal.core.util.logging.Log;
+import org.thoughtcrime.securesms.contacts.sync.DirectoryHelper;
+import org.thoughtcrime.securesms.conversation.ConversationIntents;
+import org.thoughtcrime.securesms.database.DatabaseFactory;
+import org.thoughtcrime.securesms.groups.ui.creategroup.CreateGroupActivity;
+import org.thoughtcrime.securesms.jobmanager.impl.NetworkConstraint;
+import org.thoughtcrime.securesms.recipients.Recipient;
+import org.thoughtcrime.securesms.recipients.RecipientId;
+import org.thoughtcrime.securesms.util.TextSecurePreferences;
+import org.thoughtcrime.securesms.util.concurrent.SimpleTask;
+import org.thoughtcrime.securesms.util.views.SimpleProgressDialog;
+import org.whispersystems.libsignal.util.guava.Optional;
+
+import java.io.IOException;
+
+/**
+ * Activity container for starting a new conversation.
+ *
+ * @author Moxie Marlinspike
+ *
+ */
+public class NewConversationActivity extends ContactSelectionActivity
+ implements ContactSelectionListFragment.ListCallback
+{
+
+ @SuppressWarnings("unused")
+ private static final String TAG = NewConversationActivity.class.getSimpleName();
+
+ @Override
+ public void onCreate(Bundle bundle, boolean ready) {
+ super.onCreate(bundle, ready);
+ assert getSupportActionBar() != null;
+ getSupportActionBar().setDisplayHomeAsUpEnabled(true);
+ }
+
+ @Override
+ public boolean onBeforeContactSelected(Optional recipientId, String number) {
+ if (recipientId.isPresent()) {
+ launch(Recipient.resolved(recipientId.get()));
+ } else {
+ Log.i(TAG, "[onContactSelected] Maybe creating a new recipient.");
+
+ if (TextSecurePreferences.isPushRegistered(this) && NetworkConstraint.isMet(this)) {
+ Log.i(TAG, "[onContactSelected] Doing contact refresh.");
+
+ AlertDialog progress = SimpleProgressDialog.show(this);
+
+ SimpleTask.run(getLifecycle(), () -> {
+ Recipient resolved = Recipient.external(this, number);
+
+ if (!resolved.isRegistered() || !resolved.hasUuid()) {
+ Log.i(TAG, "[onContactSelected] Not registered or no UUID. Doing a directory refresh.");
+ try {
+ DirectoryHelper.refreshDirectoryFor(this, resolved, false);
+ resolved = Recipient.resolved(resolved.getId());
+ } catch (IOException e) {
+ Log.w(TAG, "[onContactSelected] Failed to refresh directory for new contact.");
+ }
+ }
+
+ return resolved;
+ }, resolved -> {
+ progress.dismiss();
+ launch(resolved);
+ });
+ } else {
+ launch(Recipient.external(this, number));
+ }
+ }
+
+ return true;
+ }
+
+ private void launch(Recipient recipient) {
+ long existingThread = DatabaseFactory.getThreadDatabase(this).getThreadIdIfExistsFor(recipient.getId());
+ Intent intent = ConversationIntents.createBuilder(this, recipient.getId(), existingThread)
+ .withDraftText(getIntent().getStringExtra(Intent.EXTRA_TEXT))
+ .withDataUri(getIntent().getData())
+ .withDataType(getIntent().getType())
+ .build();
+
+ startActivity(intent);
+ finish();
+ }
+
+ @Override
+ public boolean onOptionsItemSelected(MenuItem item) {
+ super.onOptionsItemSelected(item);
+
+ switch (item.getItemId()) {
+ case android.R.id.home: super.onBackPressed(); return true;
+ case R.id.menu_refresh: handleManualRefresh(); return true;
+ case R.id.menu_new_group: handleCreateGroup(); return true;
+ case R.id.menu_invite: handleInvite(); return true;
+ }
+
+ return false;
+ }
+
+ private void handleManualRefresh() {
+ contactsFragment.setRefreshing(true);
+ onRefresh();
+ }
+
+ private void handleCreateGroup() {
+ startActivity(CreateGroupActivity.newIntent(this));
+ }
+
+ private void handleInvite() {
+ startActivity(new Intent(this, InviteActivity.class));
+ }
+
+ @Override
+ public boolean onCreateOptionsMenu(Menu menu) {
+ menu.clear();
+ getMenuInflater().inflate(R.menu.new_conversation_activity, menu);
+
+ super.onCreateOptionsMenu(menu);
+ return true;
+ }
+
+ @Override
+ public void onInvite() {
+ handleInvite();
+ finish();
+ }
+
+ @Override
+ public void onNewGroup(boolean forceV1) {
+ handleCreateGroup();
+ finish();
+ }
+}
diff --git a/app/src/main/java/org/thoughtcrime/securesms/PassphraseActivity.java b/app/src/main/java/org/thoughtcrime/securesms/PassphraseActivity.java
new file mode 100644
index 00000000..5bf576a3
--- /dev/null
+++ b/app/src/main/java/org/thoughtcrime/securesms/PassphraseActivity.java
@@ -0,0 +1,77 @@
+/**
+ * Copyright (C) 2011 Whisper Systems
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see .
+ */
+package org.thoughtcrime.securesms;
+
+import android.content.ComponentName;
+import android.content.Context;
+import android.content.Intent;
+import android.content.ServiceConnection;
+import android.os.IBinder;
+
+import org.signal.core.util.logging.Log;
+import org.thoughtcrime.securesms.crypto.MasterSecret;
+import org.thoughtcrime.securesms.service.KeyCachingService;
+
+
+/**
+ * Base Activity for changing/prompting local encryption passphrase.
+ *
+ * @author Moxie Marlinspike
+ */
+public abstract class PassphraseActivity extends BaseActivity {
+
+ private static final String TAG = PassphraseActivity.class.getSimpleName();
+
+ private KeyCachingService keyCachingService;
+ private MasterSecret masterSecret;
+
+ protected void setMasterSecret(MasterSecret masterSecret) {
+ this.masterSecret = masterSecret;
+ Intent bindIntent = new Intent(this, KeyCachingService.class);
+ startService(bindIntent);
+ bindService(bindIntent, serviceConnection, Context.BIND_AUTO_CREATE);
+ }
+
+ protected abstract void cleanup();
+
+ private ServiceConnection serviceConnection = new ServiceConnection() {
+ @Override
+ public void onServiceConnected(ComponentName className, IBinder service) {
+ keyCachingService = ((KeyCachingService.KeySetBinder)service).getService();
+ keyCachingService.setMasterSecret(masterSecret);
+
+ PassphraseActivity.this.unbindService(PassphraseActivity.this.serviceConnection);
+
+ masterSecret = null;
+ cleanup();
+
+ Intent nextIntent = getIntent().getParcelableExtra("next_intent");
+ if (nextIntent != null) {
+ try {
+ startActivity(nextIntent);
+ } catch (java.lang.SecurityException e) {
+ Log.w(TAG, "Access permission not passed from PassphraseActivity, retry sharing.");
+ }
+ }
+ finish();
+ }
+ @Override
+ public void onServiceDisconnected(ComponentName name) {
+ keyCachingService = null;
+ }
+ };
+}
diff --git a/app/src/main/java/org/thoughtcrime/securesms/PassphraseChangeActivity.java b/app/src/main/java/org/thoughtcrime/securesms/PassphraseChangeActivity.java
new file mode 100644
index 00000000..316e4e10
--- /dev/null
+++ b/app/src/main/java/org/thoughtcrime/securesms/PassphraseChangeActivity.java
@@ -0,0 +1,177 @@
+/**
+ * Copyright (C) 2011 Whisper Systems
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see .
+ */
+package org.thoughtcrime.securesms;
+
+import android.content.Context;
+import android.os.AsyncTask;
+import android.os.Bundle;
+import android.text.Editable;
+import android.view.View;
+import android.view.View.OnClickListener;
+import android.widget.Button;
+import android.widget.EditText;
+
+import org.signal.core.util.logging.Log;
+import org.thoughtcrime.securesms.crypto.InvalidPassphraseException;
+import org.thoughtcrime.securesms.crypto.MasterSecret;
+import org.thoughtcrime.securesms.crypto.MasterSecretUtil;
+import org.thoughtcrime.securesms.util.DynamicLanguage;
+import org.thoughtcrime.securesms.util.DynamicTheme;
+import org.thoughtcrime.securesms.util.TextSecurePreferences;
+
+/**
+ * Activity for changing a user's local encryption passphrase.
+ *
+ * @author Moxie Marlinspike
+ */
+
+public class PassphraseChangeActivity extends PassphraseActivity {
+
+ private static final String TAG = Log.tag(PassphraseChangeActivity.class);
+
+ private DynamicTheme dynamicTheme = new DynamicTheme();
+ private DynamicLanguage dynamicLanguage = new DynamicLanguage();
+
+ private EditText originalPassphrase;
+ private EditText newPassphrase;
+ private EditText repeatPassphrase;
+ private Button okButton;
+ private Button cancelButton;
+
+ @Override
+ public void onCreate(Bundle savedInstanceState) {
+ dynamicTheme.onCreate(this);
+ dynamicLanguage.onCreate(this);
+ super.onCreate(savedInstanceState);
+
+ setContentView(R.layout.change_passphrase_activity);
+
+ initializeResources();
+ }
+
+ @Override
+ public void onResume() {
+ super.onResume();
+ dynamicTheme.onResume(this);
+ dynamicLanguage.onResume(this);
+ }
+
+ private void initializeResources() {
+ this.originalPassphrase = (EditText) findViewById(R.id.old_passphrase );
+ this.newPassphrase = (EditText) findViewById(R.id.new_passphrase );
+ this.repeatPassphrase = (EditText) findViewById(R.id.repeat_passphrase );
+
+ this.okButton = (Button ) findViewById(R.id.ok_button );
+ this.cancelButton = (Button ) findViewById(R.id.cancel_button );
+
+ this.okButton.setOnClickListener(new OkButtonClickListener());
+ this.cancelButton.setOnClickListener(new CancelButtonClickListener());
+
+ if (TextSecurePreferences.isPasswordDisabled(this)) {
+ this.originalPassphrase.setVisibility(View.GONE);
+ } else {
+ this.originalPassphrase.setVisibility(View.VISIBLE);
+ }
+ }
+
+ private void verifyAndSavePassphrases() {
+ Editable originalText = this.originalPassphrase.getText();
+ Editable newText = this.newPassphrase.getText();
+ Editable repeatText = this.repeatPassphrase.getText();
+
+ String original = (originalText == null ? "" : originalText.toString());
+ String passphrase = (newText == null ? "" : newText.toString());
+ String passphraseRepeat = (repeatText == null ? "" : repeatText.toString());
+
+ if (TextSecurePreferences.isPasswordDisabled(this)) {
+ original = MasterSecretUtil.UNENCRYPTED_PASSPHRASE;
+ }
+
+ if (!passphrase.equals(passphraseRepeat)) {
+ this.newPassphrase.setText("");
+ this.repeatPassphrase.setText("");
+ this.newPassphrase.setError(getString(R.string.PassphraseChangeActivity_passphrases_dont_match_exclamation));
+ this.newPassphrase.requestFocus();
+ } else if (passphrase.equals("")) {
+ this.newPassphrase.setError(getString(R.string.PassphraseChangeActivity_enter_new_passphrase_exclamation));
+ this.newPassphrase.requestFocus();
+ } else {
+ new ChangePassphraseTask(this).executeOnExecutor(AsyncTask.THREAD_POOL_EXECUTOR, original, passphrase);
+ }
+ }
+
+ private class CancelButtonClickListener implements OnClickListener {
+ public void onClick(View v) {
+ finish();
+ }
+ }
+
+ private class OkButtonClickListener implements OnClickListener {
+ public void onClick(View v) {
+ verifyAndSavePassphrases();
+ }
+ }
+
+ private class ChangePassphraseTask extends AsyncTask {
+ private final Context context;
+
+ public ChangePassphraseTask(Context context) {
+ this.context = context;
+ }
+
+ @Override
+ protected void onPreExecute() {
+ okButton.setEnabled(false);
+ }
+
+ @Override
+ protected MasterSecret doInBackground(String... params) {
+ try {
+ MasterSecret masterSecret = MasterSecretUtil.changeMasterSecretPassphrase(context, params[0], params[1]);
+ TextSecurePreferences.setPasswordDisabled(context, false);
+
+ return masterSecret;
+
+ } catch (InvalidPassphraseException e) {
+ Log.w(TAG, e);
+ return null;
+ }
+ }
+
+ @Override
+ protected void onPostExecute(MasterSecret masterSecret) {
+ okButton.setEnabled(true);
+
+ if (masterSecret != null) {
+ setMasterSecret(masterSecret);
+ } else {
+ originalPassphrase.setText("");
+ originalPassphrase.setError(getString(R.string.PassphraseChangeActivity_incorrect_old_passphrase_exclamation));
+ originalPassphrase.requestFocus();
+ }
+ }
+ }
+
+ @Override
+ protected void cleanup() {
+ this.originalPassphrase = null;
+ this.newPassphrase = null;
+ this.repeatPassphrase = null;
+
+ System.gc();
+ }
+}
diff --git a/app/src/main/java/org/thoughtcrime/securesms/PassphraseCreateActivity.java b/app/src/main/java/org/thoughtcrime/securesms/PassphraseCreateActivity.java
new file mode 100644
index 00000000..18c3887b
--- /dev/null
+++ b/app/src/main/java/org/thoughtcrime/securesms/PassphraseCreateActivity.java
@@ -0,0 +1,80 @@
+/**
+ * Copyright (C) 2011 Whisper Systems
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see .
+ */
+package org.thoughtcrime.securesms;
+
+import android.os.AsyncTask;
+import android.os.Bundle;
+
+import org.thoughtcrime.securesms.crypto.IdentityKeyUtil;
+import org.thoughtcrime.securesms.crypto.MasterSecret;
+import org.thoughtcrime.securesms.crypto.MasterSecretUtil;
+import org.thoughtcrime.securesms.util.VersionTracker;
+
+/**
+ * Activity for creating a user's local encryption passphrase.
+ *
+ * @author Moxie Marlinspike
+ */
+
+public class PassphraseCreateActivity extends PassphraseActivity {
+
+ public PassphraseCreateActivity() { }
+
+ @Override
+ public void onCreate(Bundle savedInstanceState) {
+ super.onCreate(savedInstanceState);
+
+ setContentView(R.layout.create_passphrase_activity);
+
+ initializeResources();
+ }
+
+ private void initializeResources() {
+ new SecretGenerator().executeOnExecutor(AsyncTask.THREAD_POOL_EXECUTOR, MasterSecretUtil.UNENCRYPTED_PASSPHRASE);
+ }
+
+ private class SecretGenerator extends AsyncTask {
+ private MasterSecret masterSecret;
+
+ @Override
+ protected void onPreExecute() {
+ }
+
+ @Override
+ protected Void doInBackground(String... params) {
+ String passphrase = params[0];
+ masterSecret = MasterSecretUtil.generateMasterSecret(PassphraseCreateActivity.this,
+ passphrase);
+
+ MasterSecretUtil.generateAsymmetricMasterSecret(PassphraseCreateActivity.this, masterSecret);
+ IdentityKeyUtil.generateIdentityKeys(PassphraseCreateActivity.this);
+ VersionTracker.updateLastSeenVersion(PassphraseCreateActivity.this);
+
+ return null;
+ }
+
+ @Override
+ protected void onPostExecute(Void param) {
+ setMasterSecret(masterSecret);
+ }
+ }
+
+ @Override
+ protected void cleanup() {
+ System.gc();
+ }
+}
diff --git a/app/src/main/java/org/thoughtcrime/securesms/PassphrasePromptActivity.java b/app/src/main/java/org/thoughtcrime/securesms/PassphrasePromptActivity.java
new file mode 100644
index 00000000..6f3f0fe1
--- /dev/null
+++ b/app/src/main/java/org/thoughtcrime/securesms/PassphrasePromptActivity.java
@@ -0,0 +1,425 @@
+/*
+ * Copyright (C) 2011 Whisper Systems
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see .
+ */
+package org.thoughtcrime.securesms;
+
+import android.animation.Animator;
+import android.app.KeyguardManager;
+import android.content.Context;
+import android.content.Intent;
+import android.graphics.PorterDuff;
+import android.os.Build;
+import android.os.Bundle;
+import android.text.Editable;
+import android.text.InputType;
+import android.text.SpannableString;
+import android.text.Spanned;
+import android.text.style.RelativeSizeSpan;
+import android.text.style.TypefaceSpan;
+import android.view.KeyEvent;
+import android.view.Menu;
+import android.view.MenuInflater;
+import android.view.MenuItem;
+import android.view.View;
+import android.view.View.OnClickListener;
+import android.view.WindowManager;
+import android.view.animation.Animation;
+import android.view.animation.BounceInterpolator;
+import android.view.animation.TranslateAnimation;
+import android.view.inputmethod.EditorInfo;
+import android.widget.EditText;
+import android.widget.ImageButton;
+import android.widget.ImageView;
+import android.widget.TextView;
+
+import androidx.annotation.NonNull;
+import androidx.appcompat.widget.Toolbar;
+import androidx.biometric.BiometricManager;
+import androidx.biometric.BiometricManager.Authenticators;
+import androidx.biometric.BiometricPrompt;
+
+import org.signal.core.util.logging.Log;
+import org.thoughtcrime.securesms.animation.AnimationCompleteListener;
+import org.thoughtcrime.securesms.components.AnimatingToggle;
+import org.thoughtcrime.securesms.crypto.InvalidPassphraseException;
+import org.thoughtcrime.securesms.crypto.MasterSecret;
+import org.thoughtcrime.securesms.crypto.MasterSecretUtil;
+import org.thoughtcrime.securesms.logsubmit.SubmitDebugLogActivity;
+import org.thoughtcrime.securesms.util.CommunicationActions;
+import org.thoughtcrime.securesms.util.DynamicIntroTheme;
+import org.thoughtcrime.securesms.util.DynamicLanguage;
+import org.thoughtcrime.securesms.util.SupportEmailUtil;
+import org.thoughtcrime.securesms.util.TextSecurePreferences;
+
+/**
+ * Activity that prompts for a user's passphrase.
+ *
+ * @author Moxie Marlinspike
+ */
+public class PassphrasePromptActivity extends PassphraseActivity {
+
+ private static final String TAG = Log.tag(PassphrasePromptActivity.class);
+ private static final int BIOMETRIC_AUTHENTICATORS = Authenticators.BIOMETRIC_STRONG | Authenticators.BIOMETRIC_WEAK;
+ private static final int ALLOWED_AUTHENTICATORS = BIOMETRIC_AUTHENTICATORS | Authenticators.DEVICE_CREDENTIAL;
+ private static final short AUTHENTICATE_REQUEST_CODE = 1007;
+ private static final String BUNDLE_ALREADY_SHOWN = "bundle_already_shown";
+ public static final String FROM_FOREGROUND = "from_foreground";
+
+ private DynamicIntroTheme dynamicTheme = new DynamicIntroTheme();
+ private DynamicLanguage dynamicLanguage = new DynamicLanguage();
+
+ private View passphraseAuthContainer;
+ private ImageView fingerprintPrompt;
+ private TextView lockScreenButton;
+
+ private EditText passphraseText;
+ private ImageButton showButton;
+ private ImageButton hideButton;
+ private AnimatingToggle visibilityToggle;
+
+ private BiometricManager biometricManager;
+ private BiometricPrompt biometricPrompt;
+ private BiometricPrompt.PromptInfo biometricPromptInfo;
+
+ private boolean authenticated;
+ private boolean hadFailure;
+ private boolean alreadyShown;
+
+ @Override
+ public void onCreate(Bundle savedInstanceState) {
+ Log.i(TAG, "onCreate()");
+ dynamicTheme.onCreate(this);
+ dynamicLanguage.onCreate(this);
+ getWindow().addFlags(WindowManager.LayoutParams.FLAG_TRANSLUCENT_STATUS);
+ getWindow().addFlags(WindowManager.LayoutParams.FLAG_TRANSLUCENT_NAVIGATION);
+ super.onCreate(savedInstanceState);
+
+ setContentView(R.layout.prompt_passphrase_activity);
+ initializeResources();
+
+ alreadyShown = (savedInstanceState != null && savedInstanceState.getBoolean(BUNDLE_ALREADY_SHOWN)) ||
+ getIntent().getBooleanExtra(FROM_FOREGROUND, false);
+ }
+
+ @Override
+ protected void onSaveInstanceState(@NonNull Bundle outState) {
+ super.onSaveInstanceState(outState);
+ outState.putBoolean(BUNDLE_ALREADY_SHOWN, alreadyShown);
+ }
+
+ @Override
+ public void onResume() {
+ super.onResume();
+ dynamicTheme.onResume(this);
+ dynamicLanguage.onResume(this);
+
+ setLockTypeVisibility();
+
+ if (TextSecurePreferences.isScreenLockEnabled(this) && !authenticated && !hadFailure) {
+ resumeScreenLock(!alreadyShown);
+ alreadyShown = true;
+ }
+
+ hadFailure = false;
+ }
+
+ @Override
+ protected void onNewIntent(Intent intent) {
+ super.onNewIntent(intent);
+ setIntent(intent);
+ }
+
+ @Override
+ public boolean onCreateOptionsMenu(Menu menu) {
+ MenuInflater inflater = this.getMenuInflater();
+ menu.clear();
+
+ inflater.inflate(R.menu.passphrase_prompt, menu);
+
+ super.onCreateOptionsMenu(menu);
+ return true;
+ }
+
+ @Override
+ public boolean onOptionsItemSelected(MenuItem item) {
+ super.onOptionsItemSelected(item);
+ if (item.getItemId() == R.id.menu_submit_debug_logs) {
+ handleLogSubmit();
+ return true;
+ } else if (item.getItemId() == R.id.menu_contact_support) {
+ sendEmailToSupport();
+ return true;
+ }
+
+ return false;
+ }
+
+ @Override
+ public void onActivityResult(int requestCode, int resultCode, Intent data) {
+ super.onActivityResult(requestCode, resultCode, data);
+
+ if (requestCode != AUTHENTICATE_REQUEST_CODE) return;
+
+ if (resultCode == RESULT_OK) {
+ handleAuthenticated();
+ } else {
+ Log.w(TAG, "Authentication failed");
+ hadFailure = true;
+ }
+ }
+
+ private void handleLogSubmit() {
+ Intent intent = new Intent(this, SubmitDebugLogActivity.class);
+ startActivity(intent);
+ }
+
+ private void handlePassphrase() {
+ try {
+ Editable text = passphraseText.getText();
+ String passphrase = (text == null ? "" : text.toString());
+ MasterSecret masterSecret = MasterSecretUtil.getMasterSecret(this, passphrase);
+
+ setMasterSecret(masterSecret);
+ } catch (InvalidPassphraseException ipe) {
+ passphraseText.setText("");
+ passphraseText.setError(
+ getString(R.string.PassphrasePromptActivity_invalid_passphrase_exclamation));
+ }
+ }
+
+ private void handleAuthenticated() {
+ try {
+ authenticated = true;
+
+ MasterSecret masterSecret = MasterSecretUtil.getMasterSecret(this, MasterSecretUtil.UNENCRYPTED_PASSPHRASE);
+ setMasterSecret(masterSecret);
+ } catch (InvalidPassphraseException e) {
+ throw new AssertionError(e);
+ }
+ }
+
+ private void setPassphraseVisibility(boolean visibility) {
+ int cursorPosition = passphraseText.getSelectionStart();
+ if (visibility) {
+ passphraseText.setInputType(InputType.TYPE_CLASS_TEXT |
+ InputType.TYPE_TEXT_VARIATION_VISIBLE_PASSWORD);
+ } else {
+ passphraseText.setInputType(InputType.TYPE_CLASS_TEXT |
+ InputType.TYPE_TEXT_VARIATION_PASSWORD);
+ }
+ passphraseText.setSelection(cursorPosition);
+ }
+
+ private void initializeResources() {
+
+ ImageButton okButton = findViewById(R.id.ok_button);
+ Toolbar toolbar = findViewById(R.id.toolbar);
+
+ showButton = findViewById(R.id.passphrase_visibility);
+ hideButton = findViewById(R.id.passphrase_visibility_off);
+ visibilityToggle = findViewById(R.id.button_toggle);
+ passphraseText = findViewById(R.id.passphrase_edit);
+ passphraseAuthContainer = findViewById(R.id.password_auth_container);
+ fingerprintPrompt = findViewById(R.id.fingerprint_auth_container);
+ lockScreenButton = findViewById(R.id.lock_screen_auth_container);
+ biometricManager = BiometricManager.from(this);
+ biometricPrompt = new BiometricPrompt(this, new BiometricAuthenticationListener());
+ biometricPromptInfo = new BiometricPrompt.PromptInfo
+ .Builder()
+ .setAllowedAuthenticators(ALLOWED_AUTHENTICATORS)
+ .setTitle(getString(R.string.PassphrasePromptActivity_unlock_signal))
+ .build();
+
+ setSupportActionBar(toolbar);
+ getSupportActionBar().setTitle("");
+
+ SpannableString hint = new SpannableString(" " + getString(R.string.PassphrasePromptActivity_enter_passphrase));
+ hint.setSpan(new RelativeSizeSpan(0.9f), 0, hint.length(), Spanned.SPAN_INCLUSIVE_INCLUSIVE);
+ hint.setSpan(new TypefaceSpan("sans-serif"), 0, hint.length(), Spanned.SPAN_INCLUSIVE_INCLUSIVE);
+
+ passphraseText.setHint(hint);
+ okButton.setOnClickListener(new OkButtonClickListener());
+ showButton.setOnClickListener(new ShowButtonOnClickListener());
+ hideButton.setOnClickListener(new HideButtonOnClickListener());
+ passphraseText.setOnEditorActionListener(new PassphraseActionListener());
+ passphraseText.setImeActionLabel(getString(R.string.prompt_passphrase_activity__unlock),
+ EditorInfo.IME_ACTION_DONE);
+
+ fingerprintPrompt.setImageResource(R.drawable.ic_fingerprint_white_48dp);
+ fingerprintPrompt.getBackground().setColorFilter(getResources().getColor(R.color.core_ultramarine), PorterDuff.Mode.SRC_IN);
+
+ lockScreenButton.setOnClickListener(v -> resumeScreenLock(true));
+ }
+
+ private void setLockTypeVisibility() {
+ if (TextSecurePreferences.isScreenLockEnabled(this)) {
+ passphraseAuthContainer.setVisibility(View.GONE);
+ fingerprintPrompt.setVisibility(biometricManager.canAuthenticate(BIOMETRIC_AUTHENTICATORS) == BiometricManager.BIOMETRIC_SUCCESS ? View.VISIBLE
+ : View.GONE);
+ lockScreenButton.setVisibility(View.VISIBLE);
+ } else {
+ passphraseAuthContainer.setVisibility(View.VISIBLE);
+ fingerprintPrompt.setVisibility(View.GONE);
+ lockScreenButton.setVisibility(View.GONE);
+ }
+ }
+
+ private void resumeScreenLock(boolean force) {
+ KeyguardManager keyguardManager = (KeyguardManager) getSystemService(Context.KEYGUARD_SERVICE);
+
+ assert keyguardManager != null;
+
+ if (!keyguardManager.isKeyguardSecure()) {
+ Log.w(TAG ,"Keyguard not secure...");
+ handleAuthenticated();
+ return;
+ }
+
+ if (Build.VERSION.SDK_INT != 29 && biometricManager.canAuthenticate(ALLOWED_AUTHENTICATORS) == BiometricManager.BIOMETRIC_SUCCESS) {
+ if (force) {
+ Log.i(TAG, "Listening for biometric authentication...");
+ biometricPrompt.authenticate(biometricPromptInfo);
+ } else {
+ Log.i(TAG, "Skipping show system biometric dialog unless forced");
+ }
+ } else if (Build.VERSION.SDK_INT >= 21) {
+ if (force) {
+ Log.i(TAG, "firing intent...");
+ Intent intent = keyguardManager.createConfirmDeviceCredentialIntent(getString(R.string.PassphrasePromptActivity_unlock_signal), "");
+ startActivityForResult(intent, AUTHENTICATE_REQUEST_CODE);
+ } else {
+ Log.i(TAG, "Skipping firing intent unless forced");
+ }
+ } else {
+ Log.w(TAG, "Not compatible...");
+ handleAuthenticated();
+ }
+ }
+
+ private void sendEmailToSupport() {
+ String body = SupportEmailUtil.generateSupportEmailBody(this,
+ R.string.PassphrasePromptActivity_signal_android_lock_screen,
+ null,
+ null);
+ CommunicationActions.openEmail(this,
+ SupportEmailUtil.getSupportEmailAddress(this),
+ getString(R.string.PassphrasePromptActivity_signal_android_lock_screen),
+ body);
+ }
+
+ private class PassphraseActionListener implements TextView.OnEditorActionListener {
+ @Override
+ public boolean onEditorAction(TextView exampleView, int actionId, KeyEvent keyEvent) {
+ if ((keyEvent == null && actionId == EditorInfo.IME_ACTION_DONE) ||
+ (keyEvent != null && keyEvent.getAction() == KeyEvent.ACTION_DOWN &&
+ (actionId == EditorInfo.IME_NULL)))
+ {
+ handlePassphrase();
+ return true;
+ } else if (keyEvent != null && keyEvent.getAction() == KeyEvent.ACTION_UP &&
+ actionId == EditorInfo.IME_NULL)
+ {
+ return true;
+ }
+
+ return false;
+ }
+ }
+
+ private class OkButtonClickListener implements OnClickListener {
+ @Override
+ public void onClick(View v) {
+ handlePassphrase();
+ }
+ }
+
+ private class ShowButtonOnClickListener implements OnClickListener {
+ @Override
+ public void onClick(View v) {
+ visibilityToggle.display(hideButton);
+ setPassphraseVisibility(true);
+ }
+ }
+
+ private class HideButtonOnClickListener implements OnClickListener {
+ @Override
+ public void onClick(View v) {
+ visibilityToggle.display(showButton);
+ setPassphraseVisibility(false);
+ }
+ }
+
+ @Override
+ protected void cleanup() {
+ this.passphraseText.setText("");
+ System.gc();
+ }
+
+ private class BiometricAuthenticationListener extends BiometricPrompt.AuthenticationCallback {
+ @Override
+ public void onAuthenticationError(int errorCode, @NonNull CharSequence errorString) {
+ Log.w(TAG, "Authentication error: " + errorCode);
+ hadFailure = true;
+
+ if (errorCode != BiometricPrompt.ERROR_CANCELED && errorCode != BiometricPrompt.ERROR_USER_CANCELED) {
+ onAuthenticationFailed();
+ }
+ }
+
+ @Override
+ public void onAuthenticationSucceeded(@NonNull BiometricPrompt.AuthenticationResult result) {
+ Log.i(TAG, "onAuthenticationSucceeded");
+ fingerprintPrompt.setImageResource(R.drawable.ic_check_white_48dp);
+ fingerprintPrompt.getBackground().setColorFilter(getResources().getColor(R.color.green_500), PorterDuff.Mode.SRC_IN);
+ fingerprintPrompt.animate().setInterpolator(new BounceInterpolator()).scaleX(1.1f).scaleY(1.1f).setDuration(500).setListener(new AnimationCompleteListener() {
+ @Override
+ public void onAnimationEnd(Animator animation) {
+ handleAuthenticated();
+
+ fingerprintPrompt.setImageResource(R.drawable.ic_fingerprint_white_48dp);
+ fingerprintPrompt.getBackground().setColorFilter(getResources().getColor(R.color.core_ultramarine), PorterDuff.Mode.SRC_IN);
+ }
+ }).start();
+ }
+
+ @Override
+ public void onAuthenticationFailed() {
+ Log.w(TAG, "onAuthenticationFailed()");
+
+ fingerprintPrompt.setImageResource(R.drawable.ic_close_white_48dp);
+ fingerprintPrompt.getBackground().setColorFilter(getResources().getColor(R.color.red_500), PorterDuff.Mode.SRC_IN);
+
+ TranslateAnimation shake = new TranslateAnimation(0, 30, 0, 0);
+ shake.setDuration(50);
+ shake.setRepeatCount(7);
+ shake.setAnimationListener(new Animation.AnimationListener() {
+ @Override
+ public void onAnimationStart(Animation animation) {}
+
+ @Override
+ public void onAnimationEnd(Animation animation) {
+ fingerprintPrompt.setImageResource(R.drawable.ic_fingerprint_white_48dp);
+ fingerprintPrompt.getBackground().setColorFilter(getResources().getColor(R.color.core_ultramarine), PorterDuff.Mode.SRC_IN);
+ }
+
+ @Override
+ public void onAnimationRepeat(Animation animation) {}
+ });
+
+ fingerprintPrompt.startAnimation(shake);
+ }
+ }
+}
diff --git a/app/src/main/java/org/thoughtcrime/securesms/PassphraseRequiredActivity.java b/app/src/main/java/org/thoughtcrime/securesms/PassphraseRequiredActivity.java
new file mode 100644
index 00000000..cfe7e3f2
--- /dev/null
+++ b/app/src/main/java/org/thoughtcrime/securesms/PassphraseRequiredActivity.java
@@ -0,0 +1,262 @@
+package org.thoughtcrime.securesms;
+
+import android.content.BroadcastReceiver;
+import android.content.Context;
+import android.content.Intent;
+import android.content.IntentFilter;
+import android.os.Bundle;
+
+import androidx.annotation.IdRes;
+import androidx.annotation.NonNull;
+import androidx.annotation.Nullable;
+import androidx.fragment.app.Fragment;
+
+import org.signal.core.util.logging.Log;
+import org.signal.core.util.tracing.Tracer;
+import org.thoughtcrime.securesms.crypto.MasterSecretUtil;
+import org.thoughtcrime.securesms.dependencies.ApplicationDependencies;
+import org.thoughtcrime.securesms.jobs.PushNotificationReceiveJob;
+import org.thoughtcrime.securesms.keyvalue.SignalStore;
+import org.thoughtcrime.securesms.lock.v2.CreateKbsPinActivity;
+import org.thoughtcrime.securesms.migrations.ApplicationMigrationActivity;
+import org.thoughtcrime.securesms.migrations.ApplicationMigrations;
+import org.thoughtcrime.securesms.pin.PinRestoreActivity;
+import org.thoughtcrime.securesms.profiles.edit.EditProfileActivity;
+import org.thoughtcrime.securesms.push.SignalServiceNetworkAccess;
+import org.thoughtcrime.securesms.recipients.Recipient;
+import org.thoughtcrime.securesms.registration.RegistrationNavigationActivity;
+import org.thoughtcrime.securesms.service.KeyCachingService;
+import org.thoughtcrime.securesms.util.AppStartup;
+import org.thoughtcrime.securesms.util.TextSecurePreferences;
+
+import java.util.Locale;
+
+public abstract class PassphraseRequiredActivity extends BaseActivity implements MasterSecretListener {
+ private static final String TAG = PassphraseRequiredActivity.class.getSimpleName();
+
+ public static final String LOCALE_EXTRA = "locale_extra";
+ public static final String NEXT_INTENT_EXTRA = "next_intent";
+
+ private static final int STATE_NORMAL = 0;
+ private static final int STATE_CREATE_PASSPHRASE = 1;
+ private static final int STATE_PROMPT_PASSPHRASE = 2;
+ private static final int STATE_UI_BLOCKING_UPGRADE = 3;
+ private static final int STATE_WELCOME_PUSH_SCREEN = 4;
+ private static final int STATE_ENTER_SIGNAL_PIN = 5;
+ private static final int STATE_CREATE_PROFILE_NAME = 6;
+ private static final int STATE_CREATE_SIGNAL_PIN = 7;
+
+ private SignalServiceNetworkAccess networkAccess;
+ private BroadcastReceiver clearKeyReceiver;
+
+ @Override
+ protected final void onCreate(Bundle savedInstanceState) {
+ Tracer.getInstance().start(Log.tag(getClass()) + "#onCreate()");
+ AppStartup.getInstance().onCriticalRenderEventStart();
+ this.networkAccess = new SignalServiceNetworkAccess(this);
+ onPreCreate();
+
+ final boolean locked = KeyCachingService.isLocked(this);
+ routeApplicationState(locked);
+
+ super.onCreate(savedInstanceState);
+
+ if (!isFinishing()) {
+ initializeClearKeyReceiver();
+ onCreate(savedInstanceState, true);
+ }
+
+ AppStartup.getInstance().onCriticalRenderEventEnd();
+ Tracer.getInstance().end(Log.tag(getClass()) + "#onCreate()");
+ }
+
+ protected void onPreCreate() {}
+ protected void onCreate(Bundle savedInstanceState, boolean ready) {}
+
+ @Override
+ protected void onResume() {
+ super.onResume();
+
+ if (networkAccess.isCensored(this)) {
+ ApplicationDependencies.getJobManager().add(new PushNotificationReceiveJob());
+ }
+ }
+
+ @Override
+ protected void onDestroy() {
+ super.onDestroy();
+ removeClearKeyReceiver(this);
+ }
+
+ @Override
+ public void onMasterSecretCleared() {
+ Log.d(TAG, "onMasterSecretCleared()");
+ if (ApplicationDependencies.getAppForegroundObserver().isForegrounded()) routeApplicationState(true);
+ else finish();
+ }
+
+ protected T initFragment(@IdRes int target,
+ @NonNull T fragment)
+ {
+ return initFragment(target, fragment, null);
+ }
+
+ protected T initFragment(@IdRes int target,
+ @NonNull T fragment,
+ @Nullable Locale locale)
+ {
+ return initFragment(target, fragment, locale, null);
+ }
+
+ protected T initFragment(@IdRes int target,
+ @NonNull T fragment,
+ @Nullable Locale locale,
+ @Nullable Bundle extras)
+ {
+ Bundle args = new Bundle();
+ args.putSerializable(LOCALE_EXTRA, locale);
+
+ if (extras != null) {
+ args.putAll(extras);
+ }
+
+ fragment.setArguments(args);
+ getSupportFragmentManager().beginTransaction()
+ .replace(target, fragment)
+ .commitAllowingStateLoss();
+ return fragment;
+ }
+
+ private void routeApplicationState(boolean locked) {
+ Intent intent = getIntentForState(getApplicationState(locked));
+ if (intent != null) {
+ startActivity(intent);
+ finish();
+ }
+ }
+
+ private Intent getIntentForState(int state) {
+ Log.d(TAG, "routeApplicationState(), state: " + state);
+
+ switch (state) {
+ case STATE_CREATE_PASSPHRASE: return getCreatePassphraseIntent();
+ case STATE_PROMPT_PASSPHRASE: return getPromptPassphraseIntent();
+ case STATE_UI_BLOCKING_UPGRADE: return getUiBlockingUpgradeIntent();
+ case STATE_WELCOME_PUSH_SCREEN: return getPushRegistrationIntent();
+ case STATE_ENTER_SIGNAL_PIN: return getEnterSignalPinIntent();
+ case STATE_CREATE_SIGNAL_PIN: return getCreateSignalPinIntent();
+ case STATE_CREATE_PROFILE_NAME: return getCreateProfileNameIntent();
+ default: return null;
+ }
+ }
+
+ private int getApplicationState(boolean locked) {
+ if (!MasterSecretUtil.isPassphraseInitialized(this)) {
+ return STATE_CREATE_PASSPHRASE;
+ } else if (locked) {
+ return STATE_PROMPT_PASSPHRASE;
+ } else if (ApplicationMigrations.isUpdate(this) && ApplicationMigrations.isUiBlockingMigrationRunning()) {
+ return STATE_UI_BLOCKING_UPGRADE;
+ } else if (!TextSecurePreferences.hasPromptedPushRegistration(this)) {
+ return STATE_WELCOME_PUSH_SCREEN;
+ } else if (SignalStore.storageServiceValues().needsAccountRestore()) {
+ return STATE_ENTER_SIGNAL_PIN;
+ } else if (userMustSetProfileName()) {
+ return STATE_CREATE_PROFILE_NAME;
+ } else if (userMustCreateSignalPin()) {
+ return STATE_CREATE_SIGNAL_PIN;
+ } else {
+ return STATE_NORMAL;
+ }
+ }
+
+ private boolean userMustCreateSignalPin() {
+ return !SignalStore.registrationValues().isRegistrationComplete() && !SignalStore.kbsValues().hasPin() && !SignalStore.kbsValues().lastPinCreateFailed() && !SignalStore.kbsValues().hasOptedOut();
+ }
+
+ private boolean userMustSetProfileName() {
+ return !SignalStore.registrationValues().isRegistrationComplete() && Recipient.self().getProfileName().isEmpty();
+ }
+
+ private Intent getCreatePassphraseIntent() {
+ return getRoutedIntent(PassphraseCreateActivity.class, getIntent());
+ }
+
+ private Intent getPromptPassphraseIntent() {
+ Intent intent = getRoutedIntent(PassphrasePromptActivity.class, getIntent());
+ intent.putExtra(PassphrasePromptActivity.FROM_FOREGROUND, ApplicationDependencies.getAppForegroundObserver().isForegrounded());
+ return intent;
+ }
+
+ private Intent getUiBlockingUpgradeIntent() {
+ return getRoutedIntent(ApplicationMigrationActivity.class,
+ TextSecurePreferences.hasPromptedPushRegistration(this)
+ ? getConversationListIntent()
+ : getPushRegistrationIntent());
+ }
+
+ private Intent getPushRegistrationIntent() {
+ return RegistrationNavigationActivity.newIntentForNewRegistration(this, getIntent());
+ }
+
+ private Intent getEnterSignalPinIntent() {
+ return getRoutedIntent(PinRestoreActivity.class, getIntent());
+ }
+
+ private Intent getCreateSignalPinIntent() {
+
+ final Intent intent;
+ if (userMustSetProfileName()) {
+ intent = getCreateProfileNameIntent();
+ } else {
+ intent = getIntent();
+ }
+
+ return getRoutedIntent(CreateKbsPinActivity.class, intent);
+ }
+
+ private Intent getCreateProfileNameIntent() {
+ return getRoutedIntent(EditProfileActivity.class, getIntent());
+ }
+
+ private Intent getRoutedIntent(Class> destination, @Nullable Intent nextIntent) {
+ final Intent intent = new Intent(this, destination);
+ if (nextIntent != null) intent.putExtra("next_intent", nextIntent);
+ return intent;
+ }
+
+ private Intent getConversationListIntent() {
+ // TODO [greyson] Navigation
+ return MainActivity.clearTop(this);
+ }
+
+ private void initializeClearKeyReceiver() {
+ this.clearKeyReceiver = new BroadcastReceiver() {
+ @Override
+ public void onReceive(Context context, Intent intent) {
+ Log.i(TAG, "onReceive() for clear key event. PasswordDisabled: " + TextSecurePreferences.isPasswordDisabled(context) + ", ScreenLock: " + TextSecurePreferences.isScreenLockEnabled(context));
+ if (TextSecurePreferences.isScreenLockEnabled(context) || !TextSecurePreferences.isPasswordDisabled(context)) {
+ onMasterSecretCleared();
+ }
+ }
+ };
+
+ IntentFilter filter = new IntentFilter(KeyCachingService.CLEAR_KEY_EVENT);
+ registerReceiver(clearKeyReceiver, filter, KeyCachingService.KEY_PERMISSION, null);
+ }
+
+ private void removeClearKeyReceiver(Context context) {
+ if (clearKeyReceiver != null) {
+ context.unregisterReceiver(clearKeyReceiver);
+ clearKeyReceiver = null;
+ }
+ }
+
+ /**
+ * Puts an extra in {@code intent} so that {@code nextIntent} will be shown after it.
+ */
+ public static @NonNull Intent chainIntent(@NonNull Intent intent, @NonNull Intent nextIntent) {
+ intent.putExtra(NEXT_INTENT_EXTRA, nextIntent);
+ return intent;
+ }
+}
diff --git a/app/src/main/java/org/thoughtcrime/securesms/PlayServicesProblemActivity.java b/app/src/main/java/org/thoughtcrime/securesms/PlayServicesProblemActivity.java
new file mode 100644
index 00000000..ff4029fd
--- /dev/null
+++ b/app/src/main/java/org/thoughtcrime/securesms/PlayServicesProblemActivity.java
@@ -0,0 +1,31 @@
+/**
+ * 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 .
+ */
+package org.thoughtcrime.securesms;
+
+import android.os.Bundle;
+
+import androidx.fragment.app.FragmentActivity;
+
+public class PlayServicesProblemActivity extends FragmentActivity {
+
+ @Override
+ public void onCreate(Bundle bundle) {
+ super.onCreate(bundle);
+ PlayServicesProblemFragment fragment = new PlayServicesProblemFragment();
+ fragment.show(getSupportFragmentManager(), "dialog");
+ }
+}
diff --git a/app/src/main/java/org/thoughtcrime/securesms/PlayServicesProblemFragment.java b/app/src/main/java/org/thoughtcrime/securesms/PlayServicesProblemFragment.java
new file mode 100644
index 00000000..9864872f
--- /dev/null
+++ b/app/src/main/java/org/thoughtcrime/securesms/PlayServicesProblemFragment.java
@@ -0,0 +1,66 @@
+/**
+ * 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 .
+ */
+
+package org.thoughtcrime.securesms;
+
+import android.app.Activity;
+import android.app.Dialog;
+import android.content.DialogInterface;
+import android.os.Bundle;
+
+import androidx.annotation.NonNull;
+import androidx.annotation.Nullable;
+import androidx.appcompat.app.AlertDialog;
+import androidx.fragment.app.DialogFragment;
+
+import com.google.android.gms.common.GoogleApiAvailability;
+
+public class PlayServicesProblemFragment extends DialogFragment {
+
+ @Override
+ public @NonNull Dialog onCreateDialog(@Nullable Bundle bundle) {
+ int code = GoogleApiAvailability.getInstance().isGooglePlayServicesAvailable(getActivity());
+ Dialog dialog = GoogleApiAvailability.getInstance().getErrorDialog(getActivity(), code, 9111);
+
+ if (dialog == null) {
+ return new AlertDialog.Builder(requireActivity())
+ .setNegativeButton(android.R.string.ok, null)
+ .setMessage(R.string.PlayServicesProblemFragment_the_version_of_google_play_services_you_have_installed_is_not_functioning)
+ .create();
+ } else {
+ return dialog;
+ }
+ }
+
+ @Override
+ public void onCancel(DialogInterface dialog) {
+ super.onCancel(dialog);
+ finish();
+ }
+
+ @Override
+ public void onDismiss(DialogInterface dialog) {
+ super.onDismiss(dialog);
+ finish();
+ }
+
+ private void finish() {
+ Activity activity = getActivity();
+ if (activity != null) activity.finish();
+ }
+
+}
diff --git a/app/src/main/java/org/thoughtcrime/securesms/PromptMmsActivity.java b/app/src/main/java/org/thoughtcrime/securesms/PromptMmsActivity.java
new file mode 100644
index 00000000..c7d40024
--- /dev/null
+++ b/app/src/main/java/org/thoughtcrime/securesms/PromptMmsActivity.java
@@ -0,0 +1,31 @@
+package org.thoughtcrime.securesms;
+
+import android.content.Intent;
+import android.os.Bundle;
+import android.widget.Button;
+
+import org.thoughtcrime.securesms.preferences.MmsPreferencesActivity;
+
+public class PromptMmsActivity extends PassphraseRequiredActivity {
+
+ @Override
+ protected void onCreate(Bundle bundle, boolean ready) {
+ setContentView(R.layout.prompt_apn_activity);
+ initializeResources();
+ }
+
+ private void initializeResources() {
+ Button okButton = findViewById(R.id.ok_button);
+ Button cancelButton = findViewById(R.id.cancel_button);
+
+ okButton.setOnClickListener(v -> {
+ Intent intent = new Intent(PromptMmsActivity.this, MmsPreferencesActivity.class);
+ intent.putExtras(PromptMmsActivity.this.getIntent().getExtras());
+ startActivity(intent);
+ finish();
+ });
+
+ cancelButton.setOnClickListener(v -> finish());
+ }
+
+}
diff --git a/app/src/main/java/org/thoughtcrime/securesms/PushContactSelectionActivity.java b/app/src/main/java/org/thoughtcrime/securesms/PushContactSelectionActivity.java
new file mode 100644
index 00000000..5e729813
--- /dev/null
+++ b/app/src/main/java/org/thoughtcrime/securesms/PushContactSelectionActivity.java
@@ -0,0 +1,67 @@
+/*
+ * Copyright (C) 2011 Whisper Systems
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see .
+ */
+package org.thoughtcrime.securesms;
+
+import android.content.Intent;
+import android.os.Bundle;
+
+import com.annimon.stream.Stream;
+
+import org.thoughtcrime.securesms.contacts.SelectedContact;
+import org.thoughtcrime.securesms.recipients.RecipientId;
+
+import java.util.ArrayList;
+import java.util.List;
+
+/**
+ * Activity container for selecting a list of contacts.
+ *
+ * @author Moxie Marlinspike
+ *
+ */
+public class PushContactSelectionActivity extends ContactSelectionActivity {
+
+ public static final String KEY_SELECTED_RECIPIENTS = "recipients";
+
+ @SuppressWarnings("unused")
+ private final static String TAG = PushContactSelectionActivity.class.getSimpleName();
+
+ @Override
+ protected void onCreate(Bundle icicle, boolean ready) {
+ super.onCreate(icicle, ready);
+
+ initializeToolbar();
+ }
+
+ protected void initializeToolbar() {
+ getToolbar().setNavigationIcon(R.drawable.ic_check_24);
+ getToolbar().setNavigationOnClickListener(v -> {
+ onFinishedSelection();
+ });
+ }
+
+ protected final void onFinishedSelection() {
+ Intent resultIntent = getIntent();
+ List selectedContacts = contactsFragment.getSelectedContacts();
+ List recipients = Stream.of(selectedContacts).map(sc -> sc.getOrCreateRecipientId(this)).toList();
+
+ resultIntent.putParcelableArrayListExtra(KEY_SELECTED_RECIPIENTS, new ArrayList<>(recipients));
+
+ setResult(RESULT_OK, resultIntent);
+ finish();
+ }
+}
diff --git a/app/src/main/java/org/thoughtcrime/securesms/ShortcutLauncherActivity.java b/app/src/main/java/org/thoughtcrime/securesms/ShortcutLauncherActivity.java
new file mode 100644
index 00000000..28f1dd58
--- /dev/null
+++ b/app/src/main/java/org/thoughtcrime/securesms/ShortcutLauncherActivity.java
@@ -0,0 +1,51 @@
+package org.thoughtcrime.securesms;
+
+import android.content.Context;
+import android.content.Intent;
+import android.os.Bundle;
+import android.widget.Toast;
+
+import androidx.annotation.NonNull;
+import androidx.annotation.Nullable;
+import androidx.appcompat.app.AppCompatActivity;
+import androidx.core.app.TaskStackBuilder;
+
+import org.thoughtcrime.securesms.recipients.Recipient;
+import org.thoughtcrime.securesms.recipients.RecipientId;
+import org.thoughtcrime.securesms.util.CommunicationActions;
+
+public class ShortcutLauncherActivity extends AppCompatActivity {
+
+ private static final String KEY_RECIPIENT = "recipient_id";
+
+ public static Intent createIntent(@NonNull Context context, @NonNull RecipientId recipientId) {
+ Intent intent = new Intent(context, ShortcutLauncherActivity.class);
+ intent.setAction(Intent.ACTION_MAIN);
+ intent.putExtra(KEY_RECIPIENT, recipientId.serialize());
+
+ return intent;
+ }
+
+ @Override
+ protected void onCreate(@Nullable Bundle savedInstanceState) {
+ super.onCreate(savedInstanceState);
+
+ String rawId = getIntent().getStringExtra(KEY_RECIPIENT);
+
+ if (rawId == null) {
+ Toast.makeText(this, R.string.ShortcutLauncherActivity_invalid_shortcut, Toast.LENGTH_SHORT).show();
+ // TODO [greyson] Navigation
+ startActivity(MainActivity.clearTop(this));
+ finish();
+ return;
+ }
+
+ Recipient recipient = Recipient.live(RecipientId.from(rawId)).get();
+ // TODO [greyson] Navigation
+ TaskStackBuilder backStack = TaskStackBuilder.create(this)
+ .addNextIntent(MainActivity.clearTop(this));
+
+ CommunicationActions.startConversation(this, recipient, null, backStack);
+ finish();
+ }
+}
diff --git a/app/src/main/java/org/thoughtcrime/securesms/SmsSendtoActivity.java b/app/src/main/java/org/thoughtcrime/securesms/SmsSendtoActivity.java
new file mode 100644
index 00000000..53686d8f
--- /dev/null
+++ b/app/src/main/java/org/thoughtcrime/securesms/SmsSendtoActivity.java
@@ -0,0 +1,108 @@
+package org.thoughtcrime.securesms;
+
+import android.app.Activity;
+import android.content.Intent;
+import android.database.Cursor;
+import android.os.Bundle;
+import android.provider.ContactsContract;
+import android.text.TextUtils;
+import android.widget.Toast;
+
+import androidx.annotation.NonNull;
+
+import org.signal.core.util.logging.Log;
+import org.thoughtcrime.securesms.conversation.ConversationIntents;
+import org.thoughtcrime.securesms.database.DatabaseFactory;
+import org.thoughtcrime.securesms.recipients.Recipient;
+import org.thoughtcrime.securesms.util.Rfc5724Uri;
+
+import java.net.URISyntaxException;
+
+public class SmsSendtoActivity extends Activity {
+
+ private static final String TAG = SmsSendtoActivity.class.getSimpleName();
+
+ @Override
+ protected void onCreate(Bundle savedInstanceState) {
+ startActivity(getNextIntent(getIntent()));
+ finish();
+ super.onCreate(savedInstanceState);
+ }
+
+ private Intent getNextIntent(Intent original) {
+ DestinationAndBody destination;
+
+ if (original.getAction().equals(Intent.ACTION_SENDTO)) {
+ destination = getDestinationForSendTo(original);
+ } else if (original.getData() != null && "content".equals(original.getData().getScheme())) {
+ destination = getDestinationForSyncAdapter(original);
+ } else {
+ destination = getDestinationForView(original);
+ }
+
+ final Intent nextIntent;
+
+ if (TextUtils.isEmpty(destination.destination)) {
+ nextIntent = new Intent(this, NewConversationActivity.class);
+ nextIntent.putExtra(Intent.EXTRA_TEXT, destination.getBody());
+ Toast.makeText(this, R.string.ConversationActivity_specify_recipient, Toast.LENGTH_LONG).show();
+ } else {
+ Recipient recipient = Recipient.external(this, destination.getDestination());
+ long threadId = DatabaseFactory.getThreadDatabase(this).getThreadIdIfExistsFor(recipient.getId());
+
+ nextIntent = ConversationIntents.createBuilder(this, recipient.getId(), threadId)
+ .withDraftText(destination.getBody())
+ .build();
+ }
+ return nextIntent;
+ }
+
+ private @NonNull DestinationAndBody getDestinationForSendTo(Intent intent) {
+ return new DestinationAndBody(intent.getData().getSchemeSpecificPart(),
+ intent.getStringExtra("sms_body"));
+ }
+
+ private @NonNull DestinationAndBody getDestinationForView(Intent intent) {
+ try {
+ Rfc5724Uri smsUri = new Rfc5724Uri(intent.getData().toString());
+ return new DestinationAndBody(smsUri.getPath(), smsUri.getQueryParams().get("body"));
+ } catch (URISyntaxException e) {
+ Log.w(TAG, "unable to parse RFC5724 URI from intent", e);
+ return new DestinationAndBody("", "");
+ }
+ }
+
+ private @NonNull DestinationAndBody getDestinationForSyncAdapter(Intent intent) {
+ Cursor cursor = null;
+
+ try {
+ cursor = getContentResolver().query(intent.getData(), null, null, null, null);
+
+ if (cursor != null && cursor.moveToNext()) {
+ return new DestinationAndBody(cursor.getString(cursor.getColumnIndexOrThrow(ContactsContract.RawContacts.Data.DATA1)), "");
+ }
+
+ return new DestinationAndBody("", "");
+ } finally {
+ if (cursor != null) cursor.close();
+ }
+ }
+
+ private static class DestinationAndBody {
+ private final String destination;
+ private final String body;
+
+ private DestinationAndBody(String destination, String body) {
+ this.destination = destination;
+ this.body = body;
+ }
+
+ public String getDestination() {
+ return destination;
+ }
+
+ public String getBody() {
+ return body;
+ }
+ }
+}
diff --git a/app/src/main/java/org/thoughtcrime/securesms/TextSecureExpiredException.java b/app/src/main/java/org/thoughtcrime/securesms/TextSecureExpiredException.java
new file mode 100644
index 00000000..f207b5d6
--- /dev/null
+++ b/app/src/main/java/org/thoughtcrime/securesms/TextSecureExpiredException.java
@@ -0,0 +1,7 @@
+package org.thoughtcrime.securesms;
+
+public class TextSecureExpiredException extends Exception {
+ public TextSecureExpiredException(String message) {
+ super(message);
+ }
+}
diff --git a/app/src/main/java/org/thoughtcrime/securesms/TransportOption.java b/app/src/main/java/org/thoughtcrime/securesms/TransportOption.java
new file mode 100644
index 00000000..902bd755
--- /dev/null
+++ b/app/src/main/java/org/thoughtcrime/securesms/TransportOption.java
@@ -0,0 +1,147 @@
+package org.thoughtcrime.securesms;
+
+import android.os.Parcel;
+import android.os.Parcelable;
+import android.text.TextUtils;
+
+import androidx.annotation.DrawableRes;
+import androidx.annotation.NonNull;
+
+import org.thoughtcrime.securesms.util.CharacterCalculator;
+import org.thoughtcrime.securesms.util.CharacterCalculator.CharacterState;
+import org.whispersystems.libsignal.util.guava.Optional;
+
+public class TransportOption implements Parcelable {
+
+ public enum Type {
+ SMS,
+ TEXTSECURE
+ }
+
+ private final int drawable;
+ private final int backgroundColor;
+ private final @NonNull String text;
+ private final @NonNull Type type;
+ private final @NonNull String composeHint;
+ private final @NonNull CharacterCalculator characterCalculator;
+ private final @NonNull Optional simName;
+ private final @NonNull Optional simSubscriptionId;
+
+ public TransportOption(@NonNull Type type,
+ @DrawableRes int drawable,
+ int backgroundColor,
+ @NonNull String text,
+ @NonNull String composeHint,
+ @NonNull CharacterCalculator characterCalculator)
+ {
+ this(type, drawable, backgroundColor, text, composeHint, characterCalculator,
+ Optional.absent(), Optional.absent());
+ }
+
+ public TransportOption(@NonNull Type type,
+ @DrawableRes int drawable,
+ int backgroundColor,
+ @NonNull String text,
+ @NonNull String composeHint,
+ @NonNull CharacterCalculator characterCalculator,
+ @NonNull Optional simName,
+ @NonNull Optional simSubscriptionId)
+ {
+ this.type = type;
+ this.drawable = drawable;
+ this.backgroundColor = backgroundColor;
+ this.text = text;
+ this.composeHint = composeHint;
+ this.characterCalculator = characterCalculator;
+ this.simName = simName;
+ this.simSubscriptionId = simSubscriptionId;
+ }
+
+ TransportOption(Parcel in) {
+ this(Type.valueOf(in.readString()),
+ in.readInt(),
+ in.readInt(),
+ in.readString(),
+ in.readString(),
+ CharacterCalculator.readFromParcel(in),
+ Optional.fromNullable(TextUtils.CHAR_SEQUENCE_CREATOR.createFromParcel(in)),
+ in.readInt() == 1 ? Optional.of(in.readInt()) : Optional.absent());
+ }
+
+ public @NonNull Type getType() {
+ return type;
+ }
+
+ public boolean isType(Type type) {
+ return this.type == type;
+ }
+
+ public boolean isSms() {
+ return type == Type.SMS;
+ }
+
+ public CharacterState calculateCharacters(String messageBody) {
+ return characterCalculator.calculateCharacters(messageBody);
+ }
+
+ public @DrawableRes int getDrawable() {
+ return drawable;
+ }
+
+ public int getBackgroundColor() {
+ return backgroundColor;
+ }
+
+ public @NonNull String getComposeHint() {
+ return composeHint;
+ }
+
+ public @NonNull String getDescription() {
+ return text;
+ }
+
+ @NonNull
+ public Optional getSimName() {
+ return simName;
+ }
+
+ @NonNull
+ public Optional getSimSubscriptionId() {
+ return simSubscriptionId;
+ }
+
+ @Override
+ public int describeContents() {
+ return 0;
+ }
+
+ @Override
+ public void writeToParcel(Parcel dest, int flags) {
+ dest.writeString(type.name());
+ dest.writeInt(drawable);
+ dest.writeInt(backgroundColor);
+ dest.writeString(text);
+ dest.writeString(composeHint);
+ CharacterCalculator.writeToParcel(dest, characterCalculator);
+ TextUtils.writeToParcel(simName.orNull(), dest, flags);
+
+ if (simSubscriptionId.isPresent()) {
+ dest.writeInt(1);
+ dest.writeInt(simSubscriptionId.get());
+ } else {
+ dest.writeInt(0);
+ }
+ }
+
+ public static final Creator CREATOR = new Creator() {
+ @Override
+ public TransportOption createFromParcel(Parcel in) {
+ return new TransportOption(in);
+ }
+
+ @Override
+ public TransportOption[] newArray(int size) {
+ return new TransportOption[size];
+ }
+ };
+}
diff --git a/app/src/main/java/org/thoughtcrime/securesms/TransportOptions.java b/app/src/main/java/org/thoughtcrime/securesms/TransportOptions.java
new file mode 100644
index 00000000..386c1e59
--- /dev/null
+++ b/app/src/main/java/org/thoughtcrime/securesms/TransportOptions.java
@@ -0,0 +1,224 @@
+package org.thoughtcrime.securesms;
+
+import android.Manifest;
+import android.content.Context;
+
+import androidx.annotation.NonNull;
+import androidx.annotation.Nullable;
+
+import org.thoughtcrime.securesms.permissions.Permissions;
+import org.thoughtcrime.securesms.util.CharacterCalculator;
+import org.thoughtcrime.securesms.util.MmsCharacterCalculator;
+import org.thoughtcrime.securesms.util.PushCharacterCalculator;
+import org.thoughtcrime.securesms.util.SmsCharacterCalculator;
+import org.thoughtcrime.securesms.util.dualsim.SubscriptionInfoCompat;
+import org.thoughtcrime.securesms.util.dualsim.SubscriptionManagerCompat;
+import org.whispersystems.libsignal.util.guava.Optional;
+
+import java.util.Collection;
+import java.util.Collections;
+import java.util.Iterator;
+import java.util.LinkedList;
+import java.util.List;
+
+import static org.thoughtcrime.securesms.TransportOption.Type;
+
+public class TransportOptions {
+
+ private static final String TAG = TransportOptions.class.getSimpleName();
+
+ private final List listeners = new LinkedList<>();
+ private final Context context;
+ private final List enabledTransports;
+
+ private Type defaultTransportType = Type.SMS;
+ private Optional defaultSubscriptionId = Optional.absent();
+ private Optional selectedOption = Optional.absent();
+
+ private final Optional systemSubscriptionId;
+
+ public TransportOptions(Context context, boolean media) {
+ this.context = context;
+ this.enabledTransports = initializeAvailableTransports(media);
+ this.systemSubscriptionId = new SubscriptionManagerCompat(context).getPreferredSubscriptionId();
+ }
+
+ public void reset(boolean media) {
+ List transportOptions = initializeAvailableTransports(media);
+
+ this.enabledTransports.clear();
+ this.enabledTransports.addAll(transportOptions);
+
+ if (selectedOption.isPresent() && !isEnabled(selectedOption.get())) {
+ setSelectedTransport(null);
+ } else {
+ this.defaultTransportType = Type.SMS;
+ this.defaultSubscriptionId = Optional.absent();
+
+ notifyTransportChangeListeners();
+ }
+ }
+
+ public void setDefaultTransport(Type type) {
+ this.defaultTransportType = type;
+
+ if (!selectedOption.isPresent()) {
+ notifyTransportChangeListeners();
+ }
+ }
+
+ public void setDefaultSubscriptionId(Optional subscriptionId) {
+ if (defaultSubscriptionId.equals(subscriptionId)) {
+ return;
+ }
+
+ this.defaultSubscriptionId = subscriptionId;
+
+ if (!selectedOption.isPresent()) {
+ notifyTransportChangeListeners();
+ }
+ }
+
+ public void setSelectedTransport(@Nullable TransportOption transportOption) {
+ this.selectedOption = Optional.fromNullable(transportOption);
+ notifyTransportChangeListeners();
+ }
+
+ public boolean isManualSelection() {
+ return this.selectedOption.isPresent();
+ }
+
+ public @NonNull TransportOption getSelectedTransport() {
+ if (selectedOption.isPresent()) return selectedOption.get();
+
+ if (defaultTransportType == Type.SMS) {
+ TransportOption transportOption = findEnabledSmsTransportOption(defaultSubscriptionId.or(systemSubscriptionId));
+ if (transportOption != null) {
+ return transportOption;
+ }
+ }
+
+ for (TransportOption transportOption : enabledTransports) {
+ if (transportOption.getType() == defaultTransportType) {
+ return transportOption;
+ }
+ }
+
+ throw new AssertionError("No options of default type!");
+ }
+
+ public static @NonNull TransportOption getPushTransportOption(@NonNull Context context) {
+ return new TransportOption(Type.TEXTSECURE,
+ R.drawable.ic_send_lock_24,
+ context.getResources().getColor(R.color.core_ultramarine),
+ context.getString(R.string.ConversationActivity_transport_signal),
+ context.getString(R.string.conversation_activity__type_message_push),
+ new PushCharacterCalculator());
+
+ }
+
+ private @Nullable TransportOption findEnabledSmsTransportOption(Optional subscriptionId) {
+ if (subscriptionId.isPresent()) {
+ final int subId = subscriptionId.get();
+
+ for (TransportOption transportOption : enabledTransports) {
+ if (transportOption.getType() == Type.SMS &&
+ subId == transportOption.getSimSubscriptionId().or(-1)) {
+ return transportOption;
+ }
+ }
+ }
+ return null;
+ }
+
+ public void disableTransport(Type type) {
+ TransportOption selected = selectedOption.orNull();
+
+ Iterator iterator = enabledTransports.iterator();
+ while (iterator.hasNext()) {
+ TransportOption option = iterator.next();
+
+ if (option.isType(type)) {
+ if (selected == option) {
+ setSelectedTransport(null);
+ }
+ iterator.remove();
+ }
+ }
+ }
+
+ public List getEnabledTransports() {
+ return enabledTransports;
+ }
+
+ public void addOnTransportChangedListener(OnTransportChangedListener listener) {
+ this.listeners.add(listener);
+ }
+
+ private List initializeAvailableTransports(boolean isMediaMessage) {
+ List results = new LinkedList<>();
+
+ if (isMediaMessage) {
+ results.addAll(getTransportOptionsForSimCards(context.getString(R.string.ConversationActivity_transport_insecure_mms),
+ context.getString(R.string.conversation_activity__type_message_mms_insecure),
+ new MmsCharacterCalculator()));
+ } else {
+ results.addAll(getTransportOptionsForSimCards(context.getString(R.string.ConversationActivity_transport_insecure_sms),
+ context.getString(R.string.conversation_activity__type_message_sms_insecure),
+ new SmsCharacterCalculator()));
+ }
+
+ results.add(getPushTransportOption(context));
+
+ return results;
+ }
+
+ private @NonNull List getTransportOptionsForSimCards(@NonNull String text,
+ @NonNull String composeHint,
+ @NonNull CharacterCalculator characterCalculator)
+ {
+ List results = new LinkedList<>();
+ SubscriptionManagerCompat subscriptionManager = new SubscriptionManagerCompat(context);
+ Collection subscriptions;
+
+ if (Permissions.hasAll(context, Manifest.permission.READ_PHONE_STATE)) {
+ subscriptions = subscriptionManager.getActiveAndReadySubscriptionInfos();
+ } else {
+ subscriptions = Collections.emptyList();
+ }
+
+ if (subscriptions.size() < 2) {
+ results.add(new TransportOption(Type.SMS, R.drawable.ic_send_unlock_24,
+ context.getResources().getColor(R.color.core_grey_50),
+ text, composeHint, characterCalculator));
+ } else {
+ for (SubscriptionInfoCompat subscriptionInfo : subscriptions) {
+ results.add(new TransportOption(Type.SMS, R.drawable.ic_send_unlock_24,
+ context.getResources().getColor(R.color.core_grey_50),
+ text, composeHint, characterCalculator,
+ Optional.of(subscriptionInfo.getDisplayName()),
+ Optional.of(subscriptionInfo.getSubscriptionId())));
+ }
+ }
+
+ return results;
+ }
+
+ private void notifyTransportChangeListeners() {
+ for (OnTransportChangedListener listener : listeners) {
+ listener.onChange(getSelectedTransport(), selectedOption.isPresent());
+ }
+ }
+
+ private boolean isEnabled(TransportOption transportOption) {
+ for (TransportOption option : enabledTransports) {
+ if (option.equals(transportOption)) return true;
+ }
+
+ return false;
+ }
+
+ public interface OnTransportChangedListener {
+ public void onChange(TransportOption newTransport, boolean manuallySelected);
+ }
+}
diff --git a/app/src/main/java/org/thoughtcrime/securesms/TransportOptionsAdapter.java b/app/src/main/java/org/thoughtcrime/securesms/TransportOptionsAdapter.java
new file mode 100644
index 00000000..b7bfaa44
--- /dev/null
+++ b/app/src/main/java/org/thoughtcrime/securesms/TransportOptionsAdapter.java
@@ -0,0 +1,73 @@
+package org.thoughtcrime.securesms;
+
+import android.content.Context;
+import android.graphics.PorterDuff.Mode;
+import android.view.LayoutInflater;
+import android.view.View;
+import android.view.ViewGroup;
+import android.widget.BaseAdapter;
+import android.widget.ImageView;
+import android.widget.TextView;
+
+import androidx.annotation.NonNull;
+
+import java.util.List;
+
+public class TransportOptionsAdapter extends BaseAdapter {
+
+ private final LayoutInflater inflater;
+
+ private List enabledTransports;
+
+ public TransportOptionsAdapter(@NonNull Context context,
+ @NonNull List enabledTransports)
+ {
+ super();
+ this.inflater = LayoutInflater.from(context);
+ this.enabledTransports = enabledTransports;
+ }
+
+ public void setEnabledTransports(List enabledTransports) {
+ this.enabledTransports = enabledTransports;
+ }
+
+ @Override
+ public int getCount() {
+ return enabledTransports.size();
+ }
+
+ @Override
+ public Object getItem(int position) {
+ return enabledTransports.get(position);
+ }
+
+ @Override
+ public long getItemId(int position) {
+ return position;
+ }
+
+ @Override
+ public View getView(int position, View convertView, ViewGroup parent) {
+ if (convertView == null) {
+ convertView = inflater.inflate(R.layout.transport_selection_list_item, parent, false);
+ }
+
+ TransportOption transport = (TransportOption) getItem(position);
+ ImageView imageView = convertView.findViewById(R.id.icon);
+ TextView textView = convertView.findViewById(R.id.text);
+ TextView subtextView = convertView.findViewById(R.id.subtext);
+
+ imageView.getBackground().setColorFilter(transport.getBackgroundColor(), Mode.MULTIPLY);
+ imageView.setImageResource(transport.getDrawable());
+ textView.setText(transport.getDescription());
+
+ if (transport.getSimName().isPresent()) {
+ subtextView.setText(transport.getSimName().get());
+ subtextView.setVisibility(View.VISIBLE);
+ } else {
+ subtextView.setVisibility(View.GONE);
+ }
+
+ return convertView;
+ }
+}
diff --git a/app/src/main/java/org/thoughtcrime/securesms/TransportOptionsPopup.java b/app/src/main/java/org/thoughtcrime/securesms/TransportOptionsPopup.java
new file mode 100644
index 00000000..63e8096f
--- /dev/null
+++ b/app/src/main/java/org/thoughtcrime/securesms/TransportOptionsPopup.java
@@ -0,0 +1,50 @@
+package org.thoughtcrime.securesms;
+
+import android.content.Context;
+import android.view.View;
+import android.widget.AdapterView;
+import android.widget.ListView;
+
+import androidx.annotation.NonNull;
+import androidx.appcompat.widget.ListPopupWindow;
+
+import java.util.LinkedList;
+import java.util.List;
+
+public class TransportOptionsPopup extends ListPopupWindow implements ListView.OnItemClickListener {
+
+ private final TransportOptionsAdapter adapter;
+ private final SelectedListener listener;
+
+ public TransportOptionsPopup(@NonNull Context context, @NonNull View anchor, @NonNull SelectedListener listener) {
+ super(context);
+ this.listener = listener;
+ this.adapter = new TransportOptionsAdapter(context, new LinkedList());
+
+ setVerticalOffset(context.getResources().getDimensionPixelOffset(R.dimen.transport_selection_popup_yoff));
+ setHorizontalOffset(context.getResources().getDimensionPixelOffset(R.dimen.transport_selection_popup_xoff));
+ setInputMethodMode(ListPopupWindow.INPUT_METHOD_NOT_NEEDED);
+ setModal(true);
+ setAnchorView(anchor);
+ setAdapter(adapter);
+ setContentWidth(context.getResources().getDimensionPixelSize(R.dimen.transport_selection_popup_width));
+
+ setOnItemClickListener(this);
+ }
+
+ public void display(List enabledTransports) {
+ adapter.setEnabledTransports(enabledTransports);
+ adapter.notifyDataSetChanged();
+ show();
+ }
+
+ @Override
+ public void onItemClick(AdapterView> parent, View view, int position, long id) {
+ listener.onSelected((TransportOption)adapter.getItem(position));
+ }
+
+ public interface SelectedListener {
+ void onSelected(TransportOption option);
+ }
+
+}
diff --git a/app/src/main/java/org/thoughtcrime/securesms/Unbindable.java b/app/src/main/java/org/thoughtcrime/securesms/Unbindable.java
new file mode 100644
index 00000000..3dd5cd8c
--- /dev/null
+++ b/app/src/main/java/org/thoughtcrime/securesms/Unbindable.java
@@ -0,0 +1,5 @@
+package org.thoughtcrime.securesms;
+
+public interface Unbindable {
+ public void unbind();
+}
diff --git a/app/src/main/java/org/thoughtcrime/securesms/VerifyIdentityActivity.java b/app/src/main/java/org/thoughtcrime/securesms/VerifyIdentityActivity.java
new file mode 100644
index 00000000..e864f636
--- /dev/null
+++ b/app/src/main/java/org/thoughtcrime/securesms/VerifyIdentityActivity.java
@@ -0,0 +1,700 @@
+/*
+ * Copyright (C) 2016-2017 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 .
+ */
+package org.thoughtcrime.securesms;
+
+import android.Manifest;
+import android.animation.TypeEvaluator;
+import android.animation.ValueAnimator;
+import android.annotation.SuppressLint;
+import android.content.ActivityNotFoundException;
+import android.content.Context;
+import android.content.Intent;
+import android.content.res.Configuration;
+import android.graphics.Bitmap;
+import android.graphics.BitmapFactory;
+import android.graphics.Canvas;
+import android.graphics.PorterDuff;
+import android.graphics.drawable.BitmapDrawable;
+import android.graphics.drawable.ColorDrawable;
+import android.os.AsyncTask;
+import android.os.Bundle;
+import android.os.Vibrator;
+import android.text.Html;
+import android.text.TextUtils;
+import android.text.method.LinkMovementMethod;
+import android.view.ContextMenu;
+import android.view.ContextMenu.ContextMenuInfo;
+import android.view.LayoutInflater;
+import android.view.Menu;
+import android.view.MenuInflater;
+import android.view.MenuItem;
+import android.view.View;
+import android.view.ViewGroup;
+import android.view.animation.Animation;
+import android.view.animation.AnticipateInterpolator;
+import android.view.animation.OvershootInterpolator;
+import android.view.animation.ScaleAnimation;
+import android.widget.CompoundButton;
+import android.widget.ImageView;
+import android.widget.TextView;
+import android.widget.Toast;
+
+import androidx.annotation.DrawableRes;
+import androidx.annotation.NonNull;
+import androidx.appcompat.app.AlertDialog;
+import androidx.appcompat.widget.SwitchCompat;
+import androidx.fragment.app.Fragment;
+import androidx.fragment.app.FragmentTransaction;
+
+import org.signal.core.util.concurrent.SignalExecutors;
+import org.signal.core.util.logging.Log;
+import org.thoughtcrime.securesms.color.MaterialColor;
+import org.thoughtcrime.securesms.components.camera.CameraView;
+import org.thoughtcrime.securesms.crypto.DatabaseSessionLock;
+import org.thoughtcrime.securesms.crypto.IdentityKeyParcelable;
+import org.thoughtcrime.securesms.crypto.IdentityKeyUtil;
+import org.thoughtcrime.securesms.database.DatabaseFactory;
+import org.thoughtcrime.securesms.database.IdentityDatabase;
+import org.thoughtcrime.securesms.database.IdentityDatabase.VerifiedStatus;
+import org.thoughtcrime.securesms.dependencies.ApplicationDependencies;
+import org.thoughtcrime.securesms.jobs.MultiDeviceVerifiedUpdateJob;
+import org.thoughtcrime.securesms.permissions.Permissions;
+import org.thoughtcrime.securesms.qr.QrCode;
+import org.thoughtcrime.securesms.qr.ScanListener;
+import org.thoughtcrime.securesms.qr.ScanningThread;
+import org.thoughtcrime.securesms.recipients.LiveRecipient;
+import org.thoughtcrime.securesms.recipients.Recipient;
+import org.thoughtcrime.securesms.recipients.RecipientId;
+import org.thoughtcrime.securesms.storage.StorageSyncHelper;
+import org.thoughtcrime.securesms.util.DynamicTheme;
+import org.thoughtcrime.securesms.util.FeatureFlags;
+import org.thoughtcrime.securesms.util.IdentityUtil;
+import org.thoughtcrime.securesms.util.TextSecurePreferences;
+import org.thoughtcrime.securesms.util.Util;
+import org.thoughtcrime.securesms.util.ViewUtil;
+import org.thoughtcrime.securesms.util.WindowUtil;
+import org.whispersystems.libsignal.IdentityKey;
+import org.whispersystems.libsignal.fingerprint.Fingerprint;
+import org.whispersystems.libsignal.fingerprint.FingerprintParsingException;
+import org.whispersystems.libsignal.fingerprint.FingerprintVersionMismatchException;
+import org.whispersystems.libsignal.fingerprint.NumericFingerprintGenerator;
+import org.whispersystems.signalservice.api.SignalSessionLock;
+import org.whispersystems.signalservice.api.util.UuidUtil;
+
+import java.io.UnsupportedEncodingException;
+import java.nio.charset.Charset;
+import java.util.Locale;
+
+/**
+ * Activity for verifying identity keys.
+ *
+ * @author Moxie Marlinspike
+ */
+@SuppressLint("StaticFieldLeak")
+public class VerifyIdentityActivity extends PassphraseRequiredActivity implements ScanListener, View.OnClickListener {
+
+ private static final String TAG = Log.tag(VerifyIdentityActivity.class);
+
+ private static final String RECIPIENT_EXTRA = "recipient_id";
+ private static final String IDENTITY_EXTRA = "recipient_identity";
+ private static final String VERIFIED_EXTRA = "verified_state";
+
+ private final DynamicTheme dynamicTheme = new DynamicTheme();
+
+ private final VerifyDisplayFragment displayFragment = new VerifyDisplayFragment();
+ private final VerifyScanFragment scanFragment = new VerifyScanFragment();
+
+ public static Intent newIntent(@NonNull Context context,
+ @NonNull IdentityDatabase.IdentityRecord identityRecord)
+ {
+ return newIntent(context,
+ identityRecord.getRecipientId(),
+ identityRecord.getIdentityKey(),
+ identityRecord.getVerifiedStatus() == IdentityDatabase.VerifiedStatus.VERIFIED);
+ }
+
+ public static Intent newIntent(@NonNull Context context,
+ @NonNull IdentityDatabase.IdentityRecord identityRecord,
+ boolean verified)
+ {
+ return newIntent(context,
+ identityRecord.getRecipientId(),
+ identityRecord.getIdentityKey(),
+ verified);
+ }
+
+ public static Intent newIntent(@NonNull Context context,
+ @NonNull RecipientId recipientId,
+ @NonNull IdentityKey identityKey,
+ boolean verified)
+ {
+ Intent intent = new Intent(context, VerifyIdentityActivity.class);
+
+ intent.putExtra(RECIPIENT_EXTRA, recipientId);
+ intent.putExtra(IDENTITY_EXTRA, new IdentityKeyParcelable(identityKey));
+ intent.putExtra(VERIFIED_EXTRA, verified);
+
+ return intent;
+ }
+
+ @Override
+ public void onPreCreate() {
+ dynamicTheme.onCreate(this);
+ }
+
+ @Override
+ protected void onCreate(Bundle state, boolean ready) {
+ getSupportActionBar().setDisplayHomeAsUpEnabled(true);
+ getSupportActionBar().setTitle(R.string.AndroidManifest__verify_safety_number);
+
+ Bundle extras = new Bundle();
+ extras.putParcelable(VerifyDisplayFragment.RECIPIENT_ID, getIntent().getParcelableExtra(RECIPIENT_EXTRA));
+ extras.putParcelable(VerifyDisplayFragment.REMOTE_IDENTITY, getIntent().getParcelableExtra(IDENTITY_EXTRA));
+ extras.putParcelable(VerifyDisplayFragment.LOCAL_IDENTITY, new IdentityKeyParcelable(IdentityKeyUtil.getIdentityKey(this)));
+ extras.putString(VerifyDisplayFragment.LOCAL_NUMBER, TextSecurePreferences.getLocalNumber(this));
+ extras.putBoolean(VerifyDisplayFragment.VERIFIED_STATE, getIntent().getBooleanExtra(VERIFIED_EXTRA, false));
+
+ scanFragment.setScanListener(this);
+ displayFragment.setClickListener(this);
+
+ initFragment(android.R.id.content, displayFragment, Locale.getDefault(), extras);
+ }
+
+ @Override
+ public boolean onOptionsItemSelected(MenuItem item) {
+ switch (item.getItemId()) {
+ case android.R.id.home: finish(); return true;
+ }
+
+ return false;
+ }
+
+ @Override
+ public void onQrDataFound(final String data) {
+ Util.runOnMain(() -> {
+ ((Vibrator)getSystemService(Context.VIBRATOR_SERVICE)).vibrate(50);
+
+ getSupportFragmentManager().popBackStack();
+ displayFragment.setScannedFingerprint(data);
+ });
+ }
+
+ @Override
+ public void onClick(View v) {
+ Permissions.with(this)
+ .request(Manifest.permission.CAMERA)
+ .ifNecessary()
+ .withPermanentDenialDialog(getString(R.string.VerifyIdentityActivity_signal_needs_the_camera_permission_in_order_to_scan_a_qr_code_but_it_has_been_permanently_denied))
+ .onAllGranted(() -> {
+ FragmentTransaction transaction = getSupportFragmentManager().beginTransaction();
+ transaction.setCustomAnimations(R.anim.slide_from_top, R.anim.slide_to_bottom,
+ R.anim.slide_from_bottom, R.anim.slide_to_top);
+
+ transaction.replace(android.R.id.content, scanFragment)
+ .addToBackStack(null)
+ .commitAllowingStateLoss();
+ })
+ .onAnyDenied(() -> Toast.makeText(this, R.string.VerifyIdentityActivity_unable_to_scan_qr_code_without_camera_permission, Toast.LENGTH_LONG).show())
+ .execute();
+ }
+
+ @Override
+ public void onRequestPermissionsResult(int requestCode, @NonNull String[] permissions, @NonNull int[] grantResults) {
+ Permissions.onRequestPermissionsResult(this, requestCode, permissions, grantResults);
+ }
+
+ private void setActionBarNotificationBarColor(MaterialColor color) {
+ getSupportActionBar().setBackgroundDrawable(new ColorDrawable(color.toActionBarColor(this)));
+
+ WindowUtil.setStatusBarColor(getWindow(), color.toStatusBarColor(this));
+ }
+
+ public static class VerifyDisplayFragment extends Fragment implements CompoundButton.OnCheckedChangeListener {
+
+ public static final String RECIPIENT_ID = "recipient_id";
+ public static final String REMOTE_NUMBER = "remote_number";
+ public static final String REMOTE_IDENTITY = "remote_identity";
+ public static final String LOCAL_IDENTITY = "local_identity";
+ public static final String LOCAL_NUMBER = "local_number";
+ public static final String VERIFIED_STATE = "verified_state";
+
+ private LiveRecipient recipient;
+ private IdentityKey localIdentity;
+ private IdentityKey remoteIdentity;
+ private Fingerprint fingerprint;
+
+ private View container;
+ private View numbersContainer;
+ private ImageView qrCode;
+ private ImageView qrVerified;
+ private TextView tapLabel;
+ private TextView description;
+ private View.OnClickListener clickListener;
+ private SwitchCompat verified;
+
+ private TextView[] codes = new TextView[12];
+ private boolean animateSuccessOnDraw = false;
+ private boolean animateFailureOnDraw = false;
+
+ @Override
+ public View onCreateView(@NonNull LayoutInflater inflater, ViewGroup viewGroup, Bundle bundle) {
+ this.container = ViewUtil.inflate(inflater, viewGroup, R.layout.verify_display_fragment);
+ this.numbersContainer = container.findViewById(R.id.number_table);
+ this.qrCode = container.findViewById(R.id.qr_code);
+ this.verified = container.findViewById(R.id.verified_switch);
+ this.qrVerified = container.findViewById(R.id.qr_verified);
+ this.description = container.findViewById(R.id.description);
+ this.tapLabel = container.findViewById(R.id.tap_label);
+ this.codes[0] = container.findViewById(R.id.code_first);
+ this.codes[1] = container.findViewById(R.id.code_second);
+ this.codes[2] = container.findViewById(R.id.code_third);
+ this.codes[3] = container.findViewById(R.id.code_fourth);
+ this.codes[4] = container.findViewById(R.id.code_fifth);
+ this.codes[5] = container.findViewById(R.id.code_sixth);
+ this.codes[6] = container.findViewById(R.id.code_seventh);
+ this.codes[7] = container.findViewById(R.id.code_eighth);
+ this.codes[8] = container.findViewById(R.id.code_ninth);
+ this.codes[9] = container.findViewById(R.id.code_tenth);
+ this.codes[10] = container.findViewById(R.id.code_eleventh);
+ this.codes[11] = container.findViewById(R.id.code_twelth);
+
+ this.qrCode.setOnClickListener(clickListener);
+ this.registerForContextMenu(numbersContainer);
+
+ this.verified.setChecked(getArguments().getBoolean(VERIFIED_STATE, false));
+ this.verified.setOnCheckedChangeListener(this);
+
+ return container;
+ }
+
+ @Override
+ public void onCreate(Bundle bundle) {
+ super.onCreate(bundle);
+
+ RecipientId recipientId = getArguments().getParcelable(RECIPIENT_ID);
+ IdentityKeyParcelable localIdentityParcelable = getArguments().getParcelable(LOCAL_IDENTITY);
+ IdentityKeyParcelable remoteIdentityParcelable = getArguments().getParcelable(REMOTE_IDENTITY);
+
+ if (recipientId == null) throw new AssertionError("RecipientId required");
+ if (localIdentityParcelable == null) throw new AssertionError("local identity required");
+ if (remoteIdentityParcelable == null) throw new AssertionError("remote identity required");
+
+ this.localIdentity = localIdentityParcelable.get();
+ this.recipient = Recipient.live(recipientId);
+ this.remoteIdentity = remoteIdentityParcelable.get();
+
+ int version;
+ byte[] localId;
+ byte[] remoteId;
+
+ //noinspection WrongThread
+ Recipient resolved = recipient.resolve();
+
+ if (FeatureFlags.verifyV2() && resolved.getUuid().isPresent()) {
+ Log.i(TAG, "Using UUID (version 2).");
+ version = 2;
+ localId = UuidUtil.toByteArray(TextSecurePreferences.getLocalUuid(requireContext()));
+ remoteId = UuidUtil.toByteArray(resolved.getUuid().get());
+ } else if (!FeatureFlags.verifyV2() && resolved.getE164().isPresent()) {
+ Log.i(TAG, "Using E164 (version 1).");
+ version = 1;
+ localId = TextSecurePreferences.getLocalNumber(requireContext()).getBytes();
+ remoteId = resolved.requireE164().getBytes();
+ } else {
+ Log.w(TAG, String.format(Locale.ENGLISH, "Could not show proper verification! verifyV2: %s, hasUuid: %s, hasE164: %s", FeatureFlags.verifyV2(), resolved.getUuid().isPresent(), resolved.getE164().isPresent()));
+ new AlertDialog.Builder(requireContext())
+ .setMessage(getString(R.string.VerifyIdentityActivity_you_must_first_exchange_messages_in_order_to_view, resolved.getDisplayName(requireContext())))
+ .setPositiveButton(android.R.string.ok, (dialog, which) -> requireActivity().finish())
+ .setOnDismissListener(dialog -> requireActivity().finish())
+ .show();
+ return;
+ }
+
+ this.recipient.observe(this, this::setRecipientText);
+
+ new AsyncTask() {
+ @Override
+ protected Fingerprint doInBackground(Void... params) {
+ return new NumericFingerprintGenerator(5200).createFor(version,
+ localId, localIdentity,
+ remoteId, remoteIdentity);
+ }
+
+ @Override
+ protected void onPostExecute(Fingerprint fingerprint) {
+ VerifyDisplayFragment.this.fingerprint = fingerprint;
+ setFingerprintViews(fingerprint, true);
+ getActivity().supportInvalidateOptionsMenu();
+ }
+ }.executeOnExecutor(AsyncTask.THREAD_POOL_EXECUTOR);
+
+ setHasOptionsMenu(true);
+ }
+
+ @Override
+ public void onResume() {
+ super.onResume();
+
+ setRecipientText(recipient.get());
+
+ if (fingerprint != null) {
+ setFingerprintViews(fingerprint, false);
+ }
+
+ if (animateSuccessOnDraw) {
+ animateSuccessOnDraw = false;
+ animateVerifiedSuccess();
+ } else if (animateFailureOnDraw) {
+ animateFailureOnDraw = false;
+ animateVerifiedFailure();
+ }
+ }
+
+ @Override
+ public void onCreateContextMenu(ContextMenu menu, View view,
+ ContextMenuInfo menuInfo)
+ {
+ super.onCreateContextMenu(menu, view, menuInfo);
+
+ if (fingerprint != null) {
+ MenuInflater inflater = getActivity().getMenuInflater();
+ inflater.inflate(R.menu.verify_display_fragment_context_menu, menu);
+ }
+ }
+
+ @Override
+ public boolean onContextItemSelected(MenuItem item) {
+ if (fingerprint == null) return super.onContextItemSelected(item);
+
+ switch (item.getItemId()) {
+ case R.id.menu_copy: handleCopyToClipboard(fingerprint, codes.length); return true;
+ case R.id.menu_compare: handleCompareWithClipboard(fingerprint); return true;
+ default: return super.onContextItemSelected(item);
+ }
+ }
+
+ @Override
+ public void onCreateOptionsMenu(Menu menu, MenuInflater inflater) {
+ super.onCreateOptionsMenu(menu, inflater);
+
+ if (fingerprint != null) {
+ inflater.inflate(R.menu.verify_identity, menu);
+ }
+ }
+
+ @Override
+ public boolean onOptionsItemSelected(MenuItem item) {
+ switch (item.getItemId()) {
+ case R.id.verify_identity__share: handleShare(fingerprint, codes.length); return true;
+ }
+
+ return false;
+ }
+
+ public void setScannedFingerprint(String scanned) {
+ try {
+ if (fingerprint.getScannableFingerprint().compareTo(scanned.getBytes("ISO-8859-1"))) {
+ this.animateSuccessOnDraw = true;
+ } else {
+ this.animateFailureOnDraw = true;
+ }
+ } catch (FingerprintVersionMismatchException e) {
+ Log.w(TAG, e);
+ if (e.getOurVersion() < e.getTheirVersion()) {
+ Toast.makeText(getActivity(), R.string.VerifyIdentityActivity_your_contact_is_running_a_newer_version_of_Signal, Toast.LENGTH_LONG).show();
+ } else {
+ Toast.makeText(getActivity(), R.string.VerifyIdentityActivity_your_contact_is_running_an_old_version_of_signal, Toast.LENGTH_LONG).show();
+ }
+ } catch (FingerprintParsingException e) {
+ Log.w(TAG, e);
+ Toast.makeText(getActivity(), R.string.VerifyIdentityActivity_the_scanned_qr_code_is_not_a_correctly_formatted_safety_number, Toast.LENGTH_LONG).show();
+ } catch (UnsupportedEncodingException e) {
+ throw new AssertionError(e);
+ }
+ }
+
+ public void setClickListener(View.OnClickListener listener) {
+ this.clickListener = listener;
+ }
+
+ private @NonNull String getFormattedSafetyNumbers(@NonNull Fingerprint fingerprint, int segmentCount) {
+ String[] segments = getSegments(fingerprint, segmentCount);
+ StringBuilder result = new StringBuilder();
+
+ for (int i = 0; i < segments.length; i++) {
+ result.append(segments[i]);
+
+ if (i != segments.length - 1) {
+ if (((i+1) % 4) == 0) result.append('\n');
+ else result.append(' ');
+ }
+ }
+
+ return result.toString();
+ }
+
+ private void handleCopyToClipboard(Fingerprint fingerprint, int segmentCount) {
+ Util.writeTextToClipboard(getActivity(), getFormattedSafetyNumbers(fingerprint, segmentCount));
+ }
+
+ private void handleCompareWithClipboard(Fingerprint fingerprint) {
+ String clipboardData = Util.readTextFromClipboard(getActivity());
+
+ if (clipboardData == null) {
+ Toast.makeText(getActivity(), R.string.VerifyIdentityActivity_no_safety_number_to_compare_was_found_in_the_clipboard, Toast.LENGTH_LONG).show();
+ return;
+ }
+
+ String numericClipboardData = clipboardData.replaceAll("\\D", "");
+
+ if (TextUtils.isEmpty(numericClipboardData) || numericClipboardData.length() != 60) {
+ Toast.makeText(getActivity(), R.string.VerifyIdentityActivity_no_safety_number_to_compare_was_found_in_the_clipboard, Toast.LENGTH_LONG).show();
+ return;
+ }
+
+ if (fingerprint.getDisplayableFingerprint().getDisplayText().equals(numericClipboardData)) {
+ animateVerifiedSuccess();
+ } else {
+ animateVerifiedFailure();
+ }
+ }
+
+ private void handleShare(@NonNull Fingerprint fingerprint, int segmentCount) {
+ String shareString =
+ getString(R.string.VerifyIdentityActivity_our_signal_safety_number) + "\n" +
+ getFormattedSafetyNumbers(fingerprint, segmentCount) + "\n";
+
+ Intent intent = new Intent();
+ intent.setAction(Intent.ACTION_SEND);
+ intent.putExtra(Intent.EXTRA_TEXT, shareString);
+ intent.setType("text/plain");
+
+ try {
+ startActivity(Intent.createChooser(intent, getString(R.string.VerifyIdentityActivity_share_safety_number_via)));
+ } catch (ActivityNotFoundException e) {
+ Toast.makeText(getActivity(), R.string.VerifyIdentityActivity_no_app_to_share_to, Toast.LENGTH_LONG).show();
+ }
+ }
+
+ private void setRecipientText(Recipient recipient) {
+ description.setText(Html.fromHtml(String.format(getActivity().getString(R.string.verify_display_fragment__if_you_wish_to_verify_the_security_of_your_end_to_end_encryption_with_s), recipient.getDisplayName(getContext()))));
+ description.setMovementMethod(LinkMovementMethod.getInstance());
+ }
+
+ private void setFingerprintViews(Fingerprint fingerprint, boolean animate) {
+ String[] segments = getSegments(fingerprint, codes.length);
+
+ for (int i=0;i() {
+ public Integer evaluate(float fraction, Integer startValue, Integer endValue) {
+ return Math.round(startValue + (endValue - startValue) * fraction);
+ }
+ });
+
+ valueAnimator.setDuration(1000);
+ valueAnimator.start();
+ }
+
+ private String[] getSegments(Fingerprint fingerprint, int segmentCount) {
+ String[] segments = new String[segmentCount];
+ String digits = fingerprint.getDisplayableFingerprint().getDisplayText();
+ int partSize = digits.length() / segmentCount;
+
+ for (int i=0;i {
+ try (SignalSessionLock.Lock unused = DatabaseSessionLock.INSTANCE.acquire()) {
+ if (isChecked) {
+ Log.i(TAG, "Saving identity: " + recipientId);
+ DatabaseFactory.getIdentityDatabase(getActivity())
+ .saveIdentity(recipientId,
+ remoteIdentity,
+ VerifiedStatus.VERIFIED, false,
+ System.currentTimeMillis(), true);
+ } else {
+ DatabaseFactory.getIdentityDatabase(getActivity())
+ .setVerified(recipientId,
+ remoteIdentity,
+ VerifiedStatus.DEFAULT);
+ }
+
+ ApplicationDependencies.getJobManager()
+ .add(new MultiDeviceVerifiedUpdateJob(recipientId,
+ remoteIdentity,
+ isChecked ? VerifiedStatus.VERIFIED
+ : VerifiedStatus.DEFAULT));
+ StorageSyncHelper.scheduleSyncForDataChange();
+
+ IdentityUtil.markIdentityVerified(getActivity(), recipient, isChecked, false);
+ }
+ });
+ }
+ }
+
+ public static class VerifyScanFragment extends Fragment {
+
+ private View container;
+ private CameraView cameraView;
+ private ScanningThread scanningThread;
+ private ScanListener scanListener;
+
+ public View onCreateView(@NonNull LayoutInflater inflater, ViewGroup viewGroup, Bundle bundle) {
+ this.container = ViewUtil.inflate(inflater, viewGroup, R.layout.verify_scan_fragment);
+ this.cameraView = container.findViewById(R.id.scanner);
+
+ return container;
+ }
+
+ @Override
+ public void onResume() {
+ super.onResume();
+ this.scanningThread = new ScanningThread();
+ this.scanningThread.setScanListener(scanListener);
+ this.scanningThread.setCharacterSet("ISO-8859-1");
+ this.cameraView.onResume();
+ this.cameraView.setPreviewCallback(scanningThread);
+ this.scanningThread.start();
+ }
+
+ @Override
+ public void onPause() {
+ super.onPause();
+ this.cameraView.onPause();
+ this.scanningThread.stopScanning();
+ }
+
+ @Override
+ public void onConfigurationChanged(Configuration newConfiguration) {
+ super.onConfigurationChanged(newConfiguration);
+ this.cameraView.onPause();
+ this.cameraView.onResume();
+ this.cameraView.setPreviewCallback(scanningThread);
+ }
+
+ public void setScanListener(ScanListener listener) {
+ if (this.scanningThread != null) scanningThread.setScanListener(listener);
+ this.scanListener = listener;
+ }
+
+ }
+
+}
diff --git a/app/src/main/java/org/thoughtcrime/securesms/WebRtcCallActivity.java b/app/src/main/java/org/thoughtcrime/securesms/WebRtcCallActivity.java
new file mode 100644
index 00000000..da14a2b9
--- /dev/null
+++ b/app/src/main/java/org/thoughtcrime/securesms/WebRtcCallActivity.java
@@ -0,0 +1,749 @@
+/*
+ * Copyright (C) 2016 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 .
+ */
+
+package org.thoughtcrime.securesms;
+
+import android.Manifest;
+import android.app.PictureInPictureParams;
+import android.content.Context;
+import android.content.Intent;
+import android.content.pm.PackageManager;
+import android.content.res.Configuration;
+import android.media.AudioManager;
+import android.os.Build;
+import android.os.Bundle;
+import android.util.Rational;
+import android.view.Window;
+import android.view.WindowManager;
+
+import androidx.annotation.NonNull;
+import androidx.appcompat.app.AlertDialog;
+import androidx.appcompat.app.AppCompatDelegate;
+import androidx.core.content.ContextCompat;
+import androidx.lifecycle.ViewModelProviders;
+
+import org.greenrobot.eventbus.EventBus;
+import org.greenrobot.eventbus.Subscribe;
+import org.greenrobot.eventbus.ThreadMode;
+import org.signal.core.util.logging.Log;
+import org.thoughtcrime.securesms.components.TooltipPopup;
+import org.thoughtcrime.securesms.components.webrtc.CallParticipantsListUpdatePopupWindow;
+import org.thoughtcrime.securesms.components.webrtc.CallParticipantsState;
+import org.thoughtcrime.securesms.components.sensors.DeviceOrientationMonitor;
+import org.thoughtcrime.securesms.components.webrtc.GroupCallSafetyNumberChangeNotificationUtil;
+import org.thoughtcrime.securesms.components.webrtc.WebRtcAudioOutput;
+import org.thoughtcrime.securesms.components.webrtc.WebRtcCallView;
+import org.thoughtcrime.securesms.components.webrtc.WebRtcCallViewModel;
+import org.thoughtcrime.securesms.components.webrtc.participantslist.CallParticipantsListDialog;
+import org.thoughtcrime.securesms.conversation.ui.error.SafetyNumberChangeDialog;
+import org.thoughtcrime.securesms.events.WebRtcViewModel;
+import org.thoughtcrime.securesms.messagerequests.CalleeMustAcceptMessageRequestActivity;
+import org.thoughtcrime.securesms.permissions.Permissions;
+import org.thoughtcrime.securesms.recipients.Recipient;
+import org.thoughtcrime.securesms.recipients.RecipientId;
+import org.thoughtcrime.securesms.ringrtc.RemotePeer;
+import org.thoughtcrime.securesms.service.WebRtcCallService;
+import org.thoughtcrime.securesms.sms.MessageSender;
+import org.thoughtcrime.securesms.util.EllapsedTimeFormatter;
+import org.thoughtcrime.securesms.util.FullscreenHelper;
+import org.thoughtcrime.securesms.util.TextSecurePreferences;
+import org.thoughtcrime.securesms.util.Util;
+import org.whispersystems.libsignal.IdentityKey;
+import org.whispersystems.signalservice.api.messages.calls.HangupMessage;
+import org.whispersystems.signalservice.api.messages.calls.OfferMessage;
+
+import java.util.List;
+
+public class WebRtcCallActivity extends BaseActivity implements SafetyNumberChangeDialog.Callback {
+
+ private static final String TAG = Log.tag(WebRtcCallActivity.class);
+
+ private static final int STANDARD_DELAY_FINISH = 1000;
+
+ public static final String ANSWER_ACTION = WebRtcCallActivity.class.getCanonicalName() + ".ANSWER_ACTION";
+ public static final String DENY_ACTION = WebRtcCallActivity.class.getCanonicalName() + ".DENY_ACTION";
+ public static final String END_CALL_ACTION = WebRtcCallActivity.class.getCanonicalName() + ".END_CALL_ACTION";
+
+ public static final String EXTRA_ENABLE_VIDEO_IF_AVAILABLE = WebRtcCallActivity.class.getCanonicalName() + ".ENABLE_VIDEO_IF_AVAILABLE";
+
+ private CallParticipantsListUpdatePopupWindow participantUpdateWindow;
+ private DeviceOrientationMonitor deviceOrientationMonitor;
+
+ private FullscreenHelper fullscreenHelper;
+ private WebRtcCallView callScreen;
+ private TooltipPopup videoTooltip;
+ private WebRtcCallViewModel viewModel;
+ private boolean enableVideoIfAvailable;
+
+ @Override
+ protected void attachBaseContext(@NonNull Context newBase) {
+ getDelegate().setLocalNightMode(AppCompatDelegate.MODE_NIGHT_YES);
+ super.attachBaseContext(newBase);
+ }
+
+ @Override
+ public void onCreate(Bundle savedInstanceState) {
+ Log.i(TAG, "onCreate()");
+ getWindow().addFlags(WindowManager.LayoutParams.FLAG_SHOW_WHEN_LOCKED);
+ getWindow().addFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON);
+ super.onCreate(savedInstanceState);
+
+ requestWindowFeature(Window.FEATURE_NO_TITLE);
+ setContentView(R.layout.webrtc_call_activity);
+
+ fullscreenHelper = new FullscreenHelper(this);
+
+ setVolumeControlStream(AudioManager.STREAM_VOICE_CALL);
+
+ initializeResources();
+ initializeViewModel();
+
+ processIntent(getIntent());
+
+ enableVideoIfAvailable = getIntent().getBooleanExtra(EXTRA_ENABLE_VIDEO_IF_AVAILABLE, false);
+ getIntent().removeExtra(EXTRA_ENABLE_VIDEO_IF_AVAILABLE);
+ }
+
+ @Override
+ public void onResume() {
+ Log.i(TAG, "onResume()");
+ super.onResume();
+ initializeScreenshotSecurity();
+
+ if (!EventBus.getDefault().isRegistered(this)) {
+ EventBus.getDefault().register(this);
+ }
+ }
+
+ @Override
+ public void onNewIntent(Intent intent) {
+ Log.i(TAG, "onNewIntent");
+ super.onNewIntent(intent);
+ processIntent(intent);
+ }
+
+ @Override
+ public void onPause() {
+ Log.i(TAG, "onPause");
+ super.onPause();
+
+ if (!isInPipMode()) {
+ EventBus.getDefault().unregister(this);
+ }
+
+ if (!viewModel.isCallStarting()) {
+ CallParticipantsState state = viewModel.getCallParticipantsState().getValue();
+ if (state != null && state.getCallState().isPreJoinOrNetworkUnavailable()) {
+ finish();
+ }
+ }
+ }
+
+ @Override
+ protected void onStop() {
+ Log.i(TAG, "onStop");
+ super.onStop();
+
+ EventBus.getDefault().unregister(this);
+
+ if (!viewModel.isCallStarting()) {
+ CallParticipantsState state = viewModel.getCallParticipantsState().getValue();
+ if (state != null && state.getCallState().isPreJoinOrNetworkUnavailable()) {
+ Intent intent = new Intent(this, WebRtcCallService.class);
+ intent.setAction(WebRtcCallService.ACTION_CANCEL_PRE_JOIN_CALL);
+ startService(intent);
+ }
+ }
+ }
+
+ @Override
+ public void onRequestPermissionsResult(int requestCode, @NonNull String[] permissions, @NonNull int[] grantResults) {
+ Permissions.onRequestPermissionsResult(this, requestCode, permissions, grantResults);
+ }
+
+ @Override
+ protected void onUserLeaveHint() {
+ enterPipModeIfPossible();
+ }
+
+ @Override
+ public void onBackPressed() {
+ if (!enterPipModeIfPossible()) {
+ super.onBackPressed();
+ }
+ }
+
+ @Override
+ public void onPictureInPictureModeChanged(boolean isInPictureInPictureMode, Configuration newConfig) {
+ viewModel.setIsInPipMode(isInPictureInPictureMode);
+ participantUpdateWindow.setEnabled(!isInPictureInPictureMode);
+ }
+
+ private boolean enterPipModeIfPossible() {
+ if (viewModel.canEnterPipMode() && isSystemPipEnabledAndAvailable()) {
+ PictureInPictureParams params = new PictureInPictureParams.Builder()
+ .setAspectRatio(new Rational(9, 16))
+ .build();
+ enterPictureInPictureMode(params);
+ CallParticipantsListDialog.dismiss(getSupportFragmentManager());
+
+ return true;
+ }
+ return false;
+ }
+
+ private boolean isInPipMode() {
+ return isSystemPipEnabledAndAvailable() && isInPictureInPictureMode();
+ }
+
+ private void processIntent(@NonNull Intent intent) {
+ if (ANSWER_ACTION.equals(intent.getAction())) {
+ viewModel.setRecipient(EventBus.getDefault().getStickyEvent(WebRtcViewModel.class).getRecipient());
+ handleAnswerWithAudio();
+ } else if (DENY_ACTION.equals(intent.getAction())) {
+ handleDenyCall();
+ } else if (END_CALL_ACTION.equals(intent.getAction())) {
+ handleEndCall();
+ }
+ }
+
+ private void initializeScreenshotSecurity() {
+ if (TextSecurePreferences.isScreenSecurityEnabled(this)) {
+ getWindow().addFlags(WindowManager.LayoutParams.FLAG_SECURE);
+ } else {
+ getWindow().clearFlags(WindowManager.LayoutParams.FLAG_SECURE);
+ }
+ }
+
+ private void initializeResources() {
+ callScreen = findViewById(R.id.callScreen);
+ callScreen.setControlsListener(new ControlsListener());
+
+ participantUpdateWindow = new CallParticipantsListUpdatePopupWindow(callScreen);
+ }
+
+ private void initializeViewModel() {
+ deviceOrientationMonitor = new DeviceOrientationMonitor(this);
+ getLifecycle().addObserver(deviceOrientationMonitor);
+
+ WebRtcCallViewModel.Factory factory = new WebRtcCallViewModel.Factory(deviceOrientationMonitor);
+
+ viewModel = ViewModelProviders.of(this, factory).get(WebRtcCallViewModel.class);
+ viewModel.setIsInPipMode(isInPipMode());
+ viewModel.getMicrophoneEnabled().observe(this, callScreen::setMicEnabled);
+ viewModel.getWebRtcControls().observe(this, callScreen::setWebRtcControls);
+ viewModel.getEvents().observe(this, this::handleViewModelEvent);
+ viewModel.getCallTime().observe(this, this::handleCallTime);
+ viewModel.getCallParticipantsState().observe(this, callScreen::updateCallParticipants);
+ viewModel.getCallParticipantListUpdate().observe(this, participantUpdateWindow::addCallParticipantListUpdate);
+ viewModel.getSafetyNumberChangeEvent().observe(this, this::handleSafetyNumberChangeEvent);
+ viewModel.getGroupMembers().observe(this, unused -> updateGroupMembersForGroupCall());
+ viewModel.shouldShowSpeakerHint().observe(this, this::updateSpeakerHint);
+
+ callScreen.getViewTreeObserver().addOnGlobalLayoutListener(() -> {
+ CallParticipantsState state = viewModel.getCallParticipantsState().getValue();
+ if (state != null) {
+ if (state.needsNewRequestSizes()) {
+ Intent intent = new Intent(WebRtcCallActivity.this, WebRtcCallService.class);
+ intent.setAction(WebRtcCallService.ACTION_GROUP_UPDATE_RENDERED_RESOLUTIONS);
+ startService(intent);
+ }
+ }
+ });
+
+ viewModel.getOrientation().observe(this, orientation -> {
+ Intent intent = new Intent(this, WebRtcCallService.class);
+ intent.setAction(WebRtcCallService.ACTION_ORIENTATION_CHANGED)
+ .putExtra(WebRtcCallService.EXTRA_ORIENTATION_DEGREES, orientation.getDegrees());
+
+ startService(intent);
+
+ switch (orientation) {
+ case LANDSCAPE_LEFT_EDGE:
+ callScreen.rotateControls(90);
+ break;
+ case LANDSCAPE_RIGHT_EDGE:
+ callScreen.rotateControls(-90);
+ break;
+ case PORTRAIT_BOTTOM_EDGE:
+ callScreen.rotateControls(0);
+ }
+ });
+ }
+
+ private void handleViewModelEvent(@NonNull WebRtcCallViewModel.Event event) {
+ if (event instanceof WebRtcCallViewModel.Event.StartCall) {
+ startCall(((WebRtcCallViewModel.Event.StartCall)event).isVideoCall());
+ return;
+ } else if (event instanceof WebRtcCallViewModel.Event.ShowGroupCallSafetyNumberChange) {
+ SafetyNumberChangeDialog.showForGroupCall(getSupportFragmentManager(), ((WebRtcCallViewModel.Event.ShowGroupCallSafetyNumberChange) event).getIdentityRecords());
+ return;
+ }
+
+ if (isInPipMode()) {
+ return;
+ }
+
+ if (event instanceof WebRtcCallViewModel.Event.ShowVideoTooltip) {
+ if (videoTooltip == null) {
+ videoTooltip = TooltipPopup.forTarget(callScreen.getVideoTooltipTarget())
+ .setBackgroundTint(ContextCompat.getColor(this, R.color.core_ultramarine))
+ .setTextColor(ContextCompat.getColor(this, R.color.core_white))
+ .setText(R.string.WebRtcCallActivity__tap_here_to_turn_on_your_video)
+ .setOnDismissListener(() -> viewModel.onDismissedVideoTooltip())
+ .show(TooltipPopup.POSITION_ABOVE);
+ return;
+ }
+ } else if (event instanceof WebRtcCallViewModel.Event.DismissVideoTooltip) {
+ if (videoTooltip != null) {
+ videoTooltip.dismiss();
+ videoTooltip = null;
+ }
+ } else {
+ throw new IllegalArgumentException("Unknown event: " + event);
+ }
+ }
+
+ private void handleCallTime(long callTime) {
+ EllapsedTimeFormatter ellapsedTimeFormatter = EllapsedTimeFormatter.fromDurationMillis(callTime);
+
+ if (ellapsedTimeFormatter == null) {
+ return;
+ }
+
+ callScreen.setStatus(getString(R.string.WebRtcCallActivity__signal_s, ellapsedTimeFormatter.toString()));
+ }
+
+ private void handleSetAudioHandset() {
+ Intent intent = new Intent(this, WebRtcCallService.class);
+ intent.setAction(WebRtcCallService.ACTION_SET_AUDIO_SPEAKER);
+ startService(intent);
+ }
+
+ private void handleSetAudioSpeaker() {
+ Intent intent = new Intent(this, WebRtcCallService.class);
+ intent.setAction(WebRtcCallService.ACTION_SET_AUDIO_SPEAKER);
+ intent.putExtra(WebRtcCallService.EXTRA_SPEAKER, true);
+ startService(intent);
+ }
+
+ private void handleSetAudioBluetooth() {
+ Intent intent = new Intent(this, WebRtcCallService.class);
+ intent.setAction(WebRtcCallService.ACTION_SET_AUDIO_BLUETOOTH);
+ intent.putExtra(WebRtcCallService.EXTRA_BLUETOOTH, true);
+ startService(intent);
+ }
+
+ private void handleSetMuteAudio(boolean enabled) {
+ Intent intent = new Intent(this, WebRtcCallService.class);
+ intent.setAction(WebRtcCallService.ACTION_SET_MUTE_AUDIO);
+ intent.putExtra(WebRtcCallService.EXTRA_MUTE, enabled);
+ startService(intent);
+ }
+
+ private void handleSetMuteVideo(boolean muted) {
+ Recipient recipient = viewModel.getRecipient().get();
+
+ if (!recipient.equals(Recipient.UNKNOWN)) {
+ String recipientDisplayName = recipient.getDisplayName(this);
+
+ Permissions.with(this)
+ .request(Manifest.permission.CAMERA)
+ .ifNecessary()
+ .withRationaleDialog(getString(R.string.WebRtcCallActivity__to_call_s_signal_needs_access_to_your_camera, recipientDisplayName), R.drawable.ic_video_solid_24_tinted)
+ .withPermanentDenialDialog(getString(R.string.WebRtcCallActivity__to_call_s_signal_needs_access_to_your_camera, recipientDisplayName))
+ .onAllGranted(() -> {
+ Intent intent = new Intent(this, WebRtcCallService.class);
+ intent.setAction(WebRtcCallService.ACTION_SET_ENABLE_VIDEO);
+ intent.putExtra(WebRtcCallService.EXTRA_ENABLE, !muted);
+ startService(intent);
+ })
+ .execute();
+ }
+ }
+
+ private void handleFlipCamera() {
+ Intent intent = new Intent(this, WebRtcCallService.class);
+ intent.setAction(WebRtcCallService.ACTION_FLIP_CAMERA);
+ startService(intent);
+ }
+
+ private void handleAnswerWithAudio() {
+ Recipient recipient = viewModel.getRecipient().get();
+
+ if (!recipient.equals(Recipient.UNKNOWN)) {
+ Permissions.with(this)
+ .request(Manifest.permission.RECORD_AUDIO)
+ .ifNecessary()
+ .withRationaleDialog(getString(R.string.WebRtcCallActivity_to_answer_the_call_from_s_give_signal_access_to_your_microphone, recipient.getDisplayName(this)),
+ R.drawable.ic_mic_solid_24)
+ .withPermanentDenialDialog(getString(R.string.WebRtcCallActivity_signal_requires_microphone_and_camera_permissions_in_order_to_make_or_receive_calls))
+ .onAllGranted(() -> {
+ callScreen.setRecipient(recipient);
+ callScreen.setStatus(getString(R.string.RedPhone_answering));
+
+ Intent intent = new Intent(this, WebRtcCallService.class);
+ intent.setAction(WebRtcCallService.ACTION_ACCEPT_CALL);
+ startService(intent);
+ })
+ .onAnyDenied(this::handleDenyCall)
+ .execute();
+ }
+ }
+
+ private void handleAnswerWithVideo() {
+ Recipient recipient = viewModel.getRecipient().get();
+
+ if (!recipient.equals(Recipient.UNKNOWN)) {
+ Permissions.with(this)
+ .request(Manifest.permission.RECORD_AUDIO, Manifest.permission.CAMERA)
+ .ifNecessary()
+ .withRationaleDialog(getString(R.string.WebRtcCallActivity_to_answer_the_call_from_s_give_signal_access_to_your_microphone, recipient.getDisplayName(this)),
+ R.drawable.ic_mic_solid_24, R.drawable.ic_video_solid_24_tinted)
+ .withPermanentDenialDialog(getString(R.string.WebRtcCallActivity_signal_requires_microphone_and_camera_permissions_in_order_to_make_or_receive_calls))
+ .onAllGranted(() -> {
+ callScreen.setRecipient(recipient);
+ callScreen.setStatus(getString(R.string.RedPhone_answering));
+
+ Intent intent = new Intent(this, WebRtcCallService.class);
+ intent.setAction(WebRtcCallService.ACTION_ACCEPT_CALL);
+ intent.putExtra(WebRtcCallService.EXTRA_ANSWER_WITH_VIDEO, true);
+ startService(intent);
+
+ handleSetMuteVideo(false);
+ })
+ .onAnyDenied(this::handleDenyCall)
+ .execute();
+ }
+ }
+
+ private void handleDenyCall() {
+ Recipient recipient = viewModel.getRecipient().get();
+
+ if (!recipient.equals(Recipient.UNKNOWN)) {
+ Intent intent = new Intent(this, WebRtcCallService.class);
+ intent.setAction(WebRtcCallService.ACTION_DENY_CALL);
+ startService(intent);
+
+ callScreen.setRecipient(recipient);
+ callScreen.setStatus(getString(R.string.RedPhone_ending_call));
+ delayedFinish();
+ }
+ }
+
+ private void handleEndCall() {
+ Log.i(TAG, "Hangup pressed, handling termination now...");
+ Intent intent = new Intent(WebRtcCallActivity.this, WebRtcCallService.class);
+ intent.setAction(WebRtcCallService.ACTION_LOCAL_HANGUP);
+ startService(intent);
+ }
+
+ private void handleOutgoingCall(@NonNull WebRtcViewModel event) {
+ if (event.getGroupState().isNotIdle()) {
+ callScreen.setStatusFromGroupCallState(event.getGroupState());
+ } else {
+ callScreen.setStatus(getString(R.string.WebRtcCallActivity__calling));
+ }
+ }
+
+ private void handleTerminate(@NonNull Recipient recipient, @NonNull HangupMessage.Type hangupType) {
+ Log.i(TAG, "handleTerminate called: " + hangupType.name());
+
+ callScreen.setStatusFromHangupType(hangupType);
+
+ EventBus.getDefault().removeStickyEvent(WebRtcViewModel.class);
+
+ if (hangupType == HangupMessage.Type.NEED_PERMISSION) {
+ startActivity(CalleeMustAcceptMessageRequestActivity.createIntent(this, recipient.getId()));
+ }
+ delayedFinish();
+ }
+
+ private void handleCallRinging() {
+ callScreen.setStatus(getString(R.string.RedPhone_ringing));
+ }
+
+ private void handleCallBusy() {
+ EventBus.getDefault().removeStickyEvent(WebRtcViewModel.class);
+ callScreen.setStatus(getString(R.string.RedPhone_busy));
+ delayedFinish(WebRtcCallService.BUSY_TONE_LENGTH);
+ }
+
+ private void handleCallConnected(@NonNull WebRtcViewModel event) {
+ getWindow().addFlags(WindowManager.LayoutParams.FLAG_IGNORE_CHEEK_PRESSES);
+ if (event.getGroupState().isNotIdleOrConnected()) {
+ callScreen.setStatusFromGroupCallState(event.getGroupState());
+ }
+ }
+
+ private void handleRecipientUnavailable() {
+ EventBus.getDefault().removeStickyEvent(WebRtcViewModel.class);
+ callScreen.setStatus(getString(R.string.RedPhone_recipient_unavailable));
+ delayedFinish();
+ }
+
+ private void handleServerFailure() {
+ EventBus.getDefault().removeStickyEvent(WebRtcViewModel.class);
+ callScreen.setStatus(getString(R.string.RedPhone_network_failed));
+ }
+
+ private void handleNoSuchUser(final @NonNull WebRtcViewModel event) {
+ if (isFinishing()) return; // XXX Stuart added this check above, not sure why, so I'm repeating in ignorance. - moxie
+ new AlertDialog.Builder(this)
+ .setTitle(R.string.RedPhone_number_not_registered)
+ .setIcon(R.drawable.ic_warning)
+ .setMessage(R.string.RedPhone_the_number_you_dialed_does_not_support_secure_voice)
+ .setCancelable(true)
+ .setPositiveButton(R.string.RedPhone_got_it, (d, w) -> handleTerminate(event.getRecipient(), HangupMessage.Type.NORMAL))
+ .setOnCancelListener(d -> handleTerminate(event.getRecipient(), HangupMessage.Type.NORMAL))
+ .show();
+ }
+
+ private void handleUntrustedIdentity(@NonNull WebRtcViewModel event) {
+ final IdentityKey theirKey = event.getRemoteParticipants().get(0).getIdentityKey();
+ final Recipient recipient = event.getRemoteParticipants().get(0).getRecipient();
+
+ if (theirKey == null) {
+ Log.w(TAG, "Untrusted identity without an identity key, terminating call.");
+ handleTerminate(recipient, HangupMessage.Type.NORMAL);
+ }
+
+ SafetyNumberChangeDialog.showForCall(getSupportFragmentManager(), recipient.getId());
+ }
+
+ public void handleSafetyNumberChangeEvent(@NonNull WebRtcCallViewModel.SafetyNumberChangeEvent safetyNumberChangeEvent) {
+ if (Util.hasItems(safetyNumberChangeEvent.getRecipientIds())) {
+ if (safetyNumberChangeEvent.isInPipMode()) {
+ GroupCallSafetyNumberChangeNotificationUtil.showNotification(this, viewModel.getRecipient().get());
+ } else {
+ GroupCallSafetyNumberChangeNotificationUtil.cancelNotification(this, viewModel.getRecipient().get());
+ SafetyNumberChangeDialog.showForDuringGroupCall(getSupportFragmentManager(), safetyNumberChangeEvent.getRecipientIds());
+ }
+ }
+ }
+
+ private void updateGroupMembersForGroupCall() {
+ startService(new Intent(this, WebRtcCallService.class).setAction(WebRtcCallService.ACTION_GROUP_REQUEST_UPDATE_MEMBERS));
+ }
+
+ private void updateSpeakerHint(boolean showSpeakerHint) {
+ if (showSpeakerHint) {
+ callScreen.showSpeakerViewHint();
+ } else {
+ callScreen.hideSpeakerViewHint();
+ }
+ }
+
+ @Override
+ public void onSendAnywayAfterSafetyNumberChange(@NonNull List changedRecipients) {
+ CallParticipantsState state = viewModel.getCallParticipantsState().getValue();
+ if (state.getGroupCallState().isConnected()) {
+ Intent intent = new Intent(this, WebRtcCallService.class);
+ intent.setAction(WebRtcCallService.ACTION_GROUP_APPROVE_SAFETY_CHANGE)
+ .putExtra(WebRtcCallService.EXTRA_RECIPIENT_IDS, RecipientId.toSerializedList(changedRecipients));
+ startService(intent);
+ } else {
+ viewModel.startCall(state.getLocalParticipant().isVideoEnabled());
+ }
+ }
+
+ @Override
+ public void onMessageResentAfterSafetyNumberChange() { }
+
+ @Override
+ public void onCanceled() {
+ CallParticipantsState state = viewModel.getCallParticipantsState().getValue();
+ if (state != null && state.getGroupCallState().isNotIdle()) {
+ if (state.getCallState().isPreJoinOrNetworkUnavailable()) {
+ Intent intent = new Intent(this, WebRtcCallService.class);
+ intent.setAction(WebRtcCallService.ACTION_CANCEL_PRE_JOIN_CALL);
+ startService(intent);
+ finish();
+ } else {
+ handleEndCall();
+ }
+ } else {
+ handleTerminate(viewModel.getRecipient().get(), HangupMessage.Type.NORMAL);
+ }
+ }
+
+ private boolean isSystemPipEnabledAndAvailable() {
+ return Build.VERSION.SDK_INT >= 26 &&
+ getPackageManager().hasSystemFeature(PackageManager.FEATURE_PICTURE_IN_PICTURE);
+ }
+
+ private void delayedFinish() {
+ delayedFinish(STANDARD_DELAY_FINISH);
+ }
+
+ private void delayedFinish(int delayMillis) {
+ callScreen.postDelayed(WebRtcCallActivity.this::finish, delayMillis);
+ }
+
+ @Subscribe(sticky = true, threadMode = ThreadMode.MAIN)
+ public void onEventMainThread(@NonNull WebRtcViewModel event) {
+ Log.i(TAG, "Got message from service: " + event);
+
+ viewModel.setRecipient(event.getRecipient());
+ callScreen.setRecipient(event.getRecipient());
+
+ switch (event.getState()) {
+ case CALL_PRE_JOIN: handleCallPreJoin(event); break;
+ case CALL_CONNECTED: handleCallConnected(event); break;
+ case NETWORK_FAILURE: handleServerFailure(); break;
+ case CALL_RINGING: handleCallRinging(); break;
+ case CALL_DISCONNECTED: handleTerminate(event.getRecipient(), HangupMessage.Type.NORMAL); break;
+ case CALL_ACCEPTED_ELSEWHERE: handleTerminate(event.getRecipient(), HangupMessage.Type.ACCEPTED); break;
+ case CALL_DECLINED_ELSEWHERE: handleTerminate(event.getRecipient(), HangupMessage.Type.DECLINED); break;
+ case CALL_ONGOING_ELSEWHERE: handleTerminate(event.getRecipient(), HangupMessage.Type.BUSY); break;
+ case CALL_NEEDS_PERMISSION: handleTerminate(event.getRecipient(), HangupMessage.Type.NEED_PERMISSION); break;
+ case NO_SUCH_USER: handleNoSuchUser(event); break;
+ case RECIPIENT_UNAVAILABLE: handleRecipientUnavailable(); break;
+ case CALL_OUTGOING: handleOutgoingCall(event); break;
+ case CALL_BUSY: handleCallBusy(); break;
+ case UNTRUSTED_IDENTITY: handleUntrustedIdentity(event); break;
+ }
+
+ boolean enableVideo = event.getLocalParticipant().getCameraState().getCameraCount() > 0 && enableVideoIfAvailable;
+
+ viewModel.updateFromWebRtcViewModel(event, enableVideo);
+
+ if (enableVideo) {
+ enableVideoIfAvailable = false;
+ handleSetMuteVideo(false);
+ }
+ }
+
+ private void handleCallPreJoin(@NonNull WebRtcViewModel event) {
+ if (event.getGroupState().isNotIdle()) {
+ callScreen.setStatusFromGroupCallState(event.getGroupState());
+ }
+ }
+
+ private void startCall(boolean isVideoCall) {
+ enableVideoIfAvailable = isVideoCall;
+
+ Intent intent = new Intent(WebRtcCallActivity.this, WebRtcCallService.class);
+ intent.setAction(WebRtcCallService.ACTION_OUTGOING_CALL)
+ .putExtra(WebRtcCallService.EXTRA_REMOTE_PEER, new RemotePeer(viewModel.getRecipient().getId()))
+ .putExtra(WebRtcCallService.EXTRA_OFFER_TYPE, (isVideoCall ? OfferMessage.Type.VIDEO_CALL : OfferMessage.Type.AUDIO_CALL).getCode());
+ startService(intent);
+
+ MessageSender.onMessageSent();
+ }
+
+ private final class ControlsListener implements WebRtcCallView.ControlsListener {
+
+ @Override
+ public void onStartCall(boolean isVideoCall) {
+ viewModel.startCall(isVideoCall);
+ }
+
+ @Override
+ public void onCancelStartCall() {
+ finish();
+ }
+
+ @Override
+ public void onControlsFadeOut() {
+ if (videoTooltip != null) {
+ videoTooltip.dismiss();
+ }
+ }
+
+ @Override
+ public void showSystemUI() {
+ fullscreenHelper.showSystemUI();
+ }
+
+ @Override
+ public void hideSystemUI() {
+ fullscreenHelper.hideSystemUI();
+ }
+
+ @Override
+ public void onAudioOutputChanged(@NonNull WebRtcAudioOutput audioOutput) {
+ switch (audioOutput) {
+ case HANDSET:
+ handleSetAudioHandset();
+ break;
+ case HEADSET:
+ handleSetAudioBluetooth();
+ break;
+ case SPEAKER:
+ handleSetAudioSpeaker();
+ break;
+ default:
+ throw new IllegalStateException("Unknown output: " + audioOutput);
+ }
+ }
+
+ @Override
+ public void onVideoChanged(boolean isVideoEnabled) {
+ handleSetMuteVideo(!isVideoEnabled);
+ }
+
+ @Override
+ public void onMicChanged(boolean isMicEnabled) {
+ handleSetMuteAudio(!isMicEnabled);
+ }
+
+ @Override
+ public void onCameraDirectionChanged() {
+ handleFlipCamera();
+ }
+
+ @Override
+ public void onEndCallPressed() {
+ handleEndCall();
+ }
+
+ @Override
+ public void onDenyCallPressed() {
+ handleDenyCall();
+ }
+
+ @Override
+ public void onAcceptCallWithVoiceOnlyPressed() {
+ handleAnswerWithAudio();
+ }
+
+ @Override
+ public void onAcceptCallPressed() {
+ if (viewModel.isAnswerWithVideoAvailable()) {
+ handleAnswerWithVideo();
+ } else {
+ handleAnswerWithAudio();
+ }
+ }
+
+ @Override
+ public void onShowParticipantsList() {
+ CallParticipantsListDialog.show(getSupportFragmentManager());
+ }
+
+ @Override
+ public void onPageChanged(@NonNull CallParticipantsState.SelectedPage page) {
+ viewModel.setIsViewingFocusedParticipant(page);
+ }
+
+ @Override
+ public void onLocalPictureInPictureClicked() {
+ viewModel.onLocalPictureInPictureClicked();
+ }
+ }
+}
diff --git a/app/src/main/java/org/thoughtcrime/securesms/animation/AnimationCompleteListener.java b/app/src/main/java/org/thoughtcrime/securesms/animation/AnimationCompleteListener.java
new file mode 100644
index 00000000..3063a04c
--- /dev/null
+++ b/app/src/main/java/org/thoughtcrime/securesms/animation/AnimationCompleteListener.java
@@ -0,0 +1,17 @@
+package org.thoughtcrime.securesms.animation;
+
+
+import android.animation.Animator;
+
+public abstract class AnimationCompleteListener implements Animator.AnimatorListener {
+ @Override
+ public final void onAnimationStart(Animator animation) {}
+
+ @Override
+ public abstract void onAnimationEnd(Animator animation);
+
+ @Override
+ public final void onAnimationCancel(Animator animation) {}
+ @Override
+ public final void onAnimationRepeat(Animator animation) {}
+}
diff --git a/app/src/main/java/org/thoughtcrime/securesms/animation/AnimationRepeatListener.java b/app/src/main/java/org/thoughtcrime/securesms/animation/AnimationRepeatListener.java
new file mode 100644
index 00000000..28317e9a
--- /dev/null
+++ b/app/src/main/java/org/thoughtcrime/securesms/animation/AnimationRepeatListener.java
@@ -0,0 +1,32 @@
+package org.thoughtcrime.securesms.animation;
+
+import android.animation.Animator;
+
+import androidx.annotation.NonNull;
+import androidx.core.util.Consumer;
+
+public final class AnimationRepeatListener implements Animator.AnimatorListener {
+
+ private final Consumer animationConsumer;
+
+ public AnimationRepeatListener(@NonNull Consumer animationConsumer) {
+ this.animationConsumer = animationConsumer;
+ }
+
+ @Override
+ public final void onAnimationStart(Animator animation) {
+ }
+
+ @Override
+ public final void onAnimationEnd(Animator animation) {
+ }
+
+ @Override
+ public final void onAnimationCancel(Animator animation) {
+ }
+
+ @Override
+ public final void onAnimationRepeat(Animator animation) {
+ this.animationConsumer.accept(animation);
+ }
+}
diff --git a/app/src/main/java/org/thoughtcrime/securesms/animation/DepthPageTransformer.java b/app/src/main/java/org/thoughtcrime/securesms/animation/DepthPageTransformer.java
new file mode 100644
index 00000000..da935281
--- /dev/null
+++ b/app/src/main/java/org/thoughtcrime/securesms/animation/DepthPageTransformer.java
@@ -0,0 +1,40 @@
+package org.thoughtcrime.securesms.animation;
+
+import android.view.View;
+
+import androidx.annotation.NonNull;
+import androidx.viewpager.widget.ViewPager;
+
+/**
+ * Based on https://developer.android.com/training/animation/screen-slide#depth-page
+ */
+public final class DepthPageTransformer implements ViewPager.PageTransformer {
+ private static final float MIN_SCALE = 0.75f;
+
+ public void transformPage(@NonNull View view, float position) {
+ final int pageWidth = view.getWidth();
+
+ if (position < -1f) {
+ view.setAlpha(0f);
+
+ } else if (position <= 0f) {
+ view.setAlpha(1f);
+ view.setTranslationX(0f);
+ view.setScaleX(1f);
+ view.setScaleY(1f);
+
+ } else if (position <= 1f) {
+ view.setAlpha(1f - position);
+
+ view.setTranslationX(pageWidth * -position);
+
+ final float scaleFactor = MIN_SCALE + (1f - MIN_SCALE) * (1f - Math.abs(position));
+
+ view.setScaleX(scaleFactor);
+ view.setScaleY(scaleFactor);
+
+ } else {
+ view.setAlpha(0f);
+ }
+ }
+}
diff --git a/app/src/main/java/org/thoughtcrime/securesms/animation/ResizeAnimation.java b/app/src/main/java/org/thoughtcrime/securesms/animation/ResizeAnimation.java
new file mode 100644
index 00000000..53084843
--- /dev/null
+++ b/app/src/main/java/org/thoughtcrime/securesms/animation/ResizeAnimation.java
@@ -0,0 +1,50 @@
+package org.thoughtcrime.securesms.animation;
+
+import android.view.View;
+import android.view.ViewGroup;
+import android.view.animation.Animation;
+import android.view.animation.Transformation;
+
+import androidx.annotation.NonNull;
+
+public class ResizeAnimation extends Animation {
+
+ private final View target;
+ private final int targetWidthPx;
+ private final int targetHeightPx;
+
+ private int startWidth;
+ private int startHeight;
+
+ public ResizeAnimation(@NonNull View target, int targetWidthPx, int targetHeightPx) {
+ this.target = target;
+ this.targetWidthPx = targetWidthPx;
+ this.targetHeightPx = targetHeightPx;
+ }
+
+ @Override
+ protected void applyTransformation(float interpolatedTime, Transformation t) {
+ int newWidth = (int) (startWidth + (targetWidthPx - startWidth) * interpolatedTime);
+ int newHeight = (int) (startHeight + (targetHeightPx - startHeight) * interpolatedTime);
+
+ ViewGroup.LayoutParams params = target.getLayoutParams();
+
+ params.width = newWidth;
+ params.height = newHeight;
+
+ target.setLayoutParams(params);
+ }
+
+ @Override
+ public void initialize(int width, int height, int parentWidth, int parentHeight) {
+ super.initialize(width, height, parentWidth, parentHeight);
+
+ this.startWidth = width;
+ this.startHeight = height;
+ }
+
+ @Override
+ public boolean willChangeBounds() {
+ return true;
+ }
+}
diff --git a/app/src/main/java/org/thoughtcrime/securesms/animation/transitions/CircleSquareImageViewTransition.java b/app/src/main/java/org/thoughtcrime/securesms/animation/transitions/CircleSquareImageViewTransition.java
new file mode 100644
index 00000000..add2d238
--- /dev/null
+++ b/app/src/main/java/org/thoughtcrime/securesms/animation/transitions/CircleSquareImageViewTransition.java
@@ -0,0 +1,83 @@
+package org.thoughtcrime.securesms.animation.transitions;
+
+import android.animation.Animator;
+import android.animation.ObjectAnimator;
+import android.annotation.TargetApi;
+import android.graphics.drawable.Drawable;
+import android.transition.Transition;
+import android.transition.TransitionValues;
+import android.util.Property;
+import android.view.View;
+import android.view.ViewGroup;
+import android.widget.ImageView;
+
+import androidx.core.graphics.drawable.RoundedBitmapDrawable;
+
+@TargetApi(21)
+abstract class CircleSquareImageViewTransition extends Transition {
+
+ private static final String CIRCLE_RATIO = "CIRCLE_RATIO";
+
+ private final boolean toCircle;
+
+ CircleSquareImageViewTransition(boolean toCircle) {
+ this.toCircle = toCircle;
+ }
+
+ @Override
+ public void captureStartValues(TransitionValues transitionValues) {
+ View view = transitionValues.view;
+ if (view instanceof ImageView) {
+ transitionValues.values.put(CIRCLE_RATIO, toCircle ? 0f : 1f);
+ }
+ }
+
+ @Override
+ public void captureEndValues(TransitionValues transitionValues) {
+ View view = transitionValues.view;
+ if (view instanceof ImageView) {
+ transitionValues.values.put(CIRCLE_RATIO, toCircle ? 1f : 0f);
+ }
+ }
+
+ @Override
+ public Animator createAnimator(ViewGroup sceneRoot, TransitionValues startValues, TransitionValues endValues) {
+ if (startValues == null || endValues == null) {
+ return null;
+ }
+
+ ImageView endImageView = (ImageView) endValues.view;
+ float start = (float) startValues.values.get(CIRCLE_RATIO);
+ float end = (float) endValues.values.get(CIRCLE_RATIO);
+
+ return ObjectAnimator.ofFloat(endImageView, new RadiusRatioProperty(), start, end);
+ }
+
+ static final class RadiusRatioProperty extends Property {
+
+ private float ratio;
+
+ RadiusRatioProperty() {
+ super(Float.class, "circle_ratio");
+ }
+
+ @Override
+ final public void set(ImageView imageView, Float ratio) {
+ this.ratio = ratio;
+ Drawable imageViewDrawable = imageView.getDrawable();
+ if (imageViewDrawable instanceof RoundedBitmapDrawable) {
+ RoundedBitmapDrawable drawable = (RoundedBitmapDrawable) imageViewDrawable;
+ if (ratio > 0.95) {
+ drawable.setCircular(true);
+ } else {
+ drawable.setCornerRadius(Math.min(drawable.getIntrinsicWidth(), drawable.getIntrinsicHeight()) * ratio * 0.5f);
+ }
+ }
+ }
+
+ @Override
+ public Float get(ImageView object) {
+ return ratio;
+ }
+ }
+}
diff --git a/app/src/main/java/org/thoughtcrime/securesms/animation/transitions/CircleToSquareImageViewTransition.java b/app/src/main/java/org/thoughtcrime/securesms/animation/transitions/CircleToSquareImageViewTransition.java
new file mode 100644
index 00000000..4c07fa0d
--- /dev/null
+++ b/app/src/main/java/org/thoughtcrime/securesms/animation/transitions/CircleToSquareImageViewTransition.java
@@ -0,0 +1,15 @@
+package org.thoughtcrime.securesms.animation.transitions;
+
+import android.annotation.TargetApi;
+import android.content.Context;
+import android.util.AttributeSet;
+
+/**
+ * Will only transition {@link android.widget.ImageView}s that contain a {@link androidx.core.graphics.drawable.RoundedBitmapDrawable}.
+ */
+@TargetApi(21)
+public final class CircleToSquareImageViewTransition extends CircleSquareImageViewTransition {
+ public CircleToSquareImageViewTransition(Context context, AttributeSet attrs) {
+ super(false);
+ }
+}
diff --git a/app/src/main/java/org/thoughtcrime/securesms/animation/transitions/SquareToCircleImageViewTransition.java b/app/src/main/java/org/thoughtcrime/securesms/animation/transitions/SquareToCircleImageViewTransition.java
new file mode 100644
index 00000000..42b51339
--- /dev/null
+++ b/app/src/main/java/org/thoughtcrime/securesms/animation/transitions/SquareToCircleImageViewTransition.java
@@ -0,0 +1,15 @@
+package org.thoughtcrime.securesms.animation.transitions;
+
+import android.annotation.TargetApi;
+import android.content.Context;
+import android.util.AttributeSet;
+
+/**
+ * Will only transition {@link android.widget.ImageView}s that contain a {@link androidx.core.graphics.drawable.RoundedBitmapDrawable}.
+ */
+@TargetApi(21)
+public final class SquareToCircleImageViewTransition extends CircleSquareImageViewTransition {
+ public SquareToCircleImageViewTransition(Context context, AttributeSet attrs) {
+ super(true);
+ }
+}
diff --git a/app/src/main/java/org/thoughtcrime/securesms/attachments/Attachment.java b/app/src/main/java/org/thoughtcrime/securesms/attachments/Attachment.java
new file mode 100644
index 00000000..90696f2c
--- /dev/null
+++ b/app/src/main/java/org/thoughtcrime/securesms/attachments/Attachment.java
@@ -0,0 +1,210 @@
+package org.thoughtcrime.securesms.attachments;
+
+import android.net.Uri;
+
+import androidx.annotation.NonNull;
+import androidx.annotation.Nullable;
+
+import org.thoughtcrime.securesms.audio.AudioHash;
+import org.thoughtcrime.securesms.blurhash.BlurHash;
+import org.thoughtcrime.securesms.database.AttachmentDatabase;
+import org.thoughtcrime.securesms.database.AttachmentDatabase.TransformProperties;
+import org.thoughtcrime.securesms.stickers.StickerLocator;
+
+public abstract class Attachment {
+
+ @NonNull
+ private final String contentType;
+ private final int transferState;
+ private final long size;
+
+ @Nullable
+ private final String fileName;
+
+ private final int cdnNumber;
+
+ @Nullable
+ private final String location;
+
+ @Nullable
+ private final String key;
+
+ @Nullable
+ private final String relay;
+
+ @Nullable
+ private final byte[] digest;
+
+ @Nullable
+ private final String fastPreflightId;
+
+ private final boolean voiceNote;
+ private final boolean borderless;
+ private final int width;
+ private final int height;
+ private final boolean quote;
+ private final long uploadTimestamp;
+
+ @Nullable
+ private final String caption;
+
+ @Nullable
+ private final StickerLocator stickerLocator;
+
+ @Nullable
+ private final BlurHash blurHash;
+
+ @Nullable
+ private final AudioHash audioHash;
+
+ @NonNull
+ private final TransformProperties transformProperties;
+
+ public Attachment(@NonNull String contentType,
+ int transferState,
+ long size,
+ @Nullable String fileName,
+ int cdnNumber,
+ @Nullable String location,
+ @Nullable String key,
+ @Nullable String relay,
+ @Nullable byte[] digest,
+ @Nullable String fastPreflightId,
+ boolean voiceNote,
+ boolean borderless,
+ int width,
+ int height,
+ boolean quote,
+ long uploadTimestamp,
+ @Nullable String caption,
+ @Nullable StickerLocator stickerLocator,
+ @Nullable BlurHash blurHash,
+ @Nullable AudioHash audioHash,
+ @Nullable TransformProperties transformProperties)
+ {
+ this.contentType = contentType;
+ this.transferState = transferState;
+ this.size = size;
+ this.fileName = fileName;
+ this.cdnNumber = cdnNumber;
+ this.location = location;
+ this.key = key;
+ this.relay = relay;
+ this.digest = digest;
+ this.fastPreflightId = fastPreflightId;
+ this.voiceNote = voiceNote;
+ this.borderless = borderless;
+ this.width = width;
+ this.height = height;
+ this.quote = quote;
+ this.uploadTimestamp = uploadTimestamp;
+ this.stickerLocator = stickerLocator;
+ this.caption = caption;
+ this.blurHash = blurHash;
+ this.audioHash = audioHash;
+ this.transformProperties = transformProperties != null ? transformProperties : TransformProperties.empty();
+ }
+
+ @Nullable
+ public abstract Uri getUri();
+
+ public int getTransferState() {
+ return transferState;
+ }
+
+ public boolean isInProgress() {
+ return transferState != AttachmentDatabase.TRANSFER_PROGRESS_DONE &&
+ transferState != AttachmentDatabase.TRANSFER_PROGRESS_FAILED;
+ }
+
+ public long getSize() {
+ return size;
+ }
+
+ @Nullable
+ public String getFileName() {
+ return fileName;
+ }
+
+ @NonNull
+ public String getContentType() {
+ return contentType;
+ }
+
+ public int getCdnNumber() {
+ return cdnNumber;
+ }
+
+ @Nullable
+ public String getLocation() {
+ return location;
+ }
+
+ @Nullable
+ public String getKey() {
+ return key;
+ }
+
+ @Nullable
+ public String getRelay() {
+ return relay;
+ }
+
+ @Nullable
+ public byte[] getDigest() {
+ return digest;
+ }
+
+ @Nullable
+ public String getFastPreflightId() {
+ return fastPreflightId;
+ }
+
+ public boolean isVoiceNote() {
+ return voiceNote;
+ }
+
+ public boolean isBorderless() {
+ return borderless;
+ }
+
+ public int getWidth() {
+ return width;
+ }
+
+ public int getHeight() {
+ return height;
+ }
+
+ public boolean isQuote() {
+ return quote;
+ }
+
+ public long getUploadTimestamp() {
+ return uploadTimestamp;
+ }
+
+ public boolean isSticker() {
+ return stickerLocator != null;
+ }
+
+ public @Nullable StickerLocator getSticker() {
+ return stickerLocator;
+ }
+
+ public @Nullable BlurHash getBlurHash() {
+ return blurHash;
+ }
+
+ public @Nullable AudioHash getAudioHash() {
+ return audioHash;
+ }
+
+ public @Nullable String getCaption() {
+ return caption;
+ }
+
+ public @NonNull TransformProperties getTransformProperties() {
+ return transformProperties;
+ }
+}
diff --git a/app/src/main/java/org/thoughtcrime/securesms/attachments/AttachmentId.java b/app/src/main/java/org/thoughtcrime/securesms/attachments/AttachmentId.java
new file mode 100644
index 00000000..d8d5e105
--- /dev/null
+++ b/app/src/main/java/org/thoughtcrime/securesms/attachments/AttachmentId.java
@@ -0,0 +1,89 @@
+package org.thoughtcrime.securesms.attachments;
+
+import android.os.Parcel;
+import android.os.Parcelable;
+
+import androidx.annotation.NonNull;
+
+import com.fasterxml.jackson.annotation.JsonProperty;
+
+import org.thoughtcrime.securesms.util.Util;
+
+public class AttachmentId implements Parcelable {
+
+ @JsonProperty
+ private final long rowId;
+
+ @JsonProperty
+ private final long uniqueId;
+
+ public AttachmentId(@JsonProperty("rowId") long rowId, @JsonProperty("uniqueId") long uniqueId) {
+ this.rowId = rowId;
+ this.uniqueId = uniqueId;
+ }
+
+ private AttachmentId(Parcel in) {
+ this.rowId = in.readLong();
+ this.uniqueId = in.readLong();
+ }
+
+ public long getRowId() {
+ return rowId;
+ }
+
+ public long getUniqueId() {
+ return uniqueId;
+ }
+
+ public String[] toStrings() {
+ return new String[] {String.valueOf(rowId), String.valueOf(uniqueId)};
+ }
+
+ public @NonNull String toString() {
+ return "AttachmentId::(" + rowId + ", " + uniqueId + ")";
+ }
+
+ public boolean isValid() {
+ return rowId >= 0 && uniqueId >= 0;
+ }
+
+ @Override
+ public boolean equals(Object o) {
+ if (this == o) return true;
+ if (o == null || getClass() != o.getClass()) return false;
+
+ AttachmentId attachmentId = (AttachmentId)o;
+
+ if (rowId != attachmentId.rowId) return false;
+ return uniqueId == attachmentId.uniqueId;
+ }
+
+ @Override
+ public int hashCode() {
+ return Util.hashCode(rowId, uniqueId);
+ }
+
+ @Override
+ public int describeContents() {
+ return 0;
+ }
+
+ @Override
+ public void writeToParcel(Parcel dest, int flags) {
+ dest.writeLong(rowId);
+ dest.writeLong(uniqueId);
+ }
+
+ public static final Creator CREATOR = new Creator() {
+ @Override
+ public AttachmentId createFromParcel(Parcel in) {
+ return new AttachmentId(in);
+ }
+
+ @Override
+ public AttachmentId[] newArray(int size) {
+ return new AttachmentId[size];
+ }
+ };
+
+}
diff --git a/app/src/main/java/org/thoughtcrime/securesms/attachments/DatabaseAttachment.java b/app/src/main/java/org/thoughtcrime/securesms/attachments/DatabaseAttachment.java
new file mode 100644
index 00000000..02321164
--- /dev/null
+++ b/app/src/main/java/org/thoughtcrime/securesms/attachments/DatabaseAttachment.java
@@ -0,0 +1,106 @@
+package org.thoughtcrime.securesms.attachments;
+
+import android.net.Uri;
+
+import androidx.annotation.Nullable;
+
+import org.thoughtcrime.securesms.audio.AudioHash;
+import org.thoughtcrime.securesms.blurhash.BlurHash;
+import org.thoughtcrime.securesms.database.AttachmentDatabase.TransformProperties;
+import org.thoughtcrime.securesms.mms.PartAuthority;
+import org.thoughtcrime.securesms.stickers.StickerLocator;
+
+import java.util.Comparator;
+
+public class DatabaseAttachment extends Attachment {
+
+ private final AttachmentId attachmentId;
+ private final long mmsId;
+ private final boolean hasData;
+ private final boolean hasThumbnail;
+ private final int displayOrder;
+
+ public DatabaseAttachment(AttachmentId attachmentId,
+ long mmsId,
+ boolean hasData,
+ boolean hasThumbnail,
+ String contentType,
+ int transferProgress,
+ long size,
+ String fileName,
+ int cdnNumber,
+ String location,
+ String key,
+ String relay,
+ byte[] digest,
+ String fastPreflightId,
+ boolean voiceNote,
+ boolean borderless,
+ int width,
+ int height,
+ boolean quote,
+ @Nullable String caption,
+ @Nullable StickerLocator stickerLocator,
+ @Nullable BlurHash blurHash,
+ @Nullable AudioHash audioHash,
+ @Nullable TransformProperties transformProperties,
+ int displayOrder,
+ long uploadTimestamp)
+ {
+ super(contentType, transferProgress, size, fileName, cdnNumber, location, key, relay, digest, fastPreflightId, voiceNote, borderless, width, height, quote, uploadTimestamp, caption, stickerLocator, blurHash, audioHash, transformProperties);
+ this.attachmentId = attachmentId;
+ this.hasData = hasData;
+ this.hasThumbnail = hasThumbnail;
+ this.mmsId = mmsId;
+ this.displayOrder = displayOrder;
+ }
+
+ @Override
+ @Nullable
+ public Uri getUri() {
+ if (hasData) {
+ return PartAuthority.getAttachmentDataUri(attachmentId);
+ } else {
+ return null;
+ }
+ }
+
+ public AttachmentId getAttachmentId() {
+ return attachmentId;
+ }
+
+ public int getDisplayOrder() {
+ return displayOrder;
+ }
+
+ @Override
+ public boolean equals(Object other) {
+ return other != null &&
+ other instanceof DatabaseAttachment &&
+ ((DatabaseAttachment) other).attachmentId.equals(this.attachmentId);
+ }
+
+ @Override
+ public int hashCode() {
+ return attachmentId.hashCode();
+ }
+
+ public long getMmsId() {
+ return mmsId;
+ }
+
+ public boolean hasData() {
+ return hasData;
+ }
+
+ public boolean hasThumbnail() {
+ return hasThumbnail;
+ }
+
+ public static class DisplayOrderComparator implements Comparator {
+ @Override
+ public int compare(DatabaseAttachment lhs, DatabaseAttachment rhs) {
+ return Integer.compare(lhs.getDisplayOrder(), rhs.getDisplayOrder());
+ }
+ }
+}
diff --git a/app/src/main/java/org/thoughtcrime/securesms/attachments/MmsNotificationAttachment.java b/app/src/main/java/org/thoughtcrime/securesms/attachments/MmsNotificationAttachment.java
new file mode 100644
index 00000000..94698068
--- /dev/null
+++ b/app/src/main/java/org/thoughtcrime/securesms/attachments/MmsNotificationAttachment.java
@@ -0,0 +1,34 @@
+package org.thoughtcrime.securesms.attachments;
+
+
+import android.net.Uri;
+
+import androidx.annotation.Nullable;
+
+import org.thoughtcrime.securesms.database.AttachmentDatabase;
+import org.thoughtcrime.securesms.database.MmsDatabase;
+
+public class MmsNotificationAttachment extends Attachment {
+
+ public MmsNotificationAttachment(int status, long size) {
+ super("application/mms", getTransferStateFromStatus(status), size, null, 0, null, null, null, null, null, false, false, 0, 0, false, 0, null, null, null, null, null);
+ }
+
+ @Nullable
+ @Override
+ public Uri getUri() {
+ return null;
+ }
+
+ private static int getTransferStateFromStatus(int status) {
+ if (status == MmsDatabase.Status.DOWNLOAD_INITIALIZED ||
+ status == MmsDatabase.Status.DOWNLOAD_NO_CONNECTIVITY)
+ {
+ return AttachmentDatabase.TRANSFER_PROGRESS_PENDING;
+ } else if (status == MmsDatabase.Status.DOWNLOAD_CONNECTING) {
+ return AttachmentDatabase.TRANSFER_PROGRESS_STARTED;
+ } else {
+ return AttachmentDatabase.TRANSFER_PROGRESS_FAILED;
+ }
+ }
+}
diff --git a/app/src/main/java/org/thoughtcrime/securesms/attachments/PointerAttachment.java b/app/src/main/java/org/thoughtcrime/securesms/attachments/PointerAttachment.java
new file mode 100644
index 00000000..431e5b07
--- /dev/null
+++ b/app/src/main/java/org/thoughtcrime/securesms/attachments/PointerAttachment.java
@@ -0,0 +1,140 @@
+package org.thoughtcrime.securesms.attachments;
+
+import android.net.Uri;
+
+import androidx.annotation.NonNull;
+import androidx.annotation.Nullable;
+
+import org.thoughtcrime.securesms.blurhash.BlurHash;
+import org.thoughtcrime.securesms.database.AttachmentDatabase;
+import org.thoughtcrime.securesms.stickers.StickerLocator;
+import org.thoughtcrime.securesms.util.Base64;
+import org.whispersystems.libsignal.util.guava.Optional;
+import org.whispersystems.signalservice.api.messages.SignalServiceAttachment;
+import org.whispersystems.signalservice.api.messages.SignalServiceDataMessage;
+
+import java.util.LinkedList;
+import java.util.List;
+
+public class PointerAttachment extends Attachment {
+
+ private PointerAttachment(@NonNull String contentType,
+ int transferState,
+ long size,
+ @Nullable String fileName,
+ int cdnNumber,
+ @NonNull String location,
+ @Nullable String key,
+ @Nullable String relay,
+ @Nullable byte[] digest,
+ @Nullable String fastPreflightId,
+ boolean voiceNote,
+ boolean borderless,
+ int width,
+ int height,
+ long uploadTimestamp,
+ @Nullable String caption,
+ @Nullable StickerLocator stickerLocator,
+ @Nullable BlurHash blurHash)
+ {
+ super(contentType, transferState, size, fileName, cdnNumber, location, key, relay, digest, fastPreflightId, voiceNote, borderless, width, height, false, uploadTimestamp, caption, stickerLocator, blurHash, null, null);
+ }
+
+ @Nullable
+ @Override
+ public Uri getUri() {
+ return null;
+ }
+
+ public static List forPointers(Optional> pointers) {
+ List results = new LinkedList<>();
+
+ if (pointers.isPresent()) {
+ for (SignalServiceAttachment pointer : pointers.get()) {
+ Optional result = forPointer(Optional.of(pointer));
+
+ if (result.isPresent()) {
+ results.add(result.get());
+ }
+ }
+ }
+
+ return results;
+ }
+
+ public static List forPointers(List pointers) {
+ List results = new LinkedList<>();
+
+ if (pointers != null) {
+ for (SignalServiceDataMessage.Quote.QuotedAttachment pointer : pointers) {
+ Optional result = forPointer(pointer);
+
+ if (result.isPresent()) {
+ results.add(result.get());
+ }
+ }
+ }
+
+ return results;
+ }
+
+ public static Optional forPointer(Optional pointer) {
+ return forPointer(pointer, null, null);
+ }
+
+ public static Optional forPointer(Optional pointer, @Nullable StickerLocator stickerLocator) {
+ return forPointer(pointer, stickerLocator, null);
+ }
+
+ public static Optional forPointer(Optional pointer, @Nullable StickerLocator stickerLocator, @Nullable String fastPreflightId) {
+ if (!pointer.isPresent() || !pointer.get().isPointer()) return Optional.absent();
+
+ String encodedKey = null;
+
+ if (pointer.get().asPointer().getKey() != null) {
+ encodedKey = Base64.encodeBytes(pointer.get().asPointer().getKey());
+ }
+
+ return Optional.of(new PointerAttachment(pointer.get().getContentType(),
+ AttachmentDatabase.TRANSFER_PROGRESS_PENDING,
+ pointer.get().asPointer().getSize().or(0),
+ pointer.get().asPointer().getFileName().orNull(),
+ pointer.get().asPointer().getCdnNumber(),
+ pointer.get().asPointer().getRemoteId().toString(),
+ encodedKey, null,
+ pointer.get().asPointer().getDigest().orNull(),
+ fastPreflightId,
+ pointer.get().asPointer().getVoiceNote(),
+ pointer.get().asPointer().isBorderless(),
+ pointer.get().asPointer().getWidth(),
+ pointer.get().asPointer().getHeight(),
+ pointer.get().asPointer().getUploadTimestamp(),
+ pointer.get().asPointer().getCaption().orNull(),
+ stickerLocator,
+ BlurHash.parseOrNull(pointer.get().asPointer().getBlurHash().orNull())));
+
+ }
+
+ public static Optional forPointer(SignalServiceDataMessage.Quote.QuotedAttachment pointer) {
+ SignalServiceAttachment thumbnail = pointer.getThumbnail();
+
+ return Optional.of(new PointerAttachment(pointer.getContentType(),
+ AttachmentDatabase.TRANSFER_PROGRESS_PENDING,
+ thumbnail != null ? thumbnail.asPointer().getSize().or(0) : 0,
+ pointer.getFileName(),
+ thumbnail != null ? thumbnail.asPointer().getCdnNumber() : 0,
+ thumbnail != null ? thumbnail.asPointer().getRemoteId().toString() : "0",
+ thumbnail != null && thumbnail.asPointer().getKey() != null ? Base64.encodeBytes(thumbnail.asPointer().getKey()) : null,
+ null,
+ thumbnail != null ? thumbnail.asPointer().getDigest().orNull() : null,
+ null,
+ false,
+ false,
+ thumbnail != null ? thumbnail.asPointer().getWidth() : 0,
+ thumbnail != null ? thumbnail.asPointer().getHeight() : 0,
+ thumbnail != null ? thumbnail.asPointer().getUploadTimestamp() : 0,
+ thumbnail != null ? thumbnail.asPointer().getCaption().orNull() : null,
+ null,
+ null));
+ }
+}
diff --git a/app/src/main/java/org/thoughtcrime/securesms/attachments/TombstoneAttachment.java b/app/src/main/java/org/thoughtcrime/securesms/attachments/TombstoneAttachment.java
new file mode 100644
index 00000000..4cdee54b
--- /dev/null
+++ b/app/src/main/java/org/thoughtcrime/securesms/attachments/TombstoneAttachment.java
@@ -0,0 +1,26 @@
+package org.thoughtcrime.securesms.attachments;
+
+import android.net.Uri;
+
+import androidx.annotation.NonNull;
+import androidx.annotation.Nullable;
+
+import org.thoughtcrime.securesms.database.AttachmentDatabase;
+
+/**
+ * An attachment that represents where an attachment used to be. Useful when you need to know that
+ * a message had an attachment and some metadata about it (like the contentType), even though the
+ * underlying media no longer exists. An example usecase would be view-once messages, so that we can
+ * quote them and know their contentType even though the media has been deleted.
+ */
+public class TombstoneAttachment extends Attachment {
+
+ public TombstoneAttachment(@NonNull String contentType, boolean quote) {
+ super(contentType, AttachmentDatabase.TRANSFER_PROGRESS_DONE, 0, null, 0, null, null, null, null, null, false, false, 0, 0, quote, 0, null, null, null, null, null);
+ }
+
+ @Override
+ public @Nullable Uri getUri() {
+ return null;
+ }
+}
diff --git a/app/src/main/java/org/thoughtcrime/securesms/attachments/UriAttachment.java b/app/src/main/java/org/thoughtcrime/securesms/attachments/UriAttachment.java
new file mode 100644
index 00000000..cc28d760
--- /dev/null
+++ b/app/src/main/java/org/thoughtcrime/securesms/attachments/UriAttachment.java
@@ -0,0 +1,70 @@
+package org.thoughtcrime.securesms.attachments;
+
+import android.net.Uri;
+
+import androidx.annotation.NonNull;
+import androidx.annotation.Nullable;
+
+import org.thoughtcrime.securesms.audio.AudioHash;
+import org.thoughtcrime.securesms.blurhash.BlurHash;
+import org.thoughtcrime.securesms.database.AttachmentDatabase.TransformProperties;
+import org.thoughtcrime.securesms.stickers.StickerLocator;
+
+public class UriAttachment extends Attachment {
+
+ private final @NonNull Uri dataUri;
+
+ public UriAttachment(@NonNull Uri uri,
+ @NonNull String contentType,
+ int transferState,
+ long size,
+ @Nullable String fileName,
+ boolean voiceNote,
+ boolean borderless,
+ boolean quote,
+ @Nullable String caption,
+ @Nullable StickerLocator stickerLocator,
+ @Nullable BlurHash blurHash,
+ @Nullable AudioHash audioHash,
+ @Nullable TransformProperties transformProperties)
+ {
+ this(uri, contentType, transferState, size, 0, 0, fileName, null, voiceNote, borderless, quote, caption, stickerLocator, blurHash, audioHash, transformProperties);
+ }
+
+ public UriAttachment(@NonNull Uri dataUri,
+ @NonNull String contentType,
+ int transferState,
+ long size,
+ int width,
+ int height,
+ @Nullable String fileName,
+ @Nullable String fastPreflightId,
+ boolean voiceNote,
+ boolean borderless,
+ boolean quote,
+ @Nullable String caption,
+ @Nullable StickerLocator stickerLocator,
+ @Nullable BlurHash blurHash,
+ @Nullable AudioHash audioHash,
+ @Nullable TransformProperties transformProperties)
+ {
+ super(contentType, transferState, size, fileName, 0, null, null, null, null, fastPreflightId, voiceNote, borderless, width, height, quote, 0, caption, stickerLocator, blurHash, audioHash, transformProperties);
+ this.dataUri = dataUri;
+ }
+
+ @Override
+ @NonNull
+ public Uri getUri() {
+ return dataUri;
+ }
+
+ @Override
+ public boolean equals(Object other) {
+ return other != null && other instanceof UriAttachment && ((UriAttachment) other).dataUri.equals(this.dataUri);
+ }
+
+ @Override
+ public int hashCode() {
+ return dataUri.hashCode();
+ }
+}
diff --git a/app/src/main/java/org/thoughtcrime/securesms/audio/AudioCodec.java b/app/src/main/java/org/thoughtcrime/securesms/audio/AudioCodec.java
new file mode 100644
index 00000000..192828c9
--- /dev/null
+++ b/app/src/main/java/org/thoughtcrime/securesms/audio/AudioCodec.java
@@ -0,0 +1,199 @@
+package org.thoughtcrime.securesms.audio;
+
+import android.annotation.TargetApi;
+import android.media.AudioFormat;
+import android.media.AudioRecord;
+import android.media.MediaCodec;
+import android.media.MediaCodecInfo;
+import android.media.MediaFormat;
+import android.media.MediaRecorder;
+import android.os.Build;
+
+import org.signal.core.util.StreamUtil;
+import org.signal.core.util.logging.Log;
+import org.thoughtcrime.securesms.util.Util;
+
+import java.io.IOException;
+import java.io.OutputStream;
+import java.nio.ByteBuffer;
+
+@TargetApi(Build.VERSION_CODES.JELLY_BEAN)
+public class AudioCodec {
+
+ private static final String TAG = AudioCodec.class.getSimpleName();
+
+ private static final int SAMPLE_RATE = 44100;
+ private static final int SAMPLE_RATE_INDEX = 4;
+ private static final int CHANNELS = 1;
+ private static final int BIT_RATE = 32000;
+
+ private final int bufferSize;
+ private final MediaCodec mediaCodec;
+ private final AudioRecord audioRecord;
+
+ private boolean running = true;
+ private boolean finished = false;
+
+ public AudioCodec() throws IOException {
+ this.bufferSize = AudioRecord.getMinBufferSize(SAMPLE_RATE, AudioFormat.CHANNEL_IN_MONO, AudioFormat.ENCODING_PCM_16BIT);
+ this.audioRecord = createAudioRecord(this.bufferSize);
+ this.mediaCodec = createMediaCodec(this.bufferSize);
+
+ this.mediaCodec.start();
+
+ try {
+ audioRecord.startRecording();
+ } catch (Exception e) {
+ Log.w(TAG, e);
+ mediaCodec.release();
+ throw new IOException(e);
+ }
+ }
+
+ public synchronized void stop() {
+ running = false;
+ while (!finished) Util.wait(this, 0);
+ }
+
+ public void start(final OutputStream outputStream) {
+ new Thread(new Runnable() {
+ @Override
+ public void run() {
+ MediaCodec.BufferInfo bufferInfo = new MediaCodec.BufferInfo();
+ byte[] audioRecordData = new byte[bufferSize];
+ ByteBuffer[] codecInputBuffers = mediaCodec.getInputBuffers();
+ ByteBuffer[] codecOutputBuffers = mediaCodec.getOutputBuffers();
+
+ try {
+ while (true) {
+ boolean running = isRunning();
+
+ handleCodecInput(audioRecord, audioRecordData, mediaCodec, codecInputBuffers, running);
+ handleCodecOutput(mediaCodec, codecOutputBuffers, bufferInfo, outputStream);
+
+ if (!running) break;
+ }
+ } catch (IOException e) {
+ Log.w(TAG, e);
+ } finally {
+ mediaCodec.stop();
+ audioRecord.stop();
+
+ mediaCodec.release();
+ audioRecord.release();
+
+ StreamUtil.close(outputStream);
+ setFinished();
+ }
+ }
+ }, "signal-AudioCodec").start();
+ }
+
+ private synchronized boolean isRunning() {
+ return running;
+ }
+
+ private synchronized void setFinished() {
+ finished = true;
+ notifyAll();
+ }
+
+ private void handleCodecInput(AudioRecord audioRecord, byte[] audioRecordData,
+ MediaCodec mediaCodec, ByteBuffer[] codecInputBuffers,
+ boolean running)
+ {
+ int length = audioRecord.read(audioRecordData, 0, audioRecordData.length);
+ int codecInputBufferIndex = mediaCodec.dequeueInputBuffer(10 * 1000);
+
+ if (codecInputBufferIndex >= 0) {
+ ByteBuffer codecBuffer = codecInputBuffers[codecInputBufferIndex];
+ codecBuffer.clear();
+ codecBuffer.put(audioRecordData);
+ mediaCodec.queueInputBuffer(codecInputBufferIndex, 0, length, 0, running ? 0 : MediaCodec.BUFFER_FLAG_END_OF_STREAM);
+ }
+ }
+
+ private void handleCodecOutput(MediaCodec mediaCodec,
+ ByteBuffer[] codecOutputBuffers,
+ MediaCodec.BufferInfo bufferInfo,
+ OutputStream outputStream)
+ throws IOException
+ {
+ int codecOutputBufferIndex = mediaCodec.dequeueOutputBuffer(bufferInfo, 0);
+
+ while (codecOutputBufferIndex != MediaCodec.INFO_TRY_AGAIN_LATER) {
+ if (codecOutputBufferIndex >= 0) {
+ ByteBuffer encoderOutputBuffer = codecOutputBuffers[codecOutputBufferIndex];
+
+ encoderOutputBuffer.position(bufferInfo.offset);
+ encoderOutputBuffer.limit(bufferInfo.offset + bufferInfo.size);
+
+ if ((bufferInfo.flags & MediaCodec.BUFFER_FLAG_CODEC_CONFIG) != MediaCodec.BUFFER_FLAG_CODEC_CONFIG) {
+ byte[] header = createAdtsHeader(bufferInfo.size - bufferInfo.offset);
+
+
+ outputStream.write(header);
+
+ byte[] data = new byte[encoderOutputBuffer.remaining()];
+ encoderOutputBuffer.get(data);
+ outputStream.write(data);
+ }
+
+ encoderOutputBuffer.clear();
+
+ mediaCodec.releaseOutputBuffer(codecOutputBufferIndex, false);
+ } else if (codecOutputBufferIndex== MediaCodec.INFO_OUTPUT_BUFFERS_CHANGED) {
+ codecOutputBuffers = mediaCodec.getOutputBuffers();
+ }
+
+ codecOutputBufferIndex = mediaCodec.dequeueOutputBuffer(bufferInfo, 0);
+ }
+
+ }
+
+ private byte[] createAdtsHeader(int length) {
+ int frameLength = length + 7;
+ byte[] adtsHeader = new byte[7];
+
+ adtsHeader[0] = (byte) 0xFF; // Sync Word
+ adtsHeader[1] = (byte) 0xF1; // MPEG-4, Layer (0), No CRC
+ adtsHeader[2] = (byte) ((MediaCodecInfo.CodecProfileLevel.AACObjectLC - 1) << 6);
+ adtsHeader[2] |= (((byte) SAMPLE_RATE_INDEX) << 2);
+ adtsHeader[2] |= (((byte) CHANNELS) >> 2);
+ adtsHeader[3] = (byte) (((CHANNELS & 3) << 6) | ((frameLength >> 11) & 0x03));
+ adtsHeader[4] = (byte) ((frameLength >> 3) & 0xFF);
+ adtsHeader[5] = (byte) (((frameLength & 0x07) << 5) | 0x1f);
+ adtsHeader[6] = (byte) 0xFC;
+
+ return adtsHeader;
+ }
+
+ private AudioRecord createAudioRecord(int bufferSize) {
+ return new AudioRecord(MediaRecorder.AudioSource.MIC, SAMPLE_RATE,
+ AudioFormat.CHANNEL_IN_MONO,
+ AudioFormat.ENCODING_PCM_16BIT, bufferSize * 10);
+ }
+
+ private MediaCodec createMediaCodec(int bufferSize) throws IOException {
+ MediaCodec mediaCodec = MediaCodec.createEncoderByType("audio/mp4a-latm");
+ MediaFormat mediaFormat = new MediaFormat();
+
+ mediaFormat.setString(MediaFormat.KEY_MIME, "audio/mp4a-latm");
+ mediaFormat.setInteger(MediaFormat.KEY_SAMPLE_RATE, SAMPLE_RATE);
+ mediaFormat.setInteger(MediaFormat.KEY_CHANNEL_COUNT, CHANNELS);
+ mediaFormat.setInteger(MediaFormat.KEY_MAX_INPUT_SIZE, bufferSize);
+ mediaFormat.setInteger(MediaFormat.KEY_BIT_RATE, BIT_RATE);
+ mediaFormat.setInteger(MediaFormat.KEY_AAC_PROFILE, MediaCodecInfo.CodecProfileLevel.AACObjectLC);
+
+ try {
+ mediaCodec.configure(mediaFormat, null, null, MediaCodec.CONFIGURE_FLAG_ENCODE);
+ } catch (Exception e) {
+ Log.w(TAG, e);
+ mediaCodec.release();
+ throw new IOException(e);
+ }
+
+ return mediaCodec;
+ }
+
+}
diff --git a/app/src/main/java/org/thoughtcrime/securesms/audio/AudioHash.java b/app/src/main/java/org/thoughtcrime/securesms/audio/AudioHash.java
new file mode 100644
index 00000000..6550fe37
--- /dev/null
+++ b/app/src/main/java/org/thoughtcrime/securesms/audio/AudioHash.java
@@ -0,0 +1,57 @@
+package org.thoughtcrime.securesms.audio;
+
+import androidx.annotation.NonNull;
+import androidx.annotation.Nullable;
+
+import org.thoughtcrime.securesms.database.model.databaseprotos.AudioWaveFormData;
+import org.whispersystems.util.Base64;
+
+import java.io.IOException;
+
+/**
+ * An AudioHash is a compact string representation of the wave form and duration for an audio file.
+ */
+public final class AudioHash {
+
+ @NonNull private final String hash;
+ @NonNull private final AudioWaveFormData audioWaveForm;
+
+ private AudioHash(@NonNull String hash, @NonNull AudioWaveFormData audioWaveForm) {
+ this.hash = hash;
+ this.audioWaveForm = audioWaveForm;
+ }
+
+ public AudioHash(@NonNull AudioWaveFormData audioWaveForm) {
+ this(Base64.encodeBytes(audioWaveForm.toByteArray()), audioWaveForm);
+ }
+
+ public static @Nullable AudioHash parseOrNull(@Nullable String hash) {
+ if (hash == null) return null;
+ try {
+ return new AudioHash(hash, AudioWaveFormData.parseFrom(Base64.decode(hash)));
+ } catch (IOException e) {
+ return null;
+ }
+ }
+
+ @NonNull AudioWaveFormData getAudioWaveForm() {
+ return audioWaveForm;
+ }
+
+ @Override
+ public boolean equals(Object o) {
+ if (this == o) return true;
+ if (o == null || getClass() != o.getClass()) return false;
+ AudioHash other = (AudioHash) o;
+ return hash.equals(other.hash);
+ }
+
+ @Override
+ public int hashCode() {
+ return hash.hashCode();
+ }
+
+ public @NonNull String getHash() {
+ return hash;
+ }
+}
diff --git a/app/src/main/java/org/thoughtcrime/securesms/audio/AudioRecorder.java b/app/src/main/java/org/thoughtcrime/securesms/audio/AudioRecorder.java
new file mode 100644
index 00000000..f2c59959
--- /dev/null
+++ b/app/src/main/java/org/thoughtcrime/securesms/audio/AudioRecorder.java
@@ -0,0 +1,99 @@
+package org.thoughtcrime.securesms.audio;
+
+import android.annotation.TargetApi;
+import android.content.Context;
+import android.net.Uri;
+import android.os.Build;
+import android.os.ParcelFileDescriptor;
+
+import androidx.annotation.NonNull;
+
+import org.signal.core.util.concurrent.SignalExecutors;
+import org.signal.core.util.logging.Log;
+import org.thoughtcrime.securesms.providers.BlobProvider;
+import org.thoughtcrime.securesms.util.MediaUtil;
+import org.thoughtcrime.securesms.util.Util;
+import org.thoughtcrime.securesms.util.concurrent.ListenableFuture;
+import org.thoughtcrime.securesms.util.concurrent.SettableFuture;
+import org.whispersystems.libsignal.util.Pair;
+
+import java.io.IOException;
+import java.util.concurrent.ExecutorService;
+
+@TargetApi(Build.VERSION_CODES.JELLY_BEAN)
+public class AudioRecorder {
+
+ private static final String TAG = AudioRecorder.class.getSimpleName();
+
+ private static final ExecutorService executor = SignalExecutors.newCachedSingleThreadExecutor("signal-AudioRecorder");
+
+ private final Context context;
+
+ private AudioCodec audioCodec;
+ private Uri captureUri;
+
+ public AudioRecorder(@NonNull Context context) {
+ this.context = context;
+ }
+
+ public void startRecording() {
+ Log.i(TAG, "startRecording()");
+
+ executor.execute(() -> {
+ Log.i(TAG, "Running startRecording() + " + Thread.currentThread().getId());
+ try {
+ if (audioCodec != null) {
+ throw new AssertionError("We can only record once at a time.");
+ }
+
+ ParcelFileDescriptor fds[] = ParcelFileDescriptor.createPipe();
+
+ captureUri = BlobProvider.getInstance()
+ .forData(new ParcelFileDescriptor.AutoCloseInputStream(fds[0]), 0)
+ .withMimeType(MediaUtil.AUDIO_AAC)
+ .createForSingleSessionOnDiskAsync(context, () -> Log.i(TAG, "Write successful."), e -> Log.w(TAG, "Error during recording", e));
+ audioCodec = new AudioCodec();
+
+ audioCodec.start(new ParcelFileDescriptor.AutoCloseOutputStream(fds[1]));
+ } catch (IOException e) {
+ Log.w(TAG, e);
+ }
+ });
+ }
+
+ public @NonNull ListenableFuture> stopRecording() {
+ Log.i(TAG, "stopRecording()");
+
+ final SettableFuture> future = new SettableFuture<>();
+
+ executor.execute(() -> {
+ if (audioCodec == null) {
+ sendToFuture(future, new IOException("MediaRecorder was never initialized successfully!"));
+ return;
+ }
+
+ audioCodec.stop();
+
+ try {
+ long size = MediaUtil.getMediaSize(context, captureUri);
+ sendToFuture(future, new Pair<>(captureUri, size));
+ } catch (IOException ioe) {
+ Log.w(TAG, ioe);
+ sendToFuture(future, ioe);
+ }
+
+ audioCodec = null;
+ captureUri = null;
+ });
+
+ return future;
+ }
+
+ private void sendToFuture(final SettableFuture future, final Exception exception) {
+ Util.runOnMain(() -> future.setException(exception));
+ }
+
+ private void sendToFuture(final SettableFuture future, final T result) {
+ Util.runOnMain(() -> future.set(result));
+ }
+}
diff --git a/app/src/main/java/org/thoughtcrime/securesms/audio/AudioWaveForm.java b/app/src/main/java/org/thoughtcrime/securesms/audio/AudioWaveForm.java
new file mode 100644
index 00000000..2a3814e5
--- /dev/null
+++ b/app/src/main/java/org/thoughtcrime/securesms/audio/AudioWaveForm.java
@@ -0,0 +1,311 @@
+package org.thoughtcrime.securesms.audio;
+
+import android.content.Context;
+import android.media.MediaCodec;
+import android.media.MediaExtractor;
+import android.media.MediaFormat;
+import android.net.Uri;
+import android.os.Build;
+import android.util.LruCache;
+
+import androidx.annotation.AnyThread;
+import androidx.annotation.NonNull;
+import androidx.annotation.RequiresApi;
+import androidx.annotation.WorkerThread;
+import androidx.core.util.Consumer;
+
+import com.google.protobuf.ByteString;
+
+import org.signal.core.util.concurrent.SignalExecutors;
+import org.signal.core.util.logging.Log;
+import org.thoughtcrime.securesms.attachments.Attachment;
+import org.thoughtcrime.securesms.attachments.DatabaseAttachment;
+import org.thoughtcrime.securesms.database.AttachmentDatabase;
+import org.thoughtcrime.securesms.database.DatabaseFactory;
+import org.thoughtcrime.securesms.database.model.databaseprotos.AudioWaveFormData;
+import org.thoughtcrime.securesms.media.DecryptableUriMediaInput;
+import org.thoughtcrime.securesms.media.MediaInput;
+import org.thoughtcrime.securesms.mms.AudioSlide;
+import org.thoughtcrime.securesms.util.Util;
+import org.thoughtcrime.securesms.util.concurrent.SerialExecutor;
+
+import java.io.IOException;
+import java.nio.ByteBuffer;
+import java.util.Locale;
+import java.util.concurrent.Executor;
+import java.util.concurrent.TimeUnit;
+
+@RequiresApi(api = Build.VERSION_CODES.M)
+public final class AudioWaveForm {
+
+ private static final String TAG = Log.tag(AudioWaveForm.class);
+
+ private static final int BAR_COUNT = 46;
+ private static final int SAMPLES_PER_BAR = 4;
+
+ private final Context context;
+ private final AudioSlide slide;
+
+ public AudioWaveForm(@NonNull Context context, @NonNull AudioSlide slide) {
+ this.context = context.getApplicationContext();
+ this.slide = slide;
+ }
+
+ private static final LruCache WAVE_FORM_CACHE = new LruCache<>(200);
+ private static final Executor AUDIO_DECODER_EXECUTOR = new SerialExecutor(SignalExecutors.BOUNDED);
+
+ @AnyThread
+ public void getWaveForm(@NonNull Consumer onSuccess, @NonNull Runnable onFailure) {
+ Uri uri = slide.getUri();
+ Attachment attachment = slide.asAttachment();
+
+ if (uri == null) {
+ Log.w(TAG, "No uri");
+ Util.runOnMain(onFailure);
+ return;
+ }
+
+ if (!(attachment instanceof DatabaseAttachment)) {
+ Log.i(TAG, "Not yet in database");
+ Util.runOnMain(onFailure);
+ return;
+ }
+
+ String cacheKey = uri.toString();
+ AudioFileInfo cached = WAVE_FORM_CACHE.get(cacheKey);
+ if (cached != null) {
+ Log.i(TAG, "Loaded wave form from cache " + cacheKey);
+ Util.runOnMain(() -> onSuccess.accept(cached));
+ return;
+ }
+
+ AUDIO_DECODER_EXECUTOR.execute(() -> {
+ AudioFileInfo cachedInExecutor = WAVE_FORM_CACHE.get(cacheKey);
+ if (cachedInExecutor != null) {
+ Log.i(TAG, "Loaded wave form from cache inside executor" + cacheKey);
+ Util.runOnMain(() -> onSuccess.accept(cachedInExecutor));
+ return;
+ }
+
+ AudioHash audioHash = attachment.getAudioHash();
+ if (audioHash != null) {
+ AudioFileInfo audioFileInfo = AudioFileInfo.fromDatabaseProtobuf(audioHash.getAudioWaveForm());
+ if (audioFileInfo.waveForm.length == 0) {
+ Log.w(TAG, "Recovering from a wave form generation error " + cacheKey);
+ Util.runOnMain(onFailure);
+ return;
+ } else if (audioFileInfo.waveForm.length != BAR_COUNT) {
+ Log.w(TAG, "Wave form from database does not match bar count, regenerating " + cacheKey);
+ } else {
+ WAVE_FORM_CACHE.put(cacheKey, audioFileInfo);
+ Log.i(TAG, "Loaded wave form from DB " + cacheKey);
+ Util.runOnMain(() -> onSuccess.accept(audioFileInfo));
+ return;
+ }
+ }
+
+ try {
+ AttachmentDatabase attachmentDatabase = DatabaseFactory.getAttachmentDatabase(context);
+ DatabaseAttachment dbAttachment = (DatabaseAttachment) attachment;
+ long startTime = System.currentTimeMillis();
+
+ attachmentDatabase.writeAudioHash(dbAttachment.getAttachmentId(), AudioWaveFormData.getDefaultInstance());
+
+ Log.i(TAG, String.format("Starting wave form generation (%s)", cacheKey));
+
+ AudioFileInfo fileInfo = generateWaveForm(uri);
+
+ Log.i(TAG, String.format(Locale.US, "Audio wave form generation time %d ms (%s)", System.currentTimeMillis() - startTime, cacheKey));
+
+ attachmentDatabase.writeAudioHash(dbAttachment.getAttachmentId(), fileInfo.toDatabaseProtobuf());
+
+ WAVE_FORM_CACHE.put(cacheKey, fileInfo);
+ Util.runOnMain(() -> onSuccess.accept(fileInfo));
+ } catch (Throwable e) {
+ Log.w(TAG, "Failed to create audio wave form for " + cacheKey, e);
+ Util.runOnMain(onFailure);
+ }
+ });
+ }
+
+ /**
+ * Based on decode sample from:
+ *
+ * https://android.googlesource.com/platform/cts/+/jb-mr2-release/tests/tests/media/src/android/media/cts/DecoderTest.java
+ */
+ @WorkerThread
+ @RequiresApi(api = 23)
+ private @NonNull AudioFileInfo generateWaveForm(@NonNull Uri uri) throws IOException {
+ try (MediaInput dataSource = DecryptableUriMediaInput.createForUri(context, uri)) {
+ long[] wave = new long[BAR_COUNT];
+ int[] waveSamples = new int[BAR_COUNT];
+
+ MediaExtractor extractor = dataSource.createExtractor();
+
+ if (extractor.getTrackCount() == 0) {
+ throw new IOException("No audio track");
+ }
+
+ MediaFormat format = extractor.getTrackFormat(0);
+
+ if (!format.containsKey(MediaFormat.KEY_DURATION)) {
+ throw new IOException("Unknown duration");
+ }
+
+ long totalDurationUs = format.getLong(MediaFormat.KEY_DURATION);
+ String mime = format.getString(MediaFormat.KEY_MIME);
+
+ if (!mime.startsWith("audio/")) {
+ throw new IOException("Mime not audio");
+ }
+
+ MediaCodec codec = MediaCodec.createDecoderByType(mime);
+
+ if (totalDurationUs == 0) {
+ throw new IOException("Zero duration");
+ }
+
+ codec.configure(format, null, null, 0);
+ codec.start();
+
+ ByteBuffer[] codecInputBuffers = codec.getInputBuffers();
+ ByteBuffer[] codecOutputBuffers = codec.getOutputBuffers();
+
+ extractor.selectTrack(0);
+
+ long kTimeOutUs = 5000;
+ MediaCodec.BufferInfo info = new MediaCodec.BufferInfo();
+ boolean sawInputEOS = false;
+ boolean sawOutputEOS = false;
+ int noOutputCounter = 0;
+
+ while (!sawOutputEOS && noOutputCounter < 50) {
+ noOutputCounter++;
+ if (!sawInputEOS) {
+ int inputBufIndex = codec.dequeueInputBuffer(kTimeOutUs);
+ if (inputBufIndex >= 0) {
+ ByteBuffer dstBuf = codecInputBuffers[inputBufIndex];
+ int sampleSize = extractor.readSampleData(dstBuf, 0);
+ long presentationTimeUs = 0;
+
+ if (sampleSize < 0) {
+ sawInputEOS = true;
+ sampleSize = 0;
+ } else {
+ presentationTimeUs = extractor.getSampleTime();
+ }
+
+ codec.queueInputBuffer(
+ inputBufIndex,
+ 0,
+ sampleSize,
+ presentationTimeUs,
+ sawInputEOS ? MediaCodec.BUFFER_FLAG_END_OF_STREAM : 0);
+
+ if (!sawInputEOS) {
+ int barSampleIndex = (int) (SAMPLES_PER_BAR * (wave.length * extractor.getSampleTime()) / totalDurationUs);
+ sawInputEOS = !extractor.advance();
+ int nextBarSampleIndex = (int) (SAMPLES_PER_BAR * (wave.length * extractor.getSampleTime()) / totalDurationUs);
+ while (!sawInputEOS && nextBarSampleIndex == barSampleIndex) {
+ sawInputEOS = !extractor.advance();
+ if (!sawInputEOS) {
+ nextBarSampleIndex = (int) (SAMPLES_PER_BAR * (wave.length * extractor.getSampleTime()) / totalDurationUs);
+ }
+ }
+ }
+ }
+ }
+
+ int outputBufferIndex;
+ do {
+ outputBufferIndex = codec.dequeueOutputBuffer(info, kTimeOutUs);
+ if (outputBufferIndex >= 0) {
+ if (info.size > 0) {
+ noOutputCounter = 0;
+ }
+
+ ByteBuffer buf = codecOutputBuffers[outputBufferIndex];
+ int barIndex = (int) ((wave.length * info.presentationTimeUs) / totalDurationUs);
+ long total = 0;
+ for (int i = 0; i < info.size; i += 2 * 4) {
+ short aShort = buf.getShort(i);
+ total += Math.abs(aShort);
+ }
+ if (barIndex >= 0 && barIndex < wave.length) {
+ wave[barIndex] += total;
+ waveSamples[barIndex] += info.size / 2;
+ }
+ codec.releaseOutputBuffer(outputBufferIndex, false);
+ if ((info.flags & MediaCodec.BUFFER_FLAG_END_OF_STREAM) != 0) {
+ sawOutputEOS = true;
+ }
+ } else if (outputBufferIndex == MediaCodec.INFO_OUTPUT_BUFFERS_CHANGED) {
+ codecOutputBuffers = codec.getOutputBuffers();
+ } else if (outputBufferIndex == MediaCodec.INFO_OUTPUT_FORMAT_CHANGED) {
+ Log.d(TAG, "output format has changed to " + codec.getOutputFormat());
+ }
+ } while (outputBufferIndex >= 0);
+ }
+
+ codec.stop();
+ codec.release();
+ extractor.release();
+
+ float[] floats = new float[BAR_COUNT];
+ byte[] bytes = new byte[BAR_COUNT];
+ float max = 0;
+
+ for (int i = 0; i < BAR_COUNT; i++) {
+ if (waveSamples[i] == 0) continue;
+
+ floats[i] = wave[i] / (float) waveSamples[i];
+ if (floats[i] > max) {
+ max = floats[i];
+ }
+ }
+
+ for (int i = 0; i < BAR_COUNT; i++) {
+ float normalized = floats[i] / max;
+ bytes[i] = (byte) (255 * normalized);
+ }
+
+ return new AudioFileInfo(totalDurationUs, bytes);
+ }
+ }
+
+ public static class AudioFileInfo {
+ private final long durationUs;
+ private final byte[] waveFormBytes;
+ private final float[] waveForm;
+
+ private static @NonNull AudioFileInfo fromDatabaseProtobuf(@NonNull AudioWaveFormData audioWaveForm) {
+ return new AudioFileInfo(audioWaveForm.getDurationUs(), audioWaveForm.getWaveForm().toByteArray());
+ }
+
+ private AudioFileInfo(long durationUs, byte[] waveFormBytes) {
+ this.durationUs = durationUs;
+ this.waveFormBytes = waveFormBytes;
+ this.waveForm = new float[waveFormBytes.length];
+
+ for (int i = 0; i < waveFormBytes.length; i++) {
+ int unsigned = waveFormBytes[i] & 0xff;
+ this.waveForm[i] = unsigned / 255f;
+ }
+ }
+
+ public long getDuration(@NonNull TimeUnit timeUnit) {
+ return timeUnit.convert(durationUs, TimeUnit.MICROSECONDS);
+ }
+
+ public float[] getWaveForm() {
+ return waveForm;
+ }
+
+ private @NonNull AudioWaveFormData toDatabaseProtobuf() {
+ return AudioWaveFormData.newBuilder()
+ .setDurationUs(durationUs)
+ .setWaveForm(ByteString.copyFrom(waveFormBytes))
+ .build();
+ }
+ }
+}
diff --git a/app/src/main/java/org/thoughtcrime/securesms/backup/BackupDialog.java b/app/src/main/java/org/thoughtcrime/securesms/backup/BackupDialog.java
new file mode 100644
index 00000000..cf6ef6ae
--- /dev/null
+++ b/app/src/main/java/org/thoughtcrime/securesms/backup/BackupDialog.java
@@ -0,0 +1,187 @@
+package org.thoughtcrime.securesms.backup;
+
+
+import android.content.ActivityNotFoundException;
+import android.content.ClipData;
+import android.content.ClipboardManager;
+import android.content.Context;
+import android.content.Intent;
+import android.net.Uri;
+import android.os.Build;
+import android.provider.DocumentsContract;
+import android.view.LayoutInflater;
+import android.view.View;
+import android.widget.Button;
+import android.widget.CheckBox;
+import android.widget.EditText;
+import android.widget.TextView;
+import android.widget.Toast;
+
+import androidx.annotation.NonNull;
+import androidx.annotation.Nullable;
+import androidx.annotation.RequiresApi;
+import androidx.appcompat.app.AlertDialog;
+import androidx.fragment.app.Fragment;
+
+import org.signal.core.util.logging.Log;
+import org.thoughtcrime.securesms.R;
+import org.thoughtcrime.securesms.keyvalue.SignalStore;
+import org.thoughtcrime.securesms.registration.fragments.RestoreBackupFragment;
+import org.thoughtcrime.securesms.service.LocalBackupListener;
+import org.thoughtcrime.securesms.util.BackupUtil;
+import org.thoughtcrime.securesms.util.TextSecurePreferences;
+import org.thoughtcrime.securesms.util.Util;
+import org.thoughtcrime.securesms.util.text.AfterTextChanged;
+
+public class BackupDialog {
+
+ private static final String TAG = Log.tag(BackupDialog.class);
+
+ public static void showEnableBackupDialog(@NonNull Context context,
+ @Nullable Intent backupDirectorySelectionIntent,
+ @Nullable String backupDirectoryDisplayName,
+ @NonNull Runnable onBackupsEnabled)
+ {
+ String[] password = BackupUtil.generateBackupPassphrase();
+ AlertDialog dialog = new AlertDialog.Builder(context)
+ .setTitle(R.string.BackupDialog_enable_local_backups)
+ .setView(backupDirectorySelectionIntent != null ? R.layout.backup_enable_dialog_v29 : R.layout.backup_enable_dialog)
+ .setPositiveButton(R.string.BackupDialog_enable_backups, null)
+ .setNegativeButton(android.R.string.cancel, null)
+ .create();
+
+ dialog.setOnShowListener(created -> {
+ if (backupDirectoryDisplayName != null) {
+ TextView folderName = dialog.findViewById(R.id.backup_enable_dialog_folder_name);
+ if (folderName != null) {
+ folderName.setText(backupDirectoryDisplayName);
+ }
+ }
+
+ Button button = ((AlertDialog) created).getButton(AlertDialog.BUTTON_POSITIVE);
+ button.setOnClickListener(v -> {
+ CheckBox confirmationCheckBox = dialog.findViewById(R.id.confirmation_check);
+ if (confirmationCheckBox.isChecked()) {
+ if (backupDirectorySelectionIntent != null && backupDirectorySelectionIntent.getData() != null) {
+ Uri backupDirectoryUri = backupDirectorySelectionIntent.getData();
+ int takeFlags = Intent.FLAG_GRANT_READ_URI_PERMISSION |
+ Intent.FLAG_GRANT_WRITE_URI_PERMISSION;
+
+ SignalStore.settings().setSignalBackupDirectory(backupDirectoryUri);
+ context.getContentResolver()
+ .takePersistableUriPermission(backupDirectoryUri, takeFlags);
+ }
+
+ BackupPassphrase.set(context, Util.join(password, " "));
+ TextSecurePreferences.setNextBackupTime(context, 0);
+ TextSecurePreferences.setBackupEnabled(context, true);
+ LocalBackupListener.schedule(context);
+
+ onBackupsEnabled.run();
+ created.dismiss();
+ } else {
+ Toast.makeText(context, R.string.BackupDialog_please_acknowledge_your_understanding_by_marking_the_confirmation_check_box, Toast.LENGTH_LONG).show();
+ }
+ });
+ });
+
+ dialog.show();
+
+ CheckBox checkBox = dialog.findViewById(R.id.confirmation_check);
+ TextView textView = dialog.findViewById(R.id.confirmation_text);
+
+ ((TextView)dialog.findViewById(R.id.code_first)).setText(password[0]);
+ ((TextView)dialog.findViewById(R.id.code_second)).setText(password[1]);
+ ((TextView)dialog.findViewById(R.id.code_third)).setText(password[2]);
+
+ ((TextView)dialog.findViewById(R.id.code_fourth)).setText(password[3]);
+ ((TextView)dialog.findViewById(R.id.code_fifth)).setText(password[4]);
+ ((TextView)dialog.findViewById(R.id.code_sixth)).setText(password[5]);
+
+ textView.setOnClickListener(v -> checkBox.toggle());
+
+ dialog.findViewById(R.id.number_table).setOnClickListener(v -> {
+ ((ClipboardManager)context.getSystemService(Context.CLIPBOARD_SERVICE)).setPrimaryClip(ClipData.newPlainText("text", Util.join(password, " ")));
+ Toast.makeText(context, R.string.BackupDialog_copied_to_clipboard, Toast.LENGTH_LONG).show();
+ });
+
+
+ }
+
+ @RequiresApi(29)
+ public static void showChooseBackupLocationDialog(@NonNull Fragment fragment, int requestCode) {
+ new AlertDialog.Builder(fragment.requireContext())
+ .setView(R.layout.backup_choose_location_dialog)
+ .setCancelable(true)
+ .setNegativeButton(android.R.string.cancel, (dialog, which) -> {
+ dialog.dismiss();
+ })
+ .setPositiveButton(R.string.BackupDialog_choose_folder, ((dialog, which) -> {
+ Intent intent = new Intent(Intent.ACTION_OPEN_DOCUMENT_TREE);
+
+ if (Build.VERSION.SDK_INT >= 26) {
+ intent.putExtra(DocumentsContract.EXTRA_INITIAL_URI, SignalStore.settings().getLatestSignalBackupDirectory());
+ }
+
+ intent.addFlags(Intent.FLAG_GRANT_PERSISTABLE_URI_PERMISSION |
+ Intent.FLAG_GRANT_WRITE_URI_PERMISSION |
+ Intent.FLAG_GRANT_READ_URI_PERMISSION);
+
+ try {
+ fragment.startActivityForResult(intent, requestCode);
+ } catch (ActivityNotFoundException e) {
+ Toast.makeText(fragment.requireContext(), R.string.BackupDialog_no_file_picker_available, Toast.LENGTH_LONG)
+ .show();
+ }
+
+ dialog.dismiss();
+ }))
+ .create()
+ .show();
+ }
+
+ public static void showDisableBackupDialog(@NonNull Context context, @NonNull Runnable onBackupsDisabled) {
+ new AlertDialog.Builder(context)
+ .setTitle(R.string.BackupDialog_delete_backups)
+ .setMessage(R.string.BackupDialog_disable_and_delete_all_local_backups)
+ .setNegativeButton(android.R.string.cancel, null)
+ .setPositiveButton(R.string.BackupDialog_delete_backups_statement, (dialog, which) -> {
+ BackupUtil.disableBackups(context);
+
+ onBackupsDisabled.run();
+ })
+ .create()
+ .show();
+ }
+
+ public static void showVerifyBackupPassphraseDialog(@NonNull Context context) {
+ View view = LayoutInflater.from(context).inflate(R.layout.enter_backup_passphrase_dialog, null);
+ EditText prompt = view.findViewById(R.id.restore_passphrase_input);
+ AlertDialog dialog = new AlertDialog.Builder(context)
+ .setTitle(R.string.BackupDialog_enter_backup_passphrase_to_verify)
+ .setView(view)
+ .setPositiveButton(R.string.BackupDialog_verify, null)
+ .setNegativeButton(android.R.string.cancel, null)
+ .show();
+
+ Button positiveButton = dialog.getButton(AlertDialog.BUTTON_POSITIVE);
+ positiveButton.setEnabled(false);
+
+ RestoreBackupFragment.PassphraseAsYouTypeFormatter formatter = new RestoreBackupFragment.PassphraseAsYouTypeFormatter();
+
+ prompt.addTextChangedListener(new AfterTextChanged(editable -> {
+ formatter.afterTextChanged(editable);
+ positiveButton.setEnabled(editable.length() == BackupUtil.PASSPHRASE_LENGTH);
+ }));
+
+ positiveButton.setOnClickListener(v -> {
+ String passphrase = prompt.getText().toString();
+ if (passphrase.equals(BackupPassphrase.get(context))) {
+ Toast.makeText(context, R.string.BackupDialog_you_successfully_entered_your_backup_passphrase, Toast.LENGTH_SHORT).show();
+ dialog.dismiss();
+ } else {
+ Toast.makeText(context, R.string.BackupDialog_passphrase_was_not_correct, Toast.LENGTH_SHORT).show();
+ }
+ });
+ }
+}
diff --git a/app/src/main/java/org/thoughtcrime/securesms/backup/BackupFileIOError.java b/app/src/main/java/org/thoughtcrime/securesms/backup/BackupFileIOError.java
new file mode 100644
index 00000000..d0f8006a
--- /dev/null
+++ b/app/src/main/java/org/thoughtcrime/securesms/backup/BackupFileIOError.java
@@ -0,0 +1,78 @@
+package org.thoughtcrime.securesms.backup;
+
+import android.app.Notification;
+import android.app.PendingIntent;
+import android.content.Context;
+import android.content.Intent;
+
+import androidx.annotation.NonNull;
+import androidx.annotation.Nullable;
+import androidx.annotation.StringRes;
+import androidx.core.app.NotificationCompat;
+import androidx.core.app.NotificationManagerCompat;
+
+import org.thoughtcrime.securesms.ApplicationPreferencesActivity;
+import org.thoughtcrime.securesms.R;
+import org.thoughtcrime.securesms.notifications.NotificationCancellationHelper;
+import org.thoughtcrime.securesms.notifications.NotificationChannels;
+
+import java.io.IOException;
+
+public enum BackupFileIOError {
+ ACCESS_ERROR(R.string.LocalBackupJobApi29_backup_failed, R.string.LocalBackupJobApi29_your_backup_directory_has_been_deleted_or_moved),
+ FILE_TOO_LARGE(R.string.LocalBackupJobApi29_backup_failed, R.string.LocalBackupJobApi29_your_backup_file_is_too_large),
+ NOT_ENOUGH_SPACE(R.string.LocalBackupJobApi29_backup_failed, R.string.LocalBackupJobApi29_there_is_not_enough_space),
+ UNKNOWN(R.string.LocalBackupJobApi29_backup_failed, R.string.LocalBackupJobApi29_tap_to_manage_backups);
+
+ private static final short BACKUP_FAILED_ID = 31321;
+
+ private final @StringRes int titleId;
+ private final @StringRes int messageId;
+
+ BackupFileIOError(@StringRes int titleId, @StringRes int messageId) {
+ this.titleId = titleId;
+ this.messageId = messageId;
+ }
+
+ public static void clearNotification(@NonNull Context context) {
+ NotificationCancellationHelper.cancelLegacy(context, BACKUP_FAILED_ID);
+ }
+
+ public void postNotification(@NonNull Context context) {
+ Intent intent = new Intent(context, ApplicationPreferencesActivity.class);
+
+ intent.putExtra(ApplicationPreferencesActivity.LAUNCH_TO_BACKUPS_FRAGMENT, true);
+
+ PendingIntent pendingIntent = PendingIntent.getActivity(context, -1, intent, 0);
+ Notification backupFailedNotification = new NotificationCompat.Builder(context, NotificationChannels.FAILURES)
+ .setSmallIcon(R.drawable.ic_signal_backup)
+ .setContentTitle(context.getString(titleId))
+ .setContentText(context.getString(messageId))
+ .setContentIntent(pendingIntent)
+ .build();
+
+ NotificationManagerCompat.from(context)
+ .notify(BACKUP_FAILED_ID, backupFailedNotification);
+ }
+
+ public static void postNotificationForException(@NonNull Context context, @NonNull IOException e, int runAttempt) {
+ BackupFileIOError error = getFromException(e);
+
+ if (error != null) {
+ error.postNotification(context);
+ }
+
+ if (error == null && runAttempt > 0) {
+ UNKNOWN.postNotification(context);
+ }
+ }
+
+ private static @Nullable BackupFileIOError getFromException(@NonNull IOException e) {
+ if (e.getMessage() != null) {
+ if (e.getMessage().contains("EFBIG")) return FILE_TOO_LARGE;
+ else if (e.getMessage().contains("ENOSPC")) return NOT_ENOUGH_SPACE;
+ }
+
+ return null;
+ }
+}
diff --git a/app/src/main/java/org/thoughtcrime/securesms/backup/BackupPassphrase.java b/app/src/main/java/org/thoughtcrime/securesms/backup/BackupPassphrase.java
new file mode 100644
index 00000000..105f2f12
--- /dev/null
+++ b/app/src/main/java/org/thoughtcrime/securesms/backup/BackupPassphrase.java
@@ -0,0 +1,56 @@
+package org.thoughtcrime.securesms.backup;
+
+import android.content.Context;
+import android.os.Build;
+
+import androidx.annotation.NonNull;
+import androidx.annotation.Nullable;
+
+import org.signal.core.util.logging.Log;
+import org.thoughtcrime.securesms.crypto.KeyStoreHelper;
+import org.thoughtcrime.securesms.util.TextSecurePreferences;
+
+/**
+ * Allows the getting and setting of the backup passphrase, which is stored encrypted on API >= 23.
+ */
+public final class BackupPassphrase {
+
+ private 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 stripSpaces(passphrase);
+ }
+
+ if (encryptedPassphrase == null) {
+ Log.i(TAG, "Migrating to encrypted passphrase.");
+ set(context, passphrase);
+ encryptedPassphrase = TextSecurePreferences.getEncryptedBackupPassphrase(context);
+ if (encryptedPassphrase == null) throw new AssertionError("Passphrase migration failed");
+ }
+
+ KeyStoreHelper.SealedData data = KeyStoreHelper.SealedData.fromString(encryptedPassphrase);
+ return stripSpaces(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);
+ }
+ }
+
+ private static String stripSpaces(@Nullable String passphrase) {
+ return passphrase != null ? passphrase.replace(" ", "") : null;
+ }
+}
diff --git a/app/src/main/java/org/thoughtcrime/securesms/backup/FullBackupBase.java b/app/src/main/java/org/thoughtcrime/securesms/backup/FullBackupBase.java
new file mode 100644
index 00000000..c88c4fec
--- /dev/null
+++ b/app/src/main/java/org/thoughtcrime/securesms/backup/FullBackupBase.java
@@ -0,0 +1,65 @@
+package org.thoughtcrime.securesms.backup;
+
+
+import androidx.annotation.NonNull;
+import androidx.annotation.Nullable;
+
+import org.greenrobot.eventbus.EventBus;
+import org.whispersystems.libsignal.util.ByteUtil;
+
+import java.security.MessageDigest;
+import java.security.NoSuchAlgorithmException;
+
+public abstract class FullBackupBase {
+
+ @SuppressWarnings("unused")
+ private static final String TAG = FullBackupBase.class.getSimpleName();
+
+ static class BackupStream {
+ static @NonNull byte[] getBackupKey(@NonNull String passphrase, @Nullable byte[] salt) {
+ try {
+ EventBus.getDefault().post(new BackupEvent(BackupEvent.Type.PROGRESS, 0));
+
+ MessageDigest digest = MessageDigest.getInstance("SHA-512");
+ byte[] input = passphrase.replace(" ", "").getBytes();
+ byte[] hash = input;
+
+ if (salt != null) digest.update(salt);
+
+ for (int i=0;i<250000;i++) {
+ if (i % 1000 == 0) EventBus.getDefault().post(new BackupEvent(BackupEvent.Type.PROGRESS, 0));
+ digest.update(hash);
+ hash = digest.digest(input);
+ }
+
+ return ByteUtil.trim(hash, 32);
+ } catch (NoSuchAlgorithmException e) {
+ throw new AssertionError(e);
+ }
+ }
+ }
+
+ public static class BackupEvent {
+ public enum Type {
+ PROGRESS,
+ FINISHED
+ }
+
+ private final Type type;
+ private final int count;
+
+ BackupEvent(Type type, int count) {
+ this.type = type;
+ this.count = count;
+ }
+
+ public Type getType() {
+ return type;
+ }
+
+ public int getCount() {
+ return count;
+ }
+ }
+
+}
diff --git a/app/src/main/java/org/thoughtcrime/securesms/backup/FullBackupExporter.java b/app/src/main/java/org/thoughtcrime/securesms/backup/FullBackupExporter.java
new file mode 100644
index 00000000..7bea78e1
--- /dev/null
+++ b/app/src/main/java/org/thoughtcrime/securesms/backup/FullBackupExporter.java
@@ -0,0 +1,498 @@
+package org.thoughtcrime.securesms.backup;
+
+
+import android.content.Context;
+import android.database.Cursor;
+import android.text.TextUtils;
+
+import androidx.annotation.NonNull;
+import androidx.annotation.Nullable;
+import androidx.annotation.RequiresApi;
+import androidx.documentfile.provider.DocumentFile;
+
+import com.annimon.stream.function.Consumer;
+import com.annimon.stream.function.Predicate;
+import com.google.protobuf.ByteString;
+
+import net.sqlcipher.database.SQLiteDatabase;
+
+import org.greenrobot.eventbus.EventBus;
+import org.signal.core.util.Conversions;
+import org.signal.core.util.logging.Log;
+import org.thoughtcrime.securesms.attachments.AttachmentId;
+import org.thoughtcrime.securesms.crypto.AttachmentSecret;
+import org.thoughtcrime.securesms.crypto.ClassicDecryptingPartInputStream;
+import org.thoughtcrime.securesms.crypto.IdentityKeyUtil;
+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.KeyValueDatabase;
+import org.thoughtcrime.securesms.database.MmsDatabase;
+import org.thoughtcrime.securesms.database.MmsSmsColumns;
+import org.thoughtcrime.securesms.database.OneTimePreKeyDatabase;
+import org.thoughtcrime.securesms.database.SearchDatabase;
+import org.thoughtcrime.securesms.database.SessionDatabase;
+import org.thoughtcrime.securesms.database.SignedPreKeyDatabase;
+import org.thoughtcrime.securesms.database.SmsDatabase;
+import org.thoughtcrime.securesms.database.StickerDatabase;
+import org.thoughtcrime.securesms.profiles.AvatarHelper;
+import org.thoughtcrime.securesms.util.SetUtil;
+import org.thoughtcrime.securesms.util.Stopwatch;
+import org.thoughtcrime.securesms.util.Util;
+import org.whispersystems.libsignal.kdf.HKDFv3;
+import org.whispersystems.libsignal.util.ByteUtil;
+
+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.NoSuchAlgorithmException;
+import java.util.LinkedList;
+import java.util.List;
+import java.util.Objects;
+import java.util.Set;
+
+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;
+
+public class FullBackupExporter extends FullBackupBase {
+
+ @SuppressWarnings("unused")
+ private static final String TAG = FullBackupExporter.class.getSimpleName();
+
+ private static final Set BLACKLISTED_TABLES = SetUtil.newHashSet(
+ SignedPreKeyDatabase.TABLE_NAME,
+ OneTimePreKeyDatabase.TABLE_NAME,
+ SessionDatabase.TABLE_NAME,
+ SearchDatabase.SMS_FTS_TABLE_NAME,
+ SearchDatabase.MMS_FTS_TABLE_NAME
+ );
+
+ public static void export(@NonNull Context context,
+ @NonNull AttachmentSecret attachmentSecret,
+ @NonNull SQLiteDatabase input,
+ @NonNull File output,
+ @NonNull String passphrase)
+ throws IOException
+ {
+ try (OutputStream outputStream = new FileOutputStream(output)) {
+ internalExport(context, attachmentSecret, input, outputStream, passphrase);
+ }
+ }
+
+ @RequiresApi(29)
+ public static void export(@NonNull Context context,
+ @NonNull AttachmentSecret attachmentSecret,
+ @NonNull SQLiteDatabase input,
+ @NonNull DocumentFile output,
+ @NonNull String passphrase)
+ throws IOException
+ {
+ try (OutputStream outputStream = Objects.requireNonNull(context.getContentResolver().openOutputStream(output.getUri()))) {
+ internalExport(context, attachmentSecret, input, outputStream, passphrase);
+ }
+ }
+
+ private static void internalExport(@NonNull Context context,
+ @NonNull AttachmentSecret attachmentSecret,
+ @NonNull SQLiteDatabase input,
+ @NonNull OutputStream fileOutputStream,
+ @NonNull String passphrase)
+ throws IOException
+ {
+ BackupFrameOutputStream outputStream = new BackupFrameOutputStream(fileOutputStream, passphrase);
+ int count = 0;
+
+ try {
+ outputStream.writeDatabaseVersion(input.getVersion());
+
+ List tables = exportSchema(input, outputStream);
+
+ Stopwatch stopwatch = new Stopwatch("Backup");
+
+ for (String table : tables) {
+ if (table.equals(MmsDatabase.TABLE_NAME)) {
+ count = exportTable(table, input, outputStream, FullBackupExporter::isNonExpiringMmsMessage, null, count);
+ } else if (table.equals(SmsDatabase.TABLE_NAME)) {
+ count = exportTable(table, input, outputStream, FullBackupExporter::isNonExpiringSmsMessage, null, count);
+ } else if (table.equals(GroupReceiptDatabase.TABLE_NAME)) {
+ count = exportTable(table, input, outputStream, cursor -> isForNonExpiringMessage(input, cursor.getLong(cursor.getColumnIndexOrThrow(GroupReceiptDatabase.MMS_ID))), null, count);
+ } else if (table.equals(AttachmentDatabase.TABLE_NAME)) {
+ count = exportTable(table, input, outputStream, cursor -> isForNonExpiringMessage(input, cursor.getLong(cursor.getColumnIndexOrThrow(AttachmentDatabase.MMS_ID))), cursor -> exportAttachment(attachmentSecret, cursor, outputStream), count);
+ } else if (table.equals(StickerDatabase.TABLE_NAME)) {
+ count = exportTable(table, input, outputStream, cursor -> true, cursor -> exportSticker(attachmentSecret, cursor, outputStream), count);
+ } else if (!BLACKLISTED_TABLES.contains(table) && !table.startsWith("sqlite_")) {
+ count = exportTable(table, input, outputStream, null, null, count);
+ }
+ stopwatch.split("table::" + table);
+ }
+
+ for (BackupProtos.SharedPreference preference : IdentityKeyUtil.getBackupRecord(context)) {
+ EventBus.getDefault().post(new BackupEvent(BackupEvent.Type.PROGRESS, ++count));
+ outputStream.write(preference);
+ }
+
+ stopwatch.split("prefs");
+
+ for (AvatarHelper.Avatar avatar : AvatarHelper.getAvatars(context)) {
+ if (avatar != null) {
+ EventBus.getDefault().post(new BackupEvent(BackupEvent.Type.PROGRESS, ++count));
+ outputStream.write(avatar.getFilename(), avatar.getInputStream(), avatar.getLength());
+ }
+ }
+
+ stopwatch.split("avatars");
+ stopwatch.stop(TAG);
+
+ outputStream.writeEnd();
+ } finally {
+ outputStream.close();
+ EventBus.getDefault().post(new BackupEvent(BackupEvent.Type.FINISHED, ++count));
+ }
+ }
+
+ private static List exportSchema(@NonNull SQLiteDatabase input, @NonNull BackupFrameOutputStream outputStream)
+ throws IOException
+ {
+ List tables = new LinkedList<>();
+
+ try (Cursor cursor = input.rawQuery("SELECT sql, name, type FROM sqlite_master", null)) {
+ while (cursor != null && cursor.moveToNext()) {
+ String sql = cursor.getString(0);
+ String name = cursor.getString(1);
+ String type = cursor.getString(2);
+
+ if (sql != null) {
+
+ boolean isSmsFtsSecretTable = name != null && !name.equals(SearchDatabase.SMS_FTS_TABLE_NAME) && name.startsWith(SearchDatabase.SMS_FTS_TABLE_NAME);
+ boolean isMmsFtsSecretTable = name != null && !name.equals(SearchDatabase.MMS_FTS_TABLE_NAME) && name.startsWith(SearchDatabase.MMS_FTS_TABLE_NAME);
+
+ if (!isSmsFtsSecretTable && !isMmsFtsSecretTable) {
+ if ("table".equals(type)) {
+ tables.add(name);
+ }
+
+ outputStream.write(BackupProtos.SqlStatement.newBuilder().setStatement(cursor.getString(0)).build());
+ }
+ }
+ }
+ }
+
+ return tables;
+ }
+
+ private static int exportTable(@NonNull String table,
+ @NonNull SQLiteDatabase input,
+ @NonNull BackupFrameOutputStream outputStream,
+ @Nullable Predicate predicate,
+ @Nullable Consumer postProcess,
+ int count)
+ throws IOException
+ {
+ String template = "INSERT INTO " + table + " VALUES ";
+
+ try (Cursor cursor = input.rawQuery("SELECT * FROM " + table, null)) {
+ while (cursor != null && cursor.moveToNext()) {
+ EventBus.getDefault().post(new BackupEvent(BackupEvent.Type.PROGRESS, ++count));
+
+ if (predicate == null || predicate.test(cursor)) {
+ StringBuilder statement = new StringBuilder(template);
+ BackupProtos.SqlStatement.Builder statementBuilder = BackupProtos.SqlStatement.newBuilder();
+
+ statement.append('(');
+
+ for (int i=0;i 0) {
+ InputStream inputStream;
+
+ if (random != null && random.length == 32) inputStream = ModernDecryptingPartInputStream.createFor(attachmentSecret, random, new File(data), 0);
+ else inputStream = ClassicDecryptingPartInputStream.createFor(attachmentSecret, new File(data));
+
+ outputStream.write(new AttachmentId(rowId, uniqueId), inputStream, size);
+ }
+ } catch (IOException e) {
+ Log.w(TAG, e);
+ }
+ }
+
+ private static void exportSticker(@NonNull AttachmentSecret attachmentSecret, @NonNull Cursor cursor, @NonNull BackupFrameOutputStream outputStream) {
+ try {
+ long rowId = cursor.getLong(cursor.getColumnIndexOrThrow(StickerDatabase._ID));
+ long size = cursor.getLong(cursor.getColumnIndexOrThrow(StickerDatabase.FILE_LENGTH));
+
+ String data = cursor.getString(cursor.getColumnIndexOrThrow(StickerDatabase.FILE_PATH));
+ byte[] random = cursor.getBlob(cursor.getColumnIndexOrThrow(StickerDatabase.FILE_RANDOM));
+
+ if (!TextUtils.isEmpty(data) && size > 0) {
+ InputStream inputStream = ModernDecryptingPartInputStream.createFor(attachmentSecret, random, new File(data), 0);
+ outputStream.writeSticker(rowId, inputStream, size);
+ }
+ } catch (IOException e) {
+ Log.w(TAG, e);
+ }
+ }
+
+ private static long calculateVeryOldStreamLength(@NonNull AttachmentSecret attachmentSecret, @Nullable byte[] random, @NonNull String data) throws IOException {
+ long result = 0;
+ InputStream inputStream;
+
+ if (random != null && random.length == 32) inputStream = ModernDecryptingPartInputStream.createFor(attachmentSecret, random, new File(data), 0);
+ else inputStream = ClassicDecryptingPartInputStream.createFor(attachmentSecret, new File(data));
+
+ int read;
+ byte[] buffer = new byte[8192];
+
+ while ((read = inputStream.read(buffer, 0, buffer.length)) != -1) {
+ result += read;
+ }
+
+ return result;
+ }
+
+ private static boolean isNonExpiringMmsMessage(@NonNull Cursor cursor) {
+ return cursor.getInt(cursor.getColumnIndexOrThrow(MmsSmsColumns.EXPIRES_IN)) <= 0 &&
+ cursor.getInt(cursor.getColumnIndexOrThrow(MmsDatabase.VIEW_ONCE)) <= 0;
+ }
+
+ private static boolean isNonExpiringSmsMessage(@NonNull Cursor cursor) {
+ return cursor.getInt(cursor.getColumnIndexOrThrow(MmsSmsColumns.EXPIRES_IN)) <= 0;
+ }
+
+ private static boolean isForNonExpiringMessage(@NonNull SQLiteDatabase db, long mmsId) {
+ String[] columns = new String[] { MmsDatabase.EXPIRES_IN, MmsDatabase.VIEW_ONCE};
+ String where = MmsDatabase.ID + " = ?";
+ String[] args = new String[] { String.valueOf(mmsId) };
+
+ try (Cursor mmsCursor = db.query(MmsDatabase.TABLE_NAME, columns, where, args, null, null, null)) {
+ if (mmsCursor != null && mmsCursor.moveToFirst()) {
+ return mmsCursor.getLong(mmsCursor.getColumnIndexOrThrow(MmsDatabase.EXPIRES_IN)) == 0 &&
+ mmsCursor.getLong(mmsCursor.getColumnIndexOrThrow(MmsDatabase.VIEW_ONCE)) == 0;
+ }
+ }
+
+ return false;
+ }
+
+
+ private static class BackupFrameOutputStream extends BackupStream {
+
+ private final OutputStream outputStream;
+ private final Cipher cipher;
+ private final Mac mac;
+
+ private final byte[] cipherKey;
+ private final byte[] macKey;
+
+ private byte[] iv;
+ private int counter;
+
+ private BackupFrameOutputStream(@NonNull OutputStream output, @NonNull String passphrase) throws IOException {
+ try {
+ byte[] salt = Util.getSecretBytes(32);
+ byte[] key = getBackupKey(passphrase, salt);
+ byte[] derived = new HKDFv3().deriveSecrets(key, "Backup Export".getBytes(), 64);
+ byte[][] split = ByteUtil.split(derived, 32, 32);
+
+ this.cipherKey = split[0];
+ this.macKey = split[1];
+
+ this.cipher = Cipher.getInstance("AES/CTR/NoPadding");
+ this.mac = Mac.getInstance("HmacSHA256");
+ this.outputStream = output;
+ this.iv = Util.getSecretBytes(16);
+ this.counter = Conversions.byteArrayToInt(iv);
+
+ mac.init(new SecretKeySpec(macKey, "HmacSHA256"));
+
+ byte[] header = BackupProtos.BackupFrame.newBuilder().setHeader(BackupProtos.Header.newBuilder()
+ .setIv(ByteString.copyFrom(iv))
+ .setSalt(ByteString.copyFrom(salt)))
+ .build().toByteArray();
+
+ outputStream.write(Conversions.intToByteArray(header.length));
+ outputStream.write(header);
+ } catch (NoSuchAlgorithmException | NoSuchPaddingException | InvalidKeyException e) {
+ throw new AssertionError(e);
+ }
+ }
+
+ public void write(BackupProtos.SharedPreference preference) throws IOException {
+ write(outputStream, BackupProtos.BackupFrame.newBuilder().setPreference(preference).build());
+ }
+
+ public void write(BackupProtos.SqlStatement statement) throws IOException {
+ write(outputStream, BackupProtos.BackupFrame.newBuilder().setStatement(statement).build());
+ }
+
+ public void write(@NonNull String avatarName, @NonNull InputStream in, long size) throws IOException {
+ write(outputStream, BackupProtos.BackupFrame.newBuilder()
+ .setAvatar(BackupProtos.Avatar.newBuilder()
+ .setRecipientId(avatarName)
+ .setLength(Util.toIntExact(size))
+ .build())
+ .build());
+
+ if (writeStream(in) != size) {
+ throw new IOException("Size mismatch!");
+ }
+ }
+
+ public void write(@NonNull AttachmentId attachmentId, @NonNull InputStream in, long size) throws IOException {
+ write(outputStream, BackupProtos.BackupFrame.newBuilder()
+ .setAttachment(BackupProtos.Attachment.newBuilder()
+ .setRowId(attachmentId.getRowId())
+ .setAttachmentId(attachmentId.getUniqueId())
+ .setLength(Util.toIntExact(size))
+ .build())
+ .build());
+
+ if (writeStream(in) != size) {
+ throw new IOException("Size mismatch!");
+ }
+ }
+
+ public void writeSticker(long rowId, @NonNull InputStream in, long size) throws IOException {
+ write(outputStream, BackupProtos.BackupFrame.newBuilder()
+ .setSticker(BackupProtos.Sticker.newBuilder()
+ .setRowId(rowId)
+ .setLength(Util.toIntExact(size))
+ .build())
+ .build());
+
+ if (writeStream(in) != size) {
+ throw new IOException("Size mismatch!");
+ }
+ }
+
+ void writeDatabaseVersion(int version) throws IOException {
+ write(outputStream, BackupProtos.BackupFrame.newBuilder()
+ .setVersion(BackupProtos.DatabaseVersion.newBuilder().setVersion(version))
+ .build());
+ }
+
+ void writeEnd() throws IOException {
+ write(outputStream, BackupProtos.BackupFrame.newBuilder().setEnd(true).build());
+ }
+
+ /**
+ * @return The amount of data written from the provided InputStream.
+ */
+ private long writeStream(@NonNull InputStream inputStream) throws IOException {
+ try {
+ Conversions.intToByteArray(iv, 0, counter++);
+ cipher.init(Cipher.ENCRYPT_MODE, new SecretKeySpec(cipherKey, "AES"), new IvParameterSpec(iv));
+ mac.update(iv);
+
+ byte[] buffer = new byte[8192];
+ long total = 0;
+
+ int read;
+
+ while ((read = inputStream.read(buffer)) != -1) {
+ byte[] ciphertext = cipher.update(buffer, 0, read);
+
+ if (ciphertext != null) {
+ outputStream.write(ciphertext);
+ mac.update(ciphertext);
+ }
+
+ total += read;
+ }
+
+ byte[] remainder = cipher.doFinal();
+ outputStream.write(remainder);
+ mac.update(remainder);
+
+ byte[] attachmentDigest = mac.doFinal();
+ outputStream.write(attachmentDigest, 0, 10);
+
+ return total;
+ } catch (InvalidKeyException | InvalidAlgorithmParameterException | IllegalBlockSizeException | BadPaddingException e) {
+ throw new AssertionError(e);
+ }
+ }
+
+ private void write(@NonNull OutputStream out, @NonNull BackupProtos.BackupFrame frame) throws IOException {
+ try {
+ Conversions.intToByteArray(iv, 0, counter++);
+ cipher.init(Cipher.ENCRYPT_MODE, new SecretKeySpec(cipherKey, "AES"), new IvParameterSpec(iv));
+
+ byte[] frameCiphertext = cipher.doFinal(frame.toByteArray());
+ byte[] frameMac = mac.doFinal(frameCiphertext);
+ byte[] length = Conversions.intToByteArray(frameCiphertext.length + 10);
+
+ out.write(length);
+ out.write(frameCiphertext);
+ out.write(frameMac, 0, 10);
+ } catch (InvalidKeyException | InvalidAlgorithmParameterException | IllegalBlockSizeException | BadPaddingException e) {
+ throw new AssertionError(e);
+ }
+ }
+
+
+ public void close() throws IOException {
+ outputStream.close();
+ }
+ }
+}
diff --git a/app/src/main/java/org/thoughtcrime/securesms/backup/FullBackupImporter.java b/app/src/main/java/org/thoughtcrime/securesms/backup/FullBackupImporter.java
new file mode 100644
index 00000000..8a865a41
--- /dev/null
+++ b/app/src/main/java/org/thoughtcrime/securesms/backup/FullBackupImporter.java
@@ -0,0 +1,366 @@
+package org.thoughtcrime.securesms.backup;
+
+
+import android.annotation.SuppressLint;
+import android.content.ContentValues;
+import android.content.Context;
+import android.content.SharedPreferences;
+import android.database.Cursor;
+import android.net.Uri;
+import android.util.Pair;
+
+import androidx.annotation.NonNull;
+
+import net.sqlcipher.database.SQLiteDatabase;
+
+import org.greenrobot.eventbus.EventBus;
+import org.signal.core.util.Conversions;
+import org.signal.core.util.StreamUtil;
+import org.signal.core.util.logging.Log;
+import org.thoughtcrime.securesms.backup.BackupProtos.Attachment;
+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.backup.BackupProtos.Sticker;
+import org.thoughtcrime.securesms.crypto.AttachmentSecret;
+import org.thoughtcrime.securesms.crypto.ModernEncryptingPartOutputStream;
+import org.thoughtcrime.securesms.database.AttachmentDatabase;
+import org.thoughtcrime.securesms.database.SearchDatabase;
+import org.thoughtcrime.securesms.database.StickerDatabase;
+import org.thoughtcrime.securesms.profiles.AvatarHelper;
+import org.thoughtcrime.securesms.recipients.RecipientId;
+import org.thoughtcrime.securesms.util.BackupUtil;
+import org.thoughtcrime.securesms.util.SqlUtil;
+import org.whispersystems.libsignal.kdf.HKDFv3;
+import org.whispersystems.libsignal.util.ByteUtil;
+
+import java.io.ByteArrayOutputStream;
+import java.io.File;
+import java.io.FileInputStream;
+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.List;
+import java.util.Objects;
+
+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;
+
+public class FullBackupImporter extends FullBackupBase {
+
+ @SuppressWarnings("unused")
+ private static final String TAG = FullBackupImporter.class.getSimpleName();
+
+ public static void importFile(@NonNull Context context, @NonNull AttachmentSecret attachmentSecret,
+ @NonNull SQLiteDatabase db, @NonNull Uri uri, @NonNull String passphrase)
+ throws IOException
+ {
+ int count = 0;
+
+ try (InputStream is = getInputStream(context, uri)) {
+ BackupRecordInputStream inputStream = new BackupRecordInputStream(is, passphrase);
+
+ db.beginTransaction();
+
+ dropAllTables(db);
+
+ BackupFrame frame;
+
+ while (!(frame = inputStream.readFrame()).getEnd()) {
+ if (count++ % 100 == 0) EventBus.getDefault().post(new BackupEvent(BackupEvent.Type.PROGRESS, count));
+
+ if (frame.hasVersion()) processVersion(db, frame.getVersion());
+ else if (frame.hasStatement()) processStatement(db, frame.getStatement());
+ else if (frame.hasPreference()) processPreference(context, frame.getPreference());
+ else if (frame.hasAttachment()) processAttachment(context, attachmentSecret, db, frame.getAttachment(), inputStream);
+ else if (frame.hasSticker()) processSticker(context, attachmentSecret, db, frame.getSticker(), inputStream);
+ else if (frame.hasAvatar()) processAvatar(context, db, frame.getAvatar(), inputStream);
+ }
+
+ db.setTransactionSuccessful();
+ } finally {
+ db.endTransaction();
+ }
+
+ EventBus.getDefault().post(new BackupEvent(BackupEvent.Type.FINISHED, count));
+ }
+
+ private static @NonNull InputStream getInputStream(@NonNull Context context, @NonNull Uri uri) throws IOException{
+ if (BackupUtil.isUserSelectionRequired(context)) {
+ return Objects.requireNonNull(context.getContentResolver().openInputStream(uri));
+ } else {
+ return new FileInputStream(new File(Objects.requireNonNull(uri.getPath())));
+ }
+ }
+
+ private static void processVersion(@NonNull SQLiteDatabase db, DatabaseVersion version) throws IOException {
+ if (version.getVersion() > db.getVersion()) {
+ throw new DatabaseDowngradeException(db.getVersion(), version.getVersion());
+ }
+
+ db.setVersion(version.getVersion());
+ }
+
+ private static void processStatement(@NonNull SQLiteDatabase db, SqlStatement statement) {
+ boolean isForSmsFtsSecretTable = statement.getStatement().contains(SearchDatabase.SMS_FTS_TABLE_NAME + "_");
+ boolean isForMmsFtsSecretTable = statement.getStatement().contains(SearchDatabase.MMS_FTS_TABLE_NAME + "_");
+ boolean isForSqliteSecretTable = statement.getStatement().toLowerCase().startsWith("create table sqlite_");
+
+ if (isForSmsFtsSecretTable || isForMmsFtsSecretTable || isForSqliteSecretTable) {
+ Log.i(TAG, "Ignoring import for statement: " + statement.getStatement());
+ return;
+ }
+
+ List parameters = new LinkedList<>();
+
+ for (SqlStatement.SqlParameter parameter : statement.getParametersList()) {
+ if (parameter.hasStringParamter()) parameters.add(parameter.getStringParamter());
+ else if (parameter.hasDoubleParameter()) parameters.add(parameter.getDoubleParameter());
+ else if (parameter.hasIntegerParameter()) parameters.add(parameter.getIntegerParameter());
+ else if (parameter.hasBlobParameter()) parameters.add(parameter.getBlobParameter().toByteArray());
+ else if (parameter.hasNullparameter()) parameters.add(null);
+ }
+
+ if (parameters.size() > 0) db.execSQL(statement.getStatement(), parameters.toArray());
+ else db.execSQL(statement.getStatement());
+ }
+
+ private static void processAttachment(@NonNull Context context, @NonNull AttachmentSecret attachmentSecret, @NonNull SQLiteDatabase db, @NonNull Attachment attachment, BackupRecordInputStream inputStream)
+ throws IOException
+ {
+ File partsDirectory = context.getDir(AttachmentDatabase.DIRECTORY, Context.MODE_PRIVATE);
+ File dataFile = File.createTempFile("part", ".mms", partsDirectory);
+ Pair output = ModernEncryptingPartOutputStream.createFor(attachmentSecret, dataFile, false);
+
+ ContentValues contentValues = new ContentValues();
+
+ try {
+ inputStream.readAttachmentTo(output.second, attachment.getLength());
+
+ contentValues.put(AttachmentDatabase.DATA, dataFile.getAbsolutePath());
+ contentValues.put(AttachmentDatabase.DATA_RANDOM, output.first);
+ } catch (BadMacException e) {
+ Log.w(TAG, "Bad MAC for attachment " + attachment.getAttachmentId() + "! Can't restore it.", e);
+ dataFile.delete();
+ contentValues.put(AttachmentDatabase.DATA, (String) null);
+ contentValues.put(AttachmentDatabase.DATA_RANDOM, (String) null);
+ }
+
+ db.update(AttachmentDatabase.TABLE_NAME, contentValues,
+ AttachmentDatabase.ROW_ID + " = ? AND " + AttachmentDatabase.UNIQUE_ID + " = ?",
+ new String[] {String.valueOf(attachment.getRowId()), String.valueOf(attachment.getAttachmentId())});
+ }
+
+ private static void processSticker(@NonNull Context context, @NonNull AttachmentSecret attachmentSecret, @NonNull SQLiteDatabase db, @NonNull Sticker sticker, BackupRecordInputStream inputStream)
+ throws IOException
+ {
+ File stickerDirectory = context.getDir(StickerDatabase.DIRECTORY, Context.MODE_PRIVATE);
+ File dataFile = File.createTempFile("sticker", ".mms", stickerDirectory);
+
+ Pair output = ModernEncryptingPartOutputStream.createFor(attachmentSecret, dataFile, false);
+
+ inputStream.readAttachmentTo(output.second, sticker.getLength());
+
+ ContentValues contentValues = new ContentValues();
+ contentValues.put(StickerDatabase.FILE_PATH, dataFile.getAbsolutePath());
+ contentValues.put(StickerDatabase.FILE_LENGTH, sticker.getLength());
+ contentValues.put(StickerDatabase.FILE_RANDOM, output.first);
+
+ db.update(StickerDatabase.TABLE_NAME, contentValues,
+ StickerDatabase._ID + " = ?",
+ new String[] {String.valueOf(sticker.getRowId())});
+ }
+
+ private static void processAvatar(@NonNull Context context, @NonNull SQLiteDatabase db, @NonNull BackupProtos.Avatar avatar, @NonNull BackupRecordInputStream inputStream) throws IOException {
+ if (avatar.hasRecipientId()) {
+ RecipientId recipientId = RecipientId.from(avatar.getRecipientId());
+ inputStream.readAttachmentTo(AvatarHelper.getOutputStream(context, recipientId), avatar.getLength());
+ } else {
+ if (avatar.hasName() && SqlUtil.tableExists(db, "recipient_preferences")) {
+ Log.w(TAG, "Avatar is missing a recipientId. Clearing signal_profile_avatar (legacy) so it can be fetched later.");
+ db.execSQL("UPDATE recipient_preferences SET signal_profile_avatar = NULL WHERE recipient_ids = ?", new String[] { avatar.getName() });
+ } else if (avatar.hasName() && SqlUtil.tableExists(db, "recipient")) {
+ Log.w(TAG, "Avatar is missing a recipientId. Clearing signal_profile_avatar so it can be fetched later.");
+ db.execSQL("UPDATE recipient SET signal_profile_avatar = NULL WHERE phone = ?", new String[] { avatar.getName() });
+ } else {
+ Log.w(TAG, "Avatar is missing a recipientId. Skipping avatar restore.");
+ }
+
+ inputStream.readAttachmentTo(new ByteArrayOutputStream(), avatar.getLength());
+ }
+ }
+
+ @SuppressLint("ApplySharedPref")
+ private static void processPreference(@NonNull Context context, SharedPreference preference) {
+ SharedPreferences preferences = context.getSharedPreferences(preference.getFile(), 0);
+ preferences.edit().putString(preference.getKey(), preference.getValue()).commit();
+ }
+
+ private static void dropAllTables(@NonNull SQLiteDatabase db) {
+ try (Cursor cursor = db.rawQuery("SELECT name, type FROM sqlite_master", null)) {
+ while (cursor != null && cursor.moveToNext()) {
+ String name = cursor.getString(0);
+ String type = cursor.getString(1);
+
+ if ("table".equals(type) && !name.startsWith("sqlite_")) {
+ db.execSQL("DROP TABLE IF EXISTS " + name);
+ }
+ }
+ }
+ }
+
+ private static class BackupRecordInputStream extends BackupStream {
+
+ private final InputStream in;
+ private final Cipher cipher;
+ private final Mac mac;
+
+ private final byte[] cipherKey;
+ private final byte[] macKey;
+
+ private byte[] iv;
+ private int counter;
+
+ private BackupRecordInputStream(@NonNull InputStream in, @NonNull String passphrase) throws IOException {
+ try {
+ this.in = in;
+
+ byte[] headerLengthBytes = new byte[4];
+ StreamUtil.readFully(in, headerLengthBytes);
+
+ int headerLength = Conversions.byteArrayToInt(headerLengthBytes);
+ byte[] headerFrame = new byte[headerLength];
+ StreamUtil.readFully(in, headerFrame);
+
+ BackupFrame frame = BackupFrame.parseFrom(headerFrame);
+
+ if (!frame.hasHeader()) {
+ throw new IOException("Backup stream does not start with header!");
+ }
+
+ BackupProtos.Header header = frame.getHeader();
+
+ this.iv = header.getIv().toByteArray();
+
+ if (iv.length != 16) {
+ throw new IOException("Invalid IV length!");
+ }
+
+ byte[] key = getBackupKey(passphrase, header.hasSalt() ? header.getSalt().toByteArray() : null);
+ byte[] derived = new HKDFv3().deriveSecrets(key, "Backup Export".getBytes(), 64);
+ byte[][] split = ByteUtil.split(derived, 32, 32);
+
+ this.cipherKey = split[0];
+ this.macKey = split[1];
+
+ this.cipher = Cipher.getInstance("AES/CTR/NoPadding");
+ this.mac = Mac.getInstance("HmacSHA256");
+ this.mac.init(new SecretKeySpec(macKey, "HmacSHA256"));
+
+ this.counter = Conversions.byteArrayToInt(iv);
+ } catch (NoSuchAlgorithmException | NoSuchPaddingException | InvalidKeyException e) {
+ throw new AssertionError(e);
+ }
+ }
+
+ BackupFrame readFrame() throws IOException {
+ return readFrame(in);
+ }
+
+ void readAttachmentTo(OutputStream out, int length) throws IOException {
+ try {
+ Conversions.intToByteArray(iv, 0, counter++);
+ cipher.init(Cipher.DECRYPT_MODE, new SecretKeySpec(cipherKey, "AES"), new IvParameterSpec(iv));
+ mac.update(iv);
+
+ byte[] buffer = new byte[8192];
+
+ while (length > 0) {
+ int read = in.read(buffer, 0, Math.min(buffer.length, length));
+ if (read == -1) throw new IOException("File ended early!");
+
+ mac.update(buffer, 0, read);
+
+ byte[] plaintext = cipher.update(buffer, 0, read);
+
+ if (plaintext != null) {
+ out.write(plaintext, 0, plaintext.length);
+ }
+
+ length -= read;
+ }
+
+ byte[] plaintext = cipher.doFinal();
+
+ if (plaintext != null) {
+ out.write(plaintext, 0, plaintext.length);
+ }
+
+ out.close();
+
+ byte[] ourMac = ByteUtil.trim(mac.doFinal(), 10);
+ byte[] theirMac = new byte[10];
+
+ try {
+ StreamUtil.readFully(in, theirMac);
+ } catch (IOException e) {
+ throw new IOException(e);
+ }
+
+ if (!MessageDigest.isEqual(ourMac, theirMac)) {
+ throw new BadMacException();
+ }
+ } catch (InvalidKeyException | InvalidAlgorithmParameterException | IllegalBlockSizeException | BadPaddingException e) {
+ throw new AssertionError(e);
+ }
+ }
+
+ private BackupFrame readFrame(InputStream in) throws IOException {
+ try {
+ byte[] length = new byte[4];
+ StreamUtil.readFully(in, length);
+
+ byte[] frame = new byte[Conversions.byteArrayToInt(length)];
+ StreamUtil.readFully(in, frame);
+
+ byte[] theirMac = new byte[10];
+ System.arraycopy(frame, frame.length - 10, theirMac, 0, theirMac.length);
+
+ mac.update(frame, 0, frame.length - 10);
+ byte[] ourMac = ByteUtil.trim(mac.doFinal(), 10);
+
+ if (!MessageDigest.isEqual(ourMac, theirMac)) {
+ throw new IOException("Bad MAC");
+ }
+
+ Conversions.intToByteArray(iv, 0, counter++);
+ cipher.init(Cipher.DECRYPT_MODE, new SecretKeySpec(cipherKey, "AES"), new IvParameterSpec(iv));
+
+ byte[] plaintext = cipher.doFinal(frame, 0, frame.length - 10);
+
+ return BackupFrame.parseFrom(plaintext);
+ } catch (InvalidKeyException | InvalidAlgorithmParameterException | IllegalBlockSizeException | BadPaddingException e) {
+ throw new AssertionError(e);
+ }
+ }
+ }
+
+ private static class BadMacException extends IOException {}
+
+ public static class DatabaseDowngradeException extends IOException {
+ DatabaseDowngradeException(int currentVersion, int backupVersion) {
+ super("Tried to import a backup with version " + backupVersion + " into a database with version " + currentVersion);
+ }
+ }
+}
diff --git a/app/src/main/java/org/thoughtcrime/securesms/blocked/BlockedUsersActivity.java b/app/src/main/java/org/thoughtcrime/securesms/blocked/BlockedUsersActivity.java
new file mode 100644
index 00000000..9f8bd6e1
--- /dev/null
+++ b/app/src/main/java/org/thoughtcrime/securesms/blocked/BlockedUsersActivity.java
@@ -0,0 +1,169 @@
+package org.thoughtcrime.securesms.blocked;
+
+import android.app.AlertDialog;
+import android.content.Intent;
+import android.graphics.Color;
+import android.os.Bundle;
+import android.view.View;
+import android.widget.ViewSwitcher;
+
+import androidx.annotation.NonNull;
+import androidx.annotation.StringRes;
+import androidx.appcompat.widget.Toolbar;
+import androidx.fragment.app.Fragment;
+import androidx.lifecycle.ViewModelProviders;
+
+import com.google.android.material.snackbar.Snackbar;
+
+import org.thoughtcrime.securesms.ContactSelectionListFragment;
+import org.thoughtcrime.securesms.PassphraseRequiredActivity;
+import org.thoughtcrime.securesms.R;
+import org.thoughtcrime.securesms.components.ContactFilterToolbar;
+import org.thoughtcrime.securesms.contacts.ContactsCursorLoader;
+import org.thoughtcrime.securesms.recipients.Recipient;
+import org.thoughtcrime.securesms.recipients.RecipientId;
+import org.thoughtcrime.securesms.util.DynamicNoActionBarTheme;
+import org.thoughtcrime.securesms.util.DynamicTheme;
+import org.whispersystems.libsignal.util.guava.Optional;
+
+public class BlockedUsersActivity extends PassphraseRequiredActivity implements BlockedUsersFragment.Listener, ContactSelectionListFragment.OnContactSelectedListener {
+
+ private static final String CONTACT_SELECTION_FRAGMENT = "Contact.Selection.Fragment";
+
+ private final DynamicTheme dynamicTheme = new DynamicNoActionBarTheme();
+
+ private BlockedUsersViewModel viewModel;
+
+ @Override
+ protected void onCreate(Bundle savedInstanceState, boolean ready) {
+ super.onCreate(savedInstanceState, ready);
+
+ dynamicTheme.onCreate(this);
+
+ setContentView(R.layout.blocked_users_activity);
+
+ BlockedUsersRepository repository = new BlockedUsersRepository(this);
+ BlockedUsersViewModel.Factory factory = new BlockedUsersViewModel.Factory(repository);
+
+ viewModel = ViewModelProviders.of(this, factory).get(BlockedUsersViewModel.class);
+
+ ViewSwitcher viewSwitcher = findViewById(R.id.toolbar_switcher);
+ Toolbar toolbar = findViewById(R.id.toolbar);
+ ContactFilterToolbar contactFilterToolbar = findViewById(R.id.filter_toolbar);
+ View container = findViewById(R.id.fragment_container);
+
+ toolbar.setNavigationOnClickListener(unused -> onBackPressed());
+ contactFilterToolbar.setNavigationOnClickListener(unused -> onBackPressed());
+ contactFilterToolbar.setOnFilterChangedListener(query -> {
+ Fragment fragment = getSupportFragmentManager().findFragmentByTag(CONTACT_SELECTION_FRAGMENT);
+ if (fragment != null) {
+ ((ContactSelectionListFragment) fragment).setQueryFilter(query);
+ }
+ });
+ contactFilterToolbar.setHint(R.string.BlockedUsersActivity__add_blocked_user);
+
+ //noinspection CodeBlock2Expr
+ getSupportFragmentManager().addOnBackStackChangedListener(() -> {
+ viewSwitcher.setDisplayedChild(getSupportFragmentManager().getBackStackEntryCount());
+
+ if (getSupportFragmentManager().getBackStackEntryCount() == 1) {
+ contactFilterToolbar.focusAndShowKeyboard();
+ }
+ });
+
+ getSupportFragmentManager().beginTransaction()
+ .add(R.id.fragment_container, new BlockedUsersFragment())
+ .commit();
+
+ viewModel.getEvents().observe(this, event -> handleEvent(container, event));
+ }
+
+ @Override
+ protected void onResume() {
+ super.onResume();
+
+ dynamicTheme.onResume(this);
+ }
+
+ @Override
+ public boolean onBeforeContactSelected(Optional recipientId, String number) {
+ final String displayName = recipientId.transform(id -> Recipient.resolved(id).getDisplayName(this)).or(number);
+
+ AlertDialog confirmationDialog = new AlertDialog.Builder(BlockedUsersActivity.this)
+ .setTitle(R.string.BlockedUsersActivity__block_user)
+ .setMessage(getString(R.string.BlockedUserActivity__s_will_not_be_able_to, displayName))
+ .setPositiveButton(R.string.BlockedUsersActivity__block, (dialog, which) -> {
+ if (recipientId.isPresent()) {
+ viewModel.block(recipientId.get());
+ } else {
+ viewModel.createAndBlock(number);
+ }
+ dialog.dismiss();
+ onBackPressed();
+ })
+ .setNegativeButton(android.R.string.cancel, (dialog, which) -> dialog.dismiss())
+ .setCancelable(true)
+ .create();
+
+ confirmationDialog.setOnShowListener(dialog -> {
+ confirmationDialog.getButton(AlertDialog.BUTTON_POSITIVE).setTextColor(Color.RED);
+ });
+
+ confirmationDialog.show();
+
+ return false;
+ }
+
+ @Override
+ public void onContactDeselected(Optional recipientId, String number) {
+
+ }
+
+ @Override
+ public void handleAddUserToBlockedList() {
+ ContactSelectionListFragment fragment = new ContactSelectionListFragment();
+ Intent intent = getIntent();
+
+ intent.putExtra(ContactSelectionListFragment.REFRESHABLE, false);
+ intent.putExtra(ContactSelectionListFragment.SELECTION_LIMITS, 1);
+ intent.putExtra(ContactSelectionListFragment.HIDE_COUNT, true);
+ intent.putExtra(ContactSelectionListFragment.DISPLAY_MODE,
+ ContactsCursorLoader.DisplayMode.FLAG_PUSH |
+ ContactsCursorLoader.DisplayMode.FLAG_SMS |
+ ContactsCursorLoader.DisplayMode.FLAG_ACTIVE_GROUPS |
+ ContactsCursorLoader.DisplayMode.FLAG_INACTIVE_GROUPS |
+ ContactsCursorLoader.DisplayMode.FLAG_BLOCK);
+
+ getSupportFragmentManager().beginTransaction()
+ .replace(R.id.fragment_container, fragment, CONTACT_SELECTION_FRAGMENT)
+ .addToBackStack(null)
+ .commit();
+ }
+
+ private void handleEvent(@NonNull View view, @NonNull BlockedUsersViewModel.Event event) {
+ final String displayName;
+
+ if (event.getRecipient() == null) {
+ displayName = event.getNumber();
+ } else {
+ displayName = event.getRecipient().getDisplayName(this);
+ }
+
+ final @StringRes int messageResId;
+ switch (event.getEventType()) {
+ case BLOCK_SUCCEEDED:
+ messageResId = R.string.BlockedUsersActivity__s_has_been_blocked;
+ break;
+ case BLOCK_FAILED:
+ messageResId = R.string.BlockedUsersActivity__failed_to_block_s;
+ break;
+ case UNBLOCK_SUCCEEDED:
+ messageResId = R.string.BlockedUsersActivity__s_has_been_unblocked;
+ break;
+ default:
+ throw new IllegalArgumentException("Unsupported event type " + event);
+ }
+
+ Snackbar.make(view, getString(messageResId, displayName), Snackbar.LENGTH_SHORT).show();
+ }
+}
diff --git a/app/src/main/java/org/thoughtcrime/securesms/blocked/BlockedUsersAdapter.java b/app/src/main/java/org/thoughtcrime/securesms/blocked/BlockedUsersAdapter.java
new file mode 100644
index 00000000..f5a2dd09
--- /dev/null
+++ b/app/src/main/java/org/thoughtcrime/securesms/blocked/BlockedUsersAdapter.java
@@ -0,0 +1,96 @@
+package org.thoughtcrime.securesms.blocked;
+
+import android.view.LayoutInflater;
+import android.view.View;
+import android.view.ViewGroup;
+import android.widget.TextView;
+
+import androidx.annotation.NonNull;
+import androidx.core.util.Consumer;
+import androidx.recyclerview.widget.DiffUtil;
+import androidx.recyclerview.widget.ListAdapter;
+import androidx.recyclerview.widget.RecyclerView;
+
+import org.thoughtcrime.securesms.R;
+import org.thoughtcrime.securesms.components.AvatarImageView;
+import org.thoughtcrime.securesms.recipients.Recipient;
+
+import java.util.Objects;
+
+final class BlockedUsersAdapter extends ListAdapter {
+
+ private final RecipientClickedListener recipientClickedListener;
+
+ BlockedUsersAdapter(@NonNull RecipientClickedListener recipientClickedListener) {
+ super(new RecipientDiffCallback());
+
+ this.recipientClickedListener = recipientClickedListener;
+ }
+
+ @Override
+ public @NonNull ViewHolder onCreateViewHolder(@NonNull ViewGroup parent, int viewType) {
+ return new ViewHolder(LayoutInflater.from(parent.getContext()).inflate(R.layout.blocked_users_adapter_item, parent, false),
+ position -> recipientClickedListener.onRecipientClicked(Objects.requireNonNull(getItem(position))));
+ }
+
+ @Override
+ public void onBindViewHolder(@NonNull ViewHolder holder, int position) {
+ holder.bind(Objects.requireNonNull(getItem(position)));
+ }
+
+ static final class ViewHolder extends RecyclerView.ViewHolder {
+
+ private final AvatarImageView avatar;
+ private final TextView displayName;
+ private final TextView numberOrUsername;
+
+ public ViewHolder(@NonNull View itemView, Consumer clickConsumer) {
+ super(itemView);
+
+ this.avatar = itemView.findViewById(R.id.avatar);
+ this.displayName = itemView.findViewById(R.id.display_name);
+ this.numberOrUsername = itemView.findViewById(R.id.number_or_username);
+
+ itemView.setOnClickListener(unused -> {
+ if (getAdapterPosition() != RecyclerView.NO_POSITION) {
+ clickConsumer.accept(getAdapterPosition());
+ }
+ });
+ }
+
+ public void bind(@NonNull Recipient recipient) {
+ avatar.setAvatar(recipient);
+ displayName.setText(recipient.getDisplayName(itemView.getContext()));
+
+ if (recipient.hasAUserSetDisplayName(itemView.getContext())) {
+ String identifier = recipient.getE164().or(recipient.getUsername()).orNull();
+
+ if (identifier != null) {
+ numberOrUsername.setText(identifier);
+ numberOrUsername.setVisibility(View.VISIBLE);
+ } else {
+ numberOrUsername.setVisibility(View.GONE);
+ }
+ } else {
+ numberOrUsername.setVisibility(View.GONE);
+ }
+ }
+ }
+
+ private static final class RecipientDiffCallback extends DiffUtil.ItemCallback {
+
+ @Override
+ public boolean areItemsTheSame(@NonNull Recipient oldItem, @NonNull Recipient newItem) {
+ return oldItem.equals(newItem);
+ }
+
+ @Override
+ public boolean areContentsTheSame(@NonNull Recipient oldItem, @NonNull Recipient newItem) {
+ return oldItem.equals(newItem);
+ }
+ }
+
+ interface RecipientClickedListener {
+ void onRecipientClicked(@NonNull Recipient recipient);
+ }
+}
diff --git a/app/src/main/java/org/thoughtcrime/securesms/blocked/BlockedUsersFragment.java b/app/src/main/java/org/thoughtcrime/securesms/blocked/BlockedUsersFragment.java
new file mode 100644
index 00000000..eb7f7e7c
--- /dev/null
+++ b/app/src/main/java/org/thoughtcrime/securesms/blocked/BlockedUsersFragment.java
@@ -0,0 +1,100 @@
+package org.thoughtcrime.securesms.blocked;
+
+import android.app.AlertDialog;
+import android.content.Context;
+import android.content.DialogInterface;
+import android.graphics.Color;
+import android.os.Bundle;
+import android.view.LayoutInflater;
+import android.view.View;
+import android.view.ViewGroup;
+
+import androidx.annotation.NonNull;
+import androidx.annotation.Nullable;
+import androidx.fragment.app.Fragment;
+import androidx.lifecycle.ViewModelProviders;
+import androidx.recyclerview.widget.RecyclerView;
+
+import org.thoughtcrime.securesms.R;
+import org.thoughtcrime.securesms.recipients.Recipient;
+
+public class BlockedUsersFragment extends Fragment {
+
+ private BlockedUsersViewModel viewModel;
+ private Listener listener;
+
+ @Override
+ public void onAttach(@NonNull Context context) {
+ super.onAttach(context);
+
+ if (context instanceof Listener) {
+ listener = (Listener) context;
+ } else {
+ throw new ClassCastException("Expected context to implement Listener");
+ }
+ }
+
+ @Override
+ public void onDetach() {
+ super.onDetach();
+
+ listener = null;
+ }
+
+ @Override
+ public @Nullable View onCreateView(@NonNull LayoutInflater inflater, @Nullable ViewGroup container, @Nullable Bundle savedInstanceState) {
+ return inflater.inflate(R.layout.blocked_users_fragment, container, false);
+ }
+
+ @Override
+ public void onViewCreated(@NonNull View view, @Nullable Bundle savedInstanceState) {
+ View addUser = view.findViewById(R.id.add_blocked_user_touch_target);
+ RecyclerView recycler = view.findViewById(R.id.blocked_users_recycler);
+ View empty = view.findViewById(R.id.no_blocked_users);
+ BlockedUsersAdapter adapter = new BlockedUsersAdapter(this::handleRecipientClicked);
+
+ recycler.setAdapter(adapter);
+
+ addUser.setOnClickListener(unused -> {
+ if (listener != null) {
+ listener.handleAddUserToBlockedList();
+ }
+ });
+
+ viewModel = ViewModelProviders.of(requireActivity()).get(BlockedUsersViewModel.class);
+ viewModel.getRecipients().observe(getViewLifecycleOwner(), list -> {
+ if (list.isEmpty()) {
+ empty.setVisibility(View.VISIBLE);
+ } else {
+ empty.setVisibility(View.GONE);
+ }
+
+ adapter.submitList(list);
+ });
+ }
+
+ private void handleRecipientClicked(@NonNull Recipient recipient) {
+ AlertDialog confirmationDialog = new AlertDialog.Builder(requireContext())
+ .setTitle(R.string.BlockedUsersActivity__unblock_user)
+ .setMessage(getString(R.string.BlockedUsersActivity__do_you_want_to_unblock_s, recipient.getDisplayName(requireContext())))
+ .setPositiveButton(R.string.BlockedUsersActivity__unblock, (dialog, which) -> {
+ viewModel.unblock(recipient.getId());
+ dialog.dismiss();
+ })
+ .setNegativeButton(android.R.string.cancel, (dialog, which) -> {
+ dialog.dismiss();
+ })
+ .setCancelable(true)
+ .create();
+
+ confirmationDialog.setOnShowListener(dialog -> {
+ confirmationDialog.getButton(DialogInterface.BUTTON_POSITIVE).setTextColor(Color.RED);
+ });
+
+ confirmationDialog.show();
+ }
+
+ interface Listener {
+ void handleAddUserToBlockedList();
+ }
+}
diff --git a/app/src/main/java/org/thoughtcrime/securesms/blocked/BlockedUsersRepository.java b/app/src/main/java/org/thoughtcrime/securesms/blocked/BlockedUsersRepository.java
new file mode 100644
index 00000000..f41ccc95
--- /dev/null
+++ b/app/src/main/java/org/thoughtcrime/securesms/blocked/BlockedUsersRepository.java
@@ -0,0 +1,76 @@
+package org.thoughtcrime.securesms.blocked;
+
+import android.content.Context;
+
+import androidx.annotation.NonNull;
+import androidx.core.util.Consumer;
+
+import org.signal.core.util.concurrent.SignalExecutors;
+import org.signal.core.util.logging.Log;
+import org.thoughtcrime.securesms.database.DatabaseFactory;
+import org.thoughtcrime.securesms.database.RecipientDatabase;
+import org.thoughtcrime.securesms.groups.GroupChangeBusyException;
+import org.thoughtcrime.securesms.groups.GroupChangeFailedException;
+import org.thoughtcrime.securesms.recipients.Recipient;
+import org.thoughtcrime.securesms.recipients.RecipientId;
+import org.thoughtcrime.securesms.recipients.RecipientUtil;
+
+import java.io.IOException;
+import java.util.ArrayList;
+import java.util.Collections;
+import java.util.List;
+
+class BlockedUsersRepository {
+
+ private static final String TAG = Log.tag(BlockedUsersRepository.class);
+
+ private final Context context;
+
+ BlockedUsersRepository(@NonNull Context context) {
+ this.context = context;
+ }
+
+ void getBlocked(@NonNull Consumer> blockedUsers) {
+ SignalExecutors.BOUNDED.execute(() -> {
+ RecipientDatabase db = DatabaseFactory.getRecipientDatabase(context);
+ try (RecipientDatabase.RecipientReader reader = db.readerForBlocked(db.getBlocked())) {
+ int count = reader.getCount();
+ if (count == 0) {
+ blockedUsers.accept(Collections.emptyList());
+ } else {
+ List recipients = new ArrayList<>();
+ while (reader.getNext() != null) {
+ recipients.add(reader.getCurrent());
+ }
+ blockedUsers.accept(recipients);
+ }
+ }
+ });
+ }
+
+ void block(@NonNull RecipientId recipientId, @NonNull Runnable success, @NonNull Runnable failure) {
+ SignalExecutors.BOUNDED.execute(() -> {
+ try {
+ RecipientUtil.block(context, Recipient.resolved(recipientId));
+ success.run();
+ } catch (IOException | GroupChangeFailedException | GroupChangeBusyException e) {
+ Log.w(TAG, "block: failed to block recipient: ", e);
+ failure.run();
+ }
+ });
+ }
+
+ void createAndBlock(@NonNull String number, @NonNull Runnable success) {
+ SignalExecutors.BOUNDED.execute(() -> {
+ RecipientUtil.blockNonGroup(context, Recipient.external(context, number));
+ success.run();
+ });
+ }
+
+ void unblock(@NonNull RecipientId recipientId, @NonNull Runnable success) {
+ SignalExecutors.BOUNDED.execute(() -> {
+ RecipientUtil.unblock(context, Recipient.resolved(recipientId));
+ success.run();
+ });
+ }
+}
diff --git a/app/src/main/java/org/thoughtcrime/securesms/blocked/BlockedUsersViewModel.java b/app/src/main/java/org/thoughtcrime/securesms/blocked/BlockedUsersViewModel.java
new file mode 100644
index 00000000..f2d6ef0e
--- /dev/null
+++ b/app/src/main/java/org/thoughtcrime/securesms/blocked/BlockedUsersViewModel.java
@@ -0,0 +1,115 @@
+package org.thoughtcrime.securesms.blocked;
+
+import androidx.annotation.NonNull;
+import androidx.annotation.Nullable;
+import androidx.lifecycle.LiveData;
+import androidx.lifecycle.MutableLiveData;
+import androidx.lifecycle.ViewModel;
+import androidx.lifecycle.ViewModelProvider;
+
+import org.thoughtcrime.securesms.recipients.Recipient;
+import org.thoughtcrime.securesms.recipients.RecipientId;
+import org.thoughtcrime.securesms.util.SingleLiveEvent;
+
+import java.util.List;
+import java.util.Objects;
+
+public class BlockedUsersViewModel extends ViewModel {
+
+ private final BlockedUsersRepository repository;
+ private final MutableLiveData> recipients;
+ private final SingleLiveEvent events = new SingleLiveEvent<>();
+
+ private BlockedUsersViewModel(@NonNull BlockedUsersRepository repository) {
+ this.repository = repository;
+ this.recipients = new MutableLiveData<>();
+
+ loadRecipients();
+ }
+
+ public LiveData> getRecipients() {
+ return recipients;
+ }
+
+ public LiveData getEvents() {
+ return events;
+ }
+
+ void block(@NonNull RecipientId recipientId) {
+ repository.block(recipientId,
+ () -> {
+ loadRecipients();
+ events.postValue(new Event(EventType.BLOCK_SUCCEEDED, Recipient.resolved(recipientId)));
+ },
+ () -> events.postValue(new Event(EventType.BLOCK_FAILED, Recipient.resolved(recipientId))));
+ }
+
+ void createAndBlock(@NonNull String number) {
+ repository.createAndBlock(number, () -> {
+ loadRecipients();
+ events.postValue(new Event(EventType.BLOCK_SUCCEEDED, number));
+ });
+ }
+
+ void unblock(@NonNull RecipientId recipientId) {
+ repository.unblock(recipientId, () -> {
+ loadRecipients();
+ events.postValue(new Event(EventType.UNBLOCK_SUCCEEDED, Recipient.resolved(recipientId)));
+ });
+ }
+
+ private void loadRecipients() {
+ repository.getBlocked(recipients::postValue);
+ }
+
+ enum EventType {
+ BLOCK_SUCCEEDED,
+ BLOCK_FAILED,
+ UNBLOCK_SUCCEEDED
+ }
+
+ public static final class Event {
+
+ private final EventType eventType;
+ private final Recipient recipient;
+ private final String number;
+
+ private Event(@NonNull EventType eventType, @NonNull Recipient recipient) {
+ this.eventType = eventType;
+ this.recipient = recipient;
+ this.number = null;
+ }
+
+ private Event(@NonNull EventType eventType, @NonNull String number) {
+ this.eventType = eventType;
+ this.recipient = null;
+ this.number = number;
+ }
+
+ public @Nullable Recipient getRecipient() {
+ return recipient;
+ }
+
+ public @Nullable String getNumber() {
+ return number;
+ }
+
+ public @NonNull EventType getEventType() {
+ return eventType;
+ }
+ }
+
+ public static class Factory implements ViewModelProvider.Factory {
+
+ private final BlockedUsersRepository repository;
+
+ public Factory(@NonNull BlockedUsersRepository repository) {
+ this.repository = repository;
+ }
+
+ @Override
+ public @NonNull T create(@NonNull Class modelClass) {
+ return Objects.requireNonNull(modelClass.cast(new BlockedUsersViewModel(repository)));
+ }
+ }
+}
diff --git a/app/src/main/java/org/thoughtcrime/securesms/blurhash/Base83.java b/app/src/main/java/org/thoughtcrime/securesms/blurhash/Base83.java
new file mode 100644
index 00000000..e65d1802
--- /dev/null
+++ b/app/src/main/java/org/thoughtcrime/securesms/blurhash/Base83.java
@@ -0,0 +1,73 @@
+/**
+ * Source: https://github.com/hsch/blurhash-java
+ *
+ * Copyright (c) 2019 Hendrik Schnepel
+ *
+ * Permission is hereby granted, free of charge, to any person obtaining a copy of this software and
+ * associated documentation files (the "Software"), to deal in the Software without restriction,
+ * including without limitation the rights to use, copy, modify, merge, publish, distribute,
+ * sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is
+ * furnished to do so, subject to the following conditions:
+ *
+ * The above copyright notice and this permission notice shall be included in all copies or
+ * substantial portions of the Software.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT
+ * NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
+ * NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM,
+ * DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+ * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
+ */
+package org.thoughtcrime.securesms.blurhash;
+
+import androidx.annotation.Nullable;
+
+final class Base83 {
+
+ private static final int MAX_LENGTH = 90;
+
+ private static final char[]ALPHABET = "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz#$%*+,-.:;=?@[]^_{|}~".toCharArray();
+
+ private static int indexOf(char[] a, char key) {
+ for (int i = 0; i < a.length; i++) {
+ if (a[i] == key) {
+ return i;
+ }
+ }
+ return -1;
+ }
+
+ static void encode(long value, int length, char[] buffer, int offset) {
+ int exp = 1;
+ for (int i = 1; i <= length; i++, exp *= 83) {
+ int digit = (int)(value / exp % 83);
+ buffer[offset + length - i] = ALPHABET[digit];
+ }
+ }
+
+ static int decode(String value, int fromInclusive, int toExclusive) {
+ int result = 0;
+ char[] chars = value.toCharArray();
+ for (int i = fromInclusive; i < toExclusive; i++) {
+ result = result * 83 + indexOf(ALPHABET, chars[i]);
+ }
+ return result;
+ }
+
+ static boolean isValid(@Nullable String value) {
+ if (value == null) return false;
+ final int length = value.length();
+
+ if (length == 0 || length > MAX_LENGTH) return false;
+
+ for (int i = 0; i < length; i++) {
+ if (indexOf(ALPHABET, value.charAt(i)) == -1) return false;
+ }
+
+ return true;
+ }
+
+ private Base83() {
+ }
+}
+
diff --git a/app/src/main/java/org/thoughtcrime/securesms/blurhash/BlurHash.java b/app/src/main/java/org/thoughtcrime/securesms/blurhash/BlurHash.java
new file mode 100644
index 00000000..fd054f03
--- /dev/null
+++ b/app/src/main/java/org/thoughtcrime/securesms/blurhash/BlurHash.java
@@ -0,0 +1,43 @@
+package org.thoughtcrime.securesms.blurhash;
+
+import androidx.annotation.NonNull;
+import androidx.annotation.Nullable;
+
+import java.util.Objects;
+
+/**
+ * A BlurHash is a compact string representation of a blurred image that we can use to show fast
+ * image previews.
+ */
+public class BlurHash {
+
+ private final String hash;
+
+ private BlurHash(@NonNull String hash) {
+ this.hash = hash;
+ }
+
+ public static @Nullable BlurHash parseOrNull(@Nullable String hash) {
+ if (Base83.isValid(hash)) {
+ return new BlurHash(hash);
+ }
+ return null;
+ }
+
+ public @NonNull String getHash() {
+ return hash;
+ }
+
+ @Override
+ public boolean equals(Object o) {
+ if (this == o) return true;
+ if (o == null || getClass() != o.getClass()) return false;
+ BlurHash blurHash = (BlurHash) o;
+ return Objects.equals(hash, blurHash.hash);
+ }
+
+ @Override
+ public int hashCode() {
+ return Objects.hash(hash);
+ }
+}
diff --git a/app/src/main/java/org/thoughtcrime/securesms/blurhash/BlurHashDecoder.java b/app/src/main/java/org/thoughtcrime/securesms/blurhash/BlurHashDecoder.java
new file mode 100644
index 00000000..4012c382
--- /dev/null
+++ b/app/src/main/java/org/thoughtcrime/securesms/blurhash/BlurHashDecoder.java
@@ -0,0 +1,113 @@
+/**
+ * Source: https://github.com/woltapp/blurhash
+ *
+ * Copyright (c) 2018 Wolt Enterprises
+ *
+ * Permission is hereby granted, free of charge, to any person obtaining a copy of this software and
+ * associated documentation files (the "Software"), to deal in the Software without restriction,
+ * including without limitation the rights to use, copy, modify, merge, publish, distribute,
+ * sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is
+ * furnished to do so, subject to the following conditions:
+ *
+ * The above copyright notice and this permission notice shall be included in all copies or
+ * substantial portions of the Software.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT
+ * NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
+ * NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM,
+ * DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+ * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
+ */
+package org.thoughtcrime.securesms.blurhash;
+
+import android.graphics.Bitmap;
+import android.graphics.Color;
+
+import androidx.annotation.Nullable;
+
+import static org.thoughtcrime.securesms.blurhash.BlurHashUtil.linearTosRGB;
+import static org.thoughtcrime.securesms.blurhash.BlurHashUtil.sRGBToLinear;
+import static org.thoughtcrime.securesms.blurhash.BlurHashUtil.signPow;
+
+class BlurHashDecoder {
+
+ static @Nullable Bitmap decode(@Nullable String blurHash, int width, int height) {
+ return decode(blurHash, width, height, 1f);
+ }
+
+ static @Nullable Bitmap decode(@Nullable String blurHash, int width, int height, double punch) {
+
+ if (blurHash == null || blurHash.length() < 6) {
+ return null;
+ }
+
+ int numCompEnc = Base83.decode(blurHash, 0, 1);
+ int numCompX = (numCompEnc % 9) + 1;
+ int numCompY = (numCompEnc / 9) + 1;
+
+ if (blurHash.length() != 4 + 2 * numCompX * numCompY) {
+ return null;
+ }
+
+ int maxAcEnc = Base83.decode(blurHash, 1, 2);
+ double maxAc = (maxAcEnc + 1) / 166f;
+ double[][] colors = new double[numCompX * numCompY][];
+ for (int i = 0; i < colors.length; i++) {
+ if (i == 0) {
+ int colorEnc = Base83.decode(blurHash, 2, 6);
+ colors[i] = decodeDc(colorEnc);
+ } else {
+ int from = 4 + i * 2;
+ int colorEnc = Base83.decode(blurHash, from, from + 2);
+ colors[i] = decodeAc(colorEnc, maxAc * punch);
+ }
+ }
+
+ return composeBitmap(width, height, numCompX, numCompY, colors);
+ }
+
+ private static double[] decodeDc(int colorEnc) {
+ int r = colorEnc >> 16;
+ int g = (colorEnc >> 8) & 255;
+ int b = colorEnc & 255;
+ return new double[] {sRGBToLinear(r),
+ sRGBToLinear(g),
+ sRGBToLinear(b)};
+ }
+
+ private static double[] decodeAc(int value, double maxAc) {
+ int r = value / (19 * 19);
+ int g = (value / 19) % 19;
+ int b = value % 19;
+ return new double[]{
+ signPow((r - 9) / 9.0f, 2f) * maxAc,
+ signPow((g - 9) / 9.0f, 2f) * maxAc,
+ signPow((b - 9) / 9.0f, 2f) * maxAc
+ };
+ }
+
+ private static Bitmap composeBitmap(int width, int height, int numCompX, int numCompY, double[][] colors) {
+ Bitmap bitmap = Bitmap.createBitmap(width, height, Bitmap.Config.ARGB_8888);
+ for (int y = 0; y < height; y++) {
+ for (int x = 0; x < width; x++) {
+
+ double r = 0f;
+ double g = 0f;
+ double b = 0f;
+
+ for (int j = 0; j < numCompY; j++) {
+ for (int i = 0; i < numCompX; i++) {
+ double basis = (Math.cos(Math.PI * x * i / width) * Math.cos(Math.PI * y * j / height));
+ double[] color = colors[j * numCompX + i];
+ r += color[0] * basis;
+ g += color[1] * basis;
+ b += color[2] * basis;
+ }
+ }
+ bitmap.setPixel(x, y, Color.rgb((int) linearTosRGB(r), (int) linearTosRGB(g), (int) linearTosRGB(b)));
+ }
+ }
+
+ return bitmap;
+ }
+}
diff --git a/app/src/main/java/org/thoughtcrime/securesms/blurhash/BlurHashEncoder.java b/app/src/main/java/org/thoughtcrime/securesms/blurhash/BlurHashEncoder.java
new file mode 100644
index 00000000..60348191
--- /dev/null
+++ b/app/src/main/java/org/thoughtcrime/securesms/blurhash/BlurHashEncoder.java
@@ -0,0 +1,148 @@
+/**
+ * Source: https://github.com/hsch/blurhash-java
+ *
+ * Copyright (c) 2019 Hendrik Schnepel
+ *
+ * Permission is hereby granted, free of charge, to any person obtaining a copy of this software and
+ * associated documentation files (the "Software"), to deal in the Software without restriction,
+ * including without limitation the rights to use, copy, modify, merge, publish, distribute,
+ * sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is
+ * furnished to do so, subject to the following conditions:
+ *
+ * The above copyright notice and this permission notice shall be included in all copies or
+ * substantial portions of the Software.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT
+ * NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
+ * NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM,
+ * DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+ * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
+ */
+
+package org.thoughtcrime.securesms.blurhash;
+
+import android.graphics.Bitmap;
+import android.graphics.BitmapFactory;
+
+import androidx.annotation.NonNull;
+import androidx.annotation.Nullable;
+
+import java.io.InputStream;
+
+import static org.thoughtcrime.securesms.blurhash.BlurHashUtil.linearTosRGB;
+import static org.thoughtcrime.securesms.blurhash.BlurHashUtil.max;
+import static org.thoughtcrime.securesms.blurhash.BlurHashUtil.sRGBToLinear;
+import static org.thoughtcrime.securesms.blurhash.BlurHashUtil.signPow;
+
+public final class BlurHashEncoder {
+
+ private BlurHashEncoder() {
+ }
+
+ public static @Nullable String encode(InputStream inputStream) {
+ BitmapFactory.Options options = new BitmapFactory.Options();
+ options.inSampleSize = 16;
+
+ Bitmap bitmap = BitmapFactory.decodeStream(inputStream, null, options);
+ if (bitmap == null) return null;
+
+ String hash = encode(bitmap);
+
+ bitmap.recycle();
+
+ return hash;
+ }
+
+ public static @Nullable String encode(@NonNull Bitmap bitmap) {
+ return encode(bitmap, 4, 3);
+ }
+
+ static String encode(Bitmap bitmap, int componentX, int componentY) {
+ int width = bitmap.getWidth();
+ int height = bitmap.getHeight();
+ int[] pixels = new int[width * height];
+ bitmap.getPixels(pixels, 0, width, 0, 0, width, height);
+ return encode(pixels, width, height, componentX, componentY);
+ }
+
+ private static String encode(int[] pixels, int width, int height, int componentX, int componentY) {
+
+ if (componentX < 1 || componentX > 9 || componentY < 1 || componentY > 9) {
+ throw new IllegalArgumentException("Blur hash must have between 1 and 9 components");
+ }
+ if (width * height != pixels.length) {
+ throw new IllegalArgumentException("Width and height must match the pixels array");
+ }
+
+ double[][] factors = new double[componentX * componentY][3];
+ for (int j = 0; j < componentY; j++) {
+ for (int i = 0; i < componentX; i++) {
+ double normalisation = i == 0 && j == 0 ? 1 : 2;
+ applyBasisFunction(pixels, width, height,
+ normalisation, i, j,
+ factors, j * componentX + i);
+ }
+ }
+
+ char[] hash = new char[1 + 1 + 4 + 2 * (factors.length - 1)]; // size flag + max AC + DC + 2 * AC components
+
+ long sizeFlag = componentX - 1 + (componentY - 1) * 9;
+ Base83.encode(sizeFlag, 1, hash, 0);
+
+ double maximumValue;
+ if (factors.length > 1) {
+ double actualMaximumValue = max(factors, 1, factors.length);
+ double quantisedMaximumValue = Math.floor(Math.max(0, Math.min(82, Math.floor(actualMaximumValue * 166 - 0.5))));
+ maximumValue = (quantisedMaximumValue + 1) / 166;
+ Base83.encode(Math.round(quantisedMaximumValue), 1, hash, 1);
+ } else {
+ maximumValue = 1;
+ Base83.encode(0, 1, hash, 1);
+ }
+
+ double[] dc = factors[0];
+ Base83.encode(encodeDC(dc), 4, hash, 2);
+
+ for (int i = 1; i < factors.length; i++) {
+ Base83.encode(encodeAC(factors[i], maximumValue), 2, hash, 6 + 2 * (i - 1));
+ }
+ return new String(hash);
+ }
+
+ private static void applyBasisFunction(int[] pixels, int width, int height,
+ double normalisation, int i, int j,
+ double[][] factors, int index)
+ {
+ double r = 0, g = 0, b = 0;
+ for (int x = 0; x < width; x++) {
+ for (int y = 0; y < height; y++) {
+ double basis = normalisation
+ * Math.cos((Math.PI * i * x) / width)
+ * Math.cos((Math.PI * j * y) / height);
+ int pixel = pixels[y * width + x];
+ r += basis * sRGBToLinear((pixel >> 16) & 0xff);
+ g += basis * sRGBToLinear((pixel >> 8) & 0xff);
+ b += basis * sRGBToLinear( pixel & 0xff);
+ }
+ }
+ double scale = 1.0 / (width * height);
+ factors[index][0] = r * scale;
+ factors[index][1] = g * scale;
+ factors[index][2] = b * scale;
+ }
+
+ private static long encodeDC(double[] value) {
+ long r = linearTosRGB(value[0]);
+ long g = linearTosRGB(value[1]);
+ long b = linearTosRGB(value[2]);
+ return (r << 16) + (g << 8) + b;
+ }
+
+ private static long encodeAC(double[] value, double maximumValue) {
+ double quantR = Math.floor(Math.max(0, Math.min(18, Math.floor(signPow(value[0] / maximumValue, 0.5) * 9 + 9.5))));
+ double quantG = Math.floor(Math.max(0, Math.min(18, Math.floor(signPow(value[1] / maximumValue, 0.5) * 9 + 9.5))));
+ double quantB = Math.floor(Math.max(0, Math.min(18, Math.floor(signPow(value[2] / maximumValue, 0.5) * 9 + 9.5))));
+ return Math.round(quantR * 19 * 19 + quantG * 19 + quantB);
+ }
+
+}
diff --git a/app/src/main/java/org/thoughtcrime/securesms/blurhash/BlurHashModelLoader.java b/app/src/main/java/org/thoughtcrime/securesms/blurhash/BlurHashModelLoader.java
new file mode 100644
index 00000000..0dfb8ed7
--- /dev/null
+++ b/app/src/main/java/org/thoughtcrime/securesms/blurhash/BlurHashModelLoader.java
@@ -0,0 +1,74 @@
+package org.thoughtcrime.securesms.blurhash;
+
+import androidx.annotation.NonNull;
+
+import com.bumptech.glide.Priority;
+import com.bumptech.glide.load.DataSource;
+import com.bumptech.glide.load.Options;
+import com.bumptech.glide.load.data.DataFetcher;
+import com.bumptech.glide.load.model.ModelLoader;
+import com.bumptech.glide.load.model.ModelLoaderFactory;
+import com.bumptech.glide.load.model.MultiModelLoaderFactory;
+import com.bumptech.glide.signature.ObjectKey;
+
+public final class BlurHashModelLoader implements ModelLoader {
+
+ private BlurHashModelLoader() {}
+
+ @Override
+ public LoadData buildLoadData(@NonNull BlurHash blurHash,
+ int width,
+ int height,
+ @NonNull Options options)
+ {
+ return new LoadData<>(new ObjectKey(blurHash.getHash()), new BlurDataFetcher(blurHash));
+ }
+
+ @Override
+ public boolean handles(@NonNull BlurHash blurHash) {
+ return true;
+ }
+
+ private final class BlurDataFetcher implements DataFetcher {
+
+ private final BlurHash blurHash;
+
+ private BlurDataFetcher(@NonNull BlurHash blurHash) {
+ this.blurHash = blurHash;
+ }
+
+ @Override
+ public void loadData(@NonNull Priority priority, @NonNull DataCallback super BlurHash> callback) {
+ callback.onDataReady(blurHash);
+ }
+
+ @Override
+ public void cleanup() {
+ }
+
+ @Override
+ public void cancel() {
+ }
+
+ @Override
+ public @NonNull Class getDataClass() {
+ return BlurHash.class;
+ }
+
+ @Override
+ public @NonNull DataSource getDataSource() {
+ return DataSource.LOCAL;
+ }
+ }
+
+ public static class Factory implements ModelLoaderFactory {
+ @Override
+ public @NonNull ModelLoader build(@NonNull MultiModelLoaderFactory multiFactory) {
+ return new BlurHashModelLoader();
+ }
+
+ @Override
+ public void teardown() {
+ }
+ }
+}
diff --git a/app/src/main/java/org/thoughtcrime/securesms/blurhash/BlurHashResourceDecoder.java b/app/src/main/java/org/thoughtcrime/securesms/blurhash/BlurHashResourceDecoder.java
new file mode 100644
index 00000000..a4464b93
--- /dev/null
+++ b/app/src/main/java/org/thoughtcrime/securesms/blurhash/BlurHashResourceDecoder.java
@@ -0,0 +1,39 @@
+package org.thoughtcrime.securesms.blurhash;
+
+import android.graphics.Bitmap;
+
+import androidx.annotation.NonNull;
+import androidx.annotation.Nullable;
+
+import com.bumptech.glide.load.Options;
+import com.bumptech.glide.load.ResourceDecoder;
+import com.bumptech.glide.load.engine.Resource;
+import com.bumptech.glide.load.resource.SimpleResource;
+
+import java.io.IOException;
+
+public class BlurHashResourceDecoder implements ResourceDecoder {
+
+ private static final int MAX_DIMEN = 20;
+
+ @Override
+ public boolean handles(@NonNull BlurHash source, @NonNull Options options) throws IOException {
+ return true;
+ }
+
+ @Override
+ public @Nullable Resource decode(@NonNull BlurHash source, int width, int height, @NonNull Options options) throws IOException {
+ final int finalWidth;
+ final int finalHeight;
+
+ if (width > height) {
+ finalWidth = Math.min(width, MAX_DIMEN);
+ finalHeight = (int) (finalWidth * height / (float) width);
+ } else {
+ finalHeight = Math.min(height, MAX_DIMEN);
+ finalWidth = (int) (finalHeight * width / (float) height);
+ }
+
+ return new SimpleResource<>(BlurHashDecoder.decode(source.getHash(), finalWidth, finalHeight));
+ }
+}
\ No newline at end of file
diff --git a/app/src/main/java/org/thoughtcrime/securesms/blurhash/BlurHashUtil.java b/app/src/main/java/org/thoughtcrime/securesms/blurhash/BlurHashUtil.java
new file mode 100644
index 00000000..0012c18e
--- /dev/null
+++ b/app/src/main/java/org/thoughtcrime/securesms/blurhash/BlurHashUtil.java
@@ -0,0 +1,62 @@
+/**
+ * Source: https://github.com/hsch/blurhash-java
+ *
+ * Copyright (c) 2019 Hendrik Schnepel
+ *
+ * Permission is hereby granted, free of charge, to any person obtaining a copy of this software and
+ * associated documentation files (the "Software"), to deal in the Software without restriction,
+ * including without limitation the rights to use, copy, modify, merge, publish, distribute,
+ * sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is
+ * furnished to do so, subject to the following conditions:
+ *
+ * The above copyright notice and this permission notice shall be included in all copies or
+ * substantial portions of the Software.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT
+ * NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
+ * NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM,
+ * DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+ * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
+ */
+package org.thoughtcrime.securesms.blurhash;
+
+final class BlurHashUtil {
+
+ static double sRGBToLinear(long value) {
+ double v = value / 255.0;
+ if (v <= 0.04045) {
+ return v / 12.92;
+ } else {
+ return Math.pow((v + 0.055) / 1.055, 2.4);
+ }
+ }
+
+ static long linearTosRGB(double value) {
+ double v = Math.max(0, Math.min(1, value));
+ if (v <= 0.0031308) {
+ return (long)(v * 12.92 * 255 + 0.5);
+ } else {
+ return (long)((1.055 * Math.pow(v, 1 / 2.4) - 0.055) * 255 + 0.5);
+ }
+ }
+
+ static double signPow(double val, double exp) {
+ return Math.copySign(Math.pow(Math.abs(val), exp), val);
+ }
+
+ static double max(double[][] values, int from, int endExclusive) {
+ double result = Double.NEGATIVE_INFINITY;
+ for (int i = from; i < endExclusive; i++) {
+ for (int j = 0; j < values[i].length; j++) {
+ double value = values[i][j];
+ if (value > result) {
+ result = value;
+ }
+ }
+ }
+ return result;
+ }
+
+ private BlurHashUtil() {
+ }
+}
diff --git a/app/src/main/java/org/thoughtcrime/securesms/color/MaterialColor.java b/app/src/main/java/org/thoughtcrime/securesms/color/MaterialColor.java
new file mode 100644
index 00000000..9372a96d
--- /dev/null
+++ b/app/src/main/java/org/thoughtcrime/securesms/color/MaterialColor.java
@@ -0,0 +1,143 @@
+package org.thoughtcrime.securesms.color;
+
+import android.content.Context;
+import android.graphics.Color;
+
+import androidx.annotation.ColorInt;
+import androidx.annotation.ColorRes;
+import androidx.annotation.NonNull;
+
+import org.thoughtcrime.securesms.R;
+import org.thoughtcrime.securesms.util.ThemeUtil;
+
+import java.util.HashMap;
+import java.util.Map;
+
+import static org.thoughtcrime.securesms.util.ThemeUtil.isDarkTheme;
+
+public enum MaterialColor {
+ CRIMSON (R.color.conversation_crimson, R.color.conversation_crimson_tint, R.color.conversation_crimson_shade, "red"),
+ VERMILLION (R.color.conversation_vermillion, R.color.conversation_vermillion_tint, R.color.conversation_vermillion_shade, "orange"),
+ BURLAP (R.color.conversation_burlap, R.color.conversation_burlap_tint, R.color.conversation_burlap_shade, "brown"),
+ FOREST (R.color.conversation_forest, R.color.conversation_forest_tint, R.color.conversation_forest_shade, "green"),
+ WINTERGREEN(R.color.conversation_wintergreen, R.color.conversation_wintergreen_tint, R.color.conversation_wintergreen_shade, "light_green"),
+ TEAL (R.color.conversation_teal, R.color.conversation_teal_tint, R.color.conversation_teal_shade, "teal"),
+ BLUE (R.color.conversation_blue, R.color.conversation_blue_tint, R.color.conversation_blue_shade, "blue"),
+ INDIGO (R.color.conversation_indigo, R.color.conversation_indigo_tint, R.color.conversation_indigo_shade, "indigo"),
+ VIOLET (R.color.conversation_violet, R.color.conversation_violet_tint, R.color.conversation_violet_shade, "purple"),
+ PLUM (R.color.conversation_plumb, R.color.conversation_plumb_tint, R.color.conversation_plumb_shade, "pink"),
+ TAUPE (R.color.conversation_taupe, R.color.conversation_taupe_tint, R.color.conversation_taupe_shade, "blue_grey"),
+ STEEL (R.color.conversation_steel, R.color.conversation_steel_tint, R.color.conversation_steel_shade, "grey"),
+ ULTRAMARINE(R.color.conversation_ultramarine, R.color.conversation_ultramarine_tint, R.color.conversation_ultramarine_shade, "ultramarine"),
+ GROUP (R.color.conversation_group, R.color.conversation_group_tint, R.color.conversation_group_shade, "blue");
+
+ private static final Map COLOR_MATCHES = new HashMap() {{
+ put("red", CRIMSON);
+ put("deep_orange", CRIMSON);
+ put("orange", VERMILLION);
+ put("amber", VERMILLION);
+ put("brown", BURLAP);
+ put("yellow", BURLAP);
+ put("pink", PLUM);
+ put("purple", VIOLET);
+ put("deep_purple", VIOLET);
+ put("indigo", INDIGO);
+ put("blue", BLUE);
+ put("light_blue", BLUE);
+ put("cyan", TEAL);
+ put("teal", TEAL);
+ put("green", FOREST);
+ put("light_green", WINTERGREEN);
+ put("lime", WINTERGREEN);
+ put("blue_grey", TAUPE);
+ put("grey", STEEL);
+ put("ultramarine", ULTRAMARINE);
+ put("group_color", GROUP);
+ }};
+
+ private final @ColorRes int mainColor;
+ private final @ColorRes int tintColor;
+ private final @ColorRes int shadeColor;
+
+ private final String serialized;
+
+
+ MaterialColor(@ColorRes int mainColor, @ColorRes int tintColor, @ColorRes int shadeColor, String serialized) {
+ this.mainColor = mainColor;
+ this.tintColor = tintColor;
+ this.shadeColor = shadeColor;
+ this.serialized = serialized;
+ }
+
+ public @ColorInt int toNotificationColor(@NonNull Context context) {
+ final boolean isDark = ThemeUtil.isDarkNotificationTheme(context);
+ return context.getResources().getColor(isDark ? shadeColor : mainColor);
+ }
+
+ public @ColorInt int toConversationColor(@NonNull Context context) {
+ return context.getResources().getColor(mainColor);
+ }
+
+ public @ColorInt int toAvatarColor(@NonNull Context context) {
+ return context.getResources().getColor(isDarkTheme(context) ? shadeColor : mainColor);
+ }
+
+ public @ColorInt int toActionBarColor(@NonNull Context context) {
+ return context.getResources().getColor(mainColor);
+ }
+
+ public @ColorInt int toStatusBarColor(@NonNull Context context) {
+ return context.getResources().getColor(mainColor);
+ }
+
+ public @ColorRes int toQuoteBarColorResource(@NonNull Context context, boolean outgoing) {
+ if (outgoing) {
+ return isDarkTheme(context) ? tintColor : shadeColor ;
+ }
+ return R.color.core_white;
+ }
+
+ public @ColorInt int toQuoteBackgroundColor(@NonNull Context context, boolean outgoing) {
+ if (outgoing) {
+ int color = toConversationColor(context);
+ int alpha = isDarkTheme(context) ? (int) (0.2 * 255) : (int) (0.4 * 255);
+ return Color.argb(alpha, Color.red(color), Color.green(color), Color.blue(color));
+ }
+ return context.getResources().getColor(isDarkTheme(context) ? R.color.transparent_black_40
+ : R.color.transparent_white_60);
+ }
+
+ public @ColorInt int toQuoteFooterColor(@NonNull Context context, boolean outgoing) {
+ if (outgoing) {
+ int color = toConversationColor(context);
+ int alpha = isDarkTheme(context) ? (int) (0.4 * 255) : (int) (0.6 * 255);
+ return Color.argb(alpha, Color.red(color), Color.green(color), Color.blue(color));
+ }
+ return context.getResources().getColor(isDarkTheme(context) ? R.color.transparent_black_60
+ : R.color.transparent_white_80);
+ }
+
+ public boolean represents(Context context, int colorValue) {
+ return context.getResources().getColor(mainColor) == colorValue ||
+ context.getResources().getColor(tintColor) == colorValue ||
+ context.getResources().getColor(shadeColor) == colorValue;
+ }
+
+ public String serialize() {
+ return serialized;
+ }
+
+ public static MaterialColor fromSerialized(String serialized) throws UnknownColorException {
+ if (COLOR_MATCHES.containsKey(serialized)) {
+ return COLOR_MATCHES.get(serialized);
+ }
+
+ throw new UnknownColorException("Unknown color: " + serialized);
+ }
+
+ public static class UnknownColorException extends Exception {
+ public UnknownColorException(String message) {
+ super(message);
+ }
+ }
+}
diff --git a/app/src/main/java/org/thoughtcrime/securesms/color/MaterialColors.java b/app/src/main/java/org/thoughtcrime/securesms/color/MaterialColors.java
new file mode 100644
index 00000000..f64d7a21
--- /dev/null
+++ b/app/src/main/java/org/thoughtcrime/securesms/color/MaterialColors.java
@@ -0,0 +1,69 @@
+package org.thoughtcrime.securesms.color;
+
+import android.content.Context;
+
+import androidx.annotation.ColorInt;
+import androidx.annotation.NonNull;
+import androidx.annotation.Nullable;
+
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.List;
+
+public class MaterialColors {
+
+ public static final MaterialColorList CONVERSATION_PALETTE = new MaterialColorList(new ArrayList<>(Arrays.asList(
+ MaterialColor.PLUM,
+ MaterialColor.CRIMSON,
+ MaterialColor.VERMILLION,
+ MaterialColor.VIOLET,
+ MaterialColor.INDIGO,
+ MaterialColor.TAUPE,
+ MaterialColor.ULTRAMARINE,
+ MaterialColor.BLUE,
+ MaterialColor.TEAL,
+ MaterialColor.FOREST,
+ MaterialColor.WINTERGREEN,
+ MaterialColor.BURLAP,
+ MaterialColor.STEEL
+ )));
+
+ public static class MaterialColorList {
+
+ private final List colors;
+
+ private MaterialColorList(List colors) {
+ this.colors = colors;
+ }
+
+ public MaterialColor get(int index) {
+ return colors.get(index);
+ }
+
+ public int size() {
+ return colors.size();
+ }
+
+ public @Nullable MaterialColor getByColor(Context context, int colorValue) {
+ for (MaterialColor color : colors) {
+ if (color.represents(context, colorValue)) {
+ return color;
+ }
+ }
+
+ return null;
+ }
+
+ public @ColorInt int[] asConversationColorArray(@NonNull Context context) {
+ int[] results = new int[colors.size()];
+ int index = 0;
+
+ for (MaterialColor color : colors) {
+ results[index++] = color.toConversationColor(context);
+ }
+
+ return results;
+ }
+ }
+}
+
diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/AccessibleToggleButton.java b/app/src/main/java/org/thoughtcrime/securesms/components/AccessibleToggleButton.java
new file mode 100644
index 00000000..b02e0894
--- /dev/null
+++ b/app/src/main/java/org/thoughtcrime/securesms/components/AccessibleToggleButton.java
@@ -0,0 +1,47 @@
+package org.thoughtcrime.securesms.components;
+
+
+import android.content.Context;
+import android.util.AttributeSet;
+
+import androidx.appcompat.widget.AppCompatToggleButton;
+
+public class AccessibleToggleButton extends AppCompatToggleButton {
+
+ private OnCheckedChangeListener listener;
+
+ public AccessibleToggleButton(Context context, AttributeSet attrs, int defStyleAttr) {
+ super(context, attrs, defStyleAttr);
+ }
+
+ public AccessibleToggleButton(Context context, AttributeSet attrs) {
+ super(context, attrs);
+ }
+
+ public AccessibleToggleButton(Context context) {
+ super(context);
+ }
+
+ @Override
+ public void setOnCheckedChangeListener(OnCheckedChangeListener listener) {
+ super.setOnCheckedChangeListener(listener);
+ this.listener = listener;
+ }
+
+ public void setChecked(boolean checked, boolean notifyListener) {
+ if (!notifyListener) {
+ super.setOnCheckedChangeListener(null);
+ }
+
+ super.setChecked(checked);
+
+ if (!notifyListener) {
+ super.setOnCheckedChangeListener(listener);
+ }
+ }
+
+ public OnCheckedChangeListener getOnCheckedChangeListener() {
+ return this.listener;
+ }
+
+}
diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/AlbumThumbnailView.java b/app/src/main/java/org/thoughtcrime/securesms/components/AlbumThumbnailView.java
new file mode 100644
index 00000000..7864e26e
--- /dev/null
+++ b/app/src/main/java/org/thoughtcrime/securesms/components/AlbumThumbnailView.java
@@ -0,0 +1,159 @@
+package org.thoughtcrime.securesms.components;
+
+import android.content.Context;
+import android.util.AttributeSet;
+import android.view.ViewGroup;
+import android.widget.FrameLayout;
+import android.widget.TextView;
+
+import androidx.annotation.ColorInt;
+import androidx.annotation.IdRes;
+import androidx.annotation.NonNull;
+import androidx.annotation.Nullable;
+
+import org.thoughtcrime.securesms.R;
+import org.thoughtcrime.securesms.mms.GlideRequests;
+import org.thoughtcrime.securesms.mms.Slide;
+import org.thoughtcrime.securesms.mms.SlideClickListener;
+import org.thoughtcrime.securesms.mms.SlidesClickedListener;
+import org.thoughtcrime.securesms.util.views.Stub;
+
+import java.util.List;
+
+public class AlbumThumbnailView extends FrameLayout {
+
+ private @Nullable SlideClickListener thumbnailClickListener;
+ private @Nullable SlidesClickedListener downloadClickListener;
+
+ private int currentSizeClass;
+
+ private ViewGroup albumCellContainer;
+ private Stub transferControls;
+
+ private final SlideClickListener defaultThumbnailClickListener = (v, slide) -> {
+ if (thumbnailClickListener != null) {
+ thumbnailClickListener.onClick(v, slide);
+ }
+ };
+
+ private final OnLongClickListener defaultLongClickListener = v -> this.performLongClick();
+
+ public AlbumThumbnailView(@NonNull Context context) {
+ super(context);
+ initialize();
+ }
+
+ public AlbumThumbnailView(@NonNull Context context, @Nullable AttributeSet attrs) {
+ super(context, attrs);
+ initialize();
+ }
+
+ private void initialize() {
+ inflate(getContext(), R.layout.album_thumbnail_view, this);
+
+ albumCellContainer = findViewById(R.id.album_cell_container);
+ transferControls = new Stub<>(findViewById(R.id.album_transfer_controls_stub));
+ }
+
+ public void setSlides(@NonNull GlideRequests glideRequests, @NonNull List slides, boolean showControls) {
+ if (slides.size() < 2) {
+ throw new IllegalStateException("Provided less than two slides.");
+ }
+
+ if (showControls) {
+ transferControls.get().setShowDownloadText(true);
+ transferControls.get().setSlides(slides);
+ transferControls.get().setDownloadClickListener(v -> {
+ if (downloadClickListener != null) {
+ downloadClickListener.onClick(v, slides);
+ }
+ });
+ } else {
+ if (transferControls.resolved()) {
+ transferControls.get().setVisibility(GONE);
+ }
+ }
+
+ int sizeClass = sizeClass(slides.size());
+
+ if (sizeClass != currentSizeClass) {
+ inflateLayout(sizeClass);
+ currentSizeClass = sizeClass;
+ }
+
+ showSlides(glideRequests, slides);
+ }
+
+ public void setCellBackgroundColor(@ColorInt int color) {
+ ViewGroup cellRoot = findViewById(R.id.album_thumbnail_root);
+
+ if (cellRoot != null) {
+ for (int i = 0; i < cellRoot.getChildCount(); i++) {
+ cellRoot.getChildAt(i).setBackgroundColor(color);
+ }
+ }
+ }
+
+ public void setThumbnailClickListener(@Nullable SlideClickListener listener) {
+ thumbnailClickListener = listener;
+ }
+
+ public void setDownloadClickListener(@Nullable SlidesClickedListener listener) {
+ downloadClickListener = listener;
+ }
+
+ private void inflateLayout(int sizeClass) {
+ albumCellContainer.removeAllViews();
+
+ switch (sizeClass) {
+ case 2:
+ inflate(getContext(), R.layout.album_thumbnail_2, albumCellContainer);
+ break;
+ case 3:
+ inflate(getContext(), R.layout.album_thumbnail_3, albumCellContainer);
+ break;
+ case 4:
+ inflate(getContext(), R.layout.album_thumbnail_4, albumCellContainer);
+ break;
+ case 5:
+ inflate(getContext(), R.layout.album_thumbnail_5, albumCellContainer);
+ break;
+ default:
+ inflate(getContext(), R.layout.album_thumbnail_many, albumCellContainer);
+ break;
+ }
+ }
+
+ private void showSlides(@NonNull GlideRequests glideRequests, @NonNull List slides) {
+ setSlide(glideRequests, slides.get(0), R.id.album_cell_1);
+ setSlide(glideRequests, slides.get(1), R.id.album_cell_2);
+
+ if (slides.size() >= 3) {
+ setSlide(glideRequests, slides.get(2), R.id.album_cell_3);
+ }
+
+ if (slides.size() >= 4) {
+ setSlide(glideRequests, slides.get(3), R.id.album_cell_4);
+ }
+
+ if (slides.size() >= 5) {
+ setSlide(glideRequests, slides.get(4), R.id.album_cell_5);
+ }
+
+ if (slides.size() > 5) {
+ TextView text = findViewById(R.id.album_cell_overflow_text);
+ text.setText(getContext().getString(R.string.AlbumThumbnailView_plus, slides.size() - 5));
+ }
+ }
+
+ private void setSlide(@NonNull GlideRequests glideRequests, @NonNull Slide slide, @IdRes int id) {
+ ThumbnailView cell = findViewById(id);
+ cell.setImageResource(glideRequests, slide, false, false);
+ cell.setThumbnailClickListener(defaultThumbnailClickListener);
+ cell.setOnLongClickListener(defaultLongClickListener);
+ }
+
+ private int sizeClass(int size) {
+ return Math.min(size, 6);
+ }
+}
diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/AlertView.java b/app/src/main/java/org/thoughtcrime/securesms/components/AlertView.java
new file mode 100644
index 00000000..1c703079
--- /dev/null
+++ b/app/src/main/java/org/thoughtcrime/securesms/components/AlertView.java
@@ -0,0 +1,71 @@
+package org.thoughtcrime.securesms.components;
+
+import android.annotation.TargetApi;
+import android.content.Context;
+import android.content.res.TypedArray;
+import android.os.Build.VERSION_CODES;
+import android.util.AttributeSet;
+import android.view.View;
+import android.widget.ImageView;
+import android.widget.LinearLayout;
+
+import org.thoughtcrime.securesms.R;
+
+public class AlertView extends LinearLayout {
+
+ private static final String TAG = AlertView.class.getSimpleName();
+
+ private ImageView approvalIndicator;
+ private ImageView failedIndicator;
+
+ public AlertView(Context context) {
+ this(context, null);
+ }
+
+ public AlertView(Context context, AttributeSet attrs) {
+ super(context, attrs);
+ initialize(attrs);
+ }
+
+ @TargetApi(VERSION_CODES.HONEYCOMB)
+ public AlertView(final Context context, AttributeSet attrs, int defStyle) {
+ super(context, attrs, defStyle);
+ initialize(attrs);
+ }
+
+ private void initialize(AttributeSet attrs) {
+ inflate(getContext(), R.layout.alert_view, this);
+
+ approvalIndicator = findViewById(R.id.pending_approval_indicator);
+ failedIndicator = findViewById(R.id.sms_failed_indicator);
+
+ if (attrs != null) {
+ TypedArray typedArray = getContext().getTheme().obtainStyledAttributes(attrs, R.styleable.AlertView, 0, 0);
+ boolean useSmallIcon = typedArray.getBoolean(R.styleable.AlertView_useSmallIcon, false);
+ typedArray.recycle();
+
+ if (useSmallIcon) {
+ int size = getResources().getDimensionPixelOffset(R.dimen.alertview_small_icon_size);
+ failedIndicator.getLayoutParams().width = size;
+ failedIndicator.getLayoutParams().height = size;
+ requestLayout();
+ }
+ }
+ }
+
+ public void setNone() {
+ this.setVisibility(View.GONE);
+ }
+
+ public void setPendingApproval() {
+ this.setVisibility(View.VISIBLE);
+ approvalIndicator.setVisibility(View.VISIBLE);
+ failedIndicator.setVisibility(View.GONE);
+ }
+
+ public void setFailed() {
+ this.setVisibility(View.VISIBLE);
+ approvalIndicator.setVisibility(View.GONE);
+ failedIndicator.setVisibility(View.VISIBLE);
+ }
+}
diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/AnimatingToggle.java b/app/src/main/java/org/thoughtcrime/securesms/components/AnimatingToggle.java
new file mode 100644
index 00000000..41676c6a
--- /dev/null
+++ b/app/src/main/java/org/thoughtcrime/securesms/components/AnimatingToggle.java
@@ -0,0 +1,71 @@
+package org.thoughtcrime.securesms.components;
+
+import android.content.Context;
+import android.util.AttributeSet;
+import android.view.View;
+import android.view.ViewGroup;
+import android.view.animation.Animation;
+import android.view.animation.AnimationUtils;
+import android.widget.FrameLayout;
+
+import androidx.annotation.NonNull;
+import androidx.annotation.Nullable;
+import androidx.interpolator.view.animation.FastOutSlowInInterpolator;
+
+import org.thoughtcrime.securesms.R;
+import org.thoughtcrime.securesms.util.ViewUtil;
+
+public class AnimatingToggle extends FrameLayout {
+
+ private View current;
+
+ private final Animation inAnimation;
+ private final Animation outAnimation;
+
+ public AnimatingToggle(Context context) {
+ this(context, null);
+ }
+
+ public AnimatingToggle(Context context, AttributeSet attrs) {
+ this(context, attrs, 0);
+ }
+
+ public AnimatingToggle(Context context, AttributeSet attrs, int defStyleAttr) {
+ super(context, attrs, defStyleAttr);
+ this.outAnimation = AnimationUtils.loadAnimation(getContext(), R.anim.animation_toggle_out);
+ this.inAnimation = AnimationUtils.loadAnimation(getContext(), R.anim.animation_toggle_in);
+ this.outAnimation.setInterpolator(new FastOutSlowInInterpolator());
+ this.inAnimation.setInterpolator(new FastOutSlowInInterpolator());
+ }
+
+ @Override
+ public void addView(@NonNull View child, int index, ViewGroup.LayoutParams params) {
+ super.addView(child, index, params);
+
+ if (!isInEditMode()) {
+ if (getChildCount() == 1) {
+ current = child;
+ child.setVisibility(View.VISIBLE);
+ } else {
+ child.setVisibility(View.GONE);
+ }
+ child.setClickable(false);
+ }
+ }
+
+ public void display(@Nullable View view) {
+ if (view == current) return;
+ if (current != null) ViewUtil.animateOut(current, outAnimation, View.GONE);
+ if (view != null) ViewUtil.animateIn(view, inAnimation);
+
+ current = view;
+ }
+
+ public void displayQuick(@Nullable View view) {
+ if (view == current) return;
+ if (current != null) current.setVisibility(View.GONE);
+ if (view != null) view.setVisibility(View.VISIBLE);
+
+ current = view;
+ }
+}
diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/ArcProgressBar.java b/app/src/main/java/org/thoughtcrime/securesms/components/ArcProgressBar.java
new file mode 100644
index 00000000..e9062fb6
--- /dev/null
+++ b/app/src/main/java/org/thoughtcrime/securesms/components/ArcProgressBar.java
@@ -0,0 +1,125 @@
+package org.thoughtcrime.securesms.components;
+
+import android.content.Context;
+import android.content.res.TypedArray;
+import android.graphics.Canvas;
+import android.graphics.Paint;
+import android.graphics.RectF;
+import android.os.Bundle;
+import android.os.Parcelable;
+import android.util.AttributeSet;
+import android.view.View;
+
+import androidx.annotation.ColorInt;
+import androidx.annotation.NonNull;
+import androidx.annotation.Nullable;
+
+import org.thoughtcrime.securesms.R;
+import org.thoughtcrime.securesms.util.Util;
+
+public class ArcProgressBar extends View {
+
+ private static final int DEFAULT_WIDTH = 10;
+ private static final float DEFAULT_PROGRESS = 0f;
+ private static final int DEFAULT_BACKGROUND_COLOR = 0xFF000000;
+ private static final int DEFAULT_FOREGROUND_COLOR = 0xFFFFFFFF;
+ private static final float DEFAULT_START_ANGLE = 0f;
+ private static final float DEFAULT_SWEEP_ANGLE = 360f;
+ private static final boolean DEFAULT_ROUNDED_ENDS = true;
+
+ private static final String SUPER = "arcprogressbar.super";
+ private static final String PROGRESS = "arcprogressbar.progress";
+
+ private float progress;
+ private final float width;
+ private final RectF arcRect = new RectF();
+
+ private final Paint arcBackgroundPaint;
+ private final Paint arcForegroundPaint;
+ private final float arcStartAngle;
+ private final float arcSweepAngle;
+
+ public ArcProgressBar(@NonNull Context context) {
+ this(context, null);
+ }
+
+ public ArcProgressBar(@NonNull Context context, @Nullable AttributeSet attrs) {
+ this(context, attrs, 0);
+ }
+
+ public ArcProgressBar(@NonNull Context context, @Nullable AttributeSet attrs, int defStyleAttr) {
+ super(context, attrs, defStyleAttr);
+
+ TypedArray attributes = context.getTheme().obtainStyledAttributes(attrs, R.styleable.ArcProgressBar, defStyleAttr, 0);
+
+ width = attributes.getDimensionPixelSize(R.styleable.ArcProgressBar_arcWidth, DEFAULT_WIDTH);
+ progress = attributes.getFloat(R.styleable.ArcProgressBar_arcProgress, DEFAULT_PROGRESS);
+ arcBackgroundPaint = createPaint(width, attributes.getColor(R.styleable.ArcProgressBar_arcBackgroundColor, DEFAULT_BACKGROUND_COLOR));
+ arcForegroundPaint = createPaint(width, attributes.getColor(R.styleable.ArcProgressBar_arcForegroundColor, DEFAULT_FOREGROUND_COLOR));
+ arcStartAngle = attributes.getFloat(R.styleable.ArcProgressBar_arcStartAngle, DEFAULT_START_ANGLE);
+ arcSweepAngle = attributes.getFloat(R.styleable.ArcProgressBar_arcSweepAngle, DEFAULT_SWEEP_ANGLE);
+
+ if (attributes.getBoolean(R.styleable.ArcProgressBar_arcRoundedEnds, DEFAULT_ROUNDED_ENDS)) {
+ arcForegroundPaint.setStrokeCap(Paint.Cap.ROUND);
+
+ if (arcSweepAngle <= 360f) {
+ arcBackgroundPaint.setStrokeCap(Paint.Cap.ROUND);
+ }
+ }
+
+ attributes.recycle();
+ }
+
+ private static Paint createPaint(float width, @ColorInt int color) {
+ Paint paint = new Paint();
+
+ paint.setStrokeWidth(width);
+ paint.setStyle(Paint.Style.STROKE);
+ paint.setAntiAlias(true);
+ paint.setColor(color);
+
+ return paint;
+ }
+
+ public void setProgress(float progress) {
+ if (this.progress != progress) {
+ this.progress = progress;
+ invalidate();
+ }
+ }
+
+ @Override
+ protected @Nullable Parcelable onSaveInstanceState() {
+ Parcelable superState = super.onSaveInstanceState();
+
+ Bundle bundle = new Bundle();
+ bundle.putParcelable(SUPER, superState);
+ bundle.putFloat(PROGRESS, progress);
+
+ return bundle;
+ }
+
+ @Override
+ protected void onRestoreInstanceState(Parcelable state) {
+ if (state.getClass() != Bundle.class) throw new IllegalStateException("Expected");
+
+ Bundle restoreState = (Bundle) state;
+
+ Parcelable superState = restoreState.getParcelable(SUPER);
+ super.onRestoreInstanceState(superState);
+
+ progress = restoreState.getLong(PROGRESS);
+ }
+
+ @Override
+ protected void onDraw(Canvas canvas) {
+ float halfWidth = width / 2f;
+ arcRect.set(0 + halfWidth,
+ 0 + halfWidth,
+ getWidth() - halfWidth,
+ getHeight() - halfWidth);
+
+ canvas.drawArc(arcRect, arcStartAngle, arcSweepAngle, false, arcBackgroundPaint);
+ canvas.drawArc(arcRect, arcStartAngle, arcSweepAngle * Util.clamp(progress, 0f, 1f), false, arcForegroundPaint);
+ }
+}
diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/AudioView.java b/app/src/main/java/org/thoughtcrime/securesms/components/AudioView.java
new file mode 100644
index 00000000..a0d776cd
--- /dev/null
+++ b/app/src/main/java/org/thoughtcrime/securesms/components/AudioView.java
@@ -0,0 +1,479 @@
+package org.thoughtcrime.securesms.components;
+
+import android.content.Context;
+import android.content.res.TypedArray;
+import android.graphics.Color;
+import android.graphics.PorterDuff;
+import android.graphics.Rect;
+import android.net.Uri;
+import android.util.AttributeSet;
+import android.view.MotionEvent;
+import android.view.View;
+import android.widget.FrameLayout;
+import android.widget.ImageView;
+import android.widget.SeekBar;
+import android.widget.TextView;
+
+import androidx.annotation.ColorInt;
+import androidx.annotation.NonNull;
+import androidx.annotation.Nullable;
+import androidx.lifecycle.Observer;
+
+import com.airbnb.lottie.LottieAnimationView;
+import com.airbnb.lottie.LottieProperty;
+import com.airbnb.lottie.SimpleColorFilter;
+import com.airbnb.lottie.model.KeyPath;
+import com.airbnb.lottie.value.LottieValueCallback;
+import com.pnikosis.materialishprogress.ProgressWheel;
+
+import org.greenrobot.eventbus.EventBus;
+import org.greenrobot.eventbus.Subscribe;
+import org.greenrobot.eventbus.ThreadMode;
+import org.thoughtcrime.securesms.R;
+import org.thoughtcrime.securesms.audio.AudioWaveForm;
+import org.thoughtcrime.securesms.components.voice.VoiceNotePlaybackState;
+import org.thoughtcrime.securesms.database.AttachmentDatabase;
+import org.thoughtcrime.securesms.events.PartProgressEvent;
+import org.thoughtcrime.securesms.mms.AudioSlide;
+import org.thoughtcrime.securesms.mms.SlideClickListener;
+
+import java.util.Objects;
+import java.util.concurrent.TimeUnit;
+
+public final class AudioView extends FrameLayout {
+
+ private static final String TAG = AudioView.class.getSimpleName();
+
+ private static final int FORWARDS = 1;
+ private static final int REVERSE = -1;
+
+ @NonNull private final AnimatingToggle controlToggle;
+ @NonNull private final View progressAndPlay;
+ @NonNull private final LottieAnimationView playPauseButton;
+ @NonNull private final ImageView downloadButton;
+ @NonNull private final ProgressWheel circleProgress;
+ @NonNull private final SeekBar seekBar;
+ private final boolean smallView;
+ private final boolean autoRewind;
+
+ @Nullable private final TextView duration;
+
+ @ColorInt private final int waveFormPlayedBarsColor;
+ @ColorInt private final int waveFormUnplayedBarsColor;
+ @ColorInt private final int waveFormThumbTint;
+
+ @Nullable private SlideClickListener downloadListener;
+ private int backwardsCounter;
+ private int lottieDirection;
+ private boolean isPlaying;
+ private long durationMillis;
+ private AudioSlide audioSlide;
+ private Callbacks callbacks;
+
+ private final Observer playbackStateObserver = this::onPlaybackState;
+
+ public AudioView(Context context) {
+ this(context, null);
+ }
+
+ public AudioView(Context context, AttributeSet attrs) {
+ this(context, attrs, 0);
+ }
+
+ public AudioView(Context context, AttributeSet attrs, int defStyleAttr) {
+ super(context, attrs, defStyleAttr);
+ TypedArray typedArray = null;
+ try {
+ typedArray = context.getTheme().obtainStyledAttributes(attrs, R.styleable.AudioView, 0, 0);
+
+ smallView = typedArray.getBoolean(R.styleable.AudioView_small, false);
+ autoRewind = typedArray.getBoolean(R.styleable.AudioView_autoRewind, false);
+
+ inflate(context, smallView ? R.layout.audio_view_small : R.layout.audio_view, this);
+
+ this.controlToggle = findViewById(R.id.control_toggle);
+ this.playPauseButton = findViewById(R.id.play);
+ this.progressAndPlay = findViewById(R.id.progress_and_play);
+ this.downloadButton = findViewById(R.id.download);
+ this.circleProgress = findViewById(R.id.circle_progress);
+ this.seekBar = findViewById(R.id.seek);
+ this.duration = findViewById(R.id.duration);
+
+ lottieDirection = REVERSE;
+ this.playPauseButton.setOnClickListener(new PlayPauseClickedListener());
+ this.seekBar.setOnSeekBarChangeListener(new SeekBarModifiedListener());
+
+ setTint(typedArray.getColor(R.styleable.AudioView_foregroundTintColor, Color.WHITE));
+
+ this.waveFormPlayedBarsColor = typedArray.getColor(R.styleable.AudioView_waveformPlayedBarsColor, Color.WHITE);
+ this.waveFormUnplayedBarsColor = typedArray.getColor(R.styleable.AudioView_waveformUnplayedBarsColor, Color.WHITE);
+ this.waveFormThumbTint = typedArray.getColor(R.styleable.AudioView_waveformThumbTint, Color.WHITE);
+
+ progressAndPlay.getBackground().setColorFilter(typedArray.getColor(R.styleable.AudioView_progressAndPlayTint, Color.BLACK), PorterDuff.Mode.SRC_IN);
+ } finally {
+ if (typedArray != null) {
+ typedArray.recycle();
+ }
+ }
+ }
+
+ @Override
+ protected void onAttachedToWindow() {
+ super.onAttachedToWindow();
+ if (!EventBus.getDefault().isRegistered(this)) EventBus.getDefault().register(this);
+ }
+
+ @Override
+ protected void onDetachedFromWindow() {
+ super.onDetachedFromWindow();
+ EventBus.getDefault().unregister(this);
+ }
+
+ public Observer getPlaybackStateObserver() {
+ return playbackStateObserver;
+ }
+
+ public void setAudio(final @NonNull AudioSlide audio,
+ final @Nullable Callbacks callbacks,
+ final boolean showControls,
+ final boolean forceHideDuration)
+ {
+ this.callbacks = callbacks;
+
+ if (duration != null) {
+ duration.setVisibility(View.VISIBLE);
+ }
+
+ if (seekBar instanceof WaveFormSeekBarView) {
+ if (audioSlide != null && !Objects.equals(audioSlide.getUri(), audio.getUri())) {
+ WaveFormSeekBarView waveFormView = (WaveFormSeekBarView) seekBar;
+ waveFormView.setWaveMode(false);
+ seekBar.setProgress(0);
+ durationMillis = 0;
+ }
+ }
+
+ if (showControls && audio.isPendingDownload()) {
+ controlToggle.displayQuick(downloadButton);
+ seekBar.setEnabled(false);
+ downloadButton.setOnClickListener(new DownloadClickedListener(audio));
+ if (circleProgress.isSpinning()) circleProgress.stopSpinning();
+ circleProgress.setVisibility(View.GONE);
+ } else if (showControls && audio.getTransferState() == AttachmentDatabase.TRANSFER_PROGRESS_STARTED) {
+ controlToggle.displayQuick(progressAndPlay);
+ seekBar.setEnabled(false);
+ circleProgress.setVisibility(View.VISIBLE);
+ circleProgress.spin();
+ } else {
+ seekBar.setEnabled(true);
+ if (circleProgress.isSpinning()) circleProgress.stopSpinning();
+ showPlayButton();
+ }
+
+ this.audioSlide = audio;
+
+ if (seekBar instanceof WaveFormSeekBarView) {
+ WaveFormSeekBarView waveFormView = (WaveFormSeekBarView) seekBar;
+ waveFormView.setColors(waveFormPlayedBarsColor, waveFormUnplayedBarsColor, waveFormThumbTint);
+ if (android.os.Build.VERSION.SDK_INT >= 23) {
+ new AudioWaveForm(getContext(), audio).getWaveForm(
+ data -> {
+ durationMillis = data.getDuration(TimeUnit.MILLISECONDS);
+ updateProgress(0, 0);
+ if (!forceHideDuration && duration != null) {
+ duration.setVisibility(VISIBLE);
+ }
+ waveFormView.setWaveData(data.getWaveForm());
+ },
+ () -> waveFormView.setWaveMode(false));
+ } else {
+ waveFormView.setWaveMode(false);
+ if (duration != null) {
+ duration.setVisibility(GONE);
+ }
+ }
+ }
+
+ if (forceHideDuration && duration != null) {
+ duration.setVisibility(View.GONE);
+ }
+ }
+
+ public void setDownloadClickListener(@Nullable SlideClickListener listener) {
+ this.downloadListener = listener;
+ }
+
+ public @Nullable Uri getAudioSlideUri() {
+ if (audioSlide != null) return audioSlide.getUri();
+ else return null;
+ }
+
+ private void onPlaybackState(@NonNull VoiceNotePlaybackState voiceNotePlaybackState) {
+ onDuration(voiceNotePlaybackState.getUri(), voiceNotePlaybackState.getTrackDuration());
+ onStart(voiceNotePlaybackState.getUri(), voiceNotePlaybackState.isAutoReset());
+ onProgress(voiceNotePlaybackState.getUri(),
+ (double) voiceNotePlaybackState.getPlayheadPositionMillis() / voiceNotePlaybackState.getTrackDuration(),
+ voiceNotePlaybackState.getPlayheadPositionMillis());
+ }
+
+ private void onDuration(@NonNull Uri uri, long durationMillis) {
+ if (isTarget(uri)) {
+ this.durationMillis = durationMillis;
+ }
+ }
+
+ private void onStart(@NonNull Uri uri, boolean autoReset) {
+ if (!isTarget(uri)) {
+ if (hasAudioUri()) {
+ onStop(audioSlide.getUri(), autoReset);
+ }
+
+ return;
+ }
+
+ if (isPlaying) {
+ return;
+ }
+
+ isPlaying = true;
+ togglePlayToPause();
+ }
+
+ private void onStop(@NonNull Uri uri, boolean autoReset) {
+ if (!isTarget(uri)) {
+ return;
+ }
+
+ if (!isPlaying) {
+ return;
+ }
+
+ isPlaying = false;
+ togglePauseToPlay();
+
+ if (autoReset || autoRewind || seekBar.getProgress() + 5 >= seekBar.getMax()) {
+ backwardsCounter = 4;
+ rewind();
+ }
+ }
+
+ private void onProgress(@NonNull Uri uri, double progress, long millis) {
+ if (!isTarget(uri)) {
+ return;
+ }
+
+ int seekProgress = (int) Math.floor(progress * seekBar.getMax());
+
+ if (seekProgress > seekBar.getProgress() || backwardsCounter > 3) {
+ backwardsCounter = 0;
+ seekBar.setProgress(seekProgress);
+ updateProgress((float) progress, millis);
+ } else {
+ backwardsCounter++;
+ }
+ }
+
+ private boolean isTarget(@NonNull Uri uri) {
+ return hasAudioUri() && Objects.equals(uri, audioSlide.getUri());
+ }
+
+ private boolean hasAudioUri() {
+ return audioSlide != null && audioSlide.getUri() != null;
+ }
+
+ @Override
+ public void setFocusable(boolean focusable) {
+ super.setFocusable(focusable);
+ this.playPauseButton.setFocusable(focusable);
+ this.seekBar.setFocusable(focusable);
+ this.seekBar.setFocusableInTouchMode(focusable);
+ this.downloadButton.setFocusable(focusable);
+ }
+
+ @Override
+ public void setClickable(boolean clickable) {
+ super.setClickable(clickable);
+ this.playPauseButton.setClickable(clickable);
+ this.seekBar.setClickable(clickable);
+ this.seekBar.setOnTouchListener(clickable ? null : new TouchIgnoringListener());
+ this.downloadButton.setClickable(clickable);
+ }
+
+ @Override
+ public void setEnabled(boolean enabled) {
+ super.setEnabled(enabled);
+ this.playPauseButton.setEnabled(enabled);
+ this.seekBar.setEnabled(enabled);
+ this.downloadButton.setEnabled(enabled);
+ }
+
+ private void updateProgress(float progress, long millis) {
+ if (callbacks != null) {
+ callbacks.onProgressUpdated(durationMillis, millis);
+ }
+
+ if (duration != null && durationMillis > 0) {
+ long remainingSecs = TimeUnit.MILLISECONDS.toSeconds(durationMillis - millis);
+ duration.setText(getResources().getString(R.string.AudioView_duration, remainingSecs / 60, remainingSecs % 60));
+ }
+
+ if (smallView) {
+ circleProgress.setInstantProgress(seekBar.getProgress() == 0 ? 1 : progress);
+ }
+ }
+
+ public void setTint(int foregroundTint) {
+ post(()-> this.playPauseButton.addValueCallback(new KeyPath("**"),
+ LottieProperty.COLOR_FILTER,
+ new LottieValueCallback<>(new SimpleColorFilter(foregroundTint))));
+
+ this.downloadButton.setColorFilter(foregroundTint, PorterDuff.Mode.SRC_IN);
+ this.circleProgress.setBarColor(foregroundTint);
+
+ if (this.duration != null) {
+ this.duration.setTextColor(foregroundTint);
+ }
+ this.seekBar.getProgressDrawable().setColorFilter(foregroundTint, PorterDuff.Mode.SRC_IN);
+ this.seekBar.getThumb().setColorFilter(foregroundTint, PorterDuff.Mode.SRC_IN);
+ }
+
+ public void getSeekBarGlobalVisibleRect(@NonNull Rect rect) {
+ seekBar.getGlobalVisibleRect(rect);
+ }
+
+ private double getProgress() {
+ if (this.seekBar.getProgress() <= 0 || this.seekBar.getMax() <= 0) {
+ return 0;
+ } else {
+ return (double)this.seekBar.getProgress() / (double)this.seekBar.getMax();
+ }
+ }
+
+ private void togglePlayToPause() {
+ startLottieAnimation(FORWARDS);
+ }
+
+ private void togglePauseToPlay() {
+ startLottieAnimation(REVERSE);
+ }
+
+ private void startLottieAnimation(int direction) {
+ showPlayButton();
+
+ if (lottieDirection == direction) {
+ return;
+ }
+ lottieDirection = direction;
+
+ playPauseButton.pauseAnimation();
+ playPauseButton.setSpeed(direction * 2);
+ playPauseButton.resumeAnimation();
+ }
+
+ private void showPlayButton() {
+ if (!smallView) {
+ circleProgress.setVisibility(GONE);
+ } else if (seekBar.getProgress() == 0) {
+ circleProgress.setInstantProgress(1);
+ }
+ playPauseButton.setVisibility(VISIBLE);
+ controlToggle.displayQuick(progressAndPlay);
+ }
+
+ public void stopPlaybackAndReset() {
+ if (audioSlide == null || audioSlide.getUri() == null) return;
+
+ if (callbacks != null) {
+ callbacks.onStopAndReset(audioSlide.getUri());
+ rewind();
+ }
+ }
+
+ private class PlayPauseClickedListener implements View.OnClickListener {
+
+ @Override
+ public void onClick(View v) {
+ if (audioSlide == null || audioSlide.getUri() == null) return;
+
+ if (callbacks != null) {
+ if (lottieDirection == REVERSE) {
+ callbacks.onPlay(audioSlide.getUri(), getProgress());
+ } else {
+ callbacks.onPause(audioSlide.getUri());
+ }
+ }
+ }
+ }
+
+ private void rewind() {
+ seekBar.setProgress(0);
+ updateProgress(0, 0);
+ }
+
+ private class DownloadClickedListener implements View.OnClickListener {
+ private final @NonNull AudioSlide slide;
+
+ private DownloadClickedListener(@NonNull AudioSlide slide) {
+ this.slide = slide;
+ }
+
+ @Override
+ public void onClick(View v) {
+ if (downloadListener != null) downloadListener.onClick(v, slide);
+ }
+ }
+
+ private class SeekBarModifiedListener implements SeekBar.OnSeekBarChangeListener {
+
+ private boolean wasPlaying;
+
+ @Override
+ public void onProgressChanged(SeekBar seekBar, int progress, boolean fromUser) {
+ }
+
+ @Override
+ public synchronized void onStartTrackingTouch(SeekBar seekBar) {
+ if (audioSlide == null || audioSlide.getUri() == null) return;
+
+ wasPlaying = isPlaying;
+ if (isPlaying) {
+ if (callbacks != null) {
+ callbacks.onPause(audioSlide.getUri());
+ }
+ }
+ }
+
+ @Override
+ public synchronized void onStopTrackingTouch(SeekBar seekBar) {
+ if (audioSlide == null || audioSlide.getUri() == null) return;
+
+ if (callbacks != null) {
+ if (wasPlaying) {
+ callbacks.onSeekTo(audioSlide.getUri(), getProgress());
+ }
+ }
+ }
+ }
+
+ private static class TouchIgnoringListener implements OnTouchListener {
+ @Override
+ public boolean onTouch(View v, MotionEvent event) {
+ return true;
+ }
+ }
+
+ @Subscribe(sticky = true, threadMode = ThreadMode.MAIN)
+ public void onEventAsync(final PartProgressEvent event) {
+ if (audioSlide != null && event.attachment.equals(audioSlide.asAttachment())) {
+ circleProgress.setInstantProgress(((float) event.progress) / event.total);
+ }
+ }
+
+ public interface Callbacks {
+ void onPlay(@NonNull Uri audioUri, double progress);
+ void onPause(@NonNull Uri audioUri);
+ void onSeekTo(@NonNull Uri audioUri, double progress);
+ void onStopAndReset(@NonNull Uri audioUri);
+ void onProgressUpdated(long durationMillis, long playheadMillis);
+ }
+}
diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/AvatarImageView.java b/app/src/main/java/org/thoughtcrime/securesms/components/AvatarImageView.java
new file mode 100644
index 00000000..39a0e36d
--- /dev/null
+++ b/app/src/main/java/org/thoughtcrime/securesms/components/AvatarImageView.java
@@ -0,0 +1,254 @@
+package org.thoughtcrime.securesms.components;
+
+import android.content.Context;
+import android.content.res.TypedArray;
+import android.graphics.Canvas;
+import android.graphics.Color;
+import android.graphics.Paint;
+import android.graphics.drawable.Drawable;
+import android.util.AttributeSet;
+
+import androidx.annotation.NonNull;
+import androidx.annotation.Nullable;
+import androidx.appcompat.widget.AppCompatImageView;
+import androidx.fragment.app.FragmentActivity;
+
+import com.bumptech.glide.load.engine.DiskCacheStrategy;
+
+import org.thoughtcrime.securesms.R;
+import org.thoughtcrime.securesms.color.MaterialColor;
+import org.thoughtcrime.securesms.contacts.avatars.ContactColors;
+import org.thoughtcrime.securesms.contacts.avatars.ContactPhoto;
+import org.thoughtcrime.securesms.contacts.avatars.ProfileContactPhoto;
+import org.thoughtcrime.securesms.contacts.avatars.ResourceContactPhoto;
+import org.thoughtcrime.securesms.groups.ui.managegroup.ManageGroupActivity;
+import org.thoughtcrime.securesms.mms.GlideApp;
+import org.thoughtcrime.securesms.mms.GlideRequests;
+import org.thoughtcrime.securesms.recipients.Recipient;
+import org.thoughtcrime.securesms.recipients.ui.bottomsheet.RecipientBottomSheetDialogFragment;
+import org.thoughtcrime.securesms.recipients.ui.managerecipient.ManageRecipientActivity;
+import org.thoughtcrime.securesms.util.AvatarUtil;
+import org.thoughtcrime.securesms.util.ThemeUtil;
+import org.thoughtcrime.securesms.util.Util;
+
+import java.util.Objects;
+
+public final class AvatarImageView extends AppCompatImageView {
+
+ private static final int SIZE_LARGE = 1;
+ private static final int SIZE_SMALL = 2;
+
+ @SuppressWarnings("unused")
+ private static final String TAG = AvatarImageView.class.getSimpleName();
+
+ private static final Paint LIGHT_THEME_OUTLINE_PAINT = new Paint();
+ private static final Paint DARK_THEME_OUTLINE_PAINT = new Paint();
+
+ static {
+ LIGHT_THEME_OUTLINE_PAINT.setColor(Color.argb((int) (255 * 0.2), 0, 0, 0));
+ LIGHT_THEME_OUTLINE_PAINT.setStyle(Paint.Style.STROKE);
+ LIGHT_THEME_OUTLINE_PAINT.setStrokeWidth(1);
+ LIGHT_THEME_OUTLINE_PAINT.setAntiAlias(true);
+
+ DARK_THEME_OUTLINE_PAINT.setColor(Color.argb((int) (255 * 0.2), 255, 255, 255));
+ DARK_THEME_OUTLINE_PAINT.setStyle(Paint.Style.STROKE);
+ DARK_THEME_OUTLINE_PAINT.setStrokeWidth(1);
+ DARK_THEME_OUTLINE_PAINT.setAntiAlias(true);
+ }
+
+ private int size;
+ private boolean inverted;
+ private Paint outlinePaint;
+ private OnClickListener listener;
+ private Recipient.FallbackPhotoProvider fallbackPhotoProvider;
+
+ private @Nullable RecipientContactPhoto recipientContactPhoto;
+ private @NonNull Drawable unknownRecipientDrawable;
+
+ public AvatarImageView(Context context) {
+ super(context);
+ initialize(context, null);
+ }
+
+ public AvatarImageView(Context context, AttributeSet attrs) {
+ super(context, attrs);
+ initialize(context, attrs);
+ }
+
+ private void initialize(@NonNull Context context, @Nullable AttributeSet attrs) {
+ setScaleType(ScaleType.CENTER_CROP);
+
+ if (attrs != null) {
+ TypedArray typedArray = context.getTheme().obtainStyledAttributes(attrs, R.styleable.AvatarImageView, 0, 0);
+ inverted = typedArray.getBoolean(R.styleable.AvatarImageView_inverted, false);
+ size = typedArray.getInt(R.styleable.AvatarImageView_fallbackImageSize, SIZE_LARGE);
+ typedArray.recycle();
+ }
+
+ outlinePaint = ThemeUtil.isDarkTheme(getContext()) ? DARK_THEME_OUTLINE_PAINT : LIGHT_THEME_OUTLINE_PAINT;
+
+ unknownRecipientDrawable = new ResourceContactPhoto(R.drawable.ic_profile_outline_40, R.drawable.ic_profile_outline_20).asDrawable(getContext(), ContactColors.UNKNOWN_COLOR.toConversationColor(getContext()), inverted);
+ }
+
+ @Override
+ protected void onDraw(Canvas canvas) {
+ super.onDraw(canvas);
+
+ float width = getWidth() - getPaddingRight() - getPaddingLeft();
+ float height = getHeight() - getPaddingBottom() - getPaddingTop();
+ float cx = width / 2f;
+ float cy = height / 2f;
+ float radius = Math.min(cx, cy) - (outlinePaint.getStrokeWidth() / 2f);
+
+ canvas.translate(getPaddingLeft(), getPaddingTop());
+ canvas.drawCircle(cx, cy, radius, outlinePaint);
+ }
+
+ @Override
+ public void setOnClickListener(OnClickListener listener) {
+ this.listener = listener;
+ super.setOnClickListener(listener);
+ }
+
+ public void setFallbackPhotoProvider(Recipient.FallbackPhotoProvider fallbackPhotoProvider) {
+ this.fallbackPhotoProvider = fallbackPhotoProvider;
+ }
+
+ /**
+ * Shows self as the actual profile picture.
+ */
+ public void setRecipient(@NonNull Recipient recipient) {
+ if (recipient.isSelf()) {
+ setAvatar(GlideApp.with(this), null, false);
+ AvatarUtil.loadIconIntoImageView(recipient, this);
+ } else {
+ setAvatar(GlideApp.with(this), recipient, false);
+ }
+ }
+
+ /**
+ * Shows self as the note to self icon.
+ */
+ public void setAvatar(@Nullable Recipient recipient) {
+ setAvatar(GlideApp.with(this), recipient, false);
+ }
+
+ /**
+ * Shows self as the profile avatar.
+ */
+ public void setAvatarUsingProfile(@Nullable Recipient recipient) {
+ setAvatar(GlideApp.with(this), recipient, false, true);
+ }
+
+ public void setAvatar(@NonNull GlideRequests requestManager, @Nullable Recipient recipient, boolean quickContactEnabled) {
+ setAvatar(requestManager, recipient, quickContactEnabled, false);
+ }
+
+ public void setAvatar(@NonNull GlideRequests requestManager, @Nullable Recipient recipient, boolean quickContactEnabled, boolean useSelfProfileAvatar) {
+ if (recipient != null) {
+ RecipientContactPhoto photo = (recipient.isSelf() && useSelfProfileAvatar) ? new RecipientContactPhoto(recipient,
+ new ProfileContactPhoto(Recipient.self(),
+ Recipient.self().getProfileAvatar()))
+ : new RecipientContactPhoto(recipient);
+
+ if (!photo.equals(recipientContactPhoto)) {
+ requestManager.clear(this);
+ recipientContactPhoto = photo;
+
+ Drawable fallbackContactPhotoDrawable = size == SIZE_SMALL
+ ? photo.recipient.getSmallFallbackContactPhotoDrawable(getContext(), inverted, fallbackPhotoProvider)
+ : photo.recipient.getFallbackContactPhotoDrawable(getContext(), inverted, fallbackPhotoProvider);
+
+ if (photo.contactPhoto != null) {
+ requestManager.load(photo.contactPhoto)
+ .fallback(fallbackContactPhotoDrawable)
+ .error(fallbackContactPhotoDrawable)
+ .diskCacheStrategy(DiskCacheStrategy.ALL)
+ .circleCrop()
+ .into(this);
+ } else {
+ setImageDrawable(fallbackContactPhotoDrawable);
+ }
+ }
+
+ setAvatarClickHandler(recipient, quickContactEnabled);
+ } else {
+ recipientContactPhoto = null;
+ requestManager.clear(this);
+ if (fallbackPhotoProvider != null) {
+ setImageDrawable(fallbackPhotoProvider.getPhotoForRecipientWithoutName()
+ .asDrawable(getContext(), MaterialColor.STEEL.toAvatarColor(getContext()), inverted));
+ } else {
+ setImageDrawable(unknownRecipientDrawable);
+ }
+
+ super.setOnClickListener(listener);
+ }
+ }
+
+ private void setAvatarClickHandler(@NonNull final Recipient recipient, boolean quickContactEnabled) {
+ if (quickContactEnabled) {
+ super.setOnClickListener(v -> {
+ Context context = getContext();
+ if (recipient.isPushGroup()) {
+ context.startActivity(ManageGroupActivity.newIntent(context, recipient.requireGroupId().requirePush()),
+ ManageGroupActivity.createTransitionBundle(context, this));
+ } else {
+ if (context instanceof FragmentActivity) {
+ RecipientBottomSheetDialogFragment.create(recipient.getId(), null)
+ .show(((FragmentActivity) context).getSupportFragmentManager(), "BOTTOM");
+ } else {
+ context.startActivity(ManageRecipientActivity.newIntent(context, recipient.getId()),
+ ManageRecipientActivity.createTransitionBundle(context, this));
+ }
+ }
+ });
+ } else {
+ super.setOnClickListener(listener);
+ setClickable(listener != null);
+ }
+ }
+
+ public void setImageBytesForGroup(@Nullable byte[] avatarBytes,
+ @Nullable Recipient.FallbackPhotoProvider fallbackPhotoProvider,
+ @NonNull MaterialColor color)
+ {
+ Drawable fallback = Util.firstNonNull(fallbackPhotoProvider, Recipient.DEFAULT_FALLBACK_PHOTO_PROVIDER)
+ .getPhotoForGroup()
+ .asDrawable(getContext(), color.toAvatarColor(getContext()));
+
+ GlideApp.with(this)
+ .load(avatarBytes)
+ .fallback(fallback)
+ .error(fallback)
+ .diskCacheStrategy(DiskCacheStrategy.ALL)
+ .circleCrop()
+ .into(this);
+ }
+
+ private static class RecipientContactPhoto {
+
+ private final @NonNull Recipient recipient;
+ private final @Nullable ContactPhoto contactPhoto;
+ private final boolean ready;
+
+ RecipientContactPhoto(@NonNull Recipient recipient) {
+ this(recipient, recipient.getContactPhoto());
+ }
+
+ RecipientContactPhoto(@NonNull Recipient recipient, @Nullable ContactPhoto contactPhoto) {
+ this.recipient = recipient;
+ this.ready = !recipient.isResolving();
+ this.contactPhoto = contactPhoto;
+ }
+
+ public boolean equals(@Nullable RecipientContactPhoto other) {
+ if (other == null) return false;
+
+ return other.recipient.equals(recipient) &&
+ other.recipient.getColor().equals(recipient.getColor()) &&
+ other.ready == ready &&
+ Objects.equals(other.contactPhoto, contactPhoto);
+ }
+ }
+}
diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/BorderlessImageView.java b/app/src/main/java/org/thoughtcrime/securesms/components/BorderlessImageView.java
new file mode 100644
index 00000000..59f46bc3
--- /dev/null
+++ b/app/src/main/java/org/thoughtcrime/securesms/components/BorderlessImageView.java
@@ -0,0 +1,78 @@
+package org.thoughtcrime.securesms.components;
+
+import android.content.Context;
+import android.util.AttributeSet;
+import android.view.View;
+import android.widget.FrameLayout;
+
+import androidx.annotation.NonNull;
+import androidx.annotation.Nullable;
+
+import com.bumptech.glide.load.resource.bitmap.CenterCrop;
+import com.bumptech.glide.load.resource.bitmap.CenterInside;
+
+import org.thoughtcrime.securesms.R;
+import org.thoughtcrime.securesms.mms.GlideRequests;
+import org.thoughtcrime.securesms.mms.Slide;
+import org.thoughtcrime.securesms.mms.SlideClickListener;
+import org.thoughtcrime.securesms.mms.SlidesClickedListener;
+
+public class BorderlessImageView extends FrameLayout {
+
+ private ThumbnailView image;
+ private View missingShade;
+
+ public BorderlessImageView(@NonNull Context context) {
+ super(context);
+ init();
+ }
+
+ public BorderlessImageView(@NonNull Context context, @Nullable AttributeSet attrs) {
+ super(context, attrs);
+ init();
+ }
+
+ private void init() {
+ inflate(getContext(), R.layout.sticker_view, this);
+
+ this.image = findViewById(R.id.sticker_thumbnail);
+ this.missingShade = findViewById(R.id.sticker_missing_shade);
+ }
+
+ @Override
+ public void setFocusable(boolean focusable) {
+ image.setFocusable(focusable);
+ }
+
+ @Override
+ public void setClickable(boolean clickable) {
+ image.setClickable(clickable);
+ }
+
+ @Override
+ public void setOnLongClickListener(@Nullable OnLongClickListener l) {
+ image.setOnLongClickListener(l);
+ }
+
+ public void setSlide(@NonNull GlideRequests glideRequests, @NonNull Slide slide) {
+ boolean showControls = slide.asAttachment().getUri() == null;
+
+ if (slide.hasSticker()) {
+ image.setFit(new CenterInside());
+ image.setImageResource(glideRequests, slide, showControls, false);
+ } else {
+ image.setFit(new CenterCrop());
+ image.setImageResource(glideRequests, slide, showControls, false, slide.asAttachment().getWidth(), slide.asAttachment().getHeight());
+ }
+
+ missingShade.setVisibility(showControls ? View.VISIBLE : View.GONE);
+ }
+
+ public void setThumbnailClickListener(@NonNull SlideClickListener listener) {
+ image.setThumbnailClickListener(listener);
+ }
+
+ public void setDownloadClickListener(@NonNull SlidesClickedListener listener) {
+ image.setDownloadClickListener(listener);
+ }
+}
diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/BubbleDrawableBuilder.java b/app/src/main/java/org/thoughtcrime/securesms/components/BubbleDrawableBuilder.java
new file mode 100644
index 00000000..7284a426
--- /dev/null
+++ b/app/src/main/java/org/thoughtcrime/securesms/components/BubbleDrawableBuilder.java
@@ -0,0 +1,76 @@
+package org.thoughtcrime.securesms.components;
+
+import android.content.Context;
+import android.graphics.drawable.Drawable;
+import android.graphics.drawable.GradientDrawable;
+import android.graphics.drawable.LayerDrawable;
+
+import org.thoughtcrime.securesms.R;
+
+public class BubbleDrawableBuilder {
+ private int color;
+ private int shadowColor;
+ private boolean hasShadow = true;
+ private boolean[] corners = new boolean[]{true,true,true,true};
+
+ protected BubbleDrawableBuilder() { }
+
+ public BubbleDrawableBuilder setColor(int color) {
+ this.color = color;
+ return this;
+ }
+
+ public BubbleDrawableBuilder setShadowColor(int shadowColor) {
+ this.shadowColor = shadowColor;
+ return this;
+ }
+
+ public BubbleDrawableBuilder setHasShadow(boolean hasShadow) {
+ this.hasShadow = hasShadow;
+ return this;
+ }
+
+ public BubbleDrawableBuilder setCorners(boolean[] corners) {
+ this.corners = corners;
+ return this;
+ }
+
+ public Drawable create(Context context) {
+ final GradientDrawable bubble = new GradientDrawable();
+ final int radius = context.getResources().getDimensionPixelSize(R.dimen.message_bubble_corner_radius);
+ final float[] radii = cornerBooleansToRadii(corners, radius);
+
+ bubble.setColor(color);
+ bubble.setCornerRadii(radii);
+
+ if (!hasShadow) {
+ return bubble;
+ } else {
+ final GradientDrawable shadow = new GradientDrawable();
+ final int distance = context.getResources().getDimensionPixelSize(R.dimen.message_bubble_shadow_distance);
+
+ shadow.setColor(shadowColor);
+ shadow.setCornerRadii(radii);
+
+ final LayerDrawable layers = new LayerDrawable(new Drawable[]{shadow, bubble});
+ layers.setLayerInset(1, 0, 0, 0, distance);
+ return layers;
+ }
+ }
+
+ private float[] cornerBooleansToRadii(boolean[] corners, int radius) {
+ if (corners == null || corners.length != 4) {
+ throw new AssertionError("there are four corners in a rectangle, silly");
+ }
+
+ float[] radii = new float[8];
+ int i = 0;
+ for (boolean corner : corners) {
+ radii[i] = radii[i+1] = corner ? radius : 0;
+ i += 2;
+ }
+
+ return radii;
+ }
+
+}
diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/CircleColorImageView.java b/app/src/main/java/org/thoughtcrime/securesms/components/CircleColorImageView.java
new file mode 100644
index 00000000..20bd089a
--- /dev/null
+++ b/app/src/main/java/org/thoughtcrime/securesms/components/CircleColorImageView.java
@@ -0,0 +1,40 @@
+package org.thoughtcrime.securesms.components;
+
+import android.content.Context;
+import android.content.res.TypedArray;
+import android.graphics.Color;
+import android.graphics.PorterDuff;
+import android.graphics.drawable.Drawable;
+import android.util.AttributeSet;
+
+import androidx.appcompat.widget.AppCompatImageView;
+
+import org.thoughtcrime.securesms.R;
+
+public class CircleColorImageView extends AppCompatImageView {
+
+ public CircleColorImageView(Context context) {
+ this(context, null);
+ }
+
+ public CircleColorImageView(Context context, AttributeSet attrs) {
+ this(context, attrs, 0);
+ }
+
+ public CircleColorImageView(Context context, AttributeSet attrs, int defStyleAttr) {
+ super(context, attrs, defStyleAttr);
+
+ int circleColor = Color.WHITE;
+
+ if (attrs != null) {
+ TypedArray typedArray = context.getTheme().obtainStyledAttributes(attrs, R.styleable.CircleColorImageView, 0, 0);
+ circleColor = typedArray.getColor(R.styleable.CircleColorImageView_circleColor, Color.WHITE);
+ typedArray.recycle();
+ }
+
+ Drawable circle = context.getResources().getDrawable(R.drawable.circle_tintable);
+ circle.setColorFilter(circleColor, PorterDuff.Mode.SRC_IN);
+
+ setBackgroundDrawable(circle);
+ }
+}
diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/ComposeText.java b/app/src/main/java/org/thoughtcrime/securesms/components/ComposeText.java
new file mode 100644
index 00000000..70a0220e
--- /dev/null
+++ b/app/src/main/java/org/thoughtcrime/securesms/components/ComposeText.java
@@ -0,0 +1,405 @@
+package org.thoughtcrime.securesms.components;
+
+import android.content.Context;
+import android.content.res.Configuration;
+import android.graphics.Canvas;
+import android.os.Build;
+import android.os.Bundle;
+import android.text.Annotation;
+import android.text.Editable;
+import android.text.InputType;
+import android.text.Spannable;
+import android.text.SpannableString;
+import android.text.SpannableStringBuilder;
+import android.text.Spanned;
+import android.text.TextUtils;
+import android.text.TextUtils.TruncateAt;
+import android.text.style.RelativeSizeSpan;
+import android.util.AttributeSet;
+import android.view.inputmethod.EditorInfo;
+import android.view.inputmethod.InputConnection;
+
+import androidx.annotation.NonNull;
+import androidx.annotation.Nullable;
+import androidx.core.content.ContextCompat;
+import androidx.core.view.inputmethod.EditorInfoCompat;
+import androidx.core.view.inputmethod.InputConnectionCompat;
+import androidx.core.view.inputmethod.InputContentInfoCompat;
+
+import org.signal.core.util.logging.Log;
+import org.thoughtcrime.securesms.R;
+import org.thoughtcrime.securesms.TransportOption;
+import org.thoughtcrime.securesms.components.emoji.EmojiEditText;
+import org.thoughtcrime.securesms.components.mention.MentionAnnotation;
+import org.thoughtcrime.securesms.components.mention.MentionDeleter;
+import org.thoughtcrime.securesms.components.mention.MentionRendererDelegate;
+import org.thoughtcrime.securesms.components.mention.MentionValidatorWatcher;
+import org.thoughtcrime.securesms.database.model.Mention;
+import org.thoughtcrime.securesms.recipients.RecipientId;
+import org.thoughtcrime.securesms.util.StringUtil;
+import org.thoughtcrime.securesms.util.TextSecurePreferences;
+
+import java.util.List;
+
+import static org.thoughtcrime.securesms.database.MentionUtil.MENTION_STARTER;
+
+public class ComposeText extends EmojiEditText {
+
+ private CharSequence hint;
+ private SpannableString subHint;
+ private MentionRendererDelegate mentionRendererDelegate;
+ private MentionValidatorWatcher mentionValidatorWatcher;
+
+ @Nullable private InputPanel.MediaListener mediaListener;
+ @Nullable private CursorPositionChangedListener cursorPositionChangedListener;
+ @Nullable private MentionQueryChangedListener mentionQueryChangedListener;
+
+ public ComposeText(Context context) {
+ super(context);
+ initialize();
+ }
+
+ public ComposeText(Context context, AttributeSet attrs) {
+ super(context, attrs);
+ initialize();
+ }
+
+ public ComposeText(Context context, AttributeSet attrs, int defStyleAttr) {
+ super(context, attrs, defStyleAttr);
+ initialize();
+ }
+
+ /**
+ * Trims and returns text while preserving potential spans like {@link MentionAnnotation}.
+ */
+ public @NonNull CharSequence getTextTrimmed() {
+ Editable text = getText();
+ if (text == null) {
+ return "";
+ }
+ return StringUtil.trimSequence(text);
+ }
+
+ @Override
+ protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) {
+ super.onMeasure(widthMeasureSpec, heightMeasureSpec);
+
+ if (!TextUtils.isEmpty(hint)) {
+ if (!TextUtils.isEmpty(subHint)) {
+ setHint(new SpannableStringBuilder().append(ellipsizeToWidth(hint))
+ .append("\n")
+ .append(ellipsizeToWidth(subHint)));
+ } else {
+ setHint(ellipsizeToWidth(hint));
+ }
+ super.onMeasure(widthMeasureSpec, heightMeasureSpec);
+ }
+ }
+
+ @Override
+ protected void onSelectionChanged(int selectionStart, int selectionEnd) {
+ super.onSelectionChanged(selectionStart, selectionEnd);
+
+ if (getText() != null) {
+ boolean selectionChanged = changeSelectionForPartialMentions(getText(), selectionStart, selectionEnd);
+ if (selectionChanged) {
+ return;
+ }
+
+ if (selectionStart == selectionEnd) {
+ doAfterCursorChange(getText());
+ } else {
+ updateQuery(null);
+ }
+ }
+
+ if (cursorPositionChangedListener != null) {
+ cursorPositionChangedListener.onCursorPositionChanged(selectionStart, selectionEnd);
+ }
+ }
+
+ @Override
+ protected void onDraw(Canvas canvas) {
+ if (getText() != null && getLayout() != null) {
+ int checkpoint = canvas.save();
+
+ // Clip using same logic as TextView drawing
+ int maxScrollY = getLayout().getHeight() - getBottom() - getTop() - getCompoundPaddingBottom() - getCompoundPaddingTop();
+ float clipLeft = getCompoundPaddingLeft() + getScrollX();
+ float clipTop = (getScrollY() == 0) ? 0 : getExtendedPaddingTop() + getScrollY();
+ float clipRight = getRight() - getLeft() - getCompoundPaddingRight() + getScrollX();
+ float clipBottom = getBottom() - getTop() + getScrollY() - ((getScrollY() == maxScrollY) ? 0 : getExtendedPaddingBottom());
+
+ canvas.clipRect(clipLeft - 10, clipTop, clipRight + 10, clipBottom);
+ canvas.translate(getTotalPaddingLeft(), getTotalPaddingTop());
+
+ try {
+ mentionRendererDelegate.draw(canvas, getText(), getLayout());
+ } finally {
+ canvas.restoreToCount(checkpoint);
+ }
+ }
+ super.onDraw(canvas);
+ }
+
+ private CharSequence ellipsizeToWidth(CharSequence text) {
+ return TextUtils.ellipsize(text,
+ getPaint(),
+ getWidth() - getPaddingLeft() - getPaddingRight(),
+ TruncateAt.END);
+ }
+
+ public void setHint(@NonNull String hint, @Nullable CharSequence subHint) {
+ this.hint = hint;
+
+ if (subHint != null) {
+ this.subHint = new SpannableString(subHint);
+ this.subHint.setSpan(new RelativeSizeSpan(0.5f), 0, subHint.length(), Spannable.SPAN_INCLUSIVE_INCLUSIVE);
+ } else {
+ this.subHint = null;
+ }
+
+ if (this.subHint != null) {
+ super.setHint(new SpannableStringBuilder().append(ellipsizeToWidth(this.hint))
+ .append("\n")
+ .append(ellipsizeToWidth(this.subHint)));
+ } else {
+ super.setHint(ellipsizeToWidth(this.hint));
+ }
+
+ super.setHint(hint);
+ }
+
+ public void appendInvite(String invite) {
+ if (getText() == null) {
+ return;
+ }
+
+ if (!TextUtils.isEmpty(getText()) && !getText().toString().equals(" ")) {
+ append(" ");
+ }
+
+ append(invite);
+ setSelection(getText().length());
+ }
+
+ public void setCursorPositionChangedListener(@Nullable CursorPositionChangedListener listener) {
+ this.cursorPositionChangedListener = listener;
+ }
+
+ public void setMentionQueryChangedListener(@Nullable MentionQueryChangedListener listener) {
+ this.mentionQueryChangedListener = listener;
+ }
+
+ public void setMentionValidator(@Nullable MentionValidatorWatcher.MentionValidator mentionValidator) {
+ mentionValidatorWatcher.setMentionValidator(mentionValidator);
+ }
+
+ private boolean isLandscape() {
+ return getResources().getConfiguration().orientation == Configuration.ORIENTATION_LANDSCAPE;
+ }
+
+ public void setTransport(TransportOption transport) {
+ final boolean useSystemEmoji = TextSecurePreferences.isSystemEmojiPreferred(getContext());
+
+ int imeOptions = (getImeOptions() & ~EditorInfo.IME_MASK_ACTION) | EditorInfo.IME_ACTION_SEND;
+ int inputType = getInputType();
+
+ if (isLandscape()) setImeActionLabel(transport.getComposeHint(), EditorInfo.IME_ACTION_SEND);
+ else setImeActionLabel(null, 0);
+
+ if (useSystemEmoji) {
+ inputType = (inputType & ~InputType.TYPE_MASK_VARIATION) | InputType.TYPE_TEXT_VARIATION_SHORT_MESSAGE;
+ }
+
+ setImeOptions(imeOptions);
+ setHint(transport.getComposeHint(),
+ transport.getSimName().isPresent()
+ ? getContext().getString(R.string.conversation_activity__from_sim_name, transport.getSimName().get())
+ : null);
+ setInputType(inputType);
+ }
+
+ @Override
+ public InputConnection onCreateInputConnection(EditorInfo editorInfo) {
+ InputConnection inputConnection = super.onCreateInputConnection(editorInfo);
+
+ if(TextSecurePreferences.isEnterSendsEnabled(getContext())) {
+ editorInfo.imeOptions &= ~EditorInfo.IME_FLAG_NO_ENTER_ACTION;
+ }
+
+ if (Build.VERSION.SDK_INT < 21) return inputConnection;
+ if (mediaListener == null) return inputConnection;
+ if (inputConnection == null) return null;
+
+ EditorInfoCompat.setContentMimeTypes(editorInfo, new String[] {"image/jpeg", "image/png", "image/gif"});
+ return InputConnectionCompat.createWrapper(inputConnection, editorInfo, new CommitContentListener(mediaListener));
+ }
+
+ public void setMediaListener(@Nullable InputPanel.MediaListener mediaListener) {
+ this.mediaListener = mediaListener;
+ }
+
+ public boolean hasMentions() {
+ Editable text = getText();
+ if (text != null) {
+ return !MentionAnnotation.getMentionAnnotations(text).isEmpty();
+ }
+ return false;
+ }
+
+ public @NonNull List getMentions() {
+ return MentionAnnotation.getMentionsFromAnnotations(getText());
+ }
+
+ private void initialize() {
+ if (TextSecurePreferences.isIncognitoKeyboardEnabled(getContext())) {
+ setImeOptions(getImeOptions() | 16777216);
+ }
+
+ mentionRendererDelegate = new MentionRendererDelegate(getContext(), ContextCompat.getColor(getContext(), R.color.conversation_mention_background_color));
+
+ addTextChangedListener(new MentionDeleter());
+ mentionValidatorWatcher = new MentionValidatorWatcher();
+ addTextChangedListener(mentionValidatorWatcher);
+ }
+
+ private boolean changeSelectionForPartialMentions(@NonNull Spanned spanned, int selectionStart, int selectionEnd) {
+ Annotation[] annotations = spanned.getSpans(0, spanned.length(), Annotation.class);
+ for (Annotation annotation : annotations) {
+ if (MentionAnnotation.isMentionAnnotation(annotation)) {
+ int spanStart = spanned.getSpanStart(annotation);
+ int spanEnd = spanned.getSpanEnd(annotation);
+
+ boolean startInMention = selectionStart > spanStart && selectionStart < spanEnd;
+ boolean endInMention = selectionEnd > spanStart && selectionEnd < spanEnd;
+
+ if (startInMention || endInMention) {
+ if (selectionStart == selectionEnd) {
+ setSelection(spanEnd, spanEnd);
+ } else {
+ int newStart = startInMention ? spanStart : selectionStart;
+ int newEnd = endInMention ? spanEnd : selectionEnd;
+ setSelection(newStart, newEnd);
+ }
+ return true;
+ }
+ }
+ }
+ return false;
+ }
+
+ private void doAfterCursorChange(@NonNull Editable text) {
+ if (enoughToFilter(text)) {
+ performFiltering(text);
+ } else {
+ updateQuery(null);
+ }
+ }
+
+ private void performFiltering(@NonNull Editable text) {
+ int end = getSelectionEnd();
+ int start = findQueryStart(text, end);
+ CharSequence query = text.subSequence(start, end);
+ updateQuery(query.toString());
+ }
+
+ private void updateQuery(@Nullable String query) {
+ if (mentionQueryChangedListener != null) {
+ mentionQueryChangedListener.onQueryChanged(query);
+ }
+ }
+
+ private boolean enoughToFilter(@NonNull Editable text) {
+ int end = getSelectionEnd();
+ if (end < 0) {
+ return false;
+ }
+ return findQueryStart(text, end) != -1;
+ }
+
+ public void replaceTextWithMention(@NonNull String displayName, @NonNull RecipientId recipientId) {
+ Editable text = getText();
+ if (text == null) {
+ return;
+ }
+
+ clearComposingText();
+
+ int end = getSelectionEnd();
+ int start = findQueryStart(text, end) - 1;
+
+ text.replace(start, end, createReplacementToken(displayName, recipientId));
+ }
+
+ private @NonNull CharSequence createReplacementToken(@NonNull CharSequence text, @NonNull RecipientId recipientId) {
+ SpannableStringBuilder builder = new SpannableStringBuilder().append(MENTION_STARTER);
+ if (text instanceof Spanned) {
+ SpannableString spannableString = new SpannableString(text + " ");
+ TextUtils.copySpansFrom((Spanned) text, 0, text.length(), Object.class, spannableString, 0);
+ builder.append(spannableString);
+ } else {
+ builder.append(text).append(" ");
+ }
+
+ builder.setSpan(MentionAnnotation.mentionAnnotationForRecipientId(recipientId), 0, builder.length() - 1, Spanned.SPAN_EXCLUSIVE_EXCLUSIVE);
+
+ return builder;
+ }
+
+ private int findQueryStart(@NonNull CharSequence text, int inputCursorPosition) {
+ if (inputCursorPosition == 0) {
+ return -1;
+ }
+
+ int delimiterSearchIndex = inputCursorPosition - 1;
+ while (delimiterSearchIndex >= 0 && (text.charAt(delimiterSearchIndex) != MENTION_STARTER && text.charAt(delimiterSearchIndex) != ' ')) {
+ delimiterSearchIndex--;
+ }
+
+ if (delimiterSearchIndex >= 0 && text.charAt(delimiterSearchIndex) == MENTION_STARTER) {
+ return delimiterSearchIndex + 1;
+ }
+ return -1;
+ }
+
+ private static class CommitContentListener implements InputConnectionCompat.OnCommitContentListener {
+
+ private static final String TAG = CommitContentListener.class.getSimpleName();
+
+ private final InputPanel.MediaListener mediaListener;
+
+ private CommitContentListener(@NonNull InputPanel.MediaListener mediaListener) {
+ this.mediaListener = mediaListener;
+ }
+
+ @Override
+ public boolean onCommitContent(InputContentInfoCompat inputContentInfo, int flags, Bundle opts) {
+ if (Build.VERSION.SDK_INT >= 25 && (flags & InputConnectionCompat.INPUT_CONTENT_GRANT_READ_URI_PERMISSION) != 0) {
+ try {
+ inputContentInfo.requestPermission();
+ } catch (Exception e) {
+ Log.w(TAG, e);
+ return false;
+ }
+ }
+
+ if (inputContentInfo.getDescription().getMimeTypeCount() > 0) {
+ mediaListener.onMediaSelected(inputContentInfo.getContentUri(),
+ inputContentInfo.getDescription().getMimeType(0));
+
+ return true;
+ }
+
+ return false;
+ }
+ }
+
+ public interface CursorPositionChangedListener {
+ void onCursorPositionChanged(int start, int end);
+ }
+
+ public interface MentionQueryChangedListener {
+ void onQueryChanged(@Nullable String query);
+ }
+}
diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/ContactFilterToolbar.java b/app/src/main/java/org/thoughtcrime/securesms/components/ContactFilterToolbar.java
new file mode 100644
index 00000000..d68058ae
--- /dev/null
+++ b/app/src/main/java/org/thoughtcrime/securesms/components/ContactFilterToolbar.java
@@ -0,0 +1,191 @@
+package org.thoughtcrime.securesms.components;
+
+import android.content.Context;
+import android.content.res.TypedArray;
+import android.graphics.Rect;
+import android.text.Editable;
+import android.text.InputType;
+import android.text.TextWatcher;
+import android.util.AttributeSet;
+import android.view.TouchDelegate;
+import android.view.View;
+import android.widget.EditText;
+import android.widget.ImageView;
+import android.widget.LinearLayout;
+
+import androidx.annotation.NonNull;
+import androidx.annotation.StringRes;
+import androidx.core.widget.TextViewCompat;
+
+import org.thoughtcrime.securesms.R;
+import org.thoughtcrime.securesms.util.ServiceUtil;
+import org.thoughtcrime.securesms.util.ViewUtil;
+import org.thoughtcrime.securesms.util.views.DarkOverflowToolbar;
+
+public final class ContactFilterToolbar extends DarkOverflowToolbar {
+ private OnFilterChangedListener listener;
+
+ private final EditText searchText;
+ private final AnimatingToggle toggle;
+ private final ImageView keyboardToggle;
+ private final ImageView dialpadToggle;
+ private final ImageView clearToggle;
+ private final LinearLayout toggleContainer;
+
+ public ContactFilterToolbar(Context context) {
+ this(context, null);
+ }
+
+ public ContactFilterToolbar(Context context, AttributeSet attrs) {
+ this(context, attrs, R.attr.toolbarStyle);
+ }
+
+ public ContactFilterToolbar(Context context, AttributeSet attrs, int defStyleAttr) {
+ super(context, attrs, defStyleAttr);
+ inflate(context, R.layout.contact_filter_toolbar, this);
+
+ this.searchText = findViewById(R.id.search_view);
+ this.toggle = findViewById(R.id.button_toggle);
+ this.keyboardToggle = findViewById(R.id.search_keyboard);
+ this.dialpadToggle = findViewById(R.id.search_dialpad);
+ this.clearToggle = findViewById(R.id.search_clear);
+ this.toggleContainer = findViewById(R.id.toggle_container);
+
+ this.keyboardToggle.setOnClickListener(new View.OnClickListener() {
+ @Override
+ public void onClick(View v) {
+ searchText.setInputType(InputType.TYPE_CLASS_TEXT | InputType.TYPE_TEXT_VARIATION_PERSON_NAME);
+ ServiceUtil.getInputMethodManager(getContext()).showSoftInput(searchText, 0);
+ displayTogglingView(dialpadToggle);
+ }
+ });
+
+ this.dialpadToggle.setOnClickListener(new View.OnClickListener() {
+ @Override
+ public void onClick(View v) {
+ searchText.setInputType(InputType.TYPE_CLASS_PHONE);
+ ServiceUtil.getInputMethodManager(getContext()).showSoftInput(searchText, 0);
+ displayTogglingView(keyboardToggle);
+ }
+ });
+
+ this.clearToggle.setOnClickListener(new View.OnClickListener() {
+ @Override
+ public void onClick(View v) {
+ searchText.setText("");
+
+ if (SearchUtil.isTextInput(searchText)) displayTogglingView(dialpadToggle);
+ else displayTogglingView(keyboardToggle);
+ }
+ });
+
+ this.searchText.addTextChangedListener(new TextWatcher() {
+ @Override
+ public void beforeTextChanged(CharSequence s, int start, int count, int after) {
+
+ }
+
+ @Override
+ public void onTextChanged(CharSequence s, int start, int before, int count) {
+
+ }
+
+ @Override
+ public void afterTextChanged(Editable s) {
+ if (!SearchUtil.isEmpty(searchText)) displayTogglingView(clearToggle);
+ else if (SearchUtil.isTextInput(searchText)) displayTogglingView(dialpadToggle);
+ else if (SearchUtil.isPhoneInput(searchText)) displayTogglingView(keyboardToggle);
+ notifyListener();
+ }
+ });
+
+ setLogo(null);
+ setContentInsetStartWithNavigation(0);
+ expandTapArea(toggleContainer, dialpadToggle);
+ applyAttributes(searchText, context, attrs, defStyleAttr);
+ searchText.requestFocus();
+ }
+
+ private void applyAttributes(@NonNull EditText searchText,
+ @NonNull Context context,
+ @NonNull AttributeSet attrs,
+ int defStyle)
+ {
+ final TypedArray attributes = context.obtainStyledAttributes(attrs,
+ R.styleable.ContactFilterToolbar,
+ defStyle,
+ 0);
+
+ int styleResource = attributes.getResourceId(R.styleable.ContactFilterToolbar_searchTextStyle, -1);
+ if (styleResource != -1) {
+ TextViewCompat.setTextAppearance(searchText, styleResource);
+ }
+ if (!attributes.getBoolean(R.styleable.ContactFilterToolbar_showDialpad, true)) {
+ dialpadToggle.setVisibility(GONE);
+ }
+ attributes.recycle();
+ }
+
+ public void focusAndShowKeyboard() {
+ ViewUtil.focusAndShowKeyboard(searchText);
+ }
+
+ public void clear() {
+ searchText.setText("");
+ notifyListener();
+ }
+
+ public void setOnFilterChangedListener(OnFilterChangedListener listener) {
+ this.listener = listener;
+ }
+
+ public void setHint(@StringRes int hint) {
+ searchText.setHint(hint);
+ }
+
+ private void notifyListener() {
+ if (listener != null) listener.onFilterChanged(searchText.getText().toString());
+ }
+
+ private void displayTogglingView(View view) {
+ toggle.display(view);
+ expandTapArea(toggleContainer, view);
+ }
+
+ private void expandTapArea(final View container, final View child) {
+ final int padding = getResources().getDimensionPixelSize(R.dimen.contact_selection_actions_tap_area);
+
+ container.post(new Runnable() {
+ @Override
+ public void run() {
+ Rect rect = new Rect();
+ child.getHitRect(rect);
+
+ rect.top -= padding;
+ rect.left -= padding;
+ rect.right += padding;
+ rect.bottom += padding;
+
+ container.setTouchDelegate(new TouchDelegate(rect, child));
+ }
+ });
+ }
+
+ private static class SearchUtil {
+ static boolean isTextInput(EditText editText) {
+ return (editText.getInputType() & InputType.TYPE_MASK_CLASS) == InputType.TYPE_CLASS_TEXT;
+ }
+
+ static boolean isPhoneInput(EditText editText) {
+ return (editText.getInputType() & InputType.TYPE_MASK_CLASS) == InputType.TYPE_CLASS_PHONE;
+ }
+
+ public static boolean isEmpty(EditText editText) {
+ return editText.getText().length() <= 0;
+ }
+ }
+
+ public interface OnFilterChangedListener {
+ void onFilterChanged(String filter);
+ }
+}
diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/ControllableTabLayout.java b/app/src/main/java/org/thoughtcrime/securesms/components/ControllableTabLayout.java
new file mode 100644
index 00000000..8c32a173
--- /dev/null
+++ b/app/src/main/java/org/thoughtcrime/securesms/components/ControllableTabLayout.java
@@ -0,0 +1,42 @@
+package org.thoughtcrime.securesms.components;
+
+import android.content.Context;
+import android.util.AttributeSet;
+import android.view.View;
+
+import com.google.android.material.tabs.TabLayout;
+
+import java.util.List;
+
+/**
+ * An implementation of {@link TabLayout} that disables taps when the view is disabled.
+ */
+public class ControllableTabLayout extends TabLayout {
+
+ private List touchables;
+
+ public ControllableTabLayout(Context context) {
+ super(context);
+ }
+
+ public ControllableTabLayout(Context context, AttributeSet attrs) {
+ super(context, attrs);
+ }
+
+ public ControllableTabLayout(Context context, AttributeSet attrs, int defStyleAttr) {
+ super(context, attrs, defStyleAttr);
+ }
+
+ @Override
+ public void setEnabled(boolean enabled) {
+ if (isEnabled() && !enabled) {
+ touchables = getTouchables();
+ }
+
+ for (View touchable : touchables) {
+ touchable.setClickable(enabled);
+ }
+
+ super.setEnabled(enabled);
+ }
+}
diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/ControllableViewPager.java b/app/src/main/java/org/thoughtcrime/securesms/components/ControllableViewPager.java
new file mode 100644
index 00000000..a8abacb1
--- /dev/null
+++ b/app/src/main/java/org/thoughtcrime/securesms/components/ControllableViewPager.java
@@ -0,0 +1,35 @@
+package org.thoughtcrime.securesms.components;
+
+import android.content.Context;
+import android.util.AttributeSet;
+import android.view.MotionEvent;
+
+import androidx.annotation.NonNull;
+import androidx.annotation.Nullable;
+import androidx.viewpager.widget.ViewPager;
+
+import org.thoughtcrime.securesms.components.viewpager.HackyViewPager;
+
+/**
+ * An implementation of {@link ViewPager} that disables swiping when the view is disabled.
+ */
+public class ControllableViewPager extends HackyViewPager {
+
+ public ControllableViewPager(@NonNull Context context) {
+ super(context);
+ }
+
+ public ControllableViewPager(@NonNull Context context, @Nullable AttributeSet attrs) {
+ super(context, attrs);
+ }
+
+ @Override
+ public boolean onTouchEvent(MotionEvent ev) {
+ return isEnabled() && super.onTouchEvent(ev);
+ }
+
+ @Override
+ public boolean onInterceptTouchEvent(MotionEvent ev) {
+ return isEnabled() && super.onInterceptTouchEvent(ev);
+ }
+}
diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/ConversationItemFooter.java b/app/src/main/java/org/thoughtcrime/securesms/components/ConversationItemFooter.java
new file mode 100644
index 00000000..d5256b4f
--- /dev/null
+++ b/app/src/main/java/org/thoughtcrime/securesms/components/ConversationItemFooter.java
@@ -0,0 +1,309 @@
+package org.thoughtcrime.securesms.components;
+
+import android.Manifest;
+import android.annotation.SuppressLint;
+import android.content.Context;
+import android.content.res.TypedArray;
+import android.graphics.PorterDuff;
+import android.graphics.PorterDuffColorFilter;
+import android.util.AttributeSet;
+import android.view.View;
+import android.widget.ImageView;
+import android.widget.LinearLayout;
+import android.widget.TextView;
+
+import androidx.annotation.DrawableRes;
+import androidx.annotation.NonNull;
+import androidx.annotation.Nullable;
+
+import com.airbnb.lottie.LottieAnimationView;
+import com.airbnb.lottie.LottieProperty;
+import com.airbnb.lottie.model.KeyPath;
+
+import org.signal.core.util.concurrent.SignalExecutors;
+import org.thoughtcrime.securesms.ApplicationContext;
+import org.thoughtcrime.securesms.R;
+import org.thoughtcrime.securesms.database.DatabaseFactory;
+import org.thoughtcrime.securesms.database.model.MessageRecord;
+import org.thoughtcrime.securesms.database.model.MmsMessageRecord;
+import org.thoughtcrime.securesms.permissions.Permissions;
+import org.thoughtcrime.securesms.service.ExpiringMessageManager;
+import org.thoughtcrime.securesms.util.DateUtils;
+import org.thoughtcrime.securesms.util.FeatureFlags;
+import org.thoughtcrime.securesms.util.ViewUtil;
+import org.thoughtcrime.securesms.util.dualsim.SubscriptionInfoCompat;
+import org.thoughtcrime.securesms.util.dualsim.SubscriptionManagerCompat;
+import org.whispersystems.libsignal.util.guava.Optional;
+
+import java.util.Locale;
+import java.util.concurrent.TimeUnit;
+
+public class ConversationItemFooter extends LinearLayout {
+
+ private TextView dateView;
+ private TextView simView;
+ private ExpirationTimerView timerView;
+ private ImageView insecureIndicatorView;
+ private DeliveryStatusView deliveryStatusView;
+ private boolean onlyShowSendingStatus;
+ private View audioSpace;
+ private TextView audioDuration;
+ private LottieAnimationView revealDot;
+
+ public ConversationItemFooter(Context context) {
+ super(context);
+ init(null);
+ }
+
+ public ConversationItemFooter(Context context, @Nullable AttributeSet attrs) {
+ super(context, attrs);
+ init(attrs);
+ }
+
+ public ConversationItemFooter(Context context, @Nullable AttributeSet attrs, int defStyleAttr) {
+ super(context, attrs, defStyleAttr);
+ init(attrs);
+ }
+
+ private void init(@Nullable AttributeSet attrs) {
+ inflate(getContext(), R.layout.conversation_item_footer, this);
+
+ dateView = findViewById(R.id.footer_date);
+ simView = findViewById(R.id.footer_sim_info);
+ timerView = findViewById(R.id.footer_expiration_timer);
+ insecureIndicatorView = findViewById(R.id.footer_insecure_indicator);
+ deliveryStatusView = findViewById(R.id.footer_delivery_status);
+ audioDuration = findViewById(R.id.footer_audio_duration);
+ audioSpace = findViewById(R.id.footer_audio_duration_space);
+ revealDot = findViewById(R.id.footer_revealed_dot);
+
+ if (attrs != null) {
+ TypedArray typedArray = getContext().getTheme().obtainStyledAttributes(attrs, R.styleable.ConversationItemFooter, 0, 0);
+ setTextColor(typedArray.getInt(R.styleable.ConversationItemFooter_footer_text_color, getResources().getColor(R.color.core_white)));
+ setIconColor(typedArray.getInt(R.styleable.ConversationItemFooter_footer_icon_color, getResources().getColor(R.color.core_white)));
+ setRevealDotColor(typedArray.getInt(R.styleable.ConversationItemFooter_footer_reveal_dot_color, getResources().getColor(R.color.core_white)));
+ typedArray.recycle();
+ }
+ }
+
+ @Override
+ protected void onDetachedFromWindow() {
+ super.onDetachedFromWindow();
+ timerView.stopAnimation();
+ }
+
+ public void setMessageRecord(@NonNull MessageRecord messageRecord, @NonNull Locale locale) {
+ presentDate(messageRecord, locale);
+ presentSimInfo(messageRecord);
+ presentTimer(messageRecord);
+ presentInsecureIndicator(messageRecord);
+ presentDeliveryStatus(messageRecord);
+ hideAudioDurationViews();
+ }
+
+ public void setAudioDuration(long totalDurationMillis, long currentPostionMillis) {
+ long remainingSecs = TimeUnit.MILLISECONDS.toSeconds(totalDurationMillis - currentPostionMillis);
+ audioDuration.setText(getResources().getString(R.string.AudioView_duration, remainingSecs / 60, remainingSecs % 60));
+ }
+
+ public void setTextColor(int color) {
+ dateView.setTextColor(color);
+ simView.setTextColor(color);
+ audioDuration.setTextColor(color);
+ }
+
+ public void setIconColor(int color) {
+ timerView.setColorFilter(color, PorterDuff.Mode.SRC_IN);
+ insecureIndicatorView.setColorFilter(color);
+ deliveryStatusView.setTint(color);
+ }
+
+ public void setRevealDotColor(int color) {
+ revealDot.addValueCallback(
+ new KeyPath("**"),
+ LottieProperty.COLOR_FILTER,
+ frameInfo -> new PorterDuffColorFilter(color, PorterDuff.Mode.SRC_ATOP)
+ );
+ }
+
+ public void setOnlyShowSendingStatus(boolean onlyShowSending, MessageRecord messageRecord) {
+ this.onlyShowSendingStatus = onlyShowSending;
+ presentDeliveryStatus(messageRecord);
+ }
+
+ public void enableBubbleBackground(@DrawableRes int drawableRes, @Nullable Integer tint) {
+ setBackgroundResource(drawableRes);
+
+ if (tint != null) {
+ getBackground().setColorFilter(tint, PorterDuff.Mode.MULTIPLY);
+ } else {
+ getBackground().clearColorFilter();
+ }
+ }
+
+ public void disableBubbleBackground() {
+ setBackground(null);
+ }
+
+ private void presentDate(@NonNull MessageRecord messageRecord, @NonNull Locale locale) {
+ dateView.forceLayout();
+ if (messageRecord.isFailed()) {
+ int errorMsg;
+ if (messageRecord.hasFailedWithNetworkFailures()) {
+ errorMsg = R.string.ConversationItem_error_network_not_delivered;
+ } else if (messageRecord.getRecipient().isPushGroup() && messageRecord.isIdentityMismatchFailure()) {
+ errorMsg = R.string.ConversationItem_error_partially_not_delivered;
+ } else {
+ errorMsg = R.string.ConversationItem_error_not_sent_tap_for_details;
+ }
+
+ dateView.setText(errorMsg);
+ } else if (messageRecord.isPendingInsecureSmsFallback()) {
+ dateView.setText(R.string.ConversationItem_click_to_approve_unencrypted);
+ } else {
+ dateView.setText(DateUtils.getExtendedRelativeTimeSpanString(getContext(), locale, messageRecord.getTimestamp()));
+ }
+ }
+
+ private void presentSimInfo(@NonNull MessageRecord messageRecord) {
+ SubscriptionManagerCompat subscriptionManager = new SubscriptionManagerCompat(getContext());
+
+ if (messageRecord.isPush() || messageRecord.getSubscriptionId() == -1 || !Permissions.hasAll(getContext(), Manifest.permission.READ_PHONE_STATE) || !subscriptionManager.isMultiSim()) {
+ simView.setVisibility(View.GONE);
+ } else {
+ Optional subscriptionInfo = subscriptionManager.getActiveSubscriptionInfo(messageRecord.getSubscriptionId());
+
+ if (subscriptionInfo.isPresent() && messageRecord.isOutgoing()) {
+ simView.setText(getContext().getString(R.string.ConversationItem_from_s, subscriptionInfo.get().getDisplayName()));
+ simView.setVisibility(View.VISIBLE);
+ } else if (subscriptionInfo.isPresent()) {
+ simView.setText(getContext().getString(R.string.ConversationItem_to_s, subscriptionInfo.get().getDisplayName()));
+ simView.setVisibility(View.VISIBLE);
+ } else {
+ simView.setVisibility(View.GONE);
+ }
+ }
+ }
+
+ @SuppressLint("StaticFieldLeak")
+ private void presentTimer(@NonNull final MessageRecord messageRecord) {
+ if (messageRecord.getExpiresIn() > 0 && !messageRecord.isPending()) {
+ this.timerView.setVisibility(View.VISIBLE);
+ this.timerView.setPercentComplete(0);
+
+ if (messageRecord.getExpireStarted() > 0) {
+ this.timerView.setExpirationTime(messageRecord.getExpireStarted(),
+ messageRecord.getExpiresIn());
+ this.timerView.startAnimation();
+
+ if (messageRecord.getExpireStarted() + messageRecord.getExpiresIn() <= System.currentTimeMillis()) {
+ ApplicationContext.getInstance(getContext()).getExpiringMessageManager().checkSchedule();
+ }
+ } else if (!messageRecord.isOutgoing() && !messageRecord.isMediaPending()) {
+ SignalExecutors.BOUNDED.execute(() -> {
+ ExpiringMessageManager expirationManager = ApplicationContext.getInstance(getContext()).getExpiringMessageManager();
+ long id = messageRecord.getId();
+ boolean mms = messageRecord.isMms();
+
+ if (mms) DatabaseFactory.getMmsDatabase(getContext()).markExpireStarted(id);
+ else DatabaseFactory.getSmsDatabase(getContext()).markExpireStarted(id);
+
+ expirationManager.scheduleDeletion(id, mms, messageRecord.getExpiresIn());
+ });
+ }
+ } else {
+ this.timerView.setVisibility(View.GONE);
+ }
+ }
+
+ private void presentInsecureIndicator(@NonNull MessageRecord messageRecord) {
+ insecureIndicatorView.setVisibility(messageRecord.isSecure() ? View.GONE : View.VISIBLE);
+ }
+
+ private void presentDeliveryStatus(@NonNull MessageRecord messageRecord) {
+ if (messageRecord.isFailed() || messageRecord.isPendingInsecureSmsFallback()) {
+ deliveryStatusView.setNone();
+ return;
+ }
+
+ if (onlyShowSendingStatus) {
+ if (messageRecord.isOutgoing() && messageRecord.isPending()) {
+ deliveryStatusView.setPending();
+ } else {
+ deliveryStatusView.setNone();
+ }
+ } else {
+ if (!messageRecord.isOutgoing()) {
+ deliveryStatusView.setNone();
+ } else if (messageRecord.isPending()) {
+ deliveryStatusView.setPending();
+ } else if (messageRecord.isRemoteRead()) {
+ deliveryStatusView.setRead();
+ } else if (messageRecord.isDelivered()) {
+ deliveryStatusView.setDelivered();
+ } else {
+ deliveryStatusView.setSent();
+ }
+ }
+ }
+
+ private void presentAudioDuration(@NonNull MessageRecord messageRecord) {
+ if (messageRecord.isMms()) {
+ MmsMessageRecord mmsMessageRecord = (MmsMessageRecord) messageRecord;
+
+ if (mmsMessageRecord.getSlideDeck().getAudioSlide() != null) {
+ if (messageRecord.isOutgoing()) {
+ moveAudioViewsForOutgoing();
+ } else {
+ moveAudioViewsForIncoming();
+ }
+ showAudioDurationViews();
+ } else {
+ hideAudioDurationViews();
+ }
+ } else {
+ hideAudioDurationViews();
+ }
+ }
+
+ private void moveAudioViewsForOutgoing() {
+ removeView(audioSpace);
+ removeView(audioDuration);
+ removeView(revealDot);
+ addView(audioSpace, 0);
+ addView(revealDot, 0);
+ addView(audioDuration, 0);
+
+ int padStart = ViewUtil.dpToPx(60);
+ int padLeft = ViewUtil.isLtr(this) ? padStart : 0;
+ int padRight = ViewUtil.isRtl(this) ? padStart : 0;
+
+ audioDuration.setPadding(padLeft, 0, padRight, 0);
+ }
+
+ private void moveAudioViewsForIncoming() {
+ removeView(audioSpace);
+ removeView(audioDuration);
+ removeView(revealDot);
+ addView(audioSpace);
+ addView(revealDot);
+ addView(audioDuration);
+
+ audioDuration.setPadding(0, 0, 0, 0);
+ }
+
+ private void showAudioDurationViews() {
+ audioSpace.setVisibility(View.VISIBLE);
+ audioDuration.setVisibility(View.VISIBLE);
+
+ if (FeatureFlags.viewedReceipts()) {
+ revealDot.setVisibility(View.VISIBLE);
+ }
+ }
+
+ private void hideAudioDurationViews() {
+ audioSpace.setVisibility(View.GONE);
+ audioDuration.setVisibility(View.GONE);
+ revealDot.setVisibility(View.GONE);
+ }
+}
diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/ConversationItemThumbnail.java b/app/src/main/java/org/thoughtcrime/securesms/components/ConversationItemThumbnail.java
new file mode 100644
index 00000000..41842104
--- /dev/null
+++ b/app/src/main/java/org/thoughtcrime/securesms/components/ConversationItemThumbnail.java
@@ -0,0 +1,170 @@
+package org.thoughtcrime.securesms.components;
+
+import android.content.Context;
+import android.content.res.TypedArray;
+import android.graphics.Canvas;
+import android.util.AttributeSet;
+import android.widget.FrameLayout;
+import android.widget.ImageView;
+
+import androidx.annotation.ColorInt;
+import androidx.annotation.NonNull;
+import androidx.annotation.Nullable;
+import androidx.annotation.UiThread;
+import androidx.core.content.ContextCompat;
+
+import org.thoughtcrime.securesms.R;
+import org.thoughtcrime.securesms.attachments.Attachment;
+import org.thoughtcrime.securesms.mms.GlideRequests;
+import org.thoughtcrime.securesms.mms.Slide;
+import org.thoughtcrime.securesms.mms.SlideClickListener;
+import org.thoughtcrime.securesms.mms.SlidesClickedListener;
+
+import java.util.List;
+
+public class ConversationItemThumbnail extends FrameLayout {
+
+ private ThumbnailView thumbnail;
+ private AlbumThumbnailView album;
+ private ImageView shade;
+ private ConversationItemFooter footer;
+ private CornerMask cornerMask;
+ private Outliner outliner;
+ private Outliner pulseOutliner;
+ private boolean borderless;
+
+ public ConversationItemThumbnail(Context context) {
+ super(context);
+ init(null);
+ }
+
+ public ConversationItemThumbnail(Context context, AttributeSet attrs) {
+ super(context, attrs);
+ init(attrs);
+ }
+
+ public ConversationItemThumbnail(final Context context, AttributeSet attrs, int defStyle) {
+ super(context, attrs, defStyle);
+ init(attrs);
+ }
+
+ private void init(@Nullable AttributeSet attrs) {
+ inflate(getContext(), R.layout.conversation_item_thumbnail, this);
+
+ this.thumbnail = findViewById(R.id.conversation_thumbnail_image);
+ this.album = findViewById(R.id.conversation_thumbnail_album);
+ this.shade = findViewById(R.id.conversation_thumbnail_shade);
+ this.footer = findViewById(R.id.conversation_thumbnail_footer);
+ this.cornerMask = new CornerMask(this);
+ this.outliner = new Outliner();
+
+ outliner.setColor(ContextCompat.getColor(getContext(), R.color.signal_inverse_transparent_20));
+
+ if (attrs != null) {
+ TypedArray typedArray = getContext().getTheme().obtainStyledAttributes(attrs, R.styleable.ConversationItemThumbnail, 0, 0);
+ thumbnail.setBounds(typedArray.getDimensionPixelSize(R.styleable.ConversationItemThumbnail_conversationThumbnail_minWidth, 0),
+ typedArray.getDimensionPixelSize(R.styleable.ConversationItemThumbnail_conversationThumbnail_maxWidth, 0),
+ typedArray.getDimensionPixelSize(R.styleable.ConversationItemThumbnail_conversationThumbnail_minHeight, 0),
+ typedArray.getDimensionPixelSize(R.styleable.ConversationItemThumbnail_conversationThumbnail_maxHeight, 0));
+ typedArray.recycle();
+ }
+ }
+
+ @SuppressWarnings("SuspiciousNameCombination")
+ @Override
+ protected void dispatchDraw(Canvas canvas) {
+ super.dispatchDraw(canvas);
+
+ if (!borderless) {
+ cornerMask.mask(canvas);
+
+ if (album.getVisibility() != VISIBLE) {
+ outliner.draw(canvas);
+ }
+ }
+
+ if (pulseOutliner != null) {
+ pulseOutliner.draw(canvas);
+ }
+ }
+
+ public void setPulseOutliner(@NonNull Outliner outliner) {
+ this.pulseOutliner = outliner;
+ }
+
+ @Override
+ public void setFocusable(boolean focusable) {
+ thumbnail.setFocusable(focusable);
+ album.setFocusable(focusable);
+ }
+
+ @Override
+ public void setClickable(boolean clickable) {
+ thumbnail.setClickable(clickable);
+ album.setClickable(clickable);
+ }
+
+ @Override
+ public void setOnLongClickListener(@Nullable OnLongClickListener l) {
+ thumbnail.setOnLongClickListener(l);
+ album.setOnLongClickListener(l);
+ }
+
+ public void showShade(boolean show) {
+ shade.setVisibility(show ? VISIBLE : GONE);
+ forceLayout();
+ }
+
+ public void setCorners(int topLeft, int topRight, int bottomRight, int bottomLeft) {
+ cornerMask.setRadii(topLeft, topRight, bottomRight, bottomLeft);
+ outliner.setRadii(topLeft, topRight, bottomRight, bottomLeft);
+ }
+
+ public void setMinimumThumbnailWidth(int width) {
+ thumbnail.setMinimumThumbnailWidth(width);
+ }
+
+ public void setBorderless(boolean borderless) {
+ this.borderless = borderless;
+ }
+
+ public ConversationItemFooter getFooter() {
+ return footer;
+ }
+
+ @UiThread
+ public void setImageResource(@NonNull GlideRequests glideRequests, @NonNull List slides,
+ boolean showControls, boolean isPreview)
+ {
+ if (slides.size() == 1) {
+ thumbnail.setVisibility(VISIBLE);
+ album.setVisibility(GONE);
+
+ Attachment attachment = slides.get(0).asAttachment();
+ thumbnail.setImageResource(glideRequests, slides.get(0), showControls, isPreview, attachment.getWidth(), attachment.getHeight());
+ setTouchDelegate(thumbnail.getTouchDelegate());
+ } else {
+ thumbnail.setVisibility(GONE);
+ album.setVisibility(VISIBLE);
+
+ album.setSlides(glideRequests, slides, showControls);
+ setTouchDelegate(album.getTouchDelegate());
+ }
+ }
+
+ public void setConversationColor(@ColorInt int color) {
+ if (album.getVisibility() == VISIBLE) {
+ album.setCellBackgroundColor(color);
+ }
+ }
+
+ public void setThumbnailClickListener(SlideClickListener listener) {
+ thumbnail.setThumbnailClickListener(listener);
+ album.setThumbnailClickListener(listener);
+ }
+
+ public void setDownloadClickListener(SlidesClickedListener listener) {
+ thumbnail.setDownloadClickListener(listener);
+ album.setDownloadClickListener(listener);
+ }
+}
diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/ConversationScrollToView.java b/app/src/main/java/org/thoughtcrime/securesms/components/ConversationScrollToView.java
new file mode 100644
index 00000000..590a24df
--- /dev/null
+++ b/app/src/main/java/org/thoughtcrime/securesms/components/ConversationScrollToView.java
@@ -0,0 +1,59 @@
+package org.thoughtcrime.securesms.components;
+
+import android.content.Context;
+import android.content.res.TypedArray;
+import android.util.AttributeSet;
+import android.widget.FrameLayout;
+import android.widget.ImageView;
+import android.widget.TextView;
+
+import androidx.annotation.NonNull;
+import androidx.annotation.Nullable;
+
+import org.thoughtcrime.securesms.R;
+
+public final class ConversationScrollToView extends FrameLayout {
+
+ private final TextView unreadCount;
+ private final ImageView scrollButton;
+
+ public ConversationScrollToView(@NonNull Context context) {
+ this(context, null);
+ }
+
+ public ConversationScrollToView(@NonNull Context context, @Nullable AttributeSet attrs) {
+ this(context, attrs, 0);
+ }
+
+ public ConversationScrollToView(@NonNull Context context, @Nullable AttributeSet attrs, int defStyleAttr) {
+ super(context, attrs, defStyleAttr);
+
+ inflate(context, R.layout.conversation_scroll_to, this);
+
+ unreadCount = findViewById(R.id.conversation_scroll_to_count);
+ scrollButton = findViewById(R.id.conversation_scroll_to_button);
+
+ if (attrs != null) {
+ TypedArray array = context.obtainStyledAttributes(attrs, R.styleable.ConversationScrollToView);
+ int srcId = array.getResourceId(R.styleable.ConversationScrollToView_cstv_scroll_button_src, 0);
+
+ scrollButton.setImageResource(srcId);
+
+ array.recycle();
+ }
+ }
+
+ @Override
+ public void setOnClickListener(@Nullable OnClickListener l) {
+ scrollButton.setOnClickListener(l);
+ }
+
+ public void setUnreadCount(int unreadCount) {
+ this.unreadCount.setText(formatUnreadCount(unreadCount));
+ this.unreadCount.setVisibility(unreadCount > 0 ? VISIBLE : GONE);
+ }
+
+ private @NonNull CharSequence formatUnreadCount(int unreadCount) {
+ return unreadCount > 999 ? "999+" : String.valueOf(unreadCount);
+ }
+}
diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/ConversationSearchBottomBar.java b/app/src/main/java/org/thoughtcrime/securesms/components/ConversationSearchBottomBar.java
new file mode 100644
index 00000000..843dcd93
--- /dev/null
+++ b/app/src/main/java/org/thoughtcrime/securesms/components/ConversationSearchBottomBar.java
@@ -0,0 +1,89 @@
+package org.thoughtcrime.securesms.components;
+
+import android.content.Context;
+import android.util.AttributeSet;
+import android.view.View;
+import android.widget.TextView;
+
+import androidx.annotation.NonNull;
+import androidx.annotation.Nullable;
+import androidx.constraintlayout.widget.ConstraintLayout;
+
+import org.thoughtcrime.securesms.R;
+
+/**
+ * Bottom navigation bar shown in the {@link org.thoughtcrime.securesms.conversation.ConversationActivity}
+ * when the user is searching within a conversation. Shows details about the results and allows the
+ * user to move between them.
+ */
+public class ConversationSearchBottomBar extends ConstraintLayout {
+
+ private View searchDown;
+ private View searchUp;
+ private TextView searchPositionText;
+ private View progressWheel;
+
+ private EventListener eventListener;
+
+
+ public ConversationSearchBottomBar(Context context) {
+ super(context);
+ }
+
+ public ConversationSearchBottomBar(Context context, AttributeSet attrs) {
+ super(context, attrs);
+ }
+
+ @Override
+ protected void onFinishInflate() {
+ super.onFinishInflate();
+
+ this.searchUp = findViewById(R.id.conversation_search_up);
+ this.searchDown = findViewById(R.id.conversation_search_down);
+ this.searchPositionText = findViewById(R.id.conversation_search_position);
+ this.progressWheel = findViewById(R.id.conversation_search_progress_wheel);
+ }
+
+ public void setData(int position, int count) {
+ progressWheel.setVisibility(GONE);
+
+ searchUp.setOnClickListener(v -> {
+ if (eventListener != null) {
+ eventListener.onSearchMoveUpPressed();
+ }
+ });
+
+ searchDown.setOnClickListener(v -> {
+ if (eventListener != null) {
+ eventListener.onSearchMoveDownPressed();
+ }
+ });
+
+ if (count > 0) {
+ searchPositionText.setText(getResources().getString(R.string.ConversationActivity_search_position, position + 1, count));
+ } else {
+ searchPositionText.setText(R.string.ConversationActivity_no_results);
+ }
+
+ setViewEnabled(searchUp, position < (count - 1));
+ setViewEnabled(searchDown, position > 0);
+ }
+
+ public void showLoading() {
+ progressWheel.setVisibility(VISIBLE);
+ }
+
+ private void setViewEnabled(@NonNull View view, boolean enabled) {
+ view.setEnabled(enabled);
+ view.setAlpha(enabled ? 1f : 0.25f);
+ }
+
+ public void setEventListener(@Nullable EventListener eventListener) {
+ this.eventListener = eventListener;
+ }
+
+ public interface EventListener {
+ void onSearchMoveUpPressed();
+ void onSearchMoveDownPressed();
+ }
+}
diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/ConversationTypingView.java b/app/src/main/java/org/thoughtcrime/securesms/components/ConversationTypingView.java
new file mode 100644
index 00000000..8eb881ca
--- /dev/null
+++ b/app/src/main/java/org/thoughtcrime/securesms/components/ConversationTypingView.java
@@ -0,0 +1,55 @@
+package org.thoughtcrime.securesms.components;
+
+import android.content.Context;
+import android.graphics.PorterDuff;
+import android.util.AttributeSet;
+import android.view.View;
+import android.widget.LinearLayout;
+
+import androidx.annotation.NonNull;
+import androidx.annotation.Nullable;
+
+import org.thoughtcrime.securesms.R;
+import org.thoughtcrime.securesms.mms.GlideRequests;
+import org.thoughtcrime.securesms.recipients.Recipient;
+
+import java.util.List;
+
+public class ConversationTypingView extends LinearLayout {
+
+ private AvatarImageView avatar;
+ private View bubble;
+ private TypingIndicatorView indicator;
+
+ public ConversationTypingView(Context context, @Nullable AttributeSet attrs) {
+ super(context, attrs);
+ }
+
+ @Override
+ protected void onFinishInflate() {
+ super.onFinishInflate();
+
+ avatar = findViewById(R.id.typing_avatar);
+ bubble = findViewById(R.id.typing_bubble);
+ indicator = findViewById(R.id.typing_indicator);
+ }
+
+ public void setTypists(@NonNull GlideRequests glideRequests, @NonNull List typists, boolean isGroupThread) {
+ if (typists.isEmpty()) {
+ indicator.stopAnimation();
+ return;
+ }
+
+ Recipient typist = typists.get(0);
+ bubble.getBackground().setColorFilter(typist.getColor().toConversationColor(getContext()), PorterDuff.Mode.MULTIPLY);
+
+ if (isGroupThread) {
+ avatar.setAvatar(glideRequests, typist, true);
+ avatar.setVisibility(VISIBLE);
+ } else {
+ avatar.setVisibility(GONE);
+ }
+
+ indicator.startAnimation();
+ }
+}
diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/CornerMask.java b/app/src/main/java/org/thoughtcrime/securesms/components/CornerMask.java
new file mode 100644
index 00000000..2d27539a
--- /dev/null
+++ b/app/src/main/java/org/thoughtcrime/securesms/components/CornerMask.java
@@ -0,0 +1,75 @@
+package org.thoughtcrime.securesms.components;
+
+import android.graphics.Canvas;
+import android.graphics.Color;
+import android.graphics.Paint;
+import android.graphics.Path;
+import android.graphics.PorterDuff;
+import android.graphics.PorterDuffXfermode;
+import android.graphics.RectF;
+import android.view.View;
+
+import androidx.annotation.NonNull;
+
+public class CornerMask {
+
+ private final float[] radii = new float[8];
+ private final Paint clearPaint = new Paint();
+ private final Path outline = new Path();
+ private final Path corners = new Path();
+ private final RectF bounds = new RectF();
+
+ public CornerMask(@NonNull View view) {
+ view.setLayerType(View.LAYER_TYPE_HARDWARE, null);
+
+ clearPaint.setColor(Color.BLACK);
+ clearPaint.setStyle(Paint.Style.FILL);
+ clearPaint.setAntiAlias(true);
+ clearPaint.setXfermode(new PorterDuffXfermode(PorterDuff.Mode.CLEAR));
+ }
+
+ public void mask(Canvas canvas) {
+ bounds.left = 0;
+ bounds.top = 0;
+ bounds.right = canvas.getWidth();
+ bounds.bottom = canvas.getHeight();
+
+ corners.reset();
+ corners.addRoundRect(bounds, radii, Path.Direction.CW);
+
+ // Note: There's a bug in the P beta where most PorterDuff modes aren't working. But CLEAR does.
+ // So we find and inverse path and use Mode.CLEAR.
+ // See issue https://issuetracker.google.com/issues/111394085.
+ outline.reset();
+ outline.addRect(bounds, Path.Direction.CW);
+ outline.op(corners, Path.Op.DIFFERENCE);
+ canvas.drawPath(outline, clearPaint);
+ }
+
+ public void setRadius(int radius) {
+ setRadii(radius, radius, radius, radius);
+ }
+
+ public void setRadii(int topLeft, int topRight, int bottomRight, int bottomLeft) {
+ radii[0] = radii[1] = topLeft;
+ radii[2] = radii[3] = topRight;
+ radii[4] = radii[5] = bottomRight;
+ radii[6] = radii[7] = bottomLeft;
+ }
+
+ public void setTopLeftRadius(int radius) {
+ radii[0] = radii[1] = radius;
+ }
+
+ public void setTopRightRadius(int radius) {
+ radii[2] = radii[3] = radius;
+ }
+
+ public void setBottomRightRadius(int radius) {
+ radii[4] = radii[5] = radius;
+ }
+
+ public void setBottomLeftRadius(int radius) {
+ radii[6] = radii[7] = radius;
+ }
+}
diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/CustomDefaultPreference.java b/app/src/main/java/org/thoughtcrime/securesms/components/CustomDefaultPreference.java
new file mode 100644
index 00000000..fc056e44
--- /dev/null
+++ b/app/src/main/java/org/thoughtcrime/securesms/components/CustomDefaultPreference.java
@@ -0,0 +1,260 @@
+package org.thoughtcrime.securesms.components;
+
+import android.app.Dialog;
+import android.content.Context;
+import android.content.res.TypedArray;
+import android.os.Bundle;
+import android.text.Editable;
+import android.text.TextUtils;
+import android.text.TextWatcher;
+import android.util.AttributeSet;
+import android.view.View;
+import android.widget.AdapterView;
+import android.widget.Button;
+import android.widget.EditText;
+import android.widget.Spinner;
+import android.widget.TextView;
+
+import androidx.annotation.NonNull;
+import androidx.appcompat.app.AlertDialog;
+import androidx.preference.DialogPreference;
+import androidx.preference.PreferenceDialogFragmentCompat;
+
+import org.signal.core.util.logging.Log;
+import org.thoughtcrime.securesms.R;
+import org.thoughtcrime.securesms.components.CustomDefaultPreference.CustomDefaultPreferenceDialogFragmentCompat.CustomPreferenceValidator;
+import org.thoughtcrime.securesms.util.TextSecurePreferences;
+
+import java.net.URI;
+import java.net.URISyntaxException;
+
+
+public class CustomDefaultPreference extends DialogPreference {
+
+ private static final String TAG = CustomDefaultPreference.class.getSimpleName();
+
+ private final int inputType;
+ private final String customPreference;
+ private final String customToggle;
+
+ private CustomPreferenceValidator validator;
+ private String defaultValue;
+
+ public CustomDefaultPreference(Context context, AttributeSet attrs) {
+ super(context, attrs);
+
+ int[] attributeNames = new int[]{android.R.attr.inputType, R.attr.custom_pref_toggle};
+ TypedArray attributes = context.obtainStyledAttributes(attrs, attributeNames);
+
+ this.inputType = attributes.getInt(0, 0);
+ this.customPreference = getKey();
+ this.customToggle = attributes.getString(1);
+ this.validator = new CustomDefaultPreferenceDialogFragmentCompat.NullValidator();
+
+ attributes.recycle();
+
+ setPersistent(false);
+ setDialogLayoutResource(R.layout.custom_default_preference_dialog);
+ }
+
+ public CustomDefaultPreference setValidator(CustomPreferenceValidator validator) {
+ this.validator = validator;
+ return this;
+ }
+
+ public CustomDefaultPreference setDefaultValue(String defaultValue) {
+ this.defaultValue = defaultValue;
+ this.setSummary(getSummary());
+ return this;
+ }
+
+ @Override
+ public String getSummary() {
+ if (isCustom()) {
+ return getContext().getString(R.string.CustomDefaultPreference_using_custom,
+ getPrettyPrintValue(getCustomValue()));
+ } else {
+ return getContext().getString(R.string.CustomDefaultPreference_using_default,
+ getPrettyPrintValue(getDefaultValue()));
+ }
+ }
+
+ private String getPrettyPrintValue(String value) {
+ if (TextUtils.isEmpty(value)) return getContext().getString(R.string.CustomDefaultPreference_none);
+ else return value;
+ }
+
+ private boolean isCustom() {
+ return TextSecurePreferences.getBooleanPreference(getContext(), customToggle, false);
+ }
+
+ private void setCustom(boolean custom) {
+ TextSecurePreferences.setBooleanPreference(getContext(), customToggle, custom);
+ }
+
+ private String getCustomValue() {
+ return TextSecurePreferences.getStringPreference(getContext(), customPreference, "");
+ }
+
+ private void setCustomValue(String value) {
+ TextSecurePreferences.setStringPreference(getContext(), customPreference, value);
+ }
+
+ private String getDefaultValue() {
+ return defaultValue;
+ }
+
+
+ public static class CustomDefaultPreferenceDialogFragmentCompat extends PreferenceDialogFragmentCompat {
+
+ private static final String INPUT_TYPE = "input_type";
+
+ private Spinner spinner;
+ private EditText customText;
+ private TextView defaultLabel;
+
+ public static CustomDefaultPreferenceDialogFragmentCompat newInstance(String key) {
+ CustomDefaultPreferenceDialogFragmentCompat fragment = new CustomDefaultPreferenceDialogFragmentCompat();
+ Bundle b = new Bundle(1);
+ b.putString(PreferenceDialogFragmentCompat.ARG_KEY, key);
+ fragment.setArguments(b);
+ return fragment;
+ }
+
+ @Override
+ protected void onBindDialogView(@NonNull View view) {
+ Log.i(TAG, "onBindDialogView");
+ super.onBindDialogView(view);
+
+ CustomDefaultPreference preference = (CustomDefaultPreference)getPreference();
+
+ this.spinner = (Spinner) view.findViewById(R.id.default_or_custom);
+ this.defaultLabel = (TextView) view.findViewById(R.id.default_label);
+ this.customText = (EditText) view.findViewById(R.id.custom_edit);
+
+ this.customText.setInputType(preference.inputType);
+ this.customText.addTextChangedListener(new TextValidator());
+ this.customText.setText(preference.getCustomValue());
+ this.spinner.setOnItemSelectedListener(new SelectionLister());
+ this.defaultLabel.setText(preference.getPrettyPrintValue(preference.defaultValue));
+ }
+
+
+ @Override
+ public @NonNull Dialog onCreateDialog(Bundle instanceState) {
+ Dialog dialog = super.onCreateDialog(instanceState);
+
+ CustomDefaultPreference preference = (CustomDefaultPreference)getPreference();
+
+ if (preference.isCustom()) spinner.setSelection(1, true);
+ else spinner.setSelection(0, true);
+
+ return dialog;
+ }
+
+ @Override
+ public void onDialogClosed(boolean positiveResult) {
+ CustomDefaultPreference preference = (CustomDefaultPreference)getPreference();
+
+ if (positiveResult) {
+ if (spinner != null) preference.setCustom(spinner.getSelectedItemPosition() == 1);
+ if (customText != null) preference.setCustomValue(customText.getText().toString());
+
+ preference.setSummary(preference.getSummary());
+ }
+ }
+
+ interface CustomPreferenceValidator {
+ public boolean isValid(String value);
+ }
+
+ private static class NullValidator implements CustomPreferenceValidator {
+ @Override
+ public boolean isValid(String value) {
+ return true;
+ }
+ }
+
+ private class TextValidator implements TextWatcher {
+
+ @Override
+ public void beforeTextChanged(CharSequence s, int start, int count, int after) {}
+
+ @Override
+ public void onTextChanged(CharSequence s, int start, int before, int count) {}
+
+ @Override
+ public void afterTextChanged(Editable s) {
+ CustomDefaultPreference preference = (CustomDefaultPreference)getPreference();
+
+ if (spinner.getSelectedItemPosition() == 1) {
+ Button positiveButton = ((AlertDialog)getDialog()).getButton(AlertDialog.BUTTON_POSITIVE);
+ positiveButton.setEnabled(preference.validator.isValid(s.toString()));
+ }
+ }
+ }
+
+ public static class UriValidator implements CustomPreferenceValidator {
+ @Override
+ public boolean isValid(String value) {
+ if (TextUtils.isEmpty(value)) return true;
+
+ try {
+ new URI(value);
+ return true;
+ } catch (URISyntaxException mue) {
+ return false;
+ }
+ }
+ }
+
+ public static class HostnameValidator implements CustomPreferenceValidator {
+ @Override
+ public boolean isValid(String value) {
+ if (TextUtils.isEmpty(value)) return true;
+
+ try {
+ URI uri = new URI(null, value, null, null);
+ return true;
+ } catch (URISyntaxException mue) {
+ return false;
+ }
+ }
+ }
+
+ public static class PortValidator implements CustomPreferenceValidator {
+ @Override
+ public boolean isValid(String value) {
+ try {
+ Integer.parseInt(value);
+ return true;
+ } catch (NumberFormatException e) {
+ return false;
+ }
+ }
+ }
+
+ private class SelectionLister implements AdapterView.OnItemSelectedListener {
+
+ @Override
+ public void onItemSelected(AdapterView> parent, View view, int position, long id) {
+ CustomDefaultPreference preference = (CustomDefaultPreference)getPreference();
+ Button positiveButton = ((AlertDialog)getDialog()).getButton(AlertDialog.BUTTON_POSITIVE);
+
+ defaultLabel.setVisibility(position == 0 ? View.VISIBLE : View.GONE);
+ customText.setVisibility(position == 0 ? View.GONE : View.VISIBLE);
+ positiveButton.setEnabled(position == 0 || preference.validator.isValid(customText.getText().toString()));
+ }
+
+ @Override
+ public void onNothingSelected(AdapterView> parent) {
+ defaultLabel.setVisibility(View.VISIBLE);
+ customText.setVisibility(View.GONE);
+ }
+ }
+
+ }
+
+
+
+}
diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/DarkSearchView.java b/app/src/main/java/org/thoughtcrime/securesms/components/DarkSearchView.java
new file mode 100644
index 00000000..fda02a7b
--- /dev/null
+++ b/app/src/main/java/org/thoughtcrime/securesms/components/DarkSearchView.java
@@ -0,0 +1,32 @@
+package org.thoughtcrime.securesms.components;
+
+import android.content.Context;
+import android.util.AttributeSet;
+import android.widget.EditText;
+
+import androidx.annotation.AttrRes;
+import androidx.annotation.NonNull;
+import androidx.annotation.Nullable;
+import androidx.core.content.ContextCompat;
+
+import org.thoughtcrime.securesms.R;
+
+/**
+ * Custom styled search view that we can insert into ActionBar menus
+ */
+public class DarkSearchView extends androidx.appcompat.widget.SearchView {
+ public DarkSearchView(@NonNull Context context) {
+ this(context, null);
+ }
+
+ public DarkSearchView(@NonNull Context context, @Nullable AttributeSet attrs) {
+ this(context, attrs, R.attr.search_view_style_dark);
+ }
+
+ public DarkSearchView(@NonNull Context context, @Nullable AttributeSet attrs, @AttrRes int defStyleAttr) {
+ super(context, attrs, defStyleAttr);
+
+ EditText searchText = findViewById(androidx.appcompat.R.id.search_src_text);
+ searchText.setTextColor(ContextCompat.getColor(context, R.color.signal_text_toolbar_subtitle));
+ }
+}
diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/DeliveryStatusView.java b/app/src/main/java/org/thoughtcrime/securesms/components/DeliveryStatusView.java
new file mode 100644
index 00000000..076791fc
--- /dev/null
+++ b/app/src/main/java/org/thoughtcrime/securesms/components/DeliveryStatusView.java
@@ -0,0 +1,104 @@
+package org.thoughtcrime.securesms.components;
+
+import android.content.Context;
+import android.content.res.TypedArray;
+import android.util.AttributeSet;
+import android.view.View;
+import android.view.animation.Animation;
+import android.view.animation.LinearInterpolator;
+import android.view.animation.RotateAnimation;
+import android.widget.FrameLayout;
+import android.widget.ImageView;
+
+import org.thoughtcrime.securesms.R;
+
+public class DeliveryStatusView extends FrameLayout {
+
+ private static final String TAG = DeliveryStatusView.class.getSimpleName();
+
+ private static final RotateAnimation ROTATION_ANIMATION = new RotateAnimation(0, 360f,
+ Animation.RELATIVE_TO_SELF, 0.5f,
+ Animation.RELATIVE_TO_SELF, 0.5f);
+ static {
+ ROTATION_ANIMATION.setInterpolator(new LinearInterpolator());
+ ROTATION_ANIMATION.setDuration(1500);
+ ROTATION_ANIMATION.setRepeatCount(Animation.INFINITE);
+ }
+
+ private final ImageView pendingIndicator;
+ private final ImageView sentIndicator;
+ private final ImageView deliveredIndicator;
+ private final ImageView readIndicator;
+
+ public DeliveryStatusView(Context context) {
+ this(context, null);
+ }
+
+ public DeliveryStatusView(Context context, AttributeSet attrs) {
+ this(context, attrs, 0);
+ }
+
+ public DeliveryStatusView(final Context context, AttributeSet attrs, int defStyle) {
+ super(context, attrs, defStyle);
+
+ inflate(context, R.layout.delivery_status_view, this);
+
+ this.deliveredIndicator = findViewById(R.id.delivered_indicator);
+ this.sentIndicator = findViewById(R.id.sent_indicator);
+ this.pendingIndicator = findViewById(R.id.pending_indicator);
+ this.readIndicator = findViewById(R.id.read_indicator);
+
+ if (attrs != null) {
+ TypedArray typedArray = context.getTheme().obtainStyledAttributes(attrs, R.styleable.DeliveryStatusView, 0, 0);
+ setTint(typedArray.getColor(R.styleable.DeliveryStatusView_iconColor, getResources().getColor(R.color.core_white)));
+ typedArray.recycle();
+ }
+ }
+
+ public void setNone() {
+ this.setVisibility(View.GONE);
+ }
+
+ public void setPending() {
+ this.setVisibility(View.VISIBLE);
+ pendingIndicator.setVisibility(View.VISIBLE);
+ pendingIndicator.startAnimation(ROTATION_ANIMATION);
+ sentIndicator.setVisibility(View.GONE);
+ deliveredIndicator.setVisibility(View.GONE);
+ readIndicator.setVisibility(View.GONE);
+ }
+
+ public void setSent() {
+ this.setVisibility(View.VISIBLE);
+ pendingIndicator.setVisibility(View.GONE);
+ pendingIndicator.clearAnimation();
+ sentIndicator.setVisibility(View.VISIBLE);
+ deliveredIndicator.setVisibility(View.GONE);
+ readIndicator.setVisibility(View.GONE);
+ }
+
+ public void setDelivered() {
+ this.setVisibility(View.VISIBLE);
+ pendingIndicator.setVisibility(View.GONE);
+ pendingIndicator.clearAnimation();
+ sentIndicator.setVisibility(View.GONE);
+ deliveredIndicator.setVisibility(View.VISIBLE);
+ readIndicator.setVisibility(View.GONE);
+ }
+
+ public void setRead() {
+ this.setVisibility(View.VISIBLE);
+ pendingIndicator.setVisibility(View.GONE);
+ pendingIndicator.clearAnimation();
+ sentIndicator.setVisibility(View.GONE);
+ deliveredIndicator.setVisibility(View.GONE);
+ readIndicator.setVisibility(View.VISIBLE);
+ }
+
+ public void setTint(int color) {
+ pendingIndicator.setColorFilter(color);
+ deliveredIndicator.setColorFilter(color);
+ sentIndicator.setColorFilter(color);
+ readIndicator.setColorFilter(color);
+ }
+}
diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/DocumentView.java b/app/src/main/java/org/thoughtcrime/securesms/components/DocumentView.java
new file mode 100644
index 00000000..8f5e806b
--- /dev/null
+++ b/app/src/main/java/org/thoughtcrime/securesms/components/DocumentView.java
@@ -0,0 +1,168 @@
+package org.thoughtcrime.securesms.components;
+
+
+import android.content.Context;
+import android.content.res.TypedArray;
+import android.graphics.Color;
+import android.graphics.PorterDuff;
+import android.util.AttributeSet;
+import android.view.View;
+import android.view.ViewGroup;
+import android.widget.FrameLayout;
+import android.widget.ImageView;
+import android.widget.TextView;
+
+import androidx.annotation.AttrRes;
+import androidx.annotation.NonNull;
+import androidx.annotation.Nullable;
+
+import com.pnikosis.materialishprogress.ProgressWheel;
+
+import org.greenrobot.eventbus.Subscribe;
+import org.greenrobot.eventbus.ThreadMode;
+import org.thoughtcrime.securesms.R;
+import org.thoughtcrime.securesms.database.AttachmentDatabase;
+import org.thoughtcrime.securesms.events.PartProgressEvent;
+import org.thoughtcrime.securesms.mms.Slide;
+import org.thoughtcrime.securesms.mms.SlideClickListener;
+import org.thoughtcrime.securesms.util.Util;
+
+public class DocumentView extends FrameLayout {
+
+ private static final String TAG = DocumentView.class.getSimpleName();
+
+ private final @NonNull AnimatingToggle controlToggle;
+ private final @NonNull ImageView downloadButton;
+ private final @NonNull ProgressWheel downloadProgress;
+ private final @NonNull View container;
+ private final @NonNull ViewGroup iconContainer;
+ private final @NonNull TextView fileName;
+ private final @NonNull TextView fileSize;
+ private final @NonNull TextView document;
+
+ private @Nullable SlideClickListener downloadListener;
+ private @Nullable SlideClickListener viewListener;
+ private @Nullable Slide documentSlide;
+
+ public DocumentView(@NonNull Context context) {
+ this(context, null);
+ }
+
+ public DocumentView(@NonNull Context context, @Nullable AttributeSet attrs) {
+ this(context, attrs, 0);
+ }
+
+ public DocumentView(@NonNull Context context, @Nullable AttributeSet attrs, @AttrRes int defStyleAttr) {
+ super(context, attrs, defStyleAttr);
+ inflate(context, R.layout.document_view, this);
+
+ this.container = findViewById(R.id.document_container);
+ this.iconContainer = findViewById(R.id.icon_container);
+ this.controlToggle = findViewById(R.id.control_toggle);
+ this.downloadButton = findViewById(R.id.download);
+ this.downloadProgress = findViewById(R.id.download_progress);
+ this.fileName = findViewById(R.id.file_name);
+ this.fileSize = findViewById(R.id.file_size);
+ this.document = findViewById(R.id.document);
+
+ if (attrs != null) {
+ TypedArray typedArray = getContext().getTheme().obtainStyledAttributes(attrs, R.styleable.DocumentView, 0, 0);
+ int titleColor = typedArray.getInt(R.styleable.DocumentView_doc_titleColor, Color.BLACK);
+ int captionColor = typedArray.getInt(R.styleable.DocumentView_doc_captionColor, Color.BLACK);
+ int downloadTint = typedArray.getInt(R.styleable.DocumentView_doc_downloadButtonTint, Color.WHITE);
+ typedArray.recycle();
+
+ fileName.setTextColor(titleColor);
+ fileSize.setTextColor(captionColor);
+ downloadButton.setColorFilter(downloadTint, PorterDuff.Mode.MULTIPLY);
+ downloadProgress.setBarColor(downloadTint);
+ }
+ }
+
+ public void setDownloadClickListener(@Nullable SlideClickListener listener) {
+ this.downloadListener = listener;
+ }
+
+ public void setDocumentClickListener(@Nullable SlideClickListener listener) {
+ this.viewListener = listener;
+ }
+
+ public void setDocument(final @NonNull Slide documentSlide,
+ final boolean showControls)
+ {
+ if (showControls && documentSlide.isPendingDownload()) {
+ controlToggle.displayQuick(downloadButton);
+ downloadButton.setOnClickListener(new DownloadClickedListener(documentSlide));
+ if (downloadProgress.isSpinning()) downloadProgress.stopSpinning();
+ } else if (showControls && documentSlide.getTransferState() == AttachmentDatabase.TRANSFER_PROGRESS_STARTED) {
+ controlToggle.displayQuick(downloadProgress);
+ downloadProgress.spin();
+ } else {
+ controlToggle.displayQuick(iconContainer);
+ if (downloadProgress.isSpinning()) downloadProgress.stopSpinning();
+ }
+
+ this.documentSlide = documentSlide;
+
+ this.fileName.setText(documentSlide.getFileName()
+ .or(documentSlide.getCaption())
+ .or(getContext().getString(R.string.DocumentView_unnamed_file)));
+ this.fileSize.setText(Util.getPrettyFileSize(documentSlide.getFileSize()));
+ this.document.setText(documentSlide.getFileType(getContext()).or("").toLowerCase());
+ this.setOnClickListener(new OpenClickedListener(documentSlide));
+ }
+
+ @Override
+ public void setFocusable(boolean focusable) {
+ super.setFocusable(focusable);
+ this.downloadButton.setFocusable(focusable);
+ }
+
+ @Override
+ public void setClickable(boolean clickable) {
+ super.setClickable(clickable);
+ this.downloadButton.setClickable(clickable);
+ }
+
+ @Override
+ public void setEnabled(boolean enabled) {
+ super.setEnabled(enabled);
+ this.downloadButton.setEnabled(enabled);
+ }
+
+ @Subscribe(sticky = true, threadMode = ThreadMode.MAIN)
+ public void onEventAsync(final PartProgressEvent event) {
+ if (documentSlide != null && event.attachment.equals(documentSlide.asAttachment())) {
+ downloadProgress.setInstantProgress(((float) event.progress) / event.total);
+ }
+ }
+
+ private class DownloadClickedListener implements View.OnClickListener {
+ private final @NonNull Slide slide;
+
+ private DownloadClickedListener(@NonNull Slide slide) {
+ this.slide = slide;
+ }
+
+ @Override
+ public void onClick(View v) {
+ if (downloadListener != null) downloadListener.onClick(v, slide);
+ }
+ }
+
+ private class OpenClickedListener implements View.OnClickListener {
+ private final @NonNull Slide slide;
+
+ private OpenClickedListener(@NonNull Slide slide) {
+ this.slide = slide;
+ }
+
+ @Override
+ public void onClick(View v) {
+ if (!slide.isPendingDownload() && !slide.isInProgress() && viewListener != null) {
+ viewListener.onClick(v, slide);
+ }
+ }
+ }
+
+}
diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/ExpirationTimerView.java b/app/src/main/java/org/thoughtcrime/securesms/components/ExpirationTimerView.java
new file mode 100644
index 00000000..d52b8916
--- /dev/null
+++ b/app/src/main/java/org/thoughtcrime/securesms/components/ExpirationTimerView.java
@@ -0,0 +1,123 @@
+package org.thoughtcrime.securesms.components;
+
+import android.content.Context;
+import android.util.AttributeSet;
+
+import androidx.annotation.NonNull;
+import androidx.annotation.Nullable;
+
+import org.thoughtcrime.securesms.R;
+import org.thoughtcrime.securesms.util.Util;
+
+import java.lang.ref.WeakReference;
+import java.util.concurrent.TimeUnit;
+
+public class ExpirationTimerView extends androidx.appcompat.widget.AppCompatImageView {
+
+ private long startedAt;
+ private long expiresIn;
+
+ private boolean visible = false;
+ private boolean stopped = true;
+
+ private final int[] frames = new int[]{ R.drawable.ic_timer_00_12,
+ R.drawable.ic_timer_05_12,
+ R.drawable.ic_timer_10_12,
+ R.drawable.ic_timer_15_12,
+ R.drawable.ic_timer_20_12,
+ R.drawable.ic_timer_25_12,
+ R.drawable.ic_timer_30_12,
+ R.drawable.ic_timer_35_12,
+ R.drawable.ic_timer_40_12,
+ R.drawable.ic_timer_45_12,
+ R.drawable.ic_timer_50_12,
+ R.drawable.ic_timer_55_12,
+ R.drawable.ic_timer_60_12 };
+
+ public ExpirationTimerView(Context context) {
+ super(context);
+ }
+
+ public ExpirationTimerView(Context context, @Nullable AttributeSet attrs) {
+ super(context, attrs);
+ }
+
+ public ExpirationTimerView(Context context, @Nullable AttributeSet attrs, int defStyleAttr) {
+ super(context, attrs, defStyleAttr);
+ }
+
+ public void setExpirationTime(long startedAt, long expiresIn) {
+ this.startedAt = startedAt;
+ this.expiresIn = expiresIn;
+ setPercentComplete(calculateProgress(this.startedAt, this.expiresIn));
+ }
+
+ public void setPercentComplete(float percentage) {
+ float percentFull = 1 - percentage;
+ int frame = (int) Math.ceil(percentFull * (frames.length - 1));
+
+ frame = Math.max(0, Math.min(frame, frames.length - 1));
+ setImageResource(frames[frame]);
+ }
+
+ public void startAnimation() {
+ synchronized (this) {
+ visible = true;
+ if (!stopped) return;
+ else stopped = false;
+ }
+
+ Util.runOnMainDelayed(new AnimationUpdateRunnable(this), calculateAnimationDelay(this.startedAt, this.expiresIn));
+ }
+
+ public void stopAnimation() {
+ synchronized (this) {
+ visible = false;
+ }
+ }
+
+ private float calculateProgress(long startedAt, long expiresIn) {
+ long progressed = System.currentTimeMillis() - startedAt;
+ float percentComplete = (float)progressed / (float)expiresIn;
+
+ return Math.max(0, Math.min(percentComplete, 1));
+ }
+
+ private long calculateAnimationDelay(long startedAt, long expiresIn) {
+ long progressed = System.currentTimeMillis() - startedAt;
+ long remaining = expiresIn - progressed;
+
+ if (remaining < TimeUnit.SECONDS.toMillis(30)) {
+ return 50;
+ } else {
+ return 1000;
+ }
+ }
+
+ private static class AnimationUpdateRunnable implements Runnable {
+
+ private final WeakReference expirationTimerViewReference;
+
+ private AnimationUpdateRunnable(@NonNull ExpirationTimerView expirationTimerView) {
+ this.expirationTimerViewReference = new WeakReference<>(expirationTimerView);
+ }
+
+ @Override
+ public void run() {
+ ExpirationTimerView timerView = expirationTimerViewReference.get();
+ if (timerView == null) return;
+
+ timerView.setExpirationTime(timerView.startedAt, timerView.expiresIn);
+
+ synchronized (timerView) {
+ if (!timerView.visible) {
+ timerView.stopped = true;
+ return;
+ }
+ }
+
+ Util.runOnMainDelayed(this, timerView.calculateAnimationDelay(timerView.startedAt, timerView.expiresIn));
+ }
+ }
+
+}
diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/FromTextView.java b/app/src/main/java/org/thoughtcrime/securesms/components/FromTextView.java
new file mode 100644
index 00000000..df6b50db
--- /dev/null
+++ b/app/src/main/java/org/thoughtcrime/securesms/components/FromTextView.java
@@ -0,0 +1,73 @@
+package org.thoughtcrime.securesms.components;
+
+import android.content.Context;
+import android.graphics.Typeface;
+import android.text.Spannable;
+import android.text.SpannableString;
+import android.text.SpannableStringBuilder;
+import android.text.style.StyleSpan;
+import android.util.AttributeSet;
+
+import androidx.annotation.Nullable;
+
+import org.thoughtcrime.securesms.R;
+import org.thoughtcrime.securesms.components.emoji.EmojiTextView;
+import org.thoughtcrime.securesms.recipients.Recipient;
+
+public class FromTextView extends EmojiTextView {
+
+ private static final String TAG = FromTextView.class.getSimpleName();
+
+ public FromTextView(Context context) {
+ super(context);
+ }
+
+ public FromTextView(Context context, AttributeSet attrs) {
+ super(context, attrs);
+ }
+
+ public void setText(Recipient recipient) {
+ setText(recipient, true);
+ }
+
+ public void setText(Recipient recipient, boolean read) {
+ setText(recipient, read, null);
+ }
+
+ public void setText(Recipient recipient, boolean read, @Nullable String suffix) {
+ String fromString = recipient.getDisplayName(getContext());
+
+ int typeface;
+
+ if (!read) {
+ typeface = Typeface.BOLD;
+ } else {
+ typeface = Typeface.NORMAL;
+ }
+
+ SpannableStringBuilder builder = new SpannableStringBuilder();
+
+ SpannableString fromSpan = new SpannableString(fromString);
+ fromSpan.setSpan(new StyleSpan(typeface), 0, builder.length(),
+ Spannable.SPAN_INCLUSIVE_EXCLUSIVE);
+
+
+ if (recipient.isSelf()) {
+ builder.append(getContext().getString(R.string.note_to_self));
+ } else {
+ builder.append(fromSpan);
+ }
+
+ if (suffix != null) {
+ builder.append(suffix);
+ }
+
+ setText(builder);
+
+ if (recipient.isBlocked()) setCompoundDrawablesWithIntrinsicBounds(R.drawable.ic_block_grey600_18dp, 0, 0, 0);
+ else if (recipient.isMuted()) setCompoundDrawablesWithIntrinsicBounds(R.drawable.ic_volume_off_grey600_18dp, 0, 0, 0);
+ else setCompoundDrawablesWithIntrinsicBounds(0, 0, 0, 0);
+ }
+
+
+}
diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/FullScreenDialogFragment.java b/app/src/main/java/org/thoughtcrime/securesms/components/FullScreenDialogFragment.java
new file mode 100644
index 00000000..c0dd74c2
--- /dev/null
+++ b/app/src/main/java/org/thoughtcrime/securesms/components/FullScreenDialogFragment.java
@@ -0,0 +1,50 @@
+package org.thoughtcrime.securesms.components;
+
+import android.os.Bundle;
+import android.view.LayoutInflater;
+import android.view.View;
+import android.view.ViewGroup;
+
+import androidx.annotation.LayoutRes;
+import androidx.annotation.NonNull;
+import androidx.annotation.Nullable;
+import androidx.annotation.StringRes;
+import androidx.appcompat.widget.Toolbar;
+import androidx.fragment.app.DialogFragment;
+
+import org.thoughtcrime.securesms.R;
+import org.thoughtcrime.securesms.util.ThemeUtil;
+
+/**
+ * Base dialog fragment for rendering as a full screen dialog with animation
+ * transitions.
+ */
+public abstract class FullScreenDialogFragment extends DialogFragment {
+
+ protected Toolbar toolbar;
+
+ @Override
+ public void onCreate(@Nullable Bundle savedInstanceState) {
+ super.onCreate(savedInstanceState);
+
+ setStyle(STYLE_NO_FRAME, R.style.Signal_DayNight_Dialog_FullScreen);
+ }
+
+ @Override
+ public final @NonNull View onCreateView(@NonNull LayoutInflater inflater, @Nullable ViewGroup container, @Nullable Bundle savedInstanceState) {
+ View view = inflater.inflate(R.layout.full_screen_dialog_fragment, container, false);
+ inflater.inflate(getDialogLayoutResource(), view.findViewById(R.id.full_screen_dialog_content), true);
+ toolbar = view.findViewById(R.id.full_screen_dialog_toolbar);
+ toolbar.setTitle(getTitle());
+ toolbar.setNavigationOnClickListener(v -> onNavigateUp());
+ return view;
+ }
+
+ protected void onNavigateUp() {
+ dismissAllowingStateLoss();
+ }
+
+ protected abstract @StringRes int getTitle();
+
+ protected abstract @LayoutRes int getDialogLayoutResource();
+}
diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/GlideBitmapListeningTarget.java b/app/src/main/java/org/thoughtcrime/securesms/components/GlideBitmapListeningTarget.java
new file mode 100644
index 00000000..f68c3ea7
--- /dev/null
+++ b/app/src/main/java/org/thoughtcrime/securesms/components/GlideBitmapListeningTarget.java
@@ -0,0 +1,34 @@
+package org.thoughtcrime.securesms.components;
+
+import android.graphics.Bitmap;
+import android.graphics.drawable.Drawable;
+import android.widget.ImageView;
+
+import androidx.annotation.NonNull;
+import androidx.annotation.Nullable;
+
+import com.bumptech.glide.request.target.BitmapImageViewTarget;
+
+import org.thoughtcrime.securesms.util.concurrent.SettableFuture;
+
+public class GlideBitmapListeningTarget extends BitmapImageViewTarget {
+
+ private final SettableFuture loaded;
+
+ public GlideBitmapListeningTarget(@NonNull ImageView view, @NonNull SettableFuture loaded) {
+ super(view);
+ this.loaded = loaded;
+ }
+
+ @Override
+ protected void setResource(@Nullable Bitmap resource) {
+ super.setResource(resource);
+ loaded.set(true);
+ }
+
+ @Override
+ public void onLoadFailed(@Nullable Drawable errorDrawable) {
+ super.onLoadFailed(errorDrawable);
+ loaded.set(true);
+ }
+}
diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/GlideDrawableListeningTarget.java b/app/src/main/java/org/thoughtcrime/securesms/components/GlideDrawableListeningTarget.java
new file mode 100644
index 00000000..571908b4
--- /dev/null
+++ b/app/src/main/java/org/thoughtcrime/securesms/components/GlideDrawableListeningTarget.java
@@ -0,0 +1,33 @@
+package org.thoughtcrime.securesms.components;
+
+import android.graphics.drawable.Drawable;
+import android.widget.ImageView;
+
+import androidx.annotation.NonNull;
+import androidx.annotation.Nullable;
+
+import com.bumptech.glide.request.target.DrawableImageViewTarget;
+
+import org.thoughtcrime.securesms.util.concurrent.SettableFuture;
+
+public class GlideDrawableListeningTarget extends DrawableImageViewTarget {
+
+ private final SettableFuture loaded;
+
+ public GlideDrawableListeningTarget(@NonNull ImageView view, @NonNull SettableFuture loaded) {
+ super(view);
+ this.loaded = loaded;
+ }
+
+ @Override
+ protected void setResource(@Nullable Drawable resource) {
+ super.setResource(resource);
+ loaded.set(true);
+ }
+
+ @Override
+ public void onLoadFailed(@Nullable Drawable errorDrawable) {
+ super.onLoadFailed(errorDrawable);
+ loaded.set(true);
+ }
+}
diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/HidingLinearLayout.java b/app/src/main/java/org/thoughtcrime/securesms/components/HidingLinearLayout.java
new file mode 100644
index 00000000..92d42e89
--- /dev/null
+++ b/app/src/main/java/org/thoughtcrime/securesms/components/HidingLinearLayout.java
@@ -0,0 +1,79 @@
+package org.thoughtcrime.securesms.components;
+
+import android.annotation.TargetApi;
+import android.content.Context;
+import android.os.Build;
+import android.util.AttributeSet;
+import android.view.animation.AlphaAnimation;
+import android.view.animation.Animation;
+import android.view.animation.AnimationSet;
+import android.view.animation.ScaleAnimation;
+import android.widget.LinearLayout;
+
+import androidx.interpolator.view.animation.FastOutSlowInInterpolator;
+
+public class HidingLinearLayout extends LinearLayout {
+
+ public HidingLinearLayout(Context context) {
+ super(context);
+ }
+
+ public HidingLinearLayout(Context context, AttributeSet attrs) {
+ super(context, attrs);
+ }
+
+ @TargetApi(Build.VERSION_CODES.HONEYCOMB)
+ public HidingLinearLayout(Context context, AttributeSet attrs, int defStyleAttr) {
+ super(context, attrs, defStyleAttr);
+ }
+
+ public void hide() {
+ if (!isEnabled() || getVisibility() == GONE) return;
+
+ AnimationSet animation = new AnimationSet(true);
+ animation.addAnimation(new ScaleAnimation(1, 0.5f, 1, 1, Animation.RELATIVE_TO_SELF, 1f, Animation.RELATIVE_TO_SELF, 0.5f));
+ animation.addAnimation(new AlphaAnimation(1, 0));
+ animation.setDuration(100);
+
+ animation.setAnimationListener(new Animation.AnimationListener() {
+ @Override
+ public void onAnimationStart(Animation animation) {
+ }
+
+ @Override
+ public void onAnimationRepeat(Animation animation) {
+ }
+
+ @Override
+ public void onAnimationEnd(Animation animation) {
+ setVisibility(GONE);
+ }
+ });
+
+ animateWith(animation);
+ }
+
+ public void show() {
+ if (!isEnabled() || getVisibility() == VISIBLE) return;
+
+ setVisibility(VISIBLE);
+
+ AnimationSet animation = new AnimationSet(true);
+ animation.addAnimation(new ScaleAnimation(0.5f, 1, 1, 1, Animation.RELATIVE_TO_SELF, 1f, Animation.RELATIVE_TO_SELF, 0.5f));
+ animation.addAnimation(new AlphaAnimation(0, 1));
+ animation.setDuration(100);
+
+ animateWith(animation);
+ }
+
+ private void animateWith(Animation animation) {
+ animation.setDuration(150);
+ animation.setInterpolator(new FastOutSlowInInterpolator());
+ startAnimation(animation);
+ }
+
+ public void disable() {
+ setVisibility(GONE);
+ setEnabled(false);
+ }
+}
diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/HourglassView.java b/app/src/main/java/org/thoughtcrime/securesms/components/HourglassView.java
new file mode 100644
index 00000000..d5f58766
--- /dev/null
+++ b/app/src/main/java/org/thoughtcrime/securesms/components/HourglassView.java
@@ -0,0 +1,83 @@
+package org.thoughtcrime.securesms.components;
+
+import android.content.Context;
+import android.content.res.TypedArray;
+import android.graphics.Bitmap;
+import android.graphics.BitmapFactory;
+import android.graphics.Canvas;
+import android.graphics.Paint;
+import android.graphics.PorterDuff;
+import android.graphics.PorterDuffColorFilter;
+import android.graphics.PorterDuffXfermode;
+import android.util.AttributeSet;
+import android.view.View;
+
+import org.thoughtcrime.securesms.R;
+
+public class HourglassView extends View {
+
+ private final Paint foregroundPaint;
+ private final Paint backgroundPaint;
+ private final Paint progressPaint;
+
+ private Bitmap empty;
+ private Bitmap full;
+
+ private float percentage;
+ private int offset;
+
+ public HourglassView(Context context) {
+ this(context, null);
+ }
+
+ public HourglassView(Context context, AttributeSet attrs) {
+ this(context, attrs, 0);
+ }
+
+ public HourglassView(Context context, AttributeSet attrs, int defStyleAttr) {
+ super(context, attrs, defStyleAttr);
+
+ int tint = 0;
+
+ if (attrs != null) {
+ TypedArray typedArray = context.getTheme().obtainStyledAttributes(attrs, R.styleable.HourglassView, 0, 0);
+ this.empty = BitmapFactory.decodeResource(getResources(), typedArray.getResourceId(R.styleable.HourglassView_empty, 0));
+ this.full = BitmapFactory.decodeResource(getResources(), typedArray.getResourceId(R.styleable.HourglassView_full, 0));
+ this.percentage = typedArray.getInt(R.styleable.HourglassView_percentage, 50);
+ this.offset = typedArray.getInt(R.styleable.HourglassView_offset, 0);
+ tint = typedArray.getColor(R.styleable.HourglassView_tint, 0);
+ typedArray.recycle();
+ }
+
+ this.backgroundPaint = new Paint();
+ this.foregroundPaint = new Paint();
+ this.progressPaint = new Paint();
+
+ this.backgroundPaint.setColorFilter(new PorterDuffColorFilter(tint, PorterDuff.Mode.MULTIPLY));
+ this.foregroundPaint.setColorFilter(new PorterDuffColorFilter(tint, PorterDuff.Mode.MULTIPLY));
+
+ this.progressPaint.setColor(getResources().getColor(R.color.black));
+ this.progressPaint.setXfermode(new PorterDuffXfermode(PorterDuff.Mode.CLEAR));
+
+ setLayerType(View.LAYER_TYPE_SOFTWARE, null);
+ }
+
+ @Override
+ public void onDraw(Canvas canvas) {
+ float progressHeight = (full.getHeight() - (offset*2)) * (percentage / 100);
+
+ canvas.drawBitmap(full, 0, 0, backgroundPaint);
+ canvas.drawRect(0, 0, full.getWidth(), offset + progressHeight, progressPaint);
+ canvas.drawBitmap(empty, 0, 0, foregroundPaint);
+ }
+
+ public void setPercentage(float percentage) {
+ this.percentage = percentage;
+ invalidate();
+ }
+
+ public void setTint(int tint) {
+ this.backgroundPaint.setColorFilter(new PorterDuffColorFilter(tint, PorterDuff.Mode.MULTIPLY));
+ this.foregroundPaint.setColorFilter(new PorterDuffColorFilter(tint, PorterDuff.Mode.MULTIPLY));
+ }
+}
diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/InputAwareLayout.java b/app/src/main/java/org/thoughtcrime/securesms/components/InputAwareLayout.java
new file mode 100644
index 00000000..142ff5cb
--- /dev/null
+++ b/app/src/main/java/org/thoughtcrime/securesms/components/InputAwareLayout.java
@@ -0,0 +1,94 @@
+package org.thoughtcrime.securesms.components;
+
+import android.content.Context;
+import android.util.AttributeSet;
+import android.widget.EditText;
+
+import androidx.annotation.NonNull;
+import androidx.annotation.Nullable;
+
+import org.thoughtcrime.securesms.components.KeyboardAwareLinearLayout.OnKeyboardShownListener;
+import org.thoughtcrime.securesms.util.ServiceUtil;
+
+public class InputAwareLayout extends KeyboardAwareLinearLayout implements OnKeyboardShownListener {
+ private InputView current;
+
+ public InputAwareLayout(Context context) {
+ this(context, null);
+ }
+
+ public InputAwareLayout(Context context, AttributeSet attrs) {
+ this(context, attrs, 0);
+ }
+
+ public InputAwareLayout(Context context, AttributeSet attrs, int defStyle) {
+ super(context, attrs, defStyle);
+ addOnKeyboardShownListener(this);
+ }
+
+ @Override public void onKeyboardShown() {
+ hideAttachedInput(true);
+ }
+
+ public void show(@NonNull final EditText imeTarget, @NonNull final InputView input) {
+ if (isKeyboardOpen()) {
+ hideSoftkey(imeTarget, new Runnable() {
+ @Override public void run() {
+ hideAttachedInput(true);
+ input.show(getKeyboardHeight(), true);
+ current = input;
+ }
+ });
+ } else {
+ if (current != null) current.hide(true);
+ input.show(getKeyboardHeight(), current != null);
+ current = input;
+ }
+ }
+
+ public InputView getCurrentInput() {
+ return current;
+ }
+
+ public void hideCurrentInput(EditText imeTarget) {
+ if (isKeyboardOpen()) hideSoftkey(imeTarget, null);
+ else hideAttachedInput(false);
+ }
+
+ public void hideAttachedInput(boolean instant) {
+ if (current != null) current.hide(instant);
+ current = null;
+ }
+
+ public boolean isInputOpen() {
+ return (isKeyboardOpen() || (current != null && current.isShowing()));
+ }
+
+ public void showSoftkey(final EditText inputTarget) {
+ postOnKeyboardOpen(new Runnable() {
+ @Override public void run() {
+ hideAttachedInput(true);
+ }
+ });
+ inputTarget.post(new Runnable() {
+ @Override public void run() {
+ inputTarget.requestFocus();
+ ServiceUtil.getInputMethodManager(inputTarget.getContext()).showSoftInput(inputTarget, 0);
+ }
+ });
+ }
+
+ public void hideSoftkey(final EditText inputTarget, @Nullable Runnable runAfterClose) {
+ if (runAfterClose != null) postOnKeyboardClose(runAfterClose);
+
+ ServiceUtil.getInputMethodManager(inputTarget.getContext())
+ .hideSoftInputFromWindow(inputTarget.getWindowToken(), 0);
+ }
+
+ public interface InputView {
+ void show(int height, boolean immediate);
+ void hide(boolean immediate);
+ boolean isShowing();
+ }
+}
+
diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/InputPanel.java b/app/src/main/java/org/thoughtcrime/securesms/components/InputPanel.java
new file mode 100644
index 00000000..b937a98b
--- /dev/null
+++ b/app/src/main/java/org/thoughtcrime/securesms/components/InputPanel.java
@@ -0,0 +1,559 @@
+package org.thoughtcrime.securesms.components;
+
+import android.animation.Animator;
+import android.animation.ValueAnimator;
+import android.content.Context;
+import android.net.Uri;
+import android.text.format.DateUtils;
+import android.util.AttributeSet;
+import android.view.KeyEvent;
+import android.view.View;
+import android.view.ViewGroup;
+import android.view.animation.AlphaAnimation;
+import android.view.animation.Animation;
+import android.view.animation.AnimationSet;
+import android.view.animation.Interpolator;
+import android.view.animation.TranslateAnimation;
+import android.widget.LinearLayout;
+import android.widget.TextView;
+import android.widget.Toast;
+
+import androidx.annotation.DimenRes;
+import androidx.annotation.MainThread;
+import androidx.annotation.NonNull;
+import androidx.annotation.Nullable;
+import androidx.core.view.ViewCompat;
+import androidx.recyclerview.widget.LinearLayoutManager;
+import androidx.recyclerview.widget.RecyclerView;
+
+import org.signal.core.util.logging.Log;
+import org.thoughtcrime.securesms.R;
+import org.thoughtcrime.securesms.animation.AnimationCompleteListener;
+import org.thoughtcrime.securesms.components.emoji.EmojiKeyboardProvider;
+import org.thoughtcrime.securesms.components.emoji.EmojiToggle;
+import org.thoughtcrime.securesms.components.emoji.MediaKeyboard;
+import org.thoughtcrime.securesms.conversation.ConversationStickerSuggestionAdapter;
+import org.thoughtcrime.securesms.database.model.StickerRecord;
+import org.thoughtcrime.securesms.linkpreview.LinkPreview;
+import org.thoughtcrime.securesms.linkpreview.LinkPreviewRepository;
+import org.thoughtcrime.securesms.mms.GlideApp;
+import org.thoughtcrime.securesms.mms.GlideRequests;
+import org.thoughtcrime.securesms.mms.QuoteModel;
+import org.thoughtcrime.securesms.mms.SlideDeck;
+import org.thoughtcrime.securesms.recipients.Recipient;
+import org.thoughtcrime.securesms.util.TextSecurePreferences;
+import org.thoughtcrime.securesms.util.Util;
+import org.thoughtcrime.securesms.util.ViewUtil;
+import org.thoughtcrime.securesms.util.concurrent.AssertedSuccessListener;
+import org.thoughtcrime.securesms.util.concurrent.ListenableFuture;
+import org.thoughtcrime.securesms.util.concurrent.SettableFuture;
+import org.whispersystems.libsignal.util.guava.Optional;
+
+import java.util.List;
+import java.util.concurrent.TimeUnit;
+
+public class InputPanel extends LinearLayout
+ implements MicrophoneRecorderView.Listener,
+ KeyboardAwareLinearLayout.OnKeyboardShownListener,
+ EmojiKeyboardProvider.EmojiEventListener,
+ ConversationStickerSuggestionAdapter.EventListener
+{
+
+ private static final String TAG = InputPanel.class.getSimpleName();
+
+ private static final long QUOTE_REVEAL_DURATION_MILLIS = 150;
+ private static final int FADE_TIME = 150;
+
+ private RecyclerView stickerSuggestion;
+ private QuoteView quoteView;
+ private LinkPreviewView linkPreview;
+ private EmojiToggle mediaKeyboard;
+ private ComposeText composeText;
+ private View quickCameraToggle;
+ private View quickAudioToggle;
+ private View buttonToggle;
+ private View recordingContainer;
+ private View recordLockCancel;
+ private View composeContainer;
+
+ private MicrophoneRecorderView microphoneRecorderView;
+ private SlideToCancel slideToCancel;
+ private RecordTime recordTime;
+ private ValueAnimator quoteAnimator;
+
+ private @Nullable Listener listener;
+ private boolean emojiVisible;
+
+ private ConversationStickerSuggestionAdapter stickerSuggestionAdapter;
+
+ public InputPanel(Context context) {
+ super(context);
+ }
+
+ public InputPanel(Context context, AttributeSet attrs) {
+ super(context, attrs);
+ }
+
+ public InputPanel(Context context, AttributeSet attrs, int defStyleAttr) {
+ super(context, attrs, defStyleAttr);
+ }
+
+ @Override
+ public void onFinishInflate() {
+ super.onFinishInflate();
+
+ View quoteDismiss = findViewById(R.id.quote_dismiss);
+
+ this.composeContainer = findViewById(R.id.compose_bubble);
+ this.stickerSuggestion = findViewById(R.id.input_panel_sticker_suggestion);
+ this.quoteView = findViewById(R.id.quote_view);
+ this.linkPreview = findViewById(R.id.link_preview);
+ this.mediaKeyboard = findViewById(R.id.emoji_toggle);
+ this.composeText = findViewById(R.id.embedded_text_editor);
+ this.quickCameraToggle = findViewById(R.id.quick_camera_toggle);
+ this.quickAudioToggle = findViewById(R.id.quick_audio_toggle);
+ this.buttonToggle = findViewById(R.id.button_toggle);
+ this.recordingContainer = findViewById(R.id.recording_container);
+ this.recordLockCancel = findViewById(R.id.record_cancel);
+ this.slideToCancel = new SlideToCancel(findViewById(R.id.slide_to_cancel));
+ this.microphoneRecorderView = findViewById(R.id.recorder_view);
+ this.microphoneRecorderView.setListener(this);
+ this.recordTime = new RecordTime(findViewById(R.id.record_time),
+ findViewById(R.id.microphone),
+ TimeUnit.HOURS.toSeconds(1),
+ () -> microphoneRecorderView.cancelAction());
+
+ this.recordLockCancel.setOnClickListener(v -> microphoneRecorderView.cancelAction());
+
+ if (TextSecurePreferences.isSystemEmojiPreferred(getContext())) {
+ mediaKeyboard.setVisibility(View.GONE);
+ emojiVisible = false;
+ } else {
+ mediaKeyboard.setVisibility(View.VISIBLE);
+ emojiVisible = true;
+ }
+
+ quoteDismiss.setOnClickListener(v -> clearQuote());
+
+ linkPreview.setCloseClickedListener(() -> {
+ if (listener != null) {
+ listener.onLinkPreviewCanceled();
+ }
+ });
+
+ stickerSuggestionAdapter = new ConversationStickerSuggestionAdapter(GlideApp.with(this), this);
+
+ stickerSuggestion.setLayoutManager(new LinearLayoutManager(getContext(), LinearLayoutManager.HORIZONTAL, false));
+ stickerSuggestion.setAdapter(stickerSuggestionAdapter);
+ }
+
+ public void setListener(final @NonNull Listener listener) {
+ this.listener = listener;
+
+ mediaKeyboard.setOnClickListener(v -> listener.onEmojiToggle());
+ }
+
+ public void setMediaListener(@NonNull MediaListener listener) {
+ composeText.setMediaListener(listener);
+ }
+
+ public void setQuote(@NonNull GlideRequests glideRequests,
+ long id,
+ @NonNull Recipient author,
+ @NonNull CharSequence body,
+ @NonNull SlideDeck attachments)
+ {
+ this.quoteView.setQuote(glideRequests, id, author, body, false, attachments);
+
+ int originalHeight = this.quoteView.getVisibility() == VISIBLE ? this.quoteView.getMeasuredHeight()
+ : 0;
+
+ this.quoteView.setVisibility(VISIBLE);
+ this.quoteView.measure(0, 0);
+
+ if (quoteAnimator != null) {
+ quoteAnimator.cancel();
+ }
+
+ quoteAnimator = createHeightAnimator(quoteView, originalHeight, this.quoteView.getMeasuredHeight(), null);
+
+ quoteAnimator.start();
+
+ if (this.linkPreview.getVisibility() == View.VISIBLE) {
+ int cornerRadius = readDimen(R.dimen.message_corner_collapse_radius);
+ this.linkPreview.setCorners(cornerRadius, cornerRadius);
+ }
+ }
+
+ public void clearQuote() {
+ if (quoteAnimator != null) {
+ quoteAnimator.cancel();
+ }
+
+ quoteAnimator = createHeightAnimator(quoteView, quoteView.getMeasuredHeight(), 0, new AnimationCompleteListener() {
+ @Override
+ public void onAnimationEnd(Animator animation) {
+ quoteView.dismiss();
+
+ if (linkPreview.getVisibility() == View.VISIBLE) {
+ int cornerRadius = readDimen(R.dimen.message_corner_radius);
+ linkPreview.setCorners(cornerRadius, cornerRadius);
+ }
+ }
+ });
+
+ quoteAnimator.start();
+ }
+
+ private static ValueAnimator createHeightAnimator(@NonNull View view,
+ int originalHeight,
+ int finalHeight,
+ @Nullable AnimationCompleteListener onAnimationComplete)
+ {
+ ValueAnimator animator = ValueAnimator.ofInt(originalHeight, finalHeight)
+ .setDuration(QUOTE_REVEAL_DURATION_MILLIS);
+
+ animator.addUpdateListener(animation -> {
+ ViewGroup.LayoutParams params = view.getLayoutParams();
+ params.height = (int) animation.getAnimatedValue();
+ view.setLayoutParams(params);
+ });
+
+ if (onAnimationComplete != null) {
+ animator.addListener(onAnimationComplete);
+ }
+
+ return animator;
+ }
+
+ public Optional getQuote() {
+ if (quoteView.getQuoteId() > 0 && quoteView.getVisibility() == View.VISIBLE) {
+ return Optional.of(new QuoteModel(quoteView.getQuoteId(), quoteView.getAuthor().getId(), quoteView.getBody().toString(), false, quoteView.getAttachments(), quoteView.getMentions()));
+ } else {
+ return Optional.absent();
+ }
+ }
+
+ public void setLinkPreviewLoading() {
+ this.linkPreview.setVisibility(View.VISIBLE);
+ this.linkPreview.setLoading();
+ }
+
+ public void setLinkPreviewNoPreview(@Nullable LinkPreviewRepository.Error customError) {
+ this.linkPreview.setVisibility(View.VISIBLE);
+ this.linkPreview.setNoPreview(customError);
+ }
+
+ public void setLinkPreview(@NonNull GlideRequests glideRequests, @NonNull Optional preview) {
+ if (preview.isPresent()) {
+ this.linkPreview.setVisibility(View.VISIBLE);
+ this.linkPreview.setLinkPreview(glideRequests, preview.get(), true);
+ } else {
+ this.linkPreview.setVisibility(View.GONE);
+ }
+
+ int cornerRadius = quoteView.getVisibility() == VISIBLE ? readDimen(R.dimen.message_corner_collapse_radius)
+ : readDimen(R.dimen.message_corner_radius);
+
+ this.linkPreview.setCorners(cornerRadius, cornerRadius);
+ }
+
+ public void clickOnComposeInput() {
+ composeText.performClick();
+ }
+
+ public void setMediaKeyboard(@NonNull MediaKeyboard mediaKeyboard) {
+ this.mediaKeyboard.attach(mediaKeyboard);
+ }
+
+ public void setStickerSuggestions(@NonNull List stickers) {
+ stickerSuggestion.setVisibility(stickers.isEmpty() ? View.GONE : View.VISIBLE);
+ stickerSuggestionAdapter.setStickers(stickers);
+ }
+
+ public void showMediaKeyboardToggle(boolean show) {
+ emojiVisible = show;
+ mediaKeyboard.setVisibility(show ? View.VISIBLE : GONE);
+ }
+
+ public void setMediaKeyboardToggleMode(boolean isSticker) {
+ mediaKeyboard.setStickerMode(isSticker);
+ }
+
+ public boolean isStickerMode() {
+ return mediaKeyboard.isStickerMode();
+ }
+
+ public View getMediaKeyboardToggleAnchorView() {
+ return mediaKeyboard;
+ }
+
+ public void setWallpaperEnabled(boolean enabled) {
+ if (enabled) {
+ setBackgroundColor(getContext().getResources().getColor(R.color.wallpaper_compose_background));
+ composeContainer.setBackgroundResource(R.drawable.compose_background_wallpaper);
+ } else {
+ setBackgroundColor(getResources().getColor(R.color.signal_background_primary));
+ composeContainer.setBackgroundResource(R.drawable.compose_background);
+ }
+ }
+
+ @Override
+ public void onRecordPermissionRequired() {
+ if (listener != null) listener.onRecorderPermissionRequired();
+ }
+
+ @Override
+ public void onRecordPressed() {
+ if (listener != null) listener.onRecorderStarted();
+ recordTime.display();
+ slideToCancel.display();
+
+ if (emojiVisible) ViewUtil.fadeOut(mediaKeyboard, FADE_TIME, View.INVISIBLE);
+ ViewUtil.fadeOut(composeText, FADE_TIME, View.INVISIBLE);
+ ViewUtil.fadeOut(quickCameraToggle, FADE_TIME, View.INVISIBLE);
+ ViewUtil.fadeOut(quickAudioToggle, FADE_TIME, View.INVISIBLE);
+ buttonToggle.animate().alpha(0).setDuration(FADE_TIME).start();
+ }
+
+ @Override
+ public void onRecordReleased() {
+ long elapsedTime = onRecordHideEvent();
+
+ if (listener != null) {
+ Log.d(TAG, "Elapsed time: " + elapsedTime);
+ if (elapsedTime > 1000) {
+ listener.onRecorderFinished();
+ } else {
+ Toast.makeText(getContext(), R.string.InputPanel_tap_and_hold_to_record_a_voice_message_release_to_send, Toast.LENGTH_LONG).show();
+ listener.onRecorderCanceled();
+ }
+ }
+ }
+
+ @Override
+ public void onRecordMoved(float offsetX, float absoluteX) {
+ slideToCancel.moveTo(offsetX);
+
+ float position = absoluteX / recordingContainer.getWidth();
+
+ if (ViewUtil.isLtr(this) && position <= 0.5 ||
+ ViewUtil.isRtl(this) && position >= 0.6)
+ {
+ this.microphoneRecorderView.cancelAction();
+ }
+ }
+
+ @Override
+ public void onRecordCanceled() {
+ onRecordHideEvent();
+ if (listener != null) listener.onRecorderCanceled();
+ }
+
+ @Override
+ public void onRecordLocked() {
+ slideToCancel.hide();
+ recordLockCancel.setVisibility(View.VISIBLE);
+ buttonToggle.animate().alpha(1).setDuration(FADE_TIME).start();
+ if (listener != null) listener.onRecorderLocked();
+ }
+
+ public void onPause() {
+ this.microphoneRecorderView.cancelAction();
+ }
+
+ public void setEnabled(boolean enabled) {
+ composeText.setEnabled(enabled);
+ mediaKeyboard.setEnabled(enabled);
+ quickAudioToggle.setEnabled(enabled);
+ quickCameraToggle.setEnabled(enabled);
+ }
+
+ private long onRecordHideEvent() {
+ recordLockCancel.setVisibility(View.GONE);
+
+ ListenableFuture future = slideToCancel.hide();
+ long elapsedTime = recordTime.hide();
+
+ future.addListener(new AssertedSuccessListener