Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 6 additions & 1 deletion .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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 }}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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<AlbumEntry> mediaAlbumsSorted = new ArrayList<>();
final ArrayList<AlbumEntry> photoAlbumsSorted = new ArrayList<>();
SparseArray<AlbumEntry> mediaAlbums = new SparseArray<>();
Expand Down Expand Up @@ -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<PhotoEntry> 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<PhotoEntry> headEntries) {
AlbumEntry mediaAlbum = allMediaAlbumEntry;
AlbumEntry photosAlbum = allPhotosAlbumEntry;
if (mediaAlbum == null && photosAlbum == null) {
return;
}
AlbumEntry referenceAlbum = mediaAlbum != null ? mediaAlbum : photosAlbum;
ArrayList<PhotoEntry> 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<AlbumEntry> mediaAlbumsSorted, final ArrayList<AlbumEntry> photoAlbumsSorted, final Integer cameraAlbumIdFinal, final AlbumEntry allMediaAlbumFinal, final AlbumEntry allPhotosAlbumFinal, final AlbumEntry allVideosAlbumFinal, int delay) {
if (broadcastPhotosRunnable != null) {
Expand Down
170 changes: 170 additions & 0 deletions TMessagesProj/src/main/java/org/telegram/ui/ChatActivity.java
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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<Long> quickSendDismissedIds = new java.util.HashSet<>();
Comment on lines +511 to +513

Copilot AI Apr 19, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

quickSendDismissedIds is an unbounded HashSet that accumulates every sent/dismissed MediaStore ID for the lifetime of the ChatActivity. Over long sessions this can grow without limit; consider capping it (e.g., LRU of last N IDs) or clearing it when leaving the chat / after a successful send.

Copilot uses AI. Check for mistakes.
private ActionBarMenuItem.Item hideTitleItem;
/* private float pagedownButtonEnterProgress;
private float searchUpDownEnterProgress;
Expand Down Expand Up @@ -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();
Expand Down Expand Up @@ -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) {
Expand Down Expand Up @@ -29757,6 +29770,8 @@ public void onShow(Bulletin bulletin) {
if (starReactionsOverlay != null) {
starReactionsOverlay.bringToFront();
}

checkQuickSendMediaPopup();
}

public float getPullingDownOffset() {
Expand All @@ -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 {
Comment on lines +29807 to +29814

Copilot AI Apr 19, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The MediaStore query uses DATE_ADDED + " > ?" with seconds precision. In ChatActivity, sinceSec is set to the pause timestamp in seconds, so photos added within the same second as onPause() can be missed. Consider using ">= ?" or subtracting 1s from sinceSec when querying to avoid off-by-one-second misses.

Copilot uses AI. Check for mistakes.
// 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<Integer, Integer> 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<Object> 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();
Expand Down Expand Up @@ -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);
Expand Down
Loading
Loading