Skip to content

Commit 2cbc01d

Browse files
committed
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
1 parent 9b98b03 commit 2cbc01d

2 files changed

Lines changed: 289 additions & 0 deletions

File tree

app/src/main/java/org/thoughtcrime/securesms/util/SaveAttachmentUtil.kt

Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ import android.os.Environment
1717
import android.provider.MediaStore
1818
import android.webkit.MimeTypeMap
1919
import androidx.core.content.contentValuesOf
20+
import androidx.exifinterface.media.ExifInterface
2021
import kotlinx.coroutines.Dispatchers
2122
import kotlinx.coroutines.withContext
2223
import org.signal.core.ui.util.StorageUtil
@@ -26,11 +27,13 @@ import org.signal.core.util.logging.logI
2627
import org.thoughtcrime.securesms.R
2728
import org.thoughtcrime.securesms.dependencies.AppDependencies
2829
import org.thoughtcrime.securesms.mms.PartAuthority
30+
import org.thoughtcrime.securesms.video.Mp4TimestampUtil
2931
import java.io.File
3032
import java.io.FileOutputStream
3133
import java.io.IOException
3234
import java.text.NumberFormat
3335
import java.text.SimpleDateFormat
36+
import java.util.Locale
3437
import java.util.concurrent.TimeUnit
3538

