mirror of
https://github.com/oxen-io/session-android.git
synced 2025-04-01 23:22:14 +00:00
Merge branch 'dev' into strings-squashed
This commit is contained in:
commit
e812527358
app
build.gradle
gradle.propertiessrc
androidTest
main
AndroidManifest.xml
java/org/thoughtcrime/securesms
MediaDocumentsAdapter.javaMediaGalleryAdapter.javaMediaOverviewActivity.javaMediaPreviewActivity.java
calls
components
contacts
conversation
glide
home
media
AttachmentHeader.ktDocumentsPage.ktFixedTimeBuckets.ktMediaOverviewActivity.ktMediaOverviewScreen.ktMediaOverviewTopAppBar.ktMediaOverviewViewModel.ktMediaPage.kt
messagerequests
mms
preferences
reactions
ui
util
res
gradle/wrapper
libsession-util/src
libsession
libsignal
scripts
settings.gradlestickyheader
@ -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 {
|
||||
@ -261,6 +247,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"
|
||||
@ -285,6 +272,8 @@ 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'
|
||||
implementation 'org.greenrobot:eventbus:3.0.0'
|
||||
@ -304,7 +293,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" />
|
||||
|
@ -44,6 +44,10 @@ import org.thoughtcrime.securesms.conversation.v2.input_bar.InputBar
|
||||
import org.thoughtcrime.securesms.home.HomeActivity
|
||||
import com.bumptech.glide.Glide
|
||||
|
||||
/**
|
||||
* Currently not used as part of our CI/Deployment processes !!!!
|
||||
*/
|
||||
|
||||
@RunWith(AndroidJUnit4::class)
|
||||
@LargeTest
|
||||
class HomeActivityTests {
|
||||
@ -108,23 +112,22 @@ class HomeActivityTests {
|
||||
PermissionGranter.allowPermissionsIfNeeded(Manifest.permission.POST_NOTIFICATIONS)
|
||||
}
|
||||
|
||||
// ACL - COMMENTED OUT BECAUSE REFERENCES DELETED FRAGMENT `EnterPublicKeyFragment.kt` - which itself used R.id.<stuff_which_no_longer_exists>
|
||||
// private fun goToMyChat() {
|
||||
// onView(withId(R.id.newConversationButton)).perform(ViewActions.click())
|
||||
// onView(withId(R.id.createPrivateChatButton)).perform(ViewActions.click())
|
||||
// // new chat
|
||||
// onView(withId(R.id.publicKeyEditText)).perform(ViewActions.closeSoftKeyboard())
|
||||
// onView(withId(R.id.copyButton)).perform(ViewActions.click())
|
||||
// val context = InstrumentationRegistry.getInstrumentation().targetContext
|
||||
// lateinit var copied: String
|
||||
// InstrumentationRegistry.getInstrumentation().runOnMainSync {
|
||||
// val clipboardManager = context.getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager
|
||||
// copied = clipboardManager.primaryClip!!.getItemAt(0).text.toString()
|
||||
// }
|
||||
// onView(withId(R.id.publicKeyEditText)).perform(ViewActions.typeText(copied))
|
||||
// onView(withId(R.id.publicKeyEditText)).perform(ViewActions.closeSoftKeyboard())
|
||||
// onView(withId(R.id.createPrivateChatButton)).perform(ViewActions.click())
|
||||
// }
|
||||
/* private fun goToMyChat() {
|
||||
onView(withId(R.id.newConversationButton)).perform(ViewActions.click())
|
||||
onView(withId(R.id.createPrivateChatButton)).perform(ViewActions.click())
|
||||
// new chat
|
||||
onView(withId(R.id.publicKeyEditText)).perform(ViewActions.closeSoftKeyboard())
|
||||
onView(withId(R.id.copyButton)).perform(ViewActions.click())
|
||||
val context = InstrumentationRegistry.getInstrumentation().targetContext
|
||||
lateinit var copied: String
|
||||
InstrumentationRegistry.getInstrumentation().runOnMainSync {
|
||||
val clipboardManager = context.getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager
|
||||
copied = clipboardManager.primaryClip!!.getItemAt(0).text.toString()
|
||||
}
|
||||
onView(withId(R.id.publicKeyEditText)).perform(ViewActions.typeText(copied))
|
||||
onView(withId(R.id.publicKeyEditText)).perform(ViewActions.closeSoftKeyboard())
|
||||
onView(withId(R.id.createPrivateChatButton)).perform(ViewActions.click())
|
||||
}*/
|
||||
|
||||
@Test
|
||||
fun testLaunches_dismiss_seedView() {
|
||||
@ -147,44 +150,39 @@ class HomeActivityTests {
|
||||
onView(withId(R.id.seedReminderView)).check(matches(not(isDisplayed())))
|
||||
}
|
||||
|
||||
// ACL - COMMENTED OUT BECAUSE REFERENCES DELETED FRAGMENT `EnterPublicKeyFragment.kt` - which itself used R.id.<stuff_which_no_longer_exists>
|
||||
// @Test
|
||||
// fun testChat_withSelf() {
|
||||
// setupLoggedInState()
|
||||
// goToMyChat()
|
||||
// TextSecurePreferences.setLinkPreviewsEnabled(context, true)
|
||||
// sendMessage("howdy")
|
||||
// sendMessage("test")
|
||||
// // tests url rewriter doesn't crash
|
||||
// sendMessage("https://www.getsession.org?random_query_parameter=testtesttesttesttesttesttesttest&other_query_parameter=testtesttesttesttesttesttesttest")
|
||||
// sendMessage("https://www.ámazon.com")
|
||||
// }
|
||||
/* @Test
|
||||
fun testChat_withSelf() {
|
||||
setupLoggedInState()
|
||||
goToMyChat()
|
||||
TextSecurePreferences.setLinkPreviewsEnabled(context, true)
|
||||
sendMessage("howdy")
|
||||
sendMessage("test")
|
||||
// tests url rewriter doesn't crash
|
||||
sendMessage("https://www.getsession.org?random_query_parameter=testtesttesttesttesttesttesttest&other_query_parameter=testtesttesttesttesttesttesttest")
|
||||
sendMessage("https://www.ámazon.com")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun testChat_displaysCorrectUrl() {
|
||||
setupLoggedInState()
|
||||
goToMyChat()
|
||||
TextSecurePreferences.setLinkPreviewsEnabled(InstrumentationRegistry.getInstrumentation().targetContext, true)
|
||||
// given the link url text
|
||||
val url = "https://www.ámazon.com"
|
||||
sendMessage(url, LinkPreview(url, "amazon", Optional.absent()))
|
||||
|
||||
// when the URL span is clicked
|
||||
onView(withSubstring(url)).perform(ViewActions.click())
|
||||
|
||||
// then the URL dialog should be displayed with a known punycode url
|
||||
val amazonPuny = "https://www.xn--mazon-wqa.com/"
|
||||
|
||||
val dialogPromptText = InstrumentationRegistry.getInstrumentation().targetContext.getString(R.string.dialog_open_url_explanation, amazonPuny)
|
||||
|
||||
onView(isRoot()).perform(waitFor(1000)) // no other way for this to work apparently
|
||||
onView(withText(dialogPromptText)).check(matches(isDisplayed()))
|
||||
}*/
|
||||
|
||||
// ACL - COMMENTED OUT BECAUSE REFERENCES DELETED FRAGMENT `EnterPublicKeyFragment.kt` - which itself used R.id.<stuff_which_no_longer_exists>
|
||||
// @Test
|
||||
// fun testChat_displaysCorrectUrl() {
|
||||
// setupLoggedInState()
|
||||
// goToMyChat()
|
||||
// TextSecurePreferences.setLinkPreviewsEnabled(InstrumentationRegistry.getInstrumentation().targetContext, true)
|
||||
// // given the link url text
|
||||
// val url = "https://www.ámazon.com"
|
||||
// sendMessage(url, LinkPreview(url, "amazon", Optional.absent()))
|
||||
//
|
||||
// // when the URL span is clicked
|
||||
// onView(withSubstring(url)).perform(ViewActions.click())
|
||||
//
|
||||
// // then the URL dialog should be displayed with a known punycode url
|
||||
// val amazonPuny = "https://www.xn--mazon-wqa.com/"
|
||||
//
|
||||
// // Substitute the URL into our string
|
||||
// val c = InstrumentationRegistry.getInstrumentation().targetContext
|
||||
// val dialogPromptText = Phrase.from(c, R.string.urlOpenDescription)
|
||||
// .put(URL_KEY, amazonPuny)
|
||||
// .format().toString()
|
||||
//
|
||||
// onView(isRoot()).perform(waitFor(1000)) // no other way for this to work apparently
|
||||
// onView(withText(dialogPromptText)).check(matches(isDisplayed()))
|
||||
// }
|
||||
|
||||
/**
|
||||
* Perform action of waiting for a specific time.
|
||||
|
@ -305,6 +305,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,135 +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);
|
||||
|
||||
String relativeDate = DateUtils.INSTANCE.getRelativeDate(getContext(), locale, mediaRecord.getDate());
|
||||
viewHolder.date.setText(relativeDate);
|
||||
|
||||
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.attachmentsErrorOpen, 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.INSTANCE.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,523 +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 static org.session.libsession.utilities.StringSubstitutionConstants.APP_NAME_KEY;
|
||||
|
||||
import android.annotation.SuppressLint;
|
||||
import android.content.Context;
|
||||
import android.content.Intent;
|
||||
import android.content.res.Configuration;
|
||||
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.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.bumptech.glide.Glide;
|
||||
import com.codewaves.stickyheadergrid.StickyHeaderGridLayoutManager;
|
||||
import com.google.android.material.tabs.TabLayout;
|
||||
import com.squareup.phrase.Phrase;
|
||||
|
||||
import java.util.Collection;
|
||||
import java.util.LinkedList;
|
||||
import java.util.List;
|
||||
import java.util.Locale;
|
||||
import kotlin.Unit;
|
||||
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.Util;
|
||||
import org.session.libsession.utilities.ViewUtil;
|
||||
import org.session.libsession.utilities.recipients.Recipient;
|
||||
import org.session.libsession.utilities.task.ProgressDialogAsyncTask;
|
||||
import org.session.libsignal.utilities.Log;
|
||||
import org.thoughtcrime.securesms.database.CursorRecyclerViewAdapter;
|
||||
import org.thoughtcrime.securesms.database.MediaDatabase;
|
||||
import org.thoughtcrime.securesms.database.loaders.BucketedThreadMediaLoader.BucketedThreadMedia;
|
||||
import org.thoughtcrime.securesms.database.loaders.BucketedThreadMediaLoader;
|
||||
import org.thoughtcrime.securesms.database.loaders.ThreadMediaLoader;
|
||||
import org.thoughtcrime.securesms.permissions.Permissions;
|
||||
import org.thoughtcrime.securesms.util.AttachmentUtil;
|
||||
import org.thoughtcrime.securesms.util.SaveAttachmentTask;
|
||||
import org.thoughtcrime.securesms.util.StickyHeaderDecoration;
|
||||
|
||||
/**
|
||||
* 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() {
|
||||
this.viewPager = ViewUtil.findById(this, R.id.pager);
|
||||
this.toolbar = ViewUtil.findById(this, R.id.search_toolbar);
|
||||
this.tabLayout = ViewUtil.findById(this, R.id.tab_layout);
|
||||
|
||||
Address address = getIntent().getParcelableExtra(ADDRESS_EXTRA);
|
||||
if (address == null) {
|
||||
Log.w(TAG, "Got null address in initializeResources.");
|
||||
} else {
|
||||
this.recipient = Recipient.from(this, address, true);
|
||||
}
|
||||
}
|
||||
|
||||
private void initializeToolbar() {
|
||||
setSupportActionBar(this.toolbar);
|
||||
ActionBar actionBar = getSupportActionBar();
|
||||
if (actionBar == null) {
|
||||
Log.w(TAG, "Could not get support actionbar");
|
||||
return;
|
||||
}
|
||||
// Implied else that the actionbar is fine to work with...
|
||||
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.media);
|
||||
else if (position == 1) return getString(R.string.files);
|
||||
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(Phrase.from(context, R.string.permissionsStorageSaveDenied)
|
||||
.put(APP_NAME_KEY, getString(R.string.app_name))
|
||||
.format().toString())
|
||||
.onAnyDenied(() -> Toast.makeText(getContext(),
|
||||
Phrase.from(context, R.string.permissionsStorageSaveDenied)
|
||||
.put(APP_NAME_KEY, getString(R.string.app_name))
|
||||
.format().toString(),
|
||||
Toast.LENGTH_LONG).show())
|
||||
.onAllGranted(() -> {
|
||||
new ProgressDialogAsyncTask<Void, Void, List<SaveAttachmentTask.Attachment>>(
|
||||
context,
|
||||
R.string.attachmentsCollecting,
|
||||
R.string.waitOneMoment) {
|
||||
@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.deleting,
|
||||
R.string.deleting) {
|
||||
@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();
|
||||
}
|
||||
}
|
||||
}
|
@ -76,6 +76,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;
|
||||
@ -387,9 +388,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() {
|
||||
|
@ -29,9 +29,9 @@ import network.loki.messenger.R
|
||||
import network.loki.messenger.databinding.ActivityWebrtcBinding
|
||||
import org.apache.commons.lang3.time.DurationFormatUtils
|
||||
import org.session.libsession.messaging.contacts.Contact
|
||||
import org.session.libsession.utilities.Address
|
||||
import org.session.libsession.utilities.StringSubstitutionConstants.APP_NAME_KEY
|
||||
import org.session.libsession.utilities.TextSecurePreferences
|
||||
import org.session.libsession.utilities.truncateIdForDisplay
|
||||
import org.session.libsignal.utilities.Log
|
||||
import org.thoughtcrime.securesms.PassphraseRequiredActionBarActivity
|
||||
import org.thoughtcrime.securesms.dependencies.DatabaseComponent
|
||||
@ -197,13 +197,8 @@ class WebRtcCallActivity : PassphraseRequiredActionBarActivity() {
|
||||
clipFloatingInsets()
|
||||
|
||||
// set up the user avatar
|
||||
TextSecurePreferences.getLocalNumber(this)?.let{
|
||||
val username = TextSecurePreferences.getProfileName(this) ?: truncateIdForDisplay(it)
|
||||
binding.userAvatar.apply {
|
||||
publicKey = it
|
||||
displayName = username
|
||||
update()
|
||||
}
|
||||
TextSecurePreferences.getLocalNumber(this)?.let {
|
||||
binding.userAvatar.load(Address.fromSerialized(it))
|
||||
}
|
||||
|
||||
|
||||
@ -340,8 +335,6 @@ class WebRtcCallActivity : PassphraseRequiredActionBarActivity() {
|
||||
|
||||
launch {
|
||||
viewModel.recipient.collect { latestRecipient ->
|
||||
binding.contactAvatar.recycle()
|
||||
|
||||
if (latestRecipient.recipient != null) {
|
||||
val contactPublicKey = latestRecipient.recipient.address.serialize()
|
||||
val contactDisplayName = getUserDisplayName(contactPublicKey)
|
||||
@ -349,11 +342,7 @@ class WebRtcCallActivity : PassphraseRequiredActionBarActivity() {
|
||||
binding.remoteRecipientName.text = contactDisplayName
|
||||
|
||||
// sort out the contact's avatar
|
||||
binding.contactAvatar.apply {
|
||||
publicKey = contactPublicKey
|
||||
displayName = contactDisplayName
|
||||
update()
|
||||
}
|
||||
binding.contactAvatar.load(latestRecipient.recipient)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -3,164 +3,224 @@ package org.thoughtcrime.securesms.components
|
||||
import android.content.Context
|
||||
import android.util.AttributeSet
|
||||
import android.view.LayoutInflater
|
||||
import android.view.View
|
||||
import android.widget.ImageView
|
||||
import android.widget.RelativeLayout
|
||||
import android.widget.FrameLayout
|
||||
import androidx.core.view.isVisible
|
||||
import com.bumptech.glide.Glide
|
||||
import com.bumptech.glide.load.engine.DiskCacheStrategy
|
||||
import dagger.hilt.android.AndroidEntryPoint
|
||||
import kotlinx.coroutines.DelicateCoroutinesApi
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.GlobalScope
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
import network.loki.messenger.R
|
||||
import network.loki.messenger.databinding.ViewProfilePictureBinding
|
||||
import org.session.libsession.avatars.ContactColors
|
||||
import org.session.libsession.avatars.ContactPhoto
|
||||
import org.session.libsession.avatars.PlaceholderAvatarPhoto
|
||||
import org.session.libsession.avatars.ProfileContactPhoto
|
||||
import org.session.libsession.avatars.ResourceContactPhoto
|
||||
import org.session.libsession.messaging.contacts.Contact
|
||||
import org.session.libsession.utilities.Address
|
||||
import org.session.libsession.utilities.AppTextSecurePreferences
|
||||
import org.session.libsession.utilities.GroupUtil
|
||||
import org.session.libsession.utilities.TextSecurePreferences
|
||||
import org.session.libsession.utilities.recipients.Recipient
|
||||
import org.session.libsignal.utilities.Log
|
||||
import org.thoughtcrime.securesms.dependencies.DatabaseComponent
|
||||
import com.bumptech.glide.Glide
|
||||
import com.bumptech.glide.RequestManager
|
||||
import org.thoughtcrime.securesms.database.GroupDatabase
|
||||
import javax.inject.Inject
|
||||
|
||||
class ProfilePictureView @JvmOverloads constructor(
|
||||
context: Context, attrs: AttributeSet? = null
|
||||
) : RelativeLayout(context, attrs) {
|
||||
private val TAG = "ProfilePictureView"
|
||||
@AndroidEntryPoint
|
||||
class ProfilePictureView : FrameLayout {
|
||||
constructor(context: Context) : super(context)
|
||||
constructor(context: Context, attrs: AttributeSet) : super(context, attrs)
|
||||
constructor(context: Context, attrs: AttributeSet, defStyleAttr: Int) : super(
|
||||
context,
|
||||
attrs,
|
||||
defStyleAttr
|
||||
)
|
||||
|
||||
@Inject
|
||||
lateinit var groupDatabase: GroupDatabase
|
||||
|
||||
private val binding = ViewProfilePictureBinding.inflate(LayoutInflater.from(context), this)
|
||||
private val glide: RequestManager = Glide.with(this)
|
||||
private val prefs = AppTextSecurePreferences(context)
|
||||
private val userPublicKey = prefs.getLocalNumber()
|
||||
var publicKey: String? = null
|
||||
var displayName: String? = null
|
||||
var additionalPublicKey: String? = null
|
||||
var additionalDisplayName: String? = null
|
||||
private var lastLoadJob: Job? = null
|
||||
private var lastLoadAddress: Address? = null
|
||||
|
||||
private val profilePicturesCache = mutableMapOf<View, Recipient>()
|
||||
private val resourcePadding by lazy {
|
||||
context.resources.getDimensionPixelSize(R.dimen.normal_padding).toFloat()
|
||||
}
|
||||
private val unknownRecipientDrawable by lazy { ResourceContactPhoto(R.drawable.ic_profile_default)
|
||||
.asDrawable(context, ContactColors.UNKNOWN_COLOR.toConversationColor(context), false, resourcePadding) }
|
||||
private val unknownOpenGroupDrawable by lazy { ResourceContactPhoto(R.drawable.ic_notification)
|
||||
.asDrawable(context, ContactColors.UNKNOWN_COLOR.toConversationColor(context), false, resourcePadding) }
|
||||
|
||||
constructor(context: Context, sender: Recipient): this(context) {
|
||||
update(sender)
|
||||
private val unknownRecipientDrawable by lazy(LazyThreadSafetyMode.NONE) {
|
||||
ResourceContactPhoto(R.drawable.ic_profile_default)
|
||||
.asDrawable(context, ContactColors.UNKNOWN_COLOR.toConversationColor(context), false)
|
||||
}
|
||||
|
||||
fun update(recipient: Recipient) {
|
||||
recipient.run { update(address, isClosedGroupRecipient, isOpenGroupInboxRecipient) }
|
||||
private val unknownOpenGroupDrawable by lazy(LazyThreadSafetyMode.NONE) {
|
||||
ResourceContactPhoto(R.drawable.ic_notification)
|
||||
.asDrawable(context, ContactColors.UNKNOWN_COLOR.toConversationColor(context), false)
|
||||
}
|
||||
|
||||
fun update(
|
||||
address: Address,
|
||||
isClosedGroupRecipient: Boolean = false,
|
||||
isOpenGroupInboxRecipient: Boolean = false
|
||||
) {
|
||||
fun getUserDisplayName(publicKey: String): String = prefs.takeIf { userPublicKey == publicKey }?.getProfileName()
|
||||
?: DatabaseComponent.get(context).sessionContactDatabase().getContactWithAccountID(publicKey)?.displayName(Contact.ContactContext.REGULAR)
|
||||
?: publicKey
|
||||
|
||||
if (isClosedGroupRecipient) {
|
||||
val members = DatabaseComponent.get(context).groupDatabase()
|
||||
.getGroupMemberAddresses(address.toGroupString(), true)
|
||||
.sorted()
|
||||
.take(2)
|
||||
if (members.size <= 1) {
|
||||
publicKey = ""
|
||||
displayName = ""
|
||||
additionalPublicKey = ""
|
||||
additionalDisplayName = ""
|
||||
} else {
|
||||
val pk = members.getOrNull(0)?.serialize() ?: ""
|
||||
publicKey = pk
|
||||
displayName = getUserDisplayName(pk)
|
||||
val apk = members.getOrNull(1)?.serialize() ?: ""
|
||||
additionalPublicKey = apk
|
||||
additionalDisplayName = getUserDisplayName(apk)
|
||||
}
|
||||
} else if(isOpenGroupInboxRecipient) {
|
||||
val publicKey = GroupUtil.getDecodedOpenGroupInboxAccountId(address.serialize())
|
||||
this.publicKey = publicKey
|
||||
displayName = getUserDisplayName(publicKey)
|
||||
additionalPublicKey = null
|
||||
} else {
|
||||
val publicKey = address.serialize()
|
||||
this.publicKey = publicKey
|
||||
displayName = getUserDisplayName(publicKey)
|
||||
additionalPublicKey = null
|
||||
}
|
||||
update()
|
||||
private fun setShowAsDoubleMode(showAsDouble: Boolean) {
|
||||
binding.doubleModeImageViewContainer.isVisible = showAsDouble
|
||||
binding.singleModeImageView.isVisible = !showAsDouble
|
||||
}
|
||||
|
||||
fun update() {
|
||||
val publicKey = publicKey ?: return Log.w(TAG, "Could not find public key to update profile picture")
|
||||
val additionalPublicKey = additionalPublicKey
|
||||
// if we have a multi avatar setup
|
||||
if (additionalPublicKey != null) {
|
||||
setProfilePictureIfNeeded(binding.doubleModeImageView1, publicKey, displayName)
|
||||
setProfilePictureIfNeeded(binding.doubleModeImageView2, additionalPublicKey, additionalDisplayName)
|
||||
binding.doubleModeImageViewContainer.visibility = View.VISIBLE
|
||||
|
||||
// clear single image
|
||||
glide.clear(binding.singleModeImageView)
|
||||
binding.singleModeImageView.visibility = View.INVISIBLE
|
||||
} else { // single image mode
|
||||
setProfilePictureIfNeeded(binding.singleModeImageView, publicKey, displayName)
|
||||
binding.singleModeImageView.visibility = View.VISIBLE
|
||||
|
||||
// clear multi image
|
||||
glide.clear(binding.doubleModeImageView1)
|
||||
glide.clear(binding.doubleModeImageView2)
|
||||
binding.doubleModeImageViewContainer.visibility = View.INVISIBLE
|
||||
private fun cancelLastLoadJob() {
|
||||
lastLoadJob?.cancel()
|
||||
lastLoadJob = null
|
||||
}
|
||||
|
||||
@OptIn(DelicateCoroutinesApi::class)
|
||||
private fun loadAsDoubleImages(model: LoadModel) {
|
||||
cancelLastLoadJob()
|
||||
|
||||
// The use of GlobalScope is intentional here, as there is no better lifecycle scope that we can use
|
||||
// to launch a coroutine from a view. The potential memory leak is not a concern here, as
|
||||
// the coroutine is very short-lived. If you change the code here to be long live then you'll
|
||||
// need to find a better scope to launch the coroutine from.
|
||||
lastLoadJob = GlobalScope.launch(Dispatchers.Main) {
|
||||
data class GroupMemberInfo(
|
||||
val contactPhoto: ContactPhoto?,
|
||||
val placeholderAvatarPhoto: PlaceholderAvatarPhoto,
|
||||
)
|
||||
|
||||
// Load group avatar if available, otherwise load member avatars
|
||||
val groupAvatarOrMemberAvatars = withContext(Dispatchers.Default) {
|
||||
model.loadRecipient(context).contactPhoto
|
||||
?: groupDatabase.getGroupMembers(model.address.toGroupString(), true)
|
||||
.map {
|
||||
GroupMemberInfo(
|
||||
contactPhoto = it.contactPhoto,
|
||||
placeholderAvatarPhoto = PlaceholderAvatarPhoto(
|
||||
hashString = it.address.serialize(),
|
||||
displayName = it.displayName()
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private fun setProfilePictureIfNeeded(imageView: ImageView, publicKey: String, displayName: String?) {
|
||||
if (publicKey.isNotEmpty()) {
|
||||
val recipient = Recipient.from(context, Address.fromSerialized(publicKey), false)
|
||||
if (profilePicturesCache[imageView] == recipient) return
|
||||
profilePicturesCache[imageView] = recipient
|
||||
val signalProfilePicture = recipient.contactPhoto
|
||||
val avatar = (signalProfilePicture as? ProfileContactPhoto)?.avatarObject
|
||||
|
||||
glide.clear(imageView)
|
||||
|
||||
val placeholder = PlaceholderAvatarPhoto(publicKey, displayName ?: "${publicKey.take(4)}...${publicKey.takeLast(4)}")
|
||||
|
||||
if (signalProfilePicture != null && avatar != "0" && avatar != "") {
|
||||
glide.load(signalProfilePicture)
|
||||
.placeholder(unknownRecipientDrawable)
|
||||
.centerCrop()
|
||||
.error(glide.load(placeholder))
|
||||
when (groupAvatarOrMemberAvatars) {
|
||||
is ContactPhoto -> {
|
||||
setShowAsDoubleMode(false)
|
||||
Glide.with(this@ProfilePictureView)
|
||||
.load(groupAvatarOrMemberAvatars)
|
||||
.error(unknownRecipientDrawable)
|
||||
.circleCrop()
|
||||
.diskCacheStrategy(DiskCacheStrategy.NONE)
|
||||
.circleCrop()
|
||||
.into(imageView)
|
||||
} else if (recipient.isCommunityRecipient && recipient.groupAvatarId == null) {
|
||||
glide.clear(imageView)
|
||||
glide.load(unknownOpenGroupDrawable)
|
||||
.centerCrop()
|
||||
.circleCrop()
|
||||
.into(imageView)
|
||||
} else {
|
||||
glide.load(placeholder)
|
||||
.placeholder(unknownRecipientDrawable)
|
||||
.centerCrop()
|
||||
.circleCrop()
|
||||
.diskCacheStrategy(DiskCacheStrategy.NONE).circleCrop().into(imageView)
|
||||
.into(binding.singleModeImageView)
|
||||
}
|
||||
|
||||
is List<*> -> {
|
||||
val first = groupAvatarOrMemberAvatars.getOrNull(0) as? GroupMemberInfo
|
||||
val second = groupAvatarOrMemberAvatars.getOrNull(1) as? GroupMemberInfo
|
||||
setShowAsDoubleMode(true)
|
||||
Glide.with(binding.doubleModeImageView1)
|
||||
.load(first?.let { it.contactPhoto ?: it.placeholderAvatarPhoto })
|
||||
.error(first?.placeholderAvatarPhoto ?: unknownRecipientDrawable)
|
||||
.circleCrop()
|
||||
.diskCacheStrategy(DiskCacheStrategy.NONE)
|
||||
.into(binding.doubleModeImageView1)
|
||||
|
||||
Glide.with(binding.doubleModeImageView2)
|
||||
.load(second?.let { it.contactPhoto ?: it.placeholderAvatarPhoto })
|
||||
.error(second?.placeholderAvatarPhoto ?: unknownRecipientDrawable)
|
||||
.circleCrop()
|
||||
.diskCacheStrategy(DiskCacheStrategy.NONE)
|
||||
.into(binding.doubleModeImageView2)
|
||||
}
|
||||
|
||||
else -> {
|
||||
setShowAsDoubleMode(false)
|
||||
binding.singleModeImageView.setImageDrawable(unknownRecipientDrawable)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
glide.load(unknownRecipientDrawable)
|
||||
.centerCrop()
|
||||
.into(imageView)
|
||||
}
|
||||
}
|
||||
|
||||
fun recycle() {
|
||||
profilePicturesCache.clear()
|
||||
@OptIn(DelicateCoroutinesApi::class)
|
||||
private fun loadAsSingleImage(model: LoadModel) {
|
||||
cancelLastLoadJob()
|
||||
|
||||
setShowAsDoubleMode(false)
|
||||
|
||||
// Only clear the old image if the address has changed. This is important as we have a delay
|
||||
// in loading the image, if this view is reused for another address before the image is loaded,
|
||||
// the previous image could be displayed for a short period of time. We would want to avoid
|
||||
// displaying the wrong image, even for a short time.
|
||||
// However, if we are displaying the same user's image again, it's ok to show the old
|
||||
// image until the new one is loaded. This is a trade-off between performance and correctness.
|
||||
if (lastLoadAddress != model.address) {
|
||||
Glide.with(this).clear(this)
|
||||
}
|
||||
|
||||
// The use of GlobalScope is intentional here, as there is no better lifecycle scope that we can use
|
||||
// to launch a coroutine from a view. The potential memory leak is not a concern here, as
|
||||
// the coroutine is very short-lived. If you change the code here to be long live then you'll
|
||||
// need to find a better scope to launch the coroutine from.
|
||||
lastLoadJob = GlobalScope.launch(Dispatchers.Main) {
|
||||
val (contactPhoto, avatarPlaceholder) = withContext(Dispatchers.Default) {
|
||||
model.loadRecipient(context).let {
|
||||
it.contactPhoto to PlaceholderAvatarPhoto(it.address.serialize(), it.displayName())
|
||||
}
|
||||
}
|
||||
|
||||
val address = model.address
|
||||
|
||||
val errorModel: Any = when {
|
||||
address.isCommunity -> unknownOpenGroupDrawable
|
||||
address.isContact -> avatarPlaceholder
|
||||
else -> unknownRecipientDrawable
|
||||
}
|
||||
|
||||
Glide.with(this@ProfilePictureView)
|
||||
.load(contactPhoto)
|
||||
.error(errorModel)
|
||||
.circleCrop()
|
||||
.diskCacheStrategy(DiskCacheStrategy.NONE)
|
||||
.into(binding.singleModeImageView)
|
||||
}
|
||||
}
|
||||
|
||||
fun load(recipient: Recipient) {
|
||||
if (recipient.address.isClosedGroup) {
|
||||
loadAsDoubleImages(LoadModel.RecipientModel(recipient))
|
||||
} else {
|
||||
loadAsSingleImage(LoadModel.RecipientModel(recipient))
|
||||
}
|
||||
|
||||
lastLoadAddress = recipient.address
|
||||
}
|
||||
|
||||
fun load(address: Address) {
|
||||
if (address.isClosedGroup) {
|
||||
loadAsDoubleImages(LoadModel.AddressModel(address))
|
||||
} else {
|
||||
loadAsSingleImage(LoadModel.AddressModel(address))
|
||||
}
|
||||
|
||||
lastLoadAddress = address
|
||||
}
|
||||
|
||||
private fun Recipient.displayName(): String {
|
||||
return if (isLocalNumber) {
|
||||
TextSecurePreferences.getProfileName(context).orEmpty()
|
||||
} else {
|
||||
profileName ?: name ?: ""
|
||||
}
|
||||
}
|
||||
|
||||
private sealed interface LoadModel {
|
||||
val address: Address
|
||||
|
||||
/**
|
||||
* Load the recipient if it's not already loaded.
|
||||
*/
|
||||
fun loadRecipient(context: Context): Recipient
|
||||
|
||||
data class AddressModel(override val address: Address) : LoadModel {
|
||||
override fun loadRecipient(context: Context): Recipient {
|
||||
return Recipient.from(context, address, false)
|
||||
}
|
||||
}
|
||||
|
||||
data class RecipientModel(val recipient: Recipient) : LoadModel {
|
||||
override val address: Address
|
||||
get() = recipient.address
|
||||
|
||||
override fun loadRecipient(context: Context): Recipient = recipient
|
||||
}
|
||||
}
|
||||
// endregion
|
||||
}
|
@ -55,7 +55,7 @@ class UserView : LinearLayout {
|
||||
}
|
||||
|
||||
val address = user.address.serialize()
|
||||
binding.profilePictureView.update(user)
|
||||
binding.profilePictureView.load(user)
|
||||
binding.actionIndicatorImageView.setImageResource(R.drawable.ic_baseline_edit_24)
|
||||
binding.nameTextView.text = if (user.isGroupRecipient) user.name else getUserDisplayName(address)
|
||||
when (actionIndicator) {
|
||||
@ -86,7 +86,6 @@ class UserView : LinearLayout {
|
||||
}
|
||||
}
|
||||
|
||||
fun unbind() { binding.profilePictureView.recycle() }
|
||||
|
||||
fun unbind() { /* Nothing to do */ }
|
||||
// endregion
|
||||
}
|
||||
|
@ -85,8 +85,9 @@ class ConversationActionBarView @JvmOverloads constructor(
|
||||
}
|
||||
|
||||
fun update(recipient: Recipient, openGroup: OpenGroup? = null, config: ExpirationConfiguration? = null) {
|
||||
binding.profilePictureView.update(recipient)
|
||||
binding.profilePictureView.load(recipient)
|
||||
binding.conversationTitleView.text = recipient.takeUnless { it.isLocalNumber }?.toShortString() ?: context.getString(R.string.noteToSelf)
|
||||
|
||||
updateSubtitle(recipient, openGroup, config)
|
||||
|
||||
binding.conversationTitleContainer.modifyLayoutParams<MarginLayoutParams> {
|
||||
|
@ -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.conversationsStart), onClose = delegate::onDialogClosePressed)
|
||||
BasicAppBar(
|
||||
title = stringResource(R.string.conversationsStart),
|
||||
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,10 +8,12 @@ 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.platform.LocalContext
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
@ -19,7 +21,8 @@ import androidx.compose.ui.tooling.preview.Preview
|
||||
import com.squareup.phrase.Phrase
|
||||
import network.loki.messenger.R
|
||||
import org.session.libsession.utilities.StringSubstitutionConstants.APP_NAME_KEY
|
||||
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
|
||||
@ -29,6 +32,7 @@ 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,
|
||||
@ -41,10 +45,11 @@ internal fun InviteFriend(
|
||||
LocalColors.current.backgroundSecondary,
|
||||
shape = MaterialTheme.shapes.small
|
||||
)) {
|
||||
AppBar(
|
||||
stringResource(R.string.sessionInviteAFriend),
|
||||
BackAppBar(
|
||||
title = stringResource(R.string.sessionInviteAFriend),
|
||||
backgroundColor = Color.Transparent, // transparent to show the rounded shape of the container
|
||||
onBack = onBack,
|
||||
onClose = onClose
|
||||
actions = { AppBarCloseIcon(onClose = onClose) }
|
||||
)
|
||||
Column(
|
||||
modifier = Modifier.padding(horizontal = 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.accountIdEnter, 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]) {
|
||||
|
@ -31,6 +31,7 @@ import org.thoughtcrime.securesms.database.CursorRecyclerViewAdapter
|
||||
import org.thoughtcrime.securesms.database.model.MessageRecord
|
||||
import org.thoughtcrime.securesms.dependencies.DatabaseComponent
|
||||
import com.bumptech.glide.RequestManager
|
||||
import org.thoughtcrime.securesms.database.getLong
|
||||
import org.thoughtcrime.securesms.preferences.PrivacySettingsActivity
|
||||
import org.thoughtcrime.securesms.showSessionDialog
|
||||
|
||||
|
@ -1,87 +0,0 @@
|
||||
package org.thoughtcrime.securesms.conversation.v2.components
|
||||
|
||||
import android.content.Context
|
||||
import android.util.AttributeSet
|
||||
import android.view.LayoutInflater
|
||||
import android.view.View
|
||||
import android.view.ViewGroup
|
||||
import android.widget.BaseAdapter
|
||||
import android.widget.ListView
|
||||
import org.session.libsession.messaging.mentions.Mention
|
||||
import org.thoughtcrime.securesms.dependencies.DatabaseComponent
|
||||
import com.bumptech.glide.RequestManager
|
||||
import org.thoughtcrime.securesms.util.toPx
|
||||
|
||||
class MentionCandidateSelectionView(context: Context, attrs: AttributeSet?, defStyleAttr: Int) : ListView(context, attrs, defStyleAttr) {
|
||||
private var mentionCandidates = listOf<Mention>()
|
||||
set(newValue) { field = newValue; mentionCandidateSelectionViewAdapter.mentionCandidates = newValue }
|
||||
var glide: RequestManager? = null
|
||||
set(newValue) { field = newValue; mentionCandidateSelectionViewAdapter.glide = newValue }
|
||||
var openGroupServer: String? = null
|
||||
set(newValue) { field = newValue; mentionCandidateSelectionViewAdapter.openGroupServer = openGroupServer }
|
||||
var openGroupRoom: String? = null
|
||||
set(newValue) { field = newValue; mentionCandidateSelectionViewAdapter.openGroupRoom = openGroupRoom }
|
||||
var onMentionCandidateSelected: ((Mention) -> Unit)? = null
|
||||
|
||||
private val mentionCandidateSelectionViewAdapter by lazy { Adapter(context) }
|
||||
|
||||
private class Adapter(private val context: Context) : BaseAdapter() {
|
||||
var mentionCandidates = listOf<Mention>()
|
||||
set(newValue) { field = newValue; notifyDataSetChanged() }
|
||||
var glide: RequestManager? = null
|
||||
var openGroupServer: String? = null
|
||||
var openGroupRoom: String? = null
|
||||
|
||||
override fun getCount(): Int {
|
||||
return mentionCandidates.count()
|
||||
}
|
||||
|
||||
override fun getItemId(position: Int): Long {
|
||||
return position.toLong()
|
||||
}
|
||||
|
||||
override fun getItem(position: Int): Mention {
|
||||
return mentionCandidates[position]
|
||||
}
|
||||
|
||||
override fun getView(position: Int, cellToBeReused: View?, parent: ViewGroup): View {
|
||||
val cell = cellToBeReused as MentionCandidateView? ?: MentionCandidateView(context)
|
||||
val mentionCandidate = getItem(position)
|
||||
cell.glide = glide
|
||||
cell.mentionCandidate = mentionCandidate
|
||||
cell.openGroupServer = openGroupServer
|
||||
cell.openGroupRoom = openGroupRoom
|
||||
return cell
|
||||
}
|
||||
}
|
||||
|
||||
constructor(context: Context, attrs: AttributeSet?) : this(context, attrs, 0)
|
||||
constructor(context: Context) : this(context, null)
|
||||
|
||||
init {
|
||||
clipToOutline = true
|
||||
adapter = mentionCandidateSelectionViewAdapter
|
||||
mentionCandidateSelectionViewAdapter.mentionCandidates = mentionCandidates
|
||||
setOnItemClickListener { _, _, position, _ ->
|
||||
onMentionCandidateSelected?.invoke(mentionCandidates[position])
|
||||
}
|
||||
}
|
||||
|
||||
fun show(mentionCandidates: List<Mention>, threadID: Long) {
|
||||
val openGroup = DatabaseComponent.get(context).lokiThreadDatabase().getOpenGroupChat(threadID)
|
||||
if (openGroup != null) {
|
||||
openGroupServer = openGroup.server
|
||||
openGroupRoom = openGroup.room
|
||||
}
|
||||
this.mentionCandidates = mentionCandidates
|
||||
val layoutParams = this.layoutParams as ViewGroup.LayoutParams
|
||||
layoutParams.height = toPx(Math.min(mentionCandidates.count(), 4) * 44, resources)
|
||||
this.layoutParams = layoutParams
|
||||
}
|
||||
|
||||
fun hide() {
|
||||
val layoutParams = this.layoutParams as ViewGroup.LayoutParams
|
||||
layoutParams.height = 0
|
||||
this.layoutParams = layoutParams
|
||||
}
|
||||
}
|
@ -1,42 +0,0 @@
|
||||
package org.thoughtcrime.securesms.conversation.v2.components
|
||||
|
||||
import android.content.Context
|
||||
import android.util.AttributeSet
|
||||
import android.view.LayoutInflater
|
||||
import android.view.View
|
||||
import android.widget.LinearLayout
|
||||
import network.loki.messenger.databinding.ViewMentionCandidateBinding
|
||||
import org.session.libsession.messaging.mentions.Mention
|
||||
import org.thoughtcrime.securesms.groups.OpenGroupManager
|
||||
import com.bumptech.glide.RequestManager
|
||||
|
||||
class MentionCandidateView : LinearLayout {
|
||||
private lateinit var binding: ViewMentionCandidateBinding
|
||||
var mentionCandidate = Mention("", "")
|
||||
set(newValue) { field = newValue; update() }
|
||||
var glide: RequestManager? = null
|
||||
var openGroupServer: String? = null
|
||||
var openGroupRoom: String? = null
|
||||
|
||||
constructor(context: Context) : this(context, null)
|
||||
constructor(context: Context, attrs: AttributeSet?) : this(context, attrs, 0)
|
||||
constructor(context: Context, attrs: AttributeSet?, defStyleAttr: Int) : super(context, attrs, defStyleAttr) { initialize() }
|
||||
|
||||
private fun initialize() {
|
||||
binding = ViewMentionCandidateBinding.inflate(LayoutInflater.from(context), this, true)
|
||||
}
|
||||
|
||||
private fun update() = with(binding) {
|
||||
mentionCandidateNameTextView.text = mentionCandidate.displayName
|
||||
profilePictureView.publicKey = mentionCandidate.publicKey
|
||||
profilePictureView.displayName = mentionCandidate.displayName
|
||||
profilePictureView.additionalPublicKey = null
|
||||
profilePictureView.update()
|
||||
if (openGroupServer != null && openGroupRoom != null) {
|
||||
val isUserModerator = OpenGroupManager.isUserModerator(context, "$openGroupRoom.$openGroupServer", mentionCandidate.publicKey)
|
||||
moderatorIconImageView.visibility = if (isUserModerator) View.VISIBLE else View.GONE
|
||||
} else {
|
||||
moderatorIconImageView.visibility = View.GONE
|
||||
}
|
||||
}
|
||||
}
|
@ -2,13 +2,11 @@ package org.thoughtcrime.securesms.conversation.v2.input_bar.mentions
|
||||
|
||||
import android.view.View
|
||||
import network.loki.messenger.databinding.ViewMentionCandidateV2Binding
|
||||
import org.session.libsession.utilities.Address
|
||||
import org.thoughtcrime.securesms.conversation.v2.mention.MentionViewModel
|
||||
|
||||
fun ViewMentionCandidateV2Binding.update(candidate: MentionViewModel.Candidate) {
|
||||
mentionCandidateNameTextView.text = candidate.nameHighlighted
|
||||
profilePictureView.publicKey = candidate.member.publicKey
|
||||
profilePictureView.displayName = candidate.member.name
|
||||
profilePictureView.additionalPublicKey = null
|
||||
profilePictureView.update()
|
||||
profilePictureView.load(Address.fromSerialized(candidate.member.publicKey))
|
||||
moderatorIconImageView.visibility = if (candidate.member.isModerator) View.VISIBLE else View.GONE
|
||||
}
|
||||
|
@ -27,7 +27,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
|
||||
@ -151,10 +151,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) {
|
||||
|
@ -25,6 +25,8 @@ import androidx.core.content.ContextCompat
|
||||
import androidx.core.os.bundleOf
|
||||
import androidx.core.view.isVisible
|
||||
import androidx.core.view.marginBottom
|
||||
import com.bumptech.glide.Glide
|
||||
import com.bumptech.glide.RequestManager
|
||||
import dagger.hilt.android.AndroidEntryPoint
|
||||
import java.util.Date
|
||||
import java.util.Locale
|
||||
@ -58,8 +60,6 @@ import org.thoughtcrime.securesms.database.ThreadDatabase
|
||||
import org.thoughtcrime.securesms.database.model.MessageRecord
|
||||
import org.thoughtcrime.securesms.groups.OpenGroupManager
|
||||
import org.thoughtcrime.securesms.home.UserDetailsBottomSheet
|
||||
import com.bumptech.glide.Glide
|
||||
import com.bumptech.glide.RequestManager
|
||||
import org.thoughtcrime.securesms.util.DateUtils
|
||||
import org.thoughtcrime.securesms.util.disableClipping
|
||||
import org.thoughtcrime.securesms.util.toDp
|
||||
@ -178,8 +178,7 @@ class VisibleMessageView : FrameLayout {
|
||||
|
||||
if (isGroupThread && !message.isOutgoing) {
|
||||
if (isEndOfMessageCluster) {
|
||||
binding.profilePictureView.publicKey = senderAccountID
|
||||
binding.profilePictureView.update(message.individualRecipient)
|
||||
binding.profilePictureView.load(message.individualRecipient)
|
||||
binding.profilePictureView.setOnClickListener {
|
||||
if (thread.isCommunityRecipient) {
|
||||
val openGroup = lokiThreadDb.getOpenGroupChat(threadID)
|
||||
@ -464,7 +463,6 @@ class VisibleMessageView : FrameLayout {
|
||||
}
|
||||
|
||||
fun recycle() {
|
||||
binding.profilePictureView.recycle()
|
||||
binding.messageContentView.root.recycle()
|
||||
}
|
||||
|
||||
|
@ -13,14 +13,14 @@ import org.session.libsession.avatars.ContactPhoto;
|
||||
import java.io.IOException;
|
||||
import java.io.InputStream;
|
||||
|
||||
class ContactPhotoFetcher implements DataFetcher<InputStream> {
|
||||
public class ContactPhotoFetcher implements DataFetcher<InputStream> {
|
||||
|
||||
private final Context context;
|
||||
private final ContactPhoto contactPhoto;
|
||||
|
||||
private InputStream inputStream;
|
||||
|
||||
ContactPhotoFetcher(@NonNull Context context, @NonNull ContactPhoto contactPhoto) {
|
||||
public ContactPhotoFetcher(@NonNull Context context, @NonNull ContactPhoto contactPhoto) {
|
||||
this.context = context.getApplicationContext();
|
||||
this.contactPhoto = contactPhoto;
|
||||
}
|
||||
|
@ -9,12 +9,15 @@ import org.session.libsession.avatars.PlaceholderAvatarPhoto
|
||||
import org.session.libsignal.utilities.Log
|
||||
import org.thoughtcrime.securesms.util.AvatarPlaceholderGenerator
|
||||
|
||||
class PlaceholderAvatarFetcher(private val context: Context,
|
||||
private val photo: PlaceholderAvatarPhoto): DataFetcher<BitmapDrawable> {
|
||||
class PlaceholderAvatarFetcher(
|
||||
private val context: Context,
|
||||
private val hashString: String,
|
||||
private val displayName: String
|
||||
): DataFetcher<BitmapDrawable> {
|
||||
|
||||
override fun loadData(priority: Priority,callback: DataFetcher.DataCallback<in BitmapDrawable>) {
|
||||
try {
|
||||
val avatar = AvatarPlaceholderGenerator.generate(context, 128, photo.hashString, photo.displayName)
|
||||
val avatar = AvatarPlaceholderGenerator.generate(context, 128, hashString, displayName)
|
||||
callback.onDataReady(avatar)
|
||||
} catch (e: Exception) {
|
||||
Log.e("Loki", "Error in fetching avatar")
|
||||
|
@ -8,6 +8,9 @@ import com.bumptech.glide.load.model.ModelLoader.LoadData
|
||||
import com.bumptech.glide.load.model.ModelLoaderFactory
|
||||
import com.bumptech.glide.load.model.MultiModelLoaderFactory
|
||||
import org.session.libsession.avatars.PlaceholderAvatarPhoto
|
||||
import org.session.libsession.utilities.Address
|
||||
import org.session.libsession.utilities.TextSecurePreferences
|
||||
import org.session.libsession.utilities.recipients.Recipient
|
||||
|
||||
class PlaceholderAvatarLoader(private val appContext: Context): ModelLoader<PlaceholderAvatarPhoto, BitmapDrawable> {
|
||||
|
||||
@ -17,7 +20,15 @@ class PlaceholderAvatarLoader(private val appContext: Context): ModelLoader<Plac
|
||||
height: Int,
|
||||
options: Options
|
||||
): LoadData<BitmapDrawable> {
|
||||
return LoadData(model, PlaceholderAvatarFetcher(appContext, model))
|
||||
val displayName: String = when {
|
||||
!model.displayName.isNullOrBlank() -> model.displayName.orEmpty()
|
||||
model.hashString == TextSecurePreferences.getLocalNumber(appContext) -> TextSecurePreferences.getProfileName(appContext).orEmpty()
|
||||
else -> Recipient.from(appContext, Address.fromSerialized(model.hashString), false).let {
|
||||
it.profileName ?: it.name ?: ""
|
||||
}
|
||||
}
|
||||
|
||||
return LoadData(model, PlaceholderAvatarFetcher(appContext, model.hashString, displayName))
|
||||
}
|
||||
|
||||
override fun handles(model: PlaceholderAvatarPhoto): Boolean = true
|
||||
|
@ -4,7 +4,6 @@ import android.content.Context
|
||||
import android.content.res.Resources
|
||||
import android.graphics.Typeface
|
||||
import android.graphics.drawable.ColorDrawable
|
||||
import android.text.TextUtils
|
||||
import android.util.AttributeSet
|
||||
import android.util.TypedValue
|
||||
import android.view.View
|
||||
@ -128,11 +127,10 @@ class ConversationView : LinearLayout {
|
||||
thread.isRead -> binding.statusIndicatorImageView.setImageResource(R.drawable.ic_filled_circle_check)
|
||||
else -> binding.statusIndicatorImageView.setImageResource(R.drawable.ic_circle_check)
|
||||
}
|
||||
binding.profilePictureView.update(thread.recipient)
|
||||
binding.profilePictureView.load(thread.recipient)
|
||||
}
|
||||
|
||||
fun recycle() {
|
||||
binding.profilePictureView.recycle()
|
||||
}
|
||||
|
||||
private fun getTitle(recipient: Recipient): String? = when {
|
||||
|
@ -478,8 +478,7 @@ class HomeActivity : PassphraseRequiredActionBarActivity(),
|
||||
ApplicationContext.getInstance(this).messageNotifier.setHomeScreenVisible(true)
|
||||
if (textSecurePreferences.getLocalNumber() == null) { return; } // This can be the case after a secondary device is auto-cleared
|
||||
IdentityKeyUtil.checkUpdate(this)
|
||||
binding.profileButton.recycle() // clear cached image before update tje profilePictureView
|
||||
binding.profileButton.update()
|
||||
binding.profileButton.load(Address.fromSerialized(publicKey))
|
||||
if (textSecurePreferences.getHasViewedSeed()) {
|
||||
binding.seedReminderView.isVisible = false
|
||||
}
|
||||
@ -519,10 +518,7 @@ class HomeActivity : PassphraseRequiredActionBarActivity(),
|
||||
}
|
||||
|
||||
private fun updateProfileButton() {
|
||||
binding.profileButton.publicKey = publicKey
|
||||
binding.profileButton.displayName = textSecurePreferences.getProfileName()
|
||||
binding.profileButton.recycle()
|
||||
binding.profileButton.update()
|
||||
binding.profileButton.load(Address.fromSerialized(publicKey))
|
||||
}
|
||||
// endregion
|
||||
|
||||
|
@ -273,8 +273,8 @@ class PathActivity : PassphraseRequiredActionBarActivity() {
|
||||
job?.cancel()
|
||||
job = GlobalScope.launch {
|
||||
withContext(Dispatchers.Main) {
|
||||
while (isActive) {
|
||||
delay(dotAnimationStartDelay)
|
||||
while (isActive) {
|
||||
expand()
|
||||
delay(EXPAND_ANIM_DELAY_MILLS)
|
||||
collapse()
|
||||
|
@ -55,8 +55,7 @@ class UserDetailsBottomSheet: BottomSheetDialogFragment() {
|
||||
val recipient = Recipient.from(requireContext(), Address.fromSerialized(publicKey), false)
|
||||
val threadRecipient = threadDb.getRecipientForThreadId(threadID) ?: return dismiss()
|
||||
with(binding) {
|
||||
profilePictureView.publicKey = publicKey
|
||||
profilePictureView.update(recipient)
|
||||
profilePictureView.load(recipient)
|
||||
nameTextViewContainer.visibility = View.VISIBLE
|
||||
nameTextViewContainer.setOnClickListener {
|
||||
if (recipient.isOpenGroupInboxRecipient || recipient.isOpenGroupOutboxRecipient) return@setOnClickListener
|
||||
|
@ -11,7 +11,6 @@ import network.loki.messenger.databinding.ViewGlobalSearchHeaderBinding
|
||||
import network.loki.messenger.databinding.ViewGlobalSearchResultBinding
|
||||
import network.loki.messenger.databinding.ViewGlobalSearchSubheaderBinding
|
||||
import org.session.libsession.utilities.GroupRecord
|
||||
import org.session.libsession.utilities.recipients.Recipient
|
||||
import org.thoughtcrime.securesms.search.model.MessageResult
|
||||
import org.thoughtcrime.securesms.ui.GetString
|
||||
import java.security.InvalidParameterException
|
||||
@ -99,12 +98,6 @@ class GlobalSearchAdapter(private val modelCallback: (Model)->Unit): RecyclerVie
|
||||
}
|
||||
}
|
||||
|
||||
override fun onViewRecycled(holder: RecyclerView.ViewHolder) {
|
||||
if (holder is ContentView) {
|
||||
holder.binding.searchResultProfilePicture.recycle()
|
||||
}
|
||||
}
|
||||
|
||||
class ContentView(view: View, private val modelCallback: (Model) -> Unit) : RecyclerView.ViewHolder(view) {
|
||||
|
||||
val binding = ViewGlobalSearchResultBinding.bind(view)
|
||||
@ -114,7 +107,6 @@ class GlobalSearchAdapter(private val modelCallback: (Model)->Unit): RecyclerVie
|
||||
}
|
||||
|
||||
fun bind(query: String, model: Model) {
|
||||
binding.searchResultProfilePicture.recycle()
|
||||
when (model) {
|
||||
is Model.GroupConversation -> bindModel(query, model)
|
||||
is Model.Contact -> bindModel(query, model)
|
||||
|
@ -92,7 +92,7 @@ fun ContentView.bindModel(query: String?, model: GroupConversation) {
|
||||
binding.searchResultSubtitle.isVisible = model.groupRecord.isClosedGroup
|
||||
binding.searchResultTimestamp.isVisible = false
|
||||
val threadRecipient = Recipient.from(binding.root.context, Address.fromSerialized(model.groupRecord.encodedId), false)
|
||||
binding.searchResultProfilePicture.update(threadRecipient)
|
||||
binding.searchResultProfilePicture.load(threadRecipient)
|
||||
val nameString = model.groupRecord.title
|
||||
binding.searchResultTitle.text = getHighlight(query, nameString)
|
||||
|
||||
@ -110,7 +110,7 @@ fun ContentView.bindModel(query: String?, model: ContactModel) = binding.run {
|
||||
searchResultTimestamp.isVisible = false
|
||||
searchResultSubtitle.text = null
|
||||
val recipient = Recipient.from(root.context, Address.fromSerialized(model.contact.accountID), false)
|
||||
searchResultProfilePicture.update(recipient)
|
||||
searchResultProfilePicture.load(recipient)
|
||||
val nameString = if (model.isSelf) root.context.getString(R.string.noteToSelf)
|
||||
else model.contact.getSearchName()
|
||||
searchResultTitle.text = getHighlight(query, nameString)
|
||||
@ -120,7 +120,7 @@ fun ContentView.bindModel(model: SavedMessages) {
|
||||
binding.searchResultSubtitle.isVisible = false
|
||||
binding.searchResultTimestamp.isVisible = false
|
||||
binding.searchResultTitle.setText(R.string.noteToSelf)
|
||||
binding.searchResultProfilePicture.update(Address.fromSerialized(model.currentUserPublicKey))
|
||||
binding.searchResultProfilePicture.load(Address.fromSerialized(model.currentUserPublicKey))
|
||||
binding.searchResultProfilePicture.isVisible = true
|
||||
}
|
||||
|
||||
@ -135,7 +135,7 @@ fun ContentView.bindModel(query: String?, model: Message) = binding.apply {
|
||||
// }
|
||||
|
||||
searchResultTimestamp.text = DateUtils.getDisplayFormattedTimeSpanString(root.context, Locale.getDefault(), model.messageResult.sentTimestampMs)
|
||||
searchResultProfilePicture.update(model.messageResult.conversationRecipient)
|
||||
searchResultProfilePicture.load(model.messageResult.conversationRecipient)
|
||||
val textSpannable = SpannableStringBuilder()
|
||||
if (model.messageResult.conversationRecipient != model.messageResult.messageRecipient) {
|
||||
// group chat, bind
|
||||
|
@ -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.attachmentsFilesEmpty),
|
||||
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 // Should be replaced with call to getLocalisedRelativeDayString
|
||||
time >= startOfYesterday -> R.string.BucketedThreadMedia_Yesterday // Should be replaced with call to getLocalisedRelativeDayString
|
||||
time >= startOfThisWeek -> R.string.attachmentsThisWeek
|
||||
time >= startOfThisMonth -> R.string.attachmentsThisMonth
|
||||
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,296 @@
|
||||
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.cameraGrantAccessDenied,
|
||||
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.attachmentsErrorOpen,
|
||||
Toast.LENGTH_LONG
|
||||
).show()
|
||||
}
|
||||
}
|
||||
|
||||
is MediaOverviewEvent.ShowSaveAttachmentError -> {
|
||||
Toast.makeText(context, R.string.attachmentsSaveError, Toast.LENGTH_LONG).show()
|
||||
}
|
||||
|
||||
is MediaOverviewEvent.ShowSaveAttachmentSuccess -> {
|
||||
Toast.makeText(context, R.string.saved, 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.permissionsRequired),
|
||||
text = context.resources.getString(R.string.attachmentsWarning),
|
||||
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.media
|
||||
MediaOverviewTab.Documents -> R.string.document
|
||||
}
|
||||
|
@ -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.selectAll),
|
||||
tint = LocalColors.current.text,
|
||||
)
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
@ -0,0 +1,427 @@
|
||||
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.getString(R.string.saving)
|
||||
|
||||
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.deleting)
|
||||
|
||||
// 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)
|
||||
}
|
||||
}
|
||||
|
207
app/src/main/java/org/thoughtcrime/securesms/media/MediaPage.kt
Normal file
207
app/src/main/java/org/thoughtcrime/securesms/media/MediaPage.kt
Normal file
@ -0,0 +1,207 @@
|
||||
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.layout.size
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
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.graphics.ColorFilter
|
||||
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.colorResource
|
||||
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.LocalDimensions
|
||||
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.attachmentsMediaEmpty),
|
||||
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 -> {
|
||||
// The code below is translated from thumbnail_view.xml:
|
||||
// Trying to show a green play button on a white background.
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.size(48.dp)
|
||||
.background(Color.White, shape = CircleShape),
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
Image(
|
||||
modifier = Modifier.padding(start = LocalDimensions.current.xxxsSpacing),
|
||||
painter = painterResource(R.drawable.triangle_right),
|
||||
contentDescription = null,
|
||||
colorFilter = ColorFilter.tint(LocalColors.current.primary)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
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))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
@ -49,12 +49,11 @@ class MessageRequestView : LinearLayout {
|
||||
binding.snippetTextView.text = snippet
|
||||
|
||||
post {
|
||||
binding.profilePictureView.update(thread.recipient)
|
||||
binding.profilePictureView.load(thread.recipient)
|
||||
}
|
||||
}
|
||||
|
||||
fun recycle() {
|
||||
binding.profilePictureView.recycle()
|
||||
}
|
||||
|
||||
private fun getUserDisplayName(recipient: Recipient): String? {
|
||||
|
@ -56,6 +56,7 @@ public class SignalGlideModule extends AppGlideModule {
|
||||
// builder.setDiskCache(new NoopDiskCacheFactory());
|
||||
}
|
||||
|
||||
/** @noinspection unchecked*/
|
||||
@Override
|
||||
public void registerComponents(@NonNull Context context, @NonNull Glide glide, @NonNull Registry registry) {
|
||||
AttachmentSecret attachmentSecret = AttachmentSecretProvider.getInstance(context).getOrCreateAttachmentSecret();
|
||||
@ -74,6 +75,7 @@ public class SignalGlideModule extends AppGlideModule {
|
||||
registry.append(AttachmentModel.class, InputStream.class, new AttachmentStreamUriLoader.Factory());
|
||||
registry.append(ChunkedImageUrl.class, InputStream.class, new ChunkedImageUrlLoader.Factory());
|
||||
registry.append(PlaceholderAvatarPhoto.class, BitmapDrawable.class, new PlaceholderAvatarLoader.Factory(context));
|
||||
|
||||
registry.replace(GlideUrl.class, InputStream.class, new OkHttpUrlLoader.Factory());
|
||||
}
|
||||
|
||||
|
@ -36,11 +36,6 @@ class BlockedContactsAdapter(val viewModel: BlockedContactsViewModel) : ListAdap
|
||||
else holder.select(getItem(position).isSelected)
|
||||
}
|
||||
|
||||
override fun onViewRecycled(holder: ViewHolder) {
|
||||
super.onViewRecycled(holder)
|
||||
holder.binding.profilePictureView.recycle()
|
||||
}
|
||||
|
||||
class ViewHolder(itemView: View): RecyclerView.ViewHolder(itemView) {
|
||||
|
||||
val glide = Glide.with(itemView)
|
||||
@ -48,9 +43,7 @@ class BlockedContactsAdapter(val viewModel: BlockedContactsViewModel) : ListAdap
|
||||
|
||||
fun bind(selectable: SelectableRecipient, toggle: (SelectableRecipient) -> Unit) {
|
||||
binding.recipientName.text = selectable.item.name
|
||||
with (binding.profilePictureView) {
|
||||
update(selectable.item)
|
||||
}
|
||||
binding.profilePictureView.load(selectable.item)
|
||||
binding.root.setOnClickListener { toggle(selectable) }
|
||||
binding.selectButton.isSelected = selectable.isSelected
|
||||
}
|
||||
|
@ -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)
|
||||
}
|
||||
|
@ -170,7 +170,7 @@ class SettingsActivity : PassphraseRequiredActionBarActivity() {
|
||||
super.onStart()
|
||||
|
||||
binding.run {
|
||||
setupProfilePictureView(profilePictureView)
|
||||
loadProfilePicture(profilePictureView)
|
||||
profilePictureView.setOnClickListener { showEditProfilePictureUI() }
|
||||
ctnGroupNameSection.setOnClickListener { startActionMode(DisplayNameEditActionModeCallback()) }
|
||||
btnGroupNameDisplay.text = getDisplayName()
|
||||
@ -190,12 +190,9 @@ class SettingsActivity : PassphraseRequiredActionBarActivity() {
|
||||
private fun getDisplayName(): String =
|
||||
TextSecurePreferences.getProfileName(this) ?: truncateIdForDisplay(hexEncodedPublicKey)
|
||||
|
||||
private fun setupProfilePictureView(view: ProfilePictureView) {
|
||||
view.apply {
|
||||
publicKey = hexEncodedPublicKey
|
||||
displayName = getDisplayName()
|
||||
update()
|
||||
}
|
||||
private fun loadProfilePicture(view: ProfilePictureView) {
|
||||
// Always reload the profile picture as it can change on this page.
|
||||
view.load(Address.fromSerialized(hexEncodedPublicKey))
|
||||
}
|
||||
|
||||
override fun onSaveInstanceState(outState: Bundle) {
|
||||
@ -336,9 +333,7 @@ class SettingsActivity : PassphraseRequiredActionBarActivity() {
|
||||
|
||||
ConfigurationMessageUtilities.forceSyncConfigurationNowIfNeeded(this@SettingsActivity)
|
||||
|
||||
// Update our visuals
|
||||
binding.profilePictureView.recycle()
|
||||
binding.profilePictureView.update()
|
||||
loadProfilePicture(binding.profilePictureView)
|
||||
}
|
||||
|
||||
// If the sync failed then inform the user
|
||||
@ -417,7 +412,7 @@ class SettingsActivity : PassphraseRequiredActionBarActivity() {
|
||||
cancelButton()
|
||||
}.apply {
|
||||
val profilePic = findViewById<ProfilePictureView>(R.id.profile_picture_view)
|
||||
?.also(::setupProfilePictureView)
|
||||
?.also(::loadProfilePicture)
|
||||
|
||||
val pictureIcon = findViewById<View>(R.id.ic_pictures)
|
||||
|
||||
|
@ -152,7 +152,7 @@ final class ReactionRecipientsAdapter extends RecyclerView.Adapter<ReactionRecip
|
||||
callback.onRemoveReaction(reaction.getBaseEmoji(), messageId, reaction.getTimestamp());
|
||||
});
|
||||
|
||||
this.avatar.update(reaction.getSender());
|
||||
this.avatar.load(reaction.getSender());
|
||||
|
||||
if (reaction.getSender().isLocalNumber()) {
|
||||
this.recipient.setText(R.string.you);
|
||||
@ -170,7 +170,6 @@ final class ReactionRecipientsAdapter extends RecyclerView.Adapter<ReactionRecip
|
||||
}
|
||||
|
||||
void unbind() {
|
||||
avatar.recycle();
|
||||
}
|
||||
|
||||
}
|
||||
|
@ -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),
|
||||
|
@ -11,7 +11,6 @@ import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.BoxScope
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.ColumnScope
|
||||
import androidx.compose.foundation.layout.RowScope
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
@ -361,7 +360,7 @@ fun RowScope.Avatar(recipient: Recipient) {
|
||||
) {
|
||||
AndroidView(
|
||||
factory = {
|
||||
ProfilePictureView(it).apply { update(recipient) }
|
||||
ProfilePictureView(it).apply { load(recipient) }
|
||||
},
|
||||
modifier = Modifier
|
||||
.width(46.dp)
|
||||
|
@ -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)) {
|
||||
fun AppBarCloseIcon(onClose: () -> Unit) {
|
||||
IconButton(onClick = onClose) {
|
||||
Icon(painter = painterResource(id = R.drawable.ic_x), contentDescription = "close")
|
||||
}
|
||||
}
|
||||
Icon(
|
||||
painter = painterResource(id = R.drawable.ic_x),
|
||||
contentDescription = stringResource(id = R.string.close)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun appBarColors(backgroundColor: Color) = TopAppBarDefaults.centerAlignedTopAppBarColors()
|
||||
.copy(
|
||||
containerColor = backgroundColor,
|
||||
scrolledContainerColor = backgroundColor,
|
||||
navigationIconContentColor = LocalColors.current.text,
|
||||
titleContentColor = LocalColors.current.text,
|
||||
actionIconContentColor = LocalColors.current.text
|
||||
)
|
||||
|
@ -65,15 +65,139 @@ class SaveAttachmentTask @JvmOverloads constructor(context: Context, count: Int
|
||||
button(R.string.cancel)
|
||||
}
|
||||
}
|
||||
|
||||
fun saveAttachment(context: Context, attachment: Attachment): String? {
|
||||
val contentType = checkNotNull(MediaUtil.getCorrectedMimeType(attachment.contentType))
|
||||
var fileName = attachment.fileName
|
||||
if (fileName == null) fileName = generateOutputFileName(contentType, attachment.date)
|
||||
fileName = sanitizeOutputFileName(fileName)
|
||||
val outputUri: Uri = getMediaStoreContentUriForType(contentType)
|
||||
val mediaUri = createOutputUri(context, outputUri, contentType, fileName)
|
||||
val updateValues = ContentValues()
|
||||
PartAuthority.getAttachmentStream(context, attachment.uri).use { inputStream ->
|
||||
if (inputStream == null) {
|
||||
return null
|
||||
}
|
||||
if (outputUri.scheme == ContentResolver.SCHEME_FILE) {
|
||||
FileOutputStream(mediaUri!!.path).use { outputStream ->
|
||||
StreamUtil.copy(inputStream, outputStream)
|
||||
MediaScannerConnection.scanFile(context, arrayOf(mediaUri.path), arrayOf(contentType), null)
|
||||
}
|
||||
} else {
|
||||
context.contentResolver.openOutputStream(mediaUri!!, "w").use { outputStream ->
|
||||
val total: Long = StreamUtil.copy(inputStream, outputStream)
|
||||
if (total > 0) {
|
||||
updateValues.put(MediaStore.MediaColumns.SIZE, total)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if (Build.VERSION.SDK_INT > 28) {
|
||||
updateValues.put(MediaStore.MediaColumns.IS_PENDING, 0)
|
||||
}
|
||||
if (updateValues.size() > 0) {
|
||||
context.contentResolver.update(mediaUri!!, updateValues, null, null)
|
||||
}
|
||||
return outputUri.lastPathSegment
|
||||
}
|
||||
|
||||
private val contextReference: WeakReference<Context>
|
||||
private fun generateOutputFileName(contentType: String, timestamp: Long): String {
|
||||
val mimeTypeMap = MimeTypeMap.getSingleton()
|
||||
val extension = mimeTypeMap.getExtensionFromMimeType(contentType) ?: "attach"
|
||||
val dateFormatter = SimpleDateFormat("yyyy-MM-dd-HHmmss")
|
||||
val base = "session-${dateFormatter.format(timestamp)}"
|
||||
|
||||
return "${base}.${extension}";
|
||||
}
|
||||
|
||||
private fun sanitizeOutputFileName(fileName: String): String {
|
||||
return File(fileName).name
|
||||
}
|
||||
|
||||
private fun getMediaStoreContentUriForType(contentType: String): Uri {
|
||||
return when {
|
||||
contentType.startsWith("video/") ->
|
||||
ExternalStorageUtil.getVideoUri()
|
||||
contentType.startsWith("audio/") ->
|
||||
ExternalStorageUtil.getAudioUri()
|
||||
contentType.startsWith("image/") ->
|
||||
ExternalStorageUtil.getImageUri()
|
||||
else ->
|
||||
ExternalStorageUtil.getDownloadUri()
|
||||
}
|
||||
}
|
||||
|
||||
private fun createOutputUri(context: Context, outputUri: Uri, contentType: String, fileName: String): Uri? {
|
||||
val fileParts: Array<String> = getFileNameParts(fileName)
|
||||
val base = fileParts[0]
|
||||
val extension = fileParts[1]
|
||||
val mimeType = MimeTypeMap.getSingleton().getMimeTypeFromExtension(extension)
|
||||
val contentValues = ContentValues()
|
||||
contentValues.put(MediaStore.MediaColumns.DISPLAY_NAME, fileName)
|
||||
contentValues.put(MediaStore.MediaColumns.MIME_TYPE, mimeType)
|
||||
contentValues.put(MediaStore.MediaColumns.DATE_ADDED, TimeUnit.MILLISECONDS.toSeconds(System.currentTimeMillis()))
|
||||
contentValues.put(MediaStore.MediaColumns.DATE_MODIFIED, TimeUnit.MILLISECONDS.toSeconds(System.currentTimeMillis()))
|
||||
if (Build.VERSION.SDK_INT > 28) {
|
||||
contentValues.put(MediaStore.MediaColumns.IS_PENDING, 1)
|
||||
} else if (outputUri.scheme == ContentResolver.SCHEME_FILE) {
|
||||
val outputDirectory = File(outputUri.path)
|
||||
var outputFile = File(outputDirectory, "$base.$extension")
|
||||
var i = 0
|
||||
while (outputFile.exists()) {
|
||||
outputFile = File(outputDirectory, base + "-" + ++i + "." + extension)
|
||||
}
|
||||
if (outputFile.isHidden) {
|
||||
throw IOException("Specified name would not be visible")
|
||||
}
|
||||
return Uri.fromFile(outputFile)
|
||||
} else {
|
||||
var outputFileName = fileName
|
||||
var dataPath = String.format("%s/%s", getExternalPathToFileForType(context, contentType), outputFileName)
|
||||
var i = 0
|
||||
while (pathTaken(context, outputUri, dataPath)) {
|
||||
Log.d(TAG, "The content exists. Rename and check again.")
|
||||
outputFileName = base + "-" + ++i + "." + extension
|
||||
dataPath = String.format("%s/%s", getExternalPathToFileForType(context, contentType), outputFileName)
|
||||
}
|
||||
contentValues.put(MediaStore.MediaColumns.DATA, dataPath)
|
||||
}
|
||||
return context.contentResolver.insert(outputUri, contentValues)
|
||||
}
|
||||
|
||||
private fun getExternalPathToFileForType(context: Context, contentType: String): String {
|
||||
val storage: File = when {
|
||||
contentType.startsWith("video/") ->
|
||||
context.getExternalFilesDir(Environment.DIRECTORY_MOVIES)!!
|
||||
contentType.startsWith("audio/") ->
|
||||
context.getExternalFilesDir(Environment.DIRECTORY_MUSIC)!!
|
||||
contentType.startsWith("image/") ->
|
||||
context.getExternalFilesDir(Environment.DIRECTORY_PICTURES)!!
|
||||
else ->
|
||||
context.getExternalFilesDir(Environment.DIRECTORY_DOCUMENTS)!!
|
||||
}
|
||||
return storage.absolutePath
|
||||
}
|
||||
|
||||
private fun getFileNameParts(fileName: String): Array<String> {
|
||||
val tokens = fileName.split("\\.(?=[^\\.]+$)".toRegex()).toTypedArray()
|
||||
return arrayOf(tokens[0], if (tokens.size > 1) tokens[1] else "")
|
||||
}
|
||||
|
||||
private fun pathTaken(context: Context, outputUri: Uri, dataPath: String): Boolean {
|
||||
context.contentResolver.query(outputUri, arrayOf(MediaStore.MediaColumns.DATA),
|
||||
MediaStore.MediaColumns.DATA + " = ?", arrayOf(dataPath),
|
||||
null).use { cursor ->
|
||||
if (cursor == null) {
|
||||
throw IOException("Something is wrong with the filename to save")
|
||||
}
|
||||
return cursor.moveToFirst()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private val contextReference = WeakReference(context)
|
||||
private val attachmentCount: Int = count
|
||||
|
||||
init {
|
||||
this.contextReference = WeakReference(context)
|
||||
}
|
||||
|
||||
@Deprecated("Deprecated in Java")
|
||||
override fun doInBackground(vararg attachments: Attachment?): Pair<Int, String?> {
|
||||
if (attachments.isEmpty()) {
|
||||
@ -105,137 +229,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)
|
||||
@ -257,4 +250,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/search_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/attachmentsFilesEmpty"
|
||||
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/attachmentsFilesEmpty" />
|
||||
|
||||
</RelativeLayout>
|
@ -1,27 +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" />
|
||||
|
||||
<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>
|
@ -1,42 +0,0 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<org.thoughtcrime.securesms.conversation.v2.components.MentionCandidateView
|
||||
xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="44dp"
|
||||
android:orientation="horizontal"
|
||||
android:paddingStart="@dimen/medium_spacing"
|
||||
android:paddingEnd="@dimen/medium_spacing"
|
||||
android:gravity="center_vertical"
|
||||
android:background="@drawable/mention_candidate_view_background">
|
||||
|
||||
<RelativeLayout
|
||||
android:layout_width="26dp"
|
||||
android:layout_height="32dp">
|
||||
|
||||
<org.thoughtcrime.securesms.components.ProfilePictureView
|
||||
android:id="@+id/profilePictureView"
|
||||
android:layout_width="@dimen/very_small_profile_picture_size"
|
||||
android:layout_height="@dimen/very_small_profile_picture_size"
|
||||
android:layout_marginTop="3dp" />
|
||||
|
||||
<ImageView
|
||||
android:id="@+id/moderatorIconImageView"
|
||||
android:layout_width="16dp"
|
||||
android:layout_height="16dp"
|
||||
android:src="@drawable/ic_crown"
|
||||
android:layout_alignParentEnd="true"
|
||||
android:layout_alignParentBottom="true" />
|
||||
|
||||
</RelativeLayout>
|
||||
|
||||
<TextView
|
||||
android:id="@+id/mentionCandidateNameTextView"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginStart="@dimen/medium_spacing"
|
||||
android:textSize="@dimen/small_font_size"
|
||||
android:textColor="?android:textColorPrimary"
|
||||
android:maxLines="1"
|
||||
android:ellipsize="end" />
|
||||
|
||||
</org.thoughtcrime.securesms.conversation.v2.components.MentionCandidateView>
|
@ -5,4 +5,20 @@
|
||||
'common' in the near future), and all content description / AccessibilityID_ strings are in
|
||||
their own 'content-descriptions' module.
|
||||
-->
|
||||
|
||||
<!-- TODO: These need to be removed and the text generated by DateUtils.getLocalisedRelativeDayString - but
|
||||
it's going to be a nuisance because that returns a string not a resource ID so just leaving them for now -AL -->
|
||||
<string name="BucketedThreadMedia_Today">Today</string>
|
||||
<string name="BucketedThreadMedia_Yesterday">Yesterday</string>
|
||||
|
||||
<!-- TODO: We'll also need plurals for these strings -->
|
||||
<plurals name="ConversationFragment_delete_selected_messages">
|
||||
<item quantity="one">Delete selected message?</item>
|
||||
<item quantity="other">Delete selected messages?</item>
|
||||
</plurals>
|
||||
<plurals name="ConversationFragment_this_will_permanently_delete_all_n_selected_messages">
|
||||
<item quantity="one">This will permanently delete the selected message.</item>
|
||||
<item quantity="other">This will permanently delete all %1$d selected messages.</item>
|
||||
</plurals>
|
||||
|
||||
</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
|
||||
|
@ -551,7 +551,7 @@ class InstrumentedTests {
|
||||
is Conversation.OneToOne -> seen.add("1-to-1: ${convo.accountId}")
|
||||
is Conversation.Community -> seen.add("og: ${convo.baseCommunityInfo.baseUrl}/r/${convo.baseCommunityInfo.room}")
|
||||
is Conversation.LegacyGroup -> seen.add("cl: ${convo.groupId}")
|
||||
null -> TODO()
|
||||
null -> { /* ignore null cases */ }
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -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>
|
@ -33,6 +33,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 />
|
@ -3,10 +3,12 @@ package org.session.libsession.avatars
|
||||
import com.bumptech.glide.load.Key
|
||||
import java.security.MessageDigest
|
||||
|
||||
class PlaceholderAvatarPhoto(val hashString: String,
|
||||
val displayName: String): Key {
|
||||
data class PlaceholderAvatarPhoto(
|
||||
val hashString: String,
|
||||
val displayName: String?
|
||||
) : Key {
|
||||
override fun updateDiskCacheKey(messageDigest: MessageDigest) {
|
||||
messageDigest.update(hashString.encodeToByteArray())
|
||||
messageDigest.update(displayName.encodeToByteArray())
|
||||
messageDigest.update(displayName?.encodeToByteArray() ?: byteArrayOf())
|
||||
}
|
||||
}
|
@ -1,7 +1,7 @@
|
||||
package org.session.libsession.utilities
|
||||
|
||||
// Non-translatable strings for use with the UI
|
||||
object StringSubstitutionConstants {
|
||||
object NonTranslatableStringConstants {
|
||||
const val APP_NAME = "Session"
|
||||
const val ARBISCAN = "Arbiscan"
|
||||
const val ARBITRUM = "Arbitrum"
|
||||
|
@ -155,6 +155,7 @@
|
||||
<string name="authenticateFailedTooManyAttempts">Too many failed authentication attempts. Please try again later.</string>
|
||||
<string name="authenticateNotAccessed">Authentication could not be accessed.</string>
|
||||
<string name="authenticateToOpen">Authenticate to open {app_name}.</string>
|
||||
<string name="back">Back</string>
|
||||
<string name="banDeleteAll">Ban and Delete All</string>
|
||||
<string name="banErrorFailed">Ban failed</string>
|
||||
<string name="banUnbanErrorFailed">Unban failed</string>
|
||||
|
@ -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 />
|
@ -19,7 +19,7 @@ chmod 600 ssh_key
|
||||
|
||||
# Define the output paths
|
||||
build_dir="app/build/outputs/apk/play/debug"
|
||||
target_path="${build_dir}/$(ls ${build_dir} | grep -o 'session-[^[:space:]]*-universal.apk')"
|
||||
target_path="${build_dir}/$(ls ${build_dir} | grep -o 'app-[^[:space:]]*-universal-debug.apk')"
|
||||
|
||||
# Validate the paths exist
|
||||
if [ ! -d $build_path ]; then
|
||||
|
@ -6,4 +6,3 @@ include ':libsession'
|
||||
include ':libsignal'
|
||||
include ':libsession-util'
|
||||
include ':content-descriptions' // ONLY AccessibilityID strings (non-translated) used to identify UI elements in automated testing
|
||||
include ':stickyheader'
|
@ -1,2 +0,0 @@
|
||||
configurations.maybeCreate("default")
|
||||
artifacts.add("default", file('stickyheadergrid-0.9.4.aar'))
|
Binary file not shown.
Loading…
x
Reference in New Issue
Block a user