From 2cbc01dddec6b5afa66ba93e308d32fba71808f2 Mon Sep 17 00:00:00 2001 From: Trigus42 <59501676+Trigus42@users.noreply.github.com> Date: Fri, 24 Apr 2026 03:33:07 +0200 Subject: [PATCH] Write original message timestamp into saved attachment file metadata. Set EXIF DateTime and DateTimeOriginal for images (JPEG/PNG/WebP) and update MP4 mvhd/tkhd/mdhd creation_time for videos (mp4/3gpp/quicktime etc.) when saving attachments to the device gallery. This ensures gallery apps sort saved media by the original sent date rather than the save date. Fixes #14584 --- .../securesms/util/SaveAttachmentUtil.kt | 52 ++++ .../securesms/video/Mp4TimestampUtil.java | 237 ++++++++++++++++++ 2 files changed, 289 insertions(+) create mode 100644 lib/video/src/main/java/org/thoughtcrime/securesms/video/Mp4TimestampUtil.java diff --git a/app/src/main/java/org/thoughtcrime/securesms/util/SaveAttachmentUtil.kt b/app/src/main/java/org/thoughtcrime/securesms/util/SaveAttachmentUtil.kt index 92684951b3b..d8e8fe0db1e 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/util/SaveAttachmentUtil.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/util/SaveAttachmentUtil.kt @@ -17,6 +17,7 @@ import android.os.Environment import android.provider.MediaStore import android.webkit.MimeTypeMap import androidx.core.content.contentValuesOf +import androidx.exifinterface.media.ExifInterface import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.withContext import org.signal.core.ui.util.StorageUtil @@ -26,11 +27,13 @@ import org.signal.core.util.logging.logI import org.thoughtcrime.securesms.R import org.thoughtcrime.securesms.dependencies.AppDependencies import org.thoughtcrime.securesms.mms.PartAuthority +import org.thoughtcrime.securesms.video.Mp4TimestampUtil import java.io.File import java.io.FileOutputStream import java.io.IOException import java.text.NumberFormat import java.text.SimpleDateFormat +import java.util.Locale import java.util.concurrent.TimeUnit /** @@ -89,6 +92,18 @@ object SaveAttachmentUtil { } } + if (contentType == "image/jpeg" || + contentType == "image/png" || + contentType == "image/webp") { + writeExifDateTime(AppDependencies.application, mediaUri, attachment.date) + } else if (contentType == "video/mp4" || + contentType == "video/3gpp" || + contentType == "video/3gpp2" || + contentType == "video/mp2ts" || + contentType == "video/quicktime") { + writeMp4CreationTime(AppDependencies.application, mediaUri, attachment.date) + } + if (Build.VERSION.SDK_INT > 28) { updateValues.put(MediaStore.MediaColumns.IS_PENDING, 0) } @@ -108,6 +123,43 @@ object SaveAttachmentUtil { } } + private fun writeExifDateTime(context: Context, mediaUri: Uri, timestamp: Long) { + try { + val dateFormat = SimpleDateFormat("yyyy:MM:dd HH:mm:ss", Locale.US) + val formattedDate = dateFormat.format(timestamp) + + if (mediaUri.scheme == ContentResolver.SCHEME_FILE) { + applyExifTimestamps(ExifInterface(mediaUri.path!!), formattedDate) + } else { + context.contentResolver.openFileDescriptor(mediaUri, "rw")?.use { pfd -> + applyExifTimestamps(ExifInterface(pfd.fileDescriptor), formattedDate) + } + } + } catch (e: Exception) { + Log.w(TAG, "Failed to write EXIF date metadata", e) + } + } + + private fun applyExifTimestamps(exif: ExifInterface, formattedDate: String) { + exif.setAttribute(ExifInterface.TAG_DATETIME, formattedDate) + exif.setAttribute(ExifInterface.TAG_DATETIME_ORIGINAL, formattedDate) + exif.saveAttributes() + } + + private fun writeMp4CreationTime(context: Context, mediaUri: Uri, timestamp: Long) { + try { + if (mediaUri.scheme == ContentResolver.SCHEME_FILE) { + Mp4TimestampUtil.setCreationTime(File(mediaUri.path!!), timestamp) + } else { + context.contentResolver.openFileDescriptor(mediaUri, "rw")?.use { pfd -> + Mp4TimestampUtil.setCreationTime(pfd.fileDescriptor, timestamp) + } + } + } catch (e: Exception) { + Log.w(TAG, "Failed to write MP4 creation time", e) + } + } + private fun getMediaStoreContentUriForType(contentType: String): Uri { return when { contentType.startsWith("video/") -> StorageUtil.getVideoUri() diff --git a/lib/video/src/main/java/org/thoughtcrime/securesms/video/Mp4TimestampUtil.java b/lib/video/src/main/java/org/thoughtcrime/securesms/video/Mp4TimestampUtil.java new file mode 100644 index 00000000000..45c47b4a7cb --- /dev/null +++ b/lib/video/src/main/java/org/thoughtcrime/securesms/video/Mp4TimestampUtil.java @@ -0,0 +1,237 @@ +/* + * Copyright 2026 Signal Messenger, LLC + * SPDX-License-Identifier: AGPL-3.0-only + */ + +package org.thoughtcrime.securesms.video; + +import android.system.ErrnoException; +import android.system.Os; +import android.system.OsConstants; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; + +import org.signal.core.util.logging.Log; + +import java.io.File; +import java.io.FileDescriptor; +import java.io.IOException; +import java.io.InterruptedIOException; +import java.io.RandomAccessFile; +import java.nio.ByteBuffer; +import java.nio.ByteOrder; +import java.nio.charset.StandardCharsets; + +/** + * Utility to patch creation/modification timestamps in an MP4 file's container + * metadata (mvhd, tkhd, mdhd boxes) without loading the entire file into memory. + *
+ * Only reads box headers (8-16 bytes each) to navigate the structure, then writes + * the timestamp fields in-place. Safe for arbitrarily large video files. + */ +public final class Mp4TimestampUtil { + + private static final String TAG = Log.tag(Mp4TimestampUtil.class); + + /** Seconds between 1904-01-01 and 1970-01-01 (MP4 epoch offset). */ + private static final long MP4_EPOCH_OFFSET = 2082844800L; + + private static final String BOX_MOOV = "moov"; + private static final String BOX_TRAK = "trak"; + private static final String BOX_MDIA = "mdia"; + private static final String BOX_MVHD = "mvhd"; + private static final String BOX_TKHD = "tkhd"; + private static final String BOX_MDHD = "mdhd"; + + private Mp4TimestampUtil() {} + + /** + * Updates creation and modification timestamps in the MP4 container metadata. + * + * @param file The MP4 file to modify in-place. + * @param timestamp Milliseconds since Unix epoch. + */ + public static void setCreationTime(@NonNull File file, long timestamp) throws IOException { + try (RandomAccessFile raf = new RandomAccessFile(file, "rw")) { + patchTimestamps(raf.getFD(), timestamp); + } + } + + /** + * Updates creation and modification timestamps in the MP4 container metadata. + * + * @param fd A readable and writable file descriptor for the MP4 file. + * @param timestamp Milliseconds since Unix epoch. + */ + public static void setCreationTime(@NonNull FileDescriptor fd, long timestamp) throws IOException { + patchTimestamps(fd, timestamp); + } + + private static void patchTimestamps(@NonNull FileDescriptor fd, long timestamp) throws IOException { + long mp4Time = (timestamp / 1000) + MP4_EPOCH_OFFSET; + + try { + long moovOffset = findTopLevelBox(fd, BOX_MOOV); + if (moovOffset < 0) { + Log.w(TAG, "No moov box found"); + return; + } + + long moovSize = readBoxSize(fd, moovOffset); + if (moovSize < 8) return; + + long moovEnd = moovOffset + moovSize; + long moovChildStart = moovOffset + boxHeaderSize(fd, moovOffset); + + patchFullBoxTimestamps(fd, moovChildStart, moovEnd, BOX_MVHD, mp4Time); + patchTrackBoxes(fd, moovChildStart, moovEnd, mp4Time); + } catch (ErrnoException | InterruptedIOException e) { + throw new IOException("Failed to patch MP4 timestamps", e); + } + } + + private static void patchTrackBoxes(@NonNull FileDescriptor fd, long searchStart, + long searchEnd, long mp4Time) + throws ErrnoException, InterruptedIOException { + long pos = searchStart; + while (pos < searchEnd) { + BoxInfo box = readBoxInfo(fd, pos); + if (box == null || box.size < 8) break; + + if (BOX_TRAK.equals(box.type)) { + long trakChildStart = pos + box.headerSize; + long trakEnd = pos + box.size; + + patchFullBoxTimestamps(fd, trakChildStart, trakEnd, BOX_TKHD, mp4Time); + + long mdiaOffset = findChildBox(fd, trakChildStart, trakEnd, BOX_MDIA); + if (mdiaOffset >= 0) { + long mdiaSize = readBoxSize(fd, mdiaOffset); + long mdiaChildStart = mdiaOffset + boxHeaderSize(fd, mdiaOffset); + long mdiaEnd = mdiaOffset + mdiaSize; + + patchFullBoxTimestamps(fd, mdiaChildStart, mdiaEnd, BOX_MDHD, mp4Time); + } + } + + pos += box.size; + } + } + + /** + * Finds a child box of the given type and patches its creation/modification timestamps in-place. + * Works for FullBox types (mvhd, tkhd, mdhd) which store version + flags after the header, + * followed by creation_time and modification_time. + */ + private static void patchFullBoxTimestamps(@NonNull FileDescriptor fd, long searchStart, + long searchEnd, @NonNull String boxType, + long mp4Time) throws ErrnoException, InterruptedIOException { + long boxOffset = findChildBox(fd, searchStart, searchEnd, boxType); + if (boxOffset < 0) return; + + BoxInfo info = readBoxInfo(fd, boxOffset); + if (info == null) return; + + // FullBox layout: [header] [version: 1 byte] [flags: 3 bytes] [creation_time] [modification_time] ... + long versionOffset = boxOffset + info.headerSize; + byte[] versionBuf = new byte[1]; + pread(fd, versionBuf, versionOffset); + int version = versionBuf[0] & 0xFF; + + long timestampOffset = versionOffset + 4; + Os.lseek(fd, timestampOffset, OsConstants.SEEK_SET); + + if (version >= 1) { + ByteBuffer buf = ByteBuffer.allocate(16).order(ByteOrder.BIG_ENDIAN); + buf.putLong(mp4Time); + buf.putLong(mp4Time); + Os.write(fd, buf.array(), 0, 16); + } else { + ByteBuffer buf = ByteBuffer.allocate(8).order(ByteOrder.BIG_ENDIAN); + buf.putInt((int) mp4Time); + buf.putInt((int) mp4Time); + Os.write(fd, buf.array(), 0, 8); + } + } + + private static long findTopLevelBox(@NonNull FileDescriptor fd, @NonNull String type) throws ErrnoException, InterruptedIOException { + long pos = 0; + while (true) { + BoxInfo box = readBoxInfo(fd, pos); + if (box == null || box.size < 8) return -1; + if (type.equals(box.type)) return pos; + pos += box.size; + } + } + + private static long findChildBox(@NonNull FileDescriptor fd, long start, long end, @NonNull String type) throws ErrnoException, InterruptedIOException { + long pos = start; + while (pos + 8 <= end) { + BoxInfo box = readBoxInfo(fd, pos); + if (box == null || box.size < 8) return -1; + if (type.equals(box.type)) return pos; + pos += box.size; + } + return -1; + } + + private static long readBoxSize(@NonNull FileDescriptor fd, long offset) throws ErrnoException, InterruptedIOException { + BoxInfo info = readBoxInfo(fd, offset); + return info != null ? info.size : -1; + } + + private static int boxHeaderSize(@NonNull FileDescriptor fd, long offset) throws ErrnoException, InterruptedIOException { + byte[] buf = new byte[4]; + pread(fd, buf, offset); + long rawSize = ByteBuffer.wrap(buf).order(ByteOrder.BIG_ENDIAN).getInt() & 0xFFFFFFFFL; + return rawSize == 1 ? 16 : 8; + } + + @Nullable + private static BoxInfo readBoxInfo(@NonNull FileDescriptor fd, long offset) throws ErrnoException, InterruptedIOException { + byte[] header = new byte[16]; + + if (pread(fd, header, 0, 8, offset) < 8) return null; + + long rawSize = ByteBuffer.wrap(header, 0, 4).order(ByteOrder.BIG_ENDIAN).getInt() & 0xFFFFFFFFL; + String type = new String(header, 4, 4, StandardCharsets.US_ASCII); + int headerSize = 8; + + if (rawSize == 1) { + if (pread(fd, header, 8, 8, offset + 8) < 8) return null; + rawSize = ByteBuffer.wrap(header, 8, 8).order(ByteOrder.BIG_ENDIAN).getLong(); + headerSize = 16; + } + + return new BoxInfo(type, rawSize, headerSize); + } + + private static int pread(@NonNull FileDescriptor fd, byte[] buf, long offset) throws ErrnoException, InterruptedIOException { + return pread(fd, buf, 0, buf.length, offset); + } + + private static int pread(@NonNull FileDescriptor fd, byte[] buf, int bufOffset, int length, long fileOffset) throws ErrnoException, InterruptedIOException { + Os.lseek(fd, fileOffset, OsConstants.SEEK_SET); + int totalRead = 0; + while (totalRead < length) { + int read = Os.read(fd, buf, bufOffset + totalRead, length - totalRead); + if (read <= 0) break; + totalRead += read; + } + return totalRead; + } + + private static final class BoxInfo { + final String type; + final long size; + final int headerSize; + + BoxInfo(String type, long size, int headerSize) { + this.type = type; + this.size = size; + this.headerSize = headerSize; + } + } +} +