3639
/**
@@ -89,6 +92,18 @@ object SaveAttachmentUtil {
8992
}
9093
}
9194

95+
if (contentType == "image/jpeg" ||
96+
contentType == "image/png" ||
97+
contentType == "image/webp") {
98+
writeExifDateTime(AppDependencies.application, mediaUri, attachment.date)
99+
} else if (contentType == "video/mp4" ||
100+
contentType == "video/3gpp" ||
101+
contentType == "video/3gpp2" ||
102+
contentType == "video/mp2ts" ||
103+
contentType == "video/quicktime") {
104+
writeMp4CreationTime(AppDependencies.application, mediaUri, attachment.date)
105+
}
106+
92107
if (Build.VERSION.SDK_INT > 28) {
93108
updateValues.put(MediaStore.MediaColumns.IS_PENDING, 0)
94109
}
@@ -108,6 +123,43 @@ object SaveAttachmentUtil {
108123
}
109124
}
110125

126+
private fun writeExifDateTime(context: Context, mediaUri: Uri, timestamp: Long) {
127+
try {
128+
val dateFormat = SimpleDateFormat("yyyy:MM:dd HH:mm:ss", Locale.US)
129+
val formattedDate = dateFormat.format(timestamp)
130+
131+
if (mediaUri.scheme == ContentResolver.SCHEME_FILE) {
132+
applyExifTimestamps(ExifInterface(mediaUri.path!!), formattedDate)
133+
} else {
134+
context.contentResolver.openFileDescriptor(mediaUri, "rw")?.use { pfd ->
135+
applyExifTimestamps(ExifInterface(pfd.fileDescriptor), formattedDate)
136+
}
137+
}
138+
} catch (e: Exception) {
139+
Log.w(TAG, "Failed to write EXIF date metadata", e)
140+
}
141+
}
142+
143+
private fun applyExifTimestamps(exif: ExifInterface, formattedDate: String) {
144+
exif.setAttribute(ExifInterface.TAG_DATETIME, formattedDate)
145+
exif.setAttribute(ExifInterface.TAG_DATETIME_ORIGINAL, formattedDate)
146+
exif.saveAttributes()
147+
}
148+
149+
private fun writeMp4CreationTime(context: Context, mediaUri: Uri, timestamp: Long) {
150+
try {
151+
if (mediaUri.scheme == ContentResolver.SCHEME_FILE) {
152+
Mp4TimestampUtil.setCreationTime(File(mediaUri.path!!), timestamp)
153+
} else {
154+
context.contentResolver.openFileDescriptor(mediaUri, "rw")?.use { pfd ->
155+
Mp4TimestampUtil.setCreationTime(pfd.fileDescriptor, timestamp)
156+
}
157+
}
158+
} catch (e: Exception) {
159+
Log.w(TAG, "Failed to write MP4 creation time", e)
160+
}
161+
}
162+
111163
private fun getMediaStoreContentUriForType(contentType: String): Uri {
112164
return when {
113165
contentType.startsWith("video/") -> StorageUtil.getVideoUri()
Lines changed: 237 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,237 @@
1+
/*
2+
* Copyright 2026 Signal Messenger, LLC
3+
* SPDX-License-Identifier: AGPL-3.0-only
4+
*/
5+
6+
package org.thoughtcrime.securesms.video;
7+
8+
import android.system.ErrnoException;
9+
import android.system.Os;
10+
import android.system.OsConstants;
11+
12+
import androidx.annotation.NonNull;
13+
import androidx.annotation.Nullable;
14+
15+
import org.signal.core.util.logging.Log;
16+
17+
import java.io.File;
18+
import java.io.FileDescriptor;
19+
import java.io.IOException;
20+
import java.io.InterruptedIOException;
21+
import java.io.RandomAccessFile;
22+
import java.nio.ByteBuffer;
23+
import java.nio.ByteOrder;
24+
import java.nio.charset.StandardCharsets;
25+
26+
/**
27+
* Utility to patch creation/modification timestamps in an MP4 file's container
28+
* metadata (mvhd, tkhd, mdhd boxes) without loading the entire file into memory.
29+
* <p>
30+
* Only reads box headers (8-16 bytes each) to navigate the structure, then writes
31+
* the timestamp fields in-place. Safe for arbitrarily large video files.
32+
*/
33+
public final class Mp4TimestampUtil {
34+
35+
private static final String TAG = Log.tag(Mp4TimestampUtil.class);
36+
37+
/** Seconds between 1904-01-01 and 1970-01-01 (MP4 epoch offset). */
38+
private static final long MP4_EPOCH_OFFSET = 2082844800L;
39+
40+
private static final String BOX_MOOV = "moov";
41+
private static final String BOX_TRAK = "trak";
42+
private static final String BOX_MDIA = "mdia";
43+
private static final String BOX_MVHD = "mvhd";
44+
private static final String BOX_TKHD = "tkhd";
45+
private static final String BOX_MDHD = "mdhd";
46+
47+
private Mp4TimestampUtil() {}
48+
49+
/**
50+
* Updates creation and modification timestamps in the MP4 container metadata.
51+
*
52+
* @param file The MP4 file to modify in-place.
53+
* @param timestamp Milliseconds since Unix epoch.
54+
*/
55+
public static void setCreationTime(@NonNull File file, long timestamp) throws IOException {
56+
try (RandomAccessFile raf = new RandomAccessFile(file, "rw")) {
57+
patchTimestamps(raf.getFD(), timestamp);
58+
}
59+
}
60+
61+
/**
62+
* Updates creation and modification timestamps in the MP4 container metadata.
63+
*
64+
* @param fd A readable and writable file descriptor for the MP4 file.
65+
* @param timestamp Milliseconds since Unix epoch.
66+
*/
67+
public static void setCreationTime(@NonNull FileDescriptor fd, long timestamp) throws IOException {
68+
patchTimestamps(fd, timestamp);
69+
}
70+
71+
private static void patchTimestamps(@NonNull FileDescriptor fd, long timestamp) throws IOException {
72+
long mp4Time = (timestamp / 1000) + MP4_EPOCH_OFFSET;
73+
74+
try {
75+
long moovOffset = findTopLevelBox(fd, BOX_MOOV);
76+
if (moovOffset < 0) {
77+
Log.w(TAG, "No moov box found");
78+
return;
79+
}
80+
81+
long moovSize = readBoxSize(fd, moovOffset);
82+
if (moovSize < 8) return;
83+
84+
long moovEnd = moovOffset + moovSize;
85+
long moovChildStart = moovOffset + boxHeaderSize(fd, moovOffset);
86+
87+
patchFullBoxTimestamps(fd, moovChildStart, moovEnd, BOX_MVHD, mp4Time);
88+
patchTrackBoxes(fd, moovChildStart, moovEnd, mp4Time);
89+
} catch (ErrnoException | InterruptedIOException e) {
90+
throw new IOException("Failed to patch MP4 timestamps", e);
91+
}
92+
}
93+
94+
private static void patchTrackBoxes(@NonNull FileDescriptor fd, long searchStart,
95+
long searchEnd, long mp4Time)
96+
throws ErrnoException, InterruptedIOException {
97+
long pos = searchStart;
98+
while (pos < searchEnd) {
99+
BoxInfo box = readBoxInfo(fd, pos);
100+
if (box == null || box.size < 8) break;
101+
102+
if (BOX_TRAK.equals(box.type)) {
103+
long trakChildStart = pos + box.headerSize;
104+
long trakEnd = pos + box.size;
105+
106+
patchFullBoxTimestamps(fd, trakChildStart, trakEnd, BOX_TKHD, mp4Time);
107+
108+
long mdiaOffset = findChildBox(fd, trakChildStart, trakEnd, BOX_MDIA);
109+
if (mdiaOffset >= 0) {
110+
long mdiaSize = readBoxSize(fd, mdiaOffset);
111+
long mdiaChildStart = mdiaOffset + boxHeaderSize(fd, mdiaOffset);
112+
long mdiaEnd = mdiaOffset + mdiaSize;
113+
114+
patchFullBoxTimestamps(fd, mdiaChildStart, mdiaEnd, BOX_MDHD, mp4Time);
115+
}
116+
}
117+
118+
pos += box.size;
119+
}
120+
}
121+
122+
/**
123+
* Finds a child box of the given type and patches its creation/modification timestamps in-place.
124+
* Works for FullBox types (mvhd, tkhd, mdhd) which store version + flags after the header,
125+
* followed by creation_time and modification_time.
126+
*/
127+
private static void patchFullBoxTimestamps(@NonNull FileDescriptor fd, long searchStart,
128+
long searchEnd, @NonNull String boxType,
129+
long mp4Time) throws ErrnoException, InterruptedIOException {
130+
long boxOffset = findChildBox(fd, searchStart, searchEnd, boxType);
131+
if (boxOffset < 0) return;
132+
133+
BoxInfo info = readBoxInfo(fd, boxOffset);
134+
if (info == null) return;
135+
136+
// FullBox layout: [header] [version: 1 byte] [flags: 3 bytes] [creation_time] [modification_time] ...
137+
long versionOffset = boxOffset + info.headerSize;
138+
byte[] versionBuf = new byte[1];
139+
pread(fd, versionBuf, versionOffset);
140+
int version = versionBuf[0] & 0xFF;
141+
142+
long timestampOffset = versionOffset + 4;
143+
Os.lseek(fd, timestampOffset, OsConstants.SEEK_SET);
144+
145+
if (version >= 1) {
146+
ByteBuffer buf = ByteBuffer.allocate(16).order(ByteOrder.BIG_ENDIAN);
147+
buf.putLong(mp4Time);
148+
buf.putLong(mp4Time);
149+
Os.write(fd, buf.array(), 0, 16);
150+
} else {
151+
ByteBuffer buf = ByteBuffer.allocate(8).order(ByteOrder.BIG_ENDIAN);
152+
buf.putInt((int) mp4Time);
153+
buf.putInt((int) mp4Time);
154+
Os.write(fd, buf.array(), 0, 8);
155+
}
156+
}
157+
158+
private static long findTopLevelBox(@NonNull FileDescriptor fd, @NonNull String type) throws ErrnoException, InterruptedIOException {
159+
long pos = 0;
160+
while (true) {
161+
BoxInfo box = readBoxInfo(fd, pos);
162+
if (box == null || box.size < 8) return -1;
163+
if (type.equals(box.type)) return pos;
164+
pos += box.size;
165+
}
166+
}
167+
168+
private static long findChildBox(@NonNull FileDescriptor fd, long start, long end, @NonNull String type) throws ErrnoException, InterruptedIOException {
169+
long pos = start;
170+
while (pos + 8 <= end) {
171+
BoxInfo box = readBoxInfo(fd, pos);
172+
if (box == null || box.size < 8) return -1;
173+
if (type.equals(box.type)) return pos;
174+
pos += box.size;
175+
}
176+
return -1;
177+
}
178+
179+
private static long readBoxSize(@NonNull FileDescriptor fd, long offset) throws ErrnoException, InterruptedIOException {
180+
BoxInfo info = readBoxInfo(fd, offset);
181+
return info != null ? info.size : -1;
182+
}
183+
184+
private static int boxHeaderSize(@NonNull FileDescriptor fd, long offset) throws ErrnoException, InterruptedIOException {
185+
byte[] buf = new byte[4];
186+
pread(fd, buf, offset);
187+
long rawSize = ByteBuffer.wrap(buf).order(ByteOrder.BIG_ENDIAN).getInt() & 0xFFFFFFFFL;
188+
return rawSize == 1 ? 16 : 8;
189+
}
190+
191+
@Nullable
192+
private static BoxInfo readBoxInfo(@NonNull FileDescriptor fd, long offset) throws ErrnoException, InterruptedIOException {
193+
byte[] header = new byte[16];
194+
195+
if (pread(fd, header, 0, 8, offset) < 8) return null;
196+
197+
long rawSize = ByteBuffer.wrap(header, 0, 4).order(ByteOrder.BIG_ENDIAN).getInt() & 0xFFFFFFFFL;
198+
String type = new String(header, 4, 4, StandardCharsets.US_ASCII);
199+
int headerSize = 8;
200+
201+
if (rawSize == 1) {
202+
if (pread(fd, header, 8, 8, offset + 8) < 8) return null;
203+
rawSize = ByteBuffer.wrap(header, 8, 8).order(ByteOrder.BIG_ENDIAN).getLong();
204+
headerSize = 16;
205+
}
206+
207+
return new BoxInfo(type, rawSize, headerSize);
208+
}
209+
210+
private static int pread(@NonNull FileDescriptor fd, byte[] buf, long offset) throws ErrnoException, InterruptedIOException {
211+
return pread(fd, buf, 0, buf.length, offset);
212+
}
213+
214+
private static int pread(@NonNull FileDescriptor fd, byte[] buf, int bufOffset, int length, long fileOffset) throws ErrnoException, InterruptedIOException {
215+
Os.lseek(fd, fileOffset, OsConstants.SEEK_SET);
216+
int totalRead = 0;
217+
while (totalRead < length) {
218+
int read = Os.read(fd, buf, bufOffset + totalRead, length - totalRead);
219+
if (read <= 0) break;
220+
totalRead += read;
221+
}
222+
return totalRead;
223+
}
224+
225+
private static final class BoxInfo {
226+
final String type;
227+
final long size;
228+
final int headerSize;
229+
230+
BoxInfo(String type, long size, int headerSize) {
231+
this.type = type;
232+
this.size = size;
233+
this.headerSize = headerSize;
234+
}
235+
}
236+
}
237+

0 commit comments

Comments
 (0)