diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index ed73961848..fa86b9f787 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -153,7 +153,12 @@ jobs: chmod +x telegram-bot-api-binary ./telegram-bot-api-binary --api-id=21724 --api-hash=3e0cb5efcd52300aec5994fdfc5bdc16 --local 2>&1 > /dev/null & curl https://raw.githubusercontent.com/PreviousAlone/ActionScript/main/uploadCI.py -o uploadCI.py - python uploadCI.py + # Legacy Telegram Markdown (used by uploadCI.py) fails with + # "can't parse entities" when the commit body contains backticks — + # they clash with the ``` fence the template wraps the message in. + # Strip them (and any CR) before passing to the uploader. + sanitized=$(printf '%s' "$COMMIT_MESSAGE" | tr -d '`\r') + COMMIT_MESSAGE="$sanitized" python uploadCI.py env: CHAT_ID: ${{ secrets.TELEGRAM_CHATID }} TELEGRAM_TOKEN: ${{ secrets.TELEGRAM_TOKEN }} diff --git a/TMessagesProj/src/main/java/org/telegram/messenger/MediaController.java b/TMessagesProj/src/main/java/org/telegram/messenger/MediaController.java index 1d4bd12bf8..96f2719ae7 100644 --- a/TMessagesProj/src/main/java/org/telegram/messenger/MediaController.java +++ b/TMessagesProj/src/main/java/org/telegram/messenger/MediaController.java @@ -5825,8 +5825,25 @@ public static String copyFileToCache(Uri uri, String ext, long sizeLimit) { return null; } + private static final java.util.concurrent.atomic.AtomicBoolean galleryScanInFlight = new java.util.concurrent.atomic.AtomicBoolean(false); + private static volatile boolean galleryScanQueued = false; + private static volatile int galleryScanQueuedGuid = 0; + private static volatile boolean galleryScanQueuedUrgent = false; + public static void loadGalleryPhotosAlbums(final int guid) { + loadGalleryPhotosAlbums(guid, false); + } + + public static void loadGalleryPhotosAlbums(final int guid, final boolean urgent) { + if (!galleryScanInFlight.compareAndSet(false, true)) { + // Coalesce: a scan is running; remember that another refresh was requested. + galleryScanQueued = true; + galleryScanQueuedGuid = guid; + galleryScanQueuedUrgent = galleryScanQueuedUrgent || urgent; + return; + } Thread thread = new Thread(() -> { + try { final ArrayList mediaAlbumsSorted = new ArrayList<>(); final ArrayList photoAlbumsSorted = new ArrayList<>(); SparseArray mediaAlbums = new SparseArray<>(); @@ -6070,11 +6087,119 @@ public static void loadGalleryPhotosAlbums(final int guid) { }); } broadcastNewPhotos(guid, mediaAlbumsSorted, photoAlbumsSorted, mediaCameraAlbumId, allMediaAlbum, allPhotosAlbum, allVideosAlbum, 0); + } finally { + galleryScanInFlight.set(false); + if (galleryScanQueued) { + galleryScanQueued = false; + final int queuedGuid = galleryScanQueuedGuid; + final boolean queuedUrgent = galleryScanQueuedUrgent; + galleryScanQueuedUrgent = false; + AndroidUtilities.runOnUIThread(() -> loadGalleryPhotosAlbums(queuedGuid, queuedUrgent)); + } + } + }); + // Urgent scans (user opened the attach menu) run at normal priority so newly + // added photos/screenshots appear quickly; background observer refreshes stay low. + thread.setPriority(urgent ? Thread.NORM_PRIORITY : Thread.MIN_PRIORITY); + thread.start(); + } + + /** + * Lightweight gallery refresh: queries only the top {@code limit} most-recent images + * from {@link MediaStore} and merges any entries that are new (by imageId) into the + * head of {@link #allMediaAlbumEntry} / {@link #allPhotosAlbumEntry}, then broadcasts + * {@link NotificationCenter#albumsDidLoad}. This is complementary to the full scan + * in {@link #loadGalleryPhotosAlbums(int, boolean)} - it lets newly taken photos + * surface in the attach grid in ~100ms instead of waiting for the full rescan. + */ + public static void quickRefreshLatestPhotos(final int guid, final int limit) { + if (allMediaAlbumEntry == null && allPhotosAlbumEntry == null) { + // No cache to merge into - nothing useful to do; full scan will populate it. + return; + } + Thread thread = new Thread(() -> { + final Context context = ApplicationLoader.applicationContext; + if (Build.VERSION.SDK_INT >= 33 + ? context.checkSelfPermission(Manifest.permission.READ_MEDIA_IMAGES) != PackageManager.PERMISSION_GRANTED + : context.checkSelfPermission(Manifest.permission.READ_EXTERNAL_STORAGE) != PackageManager.PERMISSION_GRANTED) { + return; + } + final ArrayList headNewEntries = new ArrayList<>(); + Cursor cursor = null; + try { + cursor = MediaStore.Images.Media.query(context.getContentResolver(), MediaStore.Images.Media.EXTERNAL_CONTENT_URI, projectionPhotos, null, null, (Build.VERSION.SDK_INT > 28 ? MediaStore.Images.Media.DATE_MODIFIED : MediaStore.Images.Media.DATE_TAKEN) + " DESC"); + if (cursor != null) { + int imageIdColumn = cursor.getColumnIndex(MediaStore.Images.Media._ID); + int bucketIdColumn = cursor.getColumnIndex(MediaStore.Images.Media.BUCKET_ID); + int dataColumn = cursor.getColumnIndex(MediaStore.Images.Media.DATA); + int dateColumn = cursor.getColumnIndex(Build.VERSION.SDK_INT > 28 ? MediaStore.Images.Media.DATE_MODIFIED : MediaStore.Images.Media.DATE_TAKEN); + int orientationColumn = cursor.getColumnIndex(MediaStore.Images.Media.ORIENTATION); + int widthColumn = cursor.getColumnIndex(MediaStore.Images.Media.WIDTH); + int heightColumn = cursor.getColumnIndex(MediaStore.Images.Media.HEIGHT); + int sizeColumn = cursor.getColumnIndex(MediaStore.Images.Media.SIZE); + int count = 0; + while (cursor.moveToNext() && count < limit) { + count++; + String path = cursor.getString(dataColumn); + if (TextUtils.isEmpty(path)) continue; + int imageId = cursor.getInt(imageIdColumn); + int bucketId = cursor.getInt(bucketIdColumn); + long dateTaken = cursor.getLong(dateColumn); + int orientation = cursor.getInt(orientationColumn); + int width = cursor.getInt(widthColumn); + int height = cursor.getInt(heightColumn); + long size = cursor.getLong(sizeColumn); + headNewEntries.add(new PhotoEntry(bucketId, imageId, dateTaken, path, orientation, 0, false, width, height, size)); + } + } + } catch (Throwable e) { + FileLog.e(e); + } finally { + if (cursor != null) { + try { cursor.close(); } catch (Exception e) { FileLog.e(e); } + } + } + if (headNewEntries.isEmpty()) { + return; + } + AndroidUtilities.runOnUIThread(() -> mergeHeadEntriesIntoCache(guid, headNewEntries)); }); - thread.setPriority(Thread.MIN_PRIORITY); + thread.setPriority(Thread.NORM_PRIORITY); thread.start(); } + private static void mergeHeadEntriesIntoCache(int guid, ArrayList headEntries) { + AlbumEntry mediaAlbum = allMediaAlbumEntry; + AlbumEntry photosAlbum = allPhotosAlbumEntry; + if (mediaAlbum == null && photosAlbum == null) { + return; + } + AlbumEntry referenceAlbum = mediaAlbum != null ? mediaAlbum : photosAlbum; + ArrayList newOnes = new ArrayList<>(); + for (PhotoEntry e : headEntries) { + if (referenceAlbum.photosByIds.get(e.imageId) == null) { + newOnes.add(e); + } + } + if (newOnes.isEmpty()) { + return; + } + // Prepend to existing albums (on UI thread, so safe w.r.t. adapter iteration + // as long as we notify right after). + for (int i = newOnes.size() - 1; i >= 0; i--) { + PhotoEntry e = newOnes.get(i); + if (mediaAlbum != null && mediaAlbum.photosByIds.get(e.imageId) == null) { + mediaAlbum.photos.add(0, e); + mediaAlbum.photosByIds.put(e.imageId, e); + } + if (photosAlbum != null && photosAlbum.photosByIds.get(e.imageId) == null) { + photosAlbum.photos.add(0, e); + photosAlbum.photosByIds.put(e.imageId, e); + } + } + NotificationCenter.getGlobalInstance().postNotificationName(NotificationCenter.albumsDidLoad, guid, allMediaAlbums, allPhotoAlbums, null); + } + public static boolean forceBroadcastNewPhotos; private static void broadcastNewPhotos(final int guid, final ArrayList mediaAlbumsSorted, final ArrayList photoAlbumsSorted, final Integer cameraAlbumIdFinal, final AlbumEntry allMediaAlbumFinal, final AlbumEntry allPhotosAlbumFinal, final AlbumEntry allVideosAlbumFinal, int delay) { if (broadcastPhotosRunnable != null) { diff --git a/TMessagesProj/src/main/java/org/telegram/ui/ChatActivity.java b/TMessagesProj/src/main/java/org/telegram/ui/ChatActivity.java index 4c85d6116c..daada8262e 100644 --- a/TMessagesProj/src/main/java/org/telegram/ui/ChatActivity.java +++ b/TMessagesProj/src/main/java/org/telegram/ui/ChatActivity.java @@ -360,6 +360,7 @@ import xyz.nextalone.nnngram.helpers.TranslateHelper.Status; import xyz.nextalone.nnngram.translate.LanguageDetectorTimeout; import xyz.nextalone.nnngram.ui.BackButtonRecentMenu; +import xyz.nextalone.nnngram.ui.QuickSendMediaPopup; import xyz.nextalone.nnngram.ui.TranslatorSettingsPopupWrapper; import xyz.nextalone.nnngram.ui.sortList.items.TextStyleItems; import xyz.nextalone.nnngram.utils.Defines; @@ -507,6 +508,9 @@ public class ChatActivity extends BaseFragment implements private ChatNotificationsPopupWrapper chatNotificationsPopupWrapper; // private ChatActivitySideControlsButtonsLayout topButtonsLayout; private ChatActivitySideControlsButtonsLayout sideControlsButtonsLayout; + private QuickSendMediaPopup quickSendMediaPopup; + private long quickSendPauseTimeSec; + private final java.util.HashSet quickSendDismissedIds = new java.util.HashSet<>(); private ActionBarMenuItem.Item hideTitleItem; /* private float pagedownButtonEnterProgress; private float searchUpDownEnterProgress; @@ -7353,6 +7357,12 @@ public void setAlpha(float alpha) { sideControlsButtonsLayout.setOnLongClickListener(this::onSideControlButtonOnLongClick); contentView.addView(sideControlsButtonsLayout, LayoutHelper.createFrame(57, 300, Gravity.RIGHT | Gravity.BOTTOM)); + quickSendMediaPopup = new QuickSendMediaPopup(context); + // Sits at the bottom-right of the chat, right-aligned with the + // scroll-to-bottom button and just above it, so it reads as a + // proper bottom-right affordance above the input bar. + contentView.addView(quickSendMediaPopup, LayoutHelper.createFrame(72, 72, Gravity.RIGHT | Gravity.BOTTOM, 0, 0, 6, 60)); + contentView.addView(topPanelLayout, LayoutHelper.createFrame(LayoutHelper.MATCH_PARENT, LayoutHelper.WRAP_CONTENT, Gravity.TOP)); updateMessageListAccessibilityVisibility(); @@ -11109,6 +11119,9 @@ private void updatePagedownButtonsPosition() { - getTopicTabsSideSize(TopicsTabsView.Position.BOTTOM) - dp(ChatInputViewsContainer.INPUT_BUBBLE_BOTTOM + 4); sideControlsButtonsLayout.setTranslationY(baseTranslationY2); + if (quickSendMediaPopup != null) { + quickSendMediaPopup.setTranslationY(baseTranslationY2); + } } if (suggestEmojiPanel != null) { @@ -29757,6 +29770,8 @@ public void onShow(Bulletin bulletin) { if (starReactionsOverlay != null) { starReactionsOverlay.bringToFront(); } + + checkQuickSendMediaPopup(); } public float getPullingDownOffset() { @@ -29772,6 +29787,157 @@ public void checkAdjustResize() { } } + private void checkQuickSendMediaPopup() { + if (!Config.quickSendMediaPopup || quickSendMediaPopup == null) { + return; + } + if (chatMode != 0 || inPreviewMode || inBubbleMode || isReport() || isInsideContainer) { + return; + } + if (getParentActivity() == null || currentEncryptedChat != null) { + // Skip secret chats to avoid surprising users with a separate send path. + return; + } + if (currentChat != null && !ChatObject.canSendPhoto(currentChat)) { + return; + } + if (!xyz.nextalone.nnngram.utils.PermissionUtils.isImagesPermissionGranted()) { + return; + } + final long sinceSec; + if (quickSendPauseTimeSec > 0) { + // Only show photos added while we were away from this chat. + sinceSec = quickSendPauseTimeSec; + } else { + // First time entering: look back 3 minutes for a freshly taken shot. + sinceSec = System.currentTimeMillis() / 1000L - 180L; + } + final long persistedDismissedId = xyz.nextalone.nnngram.config.ConfigManager + .getLongOrDefault(xyz.nextalone.nnngram.utils.Defines.quickSendMediaLastDismissedId, 0L); + QuickSendMediaPopup.queryLatestImage(sinceSec, entry -> { + if (entry == null || quickSendMediaPopup == null || paused) { + return; + } + if (entry.id <= persistedDismissedId) { + // Previously dismissed across sessions - MediaStore imageIds increase + // monotonically, so anything at or below the watermark is old news. + return; + } + if (quickSendDismissedIds.contains(entry.id)) { + return; + } + if (quickSendMediaPopup.isShowingFor(entry.id)) { + return; + } + // The photo is about to be surfaced once; persist the dismissal + // watermark immediately so that *any* exit path (send, cancel + // from the preview, 10s timeout, leaving the chat, process + // restart) counts as "already seen" and the same image is not + // surfaced a second time. + rememberQuickSendDismissed(entry); + quickSendMediaPopup.show(entry, new QuickSendMediaPopup.Delegate() { + @Override + public void onSend(QuickSendMediaPopup.QuickSendMediaEntry e) { + openQuickSendPreview(e); + } + + @Override + public void onUserDismiss(QuickSendMediaPopup.QuickSendMediaEntry e) { + // Already persisted on show(); nothing extra to do. + } + }); + }); + } + + private void rememberQuickSendDismissed(QuickSendMediaPopup.QuickSendMediaEntry e) { + if (e == null) return; + quickSendDismissedIds.add(e.id); + if (e.dateAdded > 0) { + quickSendPauseTimeSec = Math.max(quickSendPauseTimeSec, e.dateAdded); + } + long prev = xyz.nextalone.nnngram.config.ConfigManager + .getLongOrDefault(xyz.nextalone.nnngram.utils.Defines.quickSendMediaLastDismissedId, 0L); + if (e.id > prev) { + xyz.nextalone.nnngram.config.ConfigManager + .putLong(xyz.nextalone.nnngram.utils.Defines.quickSendMediaLastDismissedId, e.id); + } + } + + private void openQuickSendPreview(QuickSendMediaPopup.QuickSendMediaEntry e) { + if (e == null || getParentActivity() == null) { + return; + } + if (!checkSlowModeAlert()) { + return; + } + final String path = e.path; + if (path == null) { + // No file path (rare, scoped-storage edge case) - fall back to direct send via URI. + sendQuickMediaEntry(e); + return; + } + Pair orientation = AndroidUtilities.getImageOrientation(path); + final MediaController.PhotoEntry photoEntry = + new MediaController.PhotoEntry(0, 0, 0, path, orientation.first, false, 0, 0, 0) + .setOrientation(orientation); + final ArrayList list = new ArrayList<>(); + list.add(photoEntry); + + PhotoViewer.getInstance().setParentActivity(this, themeDelegate); + if (PhotoViewer.getInstance().isVisible()) { + PhotoViewer.getInstance().closePhoto(false, false); + } + PhotoViewer.getInstance().openPhotoForSelect(list, 0, 0, false, new PhotoViewer.EmptyPhotoViewerProvider() { + @Override + public void sendButtonPressed(int index, VideoEditedInfo videoEditedInfo, boolean notify, int scheduleDate, int scheduleRepeatPeriod, boolean forceDocument) { + // User confirmed from the preview: persist dismissal and send. + rememberQuickSendDismissed(e); + sendMedia(photoEntry, videoEditedInfo, notify, scheduleDate, scheduleRepeatPeriod, forceDocument, 0); + } + + @Override + public boolean canScrollAway() { + return false; + } + + @Override + public boolean canCaptureMorePhotos() { + return false; + } + }, this); + } + + private void sendQuickMediaEntry(QuickSendMediaPopup.QuickSendMediaEntry e) { + if (e == null || (e.path == null && e.uri == null)) { + return; + } + if (!checkSlowModeAlert()) { + return; + } + rememberQuickSendDismissed(e); + SendMessagesHelper.prepareSendingPhoto( + getAccountInstance(), + e.path, + e.uri, + dialog_id, + replyingMessageObject, + getThreadMessage(), + replyingQuote, + null, + null, + null, + null, + 0, + null, + true, + 0, + chatMode, + quickReplyShortcut, + getQuickReplyId() + ); + afterMessageSend(); + } + @Override public void finishFragment() { super.finishFragment(); @@ -29840,6 +30006,10 @@ public void onPause() { if (contentView != null) { contentView.onPause(); } + if (quickSendMediaPopup != null) { + quickSendMediaPopup.dismiss(false); + } + quickSendPauseTimeSec = System.currentTimeMillis() / 1000L; if (chatMode == 0 || chatMode == MODE_SAVED && getUserConfig().getClientUserId() == getSavedDialogId() || chatMode == MODE_SUGGESTIONS && ChatObject.isMonoForum(currentChat)) { saveDraft(); getMessagesController().cancelTyping(0, dialog_id, threadMessageId); diff --git a/TMessagesProj/src/main/java/org/telegram/ui/Components/ChatAttachAlertPhotoLayout.java b/TMessagesProj/src/main/java/org/telegram/ui/Components/ChatAttachAlertPhotoLayout.java index c2d0b3a7ff..e364810bd7 100644 --- a/TMessagesProj/src/main/java/org/telegram/ui/Components/ChatAttachAlertPhotoLayout.java +++ b/TMessagesProj/src/main/java/org/telegram/ui/Components/ChatAttachAlertPhotoLayout.java @@ -2560,15 +2560,12 @@ public void onAnimationEnd(Animator animator) { } public void loadGalleryPhotos() { - MediaController.AlbumEntry albumEntry; - if (shouldLoadAllMedia()) { - albumEntry = MediaController.allMediaAlbumEntry; - } else { - albumEntry = MediaController.allPhotosAlbumEntry; - } - if (albumEntry == null) { - MediaController.loadGalleryPhotosAlbums(0); - } + // Fast path: if we already have a cached album, prepend just-taken photos + // into it (~100ms) so the grid shows them immediately instead of waiting + // for the full rescan. The full rescan still runs right after at normal + // priority so bucket counts and album lists stay accurate. + MediaController.quickRefreshLatestPhotos(0, 30); + MediaController.loadGalleryPhotosAlbums(0, true); } private boolean shouldLoadAllMedia() { diff --git a/TMessagesProj/src/main/java/xyz/nextalone/nnngram/activity/ChatSettingActivity.java b/TMessagesProj/src/main/java/xyz/nextalone/nnngram/activity/ChatSettingActivity.java index 975ef27531..4e3305c3c9 100644 --- a/TMessagesProj/src/main/java/xyz/nextalone/nnngram/activity/ChatSettingActivity.java +++ b/TMessagesProj/src/main/java/xyz/nextalone/nnngram/activity/ChatSettingActivity.java @@ -123,6 +123,7 @@ public class ChatSettingActivity extends BaseActivity { private int hideTimeForStickerRow; private int showMessageIDRow; private int hideQuickSendMediaBottomRow; + private int quickSendMediaPopupRow; private int customQuickMessageRow; private int scrollableChatPreviewRow; private int showTabsOnForwardRow; @@ -343,6 +344,11 @@ protected void onItemClick(View view, int position, float x, float y) { if (view instanceof TextCheckCell) { ((TextCheckCell) view).setChecked(Config.hideQuickSendMediaBottom); } + } else if (position == quickSendMediaPopupRow) { + Config.toggleQuickSendMediaPopup(); + if (view instanceof TextCheckCell) { + ((TextCheckCell) view).setChecked(Config.quickSendMediaPopup); + } } else if (position == customQuickMessageRow) { setCustomQuickMessage(); listAdapter.notifyItemChanged(position, PARTIAL); @@ -582,6 +588,7 @@ protected void updateRows() { quickToggleAnonymous = addRow("quickToggleAnonymous"); hideSendAsButtonRow = addRow("hideSendAsButton"); hideQuickSendMediaBottomRow = addRow("hideQuickSendMediaBottom"); + quickSendMediaPopupRow = addRow("quickSendMediaPopup"); customQuickMessageRow = addRow("customQuickMessage"); scrollableChatPreviewRow = addRow("scrollableChatPreview"); showTabsOnForwardRow = addRow("showTabsOnForward"); @@ -747,6 +754,11 @@ public void onBindViewHolder(RecyclerView.ViewHolder holder, int position, boole } else if (position == hideQuickSendMediaBottomRow) { textCell.setTextAndCheck(LocaleController.getString("DisableQuickSendMediaBottom", R.string.DisableQuickSendMediaBottom), Config.hideQuickSendMediaBottom, true); + } else if (position == quickSendMediaPopupRow) { + textCell.setTextAndValueAndCheck( + LocaleController.getString("quickSendMediaPopup", R.string.quickSendMediaPopup), + LocaleController.getString("quickSendMediaPopupInfo", R.string.quickSendMediaPopupInfo), + Config.quickSendMediaPopup, true, true); } else if (position == scrollableChatPreviewRow) { textCell.setTextAndCheck(LocaleController.getString("scrollableChatPreview", R.string.scrollableChatPreview), Config.scrollableChatPreview, true); } else if (position == showTabsOnForwardRow) { diff --git a/TMessagesProj/src/main/java/xyz/nextalone/nnngram/ui/QuickSendMediaPopup.java b/TMessagesProj/src/main/java/xyz/nextalone/nnngram/ui/QuickSendMediaPopup.java new file mode 100644 index 0000000000..7efa637853 --- /dev/null +++ b/TMessagesProj/src/main/java/xyz/nextalone/nnngram/ui/QuickSendMediaPopup.java @@ -0,0 +1,333 @@ +/* + * Copyright (C) 2019-2026 qwq233 + * https://github.com/qwq233/Nullgram + * + * 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 2 + * 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 software. + * If not, see + * + */ +package xyz.nextalone.nnngram.ui; + +import static org.telegram.messenger.AndroidUtilities.dp; + +import android.content.ContentUris; +import android.content.Context; +import android.database.Cursor; +import android.graphics.Bitmap; +import android.graphics.BitmapFactory; +import android.graphics.Color; +import android.graphics.Matrix; +import android.graphics.Outline; +import android.graphics.PorterDuff; +import android.graphics.PorterDuffColorFilter; +import android.graphics.drawable.ColorDrawable; +import android.media.ExifInterface; +import android.net.Uri; +import android.provider.MediaStore; +import android.view.Gravity; +import android.view.View; +import android.view.ViewOutlineProvider; +import android.view.animation.OvershootInterpolator; +import android.widget.FrameLayout; +import android.widget.ImageView; + +import org.telegram.messenger.AndroidUtilities; +import org.telegram.messenger.ApplicationLoader; +import org.telegram.messenger.FileLog; +import org.telegram.messenger.LocaleController; +import org.telegram.messenger.R; +import org.telegram.messenger.Utilities; +import org.telegram.ui.ActionBar.Theme; +import org.telegram.ui.Components.LayoutHelper; + +public class QuickSendMediaPopup extends FrameLayout { + + public interface Delegate { + void onSend(QuickSendMediaEntry entry); + + /** Called when the user explicitly taps the × to dismiss. Not called on auto-dismiss. */ + default void onUserDismiss(QuickSendMediaEntry entry) {} + } + + public static class QuickSendMediaEntry { + public long id; + public String path; + public Uri uri; + public long dateAdded; + public boolean isVideo; + public int orientation; + } + + private static final long AUTO_DISMISS_MS = 10_000L; + + private final ImageView thumbView; + private final ImageView closeButton; + private final ImageView sendIcon; + private QuickSendMediaEntry entry; + private Delegate delegate; + private boolean dismissed; + private boolean sending; + + private final Runnable autoDismissRunnable = () -> dismiss(true); + + public QuickSendMediaPopup(Context context) { + super(context); + + setWillNotDraw(false); + setClipChildren(false); + + FrameLayout card = new FrameLayout(context); + card.setBackground(Theme.createRoundRectDrawable(dp(14), Theme.getColor(Theme.key_dialogBackground))); + card.setOutlineProvider(new ViewOutlineProvider() { + @Override + public void getOutline(View view, Outline outline) { + outline.setRoundRect(0, 0, view.getWidth(), view.getHeight(), dp(14)); + } + }); + card.setElevation(dp(6)); + addView(card, LayoutHelper.createFrame(60, 60, Gravity.CENTER, 8, 8, 8, 8)); + + thumbView = new ImageView(context); + thumbView.setScaleType(ImageView.ScaleType.CENTER_CROP); + thumbView.setOutlineProvider(new ViewOutlineProvider() { + @Override + public void getOutline(View view, Outline outline) { + outline.setRoundRect(0, 0, view.getWidth(), view.getHeight(), dp(10)); + } + }); + thumbView.setClipToOutline(true); + thumbView.setBackgroundColor(Theme.getColor(Theme.key_windowBackgroundGray)); + card.addView(thumbView, LayoutHelper.createFrame(LayoutHelper.MATCH_PARENT, LayoutHelper.MATCH_PARENT, Gravity.CENTER, 4, 4, 4, 4)); + + sendIcon = new ImageView(context); + sendIcon.setImageResource(R.drawable.attach_send); + sendIcon.setColorFilter(new PorterDuffColorFilter(Color.WHITE, PorterDuff.Mode.SRC_IN)); + sendIcon.setScaleType(ImageView.ScaleType.CENTER_INSIDE); + sendIcon.setBackground(Theme.createCircleDrawable(dp(22), Theme.getColor(Theme.key_chats_actionBackground))); + sendIcon.setContentDescription(LocaleController.getString("Send", R.string.Send)); + card.addView(sendIcon, LayoutHelper.createFrame(22, 22, Gravity.BOTTOM | Gravity.RIGHT, 0, 0, -4, -4)); + + closeButton = new ImageView(context); + closeButton.setImageResource(R.drawable.msg_close); + closeButton.setColorFilter(new PorterDuffColorFilter(Theme.getColor(Theme.key_dialogTextBlack), PorterDuff.Mode.SRC_IN)); + closeButton.setScaleType(ImageView.ScaleType.CENTER_INSIDE); + closeButton.setBackground(Theme.createCircleDrawable(dp(20), Theme.getColor(Theme.key_dialogBackground))); + closeButton.setElevation(dp(4)); + closeButton.setContentDescription(LocaleController.getString("Close", R.string.Close)); + addView(closeButton, LayoutHelper.createFrame(20, 20, Gravity.TOP | Gravity.RIGHT, 0, 0, 0, 0)); + + card.setOnClickListener(v -> { + if (sending || entry == null || delegate == null) { + return; + } + sending = true; + AndroidUtilities.cancelRunOnUIThread(autoDismissRunnable); + delegate.onSend(entry); + dismiss(true); + }); + closeButton.setOnClickListener(v -> { + if (entry != null && delegate != null) { + delegate.onUserDismiss(entry); + } + dismiss(true); + }); + + setAlpha(0f); + setScaleX(0.6f); + setScaleY(0.6f); + setVisibility(GONE); + } + + public void show(QuickSendMediaEntry entry, Delegate delegate) { + this.entry = entry; + this.delegate = delegate; + dismissed = false; + sending = false; + setVisibility(VISIBLE); + loadThumb(entry); + animate().cancel(); + setAlpha(0f); + setScaleX(0.6f); + setScaleY(0.6f); + animate() + .alpha(1f) + .scaleX(1f) + .scaleY(1f) + .setDuration(260) + .setInterpolator(new OvershootInterpolator(1.6f)) + .start(); + + AndroidUtilities.cancelRunOnUIThread(autoDismissRunnable); + AndroidUtilities.runOnUIThread(autoDismissRunnable, AUTO_DISMISS_MS); + } + + public void dismiss(boolean animated) { + if (dismissed) { + return; + } + dismissed = true; + AndroidUtilities.cancelRunOnUIThread(autoDismissRunnable); + animate().cancel(); + if (animated) { + animate() + .alpha(0f) + .scaleX(0.6f) + .scaleY(0.6f) + .setDuration(180) + .withEndAction(() -> setVisibility(GONE)) + .start(); + } else { + setAlpha(0f); + setVisibility(GONE); + } + } + + public QuickSendMediaEntry getEntry() { + return entry; + } + + public boolean isShowingFor(long id) { + return !dismissed && getVisibility() == VISIBLE && entry != null && entry.id == id; + } + + private void loadThumb(QuickSendMediaEntry e) { + thumbView.setImageDrawable(new ColorDrawable(Theme.getColor(Theme.key_windowBackgroundGray))); + Utilities.searchQueue.postRunnable(() -> { + Bitmap bitmap = decodeThumb(e); + AndroidUtilities.runOnUIThread(() -> { + if (dismissed || entry == null || entry.id != e.id) { + if (bitmap != null) { + bitmap.recycle(); + } + return; + } + if (bitmap != null) { + thumbView.setImageBitmap(bitmap); + } + }); + }); + } + + private Bitmap decodeThumb(QuickSendMediaEntry e) { + try { + int target = dp(120); + Bitmap bitmap = null; + if (e.path != null) { + BitmapFactory.Options opts = new BitmapFactory.Options(); + opts.inJustDecodeBounds = true; + BitmapFactory.decodeFile(e.path, opts); + int w = opts.outWidth; + int h = opts.outHeight; + int sample = 1; + while (w / sample > target * 2 && h / sample > target * 2) { + sample *= 2; + } + BitmapFactory.Options decodeOpts = new BitmapFactory.Options(); + decodeOpts.inSampleSize = sample; + bitmap = BitmapFactory.decodeFile(e.path, decodeOpts); + } + if (bitmap == null && e.uri != null) { + try { + bitmap = android.provider.MediaStore.Images.Media.getBitmap( + ApplicationLoader.applicationContext.getContentResolver(), e.uri); + } catch (Throwable ignored) { + } + } + if (bitmap != null && e.orientation != 0) { + Matrix matrix = new Matrix(); + matrix.postRotate(e.orientation); + Bitmap rotated = Bitmap.createBitmap(bitmap, 0, 0, bitmap.getWidth(), bitmap.getHeight(), matrix, true); + if (rotated != bitmap) { + bitmap.recycle(); + } + bitmap = rotated; + } + return bitmap; + } catch (Throwable t) { + FileLog.e(t); + return null; + } + } + + /** + * Queries MediaStore for the newest image added after {@code sinceDateSec} (unix seconds). + * Runs on a background thread and calls {@code callback} on the UI thread. + */ + public static void queryLatestImage(long sinceDateSec, java.util.function.Consumer callback) { + Utilities.searchQueue.postRunnable(() -> { + QuickSendMediaEntry result = null; + Cursor cursor = null; + try { + String[] projection = new String[]{ + MediaStore.Images.Media._ID, + MediaStore.Images.Media.DATA, + MediaStore.Images.Media.DATE_ADDED, + MediaStore.Images.Media.ORIENTATION + }; + String selection = MediaStore.Images.Media.DATE_ADDED + " > ?"; + String[] args = new String[]{String.valueOf(sinceDateSec)}; + // LIMIT in sortOrder is unsupported on Android 11+; take first row from ordered cursor instead. + cursor = ApplicationLoader.applicationContext.getContentResolver().query( + MediaStore.Images.Media.EXTERNAL_CONTENT_URI, + projection, + selection, + args, + MediaStore.Images.Media.DATE_ADDED + " DESC" + ); + if (cursor != null && cursor.moveToFirst()) { + int idCol = cursor.getColumnIndex(MediaStore.Images.Media._ID); + int dataCol = cursor.getColumnIndex(MediaStore.Images.Media.DATA); + int dateCol = cursor.getColumnIndex(MediaStore.Images.Media.DATE_ADDED); + int orientCol = cursor.getColumnIndex(MediaStore.Images.Media.ORIENTATION); + QuickSendMediaEntry e = new QuickSendMediaEntry(); + e.id = idCol >= 0 ? cursor.getLong(idCol) : 0L; + e.path = dataCol >= 0 ? cursor.getString(dataCol) : null; + e.dateAdded = dateCol >= 0 ? cursor.getLong(dateCol) : 0L; + e.orientation = orientCol >= 0 ? cursor.getInt(orientCol) : 0; + e.uri = ContentUris.withAppendedId(MediaStore.Images.Media.EXTERNAL_CONTENT_URI, e.id); + e.isVideo = false; + // DATA column may be null on Android 10+ in some cases; fall back to EXIF via URI later. + if (e.orientation == 0 && e.path != null) { + try { + ExifInterface exif = new ExifInterface(e.path); + int o = exif.getAttributeInt(ExifInterface.TAG_ORIENTATION, ExifInterface.ORIENTATION_NORMAL); + switch (o) { + case ExifInterface.ORIENTATION_ROTATE_90: e.orientation = 90; break; + case ExifInterface.ORIENTATION_ROTATE_180: e.orientation = 180; break; + case ExifInterface.ORIENTATION_ROTATE_270: e.orientation = 270; break; + } + } catch (Throwable ignored) { + } + } + if (e.path != null || e.uri != null) { + result = e; + } + } + } catch (Throwable t) { + FileLog.e(t); + } finally { + if (cursor != null) { + try { cursor.close(); } catch (Throwable ignored) {} + } + } + final QuickSendMediaEntry finalResult = result; + AndroidUtilities.runOnUIThread(() -> callback.accept(finalResult)); + }); + } + + @Override + protected void onDetachedFromWindow() { + super.onDetachedFromWindow(); + AndroidUtilities.cancelRunOnUIThread(autoDismissRunnable); + } +} diff --git a/TMessagesProj/src/main/java/xyz/nextalone/nnngram/utils/Defines.kt b/TMessagesProj/src/main/java/xyz/nextalone/nnngram/utils/Defines.kt index f81a728da7..4bc4eb5ab0 100644 --- a/TMessagesProj/src/main/java/xyz/nextalone/nnngram/utils/Defines.kt +++ b/TMessagesProj/src/main/java/xyz/nextalone/nnngram/utils/Defines.kt @@ -72,6 +72,8 @@ object Defines { @BooleanConfig const val hideTimeForSticker = "hideTimeForSticker" @BooleanConfig const val showMessageID = "showMessageID" @BooleanConfig const val hideQuickSendMediaBottom = "hideQuickSendMediaButtom" + @BooleanConfig(true) const val quickSendMediaPopup = "quickSendMediaPopup" + const val quickSendMediaLastDismissedId = "quickSendMediaLastDismissedId" @BooleanConfig const val largeAvatarAsBackground = "largeAvatarAsBackground" @BooleanConfig const val useSystemEmoji = "useSystemEmoji" const val customQuickMessage = "customQuickCommand" diff --git a/TMessagesProj/src/main/res/values-zh-rTW/strings_nullgram.xml b/TMessagesProj/src/main/res/values-zh-rTW/strings_nullgram.xml index a42d26ada9..b50e0b7ca4 100644 --- a/TMessagesProj/src/main/res/values-zh-rTW/strings_nullgram.xml +++ b/TMessagesProj/src/main/res/values-zh-rTW/strings_nullgram.xml @@ -294,6 +294,8 @@ 覆蓋 TELEOFFICIAL 的裝置效能偵測器。\n低模式將盡量減少動畫效果和其他內容,有助於節省前景中的電力。\n高模式可能會大幅增加能源消耗和效能負擔,並導致卡頓現象。 跳至開始 停用快速傳送媒體底部 + 新圖片快捷傳送懸浮視窗 + 在聊天介面右下角顯示一個懸浮按鈕,用來快速傳送剛剛拍攝的照片或螢幕截圖。僅在開啟聊天或從背景恢復時偵測。 百度翻譯 TranSmart 翻譯器 總是無聲傳送 diff --git a/TMessagesProj/src/main/res/values-zh/strings_nullgram.xml b/TMessagesProj/src/main/res/values-zh/strings_nullgram.xml index 2f0468cbda..6089b6bd94 100644 --- a/TMessagesProj/src/main/res/values-zh/strings_nullgram.xml +++ b/TMessagesProj/src/main/res/values-zh/strings_nullgram.xml @@ -297,6 +297,8 @@ 覆盖 TELEOFFICIAL 的设备性能检测。\n低模式将尽可能减少动画效果和其他东西,这可能有助于节省前台运行时的耗电。\n高模式可能会大幅增加功耗和性能负担并导致卡顿。 回到顶部 禁用快速发送媒体按钮 + 新增图片快捷发送悬浮窗 + 在聊天界面右下角显示一个悬浮按钮,用于快速发送刚刚拍摄的照片或截图。仅在打开聊天或从后台恢复时检测。 百度翻译 腾讯交互翻译 总是静音发送 diff --git a/TMessagesProj/src/main/res/values/strings_nullgram.xml b/TMessagesProj/src/main/res/values/strings_nullgram.xml index ebc0d2581c..806d5e0856 100644 --- a/TMessagesProj/src/main/res/values/strings_nullgram.xml +++ b/TMessagesProj/src/main/res/values/strings_nullgram.xml @@ -301,6 +301,8 @@ Override TELEOFFICIAL\'s device performance detector.\nLow mode will reduce animation effects and other stuff as much as possible, which may help to save power in the foreground.\nHigh mode may greatly increase power consumption and performance burden and cause stuttering. Jump To Beginning Disable Quick Send Media Bottom + Quick send popup for new photos + Show a floating button in the bottom right corner of the chat to quickly send a photo or screenshot taken just now. Detection runs when you open or resume the chat. Baidu Translator TranSmart Translator Always Send Without Sound