1package eu.siacs.conversations.services;
2
3import android.content.Context;
4import android.content.SharedPreferences;
5import android.net.Uri;
6import android.preference.PreferenceManager;
7import android.util.Log;
8import androidx.annotation.NonNull;
9import com.otaliastudios.transcoder.Transcoder;
10import com.otaliastudios.transcoder.TranscoderListener;
11import eu.siacs.conversations.Config;
12import eu.siacs.conversations.R;
13import eu.siacs.conversations.crypto.PgpEngine;
14import eu.siacs.conversations.entities.DownloadableFile;
15import eu.siacs.conversations.entities.Message;
16import eu.siacs.conversations.persistance.FileBackend;
17import eu.siacs.conversations.ui.UiCallback;
18import eu.siacs.conversations.utils.MimeUtils;
19import eu.siacs.conversations.utils.TranscoderStrategies;
20import java.io.File;
21import java.io.FileNotFoundException;
22import java.util.Objects;
23import java.util.concurrent.ExecutionException;
24import java.util.concurrent.Future;
25
26public class AttachFileToConversationRunnable implements Runnable, TranscoderListener {
27
28 private final XmppConnectionService mXmppConnectionService;
29 private final Message message;
30 private final Uri uri;
31 private final String type;
32 private final UiCallback<Message> callback;
33 private final boolean isVideoMessage;
34 private final long originalFileSize;
35 private int currentProgress = -1;
36
37 AttachFileToConversationRunnable(
38 XmppConnectionService xmppConnectionService,
39 Uri uri,
40 String type,
41 Message message,
42 UiCallback<Message> callback) {
43 this.uri = uri;
44 this.type = type;
45 this.mXmppConnectionService = xmppConnectionService;
46 this.message = message;
47 this.callback = callback;
48 final String mimeType =
49 MimeUtils.guessMimeTypeFromUriAndMime(mXmppConnectionService, uri, type);
50 final int autoAcceptFileSize =
51 mXmppConnectionService.getResources().getInteger(R.integer.auto_accept_filesize);
52 this.originalFileSize = FileBackend.getFileSize(mXmppConnectionService, uri);
53 this.isVideoMessage =
54 (mimeType != null && mimeType.startsWith("video/"))
55 && originalFileSize > autoAcceptFileSize
56 && !"uncompressed".equals(getVideoCompression());
57 }
58
59 boolean isVideoMessage() {
60 return this.isVideoMessage;
61 }
62
63 private void processAsFile() {
64 final String path = mXmppConnectionService.getFileBackend().getOriginalPath(uri);
65 if (path != null && !FileBackend.isPathBlacklisted(path)) {
66 message.setRelativeFilePath(path);
67 mXmppConnectionService.getFileBackend().updateFileParams(message);
68 if (message.getEncryption() == Message.ENCRYPTION_DECRYPTED) {
69 mXmppConnectionService.getPgpEngine().encrypt(message, callback);
70 } else {
71 mXmppConnectionService.sendMessage(message);
72 callback.success(message);
73 }
74 } else {
75 try {
76 mXmppConnectionService
77 .getFileBackend()
78 .copyFileToPrivateStorage(message, uri, type);
79 mXmppConnectionService.getFileBackend().updateFileParams(message);
80 if (message.getEncryption() == Message.ENCRYPTION_DECRYPTED) {
81 final PgpEngine pgpEngine = mXmppConnectionService.getPgpEngine();
82 if (pgpEngine != null) {
83 pgpEngine.encrypt(message, callback);
84 } else if (callback != null) {
85 callback.error(R.string.unable_to_connect_to_keychain, null);
86 }
87 } else {
88 mXmppConnectionService.sendMessage(message);
89 callback.success(message);
90 }
91 } catch (final FileBackend.FileCopyException e) {
92 callback.error(e.getResId(), message);
93 }
94 }
95 }
96
97 private void fallbackToProcessAsFile() {
98 final var file = mXmppConnectionService.getFileBackend().getFile(message);
99 if (file.exists() && file.delete()) {
100 Log.d(Config.LOGTAG, "deleted preexisting file " + file.getAbsolutePath());
101 }
102 XmppConnectionService.FILE_ATTACHMENT_EXECUTOR.execute(this::processAsFile);
103 }
104
105 private void processAsVideo() throws FileNotFoundException {
106 Log.d(Config.LOGTAG, "processing file as video");
107 mXmppConnectionService.startOngoingVideoTranscodingForegroundNotification();
108 mXmppConnectionService
109 .getFileBackend()
110 .setupRelativeFilePath(message, String.format("%s.%s", message.getUuid(), "mp4"));
111 final DownloadableFile file = mXmppConnectionService.getFileBackend().getFile(message);
112 if (Objects.requireNonNull(file.getParentFile()).mkdirs()) {
113 Log.d(Config.LOGTAG, "created parent directory for video file");
114 }
115
116 final boolean highQuality = "720".equals(getVideoCompression());
117
118 final Future<Void> future;
119 try {
120 future =
121 Transcoder.into(file.getAbsolutePath())
122 .addDataSource(mXmppConnectionService, uri)
123 .setVideoTrackStrategy(
124 highQuality
125 ? TranscoderStrategies.VIDEO_720P
126 : TranscoderStrategies.VIDEO_360P)
127 .setAudioTrackStrategy(
128 highQuality
129 ? TranscoderStrategies.AUDIO_HQ
130 : TranscoderStrategies.AUDIO_MQ)
131 .setListener(this)
132 .transcode();
133 } catch (final RuntimeException e) {
134 // transcode can already throw if there is an invalid file format or a platform bug
135 mXmppConnectionService.stopOngoingVideoTranscodingForegroundNotification();
136 fallbackToProcessAsFile();
137 return;
138 }
139 try {
140 future.get();
141 } catch (final InterruptedException e) {
142 throw new AssertionError(e);
143 } catch (final ExecutionException e) {
144 if (e.getCause() instanceof Error) {
145 mXmppConnectionService.stopOngoingVideoTranscodingForegroundNotification();
146 fallbackToProcessAsFile();
147 } else {
148 Log.d(Config.LOGTAG, "ignoring execution exception. Handled by onTranscodeFiled()");
149 }
150 }
151 }
152
153 @Override
154 public void onTranscodeProgress(double progress) {
155 final int p = (int) Math.round(progress * 100);
156 if (p > currentProgress) {
157 currentProgress = p;
158 mXmppConnectionService
159 .getNotificationService()
160 .updateFileAddingNotification(p, message);
161 }
162 }
163
164 @Override
165 public void onTranscodeCompleted(int successCode) {
166 mXmppConnectionService.stopOngoingVideoTranscodingForegroundNotification();
167 final File file = mXmppConnectionService.getFileBackend().getFile(message);
168 long convertedFileSize = mXmppConnectionService.getFileBackend().getFile(message).getSize();
169 Log.d(
170 Config.LOGTAG,
171 "originalFileSize=" + originalFileSize + " convertedFileSize=" + convertedFileSize);
172 if (originalFileSize != 0 && convertedFileSize >= originalFileSize) {
173 if (file.delete()) {
174 Log.d(
175 Config.LOGTAG,
176 "original file size was smaller. deleting and processing as file");
177 fallbackToProcessAsFile();
178 return;
179 } else {
180 Log.d(Config.LOGTAG, "unable to delete converted file");
181 }
182 }
183 mXmppConnectionService.getFileBackend().updateFileParams(message);
184 if (message.getEncryption() == Message.ENCRYPTION_DECRYPTED) {
185 mXmppConnectionService.getPgpEngine().encrypt(message, callback);
186 } else {
187 mXmppConnectionService.sendMessage(message);
188 callback.success(message);
189 }
190 }
191
192 @Override
193 public void onTranscodeCanceled() {
194 mXmppConnectionService.stopOngoingVideoTranscodingForegroundNotification();
195 fallbackToProcessAsFile();
196 }
197
198 @Override
199 public void onTranscodeFailed(@NonNull final Throwable exception) {
200 mXmppConnectionService.stopOngoingVideoTranscodingForegroundNotification();
201 Log.d(Config.LOGTAG, "video transcoding failed", exception);
202 fallbackToProcessAsFile();
203 }
204
205 @Override
206 public void run() {
207 if (this.isVideoMessage()) {
208 try {
209 processAsVideo();
210 } catch (final FileNotFoundException e) {
211 processAsFile();
212 }
213 } else {
214 processAsFile();
215 }
216 }
217
218 private String getVideoCompression() {
219 return getVideoCompression(mXmppConnectionService);
220 }
221
222 public static String getVideoCompression(final Context context) {
223 final SharedPreferences preferences =
224 PreferenceManager.getDefaultSharedPreferences(context);
225 return preferences.getString(
226 "video_compression", context.getResources().getString(R.string.video_compression));
227 }
228}