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