diff --git a/app/build.gradle b/app/build.gradle index bc86240db6..e0acb126b1 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -213,6 +213,8 @@ android { buildConfigField "String", "CLOUD_EMAIL", "\"cloud@in.faircode.eu\"" buildConfigField "String", "OPENAI_ENDPOINT", "\"https://api.openai.com/v1/\"" buildConfigField "String", "OPENAI_PRIVACY", "\"https://openai.com/policies/privacy-policy\"" + buildConfigField "String", "GEMINI_ENDPOINT", "\"https://generativelanguage.googleapis.com/v1beta/\"" + buildConfigField "String", "GEMINI_PRIVACY", "\"https://policies.google.com/privacy\"" buildConfigField "String", "FDROID", "\"https://f-droid.org/packages/%s/\"" } large { @@ -235,6 +237,8 @@ android { buildConfigField "String", "CLOUD_EMAIL", "\"cloud@in.faircode.eu\"" buildConfigField "String", "OPENAI_ENDPOINT", "\"https://api.openai.com/v1/\"" buildConfigField "String", "OPENAI_PRIVACY", "\"https://openai.com/policies/privacy-policy\"" + buildConfigField "String", "GEMINI_ENDPOINT", "\"https://generativelanguage.googleapis.com/v1beta/\"" + buildConfigField "String", "GEMINI_PRIVACY", "\"https://policies.google.com/privacy\"" buildConfigField "String", "FDROID", "\"https://f-droid.org/packages/%s/\"" } fdroid { @@ -266,6 +270,8 @@ android { buildConfigField "String", "CLOUD_EMAIL", "\"cloud@in.faircode.eu\"" buildConfigField "String", "OPENAI_ENDPOINT", "\"https://api.openai.com/v1/\"" buildConfigField "String", "OPENAI_PRIVACY", "\"https://openai.com/policies/privacy-policy\"" + buildConfigField "String", "GEMINI_ENDPOINT", "\"https://generativelanguage.googleapis.com/v1beta/\"" + buildConfigField "String", "GEMINI_PRIVACY", "\"https://policies.google.com/privacy\"" buildConfigField "String", "FDROID", "\"https://f-droid.org/packages/%s/\"" } play { @@ -289,6 +295,8 @@ android { buildConfigField "String", "CLOUD_EMAIL", "\"\"" buildConfigField "String", "OPENAI_ENDPOINT", "\"\"" buildConfigField "String", "OPENAI_PRIVACY", "\"\"" + buildConfigField "String", "GEMINI_ENDPOINT", "\"\"" + buildConfigField "String", "GEMINI_PRIVACY", "\"\"" buildConfigField "String", "FDROID", "\"\"" getIsDefault().set(true) } @@ -313,6 +321,8 @@ android { buildConfigField "String", "CLOUD_EMAIL", "\"\"" buildConfigField "String", "OPENAI_ENDPOINT", "\"\"" buildConfigField "String", "OPENAI_PRIVACY", "\"\"" + buildConfigField "String", "GEMINI_ENDPOINT", "\"\"" + buildConfigField "String", "GEMINI_PRIVACY", "\"\"" buildConfigField "String", "FDROID", "\"\"" } } diff --git a/app/src/main/java/eu/faircode/email/FragmentCompose.java b/app/src/main/java/eu/faircode/email/FragmentCompose.java index 3fc08e5027..98e32c625f 100644 --- a/app/src/main/java/eu/faircode/email/FragmentCompose.java +++ b/app/src/main/java/eu/faircode/email/FragmentCompose.java @@ -1878,11 +1878,14 @@ public class FragmentCompose extends FragmentBase { ImageButton ibOpenAi = (ImageButton) infl.inflate(R.layout.action_button, null); ibOpenAi.setId(View.generateViewId()); ibOpenAi.setImageResource(R.drawable.twotone_smart_toy_24); - ibOpenAi.setContentDescription(getString(R.string.title_openai)); + ibOpenAi.setContentDescription("AI"); ibOpenAi.setOnClickListener(new View.OnClickListener() { @Override public void onClick(View view) { - onOpenAi(); + if (OpenAI.isAvailable(view.getContext())) + onOpenAi(); + else if (Gemini.isAvailable(view.getContext())) + onGemini(); } }); menu.findItem(R.id.menu_openai).setActionView(ibOpenAi); @@ -1952,7 +1955,7 @@ public class FragmentCompose extends FragmentBase { menu.findItem(R.id.menu_translate).setVisible(DeepL.isAvailable(context)); menu.findItem(R.id.menu_openai).setEnabled(state == State.LOADED && !chatting); ((ImageButton) menu.findItem(R.id.menu_openai).getActionView()).setEnabled(!chatting); - menu.findItem(R.id.menu_openai).setVisible(OpenAI.isAvailable(context)); + menu.findItem(R.id.menu_openai).setVisible(OpenAI.isAvailable(context) || Gemini.isAvailable(context)); menu.findItem(R.id.menu_zoom).setEnabled(state == State.LOADED); menu.findItem(R.id.menu_style).setEnabled(state == State.LOADED); menu.findItem(R.id.menu_media).setEnabled(state == State.LOADED); @@ -2753,6 +2756,80 @@ public class FragmentCompose extends FragmentBase { }.serial().execute(this, args, "openai"); } + private void onGemini() { + int start = etBody.getSelectionStart(); + int end = etBody.getSelectionEnd(); + boolean selection = (start >= 0 && end > start); + Editable edit = etBody.getText(); + String body = (selection ? edit.subSequence(start, end) : edit).toString().trim(); + + Bundle args = new Bundle(); + args.putLong("id", working); + args.putString("body", body); + args.putBoolean("selection", selection); + + new SimpleTask() { + @Override + protected void onPreExecute(Bundle args) { + chatting = true; + invalidateOptionsMenu(); + } + + @Override + protected void onPostExecute(Bundle args) { + chatting = false; + invalidateOptionsMenu(); + } + + @Override + protected String[] onExecute(Context context, Bundle args) throws Throwable { + long id = args.getLong("id"); + String body = args.getString("body"); + boolean selection = args.getBoolean("selection"); + + SharedPreferences prefs = PreferenceManager.getDefaultSharedPreferences(context); + String model = prefs.getString("gemini_model", "gemini-pro"); + + return Gemini.generate(context, model, new String[]{Gemini.truncateParagraphs(body)}); + } + + @Override + protected void onExecuted(Bundle args, String[] result) { + if (result == null || result.length == 0) + return; + + String text = result[0] + .replaceAll("^\\n+", "").replaceAll("\\n+$", ""); + + Editable edit = etBody.getText(); + int start = etBody.getSelectionStart(); + int end = etBody.getSelectionEnd(); + + int index; + if (etBody.hasSelection()) { + edit.delete(start, end); + index = start; + } else + index = end; + + if (index < 0) + index = 0; + if (index > 0 && edit.charAt(index - 1) != '\n') + edit.insert(index++, "\n"); + + edit.insert(index, text + "\n"); + etBody.setSelection(index + text.length() + 1); + + StyleHelper.markAsInserted(edit, index, index + text.length() + 1); + } + + @Override + protected void onException(Bundle args, Throwable ex) { + Log.unexpectedError(getParentFragmentManager(), ex, !(ex instanceof IOException)); + } + }.serial().execute(this, args, "gemini"); + } + private void onTranslate(View anchor) { final Context context = anchor.getContext(); diff --git a/app/src/main/java/eu/faircode/email/FragmentOptionsIntegrations.java b/app/src/main/java/eu/faircode/email/FragmentOptionsIntegrations.java index 9c3322afdf..3c6b5ed641 100644 --- a/app/src/main/java/eu/faircode/email/FragmentOptionsIntegrations.java +++ b/app/src/main/java/eu/faircode/email/FragmentOptionsIntegrations.java @@ -84,10 +84,16 @@ public class FragmentOptionsIntegrations extends FragmentBase implements SharedP private SeekBar sbOpenAiTemperature; private SwitchCompat swOpenAiModeration; private ImageButton ibOpenAi; + private SwitchCompat swGemini; + private TextView tvGeminiPrivacy; + private EditText etGemini; + private TextInputLayout tilGemini; + private EditText etGeminiModel; private CardView cardVirusTotal; private CardView cardSend; private CardView cardOpenAi; + private CardView cardGemini; private NumberFormat NF = NumberFormat.getNumberInstance(); @@ -96,7 +102,8 @@ public class FragmentOptionsIntegrations extends FragmentBase implements SharedP "deepl_enabled", "vt_enabled", "vt_apikey", "send_enabled", "send_host", "send_dlimit", "send_tlimit", - "openai_enabled", "openai_uri", "openai_apikey", "openai_model", "openai_temperature", "openai_moderation" + "openai_enabled", "openai_uri", "openai_apikey", "openai_model", "openai_temperature", "openai_moderation", + "gemini_enabled", "gemini_uri", "gemini_apikey", "gemini_model" )); @Override @@ -122,16 +129,20 @@ public class FragmentOptionsIntegrations extends FragmentBase implements SharedP etLanguageToolUser = view.findViewById(R.id.etLanguageToolUser); tilLanguageToolKey = view.findViewById(R.id.tilLanguageToolKey); ibLanguageTool = view.findViewById(R.id.ibLanguageTool); + swDeepL = view.findViewById(R.id.swDeepL); tvDeepLPrivacy = view.findViewById(R.id.tvDeepLPrivacy); ibDeepL = view.findViewById(R.id.ibDeepL); + swVirusTotal = view.findViewById(R.id.swVirusTotal); tvVirusTotalPrivacy = view.findViewById(R.id.tvVirusTotalPrivacy); tilVirusTotal = view.findViewById(R.id.tilVirusTotal); ibVirusTotal = view.findViewById(R.id.ibVirusTotal); + swSend = view.findViewById(R.id.swSend); etSend = view.findViewById(R.id.etSend); ibSend = view.findViewById(R.id.ibSend); + swOpenAi = view.findViewById(R.id.swOpenAi); tvOpenAiPrivacy = view.findViewById(R.id.tvOpenAiPrivacy); etOpenAi = view.findViewById(R.id.etOpenAi); @@ -142,9 +153,16 @@ public class FragmentOptionsIntegrations extends FragmentBase implements SharedP swOpenAiModeration = view.findViewById(R.id.swOpenAiModeration); ibOpenAi = view.findViewById(R.id.ibOpenAi); + swGemini = view.findViewById(R.id.swGemini); + tvGeminiPrivacy = view.findViewById(R.id.tvGeminiPrivacy); + etGemini = view.findViewById(R.id.etGemini); + tilGemini = view.findViewById(R.id.tilGemini); + etGeminiModel = view.findViewById(R.id.etGeminiModel); + cardVirusTotal = view.findViewById(R.id.cardVirusTotal); cardSend = view.findViewById(R.id.cardSend); cardOpenAi = view.findViewById(R.id.cardOpenAi); + cardGemini = view.findViewById(R.id.cardGemini); setOptions(); @@ -395,6 +413,8 @@ public class FragmentOptionsIntegrations extends FragmentBase implements SharedP etOpenAiModel.setEnabled(checked); sbOpenAiTemperature.setEnabled(checked); swOpenAiModeration.setEnabled(checked); + if (checked) + swGemini.setChecked(false); } }); @@ -502,12 +522,95 @@ public class FragmentOptionsIntegrations extends FragmentBase implements SharedP } }); + swGemini.setOnCheckedChangeListener(new CompoundButton.OnCheckedChangeListener() { + @Override + public void onCheckedChanged(CompoundButton compoundButton, boolean checked) { + prefs.edit().putBoolean("gemini_enabled", checked).apply(); + etGeminiModel.setEnabled(checked); + if (checked) + swOpenAi.setChecked(false); + } + }); + + tvGeminiPrivacy.getPaint().setUnderlineText(true); + tvGeminiPrivacy.setOnClickListener(new View.OnClickListener() { + @Override + public void onClick(View v) { + Helper.view(v.getContext(), Uri.parse(BuildConfig.GEMINI_PRIVACY), true); + } + }); + + etGemini.setHint(BuildConfig.GEMINI_ENDPOINT); + etGemini.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("gemini_uri").apply(); + else + prefs.edit().putString("gemini_uri", apikey).apply(); + } + }); + + tilGemini.getEditText().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("gemini_apikey").apply(); + else + prefs.edit().putString("gemini_apikey", apikey).apply(); + } + }); + + etGeminiModel.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 model = s.toString().trim(); + if (TextUtils.isEmpty(model)) + prefs.edit().remove("gemini_model").apply(); + else + prefs.edit().putString("gemini_model", model).apply(); + } + }); + // Initialize FragmentDialogTheme.setBackground(getContext(), view, false); cardVirusTotal.setVisibility(BuildConfig.PLAY_STORE_RELEASE ? View.GONE : View.VISIBLE); cardSend.setVisibility(BuildConfig.PLAY_STORE_RELEASE ? View.GONE : View.VISIBLE); cardOpenAi.setVisibility(TextUtils.isEmpty(BuildConfig.OPENAI_ENDPOINT) ? View.GONE : View.VISIBLE); + cardGemini.setVisibility(TextUtils.isEmpty(BuildConfig.GEMINI_ENDPOINT) ? View.GONE : View.VISIBLE); PreferenceManager.getDefaultSharedPreferences(getContext()).registerOnSharedPreferenceChangeListener(this); @@ -532,7 +635,10 @@ public class FragmentOptionsIntegrations extends FragmentBase implements SharedP "send_host".equals(key) || "openai_uri".equals(key) || "openai_apikey".equals(key) || - "openai_model".equals(key)) + "openai_model".equals(key) || + "gemini_uri".equals(key) || + "gemini_apikey".equals(key) || + "gemini_model".equals(key)) return; getMainHandler().removeCallbacks(update); @@ -582,11 +688,15 @@ public class FragmentOptionsIntegrations extends FragmentBase implements SharedP etLanguageTool.setText(prefs.getString("lt_uri", null)); etLanguageToolUser.setText(prefs.getString("lt_user", null)); tilLanguageToolKey.getEditText().setText(prefs.getString("lt_key", null)); + swDeepL.setChecked(prefs.getBoolean("deepl_enabled", false)); + swVirusTotal.setChecked(prefs.getBoolean("vt_enabled", false)); tilVirusTotal.getEditText().setText(prefs.getString("vt_apikey", null)); + swSend.setChecked(prefs.getBoolean("send_enabled", false)); etSend.setText(prefs.getString("send_host", null)); + swOpenAi.setChecked(prefs.getBoolean("openai_enabled", false)); etOpenAi.setText(prefs.getString("openai_uri", null)); tilOpenAi.getEditText().setText(prefs.getString("openai_apikey", null)); @@ -600,6 +710,12 @@ public class FragmentOptionsIntegrations extends FragmentBase implements SharedP swOpenAiModeration.setChecked(prefs.getBoolean("openai_moderation", false)); swOpenAiModeration.setEnabled(swOpenAi.isChecked()); + + swGemini.setChecked(prefs.getBoolean("gemini_enabled", false)); + etGemini.setText(prefs.getString("gemini_uri", null)); + tilGemini.getEditText().setText(prefs.getString("gemini_apikey", null)); + etGeminiModel.setText(prefs.getString("gemini_model", null)); + etGeminiModel.setEnabled(swGemini.isChecked()); } catch (Throwable ex) { Log.e(ex); } diff --git a/app/src/main/java/eu/faircode/email/Gemini.java b/app/src/main/java/eu/faircode/email/Gemini.java new file mode 100644 index 0000000000..4f5124d433 --- /dev/null +++ b/app/src/main/java/eu/faircode/email/Gemini.java @@ -0,0 +1,165 @@ +package eu.faircode.email; + +/* + This file is part of FairEmail. + + FairEmail is free software: you can redistribute it and/or modify + it under the terms of the GNU General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + FairEmail is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU General Public License for more details. + + You should have received a copy of the GNU General Public License + along with FairEmail. If not, see . + + Copyright 2018-2024 by Marcel Bokhorst (M66B) +*/ + + +import android.content.Context; +import android.content.SharedPreferences; +import android.net.Uri; +import android.text.TextUtils; + +import androidx.annotation.NonNull; +import androidx.preference.PreferenceManager; + +import org.json.JSONArray; +import org.json.JSONException; +import org.json.JSONObject; + +import java.io.IOException; +import java.io.InputStream; +import java.net.URL; +import java.util.Date; +import java.util.Objects; + +import javax.net.ssl.HttpsURLConnection; + +public class Gemini { + private static final int MAX_GEMINI_LEN = 1000; // characters + private static final int TIMEOUT = 20; // seconds + + static boolean isAvailable(Context context) { + if (TextUtils.isEmpty(BuildConfig.GEMINI_ENDPOINT)) + return false; + + SharedPreferences prefs = PreferenceManager.getDefaultSharedPreferences(context); + boolean enabled = prefs.getBoolean("gemini_enabled", false); + String apikey = prefs.getString("gemini_apikey", null); + + return (enabled && + (!TextUtils.isEmpty(apikey) || !Objects.equals(getUri(context), BuildConfig.GEMINI_ENDPOINT))); + } + + static String[] generate(Context context, String model, String[] texts) throws JSONException, IOException { + JSONArray jpart = new JSONArray(); + for (String text : texts) { + JSONObject jtext = new JSONObject(); + jtext.put("text", text); + jpart.put(jtext); + } + + JSONObject jcontent = new JSONObject(); + jcontent.put("parts", jpart); + JSONArray jcontents = new JSONArray(); + jcontents.put(jcontent); + JSONObject jrequest = new JSONObject(); + jrequest.put("contents", jcontents); + + String path = "models/" + model + ":generateContent"; + + JSONObject jresponse = call(context, "POST", path, jrequest); + + return new String[]{jresponse.getJSONArray("candidates") + .getJSONObject(0) + .getJSONObject("content") + .getJSONArray("parts") + .getJSONObject(0) + .getString("text")}; + } + + private static String getUri(Context context) { + SharedPreferences prefs = PreferenceManager.getDefaultSharedPreferences(context); + return prefs.getString("gemini_uri", BuildConfig.GEMINI_ENDPOINT); + } + + private static JSONObject call(Context context, String method, String path, JSONObject args) throws JSONException, IOException { + SharedPreferences prefs = PreferenceManager.getDefaultSharedPreferences(context); + String apikey = prefs.getString("gemini_apikey", null); + + // https://ai.google.dev/tutorials/rest_quickstart + // https://ai.google.dev/api/rest + Uri uri = Uri.parse(getUri(context)).buildUpon() + .appendEncodedPath(path) + .appendQueryParameter("key", apikey) + .build(); + Log.i("Gemini uri=" + uri); + + long start = new Date().getTime(); + + URL url = new URL(uri.toString()); + HttpsURLConnection connection = (HttpsURLConnection) url.openConnection(); + + connection.setRequestMethod(method); + connection.setDoOutput(args != null); + connection.setDoInput(true); + connection.setReadTimeout(TIMEOUT * 1000); + connection.setConnectTimeout(TIMEOUT * 1000); + ConnectionHelper.setUserAgent(context, connection); + connection.setRequestProperty("Accept", "application/json"); + connection.setRequestProperty("Content-Type", "application/json"); + connection.connect(); + + try { + if (args != null) { + String json = args.toString(); + Log.i("Gemini request=" + json); + connection.getOutputStream().write(json.getBytes()); + } + + int status = connection.getResponseCode(); + if (status != HttpsURLConnection.HTTP_OK) { + String error = "Error " + status + ": " + connection.getResponseMessage(); + try { + InputStream is = connection.getErrorStream(); + if (is != null) + error += "\n" + Helper.readStream(is); + } catch (Throwable ex) { + Log.w(ex); + } + Log.w("Gemini error=" + error); + throw new IOException(error); + } + + String response = Helper.readStream(connection.getInputStream()); + Log.i("Gemini response=" + response); + + return new JSONObject(response); + } finally { + connection.disconnect(); + long elapsed = new Date().getTime() - start; + Log.i("Gemini elapsed=" + (elapsed / 1000f)); + } + } + + static String truncateParagraphs(@NonNull String text) { + return truncateParagraphs(text, MAX_GEMINI_LEN); + } + + static String truncateParagraphs(@NonNull String text, int maxlen) { + String[] paragraphs = text.split("[\\r\\n]+"); + + int i = 0; + StringBuilder sb = new StringBuilder(); + while (i < paragraphs.length && + sb.length() + paragraphs[i].length() + 1 < maxlen) + sb.append(paragraphs[i++]).append('\n'); + + return sb.toString(); + } +} diff --git a/app/src/main/res/layout/fragment_options_integrations.xml b/app/src/main/res/layout/fragment_options_integrations.xml index b933143e40..3348733fa4 100644 --- a/app/src/main/res/layout/fragment_options_integrations.xml +++ b/app/src/main/res/layout/fragment_options_integrations.xml @@ -615,5 +615,119 @@ app:srcCompat="@drawable/twotone_info_24" /> + + + + + + + + + + + + + + + + + + + + + + + diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index f8274192be..3e0ff75f0b 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -885,6 +885,7 @@ Model Temperature: %1$s Content moderation + Gemini integration I want to use an SD card Periodically check if FairEmail is still active Check for GitHub updates