mirror of
https://github.com/oxen-io/session-android.git
synced 2024-11-21 06:55:16 +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 {
|
||||
id 'com.google.devtools.ksp'
|
||||
id 'com.google.dagger.hilt.android'
|
||||
@ -24,8 +9,8 @@ apply plugin: 'witness'
|
||||
apply plugin: 'kotlin-parcelize'
|
||||
apply plugin: 'kotlinx-serialization'
|
||||
|
||||
configurations.all {
|
||||
exclude module: "commons-logging"
|
||||
configurations.forEach {
|
||||
it.exclude module: "commons-logging"
|
||||
}
|
||||
|
||||
def canonicalVersionCode = 379
|
||||
@ -64,13 +49,9 @@ android {
|
||||
}
|
||||
|
||||
packagingOptions {
|
||||
exclude 'LICENSE.txt'
|
||||
exclude 'LICENSE'
|
||||
exclude 'NOTICE'
|
||||
exclude 'asm-license.txt'
|
||||
exclude 'META-INF/LICENSE'
|
||||
exclude 'META-INF/NOTICE'
|
||||
exclude 'META-INF/proguard/androidx-annotations.pro'
|
||||
resources {
|
||||
excludes += ['LICENSE.txt', 'LICENSE', 'NOTICE', 'asm-license.txt', 'META-INF/LICENSE', 'META-INF/NOTICE', 'META-INF/proguard/androidx-annotations.pro']
|
||||
}
|
||||
}
|
||||
|
||||
splits {
|
||||
@ -85,6 +66,7 @@ android {
|
||||
buildFeatures {
|
||||
compose true
|
||||
}
|
||||
|
||||
composeOptions {
|
||||
kotlinCompilerExtensionVersion '1.5.14'
|
||||
}
|
||||
@ -108,8 +90,8 @@ android {
|
||||
buildConfigField "String", "USER_AGENT", "\"OWA\""
|
||||
buildConfigField "String[]", "LANGUAGES", "new String[]{\"" + autoResConfig().collect { s -> s.replace('-r', '_') }.join('", "') + '"}'
|
||||
buildConfigField "int", "CANONICAL_VERSION_CODE", "$canonicalVersionCode"
|
||||
resourceConfigurations += []
|
||||
|
||||
resConfigs autoResConfig()
|
||||
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
|
||||
// The following argument makes the Android Test Orchestrator run its
|
||||
// "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 ->
|
||||
def abiName = output.getFilter("ABI") ?: 'universal'
|
||||
def postFix = abiPostFix.get(abiName, 0)
|
||||
@ -180,10 +162,6 @@ android {
|
||||
}
|
||||
}
|
||||
|
||||
lintOptions {
|
||||
abortOnError true
|
||||
baseline file("lint-baseline.xml")
|
||||
}
|
||||
|
||||
testOptions {
|
||||
unitTests {
|
||||
@ -192,7 +170,6 @@ android {
|
||||
}
|
||||
|
||||
buildFeatures {
|
||||
dataBinding true
|
||||
viewBinding true
|
||||
}
|
||||
|
||||
@ -212,9 +189,11 @@ android {
|
||||
}
|
||||
}
|
||||
|
||||
task testPlayDebugUnitTestCoverageReport(type: JacocoReport, dependsOn: "testPlayDebugUnitTest") {
|
||||
tasks.register('testPlayDebugUnitTestCoverageReport', JacocoReport) {
|
||||
dependsOn 'testPlayDebugUnitTest'
|
||||
|
||||
reports {
|
||||
xml.enabled = true
|
||||
xml.required = true
|
||||
}
|
||||
|
||||
// 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.
|
||||
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 {
|
||||
@ -258,6 +244,7 @@ dependencies {
|
||||
implementation "androidx.lifecycle:lifecycle-extensions:2.2.0"
|
||||
implementation "androidx.paging:paging-runtime-ktx:$pagingVersion"
|
||||
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.core:core-ktx:$coreVersion"
|
||||
implementation "androidx.work:work-runtime-ktx:2.7.1"
|
||||
@ -279,6 +266,7 @@ dependencies {
|
||||
implementation 'commons-net:commons-net:3.7.2'
|
||||
implementation 'com.github.chrisbanes:PhotoView:2.1.3'
|
||||
implementation "com.github.bumptech.glide:glide:$glideVersion"
|
||||
implementation "com.github.bumptech.glide:compose:1.0.0-beta01"
|
||||
ksp "com.github.bumptech.glide:ksp:$glideVersion"
|
||||
implementation 'com.makeramen:roundedimageview:2.1.0'
|
||||
implementation 'com.pnikosis:materialish-progress:1.5'
|
||||
@ -299,7 +287,6 @@ dependencies {
|
||||
exclude group: 'com.squareup.okhttp', module: 'okhttp-urlconnection'
|
||||
}
|
||||
implementation 'com.annimon:stream:1.1.8'
|
||||
implementation project(':stickyheader')
|
||||
implementation 'com.github.dmytrodanylyk.circular-progress-button:library:1.1.3-S2'
|
||||
implementation 'androidx.sqlite:sqlite-ktx:2.3.1'
|
||||
implementation 'net.zetetic:sqlcipher-android:4.5.4@aar'
|
||||
|
@ -1,6 +1,5 @@
|
||||
<manifest
|
||||
xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
package="network.loki.messenger.test">
|
||||
xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<application>
|
||||
<uses-library android:name="android.test.runner"
|
||||
android:required="false" />
|
||||
|
@ -306,6 +306,8 @@
|
||||
android:name="android.support.PARENT_ACTIVITY"
|
||||
android:value="org.thoughtcrime.securesms.home.HomeActivity" />
|
||||
</activity>
|
||||
<activity android:name="org.thoughtcrime.securesms.media.MediaOverviewActivity" />
|
||||
|
||||
<service android:enabled="true" android:name="org.thoughtcrime.securesms.service.WebRtcCallService"
|
||||
android:foregroundServiceType="microphone"
|
||||
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.loaders.PagingMediaLoader;
|
||||
import org.thoughtcrime.securesms.database.model.MmsMessageRecord;
|
||||
import org.thoughtcrime.securesms.media.MediaOverviewActivity;
|
||||
import org.thoughtcrime.securesms.mediapreview.MediaPreviewViewModel;
|
||||
import org.thoughtcrime.securesms.mediapreview.MediaRailAdapter;
|
||||
import org.thoughtcrime.securesms.mms.Slide;
|
||||
@ -390,9 +391,7 @@ public class MediaPreviewActivity extends PassphraseRequiredActionBarActivity im
|
||||
}
|
||||
|
||||
private void showOverview() {
|
||||
Intent intent = new Intent(this, MediaOverviewActivity.class);
|
||||
intent.putExtra(MediaOverviewActivity.ADDRESS_EXTRA, conversationRecipient.getAddress());
|
||||
startActivity(intent);
|
||||
startActivity(MediaOverviewActivity.createIntent(this, conversationRecipient.getAddress()));
|
||||
}
|
||||
|
||||
private void forward() {
|
||||
|
@ -7,11 +7,13 @@ import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.input.nestedscroll.nestedScroll
|
||||
import androidx.compose.ui.platform.rememberNestedScrollInteropConnection
|
||||
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.ui.Divider
|
||||
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.LocalType
|
||||
import org.thoughtcrime.securesms.ui.theme.PreviewTheme
|
||||
import org.thoughtcrime.securesms.ui.theme.SessionColorsParameterProvider
|
||||
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
|
||||
internal fun StartConversationScreen(
|
||||
accountId: String,
|
||||
@ -41,7 +45,11 @@ internal fun StartConversationScreen(
|
||||
LocalColors.current.backgroundSecondary,
|
||||
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(
|
||||
modifier = Modifier.nestedScroll(rememberNestedScrollInteropConnection()),
|
||||
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.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.tooling.preview.Preview
|
||||
import network.loki.messenger.R
|
||||
import org.thoughtcrime.securesms.ui.theme.LocalDimensions
|
||||
import org.thoughtcrime.securesms.ui.theme.PreviewTheme
|
||||
import org.thoughtcrime.securesms.ui.theme.LocalColors
|
||||
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.SlimOutlineButton
|
||||
import org.thoughtcrime.securesms.ui.components.SlimOutlineCopyButton
|
||||
import org.thoughtcrime.securesms.ui.components.border
|
||||
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.PreviewTheme
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
internal fun InviteFriend(
|
||||
accountId: String,
|
||||
@ -38,7 +42,12 @@ internal fun InviteFriend(
|
||||
LocalColors.current.backgroundSecondary,
|
||||
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(
|
||||
modifier = Modifier.padding(horizontal = 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.rememberScrollState
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Scaffold
|
||||
import androidx.compose.material3.Surface
|
||||
@ -29,6 +30,7 @@ import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.input.nestedscroll.nestedScroll
|
||||
import androidx.compose.ui.platform.LocalDensity
|
||||
import androidx.compose.ui.platform.LocalView
|
||||
@ -43,7 +45,8 @@ import kotlinx.coroutines.flow.emptyFlow
|
||||
import network.loki.messenger.R
|
||||
import org.thoughtcrime.securesms.conversation.start.StartConversationFragment.Companion.PEEK_RATIO
|
||||
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.MaybeScanQrCode
|
||||
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)
|
||||
|
||||
@OptIn(ExperimentalFoundationApi::class)
|
||||
@OptIn(ExperimentalFoundationApi::class, ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
internal fun NewMessage(
|
||||
state: State,
|
||||
@ -76,7 +79,12 @@ internal fun NewMessage(
|
||||
LocalColors.current.backgroundSecondary,
|
||||
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)
|
||||
HorizontalPager(pagerState) {
|
||||
when (TITLES[it]) {
|
||||
|
@ -24,7 +24,7 @@ import org.session.libsession.utilities.TextSecurePreferences
|
||||
import org.session.libsession.utilities.recipients.Recipient
|
||||
import org.session.libsignal.utilities.guava.Optional
|
||||
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.calls.WebRtcCallActivity
|
||||
import org.thoughtcrime.securesms.contacts.SelectContactsActivity
|
||||
@ -149,10 +149,8 @@ object ConversationMenuHelper {
|
||||
}
|
||||
|
||||
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
|
||||
activity.startActivity(intent)
|
||||
activity.startActivity(MediaOverviewActivity.createIntent(context, thread.address))
|
||||
}
|
||||
|
||||
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)
|
||||
.putExtra(Settings.EXTRA_APP_PACKAGE, BuildConfig.APPLICATION_ID)
|
||||
.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
|
||||
.takeIf { IntentUtils.isResolvable(requireContext(), it) }.let {
|
||||
startActivity(it)
|
||||
}
|
||||
.takeIf { IntentUtils.isResolvable(requireContext(), it) }
|
||||
?.let { startActivity(it) }
|
||||
}
|
||||
button(R.string.dismiss)
|
||||
}
|
||||
|
@ -1,6 +1,7 @@
|
||||
package org.thoughtcrime.securesms.ui
|
||||
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.border
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
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.text.style.TextAlign
|
||||
import androidx.compose.ui.tooling.preview.Preview
|
||||
import androidx.compose.ui.unit.dp
|
||||
import network.loki.messenger.R
|
||||
import org.thoughtcrime.securesms.ui.theme.LocalColors
|
||||
import org.thoughtcrime.securesms.ui.theme.LocalDimensions
|
||||
@ -56,11 +58,17 @@ fun AlertDialog(
|
||||
onDismissRequest = onDismissRequest,
|
||||
content = {
|
||||
Box(
|
||||
modifier = Modifier.background(color = LocalColors.current.backgroundSecondary,
|
||||
modifier = Modifier.background(
|
||||
color = LocalColors.current.backgroundSecondary,
|
||||
shape = MaterialTheme.shapes.small)
|
||||
.border(
|
||||
width = 1.dp,
|
||||
color = LocalColors.current.borders,
|
||||
shape = MaterialTheme.shapes.small)
|
||||
|
||||
) {
|
||||
// only show the 'x' button is required
|
||||
if(showCloseButton) {
|
||||
if (showCloseButton) {
|
||||
IconButton(
|
||||
onClick = onDismissRequest,
|
||||
modifier = Modifier.align(Alignment.TopEnd)
|
||||
@ -78,7 +86,7 @@ fun AlertDialog(
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(top = LocalDimensions.current.smallSpacing)
|
||||
.padding(top = LocalDimensions.current.spacing)
|
||||
.padding(horizontal = LocalDimensions.current.smallSpacing)
|
||||
) {
|
||||
title?.let {
|
||||
@ -123,7 +131,12 @@ fun AlertDialog(
|
||||
}
|
||||
|
||||
@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(
|
||||
modifier = modifier,
|
||||
shape = RectangleShape,
|
||||
@ -135,8 +148,7 @@ fun DialogButton(text: String, modifier: Modifier, color: Color = Color.Unspecif
|
||||
style = LocalType.current.large.bold(),
|
||||
textAlign = TextAlign.Center,
|
||||
modifier = Modifier.padding(
|
||||
top = LocalDimensions.current.smallSpacing,
|
||||
bottom = LocalDimensions.current.spacing
|
||||
vertical = LocalDimensions.current.smallSpacing
|
||||
)
|
||||
)
|
||||
}
|
||||
@ -144,7 +156,7 @@ fun DialogButton(text: String, modifier: Modifier, color: Color = Color.Unspecif
|
||||
|
||||
@Preview
|
||||
@Composable
|
||||
fun PreviewSimpleDialog(){
|
||||
fun PreviewSimpleDialog() {
|
||||
PreviewTheme {
|
||||
AlertDialog(
|
||||
onDismissRequest = {},
|
||||
@ -166,7 +178,7 @@ fun PreviewSimpleDialog(){
|
||||
|
||||
@Preview
|
||||
@Composable
|
||||
fun PreviewXCloseDialog(){
|
||||
fun PreviewXCloseDialog() {
|
||||
PreviewTheme {
|
||||
AlertDialog(
|
||||
title = stringResource(R.string.urlOpen),
|
||||
|
@ -1,53 +1,176 @@
|
||||
package org.thoughtcrime.securesms.ui.components
|
||||
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.RowScope
|
||||
import androidx.compose.material3.CenterAlignedTopAppBar
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
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.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
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.PreviewParameter
|
||||
import androidx.compose.ui.unit.dp
|
||||
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.PreviewTheme
|
||||
import org.thoughtcrime.securesms.ui.theme.SessionColorsParameterProvider
|
||||
import org.thoughtcrime.securesms.ui.theme.ThemeColors
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Preview
|
||||
@Composable
|
||||
fun AppBarPreview(
|
||||
@PreviewParameter(SessionColorsParameterProvider::class) colors: ThemeColors
|
||||
) {
|
||||
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
|
||||
fun AppBar(title: String, onClose: () -> Unit = {}, onBack: (() -> Unit)? = null) {
|
||||
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) {
|
||||
Icon(painter = painterResource(id = R.drawable.ic_x), contentDescription = "close")
|
||||
}
|
||||
}
|
||||
fun AppBarCloseIcon(onClose: () -> Unit) {
|
||||
IconButton(onClick = onClose) {
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
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 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 contextReference = WeakReference(context)
|
||||
private val attachmentCount: Int = count
|
||||
|
||||
init {
|
||||
this.contextReference = WeakReference(context)
|
||||
}
|
||||
|
||||
@Deprecated("Deprecated in Java")
|
||||
override fun doInBackground(vararg attachments: Attachment?): Pair<Int, String?> {
|
||||
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")
|
||||
override fun onPostExecute(result: Pair<Int, String?>) {
|
||||
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?)
|
||||
|
||||
}
|
@ -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_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="back">Back</string>
|
||||
</resources>
|
||||
|
@ -15,7 +15,7 @@ android.enableJetifier=true
|
||||
|
||||
org.gradle.jvmargs=-Xmx2048M -Dkotlin.daemon.jvm.options\="-Xmx2048M"
|
||||
|
||||
gradlePluginVersion=7.3.1
|
||||
gradlePluginVersion=8.5.2
|
||||
googleServicesVersion=4.3.12
|
||||
kotlinVersion=1.9.24
|
||||
android.useAndroidX=true
|
||||
@ -40,3 +40,6 @@ phraseVersion=1.2.0
|
||||
preferenceVersion=1.2.0
|
||||
protobufVersion=2.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
|
||||
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
|
||||
zipStorePath=wrapper/dists
|
||||
zipStoreBase=GRADLE_USER_HOME
|
||||
|
@ -1,5 +1,4 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
package="network.loki.messenger.libsession_util">
|
||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
|
||||
</manifest>
|
@ -17,6 +17,12 @@ android {
|
||||
sourceCompatibility JavaVersion.VERSION_1_8
|
||||
targetCompatibility JavaVersion.VERSION_1_8
|
||||
}
|
||||
|
||||
kotlinOptions {
|
||||
jvmTarget = '1.8'
|
||||
}
|
||||
|
||||
namespace 'org.session.libsession'
|
||||
}
|
||||
|
||||
dependencies {
|
||||
|
@ -1,2 +1,2 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<manifest package="org.session.libsession" />
|
||||
<manifest />
|
@ -12,6 +12,11 @@ android {
|
||||
sourceCompatibility JavaVersion.VERSION_1_8
|
||||
targetCompatibility JavaVersion.VERSION_1_8
|
||||
}
|
||||
|
||||
kotlinOptions {
|
||||
jvmTarget = '1.8'
|
||||
}
|
||||
namespace 'org.session.libsignal'
|
||||
}
|
||||
|
||||
dependencies {
|
||||
|
@ -1,2 +1,2 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<manifest package="org.session.libsignal" />
|
||||
<manifest />
|
@ -5,4 +5,3 @@ include ':liblazysodium'
|
||||
include ':libsession'
|
||||
include ':libsignal'
|
||||
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