From 481f5ebfc1acf7b7cb4690be8e84d4c0eae146a8 Mon Sep 17 00:00:00 2001 From: kosyak Date: Sun, 14 Jul 2024 17:01:50 +0200 Subject: [PATCH] per conversation custom backgrounds --- .../ui/ConversationFragment.java | 52 +++ .../conversations/ui/SettingsActivity.java | 52 +++ .../utils/ChatBackgroundHelper.java | 305 ++++++++++++++++++ src/main/res/layout/fragment_conversation.xml | 12 +- src/main/res/menu/fragment_conversation.xml | 12 + src/main/res/values/colors.xml | 1 + src/main/res/values/strings.xml | 9 + src/main/res/xml/preferences.xml | 8 + 8 files changed, 447 insertions(+), 4 deletions(-) create mode 100644 src/main/java/eu/siacs/conversations/utils/ChatBackgroundHelper.java diff --git a/src/main/java/eu/siacs/conversations/ui/ConversationFragment.java b/src/main/java/eu/siacs/conversations/ui/ConversationFragment.java index 0b24f9439..4f2425e0f 100644 --- a/src/main/java/eu/siacs/conversations/ui/ConversationFragment.java +++ b/src/main/java/eu/siacs/conversations/ui/ConversationFragment.java @@ -70,6 +70,7 @@ import androidx.annotation.NonNull; import androidx.annotation.StringRes; import androidx.appcompat.app.AlertDialog; import androidx.appcompat.content.res.AppCompatResources; +import androidx.core.content.ContextCompat; import androidx.core.view.inputmethod.InputConnectionCompat; import androidx.core.view.inputmethod.InputContentInfoCompat; import androidx.databinding.DataBindingUtil; @@ -80,6 +81,7 @@ import com.google.common.collect.ImmutableList; import org.jetbrains.annotations.NotNull; +import java.io.File; import java.util.AbstractMap; import java.util.ArrayList; import java.util.Arrays; @@ -135,11 +137,13 @@ import eu.siacs.conversations.ui.util.ScrollState; import eu.siacs.conversations.ui.util.SendButtonAction; import eu.siacs.conversations.ui.util.SendButtonTool; import eu.siacs.conversations.ui.util.ShareUtil; +import eu.siacs.conversations.ui.util.StyledAttributes; import eu.siacs.conversations.ui.util.ViewUtil; import eu.siacs.conversations.ui.widget.EditMessage; import eu.siacs.conversations.ui.widget.HighlighterView; import eu.siacs.conversations.ui.widget.TabLayout; import eu.siacs.conversations.utils.AccountUtils; +import eu.siacs.conversations.utils.ChatBackgroundHelper; import eu.siacs.conversations.utils.Compatibility; import eu.siacs.conversations.utils.Emoticons; import eu.siacs.conversations.utils.GeoHelper; @@ -1321,6 +1325,12 @@ public class ConversationFragment extends XmppFragment } else { this.postponedActivityResult.push(activityResult); } + + ChatBackgroundHelper.onActivityResult(activity, requestCode, resultCode, data, conversation.getUuid()); + + if (requestCode == ChatBackgroundHelper.REQUEST_IMPORT_BACKGROUND) { + refresh(); + } } public void unblockConversation(final Blockable conversation) { @@ -1367,6 +1377,7 @@ public class ConversationFragment extends XmppFragment final MenuItem menuOngoingCall = menu.findItem(R.id.action_ongoing_call); final MenuItem menuVideoCall = menu.findItem(R.id.action_video_call); final MenuItem menuTogglePinned = menu.findItem(R.id.action_toggle_pinned); + final MenuItem deleteCustomBg = menu.findItem(R.id.action_delete_custom_bg); if (conversation != null) { if (conversation.getMode() == Conversation.MODE_MULTI) { @@ -1417,6 +1428,8 @@ public class ConversationFragment extends XmppFragment } else { menuTogglePinned.setTitle(R.string.add_to_favorites); } + + deleteCustomBg.setVisible(ChatBackgroundHelper.getBgFile(activity, conversation.getUuid()).exists()); } Fragment secondaryFragment = activity.getFragmentManager().findFragmentById(R.id.secondary_fragment); @@ -2032,6 +2045,27 @@ public class ConversationFragment extends XmppFragment case R.id.action_throttle: throttleNoisyNoftificationsDialog(conversation); break; + case R.id.action_set_custom_bg: + if (activity.hasStoragePermission(ChatBackgroundHelper.REQUEST_IMPORT_BACKGROUND)) { + ChatBackgroundHelper.openBGPicker(this); + } + break; + case R.id.action_delete_custom_bg: + try { + File bgfile = ChatBackgroundHelper.getBgFile(activity, conversation.getUuid()); + if (bgfile.exists()) { + bgfile.delete(); + Toast.makeText(activity, R.string.delete_background_success,Toast.LENGTH_LONG).show(); + } else { + Toast.makeText(activity, R.string.no_background_set,Toast.LENGTH_LONG).show(); + } + + refresh(); + } catch (Exception e) { + Toast.makeText(activity, R.string.delete_background_failed,Toast.LENGTH_LONG).show(); + throw new RuntimeException(e); + } + break; case R.id.action_block: case R.id.action_unblock: final Activity activity = getActivity(); @@ -2416,6 +2450,8 @@ public class ConversationFragment extends XmppFragment Toast.LENGTH_SHORT) .show(); } + + ChatBackgroundHelper.onRequestPermissionsResult(this, requestCode, permissions, grantResults); } if (writeGranted(grantResults, permissions)) { if (activity != null && activity.xmppConnectionService != null) { @@ -2429,6 +2465,18 @@ public class ConversationFragment extends XmppFragment } } + private void updateChatBG() { + if (activity != null) { + Uri uri = ChatBackgroundHelper.getBgUri(activity, conversation.getUuid()); + if (uri != null) { + binding.backgroundImage.setImageURI(uri); + binding.backgroundImage.setVisibility(View.VISIBLE); + } else { + binding.backgroundImage.setVisibility(View.GONE); + } + } + } + public void startDownloadable(Message message) { if (!hasPermissions(REQUEST_START_DOWNLOAD, Manifest.permission.WRITE_EXTERNAL_STORAGE)) { this.mPendingDownloadableMessage = message; @@ -2648,6 +2696,7 @@ public class ConversationFragment extends XmppFragment public void onResume() { super.onResume(); binding.messagesView.post(this::fireReadEvent); + updateChatBG(); } private void fireReadEvent() { @@ -2982,6 +3031,8 @@ public class ConversationFragment extends XmppFragment findAndReInitByUuidOrArchive(uuid); } } + + updateChatBG(); } @Override @@ -3514,6 +3565,7 @@ public class ConversationFragment extends XmppFragment "ConversationFragment.refresh() skipped updated because view binding was null"); return; } + updateChatBG(); if (this.conversation != null && this.activity != null && this.activity.xmppConnectionService != null) { diff --git a/src/main/java/eu/siacs/conversations/ui/SettingsActivity.java b/src/main/java/eu/siacs/conversations/ui/SettingsActivity.java index 2f1dd3bd3..2a58795b3 100644 --- a/src/main/java/eu/siacs/conversations/ui/SettingsActivity.java +++ b/src/main/java/eu/siacs/conversations/ui/SettingsActivity.java @@ -6,6 +6,10 @@ import android.content.Intent; import android.content.SharedPreferences; import android.content.SharedPreferences.OnSharedPreferenceChangeListener; import android.content.pm.PackageManager; +import android.graphics.Bitmap; +import android.graphics.BitmapFactory; +import android.graphics.Color; +import android.graphics.Matrix; import android.net.Uri; import android.os.Build; import android.os.Bundle; @@ -22,12 +26,19 @@ import android.widget.Toast; import androidx.annotation.NonNull; import androidx.appcompat.app.AlertDialog; import androidx.core.content.ContextCompat; +import androidx.exifinterface.media.ExifInterface; import com.google.common.base.Strings; import com.google.common.collect.ImmutableList; import com.google.common.collect.Lists; +import java.io.Closeable; import java.io.File; +import java.io.FileNotFoundException; +import java.io.FileOutputStream; +import java.io.IOException; +import java.io.InputStream; +import java.io.OutputStream; import java.net.URI; import java.net.URISyntaxException; import java.security.KeyStoreException; @@ -48,6 +59,7 @@ import eu.siacs.conversations.services.QuickConversationsService; import eu.siacs.conversations.services.UnifiedPushDistributor; import eu.siacs.conversations.ui.util.SettingsUtils; import eu.siacs.conversations.ui.util.StyledAttributes; +import eu.siacs.conversations.utils.ChatBackgroundHelper; import eu.siacs.conversations.utils.GeoHelper; import eu.siacs.conversations.utils.ThemeHelper; import eu.siacs.conversations.utils.TimeFrameUtils; @@ -362,6 +374,37 @@ public class SettingsActivity extends XmppActivity implements OnSharedPreference privacyCategory.removePreference(omemoPreference); } } + + final Preference importBackgroundPreference = mSettingsFragment.findPreference("import_background"); + if (importBackgroundPreference != null) { + importBackgroundPreference.setSummary(getString(R.string.pref_chat_background_summary)); + importBackgroundPreference.setOnPreferenceClickListener(preference -> { + if (hasStoragePermission(ChatBackgroundHelper.REQUEST_IMPORT_BACKGROUND)) { + ChatBackgroundHelper.openBGPicker(this); + } + return true; + }); + } + + final Preference deleteBackgroundPreference = mSettingsFragment.findPreference("delete_background"); + if (deleteBackgroundPreference != null) { + deleteBackgroundPreference.setSummary(getString(R.string.pref_delete_background_summary)); + deleteBackgroundPreference.setOnPreferenceClickListener(preference -> { + try { + File bgfile = ChatBackgroundHelper.getBgFile(this, null); + if (bgfile.exists()) { + bgfile.delete(); + Toast.makeText(this,R.string.delete_background_success,Toast.LENGTH_LONG).show(); + } else { + Toast.makeText(this,R.string.no_background_set,Toast.LENGTH_LONG).show(); + } + } catch (Exception e) { + Toast.makeText(this,R.string.delete_background_failed,Toast.LENGTH_LONG).show(); + throw new RuntimeException(e); + } + return true; + }); + } } private void changeOmemoSettingSummary() { @@ -588,6 +631,13 @@ public class SettingsActivity extends XmppActivity implements OnSharedPreference SettingsUtils.applyScreenshotPreventionSetting(this); } + @Override + protected void onActivityResult(int requestCode, int resultCode, Intent data) { + super.onActivityResult(requestCode, resultCode, data); + + ChatBackgroundHelper.onActivityResult(this, requestCode, resultCode, data, null); + } + @Override public void onRequestPermissionsResult( int requestCode, @NonNull String[] permissions, @NonNull int[] grantResults) { @@ -597,6 +647,8 @@ public class SettingsActivity extends XmppActivity implements OnSharedPreference if (requestCode == REQUEST_CREATE_BACKUP) { createBackup(); } + + ChatBackgroundHelper.onRequestPermissionsResult(this, requestCode, permissions, grantResults); } else { Toast.makeText( this, diff --git a/src/main/java/eu/siacs/conversations/utils/ChatBackgroundHelper.java b/src/main/java/eu/siacs/conversations/utils/ChatBackgroundHelper.java new file mode 100644 index 000000000..2b5a02320 --- /dev/null +++ b/src/main/java/eu/siacs/conversations/utils/ChatBackgroundHelper.java @@ -0,0 +1,305 @@ +package eu.siacs.conversations.utils; + +import static android.app.Activity.RESULT_OK; + +import android.app.Activity; +import android.app.Fragment; +import android.content.Intent; +import android.content.pm.PackageManager; +import android.graphics.Bitmap; +import android.graphics.BitmapFactory; +import android.graphics.Color; +import android.graphics.Matrix; +import android.net.Uri; +import android.util.Log; +import android.view.View; +import android.widget.Toast; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import androidx.exifinterface.media.ExifInterface; + +import org.checkerframework.checker.units.qual.N; + +import java.io.Closeable; +import java.io.File; +import java.io.FileNotFoundException; +import java.io.FileOutputStream; +import java.io.IOException; +import java.io.InputStream; +import java.io.OutputStream; + +import eu.siacs.conversations.Config; +import eu.siacs.conversations.R; + +public class ChatBackgroundHelper { + public static final int REQUEST_IMPORT_BACKGROUND = 0xbf8704; + + public static void onActivityResult(Activity activity, int requestCode, int resultCode, Intent data, @Nullable String conversationUUID) { + if(requestCode == REQUEST_IMPORT_BACKGROUND) { + if (resultCode == RESULT_OK) { + Uri bguri = data.getData(); + onPickFile(activity, bguri, conversationUUID); + } + } + } + + public static void onRequestPermissionsResult(Activity activity, + int requestCode, @NonNull String[] permissions, @NonNull int[] grantResults) { + if (grantResults.length > 0 && grantResults[0] == PackageManager.PERMISSION_GRANTED && requestCode == REQUEST_IMPORT_BACKGROUND) { + openBGPicker(activity); + } + } + + public static void onRequestPermissionsResult(Fragment fragment, + int requestCode, @NonNull String[] permissions, @NonNull int[] grantResults) { + if (grantResults.length > 0 && grantResults[0] == PackageManager.PERMISSION_GRANTED && requestCode == REQUEST_IMPORT_BACKGROUND) { + openBGPicker(fragment); + } + } + + public static File getBgFile(Activity activity, @Nullable String conversationUUID) { + if (conversationUUID == null) { + return new File(activity.getFilesDir() + File.separator + "backgrounds" + File.separator + "bg.jpg"); + } else { + return new File(activity.getFilesDir() + File.separator + "backgrounds" + File.separator + "bg_" + conversationUUID + ".jpg"); + } + } + + @Nullable + public static Uri getBgUri(Activity activity, String conversationUUID) { + File chatBgfileUri = new File(activity.getFilesDir() + File.separator + "backgrounds" + File.separator + "bg_" + conversationUUID + ".jpg"); + File bgfileUri = new File(activity.getFilesDir() + File.separator + "backgrounds" + File.separator + "bg.jpg"); + if (chatBgfileUri.exists()) { + return Uri.fromFile(chatBgfileUri); + } else if (bgfileUri.exists()) { + return Uri.fromFile(bgfileUri); + } else { + return null; + } + } + + public static void openBGPicker(Activity activity) { + Intent intent = new Intent(Intent.ACTION_GET_CONTENT); + intent.setType("image/*"); + intent.putExtra(Intent.EXTRA_ALLOW_MULTIPLE, false); + activity.startActivityForResult(Intent.createChooser(intent, "Select image"), REQUEST_IMPORT_BACKGROUND); + } + + public static void openBGPicker(Fragment fragment) { + Intent intent = new Intent(Intent.ACTION_GET_CONTENT); + intent.setType("image/*"); + intent.putExtra(Intent.EXTRA_ALLOW_MULTIPLE, false); + fragment.startActivityForResult(Intent.createChooser(intent, "Select image"), REQUEST_IMPORT_BACKGROUND); + } + + private static void onPickFile(Activity activity, Uri uri, @Nullable String conversationUUID) { + if (uri != null) { + InputStream in; + OutputStream out; + try { + File bgfolder = new File(activity.getFilesDir() + File.separator + "backgrounds"); + File bgfile; + + if (conversationUUID != null) { + bgfile = new File(activity.getFilesDir() + File.separator + "backgrounds" + File.separator + "bg_" + conversationUUID + ".jpg"); + } else { + bgfile = new File(activity.getFilesDir() + File.separator + "backgrounds" + File.separator + "bg.jpg"); + } + //create output directory if it doesn't exist + if (!bgfolder.exists()) { + bgfolder.mkdirs(); + } + + in = activity.getContentResolver().openInputStream(uri); + out = new FileOutputStream(bgfile); + byte[] buffer = new byte[4096]; + int read; + while ((read = in.read(buffer)) != -1) { + out.write(buffer, 0, read); + } + in.close(); + in = null; + // write the output file + out.flush(); + out.close(); + out = null; + compressImage(activity, bgfile, uri, 0); + Toast.makeText(activity, R.string.custom_background_set,Toast.LENGTH_LONG).show(); + } catch (IOException exception) { + Toast.makeText(activity,R.string.create_background_failed,Toast.LENGTH_LONG).show(); + Log.d(Config.LOGTAG, "Could not create background" + exception); + } + } + } + + private static void compressImage(Activity activity, File f, Uri image, int sampleSize) throws IOException { + InputStream is = null; + OutputStream os = null; + int IMAGE_QUALITY = 65; + int ImageSize = (int) (0.08 * 1024 * 1024); + try { + if (!f.exists() && !f.createNewFile()) { + throw new IOException(String.valueOf(R.string.error_unable_to_create_temporary_file)); + } + is = activity.getContentResolver().openInputStream(image); + if (is == null) { + throw new IOException(String.valueOf(R.string.error_not_an_image_file)); + } + final Bitmap originalBitmap; + final BitmapFactory.Options options = new BitmapFactory.Options(); + final int inSampleSize = (int) Math.pow(2, sampleSize); + Log.d(Config.LOGTAG, "reading bitmap with sample size " + inSampleSize); + options.inSampleSize = inSampleSize; + originalBitmap = BitmapFactory.decodeStream(is, null, options); + is.close(); + if (originalBitmap == null) { + throw new IOException("Source file was not an image"); + } + if (!"image/jpeg".equals(options.outMimeType) && hasAlpha(originalBitmap)) { + originalBitmap.recycle(); + throw new IOException("Source file had alpha channel"); + } + int size; + int resolution = 1920; + if (resolution == 0) { + int height = originalBitmap.getHeight(); + int width = originalBitmap.getWidth(); + size = height > width ? height : width; + } else { + size = resolution; + } + Bitmap scaledBitmap = resize(originalBitmap, size); + final int rotation = getRotation(activity, image); + scaledBitmap = rotate(scaledBitmap, rotation); + boolean targetSizeReached = false; + int quality = IMAGE_QUALITY; + while (!targetSizeReached) { + os = new FileOutputStream(f); + boolean success = scaledBitmap.compress(Config.IMAGE_FORMAT, quality, os); + if (!success) { + throw new IOException(String.valueOf(R.string.error_compressing_image)); + } + os.flush(); + targetSizeReached = (f.length() <= ImageSize && ImageSize != 0) || quality <= 50; + quality -= 5; + } + scaledBitmap.recycle(); + } catch (final FileNotFoundException e) { + cleanup(f); + throw new IOException(String.valueOf(R.string.error_file_not_found)); + } catch (final IOException e) { + cleanup(f); + throw new IOException(String.valueOf(R.string.error_io_exception)); + } catch (SecurityException e) { + cleanup(f); + throw new IOException(String.valueOf(R.string.error_security_exception_during_image_copy)); + } catch (final OutOfMemoryError e) { + ++sampleSize; + if (sampleSize <= 3) { + compressImage(activity, f, image, sampleSize); + } else { + throw new IOException(String.valueOf(R.string.error_out_of_memory)); + } + } finally { + close(os); + close(is); + } + } + + private static int getRotation(Activity activity, final Uri image) { + try (final InputStream is = activity.getContentResolver().openInputStream(image)) { + return is == null ? 0 : getRotation(is); + } catch (final Exception e) { + return 0; + } + } + + private static int getRotation(final InputStream inputStream) throws IOException { + final ExifInterface exif = new ExifInterface(inputStream); + final int orientation = exif.getAttributeInt(ExifInterface.TAG_ORIENTATION, ExifInterface.ORIENTATION_UNDEFINED); + switch (orientation) { + case ExifInterface.ORIENTATION_ROTATE_180: + return 180; + case ExifInterface.ORIENTATION_ROTATE_90: + return 90; + case ExifInterface.ORIENTATION_ROTATE_270: + return 270; + default: + return 0; + } + } + + private static Bitmap rotate(final Bitmap bitmap, final int degree) { + if (degree == 0) { + return bitmap; + } + final int w = bitmap.getWidth(); + final int h = bitmap.getHeight(); + final Matrix matrix = new Matrix(); + matrix.postRotate(degree); + final Bitmap result = Bitmap.createBitmap(bitmap, 0, 0, w, h, matrix, true); + if (!bitmap.isRecycled()) { + bitmap.recycle(); + } + return result; + } + + private static void close(final Closeable stream) { + if (stream != null) { + try { + stream.close(); + } catch (Exception e) { + Log.d(Config.LOGTAG, "unable to close stream", e); + } + } + } + + private static void cleanup(final File file) { + try { + file.delete(); + } catch (Exception e) { + + } + } + + private static Bitmap resize(final Bitmap originalBitmap, int size) throws IOException { + int w = originalBitmap.getWidth(); + int h = originalBitmap.getHeight(); + if (w <= 0 || h <= 0) { + throw new IOException("Decoded bitmap reported bounds smaller 0"); + } else if (Math.max(w, h) > size) { + int scalledW; + int scalledH; + if (w <= h) { + scalledW = Math.max((int) (w / ((double) h / size)), 1); + scalledH = size; + } else { + scalledW = size; + scalledH = Math.max((int) (h / ((double) w / size)), 1); + } + final Bitmap result = Bitmap.createScaledBitmap(originalBitmap, scalledW, scalledH, true); + if (!originalBitmap.isRecycled()) { + originalBitmap.recycle(); + } + return result; + } else { + return originalBitmap; + } + } + + private static boolean hasAlpha(final Bitmap bitmap) { + final int w = bitmap.getWidth(); + final int h = bitmap.getHeight(); + final int yStep = Math.max(1, w / 100); + final int xStep = Math.max(1, h / 100); + for (int x = 0; x < w; x += xStep) { + for (int y = 0; y < h; y += yStep) { + if (Color.alpha(bitmap.getPixel(x, y)) < 255) { + return true; + } + } + } + return false; + } +} diff --git a/src/main/res/layout/fragment_conversation.xml b/src/main/res/layout/fragment_conversation.xml index 9f6683cec..0efee1f09 100644 --- a/src/main/res/layout/fragment_conversation.xml +++ b/src/main/res/layout/fragment_conversation.xml @@ -7,6 +7,13 @@ android:layout_height="match_parent" android:background="?attr/color_background_secondary"> + + + android:layout_height="fill_parent"> diff --git a/src/main/res/menu/fragment_conversation.xml b/src/main/res/menu/fragment_conversation.xml index b25d7411a..64257fd9d 100644 --- a/src/main/res/menu/fragment_conversation.xml +++ b/src/main/res/menu/fragment_conversation.xml @@ -147,6 +147,18 @@ android:orderInCategory="75" android:title="@string/refresh_feature_discovery" app:showAsAction="never" /> + + + + diff --git a/src/main/res/values/colors.xml b/src/main/res/values/colors.xml index ca06ae91e..2a1c55b68 100644 --- a/src/main/res/values/colors.xml +++ b/src/main/res/values/colors.xml @@ -16,6 +16,7 @@ #ff9e9e9e #ff616161 #66616161 + #40616161 #ff424242 #ff282828 #fff44336 diff --git a/src/main/res/values/strings.xml b/src/main/res/values/strings.xml index ecb3638e6..3ad0b48a6 100644 --- a/src/main/res/values/strings.xml +++ b/src/main/res/values/strings.xml @@ -1090,4 +1090,13 @@ Do you really want to retract this message? Chats Accounts + Custom background + Choose an own image file as chat background. + Remove chat background + Remove your custom background image from the chat + Failed to create background + Custom background set + Couldn\'t remove background image + Background image removed + No custom background set diff --git a/src/main/res/xml/preferences.xml b/src/main/res/xml/preferences.xml index 0a5cb1b37..583651446 100644 --- a/src/main/res/xml/preferences.xml +++ b/src/main/res/xml/preferences.xml @@ -232,6 +232,14 @@ android:key="font_size" android:summary="@string/pref_font_size_summary" android:title="@string/pref_font_size" /> + +