FFSend integration

pull/209/head
M66B 2 years ago
parent 1660aa2e01
commit 992e858ea1

@ -1,8 +1,14 @@
package eu.faircode.email; package eu.faircode.email;
import java.io.File; import androidx.documentfile.provider.DocumentFile;
import java.io.InputStream;
public class FFSend { public class FFSend {
public static void upload(File file, int dLimit, int timeLimit, String uri) { static final String FF_DEFAULT_SERVER = "";
static final String FF_INSTANCES = "";
public static String upload(InputStream is, DocumentFile dfile, int dLimit, int timeLimit, String server) {
return null;
} }
} }

@ -1,7 +1,11 @@
package eu.faircode.email; package eu.faircode.email;
import android.net.Uri;
import android.text.TextUtils;
import android.util.Base64; import android.util.Base64;
import androidx.documentfile.provider.DocumentFile;
import com.neovisionaries.ws.client.WebSocket; import com.neovisionaries.ws.client.WebSocket;
import com.neovisionaries.ws.client.WebSocketAdapter; import com.neovisionaries.ws.client.WebSocketAdapter;
import com.neovisionaries.ws.client.WebSocketFactory; import com.neovisionaries.ws.client.WebSocketFactory;
@ -13,8 +17,6 @@ import org.json.JSONArray;
import org.json.JSONException; import org.json.JSONException;
import org.json.JSONObject; import org.json.JSONObject;
import java.io.File;
import java.io.FileInputStream;
import java.io.FileNotFoundException; import java.io.FileNotFoundException;
import java.io.InputStream; import java.io.InputStream;
import java.nio.ByteBuffer; import java.nio.ByteBuffer;
@ -37,11 +39,8 @@ import javax.crypto.spec.GCMParameterSpec;
import javax.crypto.spec.SecretKeySpec; import javax.crypto.spec.SecretKeySpec;
public class FFSend { public class FFSend {
// https://github.com/timvisee/send-instances/
// https://github.com/nneonneo/ffsend/blob/master/ffsend.py
// https://datatracker.ietf.org/doc/html/rfc8188 // https://datatracker.ietf.org/doc/html/rfc8188
// https://github.com/nneonneo/ffsend/blob/master/ffsend.py
// wss://send.vis.ee/api/ws
/* /*
curl --request POST \ curl --request POST \
@ -50,17 +49,23 @@ public class FFSend {
--data '{"owner_token": "..."}' --data '{"owner_token": "..."}'
*/ */
private static final int FF_TIMEOUT = 5000; static final String FF_DEFAULT_SERVER = "https://send.vis.ee/";
static final String FF_INSTANCES = "https://github.com/timvisee/send-instances/";
private static final int FF_TIMEOUT = 20 * 1000;
public static void upload(File file, int dLimit, int timeLimit, String uri) throws Throwable { public static String upload(InputStream is, DocumentFile dfile, int dLimit, int timeLimit, String server) throws Throwable {
String result;
SecureRandom rnd = new SecureRandom(); SecureRandom rnd = new SecureRandom();
byte[] secret = new byte[16]; byte[] secret = new byte[16];
rnd.nextBytes(secret); rnd.nextBytes(secret);
JSONObject jupload = getMetadata(file, dLimit, timeLimit, secret); JSONObject jupload = getMetadata(dfile, dLimit, timeLimit, secret);
WebSocket ws = new WebSocketFactory().createSocket(uri, FF_TIMEOUT); Uri uri = Uri.parse("wss://" + Uri.parse(server).getHost() + "/api/ws");
WebSocket ws = new WebSocketFactory().createSocket(uri.toString(), FF_TIMEOUT);
Semaphore sem = new Semaphore(0); Semaphore sem = new Semaphore(0);
List<String> queue = Collections.synchronizedList(new ArrayList<>()); List<String> queue = Collections.synchronizedList(new ArrayList<>());
@ -87,8 +92,9 @@ public class FFSend {
JSONObject jreply = new JSONObject(queue.remove(0)); JSONObject jreply = new JSONObject(queue.remove(0));
Log.i("FFSend reply=" + jreply); Log.i("FFSend reply=" + jreply);
Log.i("FFSend url=" + jreply.getString("url") + result = jreply.getString("url") +
"#" + Base64.encodeToString(secret, Base64.URL_SAFE | Base64.NO_PADDING | Base64.NO_WRAP)); "#" + Base64.encodeToString(secret, Base64.URL_SAFE | Base64.NO_PADDING | Base64.NO_WRAP);
Log.i("FFSend url=" + result);
// The record sequence number (SEQ) is a 96-bit unsigned integer in network byte order that starts at zero. // The record sequence number (SEQ) is a 96-bit unsigned integer in network byte order that starts at zero.
// network byte order = transmitting the most significant byte first // network byte order = transmitting the most significant byte first
@ -132,49 +138,47 @@ public class FFSend {
Log.i("FFSend nonce base=" + Helper.hex(nonce_base)); Log.i("FFSend nonce base=" + Helper.hex(nonce_base));
// TODO zero length files // TODO zero length files
try (InputStream is = new FileInputStream(file)) { int len;
int len; long size = 0;
long size = 0; long fileSize = dfile.length();
long fileSize = file.length(); // content any length up to rs-17 octets
// content any length up to rs-17 octets while ((len = is.read(buffer, 0, buffer.length - 17)) > 0) {
while ((len = is.read(buffer, 0, buffer.length - 17)) > 0) { Log.i("FFSend read=" + len);
Log.i("FFSend read=" + len);
// add a delimiter octet (0x01 or 0x02)
// add a delimiter octet (0x01 or 0x02) // then 0x00-valued octets to rs-16 (or less on the last record)
// then 0x00-valued octets to rs-16 (or less on the last record) // The last record uses a padding delimiter octet set to the value 2,
// The last record uses a padding delimiter octet set to the value 2, // all other records have a padding delimiter octet value of 1.
// all other records have a padding delimiter octet value of 1. size += len;
size += len; if (size == fileSize)
if (size == fileSize) buffer[len++] = 0x02;
buffer[len++] = 0x02; else {
else { buffer[len++] = 0x01;
buffer[len++] = 0x01; while (len < buffer.length - 17)
while (len < buffer.length - 17) buffer[len++] = 0x00;
buffer[len++] = 0x00;
}
Log.i("FFSend record len=" + len + " size=" + size + "/" + fileSize);
byte[] nonce = Arrays.copyOf(nonce_base, nonce_base.length);
ByteBuffer xor = ByteBuffer.wrap(nonce);
xor.putInt(nonce.length - 4, xor.getInt(nonce.length - 4) ^ seq);
Log.i("FFSend seq=" + seq + " nonce=" + Helper.hex(nonce));
// encrypt with AEAD_AES_128_GCM; final size is rs; the last record can be smaller
Cipher cipher = Cipher.getInstance("AES/GCM/NoPadding");
cipher.init(Cipher.ENCRYPT_MODE,
new SecretKeySpec(cek, "AES"),
new GCMParameterSpec(16 * 8, nonce));
byte[] message = cipher.doFinal(buffer, 0, len);
Log.i("FFSend message len=" + message.length);
ws.sendBinary(message);
seq++;
} }
Log.i("FFSend record len=" + len + " size=" + size + "/" + fileSize);
Log.i("FFSend EOF size=" + size);
ws.sendBinary(new byte[]{0}, true); byte[] nonce = Arrays.copyOf(nonce_base, nonce_base.length);
ByteBuffer xor = ByteBuffer.wrap(nonce);
xor.putInt(nonce.length - 4, xor.getInt(nonce.length - 4) ^ seq);
Log.i("FFSend seq=" + seq + " nonce=" + Helper.hex(nonce));
// encrypt with AEAD_AES_128_GCM; final size is rs; the last record can be smaller
Cipher cipher = Cipher.getInstance("AES/GCM/NoPadding");
cipher.init(Cipher.ENCRYPT_MODE,
new SecretKeySpec(cek, "AES"),
new GCMParameterSpec(16 * 8, nonce));
byte[] message = cipher.doFinal(buffer, 0, len);
Log.i("FFSend message len=" + message.length);
ws.sendBinary(message);
seq++;
} }
Log.i("FFSend EOF size=" + size);
ws.sendBinary(new byte[]{0}, true);
Log.i("FFSend wait confirm"); Log.i("FFSend wait confirm");
sem.tryAcquire(FF_TIMEOUT, TimeUnit.MILLISECONDS); sem.tryAcquire(FF_TIMEOUT, TimeUnit.MILLISECONDS);
@ -185,13 +189,18 @@ public class FFSend {
} finally { } finally {
ws.disconnect(); ws.disconnect();
} }
return result;
} }
private static JSONObject getMetadata(File file, int dLimit, int timeLimit, byte[] secret) private static JSONObject getMetadata(DocumentFile dfile, int dLimit, int timeLimit, byte[] secret)
throws JSONException, NoSuchPaddingException, NoSuchAlgorithmException, InvalidAlgorithmParameterException, InvalidKeyException, IllegalBlockSizeException, BadPaddingException { throws JSONException, NoSuchPaddingException, NoSuchAlgorithmException, InvalidAlgorithmParameterException, InvalidKeyException, IllegalBlockSizeException, BadPaddingException {
String fileName = file.getName(); String fileName = dfile.getName();
long fileSize = file.length(); long fileSize = dfile.length();
String mimeType = Helper.guessMimeType(fileName); String mimeType = dfile.getType();
if (TextUtils.isEmpty(mimeType))
mimeType = Helper.guessMimeType(fileName);
JSONObject jfile = new JSONObject(); JSONObject jfile = new JSONObject();
jfile.put("name", fileName); jfile.put("name", fileName);

@ -23,8 +23,12 @@ import static android.app.Activity.RESULT_OK;
import android.app.Dialog; import android.app.Dialog;
import android.content.ClipboardManager; import android.content.ClipboardManager;
import android.content.ContentResolver;
import android.content.Context; import android.content.Context;
import android.content.DialogInterface; import android.content.DialogInterface;
import android.content.Intent;
import android.content.SharedPreferences;
import android.content.pm.PackageManager;
import android.net.Uri; import android.net.Uri;
import android.os.Bundle; import android.os.Bundle;
import android.text.Editable; import android.text.Editable;
@ -41,10 +45,13 @@ import android.widget.TextView;
import androidx.annotation.NonNull; import androidx.annotation.NonNull;
import androidx.annotation.Nullable; import androidx.annotation.Nullable;
import androidx.appcompat.app.AlertDialog; import androidx.appcompat.app.AlertDialog;
import androidx.documentfile.provider.DocumentFile;
import androidx.preference.PreferenceManager;
import org.jsoup.nodes.Document; import org.jsoup.nodes.Document;
import org.jsoup.nodes.Element; import org.jsoup.nodes.Element;
import java.io.FileNotFoundException;
import java.io.IOException; import java.io.IOException;
import java.io.InputStream; import java.io.InputStream;
import java.net.HttpURLConnection; import java.net.HttpURLConnection;
@ -54,9 +61,12 @@ import java.nio.charset.StandardCharsets;
public class FragmentDialogInsertLink extends FragmentDialogBase { public class FragmentDialogInsertLink extends FragmentDialogBase {
private EditText etLink; private EditText etLink;
private EditText etTitle; private EditText etTitle;
private Button btnUpload;
private ProgressBar pbUpload;
private static final int METADATA_CONNECT_TIMEOUT = 10 * 1000; // milliseconds private static final int METADATA_CONNECT_TIMEOUT = 10 * 1000; // milliseconds
private static final int METADATA_READ_TIMEOUT = 15 * 1000; // milliseconds private static final int METADATA_READ_TIMEOUT = 15 * 1000; // milliseconds
private static final int REQUEST_FFSEND = 1;
@Override @Override
public void onSaveInstanceState(@NonNull Bundle outState) { public void onSaveInstanceState(@NonNull Bundle outState) {
@ -79,6 +89,8 @@ public class FragmentDialogInsertLink extends FragmentDialogBase {
etTitle = view.findViewById(R.id.etTitle); etTitle = view.findViewById(R.id.etTitle);
final Button btnMetadata = view.findViewById(R.id.btnMetadata); final Button btnMetadata = view.findViewById(R.id.btnMetadata);
final ProgressBar pbWait = view.findViewById(R.id.pbWait); final ProgressBar pbWait = view.findViewById(R.id.pbWait);
btnUpload = view.findViewById(R.id.btnUpload);
pbUpload = view.findViewById(R.id.pbUpload);
etLink.addTextChangedListener(new TextWatcher() { etLink.addTextChangedListener(new TextWatcher() {
@Override @Override
@ -225,6 +237,17 @@ public class FragmentDialogInsertLink extends FragmentDialogBase {
} }
}); });
btnUpload.setOnClickListener(new View.OnClickListener() {
@Override
public void onClick(View view) {
Intent intent = new Intent(Intent.ACTION_GET_CONTENT);
intent.addCategory(Intent.CATEGORY_OPENABLE);
intent.addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION);
intent.setType("*/*");
startActivityForResult(Helper.getChooser(getContext(), intent), REQUEST_FFSEND);
}
});
if (savedInstanceState == null) { if (savedInstanceState == null) {
String link = (uri == null ? "https://" : uri.toString()); String link = (uri == null ? "https://" : uri.toString());
etLink.setText(link); etLink.setText(link);
@ -234,7 +257,9 @@ public class FragmentDialogInsertLink extends FragmentDialogBase {
etTitle.setText(savedInstanceState.getString("fair:text")); etTitle.setText(savedInstanceState.getString("fair:text"));
} }
btnUpload.setVisibility(BuildConfig.PLAY_STORE_RELEASE ? View.GONE : View.VISIBLE);
pbWait.setVisibility(View.GONE); pbWait.setVisibility(View.GONE);
pbUpload.setVisibility(View.GONE);
return new AlertDialog.Builder(context) return new AlertDialog.Builder(context)
.setView(view) .setView(view)
@ -256,6 +281,76 @@ public class FragmentDialogInsertLink extends FragmentDialogBase {
.create(); .create();
} }
@Override
public void onActivityResult(int requestCode, int resultCode, @Nullable Intent data) {
super.onActivityResult(requestCode, resultCode, data);
try {
switch (requestCode) {
case REQUEST_FFSEND:
if (resultCode == RESULT_OK && data != null)
onFFSend(data.getData());
break;
}
} catch (Throwable ex) {
Log.e(ex);
}
}
private void onFFSend(Uri uri) {
Bundle args = new Bundle();
args.putParcelable("uri", uri);
new SimpleTask<String>() {
@Override
protected void onPreExecute(Bundle args) {
btnUpload.setEnabled(false);
pbUpload.setVisibility(View.VISIBLE);
}
@Override
protected void onPostExecute(Bundle args) {
btnUpload.setEnabled(true);
pbUpload.setVisibility(View.GONE);
}
@Override
protected String onExecute(Context context, Bundle args) throws Throwable {
Uri uri = args.getParcelable("uri");
if (uri == null)
throw new FileNotFoundException("uri");
if (!"content".equals(uri.getScheme()))
throw new FileNotFoundException("content");
DocumentFile dfile = DocumentFile.fromSingleUri(context, uri);
if (dfile == null)
throw new FileNotFoundException("dfile");
args.putString("title", dfile.getName());
SharedPreferences prefs = PreferenceManager.getDefaultSharedPreferences(context);
String server = prefs.getString("ff_send", FFSend.FF_DEFAULT_SERVER);
ContentResolver resolver = context.getContentResolver();
try (InputStream is = resolver.openInputStream(uri)) {
return FFSend.upload(is, dfile, 10, 60 * 60, server);
}
}
@Override
protected void onExecuted(Bundle args, String ffsend) {
etLink.setText(ffsend);
etTitle.setText(args.getString("title"));
}
@Override
protected void onException(Bundle args, Throwable ex) {
Log.unexpectedError(getParentFragmentManager(), ex);
}
}.execute(this, args, "ffsend");
}
private static class OpenGraph { private static class OpenGraph {
private String title; private String title;
private String description; private String description;

@ -126,6 +126,8 @@ public class FragmentOptionsMisc extends FragmentBase implements SharedPreferenc
private TextView tvVirusTotalPrivacy; private TextView tvVirusTotalPrivacy;
private EditText etVirusTotal; private EditText etVirusTotal;
private ImageButton ibVirusTotal; private ImageButton ibVirusTotal;
private EditText etFFSend;
private ImageButton ibFFSend;
private SwitchCompat swUpdates; private SwitchCompat swUpdates;
private ImageButton ibChannelUpdated; private ImageButton ibChannelUpdated;
private SwitchCompat swCheckWeekly; private SwitchCompat swCheckWeekly;
@ -215,6 +217,7 @@ public class FragmentOptionsMisc extends FragmentBase implements SharedPreferenc
private TextView tvPermissions; private TextView tvPermissions;
private Group grpVirusTotal; private Group grpVirusTotal;
private Group grpFFSend;
private Group grpUpdates; private Group grpUpdates;
private Group grpTest; private Group grpTest;
private CardView cardDebug; private CardView cardDebug;
@ -226,11 +229,11 @@ public class FragmentOptionsMisc extends FragmentBase implements SharedPreferenc
private final static String[] RESET_OPTIONS = new String[]{ private final static String[] RESET_OPTIONS = new String[]{
"sort_answers", "shortcuts", "fts", "sort_answers", "shortcuts", "fts",
"classification", "class_min_probability", "class_min_difference", "classification", "class_min_probability", "class_min_difference",
"language", "lt_enabled", "deepl_enabled", "vt_enabled", "vt_apikey", "language", "lt_enabled", "deepl_enabled", "vt_enabled", "vt_apikey", "ff_send",
"updates", "weekly", "show_changelog", "updates", "weekly", "show_changelog",
"crash_reports", "cleanup_attachments", "crash_reports", "cleanup_attachments",
"watchdog", "experiments", "main_log", "protocol", "log_level", "debug", "leak_canary", "test1", "watchdog", "experiments", "main_log", "protocol", "log_level", "debug", "leak_canary",
"test2", "test3", "test4", "test5", "test1", "test2", "test3", "test4", "test5",
"work_manager", // "external_storage", "work_manager", // "external_storage",
"query_threads", "wal", "sqlite_checkpoints", "sqlite_analyze", "sqlite_auto_vacuum", "sqlite_cache", "query_threads", "wal", "sqlite_checkpoints", "sqlite_analyze", "sqlite_auto_vacuum", "sqlite_cache",
"chunk_size", "thread_range", "undo_manager", "chunk_size", "thread_range", "undo_manager",
@ -317,6 +320,8 @@ public class FragmentOptionsMisc extends FragmentBase implements SharedPreferenc
tvVirusTotalPrivacy = view.findViewById(R.id.tvVirusTotalPrivacy); tvVirusTotalPrivacy = view.findViewById(R.id.tvVirusTotalPrivacy);
etVirusTotal = view.findViewById(R.id.etVirusTotal); etVirusTotal = view.findViewById(R.id.etVirusTotal);
ibVirusTotal = view.findViewById(R.id.ibVirusTotal); ibVirusTotal = view.findViewById(R.id.ibVirusTotal);
etFFSend = view.findViewById(R.id.etFFSend);
ibFFSend = view.findViewById(R.id.ibFFSend);
swUpdates = view.findViewById(R.id.swUpdates); swUpdates = view.findViewById(R.id.swUpdates);
ibChannelUpdated = view.findViewById(R.id.ibChannelUpdated); ibChannelUpdated = view.findViewById(R.id.ibChannelUpdated);
swCheckWeekly = view.findViewById(R.id.swWeekly); swCheckWeekly = view.findViewById(R.id.swWeekly);
@ -406,6 +411,7 @@ public class FragmentOptionsMisc extends FragmentBase implements SharedPreferenc
tvPermissions = view.findViewById(R.id.tvPermissions); tvPermissions = view.findViewById(R.id.tvPermissions);
grpVirusTotal = view.findViewById(R.id.grpVirusTotal); grpVirusTotal = view.findViewById(R.id.grpVirusTotal);
grpFFSend = view.findViewById(R.id.grpFFSend);
grpUpdates = view.findViewById(R.id.grpUpdates); grpUpdates = view.findViewById(R.id.grpUpdates);
grpTest = view.findViewById(R.id.grpTest); grpTest = view.findViewById(R.id.grpTest);
cardDebug = view.findViewById(R.id.cardDebug); cardDebug = view.findViewById(R.id.cardDebug);
@ -689,6 +695,35 @@ public class FragmentOptionsMisc extends FragmentBase implements SharedPreferenc
} }
}); });
etFFSend.setHint(FFSend.FF_DEFAULT_SERVER);
etFFSend.addTextChangedListener(new TextWatcher() {
@Override
public void beforeTextChanged(CharSequence s, int start, int count, int after) {
// Do nothing
}
@Override
public void onTextChanged(CharSequence s, int start, int before, int count) {
// Do nothing
}
@Override
public void afterTextChanged(Editable s) {
String apikey = s.toString().trim();
if (TextUtils.isEmpty(apikey))
prefs.edit().remove("ff_send").apply();
else
prefs.edit().putString("ff_send", apikey).apply();
}
});
ibFFSend.setOnClickListener(new View.OnClickListener() {
@Override
public void onClick(View v) {
Helper.view(v.getContext(), Uri.parse(FFSend.FF_INSTANCES), true);
}
});
swUpdates.setOnCheckedChangeListener(new CompoundButton.OnCheckedChangeListener() { swUpdates.setOnCheckedChangeListener(new CompoundButton.OnCheckedChangeListener() {
@Override @Override
public void onCheckedChanged(CompoundButton compoundButton, boolean checked) { public void onCheckedChanged(CompoundButton compoundButton, boolean checked) {
@ -1632,6 +1667,7 @@ public class FragmentOptionsMisc extends FragmentBase implements SharedPreferenc
}); });
grpVirusTotal.setVisibility(BuildConfig.PLAY_STORE_RELEASE ? View.GONE : View.VISIBLE); grpVirusTotal.setVisibility(BuildConfig.PLAY_STORE_RELEASE ? View.GONE : View.VISIBLE);
grpFFSend.setVisibility(BuildConfig.PLAY_STORE_RELEASE ? View.GONE : View.VISIBLE);
grpUpdates.setVisibility(!BuildConfig.DEBUG && grpUpdates.setVisibility(!BuildConfig.DEBUG &&
(Helper.isPlayStoreInstall() || !Helper.hasValidFingerprint(getContext())) (Helper.isPlayStoreInstall() || !Helper.hasValidFingerprint(getContext()))
@ -1706,7 +1742,7 @@ public class FragmentOptionsMisc extends FragmentBase implements SharedPreferenc
if ("last_cleanup".equals(key)) if ("last_cleanup".equals(key))
setLastCleanup(prefs.getLong(key, -1)); setLastCleanup(prefs.getLong(key, -1));
if ("vt_apikey".equals(key)) if ("vt_apikey".equals(key) || "ff_send".equals(key))
return; return;
setOptions(); setOptions();
@ -1853,6 +1889,7 @@ public class FragmentOptionsMisc extends FragmentBase implements SharedPreferenc
swDeepL.setChecked(prefs.getBoolean("deepl_enabled", false)); swDeepL.setChecked(prefs.getBoolean("deepl_enabled", false));
swVirusTotal.setChecked(prefs.getBoolean("vt_enabled", false)); swVirusTotal.setChecked(prefs.getBoolean("vt_enabled", false));
etVirusTotal.setText(prefs.getString("vt_apikey", null)); etVirusTotal.setText(prefs.getString("vt_apikey", null));
etFFSend.setText(prefs.getString("ff_send", null));
swUpdates.setChecked(prefs.getBoolean("updates", true)); swUpdates.setChecked(prefs.getBoolean("updates", true));
swCheckWeekly.setChecked(prefs.getBoolean("weekly", Helper.hasPlayStore(getContext()))); swCheckWeekly.setChecked(prefs.getBoolean("weekly", Helper.hasPlayStore(getContext())));
swCheckWeekly.setEnabled(swUpdates.isChecked()); swCheckWeekly.setEnabled(swUpdates.isChecked());

@ -115,5 +115,28 @@
android:textStyle="italic" android:textStyle="italic"
app:layout_constraintStart_toStartOf="parent" app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@id/btnMetadata" /> app:layout_constraintTop_toBottomOf="@id/btnMetadata" />
<Button
android:id="@+id/btnUpload"
style="?android:attr/buttonStyleSmall"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginTop="12dp"
android:drawableEnd="@drawable/twotone_file_upload_24"
android:drawablePadding="6dp"
android:text="@string/title_style_link_ffsend"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@id/tvMetadataRemark" />
<eu.faircode.email.ContentLoadingProgressBar
android:id="@+id/pbUpload"
style="@style/Base.Widget.AppCompat.ProgressBar"
android:layout_width="24dp"
android:layout_height="24dp"
android:layout_marginStart="12dp"
android:indeterminate="true"
app:layout_constraintBottom_toBottomOf="@id/btnUpload"
app:layout_constraintStart_toEndOf="@id/btnUpload"
app:layout_constraintTop_toTopOf="@id/btnUpload" />
</androidx.constraintlayout.widget.ConstraintLayout> </androidx.constraintlayout.widget.ConstraintLayout>
</eu.faircode.email.ScrollViewEx> </eu.faircode.email.ScrollViewEx>

@ -439,6 +439,42 @@
app:layout_constraintTop_toBottomOf="@id/etVirusTotal" app:layout_constraintTop_toBottomOf="@id/etVirusTotal"
app:srcCompat="@drawable/twotone_info_24" /> app:srcCompat="@drawable/twotone_info_24" />
<TextView
android:id="@+id/tvFFSend"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginTop="12dp"
android:layout_marginEnd="48dp"
android:text="@string/title_advanced_ffsend_server"
android:textAppearance="@style/TextAppearance.AppCompat.Small"
android:textColor="?android:attr/textColorPrimary"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@id/ibVirusTotal" />
<EditText
android:id="@+id/etFFSend"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginTop="12dp"
android:hint="wss://send.vis.ee/api/ws"
android:inputType="textUri"
android:textAppearance="@style/TextAppearance.AppCompat.Small"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@id/tvFFSend" />
<ImageButton
android:id="@+id/ibFFSend"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginTop="12dp"
android:contentDescription="@string/title_info"
android:tooltipText="@string/title_info"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@id/etFFSend"
app:srcCompat="@drawable/twotone_info_24" />
<androidx.appcompat.widget.SwitchCompat <androidx.appcompat.widget.SwitchCompat
android:id="@+id/swUpdates" android:id="@+id/swUpdates"
android:layout_width="0dp" android:layout_width="0dp"
@ -448,7 +484,7 @@
android:text="@string/title_advanced_updates" android:text="@string/title_advanced_updates"
app:layout_constraintEnd_toEndOf="parent" app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent" app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@id/ibVirusTotal" app:layout_constraintTop_toBottomOf="@id/ibFFSend"
app:switchPadding="12dp" /> app:switchPadding="12dp" />
<ImageButton <ImageButton
@ -600,6 +636,12 @@
android:layout_height="0dp" android:layout_height="0dp"
app:constraint_referenced_ids="swVirusTotal,tvVirusTotalPrivacy,ibVirusTotal,etVirusTotal" /> app:constraint_referenced_ids="swVirusTotal,tvVirusTotalPrivacy,ibVirusTotal,etVirusTotal" />
<androidx.constraintlayout.widget.Group
android:id="@+id/grpFFSend"
android:layout_width="0dp"
android:layout_height="0dp"
app:constraint_referenced_ids="tvFFSend,etFFSend,ibFFSend" />
<androidx.constraintlayout.widget.Group <androidx.constraintlayout.widget.Group
android:id="@+id/grpUpdates" android:id="@+id/grpUpdates"
android:layout_width="0dp" android:layout_width="0dp"

@ -734,6 +734,7 @@
<string name="title_advanced_lt">LanguageTool integration</string> <string name="title_advanced_lt">LanguageTool integration</string>
<string name="title_advanced_deepl">DeepL integration</string> <string name="title_advanced_deepl">DeepL integration</string>
<string name="title_advanced_virus_total">VirusTotal integration</string> <string name="title_advanced_virus_total">VirusTotal integration</string>
<string name="title_advanced_ffsend_server" translatable="false">FFSend server</string>
<string name="title_advanced_sdcard">I want to use an sdcard</string> <string name="title_advanced_sdcard">I want to use an sdcard</string>
<string name="title_advanced_watchdog">Periodically check if FairEmail is still active</string> <string name="title_advanced_watchdog">Periodically check if FairEmail is still active</string>
<string name="title_advanced_updates">Check for GitHub updates</string> <string name="title_advanced_updates">Check for GitHub updates</string>
@ -1418,6 +1419,7 @@
<string name="title_style_link_title">Title</string> <string name="title_style_link_title">Title</string>
<string name="title_style_link_metadata">Fetch title</string> <string name="title_style_link_metadata">Fetch title</string>
<string name="title_style_link_metadata_remark">This will fetch the title at the entered address</string> <string name="title_style_link_metadata_remark">This will fetch the title at the entered address</string>
<string name="title_style_link_ffsend" translatable="false">FFSend</string>
<string name="title_add_image">Add image</string> <string name="title_add_image">Add image</string>
<string name="title_add_image_inline">Insert</string> <string name="title_add_image_inline">Insert</string>

Loading…
Cancel
Save