mirror of
https://github.com/oxen-io/session-android.git
synced 2024-11-21 15:05:19 +00:00
SES-2524 - Rewrite media gallery in Compose (#1619)
This commit is contained in:
parent
f379604c54
commit
16d6efbb5b
@ -1,18 +1,3 @@
|
|||||||
|
|
||||||
buildscript {
|
|
||||||
repositories {
|
|
||||||
google()
|
|
||||||
mavenCentral()
|
|
||||||
}
|
|
||||||
dependencies {
|
|
||||||
classpath "com.android.tools.build:gradle:$gradlePluginVersion"
|
|
||||||
classpath files('libs/gradle-witness.jar')
|
|
||||||
classpath "org.jetbrains.kotlin:kotlin-gradle-plugin:$kotlinVersion"
|
|
||||||
classpath "org.jetbrains.kotlin:kotlin-serialization:$kotlinVersion"
|
|
||||||
classpath "com.google.gms:google-services:$googleServicesVersion"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
plugins {
|
plugins {
|
||||||
id 'com.google.devtools.ksp'
|
id 'com.google.devtools.ksp'
|
||||||
id 'com.google.dagger.hilt.android'
|
id 'com.google.dagger.hilt.android'
|
||||||
@ -24,8 +9,8 @@ apply plugin: 'witness'
|
|||||||
apply plugin: 'kotlin-parcelize'
|
apply plugin: 'kotlin-parcelize'
|
||||||
apply plugin: 'kotlinx-serialization'
|
apply plugin: 'kotlinx-serialization'
|
||||||
|
|
||||||
configurations.all {
|
configurations.forEach {
|
||||||
exclude module: "commons-logging"
|
it.exclude module: "commons-logging"
|
||||||
}
|
}
|
||||||
|
|
||||||
def canonicalVersionCode = 379
|
def canonicalVersionCode = 379
|
||||||
@ -64,13 +49,9 @@ android {
|
|||||||
}
|
}
|
||||||
|
|
||||||
packagingOptions {
|
packagingOptions {
|
||||||
exclude 'LICENSE.txt'
|
resources {
|
||||||
exclude 'LICENSE'
|
excludes += ['LICENSE.txt', 'LICENSE', 'NOTICE', 'asm-license.txt', 'META-INF/LICENSE', 'META-INF/NOTICE', 'META-INF/proguard/androidx-annotations.pro']
|
||||||
exclude 'NOTICE'
|
}
|
||||||
exclude 'asm-license.txt'
|
|
||||||
exclude 'META-INF/LICENSE'
|
|
||||||
exclude 'META-INF/NOTICE'
|
|
||||||
exclude 'META-INF/proguard/androidx-annotations.pro'
|
|
||||||
}
|
}
|
||||||
|
|
||||||
splits {
|
splits {
|
||||||
@ -85,6 +66,7 @@ android {
|
|||||||
buildFeatures {
|
buildFeatures {
|
||||||
compose true
|
compose true
|
||||||
}
|
}
|
||||||
|
|
||||||
composeOptions {
|
composeOptions {
|
||||||
kotlinCompilerExtensionVersion '1.5.14'
|
kotlinCompilerExtensionVersion '1.5.14'
|
||||||
}
|
}
|
||||||
@ -108,8 +90,8 @@ android {
|
|||||||
buildConfigField "String", "USER_AGENT", "\"OWA\""
|
buildConfigField "String", "USER_AGENT", "\"OWA\""
|
||||||
buildConfigField "String[]", "LANGUAGES", "new String[]{\"" + autoResConfig().collect { s -> s.replace('-r', '_') }.join('", "') + '"}'
|
buildConfigField "String[]", "LANGUAGES", "new String[]{\"" + autoResConfig().collect { s -> s.replace('-r', '_') }.join('", "') + '"}'
|
||||||
buildConfigField "int", "CANONICAL_VERSION_CODE", "$canonicalVersionCode"
|
buildConfigField "int", "CANONICAL_VERSION_CODE", "$canonicalVersionCode"
|
||||||
|
resourceConfigurations += []
|
||||||
|
|
||||||
resConfigs autoResConfig()
|
|
||||||
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
|
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
|
||||||
// The following argument makes the Android Test Orchestrator run its
|
// The following argument makes the Android Test Orchestrator run its
|
||||||
// "pm clear" command after each test invocation. This command ensures
|
// "pm clear" command after each test invocation. This command ensures
|
||||||
@ -169,7 +151,7 @@ android {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
applicationVariants.all { variant ->
|
applicationVariants.forEach { variant ->
|
||||||
variant.outputs.each { output ->
|
variant.outputs.each { output ->
|
||||||
def abiName = output.getFilter("ABI") ?: 'universal'
|
def abiName = output.getFilter("ABI") ?: 'universal'
|
||||||
def postFix = abiPostFix.get(abiName, 0)
|
def postFix = abiPostFix.get(abiName, 0)
|
||||||
@ -180,10 +162,6 @@ android {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
lintOptions {
|
|
||||||
abortOnError true
|
|
||||||
baseline file("lint-baseline.xml")
|
|
||||||
}
|
|
||||||
|
|
||||||
testOptions {
|
testOptions {
|
||||||
unitTests {
|
unitTests {
|
||||||
@ -192,7 +170,6 @@ android {
|
|||||||
}
|
}
|
||||||
|
|
||||||
buildFeatures {
|
buildFeatures {
|
||||||
dataBinding true
|
|
||||||
viewBinding true
|
viewBinding true
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -212,9 +189,11 @@ android {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
task testPlayDebugUnitTestCoverageReport(type: JacocoReport, dependsOn: "testPlayDebugUnitTest") {
|
tasks.register('testPlayDebugUnitTestCoverageReport', JacocoReport) {
|
||||||
|
dependsOn 'testPlayDebugUnitTest'
|
||||||
|
|
||||||
reports {
|
reports {
|
||||||
xml.enabled = true
|
xml.required = true
|
||||||
}
|
}
|
||||||
|
|
||||||
// Add files that should not be listed in the report (e.g. generated Files from dagger)
|
// Add files that should not be listed in the report (e.g. generated Files from dagger)
|
||||||
@ -232,6 +211,13 @@ android {
|
|||||||
// This is enabled with 'enableUnitTestCoverage' in the 'debug' build type.
|
// This is enabled with 'enableUnitTestCoverage' in the 'debug' build type.
|
||||||
executionData.from = "${project.buildDir}/outputs/unit_test_code_coverage/playDebugUnitTest/testPlayDebugUnitTest.exec"
|
executionData.from = "${project.buildDir}/outputs/unit_test_code_coverage/playDebugUnitTest/testPlayDebugUnitTest.exec"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
testNamespace 'network.loki.messenger.test'
|
||||||
|
lint {
|
||||||
|
abortOnError true
|
||||||
|
baseline file('lint-baseline.xml')
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
dependencies {
|
dependencies {
|
||||||
@ -258,6 +244,7 @@ dependencies {
|
|||||||
implementation "androidx.lifecycle:lifecycle-extensions:2.2.0"
|
implementation "androidx.lifecycle:lifecycle-extensions:2.2.0"
|
||||||
implementation "androidx.paging:paging-runtime-ktx:$pagingVersion"
|
implementation "androidx.paging:paging-runtime-ktx:$pagingVersion"
|
||||||
implementation 'androidx.activity:activity-ktx:1.5.1'
|
implementation 'androidx.activity:activity-ktx:1.5.1'
|
||||||
|
implementation 'androidx.activity:activity-compose:1.5.1'
|
||||||
implementation 'androidx.fragment:fragment-ktx:1.5.3'
|
implementation 'androidx.fragment:fragment-ktx:1.5.3'
|
||||||
implementation "androidx.core:core-ktx:$coreVersion"
|
implementation "androidx.core:core-ktx:$coreVersion"
|
||||||
implementation "androidx.work:work-runtime-ktx:2.7.1"
|
implementation "androidx.work:work-runtime-ktx:2.7.1"
|
||||||
@ -279,6 +266,7 @@ dependencies {
|
|||||||
implementation 'commons-net:commons-net:3.7.2'
|
implementation 'commons-net:commons-net:3.7.2'
|
||||||
implementation 'com.github.chrisbanes:PhotoView:2.1.3'
|
implementation 'com.github.chrisbanes:PhotoView:2.1.3'
|
||||||
implementation "com.github.bumptech.glide:glide:$glideVersion"
|
implementation "com.github.bumptech.glide:glide:$glideVersion"
|
||||||
|
implementation "com.github.bumptech.glide:compose:1.0.0-beta01"
|
||||||
ksp "com.github.bumptech.glide:ksp:$glideVersion"
|
ksp "com.github.bumptech.glide:ksp:$glideVersion"
|
||||||
implementation 'com.makeramen:roundedimageview:2.1.0'
|
implementation 'com.makeramen:roundedimageview:2.1.0'
|
||||||
implementation 'com.pnikosis:materialish-progress:1.5'
|
implementation 'com.pnikosis:materialish-progress:1.5'
|
||||||
@ -299,7 +287,6 @@ dependencies {
|
|||||||
exclude group: 'com.squareup.okhttp', module: 'okhttp-urlconnection'
|
exclude group: 'com.squareup.okhttp', module: 'okhttp-urlconnection'
|
||||||
}
|
}
|
||||||
implementation 'com.annimon:stream:1.1.8'
|
implementation 'com.annimon:stream:1.1.8'
|
||||||
implementation project(':stickyheader')
|
|
||||||
implementation 'com.github.dmytrodanylyk.circular-progress-button:library:1.1.3-S2'
|
implementation 'com.github.dmytrodanylyk.circular-progress-button:library:1.1.3-S2'
|
||||||
implementation 'androidx.sqlite:sqlite-ktx:2.3.1'
|
implementation 'androidx.sqlite:sqlite-ktx:2.3.1'
|
||||||
implementation 'net.zetetic:sqlcipher-android:4.5.4@aar'
|
implementation 'net.zetetic:sqlcipher-android:4.5.4@aar'
|
||||||
|
@ -1,6 +1,5 @@
|
|||||||
<manifest
|
<manifest
|
||||||
xmlns:android="http://schemas.android.com/apk/res/android"
|
xmlns:android="http://schemas.android.com/apk/res/android">
|
||||||
package="network.loki.messenger.test">
|
|
||||||
<application>
|
<application>
|
||||||
<uses-library android:name="android.test.runner"
|
<uses-library android:name="android.test.runner"
|
||||||
android:required="false" />
|
android:required="false" />
|
||||||
|
@ -306,6 +306,8 @@
|
|||||||
android:name="android.support.PARENT_ACTIVITY"
|
android:name="android.support.PARENT_ACTIVITY"
|
||||||
android:value="org.thoughtcrime.securesms.home.HomeActivity" />
|
android:value="org.thoughtcrime.securesms.home.HomeActivity" />
|
||||||
</activity>
|
</activity>
|
||||||
|
<activity android:name="org.thoughtcrime.securesms.media.MediaOverviewActivity" />
|
||||||
|
|
||||||
<service android:enabled="true" android:name="org.thoughtcrime.securesms.service.WebRtcCallService"
|
<service android:enabled="true" android:name="org.thoughtcrime.securesms.service.WebRtcCallService"
|
||||||
android:foregroundServiceType="microphone"
|
android:foregroundServiceType="microphone"
|
||||||
android:exported="false" />
|
android:exported="false" />
|
||||||
|
@ -1,132 +0,0 @@
|
|||||||
package org.thoughtcrime.securesms;
|
|
||||||
|
|
||||||
|
|
||||||
import android.content.ActivityNotFoundException;
|
|
||||||
import android.content.Context;
|
|
||||||
import android.content.Intent;
|
|
||||||
import android.database.Cursor;
|
|
||||||
import androidx.annotation.NonNull;
|
|
||||||
import androidx.recyclerview.widget.RecyclerView;
|
|
||||||
import android.view.LayoutInflater;
|
|
||||||
import android.view.View;
|
|
||||||
import android.view.ViewGroup;
|
|
||||||
import android.widget.TextView;
|
|
||||||
import android.widget.Toast;
|
|
||||||
|
|
||||||
import org.thoughtcrime.securesms.MediaDocumentsAdapter.HeaderViewHolder;
|
|
||||||
import org.thoughtcrime.securesms.MediaDocumentsAdapter.ViewHolder;
|
|
||||||
import org.thoughtcrime.securesms.components.DocumentView;
|
|
||||||
import org.thoughtcrime.securesms.database.CursorRecyclerViewAdapter;
|
|
||||||
import org.thoughtcrime.securesms.database.MediaDatabase;
|
|
||||||
import org.session.libsignal.utilities.Log;
|
|
||||||
import org.thoughtcrime.securesms.mms.DocumentSlide;
|
|
||||||
import org.thoughtcrime.securesms.mms.PartAuthority;
|
|
||||||
import org.thoughtcrime.securesms.mms.Slide;
|
|
||||||
import org.thoughtcrime.securesms.util.DateUtils;
|
|
||||||
import org.thoughtcrime.securesms.util.MediaUtil;
|
|
||||||
import org.thoughtcrime.securesms.util.StickyHeaderDecoration;
|
|
||||||
|
|
||||||
import org.session.libsession.utilities.Util;
|
|
||||||
|
|
||||||
import java.util.Calendar;
|
|
||||||
import java.util.Date;
|
|
||||||
import java.util.Locale;
|
|
||||||
|
|
||||||
import network.loki.messenger.R;
|
|
||||||
|
|
||||||
import static com.codewaves.stickyheadergrid.StickyHeaderGridLayoutManager.TAG;
|
|
||||||
|
|
||||||
public class MediaDocumentsAdapter extends CursorRecyclerViewAdapter<ViewHolder> implements StickyHeaderDecoration.StickyHeaderAdapter<HeaderViewHolder> {
|
|
||||||
|
|
||||||
private final Calendar calendar;
|
|
||||||
private final Locale locale;
|
|
||||||
|
|
||||||
MediaDocumentsAdapter(Context context, Cursor cursor, Locale locale) {
|
|
||||||
super(context, cursor);
|
|
||||||
|
|
||||||
this.calendar = Calendar.getInstance();
|
|
||||||
this.locale = locale;
|
|
||||||
}
|
|
||||||
|
|
||||||
@Override
|
|
||||||
public ViewHolder onCreateItemViewHolder(ViewGroup parent, int viewType) {
|
|
||||||
return new ViewHolder(LayoutInflater.from(getContext()).inflate(R.layout.media_overview_document_item, parent, false));
|
|
||||||
}
|
|
||||||
|
|
||||||
@Override
|
|
||||||
public void onBindItemViewHolder(ViewHolder viewHolder, @NonNull Cursor cursor) {
|
|
||||||
MediaDatabase.MediaRecord mediaRecord = MediaDatabase.MediaRecord.from(getContext(), cursor);
|
|
||||||
Slide slide = MediaUtil.getSlideForAttachment(getContext(), mediaRecord.getAttachment());
|
|
||||||
|
|
||||||
if (slide != null && slide.hasDocument()) {
|
|
||||||
viewHolder.documentView.setDocument((DocumentSlide)slide, false);
|
|
||||||
viewHolder.date.setText(DateUtils.getRelativeDate(getContext(), locale, mediaRecord.getDate()));
|
|
||||||
viewHolder.documentView.setVisibility(View.VISIBLE);
|
|
||||||
viewHolder.date.setVisibility(View.VISIBLE);
|
|
||||||
viewHolder.documentView.setOnClickListener(view -> {
|
|
||||||
Intent intent = new Intent(Intent.ACTION_VIEW);
|
|
||||||
intent.addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION);
|
|
||||||
intent.setDataAndType(PartAuthority.getAttachmentPublicUri(slide.getUri()), slide.getContentType());
|
|
||||||
try {
|
|
||||||
getContext().startActivity(intent);
|
|
||||||
} catch (ActivityNotFoundException anfe) {
|
|
||||||
Log.w(TAG, "No activity existed to view the media.");
|
|
||||||
Toast.makeText(getContext(), R.string.ConversationItem_unable_to_open_media, Toast.LENGTH_LONG).show();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
} else {
|
|
||||||
viewHolder.documentView.setVisibility(View.GONE);
|
|
||||||
viewHolder.date.setVisibility(View.GONE);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@Override
|
|
||||||
public long getHeaderId(int position) {
|
|
||||||
if (!isActiveCursor()) return -1;
|
|
||||||
if (isHeaderPosition(position)) return -1;
|
|
||||||
if (isFooterPosition(position)) return -1;
|
|
||||||
if (position >= getItemCount()) return -1;
|
|
||||||
if (position < 0) return -1;
|
|
||||||
|
|
||||||
Cursor cursor = getCursorAtPositionOrThrow(position);
|
|
||||||
MediaDatabase.MediaRecord mediaRecord = MediaDatabase.MediaRecord.from(getContext(), cursor);
|
|
||||||
|
|
||||||
calendar.setTime(new Date(mediaRecord.getDate()));
|
|
||||||
return Util.hashCode(calendar.get(Calendar.YEAR), calendar.get(Calendar.DAY_OF_YEAR));
|
|
||||||
}
|
|
||||||
|
|
||||||
@Override
|
|
||||||
public HeaderViewHolder onCreateHeaderViewHolder(ViewGroup parent) {
|
|
||||||
return new HeaderViewHolder(LayoutInflater.from(getContext()).inflate(R.layout.media_overview_document_item_header, parent, false));
|
|
||||||
}
|
|
||||||
|
|
||||||
@Override
|
|
||||||
public void onBindHeaderViewHolder(HeaderViewHolder viewHolder, int position) {
|
|
||||||
Cursor cursor = getCursorAtPositionOrThrow(position);
|
|
||||||
MediaDatabase.MediaRecord mediaRecord = MediaDatabase.MediaRecord.from(getContext(), cursor);
|
|
||||||
viewHolder.textView.setText(DateUtils.getRelativeDate(getContext(), locale, mediaRecord.getDate()));
|
|
||||||
}
|
|
||||||
|
|
||||||
public static class ViewHolder extends RecyclerView.ViewHolder {
|
|
||||||
|
|
||||||
private final DocumentView documentView;
|
|
||||||
private final TextView date;
|
|
||||||
|
|
||||||
public ViewHolder(View itemView) {
|
|
||||||
super(itemView);
|
|
||||||
this.documentView = itemView.findViewById(R.id.document_view);
|
|
||||||
this.date = itemView.findViewById(R.id.date);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
static class HeaderViewHolder extends RecyclerView.ViewHolder {
|
|
||||||
|
|
||||||
private final TextView textView;
|
|
||||||
|
|
||||||
HeaderViewHolder(View itemView) {
|
|
||||||
super(itemView);
|
|
||||||
this.textView = itemView.findViewById(R.id.text);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
}
|
|
@ -1,173 +0,0 @@
|
|||||||
/*
|
|
||||||
* Copyright (C) 2015 Open Whisper Systems
|
|
||||||
*
|
|
||||||
* This program is free software: you can redistribute it and/or modify
|
|
||||||
* it under the terms of the GNU General Public License as published by
|
|
||||||
* the Free Software Foundation, either version 3 of the License, or
|
|
||||||
* (at your option) any later version.
|
|
||||||
*
|
|
||||||
* This program is distributed in the hope that it will be useful,
|
|
||||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
|
||||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
|
||||||
* GNU General Public License for more details.
|
|
||||||
*
|
|
||||||
* You should have received a copy of the GNU General Public License
|
|
||||||
* along with this program. If not, see <http://www.gnu.org/licenses/>.
|
|
||||||
*/
|
|
||||||
package org.thoughtcrime.securesms;
|
|
||||||
|
|
||||||
import android.content.Context;
|
|
||||||
import androidx.annotation.NonNull;
|
|
||||||
import android.view.LayoutInflater;
|
|
||||||
import android.view.View;
|
|
||||||
import android.view.ViewGroup;
|
|
||||||
import android.widget.TextView;
|
|
||||||
|
|
||||||
import com.codewaves.stickyheadergrid.StickyHeaderGridAdapter;
|
|
||||||
|
|
||||||
|
|
||||||
import org.thoughtcrime.securesms.conversation.v2.utilities.ThumbnailView;
|
|
||||||
import org.thoughtcrime.securesms.database.MediaDatabase.MediaRecord;
|
|
||||||
import org.thoughtcrime.securesms.database.loaders.BucketedThreadMediaLoader.BucketedThreadMedia;
|
|
||||||
import com.bumptech.glide.RequestManager;
|
|
||||||
import org.thoughtcrime.securesms.mms.Slide;
|
|
||||||
import org.thoughtcrime.securesms.util.MediaUtil;
|
|
||||||
|
|
||||||
import java.util.Collection;
|
|
||||||
import java.util.HashSet;
|
|
||||||
import java.util.Locale;
|
|
||||||
import java.util.Set;
|
|
||||||
|
|
||||||
import network.loki.messenger.R;
|
|
||||||
|
|
||||||
class MediaGalleryAdapter extends StickyHeaderGridAdapter {
|
|
||||||
|
|
||||||
@SuppressWarnings("unused")
|
|
||||||
private static final String TAG = MediaGalleryAdapter.class.getSimpleName();
|
|
||||||
|
|
||||||
private final Context context;
|
|
||||||
private final RequestManager glideRequests;
|
|
||||||
private final Locale locale;
|
|
||||||
private final ItemClickListener itemClickListener;
|
|
||||||
private final Set<MediaRecord> selected;
|
|
||||||
|
|
||||||
private BucketedThreadMedia media;
|
|
||||||
|
|
||||||
private static class ViewHolder extends StickyHeaderGridAdapter.ItemViewHolder {
|
|
||||||
ThumbnailView imageView;
|
|
||||||
View selectedIndicator;
|
|
||||||
|
|
||||||
ViewHolder(View v) {
|
|
||||||
super(v);
|
|
||||||
imageView = v.findViewById(R.id.image);
|
|
||||||
selectedIndicator = v.findViewById(R.id.selected_indicator);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private static class HeaderHolder extends StickyHeaderGridAdapter.HeaderViewHolder {
|
|
||||||
TextView textView;
|
|
||||||
|
|
||||||
HeaderHolder(View itemView) {
|
|
||||||
super(itemView);
|
|
||||||
textView = itemView.findViewById(R.id.text);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
MediaGalleryAdapter(@NonNull Context context,
|
|
||||||
@NonNull RequestManager glideRequests,
|
|
||||||
BucketedThreadMedia media,
|
|
||||||
Locale locale,
|
|
||||||
ItemClickListener clickListener)
|
|
||||||
{
|
|
||||||
this.context = context;
|
|
||||||
this.glideRequests = glideRequests;
|
|
||||||
this.locale = locale;
|
|
||||||
this.media = media;
|
|
||||||
this.itemClickListener = clickListener;
|
|
||||||
this.selected = new HashSet<>();
|
|
||||||
}
|
|
||||||
|
|
||||||
public void setMedia(BucketedThreadMedia media) {
|
|
||||||
this.media = media;
|
|
||||||
}
|
|
||||||
|
|
||||||
@Override
|
|
||||||
public StickyHeaderGridAdapter.HeaderViewHolder onCreateHeaderViewHolder(ViewGroup parent, int headerType) {
|
|
||||||
return new HeaderHolder(LayoutInflater.from(context).inflate(R.layout.media_overview_gallery_item_header, parent, false));
|
|
||||||
}
|
|
||||||
|
|
||||||
@Override
|
|
||||||
public ItemViewHolder onCreateItemViewHolder(ViewGroup parent, int itemType) {
|
|
||||||
return new ViewHolder(LayoutInflater.from(context).inflate(R.layout.media_overview_gallery_item, parent, false));
|
|
||||||
}
|
|
||||||
|
|
||||||
@Override
|
|
||||||
public void onBindHeaderViewHolder(StickyHeaderGridAdapter.HeaderViewHolder viewHolder, int section) {
|
|
||||||
((HeaderHolder)viewHolder).textView.setText(media.getName(section, locale));
|
|
||||||
}
|
|
||||||
|
|
||||||
@Override
|
|
||||||
public void onBindItemViewHolder(ItemViewHolder viewHolder, int section, int offset) {
|
|
||||||
MediaRecord mediaRecord = media.get(section, offset);
|
|
||||||
ThumbnailView thumbnailView = ((ViewHolder)viewHolder).imageView;
|
|
||||||
View selectedIndicator = ((ViewHolder)viewHolder).selectedIndicator;
|
|
||||||
Slide slide = MediaUtil.getSlideForAttachment(context, mediaRecord.getAttachment());
|
|
||||||
|
|
||||||
if (slide != null) {
|
|
||||||
thumbnailView.setImageResource(glideRequests, slide, false);
|
|
||||||
}
|
|
||||||
|
|
||||||
thumbnailView.setOnClickListener(view -> itemClickListener.onMediaClicked(mediaRecord));
|
|
||||||
thumbnailView.setOnLongClickListener(view -> {
|
|
||||||
itemClickListener.onMediaLongClicked(mediaRecord);
|
|
||||||
return true;
|
|
||||||
});
|
|
||||||
|
|
||||||
selectedIndicator.setVisibility(selected.contains(mediaRecord) ? View.VISIBLE : View.GONE);
|
|
||||||
}
|
|
||||||
|
|
||||||
@Override
|
|
||||||
public int getSectionCount() {
|
|
||||||
return media.getSectionCount();
|
|
||||||
}
|
|
||||||
|
|
||||||
@Override
|
|
||||||
public int getSectionItemCount(int section) {
|
|
||||||
return media.getSectionItemCount(section);
|
|
||||||
}
|
|
||||||
|
|
||||||
public void toggleSelection(@NonNull MediaRecord mediaRecord) {
|
|
||||||
if (!selected.remove(mediaRecord)) {
|
|
||||||
selected.add(mediaRecord);
|
|
||||||
}
|
|
||||||
notifyDataSetChanged();
|
|
||||||
}
|
|
||||||
|
|
||||||
public int getSelectedMediaCount() {
|
|
||||||
return selected.size();
|
|
||||||
}
|
|
||||||
|
|
||||||
@NonNull
|
|
||||||
public Collection<MediaRecord> getSelectedMedia() {
|
|
||||||
return new HashSet<>(selected);
|
|
||||||
}
|
|
||||||
|
|
||||||
public void clearSelection() {
|
|
||||||
selected.clear();
|
|
||||||
notifyDataSetChanged();
|
|
||||||
}
|
|
||||||
|
|
||||||
void selectAllMedia() {
|
|
||||||
for (int section = 0; section < media.getSectionCount(); section++) {
|
|
||||||
for (int item = 0; item < media.getSectionItemCount(section); item++) {
|
|
||||||
selected.add(media.get(section, item));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
this.notifyDataSetChanged();
|
|
||||||
}
|
|
||||||
|
|
||||||
interface ItemClickListener {
|
|
||||||
void onMediaClicked(@NonNull MediaRecord mediaRecord);
|
|
||||||
void onMediaLongClicked(MediaRecord mediaRecord);
|
|
||||||
}
|
|
||||||
}
|
|
@ -1,509 +0,0 @@
|
|||||||
/*
|
|
||||||
* Copyright (C) 2015 Open Whisper Systems
|
|
||||||
*
|
|
||||||
* This program is free software: you can redistribute it and/or modify
|
|
||||||
* it under the terms of the GNU General Public License as published by
|
|
||||||
* the Free Software Foundation, either version 3 of the License, or
|
|
||||||
* (at your option) any later version.
|
|
||||||
*
|
|
||||||
* This program is distributed in the hope that it will be useful,
|
|
||||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
|
||||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
|
||||||
* GNU General Public License for more details.
|
|
||||||
*
|
|
||||||
* You should have received a copy of the GNU General Public License
|
|
||||||
* along with this program. If not, see <http://www.gnu.org/licenses/>.
|
|
||||||
*/
|
|
||||||
package org.thoughtcrime.securesms;
|
|
||||||
|
|
||||||
import android.annotation.SuppressLint;
|
|
||||||
import android.content.Context;
|
|
||||||
import android.content.Intent;
|
|
||||||
import android.content.res.Configuration;
|
|
||||||
import android.content.res.Resources;
|
|
||||||
import android.database.Cursor;
|
|
||||||
import android.os.Build;
|
|
||||||
import android.os.Bundle;
|
|
||||||
import android.view.LayoutInflater;
|
|
||||||
import android.view.Menu;
|
|
||||||
import android.view.MenuItem;
|
|
||||||
import android.view.View;
|
|
||||||
import android.view.ViewGroup;
|
|
||||||
import android.view.Window;
|
|
||||||
import android.widget.TextView;
|
|
||||||
import android.widget.Toast;
|
|
||||||
|
|
||||||
import androidx.annotation.NonNull;
|
|
||||||
import androidx.appcompat.app.ActionBar;
|
|
||||||
import androidx.appcompat.app.AlertDialog;
|
|
||||||
import androidx.appcompat.app.AppCompatActivity;
|
|
||||||
import androidx.appcompat.view.ActionMode;
|
|
||||||
import androidx.appcompat.widget.Toolbar;
|
|
||||||
import androidx.core.content.ContextCompat;
|
|
||||||
import androidx.fragment.app.Fragment;
|
|
||||||
import androidx.fragment.app.FragmentActivity;
|
|
||||||
import androidx.fragment.app.FragmentManager;
|
|
||||||
import androidx.fragment.app.FragmentStatePagerAdapter;
|
|
||||||
import androidx.loader.app.LoaderManager;
|
|
||||||
import androidx.loader.content.Loader;
|
|
||||||
import androidx.recyclerview.widget.DividerItemDecoration;
|
|
||||||
import androidx.recyclerview.widget.LinearLayoutManager;
|
|
||||||
import androidx.recyclerview.widget.RecyclerView;
|
|
||||||
import androidx.viewpager.widget.ViewPager;
|
|
||||||
|
|
||||||
import com.codewaves.stickyheadergrid.StickyHeaderGridLayoutManager;
|
|
||||||
import com.google.android.material.tabs.TabLayout;
|
|
||||||
|
|
||||||
import org.session.libsession.messaging.messages.control.DataExtractionNotification;
|
|
||||||
import org.session.libsession.messaging.sending_receiving.MessageSender;
|
|
||||||
import org.session.libsession.snode.SnodeAPI;
|
|
||||||
import org.session.libsession.utilities.Address;
|
|
||||||
import org.thoughtcrime.securesms.database.CursorRecyclerViewAdapter;
|
|
||||||
import org.thoughtcrime.securesms.database.MediaDatabase;
|
|
||||||
import org.thoughtcrime.securesms.database.loaders.BucketedThreadMediaLoader;
|
|
||||||
import org.thoughtcrime.securesms.database.loaders.BucketedThreadMediaLoader.BucketedThreadMedia;
|
|
||||||
import org.thoughtcrime.securesms.database.loaders.ThreadMediaLoader;
|
|
||||||
import com.bumptech.glide.Glide;
|
|
||||||
import org.thoughtcrime.securesms.permissions.Permissions;
|
|
||||||
import org.session.libsession.utilities.recipients.Recipient;
|
|
||||||
import org.thoughtcrime.securesms.util.AttachmentUtil;
|
|
||||||
import org.thoughtcrime.securesms.util.SaveAttachmentTask;
|
|
||||||
import org.thoughtcrime.securesms.util.StickyHeaderDecoration;
|
|
||||||
import org.session.libsession.utilities.Util;
|
|
||||||
import org.session.libsession.utilities.ViewUtil;
|
|
||||||
import org.session.libsession.utilities.task.ProgressDialogAsyncTask;
|
|
||||||
|
|
||||||
import java.util.Collection;
|
|
||||||
import java.util.LinkedList;
|
|
||||||
import java.util.List;
|
|
||||||
import java.util.Locale;
|
|
||||||
|
|
||||||
import kotlin.Unit;
|
|
||||||
import network.loki.messenger.R;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Activity for displaying media attachments in-app
|
|
||||||
*/
|
|
||||||
public class MediaOverviewActivity extends PassphraseRequiredActionBarActivity {
|
|
||||||
|
|
||||||
@SuppressWarnings("unused")
|
|
||||||
private final static String TAG = MediaOverviewActivity.class.getSimpleName();
|
|
||||||
|
|
||||||
public static final String ADDRESS_EXTRA = "address";
|
|
||||||
|
|
||||||
private Toolbar toolbar;
|
|
||||||
private TabLayout tabLayout;
|
|
||||||
private ViewPager viewPager;
|
|
||||||
private Recipient recipient;
|
|
||||||
|
|
||||||
@Override
|
|
||||||
protected void onCreate(Bundle bundle, boolean ready) {
|
|
||||||
setContentView(R.layout.media_overview_activity);
|
|
||||||
|
|
||||||
initializeResources();
|
|
||||||
initializeToolbar();
|
|
||||||
|
|
||||||
this.tabLayout.setupWithViewPager(viewPager);
|
|
||||||
this.viewPager.setAdapter(new MediaOverviewPagerAdapter(getSupportFragmentManager()));
|
|
||||||
}
|
|
||||||
|
|
||||||
@Override
|
|
||||||
public boolean onOptionsItemSelected(MenuItem item) {
|
|
||||||
super.onOptionsItemSelected(item);
|
|
||||||
|
|
||||||
switch (item.getItemId()) {
|
|
||||||
case android.R.id.home: finish(); return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
private void initializeResources() {
|
|
||||||
Address address = getIntent().getParcelableExtra(ADDRESS_EXTRA);
|
|
||||||
|
|
||||||
this.viewPager = ViewUtil.findById(this, R.id.pager);
|
|
||||||
this.toolbar = ViewUtil.findById(this, R.id.toolbar);
|
|
||||||
this.tabLayout = ViewUtil.findById(this, R.id.tab_layout);
|
|
||||||
this.recipient = Recipient.from(this, address, true);
|
|
||||||
}
|
|
||||||
|
|
||||||
private void initializeToolbar() {
|
|
||||||
setSupportActionBar(this.toolbar);
|
|
||||||
ActionBar actionBar = getSupportActionBar();
|
|
||||||
actionBar.setTitle(recipient.toShortString());
|
|
||||||
actionBar.setDisplayHomeAsUpEnabled(true);
|
|
||||||
actionBar.setHomeButtonEnabled(true);
|
|
||||||
this.recipient.addListener(recipient -> {
|
|
||||||
Util.runOnMain(() -> actionBar.setTitle(recipient.toShortString()));
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
public void onEnterMultiSelect() {
|
|
||||||
tabLayout.setEnabled(false);
|
|
||||||
viewPager.setEnabled(false);
|
|
||||||
}
|
|
||||||
|
|
||||||
public void onExitMultiSelect() {
|
|
||||||
tabLayout.setEnabled(true);
|
|
||||||
viewPager.setEnabled(true);
|
|
||||||
}
|
|
||||||
|
|
||||||
private class MediaOverviewPagerAdapter extends FragmentStatePagerAdapter {
|
|
||||||
|
|
||||||
MediaOverviewPagerAdapter(FragmentManager fragmentManager) {
|
|
||||||
super(fragmentManager);
|
|
||||||
}
|
|
||||||
|
|
||||||
@Override
|
|
||||||
public Fragment getItem(int position) {
|
|
||||||
Fragment fragment;
|
|
||||||
|
|
||||||
if (position == 0) fragment = new MediaOverviewGalleryFragment();
|
|
||||||
else if (position == 1) fragment = new MediaOverviewDocumentsFragment();
|
|
||||||
else throw new AssertionError();
|
|
||||||
|
|
||||||
Bundle args = new Bundle();
|
|
||||||
args.putString(MediaOverviewGalleryFragment.ADDRESS_EXTRA, recipient.getAddress().serialize());
|
|
||||||
args.putSerializable(MediaOverviewGalleryFragment.LOCALE_EXTRA, Locale.getDefault());
|
|
||||||
|
|
||||||
fragment.setArguments(args);
|
|
||||||
|
|
||||||
return fragment;
|
|
||||||
}
|
|
||||||
|
|
||||||
@Override
|
|
||||||
public int getCount() {
|
|
||||||
return 2;
|
|
||||||
}
|
|
||||||
|
|
||||||
@Override
|
|
||||||
public CharSequence getPageTitle(int position) {
|
|
||||||
if (position == 0) return getString(R.string.MediaOverviewActivity_Media);
|
|
||||||
else if (position == 1) return getString(R.string.MediaOverviewActivity_Documents);
|
|
||||||
else throw new AssertionError();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
public static abstract class MediaOverviewFragment<T> extends Fragment implements LoaderManager.LoaderCallbacks<T> {
|
|
||||||
|
|
||||||
public static final String ADDRESS_EXTRA = "address";
|
|
||||||
public static final String LOCALE_EXTRA = "locale_extra";
|
|
||||||
|
|
||||||
protected TextView noMedia;
|
|
||||||
protected Recipient recipient;
|
|
||||||
protected RecyclerView recyclerView;
|
|
||||||
protected Locale locale;
|
|
||||||
|
|
||||||
@Override
|
|
||||||
public void onCreate(Bundle bundle) {
|
|
||||||
super.onCreate(bundle);
|
|
||||||
|
|
||||||
String address = getArguments().getString(ADDRESS_EXTRA);
|
|
||||||
Locale locale = (Locale)getArguments().getSerializable(LOCALE_EXTRA);
|
|
||||||
|
|
||||||
if (address == null) throw new AssertionError();
|
|
||||||
if (locale == null) throw new AssertionError();
|
|
||||||
|
|
||||||
this.recipient = Recipient.from(getContext(), Address.fromSerialized(address), true);
|
|
||||||
this.locale = locale;
|
|
||||||
|
|
||||||
getLoaderManager().initLoader(0, null, this);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
public static class MediaOverviewGalleryFragment
|
|
||||||
extends MediaOverviewFragment<BucketedThreadMedia>
|
|
||||||
implements MediaGalleryAdapter.ItemClickListener
|
|
||||||
{
|
|
||||||
|
|
||||||
private StickyHeaderGridLayoutManager gridManager;
|
|
||||||
private ActionMode actionMode;
|
|
||||||
private ActionModeCallback actionModeCallback = new ActionModeCallback();
|
|
||||||
|
|
||||||
@Override
|
|
||||||
public View onCreateView(@NonNull LayoutInflater inflater, ViewGroup container, Bundle savedInstanceState) {
|
|
||||||
View view = inflater.inflate(R.layout.media_overview_gallery_fragment, container, false);
|
|
||||||
|
|
||||||
this.recyclerView = ViewUtil.findById(view, R.id.media_grid);
|
|
||||||
this.noMedia = ViewUtil.findById(view, R.id.no_images);
|
|
||||||
this.gridManager = new StickyHeaderGridLayoutManager(getResources().getInteger(R.integer.media_overview_cols));
|
|
||||||
|
|
||||||
this.recyclerView.setAdapter(new MediaGalleryAdapter(getContext(),
|
|
||||||
Glide.with(this),
|
|
||||||
new BucketedThreadMedia(getContext()),
|
|
||||||
locale,
|
|
||||||
this));
|
|
||||||
this.recyclerView.setLayoutManager(gridManager);
|
|
||||||
this.recyclerView.setHasFixedSize(true);
|
|
||||||
|
|
||||||
return view;
|
|
||||||
}
|
|
||||||
|
|
||||||
@Override
|
|
||||||
public void onConfigurationChanged(Configuration newConfig) {
|
|
||||||
super.onConfigurationChanged(newConfig);
|
|
||||||
if (gridManager != null) {
|
|
||||||
this.gridManager = new StickyHeaderGridLayoutManager(getResources().getInteger(R.integer.media_overview_cols));
|
|
||||||
this.recyclerView.setLayoutManager(gridManager);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@Override
|
|
||||||
public @NonNull Loader<BucketedThreadMedia> onCreateLoader(int i, Bundle bundle) {
|
|
||||||
return new BucketedThreadMediaLoader(getContext(), recipient.getAddress());
|
|
||||||
}
|
|
||||||
|
|
||||||
@Override
|
|
||||||
public void onLoadFinished(@NonNull Loader<BucketedThreadMedia> loader, BucketedThreadMedia bucketedThreadMedia) {
|
|
||||||
((MediaGalleryAdapter) recyclerView.getAdapter()).setMedia(bucketedThreadMedia);
|
|
||||||
((MediaGalleryAdapter) recyclerView.getAdapter()).notifyAllSectionsDataSetChanged();
|
|
||||||
|
|
||||||
noMedia.setVisibility(recyclerView.getAdapter().getItemCount() > 0 ? View.GONE : View.VISIBLE);
|
|
||||||
getActivity().invalidateOptionsMenu();
|
|
||||||
}
|
|
||||||
|
|
||||||
@Override
|
|
||||||
public void onLoaderReset(@NonNull Loader<BucketedThreadMedia> cursorLoader) {
|
|
||||||
((MediaGalleryAdapter) recyclerView.getAdapter()).setMedia(new BucketedThreadMedia(getContext()));
|
|
||||||
}
|
|
||||||
|
|
||||||
@Override
|
|
||||||
public void onMediaClicked(@NonNull MediaDatabase.MediaRecord mediaRecord) {
|
|
||||||
if (actionMode != null) {
|
|
||||||
handleMediaMultiSelectClick(mediaRecord);
|
|
||||||
} else {
|
|
||||||
handleMediaPreviewClick(mediaRecord);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private void handleMediaMultiSelectClick(@NonNull MediaDatabase.MediaRecord mediaRecord) {
|
|
||||||
MediaGalleryAdapter adapter = getListAdapter();
|
|
||||||
|
|
||||||
adapter.toggleSelection(mediaRecord);
|
|
||||||
if (adapter.getSelectedMediaCount() == 0) {
|
|
||||||
actionMode.finish();
|
|
||||||
} else {
|
|
||||||
actionMode.setTitle(String.valueOf(adapter.getSelectedMediaCount()));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private void handleMediaPreviewClick(@NonNull MediaDatabase.MediaRecord mediaRecord) {
|
|
||||||
if (mediaRecord.getAttachment().getDataUri() == null) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
Context context = getContext();
|
|
||||||
if (context == null) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
Intent intent = new Intent(context, MediaPreviewActivity.class);
|
|
||||||
intent.putExtra(MediaPreviewActivity.DATE_EXTRA, mediaRecord.getDate());
|
|
||||||
intent.putExtra(MediaPreviewActivity.SIZE_EXTRA, mediaRecord.getAttachment().getSize());
|
|
||||||
intent.putExtra(MediaPreviewActivity.ADDRESS_EXTRA, recipient.getAddress());
|
|
||||||
intent.putExtra(MediaPreviewActivity.OUTGOING_EXTRA, mediaRecord.isOutgoing());
|
|
||||||
intent.putExtra(MediaPreviewActivity.LEFT_IS_RECENT_EXTRA, true);
|
|
||||||
|
|
||||||
intent.setDataAndType(mediaRecord.getAttachment().getDataUri(), mediaRecord.getContentType());
|
|
||||||
context.startActivity(intent);
|
|
||||||
}
|
|
||||||
|
|
||||||
@Override
|
|
||||||
public void onMediaLongClicked(MediaDatabase.MediaRecord mediaRecord) {
|
|
||||||
if (actionMode == null) {
|
|
||||||
((MediaGalleryAdapter) recyclerView.getAdapter()).toggleSelection(mediaRecord);
|
|
||||||
recyclerView.getAdapter().notifyDataSetChanged();
|
|
||||||
|
|
||||||
enterMultiSelect();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@SuppressWarnings("CodeBlock2Expr")
|
|
||||||
@SuppressLint({"InlinedApi", "StaticFieldLeak"})
|
|
||||||
private void handleSaveMedia(@NonNull Collection<MediaDatabase.MediaRecord> mediaRecords) {
|
|
||||||
final Context context = requireContext();
|
|
||||||
|
|
||||||
SaveAttachmentTask.showWarningDialog(context, mediaRecords.size(), () -> {
|
|
||||||
Permissions.with(this)
|
|
||||||
.request(android.Manifest.permission.WRITE_EXTERNAL_STORAGE)
|
|
||||||
.maxSdkVersion(Build.VERSION_CODES.P)
|
|
||||||
.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(getContext(), R.string.MediaPreviewActivity_unable_to_write_to_external_storage_without_permission, Toast.LENGTH_LONG).show())
|
|
||||||
.onAllGranted(() -> {
|
|
||||||
new ProgressDialogAsyncTask<Void, Void, List<SaveAttachmentTask.Attachment>>(
|
|
||||||
context,
|
|
||||||
R.string.MediaOverviewActivity_collecting_attachments,
|
|
||||||
R.string.please_wait) {
|
|
||||||
@Override
|
|
||||||
protected List<SaveAttachmentTask.Attachment> doInBackground(Void... params) {
|
|
||||||
List<SaveAttachmentTask.Attachment> attachments = new LinkedList<>();
|
|
||||||
|
|
||||||
for (MediaDatabase.MediaRecord mediaRecord : mediaRecords) {
|
|
||||||
if (mediaRecord.getAttachment().getDataUri() != null) {
|
|
||||||
attachments.add(new SaveAttachmentTask.Attachment(mediaRecord.getAttachment().getDataUri(),
|
|
||||||
mediaRecord.getContentType(),
|
|
||||||
mediaRecord.getDate(),
|
|
||||||
mediaRecord.getAttachment().getFileName()));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return attachments;
|
|
||||||
}
|
|
||||||
|
|
||||||
@Override
|
|
||||||
protected void onPostExecute(List<SaveAttachmentTask.Attachment> attachments) {
|
|
||||||
super.onPostExecute(attachments);
|
|
||||||
SaveAttachmentTask saveTask = new SaveAttachmentTask(context, attachments.size());
|
|
||||||
saveTask.executeOnExecutor(THREAD_POOL_EXECUTOR,
|
|
||||||
attachments.toArray(new SaveAttachmentTask.Attachment[attachments.size()]));
|
|
||||||
actionMode.finish();
|
|
||||||
boolean containsIncoming = mediaRecords.parallelStream().anyMatch(m -> !m.isOutgoing());
|
|
||||||
if (containsIncoming) {
|
|
||||||
sendMediaSavedNotificationIfNeeded();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}.execute();
|
|
||||||
})
|
|
||||||
.execute();
|
|
||||||
return Unit.INSTANCE;
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
private void sendMediaSavedNotificationIfNeeded() {
|
|
||||||
if (recipient.isGroupRecipient()) return;
|
|
||||||
DataExtractionNotification message = new DataExtractionNotification(new DataExtractionNotification.Kind.MediaSaved(SnodeAPI.getNowWithOffset()));
|
|
||||||
MessageSender.send(message, recipient.getAddress());
|
|
||||||
}
|
|
||||||
|
|
||||||
@SuppressLint("StaticFieldLeak")
|
|
||||||
private void handleDeleteMedia(@NonNull Collection<MediaDatabase.MediaRecord> mediaRecords) {
|
|
||||||
int recordCount = mediaRecords.size();
|
|
||||||
|
|
||||||
DeleteMediaDialog.show(
|
|
||||||
requireContext(),
|
|
||||||
recordCount,
|
|
||||||
() -> new ProgressDialogAsyncTask<MediaDatabase.MediaRecord, Void, Void>(
|
|
||||||
requireContext(),
|
|
||||||
R.string.MediaOverviewActivity_Media_delete_progress_title,
|
|
||||||
R.string.MediaOverviewActivity_Media_delete_progress_message) {
|
|
||||||
@Override
|
|
||||||
protected Void doInBackground(MediaDatabase.MediaRecord... records) {
|
|
||||||
if (records == null || records.length == 0) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
for (MediaDatabase.MediaRecord record : records) {
|
|
||||||
AttachmentUtil.deleteAttachment(getContext(), record.getAttachment());
|
|
||||||
}
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
}.execute(mediaRecords.toArray(new MediaDatabase.MediaRecord[mediaRecords.size()])));
|
|
||||||
}
|
|
||||||
|
|
||||||
private void handleSelectAllMedia() {
|
|
||||||
getListAdapter().selectAllMedia();
|
|
||||||
actionMode.setTitle(String.valueOf(getListAdapter().getSelectedMediaCount()));
|
|
||||||
}
|
|
||||||
|
|
||||||
private MediaGalleryAdapter getListAdapter() {
|
|
||||||
return (MediaGalleryAdapter) recyclerView.getAdapter();
|
|
||||||
}
|
|
||||||
|
|
||||||
private void enterMultiSelect() {
|
|
||||||
actionMode = ((AppCompatActivity) getActivity()).startSupportActionMode(actionModeCallback);
|
|
||||||
((MediaOverviewActivity) getActivity()).onEnterMultiSelect();
|
|
||||||
}
|
|
||||||
|
|
||||||
private class ActionModeCallback implements ActionMode.Callback {
|
|
||||||
|
|
||||||
private int originalStatusBarColor;
|
|
||||||
|
|
||||||
@Override
|
|
||||||
public boolean onCreateActionMode(ActionMode mode, Menu menu) {
|
|
||||||
mode.getMenuInflater().inflate(R.menu.media_overview_context, menu);
|
|
||||||
mode.setTitle("1");
|
|
||||||
|
|
||||||
FragmentActivity activity = getActivity();
|
|
||||||
if (activity == null) return false;
|
|
||||||
|
|
||||||
Window window = activity.getWindow();
|
|
||||||
originalStatusBarColor = window.getStatusBarColor();
|
|
||||||
window.setStatusBarColor(ContextCompat.getColor(activity, R.color.action_mode_status_bar));
|
|
||||||
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
@Override
|
|
||||||
public boolean onPrepareActionMode(ActionMode mode, Menu menu) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
@Override
|
|
||||||
public boolean onActionItemClicked(ActionMode mode, MenuItem menuItem) {
|
|
||||||
switch (menuItem.getItemId()) {
|
|
||||||
case R.id.save:
|
|
||||||
handleSaveMedia(getListAdapter().getSelectedMedia());
|
|
||||||
return true;
|
|
||||||
case R.id.delete:
|
|
||||||
handleDeleteMedia(getListAdapter().getSelectedMedia());
|
|
||||||
actionMode.finish();
|
|
||||||
return true;
|
|
||||||
case R.id.select_all:
|
|
||||||
handleSelectAllMedia();
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
@Override
|
|
||||||
public void onDestroyActionMode(ActionMode mode) {
|
|
||||||
actionMode = null;
|
|
||||||
getListAdapter().clearSelection();
|
|
||||||
|
|
||||||
MediaOverviewActivity activity = ((MediaOverviewActivity) getActivity());
|
|
||||||
if(activity == null) return;
|
|
||||||
|
|
||||||
activity.onExitMultiSelect();
|
|
||||||
activity.getWindow().setStatusBarColor(originalStatusBarColor);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
public static class MediaOverviewDocumentsFragment extends MediaOverviewFragment<Cursor> {
|
|
||||||
|
|
||||||
@Override
|
|
||||||
public View onCreateView(@NonNull LayoutInflater inflater, ViewGroup container, Bundle savedInstanceState) {
|
|
||||||
View view = inflater.inflate(R.layout.media_overview_documents_fragment, container, false);
|
|
||||||
MediaDocumentsAdapter adapter = new MediaDocumentsAdapter(getContext(), null, locale);
|
|
||||||
|
|
||||||
this.recyclerView = ViewUtil.findById(view, R.id.recycler_view);
|
|
||||||
this.noMedia = ViewUtil.findById(view, R.id.no_documents);
|
|
||||||
|
|
||||||
this.recyclerView.setAdapter(adapter);
|
|
||||||
this.recyclerView.setLayoutManager(new LinearLayoutManager(getContext(), LinearLayoutManager.VERTICAL, false));
|
|
||||||
this.recyclerView.addItemDecoration(new StickyHeaderDecoration(adapter, false, true));
|
|
||||||
this.recyclerView.addItemDecoration(new DividerItemDecoration(getContext(), DividerItemDecoration.VERTICAL));
|
|
||||||
|
|
||||||
return view;
|
|
||||||
}
|
|
||||||
|
|
||||||
@Override
|
|
||||||
public @NonNull Loader<Cursor> onCreateLoader(int id, Bundle args) {
|
|
||||||
return new ThreadMediaLoader(getContext(), recipient.getAddress(), false);
|
|
||||||
}
|
|
||||||
|
|
||||||
@Override
|
|
||||||
public void onLoadFinished(@NonNull Loader<Cursor> loader, Cursor data) {
|
|
||||||
((CursorRecyclerViewAdapter)this.recyclerView.getAdapter()).changeCursor(data);
|
|
||||||
getActivity().invalidateOptionsMenu();
|
|
||||||
|
|
||||||
this.noMedia.setVisibility(data.getCount() > 0 ? View.GONE : View.VISIBLE);
|
|
||||||
}
|
|
||||||
|
|
||||||
@Override
|
|
||||||
public void onLoaderReset(@NonNull Loader<Cursor> loader) {
|
|
||||||
((CursorRecyclerViewAdapter)this.recyclerView.getAdapter()).changeCursor(null);
|
|
||||||
getActivity().invalidateOptionsMenu();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
@ -71,6 +71,7 @@ import org.thoughtcrime.securesms.components.MediaView;
|
|||||||
import org.thoughtcrime.securesms.database.MediaDatabase.MediaRecord;
|
import org.thoughtcrime.securesms.database.MediaDatabase.MediaRecord;
|
||||||
import org.thoughtcrime.securesms.database.loaders.PagingMediaLoader;
|
import org.thoughtcrime.securesms.database.loaders.PagingMediaLoader;
|
||||||
import org.thoughtcrime.securesms.database.model.MmsMessageRecord;
|
import org.thoughtcrime.securesms.database.model.MmsMessageRecord;
|
||||||
|
import org.thoughtcrime.securesms.media.MediaOverviewActivity;
|
||||||
import org.thoughtcrime.securesms.mediapreview.MediaPreviewViewModel;
|
import org.thoughtcrime.securesms.mediapreview.MediaPreviewViewModel;
|
||||||
import org.thoughtcrime.securesms.mediapreview.MediaRailAdapter;
|
import org.thoughtcrime.securesms.mediapreview.MediaRailAdapter;
|
||||||
import org.thoughtcrime.securesms.mms.Slide;
|
import org.thoughtcrime.securesms.mms.Slide;
|
||||||
@ -390,9 +391,7 @@ public class MediaPreviewActivity extends PassphraseRequiredActionBarActivity im
|
|||||||
}
|
}
|
||||||
|
|
||||||
private void showOverview() {
|
private void showOverview() {
|
||||||
Intent intent = new Intent(this, MediaOverviewActivity.class);
|
startActivity(MediaOverviewActivity.createIntent(this, conversationRecipient.getAddress()));
|
||||||
intent.putExtra(MediaOverviewActivity.ADDRESS_EXTRA, conversationRecipient.getAddress());
|
|
||||||
startActivity(intent);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private void forward() {
|
private void forward() {
|
||||||
|
@ -7,11 +7,13 @@ import androidx.compose.foundation.layout.height
|
|||||||
import androidx.compose.foundation.layout.padding
|
import androidx.compose.foundation.layout.padding
|
||||||
import androidx.compose.foundation.rememberScrollState
|
import androidx.compose.foundation.rememberScrollState
|
||||||
import androidx.compose.foundation.verticalScroll
|
import androidx.compose.foundation.verticalScroll
|
||||||
|
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||||
import androidx.compose.material3.MaterialTheme
|
import androidx.compose.material3.MaterialTheme
|
||||||
import androidx.compose.material3.Surface
|
import androidx.compose.material3.Surface
|
||||||
import androidx.compose.material3.Text
|
import androidx.compose.material3.Text
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.graphics.Color
|
||||||
import androidx.compose.ui.input.nestedscroll.nestedScroll
|
import androidx.compose.ui.input.nestedscroll.nestedScroll
|
||||||
import androidx.compose.ui.platform.rememberNestedScrollInteropConnection
|
import androidx.compose.ui.platform.rememberNestedScrollInteropConnection
|
||||||
import androidx.compose.ui.res.stringResource
|
import androidx.compose.ui.res.stringResource
|
||||||
@ -22,16 +24,18 @@ import org.thoughtcrime.securesms.conversation.start.NullStartConversationDelega
|
|||||||
import org.thoughtcrime.securesms.conversation.start.StartConversationDelegate
|
import org.thoughtcrime.securesms.conversation.start.StartConversationDelegate
|
||||||
import org.thoughtcrime.securesms.ui.Divider
|
import org.thoughtcrime.securesms.ui.Divider
|
||||||
import org.thoughtcrime.securesms.ui.ItemButton
|
import org.thoughtcrime.securesms.ui.ItemButton
|
||||||
|
import org.thoughtcrime.securesms.ui.components.AppBarCloseIcon
|
||||||
|
import org.thoughtcrime.securesms.ui.components.BasicAppBar
|
||||||
|
import org.thoughtcrime.securesms.ui.components.QrImage
|
||||||
|
import org.thoughtcrime.securesms.ui.contentDescription
|
||||||
|
import org.thoughtcrime.securesms.ui.theme.LocalColors
|
||||||
import org.thoughtcrime.securesms.ui.theme.LocalDimensions
|
import org.thoughtcrime.securesms.ui.theme.LocalDimensions
|
||||||
|
import org.thoughtcrime.securesms.ui.theme.LocalType
|
||||||
import org.thoughtcrime.securesms.ui.theme.PreviewTheme
|
import org.thoughtcrime.securesms.ui.theme.PreviewTheme
|
||||||
import org.thoughtcrime.securesms.ui.theme.SessionColorsParameterProvider
|
import org.thoughtcrime.securesms.ui.theme.SessionColorsParameterProvider
|
||||||
import org.thoughtcrime.securesms.ui.theme.ThemeColors
|
import org.thoughtcrime.securesms.ui.theme.ThemeColors
|
||||||
import org.thoughtcrime.securesms.ui.theme.LocalColors
|
|
||||||
import org.thoughtcrime.securesms.ui.components.AppBar
|
|
||||||
import org.thoughtcrime.securesms.ui.components.QrImage
|
|
||||||
import org.thoughtcrime.securesms.ui.contentDescription
|
|
||||||
import org.thoughtcrime.securesms.ui.theme.LocalType
|
|
||||||
|
|
||||||
|
@OptIn(ExperimentalMaterial3Api::class)
|
||||||
@Composable
|
@Composable
|
||||||
internal fun StartConversationScreen(
|
internal fun StartConversationScreen(
|
||||||
accountId: String,
|
accountId: String,
|
||||||
@ -41,7 +45,11 @@ internal fun StartConversationScreen(
|
|||||||
LocalColors.current.backgroundSecondary,
|
LocalColors.current.backgroundSecondary,
|
||||||
shape = MaterialTheme.shapes.small
|
shape = MaterialTheme.shapes.small
|
||||||
)) {
|
)) {
|
||||||
AppBar(stringResource(R.string.dialog_start_conversation_title), onClose = delegate::onDialogClosePressed)
|
BasicAppBar(
|
||||||
|
title = stringResource(R.string.dialog_start_conversation_title),
|
||||||
|
backgroundColor = Color.Transparent, // transparent to show the rounded shape of the container
|
||||||
|
actions = { AppBarCloseIcon(onClose = delegate::onDialogClosePressed) }
|
||||||
|
)
|
||||||
Surface(
|
Surface(
|
||||||
modifier = Modifier.nestedScroll(rememberNestedScrollInteropConnection()),
|
modifier = Modifier.nestedScroll(rememberNestedScrollInteropConnection()),
|
||||||
color = LocalColors.current.backgroundSecondary
|
color = LocalColors.current.backgroundSecondary
|
||||||
|
@ -8,24 +8,28 @@ import androidx.compose.foundation.layout.Spacer
|
|||||||
import androidx.compose.foundation.layout.fillMaxWidth
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
import androidx.compose.foundation.layout.height
|
import androidx.compose.foundation.layout.height
|
||||||
import androidx.compose.foundation.layout.padding
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||||
import androidx.compose.material3.MaterialTheme
|
import androidx.compose.material3.MaterialTheme
|
||||||
import androidx.compose.material3.Text
|
import androidx.compose.material3.Text
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.graphics.Color
|
||||||
import androidx.compose.ui.res.stringResource
|
import androidx.compose.ui.res.stringResource
|
||||||
import androidx.compose.ui.text.style.TextAlign
|
import androidx.compose.ui.text.style.TextAlign
|
||||||
import androidx.compose.ui.tooling.preview.Preview
|
import androidx.compose.ui.tooling.preview.Preview
|
||||||
import network.loki.messenger.R
|
import network.loki.messenger.R
|
||||||
import org.thoughtcrime.securesms.ui.theme.LocalDimensions
|
import org.thoughtcrime.securesms.ui.components.AppBarCloseIcon
|
||||||
import org.thoughtcrime.securesms.ui.theme.PreviewTheme
|
import org.thoughtcrime.securesms.ui.components.BackAppBar
|
||||||
import org.thoughtcrime.securesms.ui.theme.LocalColors
|
|
||||||
import org.thoughtcrime.securesms.ui.components.AppBar
|
|
||||||
import org.thoughtcrime.securesms.ui.components.SlimOutlineButton
|
import org.thoughtcrime.securesms.ui.components.SlimOutlineButton
|
||||||
import org.thoughtcrime.securesms.ui.components.SlimOutlineCopyButton
|
import org.thoughtcrime.securesms.ui.components.SlimOutlineCopyButton
|
||||||
import org.thoughtcrime.securesms.ui.components.border
|
import org.thoughtcrime.securesms.ui.components.border
|
||||||
import org.thoughtcrime.securesms.ui.contentDescription
|
import org.thoughtcrime.securesms.ui.contentDescription
|
||||||
|
import org.thoughtcrime.securesms.ui.theme.LocalColors
|
||||||
|
import org.thoughtcrime.securesms.ui.theme.LocalDimensions
|
||||||
import org.thoughtcrime.securesms.ui.theme.LocalType
|
import org.thoughtcrime.securesms.ui.theme.LocalType
|
||||||
|
import org.thoughtcrime.securesms.ui.theme.PreviewTheme
|
||||||
|
|
||||||
|
@OptIn(ExperimentalMaterial3Api::class)
|
||||||
@Composable
|
@Composable
|
||||||
internal fun InviteFriend(
|
internal fun InviteFriend(
|
||||||
accountId: String,
|
accountId: String,
|
||||||
@ -38,7 +42,12 @@ internal fun InviteFriend(
|
|||||||
LocalColors.current.backgroundSecondary,
|
LocalColors.current.backgroundSecondary,
|
||||||
shape = MaterialTheme.shapes.small
|
shape = MaterialTheme.shapes.small
|
||||||
)) {
|
)) {
|
||||||
AppBar(stringResource(R.string.invite_a_friend), onBack = onBack, onClose = onClose)
|
BackAppBar(
|
||||||
|
title = stringResource(R.string.invite_a_friend),
|
||||||
|
backgroundColor = Color.Transparent, // transparent to show the rounded shape of the container
|
||||||
|
onBack = onBack,
|
||||||
|
actions = { AppBarCloseIcon(onClose = onClose) }
|
||||||
|
)
|
||||||
Column(
|
Column(
|
||||||
modifier = Modifier.padding(horizontal = LocalDimensions.current.spacing)
|
modifier = Modifier.padding(horizontal = LocalDimensions.current.spacing)
|
||||||
.padding(top = LocalDimensions.current.spacing),
|
.padding(top = LocalDimensions.current.spacing),
|
||||||
|
@ -17,6 +17,7 @@ import androidx.compose.foundation.pager.HorizontalPager
|
|||||||
import androidx.compose.foundation.pager.rememberPagerState
|
import androidx.compose.foundation.pager.rememberPagerState
|
||||||
import androidx.compose.foundation.rememberScrollState
|
import androidx.compose.foundation.rememberScrollState
|
||||||
import androidx.compose.foundation.verticalScroll
|
import androidx.compose.foundation.verticalScroll
|
||||||
|
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||||
import androidx.compose.material3.MaterialTheme
|
import androidx.compose.material3.MaterialTheme
|
||||||
import androidx.compose.material3.Scaffold
|
import androidx.compose.material3.Scaffold
|
||||||
import androidx.compose.material3.Surface
|
import androidx.compose.material3.Surface
|
||||||
@ -29,6 +30,7 @@ import androidx.compose.runtime.mutableStateOf
|
|||||||
import androidx.compose.runtime.remember
|
import androidx.compose.runtime.remember
|
||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.graphics.Color
|
||||||
import androidx.compose.ui.input.nestedscroll.nestedScroll
|
import androidx.compose.ui.input.nestedscroll.nestedScroll
|
||||||
import androidx.compose.ui.platform.LocalDensity
|
import androidx.compose.ui.platform.LocalDensity
|
||||||
import androidx.compose.ui.platform.LocalView
|
import androidx.compose.ui.platform.LocalView
|
||||||
@ -43,7 +45,8 @@ import kotlinx.coroutines.flow.emptyFlow
|
|||||||
import network.loki.messenger.R
|
import network.loki.messenger.R
|
||||||
import org.thoughtcrime.securesms.conversation.start.StartConversationFragment.Companion.PEEK_RATIO
|
import org.thoughtcrime.securesms.conversation.start.StartConversationFragment.Companion.PEEK_RATIO
|
||||||
import org.thoughtcrime.securesms.ui.LoadingArcOr
|
import org.thoughtcrime.securesms.ui.LoadingArcOr
|
||||||
import org.thoughtcrime.securesms.ui.components.AppBar
|
import org.thoughtcrime.securesms.ui.components.AppBarCloseIcon
|
||||||
|
import org.thoughtcrime.securesms.ui.components.BackAppBar
|
||||||
import org.thoughtcrime.securesms.ui.components.BorderlessButtonWithIcon
|
import org.thoughtcrime.securesms.ui.components.BorderlessButtonWithIcon
|
||||||
import org.thoughtcrime.securesms.ui.components.MaybeScanQrCode
|
import org.thoughtcrime.securesms.ui.components.MaybeScanQrCode
|
||||||
import org.thoughtcrime.securesms.ui.components.PrimaryOutlineButton
|
import org.thoughtcrime.securesms.ui.components.PrimaryOutlineButton
|
||||||
@ -60,7 +63,7 @@ import kotlin.math.max
|
|||||||
|
|
||||||
private val TITLES = listOf(R.string.enter_account_id, R.string.qrScan)
|
private val TITLES = listOf(R.string.enter_account_id, R.string.qrScan)
|
||||||
|
|
||||||
@OptIn(ExperimentalFoundationApi::class)
|
@OptIn(ExperimentalFoundationApi::class, ExperimentalMaterial3Api::class)
|
||||||
@Composable
|
@Composable
|
||||||
internal fun NewMessage(
|
internal fun NewMessage(
|
||||||
state: State,
|
state: State,
|
||||||
@ -76,7 +79,12 @@ internal fun NewMessage(
|
|||||||
LocalColors.current.backgroundSecondary,
|
LocalColors.current.backgroundSecondary,
|
||||||
shape = MaterialTheme.shapes.small
|
shape = MaterialTheme.shapes.small
|
||||||
)) {
|
)) {
|
||||||
AppBar(stringResource(R.string.messageNew), onClose = onClose, onBack = onBack)
|
BackAppBar(
|
||||||
|
title = stringResource(R.string.messageNew),
|
||||||
|
backgroundColor = Color.Transparent, // transparent to show the rounded shape of the container
|
||||||
|
onBack = onBack,
|
||||||
|
actions = { AppBarCloseIcon(onClose = onClose) }
|
||||||
|
)
|
||||||
SessionTabRow(pagerState, TITLES)
|
SessionTabRow(pagerState, TITLES)
|
||||||
HorizontalPager(pagerState) {
|
HorizontalPager(pagerState) {
|
||||||
when (TITLES[it]) {
|
when (TITLES[it]) {
|
||||||
|
@ -24,7 +24,7 @@ import org.session.libsession.utilities.TextSecurePreferences
|
|||||||
import org.session.libsession.utilities.recipients.Recipient
|
import org.session.libsession.utilities.recipients.Recipient
|
||||||
import org.session.libsignal.utilities.guava.Optional
|
import org.session.libsignal.utilities.guava.Optional
|
||||||
import org.session.libsignal.utilities.toHexString
|
import org.session.libsignal.utilities.toHexString
|
||||||
import org.thoughtcrime.securesms.MediaOverviewActivity
|
import org.thoughtcrime.securesms.media.MediaOverviewActivity
|
||||||
import org.thoughtcrime.securesms.ShortcutLauncherActivity
|
import org.thoughtcrime.securesms.ShortcutLauncherActivity
|
||||||
import org.thoughtcrime.securesms.calls.WebRtcCallActivity
|
import org.thoughtcrime.securesms.calls.WebRtcCallActivity
|
||||||
import org.thoughtcrime.securesms.contacts.SelectContactsActivity
|
import org.thoughtcrime.securesms.contacts.SelectContactsActivity
|
||||||
@ -149,10 +149,8 @@ object ConversationMenuHelper {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private fun showAllMedia(context: Context, thread: Recipient) {
|
private fun showAllMedia(context: Context, thread: Recipient) {
|
||||||
val intent = Intent(context, MediaOverviewActivity::class.java)
|
|
||||||
intent.putExtra(MediaOverviewActivity.ADDRESS_EXTRA, thread.address)
|
|
||||||
val activity = context as AppCompatActivity
|
val activity = context as AppCompatActivity
|
||||||
activity.startActivity(intent)
|
activity.startActivity(MediaOverviewActivity.createIntent(context, thread.address))
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun search(context: Context) {
|
private fun search(context: Context) {
|
||||||
|
@ -0,0 +1,30 @@
|
|||||||
|
package org.thoughtcrime.securesms.media
|
||||||
|
|
||||||
|
import androidx.compose.foundation.background
|
||||||
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import org.thoughtcrime.securesms.ui.theme.LocalColors
|
||||||
|
import org.thoughtcrime.securesms.ui.theme.LocalDimensions
|
||||||
|
import org.thoughtcrime.securesms.ui.theme.LocalType
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun AttachmentHeader(
|
||||||
|
text: String,
|
||||||
|
modifier: Modifier = Modifier
|
||||||
|
){
|
||||||
|
Text(
|
||||||
|
modifier = modifier
|
||||||
|
.background(LocalColors.current.background)
|
||||||
|
.fillMaxWidth()
|
||||||
|
.padding(
|
||||||
|
horizontal = LocalDimensions.current.smallSpacing,
|
||||||
|
vertical = LocalDimensions.current.xsSpacing
|
||||||
|
),
|
||||||
|
text = text,
|
||||||
|
style = LocalType.current.xl,
|
||||||
|
color = LocalColors.current.text
|
||||||
|
)
|
||||||
|
}
|
@ -0,0 +1,111 @@
|
|||||||
|
package org.thoughtcrime.securesms.media
|
||||||
|
|
||||||
|
import androidx.compose.foundation.ExperimentalFoundationApi
|
||||||
|
import androidx.compose.foundation.Image
|
||||||
|
import androidx.compose.foundation.background
|
||||||
|
import androidx.compose.foundation.clickable
|
||||||
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
|
import androidx.compose.foundation.layout.Box
|
||||||
|
import androidx.compose.foundation.layout.Column
|
||||||
|
import androidx.compose.foundation.layout.Row
|
||||||
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.foundation.lazy.LazyColumn
|
||||||
|
import androidx.compose.foundation.lazy.items
|
||||||
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.input.nestedscroll.NestedScrollConnection
|
||||||
|
import androidx.compose.ui.input.nestedscroll.nestedScroll
|
||||||
|
import androidx.compose.ui.res.painterResource
|
||||||
|
import androidx.compose.ui.res.stringResource
|
||||||
|
import androidx.compose.ui.text.style.TextAlign
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import network.loki.messenger.R
|
||||||
|
import org.session.libsession.utilities.Util
|
||||||
|
import org.thoughtcrime.securesms.ui.theme.LocalColors
|
||||||
|
import org.thoughtcrime.securesms.ui.theme.LocalDimensions
|
||||||
|
import org.thoughtcrime.securesms.ui.theme.LocalType
|
||||||
|
|
||||||
|
@OptIn(ExperimentalFoundationApi::class)
|
||||||
|
@Composable
|
||||||
|
fun DocumentsPage(
|
||||||
|
nestedScrollConnection: NestedScrollConnection,
|
||||||
|
content: TabContent?,
|
||||||
|
onItemClicked: (MediaOverviewItem) -> Unit,
|
||||||
|
) {
|
||||||
|
when {
|
||||||
|
content == null -> {
|
||||||
|
// Loading
|
||||||
|
}
|
||||||
|
|
||||||
|
content.isEmpty() -> {
|
||||||
|
Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
|
||||||
|
Text(
|
||||||
|
text = stringResource(R.string.media_overview_documents_fragment__no_documents_found),
|
||||||
|
style = LocalType.current.base,
|
||||||
|
color = LocalColors.current.text
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
else -> {
|
||||||
|
LazyColumn(
|
||||||
|
modifier = Modifier
|
||||||
|
.nestedScroll(nestedScrollConnection)
|
||||||
|
.fillMaxSize()
|
||||||
|
.padding(2.dp),
|
||||||
|
verticalArrangement = Arrangement.spacedBy(LocalDimensions.current.xxsSpacing)
|
||||||
|
) {
|
||||||
|
for ((bucketTitle, files) in content) {
|
||||||
|
stickyHeader {
|
||||||
|
AttachmentHeader(text = bucketTitle)
|
||||||
|
}
|
||||||
|
|
||||||
|
items(files) { file ->
|
||||||
|
Row(
|
||||||
|
modifier = Modifier
|
||||||
|
.clickable(onClick = { onItemClicked(file) })
|
||||||
|
.padding(LocalDimensions.current.smallSpacing),
|
||||||
|
horizontalArrangement = Arrangement.spacedBy(LocalDimensions.current.xxsSpacing),
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
) {
|
||||||
|
Image(
|
||||||
|
painterResource(R.drawable.ic_document_large_dark),
|
||||||
|
contentDescription = null
|
||||||
|
)
|
||||||
|
|
||||||
|
Column(verticalArrangement = Arrangement.spacedBy(LocalDimensions.current.xxxsSpacing)) {
|
||||||
|
Text(
|
||||||
|
text = file.fileName.orEmpty(),
|
||||||
|
style = LocalType.current.large,
|
||||||
|
color = LocalColors.current.text
|
||||||
|
)
|
||||||
|
|
||||||
|
Row(modifier = Modifier.fillMaxWidth()) {
|
||||||
|
Text(
|
||||||
|
modifier = Modifier.weight(1f),
|
||||||
|
text = Util.getPrettyFileSize(file.fileSize),
|
||||||
|
style = LocalType.current.small,
|
||||||
|
color = LocalColors.current.textSecondary,
|
||||||
|
textAlign = TextAlign.Start,
|
||||||
|
)
|
||||||
|
|
||||||
|
Text(
|
||||||
|
text = file.date,
|
||||||
|
style = LocalType.current.small,
|
||||||
|
color = LocalColors.current.textSecondary,
|
||||||
|
textAlign = TextAlign.End,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
@ -0,0 +1,45 @@
|
|||||||
|
package org.thoughtcrime.securesms.media
|
||||||
|
|
||||||
|
import androidx.annotation.StringRes
|
||||||
|
import network.loki.messenger.R
|
||||||
|
import java.time.ZonedDateTime
|
||||||
|
import java.time.temporal.WeekFields
|
||||||
|
import java.util.Locale
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A data structure that describes a series of time points in the past. It's primarily
|
||||||
|
* used to bucket items into categories like "Today", "Yesterday", "This week", "This month", etc.
|
||||||
|
*
|
||||||
|
* Call [getBucketText] to get the appropriate string resource for a given time. If no bucket is
|
||||||
|
* appropriate, it will return null.
|
||||||
|
*/
|
||||||
|
class FixedTimeBuckets(
|
||||||
|
private val startOfToday: ZonedDateTime,
|
||||||
|
private val startOfYesterday: ZonedDateTime,
|
||||||
|
private val startOfThisWeek: ZonedDateTime,
|
||||||
|
private val startOfThisMonth: ZonedDateTime
|
||||||
|
) {
|
||||||
|
constructor(now: ZonedDateTime = ZonedDateTime.now()) : this(
|
||||||
|
startOfToday = now.toLocalDate().atStartOfDay(now.zone),
|
||||||
|
startOfYesterday = now.toLocalDate().minusDays(1).atStartOfDay(now.zone),
|
||||||
|
startOfThisWeek = now.toLocalDate()
|
||||||
|
.with(WeekFields.of(Locale.getDefault()).dayOfWeek(), 1)
|
||||||
|
.atStartOfDay(now.zone),
|
||||||
|
startOfThisMonth = now.toLocalDate().withDayOfMonth(1).atStartOfDay(now.zone)
|
||||||
|
)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Test the given time against the buckets and return the appropriate string resource the time
|
||||||
|
* bucket. If no bucket is appropriate, it will return null.
|
||||||
|
*/
|
||||||
|
@StringRes
|
||||||
|
fun getBucketText(time: ZonedDateTime): Int? {
|
||||||
|
return when {
|
||||||
|
time >= startOfToday -> R.string.BucketedThreadMedia_Today
|
||||||
|
time >= startOfYesterday -> R.string.BucketedThreadMedia_Yesterday
|
||||||
|
time >= startOfThisWeek -> R.string.BucketedThreadMedia_This_week
|
||||||
|
time >= startOfThisMonth -> R.string.BucketedThreadMedia_This_month
|
||||||
|
else -> null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
@ -0,0 +1,43 @@
|
|||||||
|
package org.thoughtcrime.securesms.media
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
import android.content.Intent
|
||||||
|
import android.os.Bundle
|
||||||
|
import androidx.activity.viewModels
|
||||||
|
import androidx.core.content.IntentCompat
|
||||||
|
import dagger.hilt.android.AndroidEntryPoint
|
||||||
|
import org.session.libsession.utilities.Address
|
||||||
|
import org.thoughtcrime.securesms.PassphraseRequiredActionBarActivity
|
||||||
|
import org.thoughtcrime.securesms.ui.setComposeContent
|
||||||
|
import javax.inject.Inject
|
||||||
|
|
||||||
|
@AndroidEntryPoint
|
||||||
|
class MediaOverviewActivity : PassphraseRequiredActionBarActivity() {
|
||||||
|
@Inject
|
||||||
|
lateinit var viewModelFactory: MediaOverviewViewModel.AssistedFactory
|
||||||
|
|
||||||
|
private val viewModel: MediaOverviewViewModel by viewModels {
|
||||||
|
viewModelFactory.create(IntentCompat.getParcelableExtra(intent, EXTRA_ADDRESS, Address::class.java)!!)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onCreate(savedInstanceState: Bundle?) {
|
||||||
|
super.onCreate(savedInstanceState)
|
||||||
|
|
||||||
|
setComposeContent {
|
||||||
|
MediaOverviewScreen(viewModel, onClose = this::finish)
|
||||||
|
}
|
||||||
|
|
||||||
|
supportActionBar?.hide()
|
||||||
|
}
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
private const val EXTRA_ADDRESS = "address"
|
||||||
|
|
||||||
|
@JvmStatic
|
||||||
|
fun createIntent(context: Context, address: Address): Intent {
|
||||||
|
return Intent(context, MediaOverviewActivity::class.java).apply {
|
||||||
|
putExtra(EXTRA_ADDRESS, address)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
@ -0,0 +1,313 @@
|
|||||||
|
package org.thoughtcrime.securesms.media
|
||||||
|
|
||||||
|
import android.Manifest.permission.WRITE_EXTERNAL_STORAGE
|
||||||
|
import android.content.ActivityNotFoundException
|
||||||
|
import android.os.Build
|
||||||
|
import android.widget.Toast
|
||||||
|
import androidx.activity.compose.BackHandler
|
||||||
|
import androidx.activity.compose.rememberLauncherForActivityResult
|
||||||
|
import androidx.activity.result.contract.ActivityResultContracts
|
||||||
|
import androidx.compose.foundation.ExperimentalFoundationApi
|
||||||
|
import androidx.compose.foundation.background
|
||||||
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
|
import androidx.compose.foundation.layout.Column
|
||||||
|
import androidx.compose.foundation.layout.Row
|
||||||
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.foundation.pager.HorizontalPager
|
||||||
|
import androidx.compose.foundation.pager.rememberPagerState
|
||||||
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
|
import androidx.compose.material3.BasicAlertDialog
|
||||||
|
import androidx.compose.material3.CircularProgressIndicator
|
||||||
|
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||||
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.material3.Scaffold
|
||||||
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.material3.TopAppBarDefaults
|
||||||
|
import androidx.compose.material3.rememberTopAppBarState
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
|
import androidx.compose.runtime.collectAsState
|
||||||
|
import androidx.compose.runtime.derivedStateOf
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.runtime.mutableStateOf
|
||||||
|
import androidx.compose.runtime.remember
|
||||||
|
import androidx.compose.runtime.setValue
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.hapticfeedback.HapticFeedbackType
|
||||||
|
import androidx.compose.ui.input.nestedscroll.nestedScroll
|
||||||
|
import androidx.compose.ui.platform.LocalContext
|
||||||
|
import androidx.compose.ui.platform.LocalHapticFeedback
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import network.loki.messenger.R
|
||||||
|
import org.thoughtcrime.securesms.ui.AlertDialog
|
||||||
|
import org.thoughtcrime.securesms.ui.DialogButtonModel
|
||||||
|
import org.thoughtcrime.securesms.ui.GetString
|
||||||
|
import org.thoughtcrime.securesms.ui.components.SessionTabRow
|
||||||
|
import org.thoughtcrime.securesms.ui.theme.LocalColors
|
||||||
|
import org.thoughtcrime.securesms.ui.theme.LocalDimensions
|
||||||
|
import org.thoughtcrime.securesms.ui.theme.LocalType
|
||||||
|
|
||||||
|
@OptIn(
|
||||||
|
ExperimentalFoundationApi::class, ExperimentalMaterial3Api::class,
|
||||||
|
)
|
||||||
|
@Composable
|
||||||
|
fun MediaOverviewScreen(
|
||||||
|
viewModel: MediaOverviewViewModel,
|
||||||
|
onClose: () -> Unit,
|
||||||
|
) {
|
||||||
|
val selectedItems by viewModel.selectedItemIDs.collectAsState()
|
||||||
|
val selectionMode by viewModel.inSelectionMode.collectAsState()
|
||||||
|
val topAppBarState = rememberTopAppBarState()
|
||||||
|
var showingDeleteConfirmation by remember { mutableStateOf(false) }
|
||||||
|
var showingSaveAttachmentWarning by remember { mutableStateOf(false) }
|
||||||
|
val context = LocalContext.current
|
||||||
|
val requestStoragePermission =
|
||||||
|
rememberLauncherForActivityResult(ActivityResultContracts.RequestPermission()) { granted ->
|
||||||
|
if (granted) {
|
||||||
|
viewModel.onSaveClicked()
|
||||||
|
} else {
|
||||||
|
Toast.makeText(
|
||||||
|
context,
|
||||||
|
R.string.AttachmentManager_signal_requires_the_external_storage_permission_in_order_to_attach_photos_videos_or_audio,
|
||||||
|
Toast.LENGTH_LONG
|
||||||
|
).show()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// In selection mode, the app bar should not be scrollable and should be pinned
|
||||||
|
val appBarScrollBehavior = if (selectionMode) {
|
||||||
|
TopAppBarDefaults.pinnedScrollBehavior(topAppBarState, canScroll = { false })
|
||||||
|
} else {
|
||||||
|
TopAppBarDefaults.enterAlwaysScrollBehavior(topAppBarState)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Reset the top app bar offset (so that it shows up) when entering selection mode
|
||||||
|
LaunchedEffect(selectionMode) {
|
||||||
|
if (selectionMode) {
|
||||||
|
topAppBarState.heightOffset = 0f
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
BackHandler(onBack = viewModel::onBackClicked)
|
||||||
|
|
||||||
|
// Event handling
|
||||||
|
LaunchedEffect(viewModel.events) {
|
||||||
|
viewModel.events.collect { event ->
|
||||||
|
when (event) {
|
||||||
|
MediaOverviewEvent.Close -> onClose()
|
||||||
|
is MediaOverviewEvent.NavigateToActivity -> {
|
||||||
|
try {
|
||||||
|
context.startActivity(event.intent)
|
||||||
|
} catch (e: ActivityNotFoundException) {
|
||||||
|
Toast.makeText(
|
||||||
|
context,
|
||||||
|
R.string.ConversationItem_unable_to_open_media,
|
||||||
|
Toast.LENGTH_LONG
|
||||||
|
).show()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
is MediaOverviewEvent.ShowSaveAttachmentError -> {
|
||||||
|
val message = context.resources.getQuantityText(
|
||||||
|
R.plurals.ConversationFragment_error_while_saving_attachments_to_sd_card,
|
||||||
|
event.errorCount
|
||||||
|
)
|
||||||
|
Toast.makeText(context, message, Toast.LENGTH_LONG).show()
|
||||||
|
}
|
||||||
|
|
||||||
|
is MediaOverviewEvent.ShowSaveAttachmentSuccess -> {
|
||||||
|
val message = if (event.directory.isNotBlank()) {
|
||||||
|
context.resources.getString(
|
||||||
|
R.string.SaveAttachmentTask_saved_to,
|
||||||
|
event.directory
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
context.resources.getString(R.string.SaveAttachmentTask_saved)
|
||||||
|
}
|
||||||
|
|
||||||
|
Toast.makeText(context, message, Toast.LENGTH_LONG).show()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Scaffold(
|
||||||
|
modifier = Modifier.nestedScroll(appBarScrollBehavior.nestedScrollConnection),
|
||||||
|
topBar = {
|
||||||
|
MediaOverviewTopAppBar(
|
||||||
|
selectionMode = selectionMode,
|
||||||
|
title = viewModel.title.collectAsState().value,
|
||||||
|
onBackClicked = viewModel::onBackClicked,
|
||||||
|
onSaveClicked = { showingSaveAttachmentWarning = true },
|
||||||
|
onDeleteClicked = { showingDeleteConfirmation = true },
|
||||||
|
onSelectAllClicked = viewModel::onSelectAllClicked,
|
||||||
|
appBarScrollBehavior = appBarScrollBehavior
|
||||||
|
)
|
||||||
|
}
|
||||||
|
) { paddings ->
|
||||||
|
Column(
|
||||||
|
modifier = Modifier
|
||||||
|
.padding(paddings)
|
||||||
|
.fillMaxSize()
|
||||||
|
) {
|
||||||
|
val pagerState = rememberPagerState(pageCount = { MediaOverviewTab.entries.size })
|
||||||
|
val selectedTab by viewModel.selectedTab.collectAsState()
|
||||||
|
|
||||||
|
// Apply "selectedTab" view model state to pager
|
||||||
|
LaunchedEffect(selectedTab) {
|
||||||
|
pagerState.animateScrollToPage(selectedTab.ordinal)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Apply "selectedTab" pager state to view model
|
||||||
|
LaunchedEffect(pagerState.currentPage) {
|
||||||
|
viewModel.onTabItemClicked(MediaOverviewTab.entries[pagerState.currentPage])
|
||||||
|
}
|
||||||
|
|
||||||
|
SessionTabRow(
|
||||||
|
pagerState = pagerState,
|
||||||
|
titles = MediaOverviewTab.entries.map { it.titleResId }
|
||||||
|
)
|
||||||
|
|
||||||
|
val content = viewModel.mediaListState.collectAsState()
|
||||||
|
val canLongPress = viewModel.canLongPress.collectAsState().value
|
||||||
|
|
||||||
|
HorizontalPager(
|
||||||
|
pagerState,
|
||||||
|
modifier = Modifier
|
||||||
|
.weight(1f)
|
||||||
|
.fillMaxWidth()
|
||||||
|
) { index ->
|
||||||
|
when (MediaOverviewTab.entries[index]) {
|
||||||
|
MediaOverviewTab.Media -> {
|
||||||
|
val haptics = LocalHapticFeedback.current
|
||||||
|
|
||||||
|
MediaPage(
|
||||||
|
content = content.value?.mediaContent,
|
||||||
|
selectedItemIDs = selectedItems,
|
||||||
|
onItemClicked = viewModel::onItemClicked,
|
||||||
|
nestedScrollConnection = appBarScrollBehavior.nestedScrollConnection,
|
||||||
|
onItemLongClicked = if(canLongPress){{
|
||||||
|
haptics.performHapticFeedback(HapticFeedbackType.LongPress)
|
||||||
|
viewModel.onItemLongClicked(it)
|
||||||
|
}} else null
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
MediaOverviewTab.Documents -> DocumentsPage(
|
||||||
|
nestedScrollConnection = appBarScrollBehavior.nestedScrollConnection,
|
||||||
|
content = content.value?.documentContent,
|
||||||
|
onItemClicked = viewModel::onItemClicked
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (showingDeleteConfirmation) {
|
||||||
|
DeleteConfirmationDialog(
|
||||||
|
onDismissRequest = { showingDeleteConfirmation = false },
|
||||||
|
onAccepted = viewModel::onDeleteClicked,
|
||||||
|
numSelected = selectedItems.size
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (showingSaveAttachmentWarning) {
|
||||||
|
SaveAttachmentWarningDialog(
|
||||||
|
onDismissRequest = { showingSaveAttachmentWarning = false },
|
||||||
|
onAccepted = {
|
||||||
|
if (Build.VERSION.SDK_INT <= Build.VERSION_CODES.P) {
|
||||||
|
requestStoragePermission.launch(WRITE_EXTERNAL_STORAGE)
|
||||||
|
} else {
|
||||||
|
viewModel.onSaveClicked()
|
||||||
|
}
|
||||||
|
},
|
||||||
|
numSelected = selectedItems.size
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
val showingActionDialog = viewModel.showingActionProgress.collectAsState().value
|
||||||
|
if (showingActionDialog != null) {
|
||||||
|
ActionProgressDialog(showingActionDialog)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun SaveAttachmentWarningDialog(
|
||||||
|
onDismissRequest: () -> Unit,
|
||||||
|
onAccepted: () -> Unit,
|
||||||
|
numSelected: Int,
|
||||||
|
) {
|
||||||
|
val context = LocalContext.current
|
||||||
|
AlertDialog(
|
||||||
|
onDismissRequest = onDismissRequest,
|
||||||
|
title = context.getString(R.string.ConversationFragment_save_to_sd_card),
|
||||||
|
text = context.resources.getQuantityString(
|
||||||
|
R.plurals.ConversationFragment_saving_n_media_to_storage_warning,
|
||||||
|
numSelected,
|
||||||
|
numSelected
|
||||||
|
),
|
||||||
|
buttons = listOf(
|
||||||
|
DialogButtonModel(GetString(R.string.save), onClick = onAccepted),
|
||||||
|
DialogButtonModel(GetString(android.R.string.cancel), dismissOnClick = true)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun DeleteConfirmationDialog(
|
||||||
|
onDismissRequest: () -> Unit,
|
||||||
|
onAccepted: () -> Unit,
|
||||||
|
numSelected: Int,
|
||||||
|
) {
|
||||||
|
val context = LocalContext.current
|
||||||
|
AlertDialog(
|
||||||
|
onDismissRequest = onDismissRequest,
|
||||||
|
title = context.resources.getQuantityString(
|
||||||
|
R.plurals.ConversationFragment_delete_selected_messages, numSelected
|
||||||
|
),
|
||||||
|
text = context.resources.getQuantityString(
|
||||||
|
R.plurals.ConversationFragment_this_will_permanently_delete_all_n_selected_messages,
|
||||||
|
numSelected,
|
||||||
|
numSelected,
|
||||||
|
),
|
||||||
|
buttons = listOf(
|
||||||
|
DialogButtonModel(GetString(R.string.delete), color = LocalColors.current.danger, onClick = onAccepted),
|
||||||
|
DialogButtonModel(GetString(android.R.string.cancel), dismissOnClick = true)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
@OptIn(ExperimentalMaterial3Api::class)
|
||||||
|
private fun ActionProgressDialog(
|
||||||
|
text: String
|
||||||
|
) {
|
||||||
|
BasicAlertDialog(
|
||||||
|
onDismissRequest = {},
|
||||||
|
) {
|
||||||
|
Row(
|
||||||
|
modifier = Modifier
|
||||||
|
.background(LocalColors.current.background, shape = MaterialTheme.shapes.medium)
|
||||||
|
.padding(LocalDimensions.current.mediumSpacing),
|
||||||
|
horizontalArrangement = Arrangement.spacedBy(LocalDimensions.current.smallSpacing),
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
) {
|
||||||
|
CircularProgressIndicator(color = LocalColors.current.primary)
|
||||||
|
Text(
|
||||||
|
text,
|
||||||
|
style = LocalType.current.large,
|
||||||
|
color = LocalColors.current.text
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private val MediaOverviewTab.titleResId: Int
|
||||||
|
get() = when (this) {
|
||||||
|
MediaOverviewTab.Media -> R.string.MediaOverviewActivity_Media
|
||||||
|
MediaOverviewTab.Documents -> R.string.MediaOverviewActivity_Documents
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,57 @@
|
|||||||
|
package org.thoughtcrime.securesms.media
|
||||||
|
|
||||||
|
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||||
|
import androidx.compose.material3.Icon
|
||||||
|
import androidx.compose.material3.IconButton
|
||||||
|
import androidx.compose.material3.TopAppBarScrollBehavior
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.ui.res.painterResource
|
||||||
|
import androidx.compose.ui.res.stringResource
|
||||||
|
import network.loki.messenger.R
|
||||||
|
import org.thoughtcrime.securesms.ui.components.ActionAppBar
|
||||||
|
import org.thoughtcrime.securesms.ui.components.AppBarBackIcon
|
||||||
|
import org.thoughtcrime.securesms.ui.theme.LocalColors
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
@OptIn(ExperimentalMaterial3Api::class)
|
||||||
|
fun MediaOverviewTopAppBar(
|
||||||
|
selectionMode: Boolean,
|
||||||
|
title: String,
|
||||||
|
onBackClicked: () -> Unit,
|
||||||
|
onSaveClicked: () -> Unit,
|
||||||
|
onDeleteClicked: () -> Unit,
|
||||||
|
onSelectAllClicked: () -> Unit,
|
||||||
|
appBarScrollBehavior: TopAppBarScrollBehavior
|
||||||
|
) {
|
||||||
|
ActionAppBar(
|
||||||
|
title = title,
|
||||||
|
navigationIcon = {AppBarBackIcon(onBack = onBackClicked)},
|
||||||
|
scrollBehavior = appBarScrollBehavior,
|
||||||
|
actionMode = selectionMode,
|
||||||
|
actionModeActions = {
|
||||||
|
IconButton(onClick = onSaveClicked) {
|
||||||
|
Icon(
|
||||||
|
painterResource(R.drawable.ic_baseline_save_24),
|
||||||
|
contentDescription = stringResource(R.string.save),
|
||||||
|
tint = LocalColors.current.text,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
IconButton(onClick = onDeleteClicked) {
|
||||||
|
Icon(
|
||||||
|
painterResource(R.drawable.ic_baseline_delete_24),
|
||||||
|
contentDescription = stringResource(R.string.delete),
|
||||||
|
tint = LocalColors.current.text,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
IconButton(onClick = onSelectAllClicked) {
|
||||||
|
Icon(
|
||||||
|
painterResource(R.drawable.ic_baseline_select_all_24),
|
||||||
|
contentDescription = stringResource(R.string.MediaOverviewActivity_Select_all),
|
||||||
|
tint = LocalColors.current.text,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
@ -0,0 +1,431 @@
|
|||||||
|
package org.thoughtcrime.securesms.media
|
||||||
|
|
||||||
|
import android.app.Application
|
||||||
|
import android.content.Context
|
||||||
|
import android.content.Intent
|
||||||
|
import android.net.Uri
|
||||||
|
import androidx.lifecycle.AndroidViewModel
|
||||||
|
import androidx.lifecycle.ViewModel
|
||||||
|
import androidx.lifecycle.ViewModelProvider
|
||||||
|
import androidx.lifecycle.viewModelScope
|
||||||
|
import dagger.assisted.Assisted
|
||||||
|
import dagger.assisted.AssistedInject
|
||||||
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.flow.MutableSharedFlow
|
||||||
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
|
import kotlinx.coroutines.flow.SharedFlow
|
||||||
|
import kotlinx.coroutines.flow.SharingStarted
|
||||||
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
|
import kotlinx.coroutines.flow.map
|
||||||
|
import kotlinx.coroutines.flow.onStart
|
||||||
|
import kotlinx.coroutines.flow.shareIn
|
||||||
|
import kotlinx.coroutines.flow.stateIn
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
import kotlinx.coroutines.withContext
|
||||||
|
import network.loki.messenger.R
|
||||||
|
import org.session.libsession.messaging.messages.control.DataExtractionNotification
|
||||||
|
import org.session.libsession.messaging.sending_receiving.MessageSender
|
||||||
|
import org.session.libsession.snode.SnodeAPI
|
||||||
|
import org.session.libsession.utilities.Address
|
||||||
|
import org.session.libsession.utilities.recipients.Recipient
|
||||||
|
import org.thoughtcrime.securesms.MediaPreviewActivity
|
||||||
|
import org.thoughtcrime.securesms.database.DatabaseContentProviders
|
||||||
|
import org.thoughtcrime.securesms.database.MediaDatabase
|
||||||
|
import org.thoughtcrime.securesms.database.MediaDatabase.MediaRecord
|
||||||
|
import org.thoughtcrime.securesms.database.ThreadDatabase
|
||||||
|
import org.thoughtcrime.securesms.mms.PartAuthority
|
||||||
|
import org.thoughtcrime.securesms.mms.Slide
|
||||||
|
import org.thoughtcrime.securesms.util.AttachmentUtil
|
||||||
|
import org.thoughtcrime.securesms.util.DateUtils
|
||||||
|
import org.thoughtcrime.securesms.util.MediaUtil
|
||||||
|
import org.thoughtcrime.securesms.util.SaveAttachmentTask
|
||||||
|
import org.thoughtcrime.securesms.util.asSequence
|
||||||
|
import org.thoughtcrime.securesms.util.observeChanges
|
||||||
|
import java.time.Instant
|
||||||
|
import java.time.LocalDate
|
||||||
|
import java.time.ZoneId
|
||||||
|
import java.time.ZonedDateTime
|
||||||
|
import java.time.format.DateTimeFormatter
|
||||||
|
import java.util.Locale
|
||||||
|
|
||||||
|
class MediaOverviewViewModel(
|
||||||
|
private val address: Address,
|
||||||
|
private val application: Application,
|
||||||
|
private val threadDatabase: ThreadDatabase,
|
||||||
|
private val mediaDatabase: MediaDatabase
|
||||||
|
) : AndroidViewModel(application) {
|
||||||
|
private val timeBuckets by lazy { FixedTimeBuckets() }
|
||||||
|
private val monthTimeBucketFormatter =
|
||||||
|
DateTimeFormatter.ofPattern("MMMM yyyy", Locale.getDefault())
|
||||||
|
|
||||||
|
private val recipient: SharedFlow<Recipient> = application.contentResolver
|
||||||
|
.observeChanges(DatabaseContentProviders.Attachment.CONTENT_URI)
|
||||||
|
.onStart { emit(DatabaseContentProviders.Attachment.CONTENT_URI) }
|
||||||
|
.map { Recipient.from(application, address, false) }
|
||||||
|
.shareIn(viewModelScope, SharingStarted.Eagerly, replay = 1)
|
||||||
|
|
||||||
|
val title: StateFlow<String> = recipient
|
||||||
|
.map { it.toShortString() }
|
||||||
|
.stateIn(viewModelScope, SharingStarted.Eagerly, "")
|
||||||
|
|
||||||
|
val mediaListState: StateFlow<MediaOverviewContent?> = recipient
|
||||||
|
.map { recipient ->
|
||||||
|
withContext(Dispatchers.Default) {
|
||||||
|
val threadId = threadDatabase.getOrCreateThreadIdFor(recipient)
|
||||||
|
val mediaItems = mediaDatabase.getGalleryMediaForThread(threadId)
|
||||||
|
.use { cursor ->
|
||||||
|
cursor.asSequence()
|
||||||
|
.map { MediaRecord.from(application, it) }
|
||||||
|
.groupRecordsByTimeBuckets()
|
||||||
|
}
|
||||||
|
|
||||||
|
val documentItems = mediaDatabase.getDocumentMediaForThread(threadId)
|
||||||
|
.use { cursor ->
|
||||||
|
cursor.asSequence()
|
||||||
|
.map { MediaRecord.from(application, it) }
|
||||||
|
.groupRecordsByRelativeTime()
|
||||||
|
}
|
||||||
|
|
||||||
|
MediaOverviewContent(
|
||||||
|
mediaContent = mediaItems,
|
||||||
|
documentContent = documentItems,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.stateIn(viewModelScope, SharingStarted.Eagerly, null)
|
||||||
|
|
||||||
|
private val mutableSelectedItemIDs = MutableStateFlow(emptySet<Long>())
|
||||||
|
val selectedItemIDs: StateFlow<Set<Long>> get() = mutableSelectedItemIDs
|
||||||
|
|
||||||
|
val inSelectionMode: StateFlow<Boolean> = selectedItemIDs
|
||||||
|
.map { it.isNotEmpty() }
|
||||||
|
.stateIn(viewModelScope, SharingStarted.Eagerly, mutableSelectedItemIDs.value.isNotEmpty())
|
||||||
|
|
||||||
|
val canLongPress: StateFlow<Boolean> = inSelectionMode
|
||||||
|
.map { !it }
|
||||||
|
.stateIn(viewModelScope, SharingStarted.Eagerly, true)
|
||||||
|
|
||||||
|
private val mutableEvents = MutableSharedFlow<MediaOverviewEvent>()
|
||||||
|
val events get() = mutableEvents
|
||||||
|
|
||||||
|
private val mutableSelectedTab = MutableStateFlow(MediaOverviewTab.Media)
|
||||||
|
val selectedTab: StateFlow<MediaOverviewTab> get() = mutableSelectedTab
|
||||||
|
|
||||||
|
private val mutableShowingActionProgress = MutableStateFlow<String?>(null)
|
||||||
|
val showingActionProgress: StateFlow<String?> get() = mutableShowingActionProgress
|
||||||
|
|
||||||
|
private val selectedMedia: Sequence<MediaOverviewItem>
|
||||||
|
get() {
|
||||||
|
val selected = selectedItemIDs.value
|
||||||
|
return mediaListState.value
|
||||||
|
?.mediaContent
|
||||||
|
?.asSequence()
|
||||||
|
.orEmpty()
|
||||||
|
.flatMap { it.second.asSequence() }
|
||||||
|
.filter { it.id in selected }
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun Sequence<MediaRecord>.groupRecordsByTimeBuckets(): List<Pair<BucketTitle, List<MediaOverviewItem>>> {
|
||||||
|
return this
|
||||||
|
.groupBy { record ->
|
||||||
|
val time =
|
||||||
|
ZonedDateTime.ofInstant(Instant.ofEpochMilli(record.date), ZoneId.of("UTC"))
|
||||||
|
timeBuckets.getBucketText(time)?.let(application::getString)
|
||||||
|
?: time.toLocalDate().withDayOfMonth(1)
|
||||||
|
}
|
||||||
|
.map { (bucket, records) ->
|
||||||
|
val bucketTitle = when (bucket) {
|
||||||
|
is String -> bucket
|
||||||
|
is LocalDate -> bucket.format(monthTimeBucketFormatter)
|
||||||
|
else -> error("Invalid bucket type: $bucket")
|
||||||
|
}
|
||||||
|
|
||||||
|
bucketTitle to records.map { record ->
|
||||||
|
MediaOverviewItem(
|
||||||
|
id = record.attachment.attachmentId.rowId,
|
||||||
|
slide = MediaUtil.getSlideForAttachment(application, record.attachment),
|
||||||
|
mediaRecord = record,
|
||||||
|
date = bucketTitle
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun Sequence<MediaRecord>.groupRecordsByRelativeTime(): List<Pair<BucketTitle, List<MediaOverviewItem>>> {
|
||||||
|
return this
|
||||||
|
.groupBy { record ->
|
||||||
|
DateUtils.getRelativeDate(application, Locale.getDefault(), record.date)
|
||||||
|
}
|
||||||
|
.map { (bucket, records) ->
|
||||||
|
bucket to records.map { record ->
|
||||||
|
MediaOverviewItem(
|
||||||
|
id = record.attachment.attachmentId.rowId,
|
||||||
|
slide = MediaUtil.getSlideForAttachment(application, record.attachment),
|
||||||
|
mediaRecord = record,
|
||||||
|
date = bucket
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
fun onItemClicked(item: MediaOverviewItem) {
|
||||||
|
if (inSelectionMode.value) {
|
||||||
|
val newSet = mutableSelectedItemIDs.value.toMutableSet()
|
||||||
|
if (item.id in newSet) {
|
||||||
|
newSet.remove(item.id)
|
||||||
|
} else {
|
||||||
|
newSet.add(item.id)
|
||||||
|
}
|
||||||
|
|
||||||
|
mutableSelectedItemIDs.value = newSet
|
||||||
|
} else if (!item.slide.hasDocument()) {
|
||||||
|
val mediaRecord = item.mediaRecord
|
||||||
|
|
||||||
|
// The item clicked is a media item, so we should open the media viewer
|
||||||
|
val intent = Intent(application, MediaPreviewActivity::class.java)
|
||||||
|
intent.putExtra(MediaPreviewActivity.DATE_EXTRA, mediaRecord.date)
|
||||||
|
intent.putExtra(MediaPreviewActivity.SIZE_EXTRA, mediaRecord.attachment.size)
|
||||||
|
intent.putExtra(MediaPreviewActivity.ADDRESS_EXTRA, address)
|
||||||
|
intent.putExtra(MediaPreviewActivity.OUTGOING_EXTRA, mediaRecord.isOutgoing)
|
||||||
|
intent.putExtra(MediaPreviewActivity.LEFT_IS_RECENT_EXTRA, true)
|
||||||
|
|
||||||
|
intent.setDataAndType(
|
||||||
|
mediaRecord.attachment.dataUri,
|
||||||
|
mediaRecord.contentType
|
||||||
|
)
|
||||||
|
|
||||||
|
viewModelScope.launch {
|
||||||
|
mutableEvents.emit(MediaOverviewEvent.NavigateToActivity(intent))
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
val intent = Intent(Intent.ACTION_VIEW)
|
||||||
|
intent.addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION)
|
||||||
|
intent.setDataAndType(
|
||||||
|
PartAuthority.getAttachmentPublicUri(item.slide.uri),
|
||||||
|
item.slide.contentType
|
||||||
|
)
|
||||||
|
|
||||||
|
viewModelScope.launch {
|
||||||
|
mutableEvents.emit(MediaOverviewEvent.NavigateToActivity(intent))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun onTabItemClicked(tab: MediaOverviewTab) {
|
||||||
|
if (inSelectionMode.value) {
|
||||||
|
// Not allowing to switch tabs while in selection mode
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
mutableSelectedTab.value = tab
|
||||||
|
}
|
||||||
|
|
||||||
|
fun onItemLongClicked(id: Long) {
|
||||||
|
mutableSelectedItemIDs.value = setOf(id)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun onSaveClicked() {
|
||||||
|
if (!inSelectionMode.value) {
|
||||||
|
// Not in selection mode, so we should not be able to save
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
viewModelScope.launch {
|
||||||
|
val selectedMedia = selectedMedia.toList()
|
||||||
|
|
||||||
|
mutableShowingActionProgress.value = application.resources.getQuantityString(
|
||||||
|
R.plurals.ConversationFragment_saving_n_attachments,
|
||||||
|
selectedMedia.size,
|
||||||
|
selectedMedia.size,
|
||||||
|
)
|
||||||
|
|
||||||
|
val attachments = selectedMedia
|
||||||
|
.asSequence()
|
||||||
|
.mapNotNull {
|
||||||
|
val uri = it.mediaRecord.attachment.dataUri ?: return@mapNotNull null
|
||||||
|
SaveAttachmentTask.Attachment(
|
||||||
|
uri = uri,
|
||||||
|
contentType = it.mediaRecord.contentType,
|
||||||
|
date = it.mediaRecord.date,
|
||||||
|
fileName = it.mediaRecord.attachment.fileName,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
var savedDirectory: String? = null
|
||||||
|
var successCount = 0
|
||||||
|
var errorCount = 0
|
||||||
|
|
||||||
|
for (attachment in attachments) {
|
||||||
|
val directory = withContext(Dispatchers.Default) {
|
||||||
|
kotlin.runCatching {
|
||||||
|
SaveAttachmentTask.saveAttachment(application, attachment)
|
||||||
|
}.getOrNull()
|
||||||
|
}
|
||||||
|
|
||||||
|
if (directory == null) {
|
||||||
|
errorCount += 1
|
||||||
|
} else {
|
||||||
|
savedDirectory = directory
|
||||||
|
successCount += 1
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (successCount > 0) {
|
||||||
|
mutableEvents.emit(MediaOverviewEvent.ShowSaveAttachmentSuccess(
|
||||||
|
savedDirectory.orEmpty(),
|
||||||
|
successCount
|
||||||
|
))
|
||||||
|
} else if (errorCount > 0) {
|
||||||
|
mutableEvents.emit(MediaOverviewEvent.ShowSaveAttachmentError(errorCount))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Send a notification of attachment saved if we are in a 1to1 chat and the
|
||||||
|
// attachments saved are from the other party (a.k.a let other person know
|
||||||
|
// that you saved their attachments, but don't need to let the whole world know as
|
||||||
|
// in groups/communities)
|
||||||
|
if (selectedMedia.any { !it.mediaRecord.isOutgoing } &&
|
||||||
|
successCount > 0 &&
|
||||||
|
!address.isGroup) {
|
||||||
|
withContext(Dispatchers.Default) {
|
||||||
|
val timestamp = SnodeAPI.nowWithOffset
|
||||||
|
val kind = DataExtractionNotification.Kind.MediaSaved(timestamp)
|
||||||
|
val message = DataExtractionNotification(kind)
|
||||||
|
MessageSender.send(message, address)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
mutableShowingActionProgress.value = null
|
||||||
|
mutableSelectedItemIDs.value = emptySet()
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
fun onDeleteClicked() {
|
||||||
|
if (!inSelectionMode.value) {
|
||||||
|
// Not in selection mode, so we should not be able to delete
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
viewModelScope.launch {
|
||||||
|
mutableShowingActionProgress.value = application.getString(R.string.MediaOverviewActivity_Media_delete_progress_message)
|
||||||
|
|
||||||
|
// Delete the selected media items, and retrieve the thread ID for the address if any
|
||||||
|
val threadId = withContext(Dispatchers.Default) {
|
||||||
|
for (media in selectedMedia) {
|
||||||
|
kotlin.runCatching {
|
||||||
|
AttachmentUtil.deleteAttachment(application, media.mediaRecord.attachment)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
threadDatabase.getThreadIdIfExistsFor(address.serialize())
|
||||||
|
}
|
||||||
|
|
||||||
|
// Notify the content provider that the thread has been updated
|
||||||
|
if (threadId >= 0) {
|
||||||
|
application.contentResolver.notifyChange(DatabaseContentProviders.Conversation.getUriForThread(threadId), null)
|
||||||
|
}
|
||||||
|
|
||||||
|
mutableShowingActionProgress.value = null
|
||||||
|
mutableSelectedItemIDs.value = emptySet()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun onSelectAllClicked() {
|
||||||
|
if (!inSelectionMode.value) {
|
||||||
|
// Not in selection mode, so we should not be able to select all
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
val allItems = mediaListState.value?.let { content ->
|
||||||
|
when (selectedTab.value) {
|
||||||
|
MediaOverviewTab.Media -> content.mediaContent
|
||||||
|
MediaOverviewTab.Documents -> content.documentContent
|
||||||
|
}
|
||||||
|
} ?: return
|
||||||
|
|
||||||
|
mutableSelectedItemIDs.value = allItems
|
||||||
|
.asSequence()
|
||||||
|
.flatMap { it.second }
|
||||||
|
.mapTo(hashSetOf()) { it.id }
|
||||||
|
}
|
||||||
|
|
||||||
|
fun onBackClicked() {
|
||||||
|
if (inSelectionMode.value) {
|
||||||
|
// Clear selection mode by clear selecting items
|
||||||
|
mutableSelectedItemIDs.value = emptySet()
|
||||||
|
} else {
|
||||||
|
viewModelScope.launch {
|
||||||
|
mutableEvents.emit(MediaOverviewEvent.Close)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@dagger.assisted.AssistedFactory
|
||||||
|
interface AssistedFactory {
|
||||||
|
fun create(address: Address): Factory
|
||||||
|
}
|
||||||
|
|
||||||
|
class Factory @AssistedInject constructor(
|
||||||
|
@Assisted private val address: Address,
|
||||||
|
private val application: Application,
|
||||||
|
private val threadDatabase: ThreadDatabase,
|
||||||
|
private val mediaDatabase: MediaDatabase
|
||||||
|
) : ViewModelProvider.Factory {
|
||||||
|
@Suppress("UNCHECKED_CAST")
|
||||||
|
override fun <T : ViewModel> create(modelClass: Class<T>): T = MediaOverviewViewModel(
|
||||||
|
address,
|
||||||
|
application,
|
||||||
|
threadDatabase,
|
||||||
|
mediaDatabase
|
||||||
|
) as T
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
enum class MediaOverviewTab {
|
||||||
|
Media,
|
||||||
|
Documents,
|
||||||
|
}
|
||||||
|
|
||||||
|
sealed interface MediaOverviewEvent {
|
||||||
|
data object Close : MediaOverviewEvent
|
||||||
|
data class ShowSaveAttachmentError(val errorCount: Int) : MediaOverviewEvent
|
||||||
|
data class ShowSaveAttachmentSuccess(val directory: String, val successCount: Int) : MediaOverviewEvent
|
||||||
|
data class NavigateToActivity(val intent: Intent) : MediaOverviewEvent
|
||||||
|
}
|
||||||
|
|
||||||
|
typealias BucketTitle = String
|
||||||
|
typealias TabContent = List<Pair<BucketTitle, List<MediaOverviewItem>>>
|
||||||
|
|
||||||
|
data class MediaOverviewContent(
|
||||||
|
val mediaContent: TabContent,
|
||||||
|
val documentContent: TabContent
|
||||||
|
)
|
||||||
|
|
||||||
|
data class MediaOverviewItem(
|
||||||
|
val id: Long,
|
||||||
|
val slide: Slide,
|
||||||
|
val date: String,
|
||||||
|
val mediaRecord: MediaRecord,
|
||||||
|
) {
|
||||||
|
val showPlayOverlay: Boolean
|
||||||
|
get() = slide.hasPlayOverlay()
|
||||||
|
|
||||||
|
val thumbnailUri: Uri?
|
||||||
|
get() = slide.thumbnailUri
|
||||||
|
|
||||||
|
val hasPlaceholder: Boolean
|
||||||
|
get() = slide.hasPlaceholder()
|
||||||
|
|
||||||
|
val fileName: String?
|
||||||
|
get() = slide.fileName.orNull()
|
||||||
|
|
||||||
|
val fileSize: Long
|
||||||
|
get() = slide.fileSize
|
||||||
|
|
||||||
|
fun placeholder(context: Context): Int {
|
||||||
|
return slide.getPlaceholderRes(context.theme)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
191
app/src/main/java/org/thoughtcrime/securesms/media/MediaPage.kt
Normal file
191
app/src/main/java/org/thoughtcrime/securesms/media/MediaPage.kt
Normal file
@ -0,0 +1,191 @@
|
|||||||
|
package org.thoughtcrime.securesms.media
|
||||||
|
|
||||||
|
import androidx.compose.animation.Crossfade
|
||||||
|
import androidx.compose.foundation.ExperimentalFoundationApi
|
||||||
|
import androidx.compose.foundation.Image
|
||||||
|
import androidx.compose.foundation.background
|
||||||
|
import androidx.compose.foundation.clickable
|
||||||
|
import androidx.compose.foundation.combinedClickable
|
||||||
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
|
import androidx.compose.foundation.layout.Box
|
||||||
|
import androidx.compose.foundation.layout.Row
|
||||||
|
import androidx.compose.foundation.layout.Spacer
|
||||||
|
import androidx.compose.foundation.layout.aspectRatio
|
||||||
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.foundation.lazy.LazyColumn
|
||||||
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.graphics.Color
|
||||||
|
import androidx.compose.ui.input.nestedscroll.NestedScrollConnection
|
||||||
|
import androidx.compose.ui.input.nestedscroll.nestedScroll
|
||||||
|
import androidx.compose.ui.layout.ContentScale
|
||||||
|
import androidx.compose.ui.platform.LocalContext
|
||||||
|
import androidx.compose.ui.res.painterResource
|
||||||
|
import androidx.compose.ui.res.stringResource
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import com.bumptech.glide.integration.compose.CrossFade
|
||||||
|
import com.bumptech.glide.integration.compose.ExperimentalGlideComposeApi
|
||||||
|
import com.bumptech.glide.integration.compose.GlideImage
|
||||||
|
import com.bumptech.glide.load.engine.DiskCacheStrategy
|
||||||
|
import network.loki.messenger.R
|
||||||
|
import org.thoughtcrime.securesms.mms.DecryptableStreamUriLoader
|
||||||
|
import org.thoughtcrime.securesms.ui.theme.LocalColors
|
||||||
|
import org.thoughtcrime.securesms.ui.theme.LocalType
|
||||||
|
import kotlin.math.ceil
|
||||||
|
|
||||||
|
private val MEDIA_SPACING = 2.dp
|
||||||
|
|
||||||
|
@OptIn(ExperimentalFoundationApi::class)
|
||||||
|
@Composable
|
||||||
|
fun MediaPage(
|
||||||
|
nestedScrollConnection: NestedScrollConnection,
|
||||||
|
content: TabContent?,
|
||||||
|
selectedItemIDs: Set<Long>,
|
||||||
|
onItemClicked: (MediaOverviewItem) -> Unit,
|
||||||
|
onItemLongClicked: ((Long) -> Unit)?,
|
||||||
|
) {
|
||||||
|
val columnCount = LocalContext.current.resources.getInteger(R.integer.media_overview_cols)
|
||||||
|
|
||||||
|
Crossfade(content, label = "Media content animation") { state ->
|
||||||
|
when {
|
||||||
|
state == null -> {
|
||||||
|
// Loading state
|
||||||
|
}
|
||||||
|
|
||||||
|
state.isEmpty() -> {
|
||||||
|
Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
|
||||||
|
Text(
|
||||||
|
text = stringResource(R.string.media_overview_activity__no_media),
|
||||||
|
style = LocalType.current.base,
|
||||||
|
color = LocalColors.current.text
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
else -> {
|
||||||
|
LazyColumn(
|
||||||
|
modifier = Modifier
|
||||||
|
.nestedScroll(nestedScrollConnection)
|
||||||
|
.fillMaxSize()
|
||||||
|
.padding(MEDIA_SPACING),
|
||||||
|
verticalArrangement = Arrangement.spacedBy(MEDIA_SPACING)
|
||||||
|
) {
|
||||||
|
for ((header, thumbnails) in state) {
|
||||||
|
stickyHeader {
|
||||||
|
AttachmentHeader(text = header)
|
||||||
|
}
|
||||||
|
|
||||||
|
val numRows = ceil(thumbnails.size / columnCount.toFloat()).toInt()
|
||||||
|
|
||||||
|
// Row of thumbnails
|
||||||
|
items(numRows) { rowIndex ->
|
||||||
|
ThumbnailRow(
|
||||||
|
columnCount = columnCount,
|
||||||
|
thumbnails = thumbnails,
|
||||||
|
rowIndex = rowIndex,
|
||||||
|
onItemClicked = onItemClicked,
|
||||||
|
onItemLongClicked = onItemLongClicked,
|
||||||
|
selectedItemIDs = selectedItemIDs
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
@OptIn(ExperimentalGlideComposeApi::class, ExperimentalFoundationApi::class)
|
||||||
|
private fun ThumbnailRow(
|
||||||
|
columnCount: Int,
|
||||||
|
thumbnails: List<MediaOverviewItem>,
|
||||||
|
rowIndex: Int,
|
||||||
|
onItemClicked: (MediaOverviewItem) -> Unit,
|
||||||
|
onItemLongClicked: ((Long) -> Unit)?,
|
||||||
|
selectedItemIDs: Set<Long>
|
||||||
|
) {
|
||||||
|
Row(horizontalArrangement = Arrangement.spacedBy(MEDIA_SPACING)) {
|
||||||
|
repeat(columnCount) { columnIndex ->
|
||||||
|
val item = thumbnails.getOrNull(rowIndex * columnCount + columnIndex)
|
||||||
|
if (item != null) {
|
||||||
|
Box(
|
||||||
|
modifier = Modifier
|
||||||
|
.weight(1f)
|
||||||
|
.aspectRatio(1f)
|
||||||
|
.let {
|
||||||
|
when {
|
||||||
|
onItemLongClicked != null -> {
|
||||||
|
it.combinedClickable(
|
||||||
|
onClick = { onItemClicked(item) },
|
||||||
|
onLongClick = { onItemLongClicked(item.id) }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
else -> {
|
||||||
|
it.clickable { onItemClicked(item) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
contentAlignment = Alignment.Center
|
||||||
|
) {
|
||||||
|
val uri = item.thumbnailUri
|
||||||
|
|
||||||
|
if (uri != null) {
|
||||||
|
GlideImage(
|
||||||
|
DecryptableStreamUriLoader.DecryptableUri(uri),
|
||||||
|
modifier = Modifier.fillMaxSize(),
|
||||||
|
contentScale = ContentScale.Crop,
|
||||||
|
contentDescription = null,
|
||||||
|
transition = CrossFade,
|
||||||
|
) {
|
||||||
|
it.diskCacheStrategy(DiskCacheStrategy.NONE)
|
||||||
|
}
|
||||||
|
} else if (item.hasPlaceholder) {
|
||||||
|
Image(
|
||||||
|
painter = painterResource(item.placeholder(LocalContext.current)),
|
||||||
|
contentDescription = null,
|
||||||
|
modifier = Modifier.fillMaxSize(),
|
||||||
|
contentScale = ContentScale.Inside
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
when {
|
||||||
|
item.showPlayOverlay -> {
|
||||||
|
Image(
|
||||||
|
painter = painterResource(R.drawable.ic_baseline_play_circle_filled_48),
|
||||||
|
contentDescription = null
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
Crossfade(
|
||||||
|
modifier = Modifier.fillMaxSize(),
|
||||||
|
targetState = item.id in selectedItemIDs,
|
||||||
|
label = "Showing selected state"
|
||||||
|
) { selected ->
|
||||||
|
if (selected) {
|
||||||
|
Image(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxSize()
|
||||||
|
.background(Color.Black.copy(alpha = 0.4f)),
|
||||||
|
contentScale = ContentScale.Inside,
|
||||||
|
painter = painterResource(R.drawable.ic_check_white_48dp),
|
||||||
|
contentDescription = stringResource(R.string.AccessibilityId_select),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
Spacer(modifier = Modifier.weight(1f))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
@ -81,9 +81,8 @@ class PrivacySettingsPreferenceFragment : ListSummaryPreferenceFragment() {
|
|||||||
Intent(Settings.ACTION_APP_NOTIFICATION_SETTINGS)
|
Intent(Settings.ACTION_APP_NOTIFICATION_SETTINGS)
|
||||||
.putExtra(Settings.EXTRA_APP_PACKAGE, BuildConfig.APPLICATION_ID)
|
.putExtra(Settings.EXTRA_APP_PACKAGE, BuildConfig.APPLICATION_ID)
|
||||||
.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
|
.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
|
||||||
.takeIf { IntentUtils.isResolvable(requireContext(), it) }.let {
|
.takeIf { IntentUtils.isResolvable(requireContext(), it) }
|
||||||
startActivity(it)
|
?.let { startActivity(it) }
|
||||||
}
|
|
||||||
}
|
}
|
||||||
button(R.string.dismiss)
|
button(R.string.dismiss)
|
||||||
}
|
}
|
||||||
|
@ -1,6 +1,7 @@
|
|||||||
package org.thoughtcrime.securesms.ui
|
package org.thoughtcrime.securesms.ui
|
||||||
|
|
||||||
import androidx.compose.foundation.background
|
import androidx.compose.foundation.background
|
||||||
|
import androidx.compose.foundation.border
|
||||||
import androidx.compose.foundation.layout.Box
|
import androidx.compose.foundation.layout.Box
|
||||||
import androidx.compose.foundation.layout.Column
|
import androidx.compose.foundation.layout.Column
|
||||||
import androidx.compose.foundation.layout.IntrinsicSize
|
import androidx.compose.foundation.layout.IntrinsicSize
|
||||||
@ -26,6 +27,7 @@ import androidx.compose.ui.res.painterResource
|
|||||||
import androidx.compose.ui.res.stringResource
|
import androidx.compose.ui.res.stringResource
|
||||||
import androidx.compose.ui.text.style.TextAlign
|
import androidx.compose.ui.text.style.TextAlign
|
||||||
import androidx.compose.ui.tooling.preview.Preview
|
import androidx.compose.ui.tooling.preview.Preview
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
import network.loki.messenger.R
|
import network.loki.messenger.R
|
||||||
import org.thoughtcrime.securesms.ui.theme.LocalColors
|
import org.thoughtcrime.securesms.ui.theme.LocalColors
|
||||||
import org.thoughtcrime.securesms.ui.theme.LocalDimensions
|
import org.thoughtcrime.securesms.ui.theme.LocalDimensions
|
||||||
@ -56,8 +58,14 @@ fun AlertDialog(
|
|||||||
onDismissRequest = onDismissRequest,
|
onDismissRequest = onDismissRequest,
|
||||||
content = {
|
content = {
|
||||||
Box(
|
Box(
|
||||||
modifier = Modifier.background(color = LocalColors.current.backgroundSecondary,
|
modifier = Modifier.background(
|
||||||
|
color = LocalColors.current.backgroundSecondary,
|
||||||
shape = MaterialTheme.shapes.small)
|
shape = MaterialTheme.shapes.small)
|
||||||
|
.border(
|
||||||
|
width = 1.dp,
|
||||||
|
color = LocalColors.current.borders,
|
||||||
|
shape = MaterialTheme.shapes.small)
|
||||||
|
|
||||||
) {
|
) {
|
||||||
// only show the 'x' button is required
|
// only show the 'x' button is required
|
||||||
if (showCloseButton) {
|
if (showCloseButton) {
|
||||||
@ -78,7 +86,7 @@ fun AlertDialog(
|
|||||||
horizontalAlignment = Alignment.CenterHorizontally,
|
horizontalAlignment = Alignment.CenterHorizontally,
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.fillMaxWidth()
|
.fillMaxWidth()
|
||||||
.padding(top = LocalDimensions.current.smallSpacing)
|
.padding(top = LocalDimensions.current.spacing)
|
||||||
.padding(horizontal = LocalDimensions.current.smallSpacing)
|
.padding(horizontal = LocalDimensions.current.smallSpacing)
|
||||||
) {
|
) {
|
||||||
title?.let {
|
title?.let {
|
||||||
@ -123,7 +131,12 @@ fun AlertDialog(
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
fun DialogButton(text: String, modifier: Modifier, color: Color = Color.Unspecified, onClick: () -> Unit) {
|
fun DialogButton(
|
||||||
|
text: String,
|
||||||
|
modifier: Modifier,
|
||||||
|
color: Color = Color.Unspecified,
|
||||||
|
onClick: () -> Unit
|
||||||
|
) {
|
||||||
TextButton(
|
TextButton(
|
||||||
modifier = modifier,
|
modifier = modifier,
|
||||||
shape = RectangleShape,
|
shape = RectangleShape,
|
||||||
@ -135,8 +148,7 @@ fun DialogButton(text: String, modifier: Modifier, color: Color = Color.Unspecif
|
|||||||
style = LocalType.current.large.bold(),
|
style = LocalType.current.large.bold(),
|
||||||
textAlign = TextAlign.Center,
|
textAlign = TextAlign.Center,
|
||||||
modifier = Modifier.padding(
|
modifier = Modifier.padding(
|
||||||
top = LocalDimensions.current.smallSpacing,
|
vertical = LocalDimensions.current.smallSpacing
|
||||||
bottom = LocalDimensions.current.spacing
|
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
@ -1,53 +1,176 @@
|
|||||||
package org.thoughtcrime.securesms.ui.components
|
package org.thoughtcrime.securesms.ui.components
|
||||||
|
|
||||||
import androidx.compose.foundation.layout.Box
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
import androidx.compose.foundation.layout.Row
|
import androidx.compose.foundation.layout.Column
|
||||||
import androidx.compose.foundation.layout.Spacer
|
import androidx.compose.foundation.layout.RowScope
|
||||||
import androidx.compose.foundation.layout.height
|
import androidx.compose.material3.CenterAlignedTopAppBar
|
||||||
import androidx.compose.foundation.layout.size
|
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||||
import androidx.compose.material3.Icon
|
import androidx.compose.material3.Icon
|
||||||
import androidx.compose.material3.IconButton
|
import androidx.compose.material3.IconButton
|
||||||
import androidx.compose.material3.Text
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.material3.TopAppBarColors
|
||||||
|
import androidx.compose.material3.TopAppBarDefaults
|
||||||
|
import androidx.compose.material3.TopAppBarScrollBehavior
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.ui.Alignment
|
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.graphics.Color
|
||||||
import androidx.compose.ui.res.painterResource
|
import androidx.compose.ui.res.painterResource
|
||||||
|
import androidx.compose.ui.res.stringResource
|
||||||
import androidx.compose.ui.tooling.preview.Preview
|
import androidx.compose.ui.tooling.preview.Preview
|
||||||
import androidx.compose.ui.tooling.preview.PreviewParameter
|
import androidx.compose.ui.tooling.preview.PreviewParameter
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
import network.loki.messenger.R
|
import network.loki.messenger.R
|
||||||
import org.thoughtcrime.securesms.ui.theme.LocalDimensions
|
import org.thoughtcrime.securesms.ui.Divider
|
||||||
|
import org.thoughtcrime.securesms.ui.theme.LocalColors
|
||||||
import org.thoughtcrime.securesms.ui.theme.LocalType
|
import org.thoughtcrime.securesms.ui.theme.LocalType
|
||||||
import org.thoughtcrime.securesms.ui.theme.PreviewTheme
|
import org.thoughtcrime.securesms.ui.theme.PreviewTheme
|
||||||
import org.thoughtcrime.securesms.ui.theme.SessionColorsParameterProvider
|
import org.thoughtcrime.securesms.ui.theme.SessionColorsParameterProvider
|
||||||
import org.thoughtcrime.securesms.ui.theme.ThemeColors
|
import org.thoughtcrime.securesms.ui.theme.ThemeColors
|
||||||
|
|
||||||
|
@OptIn(ExperimentalMaterial3Api::class)
|
||||||
@Preview
|
@Preview
|
||||||
@Composable
|
@Composable
|
||||||
fun AppBarPreview(
|
fun AppBarPreview(
|
||||||
@PreviewParameter(SessionColorsParameterProvider::class) colors: ThemeColors
|
@PreviewParameter(SessionColorsParameterProvider::class) colors: ThemeColors
|
||||||
) {
|
) {
|
||||||
PreviewTheme(colors) {
|
PreviewTheme(colors) {
|
||||||
AppBar(title = "Title", {}, {})
|
Column() {
|
||||||
|
BasicAppBar(title = "Basic App Bar")
|
||||||
|
Divider()
|
||||||
|
BasicAppBar(title = "Basic App Bar With Color", backgroundColor = LocalColors.current.backgroundSecondary)
|
||||||
|
Divider()
|
||||||
|
BackAppBar(title = "Back Bar", onBack = {})
|
||||||
|
Divider()
|
||||||
|
ActionAppBar(
|
||||||
|
title = "Action mode",
|
||||||
|
actionMode = true,
|
||||||
|
actionModeActions = {
|
||||||
|
IconButton(onClick = {}) {
|
||||||
|
Icon(
|
||||||
|
painter = painterResource(id = R.drawable.check),
|
||||||
|
contentDescription = "check"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Basic structure for an app bar.
|
||||||
|
* It can be passed navigation content and actions
|
||||||
|
*/
|
||||||
|
@OptIn(ExperimentalMaterial3Api::class)
|
||||||
|
@Composable
|
||||||
|
fun BasicAppBar(
|
||||||
|
title: String,
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
|
scrollBehavior: TopAppBarScrollBehavior? = null,
|
||||||
|
backgroundColor: Color = LocalColors.current.background,
|
||||||
|
navigationIcon: @Composable () -> Unit = {},
|
||||||
|
actions: @Composable RowScope.() -> Unit = {},
|
||||||
|
){
|
||||||
|
CenterAlignedTopAppBar(
|
||||||
|
modifier = modifier,
|
||||||
|
title = {
|
||||||
|
AppBarText(title = title)
|
||||||
|
},
|
||||||
|
colors = appBarColors(backgroundColor),
|
||||||
|
navigationIcon = navigationIcon,
|
||||||
|
actions = actions,
|
||||||
|
scrollBehavior = scrollBehavior
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Common use case of an app bar with a back button
|
||||||
|
*/
|
||||||
|
@OptIn(ExperimentalMaterial3Api::class)
|
||||||
|
@Composable
|
||||||
|
fun BackAppBar(
|
||||||
|
title: String,
|
||||||
|
onBack: () -> Unit,
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
|
scrollBehavior: TopAppBarScrollBehavior? = null,
|
||||||
|
backgroundColor: Color = LocalColors.current.background,
|
||||||
|
actions: @Composable RowScope.() -> Unit = {},
|
||||||
|
){
|
||||||
|
BasicAppBar(
|
||||||
|
modifier = modifier,
|
||||||
|
title = title,
|
||||||
|
navigationIcon = {
|
||||||
|
AppBarBackIcon(onBack = onBack)
|
||||||
|
},
|
||||||
|
actions = actions,
|
||||||
|
scrollBehavior = scrollBehavior,
|
||||||
|
backgroundColor = backgroundColor
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@ExperimentalMaterial3Api
|
||||||
|
@Composable
|
||||||
|
fun ActionAppBar(
|
||||||
|
title: String,
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
|
scrollBehavior: TopAppBarScrollBehavior? = null,
|
||||||
|
backgroundColor: Color = LocalColors.current.background,
|
||||||
|
actionMode: Boolean = false,
|
||||||
|
navigationIcon: @Composable () -> Unit = {},
|
||||||
|
actions: @Composable RowScope.() -> Unit = {},
|
||||||
|
actionModeActions: @Composable (RowScope.() -> Unit) = {},
|
||||||
|
) {
|
||||||
|
CenterAlignedTopAppBar(
|
||||||
|
modifier = modifier,
|
||||||
|
title = {
|
||||||
|
if (!actionMode) {
|
||||||
|
AppBarText(title = title)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
navigationIcon =navigationIcon,
|
||||||
|
scrollBehavior = scrollBehavior,
|
||||||
|
colors = appBarColors(backgroundColor),
|
||||||
|
actions = {
|
||||||
|
if (actionMode) {
|
||||||
|
actionModeActions()
|
||||||
|
} else {
|
||||||
|
actions()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun AppBarText(title: String) {
|
||||||
|
Text(text = title, style = LocalType.current.h4)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun AppBarBackIcon(onBack: () -> Unit) {
|
||||||
|
IconButton(onClick = onBack) {
|
||||||
|
Icon(
|
||||||
|
painter = painterResource(id = R.drawable.ic_baseline_arrow_back_24),
|
||||||
|
contentDescription = stringResource(R.string.back)
|
||||||
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
fun AppBar(title: String, onClose: () -> Unit = {}, onBack: (() -> Unit)? = null) {
|
fun AppBarCloseIcon(onClose: () -> Unit) {
|
||||||
Row(modifier = Modifier.height(LocalDimensions.current.appBarHeight), verticalAlignment = Alignment.CenterVertically) {
|
|
||||||
Box(contentAlignment = Alignment.Center, modifier = Modifier.size(LocalDimensions.current.appBarHeight)) {
|
|
||||||
onBack?.let {
|
|
||||||
IconButton(onClick = it) {
|
|
||||||
Icon(painter = painterResource(id = R.drawable.ic_prev), contentDescription = "back")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Spacer(modifier = Modifier.weight(1f))
|
|
||||||
Text(text = title, style = LocalType.current.h4)
|
|
||||||
Spacer(modifier = Modifier.weight(1f))
|
|
||||||
Box(contentAlignment = Alignment.Center, modifier = Modifier.size(LocalDimensions.current.appBarHeight)) {
|
|
||||||
IconButton(onClick = onClose) {
|
IconButton(onClick = onClose) {
|
||||||
Icon(painter = painterResource(id = R.drawable.ic_x), contentDescription = "close")
|
Icon(
|
||||||
}
|
painter = painterResource(id = R.drawable.ic_x),
|
||||||
}
|
contentDescription = stringResource(id = R.string.close)
|
||||||
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@OptIn(ExperimentalMaterial3Api::class)
|
||||||
|
@Composable
|
||||||
|
fun appBarColors(backgroundColor: Color) = TopAppBarDefaults.centerAlignedTopAppBarColors()
|
||||||
|
.copy(
|
||||||
|
containerColor = backgroundColor,
|
||||||
|
scrolledContainerColor = backgroundColor,
|
||||||
|
navigationIconContentColor = LocalColors.current.text,
|
||||||
|
titleContentColor = LocalColors.current.text,
|
||||||
|
actionIconContentColor = LocalColors.current.text
|
||||||
|
)
|
||||||
|
@ -57,15 +57,139 @@ class SaveAttachmentTask @JvmOverloads constructor(context: Context, count: Int
|
|||||||
button(R.string.no)
|
button(R.string.no)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fun saveAttachment(context: Context, attachment: Attachment): String? {
|
||||||
|
val contentType = checkNotNull(MediaUtil.getCorrectedMimeType(attachment.contentType))
|
||||||
|
var fileName = attachment.fileName
|
||||||
|
if (fileName == null) fileName = generateOutputFileName(contentType, attachment.date)
|
||||||
|
fileName = sanitizeOutputFileName(fileName)
|
||||||
|
val outputUri: Uri = getMediaStoreContentUriForType(contentType)
|
||||||
|
val mediaUri = createOutputUri(context, outputUri, contentType, fileName)
|
||||||
|
val updateValues = ContentValues()
|
||||||
|
PartAuthority.getAttachmentStream(context, attachment.uri).use { inputStream ->
|
||||||
|
if (inputStream == null) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
if (outputUri.scheme == ContentResolver.SCHEME_FILE) {
|
||||||
|
FileOutputStream(mediaUri!!.path).use { outputStream ->
|
||||||
|
StreamUtil.copy(inputStream, outputStream)
|
||||||
|
MediaScannerConnection.scanFile(context, arrayOf(mediaUri.path), arrayOf(contentType), null)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
context.contentResolver.openOutputStream(mediaUri!!, "w").use { outputStream ->
|
||||||
|
val total: Long = StreamUtil.copy(inputStream, outputStream)
|
||||||
|
if (total > 0) {
|
||||||
|
updateValues.put(MediaStore.MediaColumns.SIZE, total)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (Build.VERSION.SDK_INT > 28) {
|
||||||
|
updateValues.put(MediaStore.MediaColumns.IS_PENDING, 0)
|
||||||
|
}
|
||||||
|
if (updateValues.size() > 0) {
|
||||||
|
context.contentResolver.update(mediaUri!!, updateValues, null, null)
|
||||||
|
}
|
||||||
|
return outputUri.lastPathSegment
|
||||||
}
|
}
|
||||||
|
|
||||||
private val contextReference: WeakReference<Context>
|
private fun generateOutputFileName(contentType: String, timestamp: Long): String {
|
||||||
|
val mimeTypeMap = MimeTypeMap.getSingleton()
|
||||||
|
val extension = mimeTypeMap.getExtensionFromMimeType(contentType) ?: "attach"
|
||||||
|
val dateFormatter = SimpleDateFormat("yyyy-MM-dd-HHmmss")
|
||||||
|
val base = "session-${dateFormatter.format(timestamp)}"
|
||||||
|
|
||||||
|
return "${base}.${extension}";
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun sanitizeOutputFileName(fileName: String): String {
|
||||||
|
return File(fileName).name
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun getMediaStoreContentUriForType(contentType: String): Uri {
|
||||||
|
return when {
|
||||||
|
contentType.startsWith("video/") ->
|
||||||
|
ExternalStorageUtil.getVideoUri()
|
||||||
|
contentType.startsWith("audio/") ->
|
||||||
|
ExternalStorageUtil.getAudioUri()
|
||||||
|
contentType.startsWith("image/") ->
|
||||||
|
ExternalStorageUtil.getImageUri()
|
||||||
|
else ->
|
||||||
|
ExternalStorageUtil.getDownloadUri()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun createOutputUri(context: Context, outputUri: Uri, contentType: String, fileName: String): Uri? {
|
||||||
|
val fileParts: Array<String> = getFileNameParts(fileName)
|
||||||
|
val base = fileParts[0]
|
||||||
|
val extension = fileParts[1]
|
||||||
|
val mimeType = MimeTypeMap.getSingleton().getMimeTypeFromExtension(extension)
|
||||||
|
val contentValues = ContentValues()
|
||||||
|
contentValues.put(MediaStore.MediaColumns.DISPLAY_NAME, fileName)
|
||||||
|
contentValues.put(MediaStore.MediaColumns.MIME_TYPE, mimeType)
|
||||||
|
contentValues.put(MediaStore.MediaColumns.DATE_ADDED, TimeUnit.MILLISECONDS.toSeconds(System.currentTimeMillis()))
|
||||||
|
contentValues.put(MediaStore.MediaColumns.DATE_MODIFIED, TimeUnit.MILLISECONDS.toSeconds(System.currentTimeMillis()))
|
||||||
|
if (Build.VERSION.SDK_INT > 28) {
|
||||||
|
contentValues.put(MediaStore.MediaColumns.IS_PENDING, 1)
|
||||||
|
} else if (outputUri.scheme == ContentResolver.SCHEME_FILE) {
|
||||||
|
val outputDirectory = File(outputUri.path)
|
||||||
|
var outputFile = File(outputDirectory, "$base.$extension")
|
||||||
|
var i = 0
|
||||||
|
while (outputFile.exists()) {
|
||||||
|
outputFile = File(outputDirectory, base + "-" + ++i + "." + extension)
|
||||||
|
}
|
||||||
|
if (outputFile.isHidden) {
|
||||||
|
throw IOException("Specified name would not be visible")
|
||||||
|
}
|
||||||
|
return Uri.fromFile(outputFile)
|
||||||
|
} else {
|
||||||
|
var outputFileName = fileName
|
||||||
|
var dataPath = String.format("%s/%s", getExternalPathToFileForType(context, contentType), outputFileName)
|
||||||
|
var i = 0
|
||||||
|
while (pathTaken(context, outputUri, dataPath)) {
|
||||||
|
Log.d(TAG, "The content exists. Rename and check again.")
|
||||||
|
outputFileName = base + "-" + ++i + "." + extension
|
||||||
|
dataPath = String.format("%s/%s", getExternalPathToFileForType(context, contentType), outputFileName)
|
||||||
|
}
|
||||||
|
contentValues.put(MediaStore.MediaColumns.DATA, dataPath)
|
||||||
|
}
|
||||||
|
return context.contentResolver.insert(outputUri, contentValues)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun getExternalPathToFileForType(context: Context, contentType: String): String {
|
||||||
|
val storage: File = when {
|
||||||
|
contentType.startsWith("video/") ->
|
||||||
|
context.getExternalFilesDir(Environment.DIRECTORY_MOVIES)!!
|
||||||
|
contentType.startsWith("audio/") ->
|
||||||
|
context.getExternalFilesDir(Environment.DIRECTORY_MUSIC)!!
|
||||||
|
contentType.startsWith("image/") ->
|
||||||
|
context.getExternalFilesDir(Environment.DIRECTORY_PICTURES)!!
|
||||||
|
else ->
|
||||||
|
context.getExternalFilesDir(Environment.DIRECTORY_DOCUMENTS)!!
|
||||||
|
}
|
||||||
|
return storage.absolutePath
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun getFileNameParts(fileName: String): Array<String> {
|
||||||
|
val tokens = fileName.split("\\.(?=[^\\.]+$)".toRegex()).toTypedArray()
|
||||||
|
return arrayOf(tokens[0], if (tokens.size > 1) tokens[1] else "")
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun pathTaken(context: Context, outputUri: Uri, dataPath: String): Boolean {
|
||||||
|
context.contentResolver.query(outputUri, arrayOf(MediaStore.MediaColumns.DATA),
|
||||||
|
MediaStore.MediaColumns.DATA + " = ?", arrayOf(dataPath),
|
||||||
|
null).use { cursor ->
|
||||||
|
if (cursor == null) {
|
||||||
|
throw IOException("Something is wrong with the filename to save")
|
||||||
|
}
|
||||||
|
return cursor.moveToFirst()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private val contextReference = WeakReference(context)
|
||||||
private val attachmentCount: Int = count
|
private val attachmentCount: Int = count
|
||||||
|
|
||||||
init {
|
|
||||||
this.contextReference = WeakReference(context)
|
|
||||||
}
|
|
||||||
|
|
||||||
@Deprecated("Deprecated in Java")
|
@Deprecated("Deprecated in Java")
|
||||||
override fun doInBackground(vararg attachments: Attachment?): Pair<Int, String?> {
|
override fun doInBackground(vararg attachments: Attachment?): Pair<Int, String?> {
|
||||||
if (attachments.isEmpty()) {
|
if (attachments.isEmpty()) {
|
||||||
@ -97,137 +221,6 @@ class SaveAttachmentTask @JvmOverloads constructor(context: Context, count: Int
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@Throws(IOException::class)
|
|
||||||
private fun saveAttachment(context: Context, attachment: Attachment): String? {
|
|
||||||
val contentType = Objects.requireNonNull(MediaUtil.getCorrectedMimeType(attachment.contentType))!!
|
|
||||||
var fileName = attachment.fileName
|
|
||||||
if (fileName == null) fileName = generateOutputFileName(contentType, attachment.date)
|
|
||||||
fileName = sanitizeOutputFileName(fileName)
|
|
||||||
val outputUri: Uri = getMediaStoreContentUriForType(contentType)
|
|
||||||
val mediaUri = createOutputUri(outputUri, contentType, fileName)
|
|
||||||
val updateValues = ContentValues()
|
|
||||||
PartAuthority.getAttachmentStream(context, attachment.uri).use { inputStream ->
|
|
||||||
if (inputStream == null) {
|
|
||||||
return null
|
|
||||||
}
|
|
||||||
if (outputUri.scheme == ContentResolver.SCHEME_FILE) {
|
|
||||||
FileOutputStream(mediaUri!!.path).use { outputStream ->
|
|
||||||
StreamUtil.copy(inputStream, outputStream)
|
|
||||||
MediaScannerConnection.scanFile(context, arrayOf(mediaUri.path), arrayOf(contentType), null)
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
context.contentResolver.openOutputStream(mediaUri!!, "w").use { outputStream ->
|
|
||||||
val total: Long = StreamUtil.copy(inputStream, outputStream)
|
|
||||||
if (total > 0) {
|
|
||||||
updateValues.put(MediaStore.MediaColumns.SIZE, total)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (Build.VERSION.SDK_INT > 28) {
|
|
||||||
updateValues.put(MediaStore.MediaColumns.IS_PENDING, 0)
|
|
||||||
}
|
|
||||||
if (updateValues.size() > 0) {
|
|
||||||
getContext().contentResolver.update(mediaUri!!, updateValues, null, null)
|
|
||||||
}
|
|
||||||
return outputUri.lastPathSegment
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun getMediaStoreContentUriForType(contentType: String): Uri {
|
|
||||||
return when {
|
|
||||||
contentType.startsWith("video/") ->
|
|
||||||
ExternalStorageUtil.getVideoUri()
|
|
||||||
contentType.startsWith("audio/") ->
|
|
||||||
ExternalStorageUtil.getAudioUri()
|
|
||||||
contentType.startsWith("image/") ->
|
|
||||||
ExternalStorageUtil.getImageUri()
|
|
||||||
else ->
|
|
||||||
ExternalStorageUtil.getDownloadUri()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@Throws(IOException::class)
|
|
||||||
private fun createOutputUri(outputUri: Uri, contentType: String, fileName: String): Uri? {
|
|
||||||
val fileParts: Array<String> = getFileNameParts(fileName)
|
|
||||||
val base = fileParts[0]
|
|
||||||
val extension = fileParts[1]
|
|
||||||
val mimeType = MimeTypeMap.getSingleton().getMimeTypeFromExtension(extension)
|
|
||||||
val contentValues = ContentValues()
|
|
||||||
contentValues.put(MediaStore.MediaColumns.DISPLAY_NAME, fileName)
|
|
||||||
contentValues.put(MediaStore.MediaColumns.MIME_TYPE, mimeType)
|
|
||||||
contentValues.put(MediaStore.MediaColumns.DATE_ADDED, TimeUnit.MILLISECONDS.toSeconds(System.currentTimeMillis()))
|
|
||||||
contentValues.put(MediaStore.MediaColumns.DATE_MODIFIED, TimeUnit.MILLISECONDS.toSeconds(System.currentTimeMillis()))
|
|
||||||
if (Build.VERSION.SDK_INT > 28) {
|
|
||||||
contentValues.put(MediaStore.MediaColumns.IS_PENDING, 1)
|
|
||||||
} else if (Objects.equals(outputUri.scheme, ContentResolver.SCHEME_FILE)) {
|
|
||||||
val outputDirectory = File(outputUri.path)
|
|
||||||
var outputFile = File(outputDirectory, "$base.$extension")
|
|
||||||
var i = 0
|
|
||||||
while (outputFile.exists()) {
|
|
||||||
outputFile = File(outputDirectory, base + "-" + ++i + "." + extension)
|
|
||||||
}
|
|
||||||
if (outputFile.isHidden) {
|
|
||||||
throw IOException("Specified name would not be visible")
|
|
||||||
}
|
|
||||||
return Uri.fromFile(outputFile)
|
|
||||||
} else {
|
|
||||||
var outputFileName = fileName
|
|
||||||
var dataPath = String.format("%s/%s", getExternalPathToFileForType(contentType), outputFileName)
|
|
||||||
var i = 0
|
|
||||||
while (pathTaken(outputUri, dataPath)) {
|
|
||||||
Log.d(TAG, "The content exists. Rename and check again.")
|
|
||||||
outputFileName = base + "-" + ++i + "." + extension
|
|
||||||
dataPath = String.format("%s/%s", getExternalPathToFileForType(contentType), outputFileName)
|
|
||||||
}
|
|
||||||
contentValues.put(MediaStore.MediaColumns.DATA, dataPath)
|
|
||||||
}
|
|
||||||
return context.contentResolver.insert(outputUri, contentValues)
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun getFileNameParts(fileName: String): Array<String> {
|
|
||||||
val tokens = fileName.split("\\.(?=[^\\.]+$)".toRegex()).toTypedArray()
|
|
||||||
return arrayOf(tokens[0], if (tokens.size > 1) tokens[1] else "")
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun getExternalPathToFileForType(contentType: String): String {
|
|
||||||
val storage: File = when {
|
|
||||||
contentType.startsWith("video/") ->
|
|
||||||
context.getExternalFilesDir(Environment.DIRECTORY_MOVIES)!!
|
|
||||||
contentType.startsWith("audio/") ->
|
|
||||||
context.getExternalFilesDir(Environment.DIRECTORY_MUSIC)!!
|
|
||||||
contentType.startsWith("image/") ->
|
|
||||||
context.getExternalFilesDir(Environment.DIRECTORY_PICTURES)!!
|
|
||||||
else ->
|
|
||||||
context.getExternalFilesDir(Environment.DIRECTORY_DOCUMENTS)!!
|
|
||||||
}
|
|
||||||
return storage.absolutePath
|
|
||||||
}
|
|
||||||
|
|
||||||
@Throws(IOException::class)
|
|
||||||
private fun pathTaken(outputUri: Uri, dataPath: String): Boolean {
|
|
||||||
context.contentResolver.query(outputUri, arrayOf(MediaStore.MediaColumns.DATA),
|
|
||||||
MediaStore.MediaColumns.DATA + " = ?", arrayOf(dataPath),
|
|
||||||
null).use { cursor ->
|
|
||||||
if (cursor == null) {
|
|
||||||
throw IOException("Something is wrong with the filename to save")
|
|
||||||
}
|
|
||||||
return cursor.moveToFirst()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun generateOutputFileName(contentType: String, timestamp: Long): String {
|
|
||||||
val mimeTypeMap = MimeTypeMap.getSingleton()
|
|
||||||
val extension = mimeTypeMap.getExtensionFromMimeType(contentType) ?: "attach"
|
|
||||||
val dateFormatter = SimpleDateFormat("yyyy-MM-dd-HHmmss")
|
|
||||||
val base = "session-${dateFormatter.format(timestamp)}"
|
|
||||||
|
|
||||||
return "${base}.${extension}";
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun sanitizeOutputFileName(fileName: String): String {
|
|
||||||
return File(fileName).name
|
|
||||||
}
|
|
||||||
|
|
||||||
@Deprecated("Deprecated in Java")
|
@Deprecated("Deprecated in Java")
|
||||||
override fun onPostExecute(result: Pair<Int, String?>) {
|
override fun onPostExecute(result: Pair<Int, String?>) {
|
||||||
super.onPostExecute(result)
|
super.onPostExecute(result)
|
||||||
@ -255,4 +248,5 @@ class SaveAttachmentTask @JvmOverloads constructor(context: Context, count: Int
|
|||||||
}
|
}
|
||||||
|
|
||||||
data class Attachment(val uri: Uri, val contentType: String, val date: Long, val fileName: String?)
|
data class Attachment(val uri: Uri, val contentType: String, val date: Long, val fileName: String?)
|
||||||
|
|
||||||
}
|
}
|
@ -1,37 +0,0 @@
|
|||||||
<?xml version="1.0" encoding="utf-8"?>
|
|
||||||
<androidx.coordinatorlayout.widget.CoordinatorLayout
|
|
||||||
xmlns:android="http://schemas.android.com/apk/res/android"
|
|
||||||
xmlns:tools="http://schemas.android.com/tools"
|
|
||||||
xmlns:app="http://schemas.android.com/apk/res-auto"
|
|
||||||
android:layout_height="match_parent"
|
|
||||||
android:layout_width="match_parent"
|
|
||||||
tools:context="org.thoughtcrime.securesms.MediaOverviewActivity">
|
|
||||||
|
|
||||||
<com.google.android.material.appbar.AppBarLayout
|
|
||||||
style="@style/Widget.Session.AppBarLayout"
|
|
||||||
android:layout_width="match_parent"
|
|
||||||
android:layout_height="wrap_content"
|
|
||||||
android:stateListAnimator="@animator/appbar_elevation">
|
|
||||||
|
|
||||||
<androidx.appcompat.widget.Toolbar
|
|
||||||
android:id="@+id/toolbar"
|
|
||||||
android:layout_width="match_parent"
|
|
||||||
android:layout_height="?android:attr/actionBarSize"
|
|
||||||
app:layout_scrollFlags="scroll|enterAlways"/>
|
|
||||||
|
|
||||||
<org.thoughtcrime.securesms.components.ControllableTabLayout
|
|
||||||
android:id="@+id/tab_layout"
|
|
||||||
android:layout_width="match_parent"
|
|
||||||
android:layout_height="wrap_content"
|
|
||||||
android:layout_gravity="top"/>
|
|
||||||
|
|
||||||
</com.google.android.material.appbar.AppBarLayout>
|
|
||||||
|
|
||||||
<org.thoughtcrime.securesms.components.ControllableViewPager
|
|
||||||
android:id="@+id/pager"
|
|
||||||
android:layout_width="match_parent"
|
|
||||||
android:layout_height="match_parent"
|
|
||||||
app:layout_behavior="@string/appbar_scrolling_view_behavior"/>
|
|
||||||
|
|
||||||
</androidx.coordinatorlayout.widget.CoordinatorLayout>
|
|
||||||
|
|
@ -1,32 +0,0 @@
|
|||||||
<?xml version="1.0" encoding="utf-8"?>
|
|
||||||
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
|
|
||||||
xmlns:tools="http://schemas.android.com/tools"
|
|
||||||
android:orientation="horizontal"
|
|
||||||
android:layout_width="match_parent"
|
|
||||||
android:layout_height="wrap_content"
|
|
||||||
android:padding="16dp">
|
|
||||||
|
|
||||||
<org.thoughtcrime.securesms.components.DocumentView
|
|
||||||
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/document_view"
|
|
||||||
android:layout_width="match_parent"
|
|
||||||
android:layout_height="wrap_content"
|
|
||||||
android:layout_gravity="center_vertical"
|
|
||||||
android:layout_weight="1"
|
|
||||||
android:visibility="visible"
|
|
||||||
app:doc_titleColor="?android:textColorPrimary"
|
|
||||||
app:doc_captionColor="?android:textColorTertiary"
|
|
||||||
tools:visibility="visible"/>
|
|
||||||
|
|
||||||
<TextView android:id="@+id/date"
|
|
||||||
android:layout_width="wrap_content"
|
|
||||||
android:layout_height="wrap_content"
|
|
||||||
android:layout_gravity="center_vertical"
|
|
||||||
android:textSize="12sp"
|
|
||||||
android:textColor="?android:textColorTertiary"
|
|
||||||
android:paddingTop="20dp"
|
|
||||||
tools:text="Jun 1"/>
|
|
||||||
|
|
||||||
</LinearLayout>
|
|
@ -1,16 +0,0 @@
|
|||||||
<?xml version="1.0" encoding="utf-8"?>
|
|
||||||
<FrameLayout xmlns:android="http://schemas.android.com/apk/res/android"
|
|
||||||
xmlns:tools="http://schemas.android.com/tools"
|
|
||||||
android:layout_width="match_parent"
|
|
||||||
android:layout_height="wrap_content"
|
|
||||||
android:background="?attr/media_overview_toolbar_background"
|
|
||||||
android:padding="16dp">
|
|
||||||
|
|
||||||
<TextView android:id="@+id/text"
|
|
||||||
android:layout_width="wrap_content"
|
|
||||||
android:layout_height="wrap_content"
|
|
||||||
android:layout_gravity="start|center_vertical"
|
|
||||||
android:textColor="?attr/media_overview_header_foreground"
|
|
||||||
android:textSize="@dimen/small_font_size"
|
|
||||||
tools:text="March 1, 2015" />
|
|
||||||
</FrameLayout>
|
|
@ -1,25 +0,0 @@
|
|||||||
<?xml version="1.0" encoding="utf-8"?>
|
|
||||||
<RelativeLayout 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:layout_width="match_parent"
|
|
||||||
android:layout_height="match_parent">
|
|
||||||
|
|
||||||
<androidx.recyclerview.widget.RecyclerView
|
|
||||||
android:id="@+id/recycler_view"
|
|
||||||
android:layout_width="match_parent"
|
|
||||||
android:layout_height="match_parent"
|
|
||||||
android:scrollbars="vertical"
|
|
||||||
tools:listitem="@layout/media_overview_document_item" />
|
|
||||||
|
|
||||||
<TextView
|
|
||||||
android:id="@+id/no_documents"
|
|
||||||
android:layout_width="match_parent"
|
|
||||||
android:layout_height="match_parent"
|
|
||||||
android:gravity="center"
|
|
||||||
android:text="@string/media_overview_documents_fragment__no_documents_found"
|
|
||||||
android:textSize="@dimen/medium_font_size"
|
|
||||||
android:visibility="gone"
|
|
||||||
tools:visibility="visible" />
|
|
||||||
|
|
||||||
</RelativeLayout>
|
|
@ -1,21 +0,0 @@
|
|||||||
<?xml version="1.0" encoding="utf-8"?>
|
|
||||||
<RelativeLayout xmlns:android="http://schemas.android.com/apk/res/android"
|
|
||||||
android:layout_width="match_parent"
|
|
||||||
android:layout_height="match_parent">
|
|
||||||
|
|
||||||
<androidx.recyclerview.widget.RecyclerView
|
|
||||||
android:id="@+id/media_grid"
|
|
||||||
android:layout_width="match_parent"
|
|
||||||
android:layout_height="match_parent"
|
|
||||||
android:scrollbars="vertical" />
|
|
||||||
|
|
||||||
<TextView android:id="@+id/no_images"
|
|
||||||
android:layout_width="match_parent"
|
|
||||||
android:layout_height="match_parent"
|
|
||||||
android:textSize="@dimen/medium_font_size"
|
|
||||||
android:gravity="center"
|
|
||||||
android:textColor="?android:textColorPrimary"
|
|
||||||
android:visibility="gone"
|
|
||||||
android:text="@string/media_overview_activity__no_media" />
|
|
||||||
|
|
||||||
</RelativeLayout>
|
|
@ -1,28 +0,0 @@
|
|||||||
<?xml version="1.0" encoding="utf-8"?>
|
|
||||||
<org.thoughtcrime.securesms.components.SquareFrameLayout
|
|
||||||
xmlns:android="http://schemas.android.com/apk/res/android"
|
|
||||||
android:layout_width="match_parent"
|
|
||||||
android:layout_height="match_parent"
|
|
||||||
android:padding="2dp">
|
|
||||||
|
|
||||||
<include layout="@layout/thumbnail_view"
|
|
||||||
android:id="@+id/image"
|
|
||||||
android:layout_width="match_parent"
|
|
||||||
android:layout_height="match_parent"
|
|
||||||
android:contentDescription="@string/media_preview_activity__media_content_description" />
|
|
||||||
|
|
||||||
<FrameLayout
|
|
||||||
android:id="@+id/selected_indicator"
|
|
||||||
android:layout_width="match_parent"
|
|
||||||
android:layout_height="match_parent"
|
|
||||||
android:background="@color/MediaOverview_Media_selected_overlay"
|
|
||||||
android:visibility="gone">
|
|
||||||
|
|
||||||
<ImageView
|
|
||||||
android:layout_width="wrap_content"
|
|
||||||
android:layout_height="wrap_content"
|
|
||||||
android:src="@drawable/check"
|
|
||||||
android:layout_gravity="center"/>
|
|
||||||
</FrameLayout>
|
|
||||||
|
|
||||||
</org.thoughtcrime.securesms.components.SquareFrameLayout>
|
|
@ -1,19 +0,0 @@
|
|||||||
<?xml version="1.0" encoding="utf-8"?>
|
|
||||||
<FrameLayout
|
|
||||||
xmlns:android="http://schemas.android.com/apk/res/android"
|
|
||||||
xmlns:tools="http://schemas.android.com/tools"
|
|
||||||
android:layout_width="match_parent"
|
|
||||||
android:layout_height="wrap_content"
|
|
||||||
android:background="?colorPrimary"
|
|
||||||
android:padding="16dp">
|
|
||||||
|
|
||||||
<TextView
|
|
||||||
android:id="@+id/text"
|
|
||||||
android:layout_width="wrap_content"
|
|
||||||
android:layout_height="wrap_content"
|
|
||||||
android:layout_gravity="start|center_vertical"
|
|
||||||
android:textColor="?android:textColorPrimary"
|
|
||||||
android:textSize="@dimen/small_font_size"
|
|
||||||
tools:text="March 1, 2015" />
|
|
||||||
|
|
||||||
</FrameLayout>
|
|
@ -1139,4 +1139,5 @@
|
|||||||
<string name="you_cannot_go_back_further_in_order_to_stop_loading_your_account_session_needs_to_quit">You cannot go back further. In order to stop loading your account, Session needs to quit.</string>
|
<string name="you_cannot_go_back_further_in_order_to_stop_loading_your_account_session_needs_to_quit">You cannot go back further. In order to stop loading your account, Session needs to quit.</string>
|
||||||
<string name="you_cannot_go_back_further_cancel_account_creation">You cannot go back further. In order to cancel your account creation, Session needs to quit.</string>
|
<string name="you_cannot_go_back_further_cancel_account_creation">You cannot go back further. In order to cancel your account creation, Session needs to quit.</string>
|
||||||
<string name="quit">Quit</string>
|
<string name="quit">Quit</string>
|
||||||
|
<string name="back">Back</string>
|
||||||
</resources>
|
</resources>
|
||||||
|
@ -15,7 +15,7 @@ android.enableJetifier=true
|
|||||||
|
|
||||||
org.gradle.jvmargs=-Xmx2048M -Dkotlin.daemon.jvm.options\="-Xmx2048M"
|
org.gradle.jvmargs=-Xmx2048M -Dkotlin.daemon.jvm.options\="-Xmx2048M"
|
||||||
|
|
||||||
gradlePluginVersion=7.3.1
|
gradlePluginVersion=8.5.2
|
||||||
googleServicesVersion=4.3.12
|
googleServicesVersion=4.3.12
|
||||||
kotlinVersion=1.9.24
|
kotlinVersion=1.9.24
|
||||||
android.useAndroidX=true
|
android.useAndroidX=true
|
||||||
@ -40,3 +40,6 @@ phraseVersion=1.2.0
|
|||||||
preferenceVersion=1.2.0
|
preferenceVersion=1.2.0
|
||||||
protobufVersion=2.5.0
|
protobufVersion=2.5.0
|
||||||
testCoreVersion=1.5.0
|
testCoreVersion=1.5.0
|
||||||
|
android.defaults.buildfeatures.buildconfig=true
|
||||||
|
android.nonTransitiveRClass=false
|
||||||
|
android.nonFinalResIds=false
|
||||||
|
2
gradle/wrapper/gradle-wrapper.properties
vendored
2
gradle/wrapper/gradle-wrapper.properties
vendored
@ -1,6 +1,6 @@
|
|||||||
#Thu Dec 30 07:09:53 SAST 2021
|
#Thu Dec 30 07:09:53 SAST 2021
|
||||||
distributionBase=GRADLE_USER_HOME
|
distributionBase=GRADLE_USER_HOME
|
||||||
distributionUrl=https\://services.gradle.org/distributions/gradle-7.4-bin.zip
|
distributionUrl=https\://services.gradle.org/distributions/gradle-8.7-bin.zip
|
||||||
distributionPath=wrapper/dists
|
distributionPath=wrapper/dists
|
||||||
zipStorePath=wrapper/dists
|
zipStorePath=wrapper/dists
|
||||||
zipStoreBase=GRADLE_USER_HOME
|
zipStoreBase=GRADLE_USER_HOME
|
||||||
|
@ -1,5 +1,4 @@
|
|||||||
<?xml version="1.0" encoding="utf-8"?>
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
|
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
|
||||||
package="network.loki.messenger.libsession_util">
|
|
||||||
|
|
||||||
</manifest>
|
</manifest>
|
@ -17,6 +17,12 @@ android {
|
|||||||
sourceCompatibility JavaVersion.VERSION_1_8
|
sourceCompatibility JavaVersion.VERSION_1_8
|
||||||
targetCompatibility JavaVersion.VERSION_1_8
|
targetCompatibility JavaVersion.VERSION_1_8
|
||||||
}
|
}
|
||||||
|
|
||||||
|
kotlinOptions {
|
||||||
|
jvmTarget = '1.8'
|
||||||
|
}
|
||||||
|
|
||||||
|
namespace 'org.session.libsession'
|
||||||
}
|
}
|
||||||
|
|
||||||
dependencies {
|
dependencies {
|
||||||
|
@ -1,2 +1,2 @@
|
|||||||
<?xml version="1.0" encoding="utf-8"?>
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
<manifest package="org.session.libsession" />
|
<manifest />
|
@ -12,6 +12,11 @@ android {
|
|||||||
sourceCompatibility JavaVersion.VERSION_1_8
|
sourceCompatibility JavaVersion.VERSION_1_8
|
||||||
targetCompatibility JavaVersion.VERSION_1_8
|
targetCompatibility JavaVersion.VERSION_1_8
|
||||||
}
|
}
|
||||||
|
|
||||||
|
kotlinOptions {
|
||||||
|
jvmTarget = '1.8'
|
||||||
|
}
|
||||||
|
namespace 'org.session.libsignal'
|
||||||
}
|
}
|
||||||
|
|
||||||
dependencies {
|
dependencies {
|
||||||
|
@ -1,2 +1,2 @@
|
|||||||
<?xml version="1.0" encoding="utf-8"?>
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
<manifest package="org.session.libsignal" />
|
<manifest />
|
@ -5,4 +5,3 @@ include ':liblazysodium'
|
|||||||
include ':libsession'
|
include ':libsession'
|
||||||
include ':libsignal'
|
include ':libsignal'
|
||||||
include ':libsession-util'
|
include ':libsession-util'
|
||||||
include ':stickyheader'
|
|
||||||
|
@ -1,2 +0,0 @@
|
|||||||
configurations.maybeCreate("default")
|
|
||||||
artifacts.add("default", file('stickyheadergrid-0.9.4.aar'))
|
|
Binary file not shown.
Loading…
Reference in New Issue
Block a user