diff --git a/app/src/dummy/java/eu/faircode/email/FFSend.java b/app/src/dummy/java/eu/faircode/email/FFSend.java index b3317dfe1b..4471c6e1c0 100644 --- a/app/src/dummy/java/eu/faircode/email/FFSend.java +++ b/app/src/dummy/java/eu/faircode/email/FFSend.java @@ -1,8 +1,14 @@ package eu.faircode.email; -import java.io.File; +import androidx.documentfile.provider.DocumentFile; + +import java.io.InputStream; 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; } -} \ No newline at end of file +} diff --git a/app/src/extra/java/eu/faircode/email/FFSend.java b/app/src/extra/java/eu/faircode/email/FFSend.java index 24f6f382ea..6a0867417a 100644 --- a/app/src/extra/java/eu/faircode/email/FFSend.java +++ b/app/src/extra/java/eu/faircode/email/FFSend.java @@ -1,7 +1,11 @@ package eu.faircode.email; +import android.net.Uri; +import android.text.TextUtils; import android.util.Base64; +import androidx.documentfile.provider.DocumentFile; + import com.neovisionaries.ws.client.WebSocket; import com.neovisionaries.ws.client.WebSocketAdapter; import com.neovisionaries.ws.client.WebSocketFactory; @@ -13,8 +17,6 @@ import org.json.JSONArray; import org.json.JSONException; import org.json.JSONObject; -import java.io.File; -import java.io.FileInputStream; import java.io.FileNotFoundException; import java.io.InputStream; import java.nio.ByteBuffer; @@ -37,11 +39,8 @@ import javax.crypto.spec.GCMParameterSpec; import javax.crypto.spec.SecretKeySpec; 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 - - // wss://send.vis.ee/api/ws + // https://github.com/nneonneo/ffsend/blob/master/ffsend.py /* curl --request POST \ @@ -50,17 +49,23 @@ public class FFSend { --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/"; - public static void upload(File file, int dLimit, int timeLimit, String uri) throws Throwable { + private static final int FF_TIMEOUT = 20 * 1000; + + public static String upload(InputStream is, DocumentFile dfile, int dLimit, int timeLimit, String server) throws Throwable { + String result; SecureRandom rnd = new SecureRandom(); byte[] secret = new byte[16]; 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); List queue = Collections.synchronizedList(new ArrayList<>()); @@ -87,8 +92,9 @@ public class FFSend { JSONObject jreply = new JSONObject(queue.remove(0)); Log.i("FFSend reply=" + jreply); - Log.i("FFSend url=" + jreply.getString("url") + - "#" + Base64.encodeToString(secret, Base64.URL_SAFE | Base64.NO_PADDING | Base64.NO_WRAP)); + result = jreply.getString("url") + + "#" + 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. // 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)); // TODO zero length files - try (InputStream is = new FileInputStream(file)) { - int len; - long size = 0; - long fileSize = file.length(); - // content any length up to rs-17 octets - while ((len = is.read(buffer, 0, buffer.length - 17)) > 0) { - Log.i("FFSend read=" + len); + int len; + long size = 0; + long fileSize = dfile.length(); + // content any length up to rs-17 octets + while ((len = is.read(buffer, 0, buffer.length - 17)) > 0) { + Log.i("FFSend read=" + len); - // add a delimiter octet (0x01 or 0x02) - // 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, - // all other records have a padding delimiter octet value of 1. - size += len; - if (size == fileSize) - buffer[len++] = 0x02; - else { - buffer[len++] = 0x01; - while (len < buffer.length - 17) - 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++; + // add a delimiter octet (0x01 or 0x02) + // 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, + // all other records have a padding delimiter octet value of 1. + size += len; + if (size == fileSize) + buffer[len++] = 0x02; + else { + buffer[len++] = 0x01; + while (len < buffer.length - 17) + buffer[len++] = 0x00; } + 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"); sem.tryAcquire(FF_TIMEOUT, TimeUnit.MILLISECONDS); @@ -185,13 +189,18 @@ public class FFSend { } finally { 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 { - String fileName = file.getName(); - long fileSize = file.length(); - String mimeType = Helper.guessMimeType(fileName); + String fileName = dfile.getName(); + long fileSize = dfile.length(); + String mimeType = dfile.getType(); + + if (TextUtils.isEmpty(mimeType)) + mimeType = Helper.guessMimeType(fileName); JSONObject jfile = new JSONObject(); jfile.put("name", fileName); @@ -237,4 +246,4 @@ public class FFSend { return jupload; } -} \ No newline at end of file +} diff --git a/app/src/main/java/eu/faircode/email/FragmentDialogInsertLink.java b/app/src/main/java/eu/faircode/email/FragmentDialogInsertLink.java index e1c8722ee9..54fe072c89 100644 --- a/app/src/main/java/eu/faircode/email/FragmentDialogInsertLink.java +++ b/app/src/main/java/eu/faircode/email/FragmentDialogInsertLink.java @@ -23,8 +23,12 @@ import static android.app.Activity.RESULT_OK; import android.app.Dialog; import android.content.ClipboardManager; +import android.content.ContentResolver; import android.content.Context; import android.content.DialogInterface; +import android.content.Intent; +import android.content.SharedPreferences; +import android.content.pm.PackageManager; import android.net.Uri; import android.os.Bundle; import android.text.Editable; @@ -41,10 +45,13 @@ import android.widget.TextView; import androidx.annotation.NonNull; import androidx.annotation.Nullable; import androidx.appcompat.app.AlertDialog; +import androidx.documentfile.provider.DocumentFile; +import androidx.preference.PreferenceManager; import org.jsoup.nodes.Document; import org.jsoup.nodes.Element; +import java.io.FileNotFoundException; import java.io.IOException; import java.io.InputStream; import java.net.HttpURLConnection; @@ -54,9 +61,12 @@ import java.nio.charset.StandardCharsets; public class FragmentDialogInsertLink extends FragmentDialogBase { private EditText etLink; private EditText etTitle; + private Button btnUpload; + private ProgressBar pbUpload; private static final int METADATA_CONNECT_TIMEOUT = 10 * 1000; // milliseconds private static final int METADATA_READ_TIMEOUT = 15 * 1000; // milliseconds + private static final int REQUEST_FFSEND = 1; @Override public void onSaveInstanceState(@NonNull Bundle outState) { @@ -79,6 +89,8 @@ public class FragmentDialogInsertLink extends FragmentDialogBase { etTitle = view.findViewById(R.id.etTitle); final Button btnMetadata = view.findViewById(R.id.btnMetadata); final ProgressBar pbWait = view.findViewById(R.id.pbWait); + btnUpload = view.findViewById(R.id.btnUpload); + pbUpload = view.findViewById(R.id.pbUpload); etLink.addTextChangedListener(new TextWatcher() { @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) { String link = (uri == null ? "https://" : uri.toString()); etLink.setText(link); @@ -234,7 +257,9 @@ public class FragmentDialogInsertLink extends FragmentDialogBase { etTitle.setText(savedInstanceState.getString("fair:text")); } + btnUpload.setVisibility(BuildConfig.PLAY_STORE_RELEASE ? View.GONE : View.VISIBLE); pbWait.setVisibility(View.GONE); + pbUpload.setVisibility(View.GONE); return new AlertDialog.Builder(context) .setView(view) @@ -256,6 +281,76 @@ public class FragmentDialogInsertLink extends FragmentDialogBase { .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() { + @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 String title; private String description; diff --git a/app/src/main/java/eu/faircode/email/FragmentOptionsMisc.java b/app/src/main/java/eu/faircode/email/FragmentOptionsMisc.java index 98ff26a5ea..b2c9aac679 100644 --- a/app/src/main/java/eu/faircode/email/FragmentOptionsMisc.java +++ b/app/src/main/java/eu/faircode/email/FragmentOptionsMisc.java @@ -126,6 +126,8 @@ public class FragmentOptionsMisc extends FragmentBase implements SharedPreferenc private TextView tvVirusTotalPrivacy; private EditText etVirusTotal; private ImageButton ibVirusTotal; + private EditText etFFSend; + private ImageButton ibFFSend; private SwitchCompat swUpdates; private ImageButton ibChannelUpdated; private SwitchCompat swCheckWeekly; @@ -215,6 +217,7 @@ public class FragmentOptionsMisc extends FragmentBase implements SharedPreferenc private TextView tvPermissions; private Group grpVirusTotal; + private Group grpFFSend; private Group grpUpdates; private Group grpTest; private CardView cardDebug; @@ -226,11 +229,11 @@ public class FragmentOptionsMisc extends FragmentBase implements SharedPreferenc private final static String[] RESET_OPTIONS = new String[]{ "sort_answers", "shortcuts", "fts", "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", "crash_reports", "cleanup_attachments", - "watchdog", "experiments", "main_log", "protocol", "log_level", "debug", "leak_canary", "test1", - "test2", "test3", "test4", "test5", + "watchdog", "experiments", "main_log", "protocol", "log_level", "debug", "leak_canary", + "test1", "test2", "test3", "test4", "test5", "work_manager", // "external_storage", "query_threads", "wal", "sqlite_checkpoints", "sqlite_analyze", "sqlite_auto_vacuum", "sqlite_cache", "chunk_size", "thread_range", "undo_manager", @@ -317,6 +320,8 @@ public class FragmentOptionsMisc extends FragmentBase implements SharedPreferenc tvVirusTotalPrivacy = view.findViewById(R.id.tvVirusTotalPrivacy); etVirusTotal = view.findViewById(R.id.etVirusTotal); ibVirusTotal = view.findViewById(R.id.ibVirusTotal); + etFFSend = view.findViewById(R.id.etFFSend); + ibFFSend = view.findViewById(R.id.ibFFSend); swUpdates = view.findViewById(R.id.swUpdates); ibChannelUpdated = view.findViewById(R.id.ibChannelUpdated); swCheckWeekly = view.findViewById(R.id.swWeekly); @@ -406,6 +411,7 @@ public class FragmentOptionsMisc extends FragmentBase implements SharedPreferenc tvPermissions = view.findViewById(R.id.tvPermissions); grpVirusTotal = view.findViewById(R.id.grpVirusTotal); + grpFFSend = view.findViewById(R.id.grpFFSend); grpUpdates = view.findViewById(R.id.grpUpdates); grpTest = view.findViewById(R.id.grpTest); 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() { @Override 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); + grpFFSend.setVisibility(BuildConfig.PLAY_STORE_RELEASE ? View.GONE : View.VISIBLE); grpUpdates.setVisibility(!BuildConfig.DEBUG && (Helper.isPlayStoreInstall() || !Helper.hasValidFingerprint(getContext())) @@ -1706,7 +1742,7 @@ public class FragmentOptionsMisc extends FragmentBase implements SharedPreferenc if ("last_cleanup".equals(key)) setLastCleanup(prefs.getLong(key, -1)); - if ("vt_apikey".equals(key)) + if ("vt_apikey".equals(key) || "ff_send".equals(key)) return; setOptions(); @@ -1853,6 +1889,7 @@ public class FragmentOptionsMisc extends FragmentBase implements SharedPreferenc swDeepL.setChecked(prefs.getBoolean("deepl_enabled", false)); swVirusTotal.setChecked(prefs.getBoolean("vt_enabled", false)); etVirusTotal.setText(prefs.getString("vt_apikey", null)); + etFFSend.setText(prefs.getString("ff_send", null)); swUpdates.setChecked(prefs.getBoolean("updates", true)); swCheckWeekly.setChecked(prefs.getBoolean("weekly", Helper.hasPlayStore(getContext()))); swCheckWeekly.setEnabled(swUpdates.isChecked()); diff --git a/app/src/main/res/layout/dialog_insert_link.xml b/app/src/main/res/layout/dialog_insert_link.xml index 50e8759098..66c4ae2132 100644 --- a/app/src/main/res/layout/dialog_insert_link.xml +++ b/app/src/main/res/layout/dialog_insert_link.xml @@ -115,5 +115,28 @@ android:textStyle="italic" app:layout_constraintStart_toStartOf="parent" app:layout_constraintTop_toBottomOf="@id/btnMetadata" /> + +