Browse Source

Added flickable layout in PictureViewerActivity using Flick and GesturedImageView and changed the activity_picture_viewer design.
Fixed file message width to allow space for the pin button.
Fixed undo button elevation and changes reply swipe color in MessageListAdapter.

Signed-off-by: Sofian Benissa <sofian.benissa@mubs.edu.lb>

Sofian Benissa 2 years ago
parent
commit
ca619fad22

+ 1 - 0
.gitignore

@@ -10,6 +10,7 @@
 /.idea/assetWizardSettings.xml
 .DS_Store
 /build
+/release
 /captures
 .externalNativeBuild
 .cxx

+ 3 - 0
.idea/misc.xml

@@ -51,4 +51,7 @@
   <component name="ProjectType">
     <option name="id" value="Android" />
   </component>
+  <component name="SuppressKotlinCodeStyleNotification">
+    <option name="disableForAll" value="true" />
+  </component>
 </project>

+ 2 - 1
app/build.gradle

@@ -66,10 +66,11 @@ dependencies {
     implementation 'me.dm7.barcodescanner:zxing:1.9.13'
     implementation 'com.google.protobuf:protobuf-java:3.9.2' //later versions min api 26+
 
-
     implementation 'net.zetetic:android-database-sqlcipher:4.4.0@aar'
     implementation "androidx.sqlite:sqlite:2.1.0"
 
+    implementation 'com.alexvasilkov:gesture-views:2.8.2'
+
 //    debugImplementation 'com.squareup.leakcanary:leakcanary-android:2.4'
 
     testImplementation 'junit:junit:4.13.1'

BIN
app/release/app-release.aab


BIN
app/release/app-release.apk


+ 0 - 18
app/release/output-metadata.json

@@ -1,18 +0,0 @@
-{
-  "version": 2,
-  "artifactType": {
-    "type": "APK",
-    "kind": "Directory"
-  },
-  "applicationId": "com.dx.anonymousmessenger",
-  "variantName": "release",
-  "elements": [
-    {
-      "type": "SINGLE",
-      "filters": [],
-      "versionCode": 27,
-      "versionName": "0.7.5",
-      "outputFile": "app-release.apk"
-    }
-  ]
-}

+ 25 - 11
app/src/main/AndroidManifest.xml

@@ -3,9 +3,17 @@
     xmlns:tools="http://schemas.android.com/tools"
     package="com.dx.anonymousmessenger">
 
+    <uses-permission android:name="android.permission.INTERNET" />
+
+    <uses-feature
+        android:name="android.hardware.touchscreen"
+        android:required="false" />
+<!--    <uses-feature-->
+<!--        android:name="android.software.leanback"-->
+<!--        android:required="true" />-->
+
     <uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
     <uses-permission android:name="android.permission.CAMERA" />
-    <uses-permission android:name="android.permission.INTERNET" />
     <uses-permission android:name="android.permission.RECEIVE_BOOT_COMPLETED" />
     <uses-permission android:name="android.permission.REQUEST_IGNORE_BATTERY_OPTIMIZATIONS" />
     <uses-permission android:name="android.permission.RECORD_AUDIO" />
@@ -31,23 +39,28 @@
         android:supportsRtl="true"
         android:theme="@style/AppTheme"
         tools:replace="android:allowBackup">
-        <activity android:name=".ui.view.log.LogActivity"
-            android:configChanges="orientation|keyboardHidden|screenSize|navigation"/>
+        <activity
+            android:name=".ui.view.log.LogActivity"
+            android:configChanges="orientation|keyboardHidden|screenSize|navigation" />
         <activity android:name=".ui.view.single_activity.SimpleScannerActivity" />
-        <activity android:name=".ui.view.notepad.NotepadActivity"
-            android:configChanges="orientation|keyboardHidden|screenSize|navigation"/>
+        <activity
+            android:name=".ui.view.notepad.NotepadActivity"
+            android:configChanges="orientation|keyboardHidden|screenSize|navigation" />
         <activity android:name=".ui.view.single_activity.FileViewerActivity" />
         <activity android:name=".ui.view.tips.TipsActivity" />
         <activity android:name=".ui.view.single_activity.AboutActivity" />
         <activity android:name=".ui.view.single_activity.ContactProfileActivity" />
         <activity android:name=".ui.view.single_activity.LicenseActivity" />
         <activity android:name=".ui.view.single_activity.MyProfileActivity" />
-        <activity android:name=".ui.view.single_activity.PictureViewerActivity"
-            android:configChanges="orientation|keyboardHidden|screenSize|navigation"/>
+        <activity
+            android:name=".ui.view.single_activity.PictureViewerActivity"
+            android:configChanges="orientation|keyboardHidden|screenSize|navigation"
+            android:theme="@style/AppTheme.TransparentFullscreen" />
         <activity android:name=".ui.view.call.RingingActivity" />
         <activity android:name=".ui.view.call.CallActivity" />
-        <activity android:name=".ui.view.single_activity.AddContactActivity"
-            android:configChanges="orientation|keyboardHidden|screenSize|navigation"/>
+        <activity
+            android:name=".ui.view.single_activity.AddContactActivity"
+            android:configChanges="orientation|keyboardHidden|screenSize|navigation" />
         <activity android:name=".ui.view.single_activity.VerifyIdentityActivity" />
         <activity android:name=".ui.view.single_activity.MyIdentityActivity" />
         <activity android:name=".ui.view.setup.SetupInProcess" />
@@ -55,8 +68,9 @@
             android:name=".ui.view.message_list.MessageListActivity"
             android:configChanges="orientation|keyboardHidden|screenSize|navigation" />
         <activity android:name=".ui.view.app.AppActivity" />
-        <activity android:name=".ui.view.setup.CreateUserActivity"
-            android:configChanges="orientation|keyboardHidden|screenSize|navigation"/>
+        <activity
+            android:name=".ui.view.setup.CreateUserActivity"
+            android:configChanges="orientation|keyboardHidden|screenSize|navigation" />
         <activity android:name=".ui.view.MainActivity">
             <intent-filter>
                 <action android:name="android.intent.action.MAIN" />

+ 6 - 2
app/src/main/java/com/dx/anonymousmessenger/DxApplication.java

@@ -570,6 +570,10 @@ public class DxApplication extends Application {
     }
 
     public void sendNotification(String title, String msg, boolean isMessage){
+        sendNotification(title, msg, isMessage, R.mipmap.ic_launcher_foreground);
+    }
+
+    public void sendNotification(String title, String msg, boolean isMessage, int icon){
         if(isMessage){
             sendNotification(title,msg);
             return;
@@ -582,7 +586,7 @@ public class DxApplication extends Application {
         Notification notification;
         if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
             notification = new NotificationCompat.Builder(this,CHANNEL_ID)
-                    .setSmallIcon(R.mipmap.ic_launcher_foreground)
+                    .setSmallIcon(icon)
                     .setContentTitle(title)
                     .setContentText(msg)
                     .setContentIntent(resultPendingIntent)
@@ -590,7 +594,7 @@ public class DxApplication extends Application {
                     .setChannelId(CHANNEL_ID).build();
         }else{
             notification = new Notification.Builder(this)
-                    .setSmallIcon(R.mipmap.ic_launcher_foreground)
+                    .setSmallIcon(icon)
                     .setContentTitle(title)
                     .setContentText(msg)
                     .setContentIntent(resultPendingIntent)

+ 1 - 1
app/src/main/java/com/dx/anonymousmessenger/service/BootReminderService.java

@@ -19,7 +19,7 @@ public class BootReminderService extends Service {
         try{
             File databaseFile = new File(getFilesDir(), "demo.db");
             if(databaseFile.exists()){
-                app.sendNotification(getString(R.string.decrypt_reminder_title),getString(R.string.decrypt_reminder_message),false);
+                app.sendNotification(getString(R.string.decrypt_reminder_title),getString(R.string.decrypt_reminder_message),false, R.drawable.ic_baseline_lock_24);
             }
         }catch (Exception ignored) {}
         super.onCreate();

+ 48 - 0
app/src/main/java/com/dx/anonymousmessenger/ui/custom/FlickDismissLayout.java

@@ -0,0 +1,48 @@
+package com.dx.anonymousmessenger.ui.custom;
+
+import android.annotation.SuppressLint;
+import android.content.Context;
+import android.util.AttributeSet;
+import android.view.MotionEvent;
+import android.widget.FrameLayout;
+
+/**
+ * A ViewGroup that can be dismissed by flicking it in any direction.
+ */
+public class FlickDismissLayout extends FrameLayout {
+
+    private FlickGestureListener flickGestureListener;
+
+    public FlickDismissLayout(Context context, AttributeSet attrs) {
+        super(context, attrs);
+    }
+
+    /**
+     * @return Return true to steal motion events from the children and have them dispatched to this ViewGroup
+     * through onTouchEvent(). The current target will receive an ACTION_CANCEL event, and no further messages
+     * will be delivered here.
+     */
+    @Override
+    public boolean onInterceptTouchEvent(MotionEvent ev) {
+        boolean intercepted = flickGestureListener.onTouch(this, ev);
+        return intercepted || super.onInterceptTouchEvent(ev);
+    }
+
+    @Override
+    @SuppressLint("ClickableViewAccessibility")
+    public boolean onTouchEvent(MotionEvent event) {
+        flickGestureListener.onTouch(this, event);
+        // Defaulting to true to avoid letting
+        // parent ViewGroup receive any touch events.
+        return true;
+    }
+
+    @Override
+    public void requestDisallowInterceptTouchEvent(boolean disallowIntercept) {
+        super.requestDisallowInterceptTouchEvent(disallowIntercept);
+    }
+
+    public void setFlickGestureListener(FlickGestureListener flickGestureListener) {
+        this.flickGestureListener = flickGestureListener;
+    }
+}

+ 278 - 0
app/src/main/java/com/dx/anonymousmessenger/ui/custom/FlickGestureListener.java

@@ -0,0 +1,278 @@
+package com.dx.anonymousmessenger.ui.custom;
+
+import android.annotation.SuppressLint;
+import android.view.MotionEvent;
+import android.view.VelocityTracker;
+import android.view.View;
+import android.view.ViewConfiguration;
+import android.view.animation.Interpolator;
+
+import androidx.annotation.FloatRange;
+import androidx.interpolator.view.animation.FastOutSlowInInterpolator;
+
+/**
+ * Listeners for a flick gesture and also moves around the View with user's finger.
+ */
+public class FlickGestureListener implements View.OnTouchListener {
+
+    private static final Interpolator ANIM_INTERPOLATOR = new FastOutSlowInInterpolator();
+    private static final boolean ROTATION_ENABLED = true;
+    public static final float DEFAULT_FLICK_THRESHOLD = 0.3f;
+
+    @FloatRange(from = 0, to = 1) private float flickThresholdSlop;
+
+    private final int touchSlop;                // Min. distance to move before registering a gesture.
+    private final int maximumFlingVelocity;     // Px per second.
+    private GestureCallbacks gestureCallbacks;
+    private float downX, downY;
+    private float lastTouchX;
+    private float lastTouchY;
+    private int lastAction = -1;
+    private boolean touchStartedOnLeftSide;
+    private VelocityTracker velocityTracker;
+    private boolean verticalScrollRegistered;
+    private boolean gestureCanceledUntilNextTouchDown;
+    private OnGestureIntercepter onGestureIntercepter;
+    private ContentHeightProvider contentHeightProvider;
+    private boolean gestureInterceptedUntilNextTouchDown;
+
+    public interface OnGestureIntercepter {
+        /**
+         * Called once everytime a scroll gesture is registered. When this returns true, gesture detection is
+         * skipped until the next touch-down is registered.
+         *
+         * @return True to intercept the gesture, false otherwise to let it go.
+         */
+        boolean shouldIntercept(float deltaY);
+    }
+
+    public interface ContentHeightProvider {
+        /**
+         * Height of the media content multiplied by its zoomed in ratio. Only used for animating the content out
+         * of the window when a flick is registered.
+         */
+        int getContentHeightForDismissAnimation();
+
+        /**
+         * Used for calculating if the content can be dismissed on finger up.
+         */
+        int getContentHeightForCalculatingThreshold();
+    }
+
+    public interface GestureCallbacks {
+        /**
+         * Called when the View has been flicked and the Activity should be dismissed.
+         *
+         * @param flickAnimationDuration Time the Activity should wait to finish for the flick animation to complete.
+         */
+        void onFlickDismissEnd(long flickAnimationDuration);
+
+        /**
+         * Called while this View is being moved around.
+         *
+         * @param moveRatio Distance moved (from the View's original position) as a ratio of the View's height.
+         */
+        void onMoveMedia(@FloatRange(from = -1, to = 1) float moveRatio);
+    }
+
+    public FlickGestureListener(ViewConfiguration viewConfiguration) {
+        touchSlop = viewConfiguration.getScaledTouchSlop();
+        maximumFlingVelocity = viewConfiguration.getScaledMaximumFlingVelocity();
+
+        // Default gesture intercepter: don't intercept anything.
+        onGestureIntercepter = o -> false;
+    }
+
+    public void setOnGestureIntercepter(OnGestureIntercepter intercepter) {
+        onGestureIntercepter = intercepter;
+    }
+
+    /**
+     * Set minimum distance the user's finger should move (in percentage of the View's dimensions) after which a flick
+     * can be registered.
+     */
+    public void setFlickThresholdSlop(@FloatRange(from = 0, to = 1) float flickThresholdSlop) {
+        this.flickThresholdSlop = flickThresholdSlop;
+    }
+
+    public void setGestureCallbacks(GestureCallbacks gestureCallbacks) {
+        this.gestureCallbacks = gestureCallbacks;
+    }
+
+    public void setContentHeightProvider(ContentHeightProvider contentHeightProvider) {
+        this.contentHeightProvider = contentHeightProvider;
+    }
+
+    @Override
+    @SuppressLint("ClickableViewAccessibility")
+    public boolean onTouch(View view, MotionEvent event) {
+        float touchX = event.getRawX();
+        float touchY = event.getRawY();
+
+        float distanceX = touchX - downX;
+        float distanceY = touchY - downY;
+        float distanceXAbs = Math.abs(distanceX);
+        float distanceYAbs = Math.abs(distanceY);
+        float deltaX = touchX - lastTouchX;
+        float deltaY = touchY - lastTouchY;
+
+        // Since both intercept() and touch() call this listener, we get duplicate ACTION_DOWNs.
+        if (touchX == lastTouchX && touchY == lastTouchY && lastAction == event.getAction()) {
+            // Clear VelocityTracker if this condition is removed.
+            return false;
+        }
+
+        lastTouchX = touchX;
+        lastTouchY = touchY;
+        lastAction = event.getAction();
+
+        switch (event.getAction()) {
+            case MotionEvent.ACTION_DOWN:
+                downX = touchX;
+                downY = touchY;
+                touchStartedOnLeftSide = touchX < view.getWidth() / 2;
+                if (velocityTracker == null) {
+                    velocityTracker = VelocityTracker.obtain();
+                }
+                //else {
+                // This is required because ACTION_DOWN is received twice.
+                //velocityTracker.clear();
+                //}
+                velocityTracker.addMovement(event);
+                return false;
+
+            case MotionEvent.ACTION_CANCEL:
+            case MotionEvent.ACTION_UP:
+                if (verticalScrollRegistered) {
+                    boolean flickRegistered = hasFingerMovedEnoughToFlick(distanceYAbs);
+                    boolean wasSwipedDownwards = distanceY > 0;
+
+                    if (flickRegistered) {
+                        animateViewFlick(view, wasSwipedDownwards);
+
+                    } else {
+                        // Figure out if the View was fling'd and if the velocity + swiped distance is enough to dismiss this View.
+                        velocityTracker.computeCurrentVelocity(1_000 /* px per second */);
+                        float yVelocityAbs = Math.abs(velocityTracker.getYVelocity());
+                        int requiredYVelocity = view.getHeight() * 6 / 10;
+                        int minSwipeDistanceForFling = view.getHeight() / 10;
+
+                        if (yVelocityAbs > requiredYVelocity && yVelocityAbs < maximumFlingVelocity && distanceYAbs >= minSwipeDistanceForFling) {
+                            // Fling detected!
+                            animateViewFlick(view, wasSwipedDownwards, 100);
+
+                        } else {
+                            // Distance moved wasn't enough to dismiss.
+                            animateViewBackToPosition(view);
+                        }
+                    }
+                }
+
+                velocityTracker.recycle();
+                velocityTracker = null;
+                verticalScrollRegistered = false;
+                gestureInterceptedUntilNextTouchDown = false;
+                gestureCanceledUntilNextTouchDown = false;
+                return false;
+
+            case MotionEvent.ACTION_MOVE:
+                if (gestureInterceptedUntilNextTouchDown || gestureCanceledUntilNextTouchDown) {
+                    return false;
+                }
+
+                // The listener only gets once chance to block the flick -- only if it's not already being moved.
+                if (!verticalScrollRegistered && onGestureIntercepter != null && onGestureIntercepter.shouldIntercept(deltaY)) {
+                    gestureInterceptedUntilNextTouchDown = true;
+                    return false;
+                }
+
+                boolean isScrollingVertically = distanceYAbs > touchSlop && distanceYAbs > distanceXAbs;
+                boolean isScrollingHorizontally = distanceXAbs > touchSlop && distanceYAbs < distanceXAbs;
+
+                // Avoid reading the gesture if the user is scrolling the horizontal list.
+                if (!verticalScrollRegistered && isScrollingHorizontally) {
+                    gestureCanceledUntilNextTouchDown = true;
+                    return false;
+                }
+
+                if (verticalScrollRegistered || isScrollingVertically) {
+                    verticalScrollRegistered = true;
+
+                    view.setTranslationX(view.getTranslationX() + deltaX);
+                    view.setTranslationY(view.getTranslationY() + deltaY);
+
+                    view.getParent().requestDisallowInterceptTouchEvent(true);
+
+                    // Rotate the card because we naturally make a swipe gesture in a circular path while holding our phones.
+                    if (ROTATION_ENABLED) {
+                        float moveRatioDelta = deltaY / view.getHeight();
+                        view.setPivotY(0);
+                        view.setRotation(view.getRotation() + moveRatioDelta * 20 * (touchStartedOnLeftSide ? -1 : 1));
+                    }
+
+                    // Send callback so that the background dim can be faded in/out.
+                    dispatchOnPhotoMoveCallback(view);
+
+                    // Track the velocity so that we can later figure out if this View was fling'd (instead of dragged).
+                    velocityTracker.addMovement(event);
+                    return true;
+
+                } else {
+                    return false;
+                }
+
+            default:
+                return false;
+        }
+    }
+
+    private void dispatchOnPhotoMoveCallback(View view) {
+        float moveRatio = view.getTranslationY() / view.getHeight();
+        gestureCallbacks.onMoveMedia(moveRatio);
+    }
+
+    private void animateViewBackToPosition(View view) {
+        view.animate().cancel();
+        view.animate()
+                .translationX(0f)
+                .translationY(0f)
+                .rotation(0f)
+                .setDuration(200)
+                .setUpdateListener(animation -> dispatchOnPhotoMoveCallback(view))
+                .setInterpolator(ANIM_INTERPOLATOR)
+                .start();
+    }
+
+    private void animateViewFlick(View view, boolean downwards) {
+        animateViewFlick(view, downwards, 200);
+    }
+
+    @SuppressWarnings("ConstantConditions")
+    private void animateViewFlick(View view, boolean downwards, long flickAnimDuration) {
+        if (view.getPivotY() != 0f) {
+            throw new AssertionError("Formula used for calculating distance rotated only works if the pivot is at (x,0");
+        }
+
+        float rotationAngle = view.getRotation();
+        int distanceRotated = (int) Math.ceil(Math.abs(Math.sin(Math.toRadians(rotationAngle)) * view.getWidth() / 2));
+        int throwDistance = distanceRotated + Math.max(contentHeightProvider.getContentHeightForDismissAnimation(), view.getRootView().getHeight());
+
+        view.animate().cancel();
+        view.animate()
+                .translationY(downwards ? throwDistance : -throwDistance)
+                .withStartAction(() -> gestureCallbacks.onFlickDismissEnd(flickAnimDuration))
+                .setDuration(flickAnimDuration)
+                .setInterpolator(ANIM_INTERPOLATOR)
+                .setUpdateListener(animation -> dispatchOnPhotoMoveCallback(view))
+                .start();
+    }
+
+    private boolean hasFingerMovedEnoughToFlick(float distanceYAbs) {
+        //Timber.d("hasFingerMovedEnoughToFlick()");
+        //Timber.i("Content h: %s", contentHeightProvider.getContentHeightForCalculatingThreshold());
+        //Timber.i("flickThresholdSlop: %s", flickThresholdSlop);
+        //Timber.i("distanceYAbs: %s", distanceYAbs);
+        float thresholdDistanceY = contentHeightProvider.getContentHeightForCalculatingThreshold() * flickThresholdSlop;
+        return distanceYAbs > thresholdDistanceY;
+    }
+}

+ 247 - 0
app/src/main/java/com/dx/anonymousmessenger/ui/custom/ZoomableGestureImageView.java

@@ -0,0 +1,247 @@
+package com.dx.anonymousmessenger.ui.custom;
+
+import android.annotation.SuppressLint;
+import android.content.Context;
+import android.graphics.Canvas;
+import android.graphics.RectF;
+import android.util.AttributeSet;
+import android.view.GestureDetector;
+import android.view.MotionEvent;
+
+import androidx.annotation.NonNull;
+
+import com.alexvasilkov.gestures.GestureController;
+import com.alexvasilkov.gestures.GestureControllerForPager;
+import com.alexvasilkov.gestures.Settings;
+import com.alexvasilkov.gestures.State;
+import com.alexvasilkov.gestures.views.GestureImageView;
+
+import java.util.HashMap;
+import java.util.Map;
+import java.util.Objects;
+
+/**
+ * This wrapper exists so that we can easily change libraries in the future. It has happened once so far
+ * and can happen again.
+ * <p>
+ * Does not support a foreground ripple, because it intercepts all touch events for handling scale and pan.
+ */
+@SuppressWarnings("MismatchedQueryAndUpdateOfCollection")
+public class ZoomableGestureImageView extends GestureImageView implements ZoomableImageView {
+
+    private static final float MAX_OVER_ZOOM = 4f;
+    private static final float MIN_OVER_ZOOM = 1f;
+
+    private final RectF IMAGE_MOVEMENT_RECT = new RectF();
+    private final Map<OnPanChangeListener, GestureController.OnStateChangeListener> onPanChangeListeners = new HashMap<>(2);
+    private final Map<OnZoomChangeListener, GestureController.OnStateChangeListener> onZoomChangeListeners = new HashMap<>(2);
+    private final GestureDetector gestureDetector;
+    private OnImageTooLargeExceptionListener imageTooLargeExceptionListener;
+
+    public ZoomableGestureImageView(Context context, AttributeSet attrs) {
+        super(context, attrs);
+
+        getController().getSettings().setOverzoomFactor(MAX_OVER_ZOOM);
+        getController().getSettings().setFillViewport(true);
+        getController().getSettings().setFitMethod(Settings.Fit.HORIZONTAL);
+
+        getController().setOnGesturesListener(new GestureController.SimpleOnGestureListener() {
+            @Override
+            public boolean onSingleTapConfirmed(MotionEvent event) {
+                performClick();
+                return true;
+            }
+
+            @Override
+            public void onUpOrCancel(MotionEvent event) {
+                // Bug workaround: Image zoom stops working after first overzoom. Resetting it when the
+                // finger is lifted seems to solve the problem.
+                getController().getSettings().setOverzoomFactor(MAX_OVER_ZOOM);
+            }
+        });
+
+        // Bug workarounds: GestureImageView doesn't request parent ViewGroups to stop intercepting touch
+        // events when it starts consuming them to zoom.
+        gestureDetector = new GestureDetector(context, new GestureDetector.SimpleOnGestureListener() {
+            @Override
+            public boolean onDoubleTapEvent(MotionEvent e) {
+                getParent().requestDisallowInterceptTouchEvent(true);
+                return super.onDoubleTapEvent(e);
+            }
+        });
+    }
+
+    @Override
+    public void draw(@NonNull Canvas canvas) {
+        try {
+            super.draw(canvas);
+        } catch (RuntimeException e) {
+            if (Objects.requireNonNull(e.getMessage()).contains("trying to draw too large")) {
+                imageTooLargeExceptionListener.onImageTooLargeException(e);
+            } else {
+                throw e;
+            }
+        }
+    }
+
+    @Override
+    public void setGravity(int gravity) {
+        getController().getSettings().setGravity(gravity);
+    }
+
+    /**
+     * Calculate height of the image that is currently visible.
+     */
+    @Override
+    public float getVisibleZoomedImageHeight() {
+        float zoomedImageHeight = getZoomedImageHeight();
+
+        // Subtract the portion that has gone outside limits due to zooming in, because they are longer visible.
+        float heightNotVisible = getController().getState().getY();
+        if (heightNotVisible < 0) {
+            zoomedImageHeight += heightNotVisible;
+        }
+
+        if (zoomedImageHeight > getHeight()) {
+            zoomedImageHeight = getHeight();
+        }
+
+        return zoomedImageHeight;
+    }
+
+    @Override
+    public float getZoomedImageHeight() {
+        return (float) getController().getSettings().getImageH() * getZoom();
+    }
+
+    @Override
+    public float getZoom() {
+        return getController().getState().getZoom();
+    }
+
+    /**
+     * Whether the image can be panned anymore vertically, upwards or downwards depending upon <var>downwardPan</var>.
+     * downwardPan == upwards scroll.
+     */
+    @Override
+    public boolean canPanFurtherVertically(boolean downwardPan) {
+        State state = getController().getState();
+        getController().getStateController().getMovementArea(state, IMAGE_MOVEMENT_RECT);
+
+        return (!downwardPan && State.compare(state.getY(), IMAGE_MOVEMENT_RECT.bottom) < 0f)
+                || (downwardPan && State.compare(state.getY(), IMAGE_MOVEMENT_RECT.top) > 0f);
+    }
+
+    @Override
+    public boolean canPanAnyFurtherHorizontally(int deltaX) {
+        float minZoom = getController().getStateController().getMinZoom(getController().getState());
+        float zoom = getController().getState().getZoom();
+
+        // if zoom factor == min zoom factor => just let the view pager handle the scroll
+        float eps = 0.001f;
+        if (Math.abs(minZoom - zoom) < eps) {
+            return false;
+        }
+
+        getController().getStateController().getMovementArea(getController().getState(), IMAGE_MOVEMENT_RECT);
+
+        float stateX = getController().getState().getX();
+        float width = IMAGE_MOVEMENT_RECT.width();
+
+        // If user reached left edge && is swiping left => let the view pager handle the scroll
+        if (Math.abs(stateX) < eps && deltaX > 0) {
+            return false;
+        }
+
+        // If user reached right edge && is swiping right => let the view pager handle the scroll
+        return !(Math.abs(stateX + width) < eps && deltaX < 0);
+    }
+
+    /**
+     * Reset zoom, rotation, etc.
+     */
+    @Override
+    public void resetState() {
+        getController().resetState();
+    }
+
+    @Override
+    public boolean dispatchTouchEvent(MotionEvent event) {
+        return getDrawable() != null && super.dispatchTouchEvent(event);
+    }
+
+    @Override
+    @SuppressLint("ClickableViewAccessibility")
+    public boolean onTouchEvent(@NonNull MotionEvent event) {
+        gestureDetector.onTouchEvent(event);
+
+        if (event.getActionMasked() == MotionEvent.ACTION_POINTER_DOWN && event.getPointerCount() == 2) {
+            // Two-finger zoom is probably going to start. Disallow parent from intercepting this gesture.
+            getParent().requestDisallowInterceptTouchEvent(true);
+        }
+
+//        return false;
+        return super.onTouchEvent(event);
+    }
+
+    @NonNull
+    @Override
+    public GestureControllerForPager getController() {
+        return super.getController();
+    }
+
+    @Override
+    public void addOnImagePanChangeListener(OnPanChangeListener listener) {
+        GestureController.OnStateChangeListener stateChangeListener = new GestureController.OnStateChangeListener() {
+            @Override
+            public void onStateChanged(State state) {
+                listener.onPanChange(state.getY());
+            }
+
+            @Override
+            public void onStateReset(State oldState, State newState) {}
+        };
+        getController().addOnStateChangeListener(stateChangeListener);
+        onPanChangeListeners.put(listener, stateChangeListener);
+    }
+
+    @Override
+    public void removeOnImagePanChangeListener(OnPanChangeListener listener) {
+        onPanChangeListeners.remove(listener);
+    }
+
+    @Override
+    public void addOnImageZoomChangeListener(OnZoomChangeListener listener) {
+        GestureController.OnStateChangeListener stateChangeListener = new GestureController.OnStateChangeListener() {
+            @Override
+            public void onStateChanged(State state) {
+                listener.onZoomChange(state.getZoom());
+            }
+
+            @Override
+            public void onStateReset(State oldState, State newState) {}
+        };
+        onZoomChangeListeners.put(listener, stateChangeListener);
+        getController().addOnStateChangeListener(stateChangeListener);
+    }
+
+    @Override
+    public void removeOnImageZoomChangeListener(OnZoomChangeListener listener) {
+        onZoomChangeListeners.remove(listener);
+    }
+
+    @Override
+    public boolean hasImage() {
+        return getDrawable() != null;
+    }
+
+    @Override
+    public int getImageHeight() {
+        return getDrawable().getIntrinsicHeight();
+    }
+
+    @Override
+    public void setOnImageTooLargeExceptionListener(OnImageTooLargeExceptionListener listener) {
+        this.imageTooLargeExceptionListener = listener;
+    }
+}

+ 74 - 0
app/src/main/java/com/dx/anonymousmessenger/ui/custom/ZoomableImageView.java

@@ -0,0 +1,74 @@
+package com.dx.anonymousmessenger.ui.custom;
+
+import android.content.Context;
+import android.graphics.drawable.Drawable;
+import android.view.View;
+import android.view.ViewPropertyAnimator;
+import android.widget.ImageView;
+
+public interface ZoomableImageView {
+
+    interface OnPanChangeListener {
+        void onPanChange(float scrollY);
+    }
+
+    interface OnZoomChangeListener {
+        void onZoomChange(float zoom);
+    }
+
+    interface OnImageTooLargeExceptionListener {
+        void onImageTooLargeException(Throwable e);
+    }
+
+    void setGravity(int gravity);
+
+    float getVisibleZoomedImageHeight();
+
+    float getZoomedImageHeight();
+
+    float getZoom();
+
+    boolean canPanFurtherVertically(boolean downwardPan);
+
+    boolean canPanAnyFurtherHorizontally(int deltaX);
+
+    void resetState();
+
+    void addOnImagePanChangeListener(OnPanChangeListener listener);
+
+    void removeOnImagePanChangeListener(OnPanChangeListener listener);
+
+    void addOnImageZoomChangeListener(OnZoomChangeListener listener);
+
+    void removeOnImageZoomChangeListener(OnZoomChangeListener listener);
+
+    boolean hasImage();
+
+    int getImageHeight();
+
+    void setOnImageTooLargeExceptionListener(OnImageTooLargeExceptionListener listener);
+
+// ======== IMAGEVIEW ======== //
+
+    default ImageView view() {
+        return (ImageView) this;
+    }
+
+    void setOnClickListener(View.OnClickListener listener);
+
+    void setVisibility(int visibility);
+
+    boolean isLaidOut();
+
+    int getHeight();
+
+    void setTranslationY(float y);
+
+    void setRotation(float r);
+
+    ViewPropertyAnimator animate();
+
+    void setImageDrawable(Drawable drawable);
+
+    Context getContext();
+}

+ 6 - 3
app/src/main/java/com/dx/anonymousmessenger/ui/view/message_list/MessageListAdapter.java

@@ -65,6 +65,7 @@ import java.util.List;
 import java.util.Objects;
 import java.util.concurrent.Executor;
 
+import static androidx.core.content.ContextCompat.getColor;
 import static androidx.core.content.ContextCompat.getDrawable;
 import static androidx.core.content.ContextCompat.getMainExecutor;
 import static androidx.core.content.ContextCompat.getSystemService;
@@ -327,6 +328,7 @@ public class MessageListAdapter extends RecyclerView.Adapter<RecyclerView.ViewHo
             // we need to show the "undo" state of the row
             holder.itemView.setBackgroundColor(Color.RED);
             ((MessageHolder)holder).undoButton.setVisibility(View.VISIBLE);
+            ((MessageHolder)holder).undoButton.setElevation(R.dimen.margin_large);
             ((MessageHolder)holder).undoButton.setOnClickListener((View.OnClickListener) v -> {
                 // user wants to undo the removal, let's cancel the pending task
                 Runnable pendingRemovalRunnable = pendingRunnables.get(message);
@@ -806,6 +808,7 @@ public class MessageListAdapter extends RecyclerView.Adapter<RecyclerView.ViewHo
                         return;
                     }
                     imageHolder.setImageBitmap(bitmap);
+
                     imageHolder.setOnClickListener(v -> {
                         Intent intent = new Intent(app, PictureViewerActivity.class);
                         intent.putExtra("address",message.getAddress().substring(0,10));
@@ -1065,7 +1068,7 @@ public class MessageListAdapter extends RecyclerView.Adapter<RecyclerView.ViewHo
             boolean initiated;
 
             private void init() {
-                background = new ColorDrawable(Color.MAGENTA);
+                background = new ColorDrawable(getColor(mContext,R.color.dx_night_700));
                 xMark = ContextCompat.getDrawable(mContext, R.drawable.ic_baseline_reply_24);
                 Objects.requireNonNull(xMark).setColorFilter(Color.WHITE, PorterDuff.Mode.SRC_ATOP);
                 xMarkMargin = (int) mContext.getResources().getDimension(R.dimen.ic_clear_margin);
@@ -1294,14 +1297,14 @@ public class MessageListAdapter extends RecyclerView.Adapter<RecyclerView.ViewHo
                 DbHelper.unPinMessage(message,app);
                 message.setPinned(false);
                 if(rv != null){
-                    Snackbar sb = Snackbar.make(rv, R.string.unpinned_message, Snackbar.LENGTH_SHORT).setAnchorView(activity.findViewById(R.id.layout_chatbox));
+                    @SuppressLint("ShowToast") Snackbar sb = Snackbar.make(rv, R.string.unpinned_message, Snackbar.LENGTH_SHORT).setAnchorView(activity.findViewById(R.id.layout_chatbox));
                     sb.show();
                 }
             }else{
                 DbHelper.pinMessage(message,app);
                 message.setPinned(true);
                 if(rv != null){
-                    Snackbar sb = Snackbar.make(rv, R.string.pinned_message, Snackbar.LENGTH_SHORT).setAnchorView(activity.findViewById(R.id.layout_chatbox));
+                    @SuppressLint("ShowToast") Snackbar sb = Snackbar.make(rv, R.string.pinned_message, Snackbar.LENGTH_SHORT).setAnchorView(activity.findViewById(R.id.layout_chatbox));
                     sb.show();
                 }
             }

+ 212 - 94
app/src/main/java/com/dx/anonymousmessenger/ui/view/single_activity/PictureViewerActivity.java

@@ -1,35 +1,46 @@
 package com.dx.anonymousmessenger.ui.view.single_activity;
 
 import android.Manifest;
+import android.animation.ObjectAnimator;
+import android.animation.TimeInterpolator;
+import android.animation.ValueAnimator;
 import android.app.AlertDialog;
 import android.content.pm.PackageManager;
 import android.graphics.Bitmap;
 import android.graphics.BitmapFactory;
+import android.graphics.Point;
+import android.graphics.drawable.Drawable;
 import android.os.Bundle;
 import android.os.Handler;
 import android.os.Looper;
 import android.provider.MediaStore;
 import android.transition.Explode;
+import android.view.Display;
 import android.view.Menu;
 import android.view.MenuItem;
 import android.view.View;
+import android.view.ViewConfiguration;
 import android.view.Window;
 import android.view.WindowManager;
 import android.view.inputmethod.InputMethodManager;
-import android.widget.ImageView;
+import android.widget.FrameLayout;
 import android.widget.TextView;
 
+import androidx.annotation.FloatRange;
 import androidx.core.content.ContextCompat;
+import androidx.interpolator.view.animation.FastOutSlowInInterpolator;
 
+import com.alexvasilkov.gestures.views.GestureImageView;
 import com.dx.anonymousmessenger.DxApplication;
 import com.dx.anonymousmessenger.R;
 import com.dx.anonymousmessenger.db.DbHelper;
 import com.dx.anonymousmessenger.file.FileHelper;
 import com.dx.anonymousmessenger.messages.MessageSender;
 import com.dx.anonymousmessenger.messages.QuotedUserMessage;
+import com.dx.anonymousmessenger.ui.custom.FlickDismissLayout;
+import com.dx.anonymousmessenger.ui.custom.FlickGestureListener;
 import com.dx.anonymousmessenger.ui.view.DxActivity;
 import com.dx.anonymousmessenger.util.Utils;
-import com.google.android.material.appbar.MaterialToolbar;
 import com.google.android.material.floatingactionbutton.FloatingActionButton;
 import com.google.android.material.snackbar.Snackbar;
 import com.google.android.material.textfield.TextInputEditText;
@@ -42,18 +53,24 @@ import java.util.Objects;
 
 import static java.util.Objects.requireNonNull;
 
-public class PictureViewerActivity extends DxActivity {
+public class PictureViewerActivity extends DxActivity implements FlickGestureListener.GestureCallbacks {
+
+    private Drawable activityBackgroundDrawable;
 
     @Override
     protected void onCreate(Bundle savedInstanceState) {
+
         super.onCreate(savedInstanceState);
         try{
             getWindow().setFlags(WindowManager.LayoutParams.FLAG_SECURE, WindowManager.LayoutParams.FLAG_SECURE);
         }catch (Exception ignored){}
+        getWindow().addFlags(WindowManager.LayoutParams.FLAG_FULLSCREEN);
+        getWindow().addFlags(WindowManager.LayoutParams.FLAG_LAYOUT_NO_LIMITS);
         getWindow().requestFeature(Window.FEATURE_SWIPE_TO_DISMISS);
         getWindow().setExitTransition(new Explode());
         setContentView(R.layout.activity_picture_viewer);
 
+
         try{
             setBackEnabled(true);
             if(getIntent().getStringExtra("address")==null || Objects.equals(getIntent().getStringExtra("address"), "")){
@@ -72,12 +89,28 @@ public class PictureViewerActivity extends DxActivity {
             }
         }catch (Exception ignored){}
 
+        animateDimmingOnEntry();
+        GestureImageView imageView = findViewById(R.id.img_to_send);
+        FlickDismissLayout fdl = findViewById(R.id.flick);
+        FlickGestureListener flickListener = createFlickGestureListener(this);
+        flickListener.setContentHeightProvider(new FlickGestureListener.ContentHeightProvider() {
+            @Override
+            public int getContentHeightForDismissAnimation() {
+                return (int) imageView.getMaxHeight();
+            }
+
+            @Override
+            public int getContentHeightForCalculatingThreshold() {
+                return (int) imageView.getMaxHeight();
+            }
+        });
+        flickListener.setOnGestureIntercepter((deltaY) -> {
+            return false;
+        });
+        fdl.setFlickGestureListener(flickListener);
+
+        // if image is from encrypted app storage
         if(getIntent().getBooleanExtra("appData",false)){
-            ((MaterialToolbar)findViewById(R.id.toolbar)).inflateMenu(R.menu.picture_menu);
-            ((MaterialToolbar)findViewById(R.id.toolbar)).setOnMenuItemClickListener((item)->{
-                onOptionsItemSelected(item);
-                return false;
-            });
             new Thread(()->{
                 Bitmap image;
                 try{
@@ -85,113 +118,129 @@ public class PictureViewerActivity extends DxActivity {
                     if(file==null){
                         return;
                     }
-                    Bitmap image2 = BitmapFactory.decodeByteArray(file, 0, file.length);
-                    if(image2.getHeight()>1280 && image2.getWidth()>960){
+                    image = BitmapFactory.decodeByteArray(file, 0, file.length);
+                    Display display = getWindowManager().getDefaultDisplay();
+                    Point size = new Point();
+                    display.getSize(size);
+                    int width = size.x;
+                    int height = size.y;
+
+                    if(image.getHeight()>height && image.getWidth()>width){
                         BitmapFactory.Options options = new BitmapFactory.Options();
                         options.inSampleSize = 2;
-                        image2 = BitmapFactory.decodeByteArray(file, 0, file.length,options);
+                        image = BitmapFactory.decodeByteArray(file, 0, file.length,options);
                     }
-                    image = image2;
-                }catch (Exception e){
-                    e.printStackTrace();
-                    return;
-                }
+
+
                 if(image==null){
                     return;
                 }
+                Bitmap finalImage = image;
                 new Handler(Looper.getMainLooper()).post(()->{
-                    ImageView img = findViewById(R.id.img_to_send);
+
+                    imageView.getController().getStateController();
+                    imageView.getController().getSettings().setRotationEnabled(true);
+                    imageView.getController().getSettings().setRestrictRotation(true);
                     try{
-                        img.setImageBitmap(image);
+                        imageView.setImageBitmap(finalImage);
                     }catch (Exception ignored) {}
                     TextView textCaption = findViewById(R.id.txt_caption_view);
-                    img.setOnClickListener(v -> {
-                        if(textCaption.getText().toString().isEmpty()){
-                            return;
-                        }
-                        if(textCaption.getVisibility()==View.VISIBLE){
+                    imageView.setOnClickListener(v -> {
+                        if(findViewById(R.id.layout_controls).getVisibility()==View.VISIBLE){
+                            findViewById(R.id.layout_controls).setVisibility(View.GONE);
                             textCaption.setVisibility(View.GONE);
                         }else{
-                            textCaption.setVisibility(View.VISIBLE);
+                            findViewById(R.id.layout_controls).setVisibility(View.VISIBLE);
+                            if(!textCaption.getText().toString().equals("")){
+                                textCaption.setVisibility(View.VISIBLE);
+                            }
                         }
                     });
-                    if(Objects.equals(getIntent().getStringExtra("message"), "")){
+                    findViewById(R.id.btn_save).setOnClickListener(v ->{
+                        saveWithAlert();
+                    });
+                    findViewById(R.id.layout_controls).setVisibility(View.VISIBLE);
+                    if(getIntent().getStringExtra("message")==null || Objects.equals(getIntent().getStringExtra("message"), "")){
                         return;
                     }
                     textCaption.setVisibility(View.VISIBLE);
                     textCaption.setText(getIntent().getStringExtra("message"));
                 });
+                }catch (Exception e){
+                    e.printStackTrace();
+                }
             }).start();
-            return;
         }
-
-        new Thread(()->{
-            Bitmap image;
-            try{
-                image = Utils.rotateBitmap(BitmapFactory.decodeFile(getIntent().getStringExtra("path")),getIntent().getStringExtra("path"));
-            }catch (Exception e){
-                e.printStackTrace();
-                return;
-            }
-            if(image==null){
-                return;
-            }
-            new Handler(Looper.getMainLooper()).post(()->{
-                ImageView img = findViewById(R.id.img_to_send);
-                img.setImageBitmap(image);
-            });
-        }).start();
-
-        TextInputLayout textInputLayout = findViewById(R.id.txt_layout_caption);
-        textInputLayout.setVisibility(View.VISIBLE);
-        TextInputEditText msg = findViewById(R.id.txt_caption);
-        InputMethodManager imm = requireNonNull(
-                ContextCompat.getSystemService(this, InputMethodManager.class));
-        imm.hideSoftInputFromWindow(msg.getWindowToken(), 0);
-        FloatingActionButton fabSendMedia = findViewById(R.id.btn_send_media);
-        fabSendMedia.setVisibility(View.VISIBLE);
-        fabSendMedia.setOnClickListener(v -> {
-            msg.setEnabled(false);
+        // else image is from local shared storage and the user might send it
+        else{
             new Thread(()->{
-                DxApplication app = (DxApplication) getApplication();
-                //get time ready
-                long time = new Date().getTime();
-                //save bytes encrypted into a file and get path
-                String filename = String.valueOf(time);
-                String path = null;
-                try {
-                    Bitmap image = Utils.rotateBitmap(BitmapFactory.decodeFile(getIntent().getStringExtra("path")),getIntent().getStringExtra("path"));
-                    if(image==null){
-                        return;
-                    }
-                    ByteArrayOutputStream stream = new ByteArrayOutputStream();
-                    image.compress(Bitmap.CompressFormat.JPEG, 100, stream);
-                    byte[] byteArray = stream.toByteArray();
-                    path = FileHelper.saveFile(byteArray,app,filename);
-                } catch (NoSuchAlgorithmException e) {
+                Bitmap image;
+                try{
+                    image = Utils.rotateBitmap(BitmapFactory.decodeFile(getIntent().getStringExtra("path")),getIntent().getStringExtra("path"));
+                }catch (Exception e){
                     e.printStackTrace();
-                }
-                if(path==null){
                     return;
                 }
-                String txtMsg = "";
-                if(msg.getText()!=null){
-                    txtMsg = msg.getText().toString();
-                }
-                final String fullAddress = DbHelper.getFullAddress(getIntent().getStringExtra(
-                        "address"),
-                        (DxApplication) getApplication());
-                if(fullAddress == null){
+                if(image==null){
                     return;
                 }
-                //save metadata in encrypted database with reference to encrypted file
-                QuotedUserMessage qum = new QuotedUserMessage("","",app.getHostname(),txtMsg,
-                        app.getAccount().getNickname(),time,false,fullAddress,false,filename,path,"image");
-                //send message and get received status
-                MessageSender.sendMediaMessage(qum,app,fullAddress);
+                new Handler(Looper.getMainLooper()).post(()->{
+//                img.getController().getSettings().setRotationEnabled(true);
+                    imageView.setImageBitmap(image);
+                });
             }).start();
-            finish();
-        });
+
+            TextInputLayout textInputLayout = findViewById(R.id.txt_layout_caption);
+            textInputLayout.setVisibility(View.VISIBLE);
+            TextInputEditText msg = findViewById(R.id.txt_caption);
+            InputMethodManager imm = requireNonNull(
+                    ContextCompat.getSystemService(this, InputMethodManager.class));
+            imm.hideSoftInputFromWindow(msg.getWindowToken(), 0);
+            FloatingActionButton fabSendMedia = findViewById(R.id.btn_send_media);
+            fabSendMedia.setVisibility(View.VISIBLE);
+            fabSendMedia.setOnClickListener(v -> {
+                msg.setEnabled(false);
+                new Thread(()->{
+                    DxApplication app = (DxApplication) getApplication();
+                    //get time ready
+                    long time = new Date().getTime();
+                    //save bytes encrypted into a file and get path
+                    String filename = String.valueOf(time);
+                    String path = null;
+                    try {
+                        Bitmap image = Utils.rotateBitmap(BitmapFactory.decodeFile(getIntent().getStringExtra("path")),getIntent().getStringExtra("path"));
+                        if(image==null){
+                            return;
+                        }
+                        ByteArrayOutputStream stream = new ByteArrayOutputStream();
+                        image.compress(Bitmap.CompressFormat.JPEG, 100, stream);
+                        byte[] byteArray = stream.toByteArray();
+                        path = FileHelper.saveFile(byteArray,app,filename);
+                    } catch (NoSuchAlgorithmException e) {
+                        e.printStackTrace();
+                    }
+                    if(path==null){
+                        return;
+                    }
+                    String txtMsg = "";
+                    if(msg.getText()!=null){
+                        txtMsg = msg.getText().toString();
+                    }
+                    final String fullAddress = DbHelper.getFullAddress(getIntent().getStringExtra(
+                            "address"),
+                            (DxApplication) getApplication());
+                    if(fullAddress == null){
+                        return;
+                    }
+                    //save metadata in encrypted database with reference to encrypted file
+                    QuotedUserMessage qum = new QuotedUserMessage("","",app.getHostname(),txtMsg,
+                            app.getAccount().getNickname(),time,false,fullAddress,false,filename,path,"image");
+                    //send message and get received status
+                    MessageSender.sendMediaMessage(qum,app,fullAddress);
+                }).start();
+                finish();
+            });
+        }
     }
 
     @Override
@@ -206,17 +255,21 @@ public class PictureViewerActivity extends DxActivity {
     @Override
     public boolean onOptionsItemSelected(MenuItem item) {
         if (item.getItemId() == R.id.save_picture) {
-            new AlertDialog.Builder(this,R.style.AppAlertDialog)
-                .setTitle(R.string.save_to_storage)
-                .setMessage(R.string.save_to_storage_explain)
-                .setIcon(android.R.drawable.ic_dialog_alert)
-                .setPositiveButton(android.R.string.yes, (dialog, whichButton) -> new Thread(this::saveToStorage
-                ).start())
-                .setNegativeButton(android.R.string.no, null).show();
+            saveWithAlert();
         }
         return super.onOptionsItemSelected(item);
     }
 
+    private void saveWithAlert() {
+        new AlertDialog.Builder(this,R.style.AppAlertDialog)
+            .setTitle(R.string.save_to_storage)
+            .setMessage(R.string.save_to_storage_explain)
+            .setIcon(android.R.drawable.ic_dialog_alert)
+            .setPositiveButton(android.R.string.yes, (dialog, whichButton) -> new Thread(this::saveToStorage
+            ).start())
+            .setNegativeButton(android.R.string.no, null).show();
+    }
+
     @Override
     public boolean onSupportNavigateUp() {
         finish();
@@ -254,4 +307,69 @@ public class PictureViewerActivity extends DxActivity {
             e.printStackTrace();
         }
     }
+
+    private void animateDimmingOnEntry() {
+        FrameLayout root_layout = findViewById(R.id.imageviewer_root);
+        if(root_layout==null || root_layout.getBackground()==null){
+            return;
+        }
+        Drawable back = root_layout.getBackground().mutate();
+        this.activityBackgroundDrawable = back;
+        back = this.activityBackgroundDrawable;
+
+        root_layout.setBackground(back);
+        ValueAnimator var1 = ObjectAnimator.ofFloat(0.5F, 0.0F);
+        var1.setDuration(200L);
+        var1.setInterpolator((TimeInterpolator)(new FastOutSlowInInterpolator()));
+        var1.addUpdateListener(new ValueAnimator.AnimatorUpdateListener() {
+            @Override
+            public void onAnimationUpdate(ValueAnimator animation) {
+                updateBackgroundDimmingAlpha((Float) animation.getAnimatedValue());
+            }
+        });
+//        var1.addUpdateListener((ValueAnimator.AnimatorUpdateListener)(new ImageViewerActivity$animateDimmingOnEntry$$inlined$apply$lambda$1(this)));
+        var1.start();
+    }
+
+    /**
+     * @param targetTransparencyFactor 1f for maximum transparency. 0f for none.
+     */
+    private void updateBackgroundDimmingAlpha(@FloatRange(from = 0, to = 1) float targetTransparencyFactor) {
+        // Increase dimming exponentially so that the background is fully transparent while the image has been moved by half.
+        float dimming = 1f - Math.min(1f, targetTransparencyFactor * 2);
+        activityBackgroundDrawable.setAlpha((int) (dimming * 255));
+    }
+
+    public FlickGestureListener createFlickGestureListener(FlickGestureListener.GestureCallbacks wrappedGestureCallbacks) {
+        FlickGestureListener flickListener = new FlickGestureListener(ViewConfiguration.get(getApplicationContext()));
+        flickListener.setFlickThresholdSlop(FlickGestureListener.DEFAULT_FLICK_THRESHOLD);
+        flickListener.setGestureCallbacks(new FlickGestureListener.GestureCallbacks() {
+            @Override
+            public void onFlickDismissEnd(long flickAnimationDuration) {
+                wrappedGestureCallbacks.onFlickDismissEnd(flickAnimationDuration);
+            }
+
+            @Override
+            public void onMoveMedia(float moveRatio) {
+                wrappedGestureCallbacks.onMoveMedia(moveRatio);
+
+                boolean isImageBeingMoved = moveRatio != 0f;
+//                titleDescriptionView.setVisibility(!isImageBeingMoved ? View.VISIBLE : View.INVISIBLE);
+
+                boolean showDimming = !isImageBeingMoved ;//&& titleDescriptionView.streamDimmingRequiredForTitleAndDescription().getValue();
+//                imageDimmingView.setVisibility(showDimming ? View.VISIBLE : View.GONE);
+            }
+        });
+        return flickListener;
+    }
+
+    @Override
+    public void onFlickDismissEnd(long flickAnimationDuration) {
+        finish();
+    }
+
+    @Override
+    public void onMoveMedia(float moveRatio) {
+        updateBackgroundDimmingAlpha(Math.abs(moveRatio));
+    }
 }

+ 9 - 0
app/src/main/res/drawable/default_background.xml

@@ -0,0 +1,9 @@
+<?xml version="1.0" encoding="utf-8"?>
+
+<shape xmlns:android="http://schemas.android.com/apk/res/android"
+    android:shape="rectangle">
+    <gradient
+        android:angle="-270"
+        android:endColor="@color/background_gradient_end"
+        android:startColor="@color/background_gradient_start" />
+</shape>

+ 113 - 64
app/src/main/res/layout/activity_picture_viewer.xml

@@ -1,77 +1,126 @@
 <?xml version="1.0" encoding="utf-8"?>
-<androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android"
+<!--<androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android"-->
+<!--    xmlns:app="http://schemas.android.com/apk/res-auto"-->
+<!--    xmlns:tools="http://schemas.android.com/tools"-->
+<!--    android:id="@+id/cl"-->
+<!--    android:layout_width="match_parent"-->
+<!--    android:layout_height="wrap_content"-->
+<!--    tools:context="com.dx.anonymousmessenger.ui.view.single_activity.PictureViewerActivity">-->
+
+    <!--    <include-->
+    <!--        android:id="@+id/top_bar"-->
+    <!--        layout="@layout/toolbar"-->
+    <!--        app:layout_constraintEnd_toEndOf="parent"-->
+    <!--        app:layout_constraintStart_toStartOf="parent"-->
+    <!--        app:layout_constraintTop_toTopOf="parent" />-->
+
+<FrameLayout xmlns:android="http://schemas.android.com/apk/res/android"
     xmlns:app="http://schemas.android.com/apk/res-auto"
     xmlns:tools="http://schemas.android.com/tools"
+    android:id="@+id/imageviewer_root"
     android:layout_width="match_parent"
     android:layout_height="match_parent"
-    tools:context="com.dx.anonymousmessenger.ui.view.single_activity.PictureViewerActivity">
+    android:background="@color/black_opacity_75"
+    android:orientation="vertical">
+
+    <com.dx.anonymousmessenger.ui.custom.FlickDismissLayout
+        android:id="@+id/flick"
+        android:layout_width="match_parent"
+        android:layout_height="match_parent">
 
-    <include
-        android:id="@+id/top_bar"
-        layout="@layout/toolbar"
-        app:layout_constraintEnd_toEndOf="parent"
-        app:layout_constraintStart_toStartOf="parent"
-        app:layout_constraintTop_toTopOf="parent" />
+        <ProgressBar
+            android:id="@+id/imageviewer_progress"
+            style="@android:style/Widget.DeviceDefault.ProgressBar"
+            android:layout_width="wrap_content"
+            android:layout_height="wrap_content"
+            android:layout_gravity="center"
+            android:indeterminate="true"
+            android:indeterminateTint="#673AB7"
+            android:progressTint="#673AB7" />
 
-    <TextView
-        android:id="@+id/txt_caption_view"
-        android:layout_width="wrap_content"
-        android:layout_height="wrap_content"
-        android:layout_margin="4dp"
-        android:background="#5E221E2A"
-        android:elevation="5dp"
-        android:fontFamily="monospace"
-        android:padding="4dp"
-        android:textSize="24sp"
-        android:visibility="gone"
-        app:layout_constraintBottom_toBottomOf="parent"
-        app:layout_constraintEnd_toEndOf="parent"
-        app:layout_constraintStart_toStartOf="parent" />
+        <com.google.android.material.floatingactionbutton.FloatingActionButton
+            android:id="@+id/btn_send_media"
+            android:layout_width="wrap_content"
+            android:layout_height="wrap_content"
+            android:layout_gravity="top"
+            android:layout_marginStart="64dp"
+            android:layout_marginTop="74dp"
+            android:clickable="true"
+            android:contentDescription="@string/send"
+            android:focusable="true"
+            android:visibility="gone"
+            app:fabSize="auto"
+            app:maxImageSize="64dp"
+            app:srcCompat="@android:drawable/ic_menu_send" />
 
-    <com.google.android.material.textfield.TextInputLayout
-        android:id="@+id/txt_layout_caption"
-        android:layout_width="match_parent"
-        android:layout_height="wrap_content"
-        android:background="#5E221E2A"
-        android:elevation="5dp"
-        android:visibility="gone"
-        app:layout_constraintBottom_toBottomOf="parent"
-        app:layout_constraintEnd_toEndOf="parent"
-        app:layout_constraintStart_toStartOf="parent">
+        <com.google.android.material.textfield.TextInputLayout
+            android:id="@+id/txt_layout_caption"
+            android:layout_width="match_parent"
+            android:layout_height="wrap_content"
+            android:layout_gravity="top"
+            android:layout_marginStart="2dp"
+            android:layout_marginTop="10dp"
+            android:layout_marginEnd="2dp"
+            android:background="#5E221E2A"
+            android:elevation="5dp"
+            android:visibility="gone">
+
+            <com.google.android.material.textfield.TextInputEditText
+                android:id="@+id/txt_caption"
+                android:layout_width="match_parent"
+                android:layout_height="wrap_content"
+                android:hint="@string/caption"
+                android:imeOptions="flagNoPersonalizedLearning|actionDone"
+                android:maxLines="1" />
+        </com.google.android.material.textfield.TextInputLayout>
+
+        <TextView
+            android:id="@+id/txt_caption_view"
+            android:layout_width="match_parent"
+            android:layout_height="wrap_content"
+            android:layout_gravity="top"
+            android:layout_marginStart="4dp"
+            android:layout_marginTop="64dp"
+            android:layout_marginEnd="4dp"
+            android:background="#5E221E2A"
+            android:elevation="5dp"
+            android:fontFamily="monospace"
+            android:gravity="center"
+            android:padding="4dp"
+            android:textSize="24sp"
+            android:visibility="gone"
+            tools:text="haahhaahhaha soooo funnyy" />
+
+        <com.alexvasilkov.gestures.views.GestureImageView
+            android:id="@+id/img_to_send"
+            android:layout_width="match_parent"
+            android:layout_height="match_parent"
+            android:layout_gravity="center"
+            android:contentDescription="@string/image_to_send"
+            app:srcCompat="@android:drawable/ic_menu_report_image" />
 
-        <com.google.android.material.textfield.TextInputEditText
-            android:id="@+id/txt_caption"
+        <LinearLayout
+            android:id="@+id/layout_controls"
             android:layout_width="match_parent"
             android:layout_height="wrap_content"
-            android:hint="@string/caption"
-            android:imeOptions="flagNoPersonalizedLearning|actionDone"
-            android:maxLines="1" />
-    </com.google.android.material.textfield.TextInputLayout>
+            android:layout_gravity="bottom"
+            android:layout_marginStart="25dp"
+            android:layout_marginEnd="25dp"
+            android:layout_marginBottom="128dp"
+            android:orientation="horizontal"
+            android:visibility="gone">
 
-    <ImageView
-        android:id="@+id/img_to_send"
-        android:layout_width="0dp"
-        android:layout_height="0dp"
-        android:contentDescription="@string/image_to_send"
-        app:layout_constraintBottom_toBottomOf="parent"
-        app:layout_constraintEnd_toEndOf="parent"
-        app:layout_constraintStart_toStartOf="parent"
-        app:layout_constraintTop_toBottomOf="@+id/top_bar"
-        app:srcCompat="@android:drawable/ic_menu_report_image" />
+            <com.google.android.material.floatingactionbutton.FloatingActionButton
+                android:id="@+id/btn_save"
+                android:layout_width="0dp"
+                android:layout_height="wrap_content"
+                android:layout_weight="1"
+                android:clickable="true"
+                android:focusable="true"
+                app:srcCompat="@drawable/ic_baseline_save_24"
+                tools:ignore="ContentDescription" />
+        </LinearLayout>
+    </com.dx.anonymousmessenger.ui.custom.FlickDismissLayout>
+</FrameLayout>
 
-    <com.google.android.material.floatingactionbutton.FloatingActionButton
-        android:id="@+id/btn_send_media"
-        android:layout_width="wrap_content"
-        android:layout_height="wrap_content"
-        android:layout_marginEnd="64dp"
-        android:layout_marginBottom="64dp"
-        android:clickable="true"
-        android:focusable="true"
-        android:visibility="gone"
-        app:fabSize="auto"
-        app:layout_constraintBottom_toBottomOf="parent"
-        app:layout_constraintEnd_toEndOf="parent"
-        app:maxImageSize="64dp"
-        app:srcCompat="@android:drawable/ic_menu_send"
-        android:contentDescription="@string/send" />
-</androidx.constraintlayout.widget.ConstraintLayout>
+<!--</androidx.constraintlayout.widget.ConstraintLayout>-->

+ 3 - 6
app/src/main/res/layout/item_file_message_received.xml

@@ -90,7 +90,7 @@
                 android:contentDescription="@string/indicates_secure_messaging"
                 app:layout_constraintBottom_toBottomOf="parent"
                 app:layout_constraintStart_toStartOf="parent"
-                app:layout_constraintTop_toBottomOf="@+id/img_holder"
+                app:layout_constraintTop_toBottomOf="@+id/txt_filename"
                 app:srcCompat="@android:drawable/ic_lock_lock" />
 
             <TextView
@@ -101,7 +101,7 @@
                 android:textColor="#ffffff"
                 android:textSize="12sp"
                 app:layout_constraintStart_toEndOf="@+id/img_seen2"
-                app:layout_constraintTop_toBottomOf="@+id/img_holder" />
+                app:layout_constraintTop_toBottomOf="@+id/txt_filename" />
 
             <TextView
                 android:id="@+id/txt_filename"
@@ -109,11 +109,8 @@
                 android:layout_height="wrap_content"
                 android:layout_margin="5dp"
                 android:layout_marginTop="12dp"
-                android:layout_marginBottom="12dp"
-                android:maxLength="60"
-                android:maxLines="2"
+                android:maxWidth="200dp"
                 android:text="@string/send_file"
-                app:layout_constraintBottom_toBottomOf="@+id/img_holder"
                 app:layout_constraintEnd_toEndOf="parent"
                 app:layout_constraintStart_toEndOf="@+id/img_holder"
                 app:layout_constraintTop_toTopOf="@+id/img_holder" />

+ 3 - 5
app/src/main/res/layout/item_file_message_sent_ok.xml

@@ -64,7 +64,7 @@
                 android:elevation="10dp"
                 app:layout_constraintBottom_toBottomOf="@+id/img_sent"
                 app:layout_constraintEnd_toEndOf="parent"
-                app:layout_constraintTop_toBottomOf="@+id/img_holder"
+                app:layout_constraintTop_toBottomOf="@+id/txt_filename"
                 app:srcCompat="@android:drawable/ic_lock_lock" />
 
             <TextView
@@ -76,7 +76,7 @@
                 android:textSize="12sp"
                 app:layout_constraintBottom_toBottomOf="parent"
                 app:layout_constraintEnd_toStartOf="@+id/img_sent"
-                app:layout_constraintTop_toBottomOf="@+id/img_holder" />
+                app:layout_constraintTop_toBottomOf="@+id/txt_filename" />
 
             <ImageView
                 android:id="@+id/img_sent"
@@ -94,10 +94,8 @@
                 android:layout_height="wrap_content"
                 android:layout_margin="5dp"
                 android:background="@drawable/rounded_rectangle_dark"
-                android:maxLength="60"
-                android:maxLines="2"
+                android:maxWidth="200dp"
                 android:text="@string/send_file"
-                app:layout_constraintBottom_toBottomOf="@+id/img_holder"
                 app:layout_constraintEnd_toEndOf="parent"
                 app:layout_constraintStart_toEndOf="@+id/img_holder"
                 app:layout_constraintTop_toTopOf="@+id/img_holder" />

+ 7 - 0
app/src/main/res/values/colors.xml

@@ -8,6 +8,7 @@
     <color name="dx_night_700">#333A41</color>
     <color name="dx_dark_purple">#19001C</color>
     <color name="green_tor">#68B030</color>
+    <color name="black_opacity_75">#C4000000</color>
 
     <color name="dx_brand_blue">@color/dx_night_700</color>
     <color name="dx_brand_green">@color/dx_lime_400</color>
@@ -22,5 +23,11 @@
     <color name="red_500">#F44336</color>
     <color name="startGradientColor">#310029</color>
     <color name="endGradientColor">#260037</color>
+    <color name="background_gradient_start">#000000</color>
+    <color name="background_gradient_end">#DDDDDD</color>
+    <color name="fastlane_background">#0096a6</color>
+    <color name="search_opaque">#ffaa3f</color>
+    <color name="selected_background">#ffaa3f</color>
+    <color name="default_background">#3d3d3d</color>
 
 </resources>

+ 1 - 0
app/src/main/res/values/dimens.xml

@@ -10,4 +10,5 @@
     <dimen name="text_size_medium">16sp</dimen>
 	<dimen name="text_size_large">22sp</dimen>
     <dimen name="ic_clear_margin">16dp</dimen>
+    <dimen name="mediaalbumviewer_image_height_when_empty">240dp</dimen>
 </resources>

+ 6 - 0
app/src/main/res/values/styles.xml

@@ -114,4 +114,10 @@
         <item name="android:textColor">@color/dx_button_text</item>
     </style>
 
+    <style name="AppTheme.TransparentFullscreen">
+        <item name="android:windowIsTranslucent">true</item>
+        <item name="android:windowBackground">@android:color/transparent</item>
+        <item name="android:colorBackgroundCacheHint">@null</item>
+    </style>
+
 </resources>