diff --git a/app/src/main/java/ml/docilealligator/infinityforreddit/AppComponent.java b/app/src/main/java/ml/docilealligator/infinityforreddit/AppComponent.java index 5a1e212696..dac283bce0 100644 --- a/app/src/main/java/ml/docilealligator/infinityforreddit/AppComponent.java +++ b/app/src/main/java/ml/docilealligator/infinityforreddit/AppComponent.java @@ -106,6 +106,7 @@ import ml.docilealligator.infinityforreddit.settings.SecurityPreferenceFragment; import ml.docilealligator.infinityforreddit.settings.ThemePreferenceFragment; import ml.docilealligator.infinityforreddit.settings.TranslationFragment; +import ml.docilealligator.infinityforreddit.settings.TranslationPreferenceFragment; import ml.docilealligator.infinityforreddit.settings.VideoPreferenceFragment; import ml.docilealligator.infinityforreddit.worker.MaterialYouWorker; import ml.docilealligator.infinityforreddit.worker.PullNotificationWorker; @@ -251,6 +252,8 @@ public interface AppComponent { void inject(TranslationFragment translationFragment); + void inject(TranslationPreferenceFragment translationPreferenceFragment); + void inject(FetchRandomSubredditOrPostActivity fetchRandomSubredditOrPostActivity); void inject(MiscellaneousPreferenceFragment miscellaneousPreferenceFragment); diff --git a/app/src/main/java/ml/docilealligator/infinityforreddit/AppModule.java b/app/src/main/java/ml/docilealligator/infinityforreddit/AppModule.java index 0fd0f3d282..52de1e0c1b 100644 --- a/app/src/main/java/ml/docilealligator/infinityforreddit/AppModule.java +++ b/app/src/main/java/ml/docilealligator/infinityforreddit/AppModule.java @@ -22,8 +22,11 @@ import dagger.Binds; import dagger.Module; import dagger.Provides; +import com.google.gson.Gson; + import ml.docilealligator.infinityforreddit.customtheme.CustomThemeWrapper; import ml.docilealligator.infinityforreddit.customviews.LoopAvailableExoCreator; +import ml.docilealligator.infinityforreddit.translation.TranslatorProvider; import ml.docilealligator.infinityforreddit.utils.APIUtils; import ml.docilealligator.infinityforreddit.utils.CustomThemeSharedPreferencesUtils; import ml.docilealligator.infinityforreddit.utils.SharedPreferencesUtils; @@ -219,4 +222,10 @@ static ExoCreator provideExoCreator(Config config, static Executor provideExecutor() { return Executors.newFixedThreadPool(4); } + + @Provides + @Singleton + static Gson provideGson() { + return new Gson(); + } } diff --git a/app/src/main/java/ml/docilealligator/infinityforreddit/activities/ViewPostDetailActivity.java b/app/src/main/java/ml/docilealligator/infinityforreddit/activities/ViewPostDetailActivity.java index 677e419206..0e523f316d 100644 --- a/app/src/main/java/ml/docilealligator/infinityforreddit/activities/ViewPostDetailActivity.java +++ b/app/src/main/java/ml/docilealligator/infinityforreddit/activities/ViewPostDetailActivity.java @@ -544,6 +544,21 @@ public void failed() { } } + public void translateComment(@NonNull Comment comment, int position) { + // TODO: Implement comment translation + Toast.makeText(this, R.string.translating, Toast.LENGTH_SHORT).show(); + + ViewPostDetailFragment fragment = mSectionsPagerAdapter.getCurrentFragment(); + if (fragment != null) { + fragment.translateComment(comment, position); + } + } + + public void translatePost(@NonNull Post post, int position) { + // TODO: Implement post translation + Toast.makeText(this, R.string.translating, Toast.LENGTH_SHORT).show(); + } + public boolean toggleSearchPanelVisibility() { if (binding.searchPanelMaterialCardViewViewPostDetailActivity.getVisibility() == View.GONE) { binding.searchPanelMaterialCardViewViewPostDetailActivity.setVisibility(View.VISIBLE); diff --git a/app/src/main/java/ml/docilealligator/infinityforreddit/bottomsheetfragments/CommentMoreBottomSheetFragment.java b/app/src/main/java/ml/docilealligator/infinityforreddit/bottomsheetfragments/CommentMoreBottomSheetFragment.java index 0510e33da5..2f558019a6 100644 --- a/app/src/main/java/ml/docilealligator/infinityforreddit/bottomsheetfragments/CommentMoreBottomSheetFragment.java +++ b/app/src/main/java/ml/docilealligator/infinityforreddit/bottomsheetfragments/CommentMoreBottomSheetFragment.java @@ -157,6 +157,13 @@ public View onCreateView(LayoutInflater inflater, ViewGroup container, }); } + binding.translateTextViewCommentMoreBottomSheetFragment.setOnClickListener(view -> { + if (activity instanceof ViewPostDetailActivity) { + ((ViewPostDetailActivity) activity).translateComment(comment, bundle.getInt(EXTRA_POSITION)); + } + dismiss(); + }); + binding.shareTextViewCommentMoreBottomSheetFragment.setOnClickListener(view -> { dismiss(); try { diff --git a/app/src/main/java/ml/docilealligator/infinityforreddit/bottomsheetfragments/PostOptionsBottomSheetFragment.java b/app/src/main/java/ml/docilealligator/infinityforreddit/bottomsheetfragments/PostOptionsBottomSheetFragment.java index caba31a96a..5c46a3ed88 100644 --- a/app/src/main/java/ml/docilealligator/infinityforreddit/bottomsheetfragments/PostOptionsBottomSheetFragment.java +++ b/app/src/main/java/ml/docilealligator/infinityforreddit/bottomsheetfragments/PostOptionsBottomSheetFragment.java @@ -273,6 +273,11 @@ public void failed() { dismiss(); }); + binding.translateTextViewPostOptionsBottomSheetFragment.setOnClickListener(view -> { + ((PostOptionsBottomSheetFragmentListener) mBaseActivity).translatePost(mPost, getArguments().getInt(EXTRA_POST_LIST_POSITION, 0)); + dismiss(); + }); + binding.reportTextViewPostOptionsBottomSheetFragment.setOnClickListener(view -> { Intent intent = new Intent(mBaseActivity, ReportActivity.class); intent.putExtra(ReportActivity.EXTRA_SUBREDDIT_NAME, mPost.getSubredditName()); diff --git a/app/src/main/java/ml/docilealligator/infinityforreddit/comment/Comment.java b/app/src/main/java/ml/docilealligator/infinityforreddit/comment/Comment.java index 26ee01b664..b9134be4e6 100644 --- a/app/src/main/java/ml/docilealligator/infinityforreddit/comment/Comment.java +++ b/app/src/main/java/ml/docilealligator/infinityforreddit/comment/Comment.java @@ -71,6 +71,10 @@ public Comment[] newArray(int size) { private boolean loadMoreChildrenFailed; private long editedTimeMillis; private Map mediaMetadataMap; + + // Translation fields + private String translatedCommentRawText; + private boolean isTranslated; public Comment(String id, String fullName, String author, String authorFullName, String authorFlair, String authorFlairHTML, String linkAuthor, @@ -182,6 +186,8 @@ protected Comment(Parcel in) { isLoadingMoreChildren = in.readByte() != 0; loadMoreChildrenFailed = in.readByte() != 0; mediaMetadataMap = (Map) in.readValue(getClass().getClassLoader()); + translatedCommentRawText = in.readString(); + isTranslated = in.readByte() != 0; } public String getId() { @@ -510,6 +516,22 @@ public void setMediaMetadataMap(Map mediaMetadataMap) { this.mediaMetadataMap = mediaMetadataMap; } + public String getTranslatedCommentRawText() { + return translatedCommentRawText; + } + + public void setTranslatedCommentRawText(String translatedCommentRawText) { + this.translatedCommentRawText = translatedCommentRawText; + } + + public boolean isTranslated() { + return isTranslated; + } + + public void setTranslated(boolean translated) { + isTranslated = translated; + } + @Override public int describeContents() { return 0; @@ -560,5 +582,7 @@ public void writeToParcel(Parcel parcel, int i) { parcel.writeByte((byte) (isLoadingMoreChildren ? 1 : 0)); parcel.writeByte((byte) (loadMoreChildrenFailed ? 1 : 0)); parcel.writeValue(mediaMetadataMap); + parcel.writeString(translatedCommentRawText); + parcel.writeByte((byte) (isTranslated ? 1 : 0)); } } diff --git a/app/src/main/java/ml/docilealligator/infinityforreddit/fragments/ViewPostDetailFragment.java b/app/src/main/java/ml/docilealligator/infinityforreddit/fragments/ViewPostDetailFragment.java index 114ee1e28e..2f4a18c149 100644 --- a/app/src/main/java/ml/docilealligator/infinityforreddit/fragments/ViewPostDetailFragment.java +++ b/app/src/main/java/ml/docilealligator/infinityforreddit/fragments/ViewPostDetailFragment.java @@ -885,6 +885,12 @@ public void saveComment(int position, boolean isSaved) { } } + public void translateComment(Comment comment, int position) { + if (mCommentsAdapter != null) { + mCommentsAdapter.translateComment(position, comment); + } + } + public void searchComment(String query, boolean searchNextComment) { if (mCommentsAdapter != null) { ArrayList visibleComments = mCommentsAdapter.getVisibleComments(); diff --git a/app/src/main/java/ml/docilealligator/infinityforreddit/post/Post.java b/app/src/main/java/ml/docilealligator/infinityforreddit/post/Post.java index 54ec141aa4..82ecc82df8 100644 --- a/app/src/main/java/ml/docilealligator/infinityforreddit/post/Post.java +++ b/app/src/main/java/ml/docilealligator/infinityforreddit/post/Post.java @@ -82,6 +82,11 @@ public class Post implements Parcelable { private String approvedBy; private boolean removed; private boolean spam; + + // Translation fields + private String translatedTitle; + private String translatedSelfText; + private boolean isTranslated; //Text and video posts public Post(String id, String fullName, String subredditName, String subredditNamePrefixed, @@ -226,6 +231,9 @@ protected Post(Parcel in) { previews = in.createTypedArrayList(Preview.CREATOR); mediaMetadataMap = (Map) in.readValue(getClass().getClassLoader()); gallery = in.createTypedArrayList(Gallery.CREATOR); + translatedTitle = in.readString(); + translatedSelfText = in.readString(); + isTranslated = in.readByte() != 0; } public static final Creator CREATOR = new Creator() { @@ -571,6 +579,9 @@ public void writeToParcel(@NonNull Parcel dest, int flags) { dest.writeTypedList(previews); dest.writeValue(mediaMetadataMap); dest.writeTypedList(gallery); + dest.writeString(translatedTitle); + dest.writeString(translatedSelfText); + dest.writeByte((byte) (isTranslated ? 1 : 0)); } public boolean isStickied() { @@ -695,6 +706,30 @@ public void setMp4Variant(String mp4Variant) { this.mp4Variant = mp4Variant; } + public String getTranslatedTitle() { + return translatedTitle; + } + + public void setTranslatedTitle(String translatedTitle) { + this.translatedTitle = translatedTitle; + } + + public String getTranslatedSelfText() { + return translatedSelfText; + } + + public void setTranslatedSelfText(String translatedSelfText) { + this.translatedSelfText = translatedSelfText; + } + + public boolean isTranslated() { + return isTranslated; + } + + public void setTranslated(boolean translated) { + isTranslated = translated; + } + @Override public boolean equals(@Nullable Object obj) { if (!(obj instanceof Post)) { diff --git a/app/src/main/java/ml/docilealligator/infinityforreddit/settings/TranslationPreferenceFragment.java b/app/src/main/java/ml/docilealligator/infinityforreddit/settings/TranslationPreferenceFragment.java new file mode 100644 index 0000000000..2ac483a2a8 --- /dev/null +++ b/app/src/main/java/ml/docilealligator/infinityforreddit/settings/TranslationPreferenceFragment.java @@ -0,0 +1,48 @@ +package ml.docilealligator.infinityforreddit.settings; + +import android.os.Bundle; +import android.text.InputType; + +import androidx.preference.EditTextPreference; + +import ml.docilealligator.infinityforreddit.R; +import ml.docilealligator.infinityforreddit.customviews.preference.CustomFontPreferenceFragmentCompat; + +public class TranslationPreferenceFragment extends CustomFontPreferenceFragmentCompat { + + @Override + public void onCreatePreferences(Bundle savedInstanceState, String rootKey) { + setPreferencesFromResource(R.xml.translation_preferences, rootKey); + + // Set API key fields to password mode + EditTextPreference googleApiKeyPref = findPreference("google_translation_api_key"); + if (googleApiKeyPref != null) { + googleApiKeyPref.setOnBindEditTextListener(editText -> { + editText.setInputType(InputType.TYPE_CLASS_TEXT | InputType.TYPE_TEXT_VARIATION_PASSWORD); + editText.setSelection(editText.getText().length()); + }); + googleApiKeyPref.setSummaryProvider(preference -> { + String value = ((EditTextPreference) preference).getText(); + if (value == null || value.isEmpty()) { + return getString(R.string.settings_google_translation_api_key_summary); + } + return "••••••••"; + }); + } + + EditTextPreference deeplApiKeyPref = findPreference("deepl_api_key"); + if (deeplApiKeyPref != null) { + deeplApiKeyPref.setOnBindEditTextListener(editText -> { + editText.setInputType(InputType.TYPE_CLASS_TEXT | InputType.TYPE_TEXT_VARIATION_PASSWORD); + editText.setSelection(editText.getText().length()); + }); + deeplApiKeyPref.setSummaryProvider(preference -> { + String value = ((EditTextPreference) preference).getText(); + if (value == null || value.isEmpty()) { + return getString(R.string.settings_deepl_api_key_summary); + } + return "••••••••"; + }); + } + } +} diff --git a/app/src/main/java/ml/docilealligator/infinityforreddit/translation/DeepLTranslator.java b/app/src/main/java/ml/docilealligator/infinityforreddit/translation/DeepLTranslator.java new file mode 100644 index 0000000000..67020c9817 --- /dev/null +++ b/app/src/main/java/ml/docilealligator/infinityforreddit/translation/DeepLTranslator.java @@ -0,0 +1,134 @@ +package ml.docilealligator.infinityforreddit.translation; + +import androidx.annotation.NonNull; + +import com.google.gson.Gson; +import com.google.gson.JsonArray; +import com.google.gson.JsonObject; + +import java.io.IOException; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.Executor; + +import okhttp3.Call; +import okhttp3.Callback; +import okhttp3.MediaType; +import okhttp3.OkHttpClient; +import okhttp3.Request; +import okhttp3.RequestBody; +import okhttp3.Response; + +public class DeepLTranslator implements Translator { + private static final String API_URL = "https://api-free.deepl.com/v2/translate"; + private static final MediaType JSON = MediaType.parse("application/json; charset=utf-8"); + + private final OkHttpClient httpClient; + private final Executor executor; + private final Gson gson; + private String apiKey; + + public DeepLTranslator(@NonNull OkHttpClient httpClient, + @NonNull Executor executor, + @NonNull Gson gson) { + this.httpClient = httpClient; + this.executor = executor; + this.gson = gson; + } + + public void setApiKey(String apiKey) { + this.apiKey = apiKey; + } + + @Override + public CompletableFuture translate(@NonNull String text, + @NonNull String sourceLanguage, + @NonNull String targetLanguage) { + CompletableFuture future = new CompletableFuture<>(); + + if (!isConfigured()) { + future.complete(TranslationResult.error("DeepL API key not configured")); + return future; + } + + executor.execute(() -> { + try { + JsonObject json = new JsonObject(); + JsonArray textArray = new JsonArray(); + textArray.add(text); + json.add("text", textArray); + json.addProperty("target_lang", targetLanguage.toUpperCase()); + if (!"auto".equalsIgnoreCase(sourceLanguage)) { + json.addProperty("source_lang", sourceLanguage.toUpperCase()); + } + + String jsonString = gson.toJson(json); + RequestBody body = RequestBody.create(jsonString, JSON); + + Request request = new Request.Builder() + .url(API_URL) + .addHeader("Authorization", "DeepL-Auth-Key " + apiKey) + .post(body) + .build(); + + httpClient.newCall(request).enqueue(new Callback() { + @Override + public void onFailure(@NonNull Call call, @NonNull IOException e) { + future.complete(TranslationResult.error("Network error: " + e.getMessage())); + } + + @Override + public void onResponse(@NonNull Call call, @NonNull Response response) { + try { + if (!response.isSuccessful()) { + future.complete(TranslationResult.error("API error: " + response.code())); + return; + } + + if (response.body() == null) { + future.complete(TranslationResult.error("Empty response body")); + return; + } + + String responseBody = response.body().string(); + JsonObject responseJson = gson.fromJson(responseBody, JsonObject.class); + + if (responseJson.has("translations")) { + JsonArray translations = responseJson.getAsJsonArray("translations"); + if (translations.size() > 0) { + JsonObject translation = translations.get(0).getAsJsonObject(); + String translatedText = translation.get("text").getAsString(); + String detectedLanguage = translation.has("detected_source_language") + ? translation.get("detected_source_language").getAsString() + : null; + future.complete(TranslationResult.success(translatedText, detectedLanguage)); + } else { + future.complete(TranslationResult.error("No translation returned")); + } + } else { + future.complete(TranslationResult.error("Invalid response format")); + } + } catch (Exception e) { + future.complete(TranslationResult.error("Parse error: " + e.getMessage())); + } finally { + response.close(); + } + } + }); + } catch (Exception e) { + future.complete(TranslationResult.error("Request error: " + e.getMessage())); + } + }); + + return future; + } + + @Override + public boolean isConfigured() { + return apiKey != null && !apiKey.isEmpty(); + } + + @Override + public String getName() { + return "DeepL"; + } +} diff --git a/app/src/main/java/ml/docilealligator/infinityforreddit/translation/GoogleCloudTranslator.java b/app/src/main/java/ml/docilealligator/infinityforreddit/translation/GoogleCloudTranslator.java new file mode 100644 index 0000000000..83d9af478f --- /dev/null +++ b/app/src/main/java/ml/docilealligator/infinityforreddit/translation/GoogleCloudTranslator.java @@ -0,0 +1,134 @@ +package ml.docilealligator.infinityforreddit.translation; + +import androidx.annotation.NonNull; + +import com.google.gson.Gson; +import com.google.gson.JsonArray; +import com.google.gson.JsonObject; + +import java.io.IOException; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.Executor; + +import okhttp3.Call; +import okhttp3.Callback; +import okhttp3.MediaType; +import okhttp3.OkHttpClient; +import okhttp3.Request; +import okhttp3.RequestBody; +import okhttp3.Response; + +public class GoogleCloudTranslator implements Translator { + private static final String API_URL = "https://translation.googleapis.com/language/translate/v2"; + private static final MediaType JSON = MediaType.parse("application/json; charset=utf-8"); + + private final OkHttpClient httpClient; + private final Executor executor; + private final Gson gson; + private String apiKey; + + public GoogleCloudTranslator(@NonNull OkHttpClient httpClient, + @NonNull Executor executor, + @NonNull Gson gson) { + this.httpClient = httpClient; + this.executor = executor; + this.gson = gson; + } + + public void setApiKey(String apiKey) { + this.apiKey = apiKey; + } + + @Override + public CompletableFuture translate(@NonNull String text, + @NonNull String sourceLanguage, + @NonNull String targetLanguage) { + CompletableFuture future = new CompletableFuture<>(); + + if (!isConfigured()) { + future.complete(TranslationResult.error("Google Cloud Translation API key not configured")); + return future; + } + + executor.execute(() -> { + try { + JsonObject json = new JsonObject(); + json.addProperty("q", text); + json.addProperty("target", targetLanguage); + if (!"auto".equals(sourceLanguage)) { + json.addProperty("source", sourceLanguage); + } + json.addProperty("format", "text"); + json.addProperty("key", apiKey); + + String jsonString = gson.toJson(json); + RequestBody body = RequestBody.create(jsonString, JSON); + + Request request = new Request.Builder() + .url(API_URL) + .post(body) + .build(); + + httpClient.newCall(request).enqueue(new Callback() { + @Override + public void onFailure(@NonNull Call call, @NonNull IOException e) { + future.complete(TranslationResult.error("Network error: " + e.getMessage())); + } + + @Override + public void onResponse(@NonNull Call call, @NonNull Response response) { + try { + if (!response.isSuccessful()) { + future.complete(TranslationResult.error("API error: " + response.code())); + return; + } + + if (response.body() == null) { + future.complete(TranslationResult.error("Empty response body")); + return; + } + + String responseBody = response.body().string(); + JsonObject responseJson = gson.fromJson(responseBody, JsonObject.class); + + if (responseJson.has("data")) { + JsonObject data = responseJson.getAsJsonObject("data"); + JsonArray translations = data.getAsJsonArray("translations"); + if (translations.size() > 0) { + JsonObject translation = translations.get(0).getAsJsonObject(); + String translatedText = translation.get("translatedText").getAsString(); + String detectedLanguage = translation.has("detectedSourceLanguage") + ? translation.get("detectedSourceLanguage").getAsString() + : null; + future.complete(TranslationResult.success(translatedText, detectedLanguage)); + } else { + future.complete(TranslationResult.error("No translation returned")); + } + } else { + future.complete(TranslationResult.error("Invalid response format")); + } + } catch (Exception e) { + future.complete(TranslationResult.error("Parse error: " + e.getMessage())); + } finally { + response.close(); + } + } + }); + } catch (Exception e) { + future.complete(TranslationResult.error("Request error: " + e.getMessage())); + } + }); + + return future; + } + + @Override + public boolean isConfigured() { + return apiKey != null && !apiKey.isEmpty(); + } + + @Override + public String getName() { + return "Google Cloud Translation"; + } +} diff --git a/app/src/main/java/ml/docilealligator/infinityforreddit/translation/NoOpTranslator.java b/app/src/main/java/ml/docilealligator/infinityforreddit/translation/NoOpTranslator.java new file mode 100644 index 0000000000..7d5bdbc91d --- /dev/null +++ b/app/src/main/java/ml/docilealligator/infinityforreddit/translation/NoOpTranslator.java @@ -0,0 +1,30 @@ +package ml.docilealligator.infinityforreddit.translation; + +import androidx.annotation.NonNull; + +import java.util.concurrent.CompletableFuture; + +/** + * No-op translator used when no translation backend is configured + */ +public class NoOpTranslator implements Translator { + + @Override + public CompletableFuture translate(@NonNull String text, + @NonNull String sourceLanguage, + @NonNull String targetLanguage) { + return CompletableFuture.completedFuture( + TranslationResult.error("No translation backend configured") + ); + } + + @Override + public boolean isConfigured() { + return false; + } + + @Override + public String getName() { + return "None"; + } +} diff --git a/app/src/main/java/ml/docilealligator/infinityforreddit/translation/TranslationResult.java b/app/src/main/java/ml/docilealligator/infinityforreddit/translation/TranslationResult.java new file mode 100644 index 0000000000..0099f9d66f --- /dev/null +++ b/app/src/main/java/ml/docilealligator/infinityforreddit/translation/TranslationResult.java @@ -0,0 +1,51 @@ +package ml.docilealligator.infinityforreddit.translation; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; + +public class TranslationResult { + @Nullable + private final String translatedText; + @Nullable + private final String detectedSourceLanguage; + @Nullable + private final String errorMessage; + private final boolean success; + + private TranslationResult(@Nullable String translatedText, + @Nullable String detectedSourceLanguage, + @Nullable String errorMessage, + boolean success) { + this.translatedText = translatedText; + this.detectedSourceLanguage = detectedSourceLanguage; + this.errorMessage = errorMessage; + this.success = success; + } + + public static TranslationResult success(@NonNull String translatedText, @Nullable String detectedSourceLanguage) { + return new TranslationResult(translatedText, detectedSourceLanguage, null, true); + } + + public static TranslationResult error(@NonNull String errorMessage) { + return new TranslationResult(null, null, errorMessage, false); + } + + public boolean isSuccess() { + return success; + } + + @Nullable + public String getTranslatedText() { + return translatedText; + } + + @Nullable + public String getDetectedSourceLanguage() { + return detectedSourceLanguage; + } + + @Nullable + public String getErrorMessage() { + return errorMessage; + } +} diff --git a/app/src/main/java/ml/docilealligator/infinityforreddit/translation/Translator.java b/app/src/main/java/ml/docilealligator/infinityforreddit/translation/Translator.java new file mode 100644 index 0000000000..d6628af3e3 --- /dev/null +++ b/app/src/main/java/ml/docilealligator/infinityforreddit/translation/Translator.java @@ -0,0 +1,36 @@ +package ml.docilealligator.infinityforreddit.translation; + +import androidx.annotation.NonNull; + +import java.util.concurrent.CompletableFuture; + +/** + * Abstract interface for translation services + */ +public interface Translator { + + /** + * Translate text from source language to target language + * @param text Text to translate + * @param sourceLanguage Source language code (e.g., "en", "es", "auto") + * @param targetLanguage Target language code (e.g., "en", "es") + * @return CompletableFuture with translation result + */ + CompletableFuture translate( + @NonNull String text, + @NonNull String sourceLanguage, + @NonNull String targetLanguage + ); + + /** + * Check if the translator is configured and ready to use + * @return true if configured, false otherwise + */ + boolean isConfigured(); + + /** + * Get the name of this translator + * @return Translator name + */ + String getName(); +} diff --git a/app/src/main/java/ml/docilealligator/infinityforreddit/translation/TranslatorProvider.java b/app/src/main/java/ml/docilealligator/infinityforreddit/translation/TranslatorProvider.java new file mode 100644 index 0000000000..9e3f3afdb9 --- /dev/null +++ b/app/src/main/java/ml/docilealligator/infinityforreddit/translation/TranslatorProvider.java @@ -0,0 +1,115 @@ +package ml.docilealligator.infinityforreddit.translation; + +import android.content.SharedPreferences; + +import androidx.annotation.NonNull; + +import com.google.gson.Gson; + +import java.util.concurrent.Executor; + +import javax.inject.Inject; +import javax.inject.Named; +import javax.inject.Singleton; + +import okhttp3.OkHttpClient; + +@Singleton +public class TranslatorProvider { + private static final String PREF_TRANSLATOR_TYPE = "translator_type"; + private static final String PREF_GOOGLE_API_KEY = "google_translation_api_key"; + private static final String PREF_DEEPL_API_KEY = "deepl_api_key"; + private static final String PREF_TARGET_LANGUAGE = "translation_target_language"; + + public static final String TYPE_NONE = "none"; + public static final String TYPE_GOOGLE = "google"; + public static final String TYPE_DEEPL = "deepl"; + + private final SharedPreferences sharedPreferences; + private final OkHttpClient httpClient; + private final Executor executor; + private final Gson gson; + + private Translator currentTranslator; + + @Inject + public TranslatorProvider(@Named("default") SharedPreferences sharedPreferences, + @Named("base") OkHttpClient httpClient, + Executor executor, + Gson gson) { + this.sharedPreferences = sharedPreferences; + this.httpClient = httpClient; + this.executor = executor; + this.gson = gson; + this.currentTranslator = createTranslator(); + } + + @NonNull + public synchronized Translator getTranslator() { + if (currentTranslator == null) { + currentTranslator = createTranslator(); + } + return currentTranslator; + } + + public synchronized void refreshTranslator() { + currentTranslator = createTranslator(); + } + + private Translator createTranslator() { + String type = sharedPreferences.getString(PREF_TRANSLATOR_TYPE, TYPE_NONE); + + switch (type) { + case TYPE_GOOGLE: + GoogleCloudTranslator googleTranslator = new GoogleCloudTranslator(httpClient, executor, gson); + String googleKey = sharedPreferences.getString(PREF_GOOGLE_API_KEY, ""); + googleTranslator.setApiKey(googleKey); + return googleTranslator; + + case TYPE_DEEPL: + DeepLTranslator deeplTranslator = new DeepLTranslator(httpClient, executor, gson); + String deeplKey = sharedPreferences.getString(PREF_DEEPL_API_KEY, ""); + deeplTranslator.setApiKey(deeplKey); + return deeplTranslator; + + case TYPE_NONE: + default: + return new NoOpTranslator(); + } + } + + public String getTargetLanguage() { + return sharedPreferences.getString(PREF_TARGET_LANGUAGE, "en"); + } + + public void setTargetLanguage(String language) { + sharedPreferences.edit().putString(PREF_TARGET_LANGUAGE, language).apply(); + } + + public String getTranslatorType() { + return sharedPreferences.getString(PREF_TRANSLATOR_TYPE, TYPE_NONE); + } + + public void setTranslatorType(String type) { + sharedPreferences.edit().putString(PREF_TRANSLATOR_TYPE, type).apply(); + refreshTranslator(); + } + + public String getGoogleApiKey() { + return sharedPreferences.getString(PREF_GOOGLE_API_KEY, ""); + } + + public void setGoogleApiKey(String apiKey) { + sharedPreferences.edit().putString(PREF_GOOGLE_API_KEY, apiKey).apply(); + refreshTranslator(); + } + + public String getDeepLApiKey() { + return sharedPreferences.getString(PREF_DEEPL_API_KEY, ""); + } + + public void setDeepLApiKey(String apiKey) { + sharedPreferences.edit().putString(PREF_DEEPL_API_KEY, apiKey).apply(); + refreshTranslator(); + } +} diff --git a/app/src/main/res/drawable/ic_translate_day_night_24dp.xml b/app/src/main/res/drawable/ic_translate_day_night_24dp.xml new file mode 100644 index 0000000000..d2f8eae6b1 --- /dev/null +++ b/app/src/main/res/drawable/ic_translate_day_night_24dp.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/layout/fragment_comment_more_bottom_sheet.xml b/app/src/main/res/layout/fragment_comment_more_bottom_sheet.xml index 2707e32d3b..64ec6c29fd 100644 --- a/app/src/main/res/layout/fragment_comment_more_bottom_sheet.xml +++ b/app/src/main/res/layout/fragment_comment_more_bottom_sheet.xml @@ -90,6 +90,25 @@ android:visibility="gone" android:fontFamily="?attr/font_family" /> + + + + 500 + + + None + Google Cloud Translation + DeepL + + + + none + google + deepl + + + + + Auto-detect + English + Spanish + French + German + Italian + Portuguese + Russian + Japanese + Korean + Chinese (Simplified) + Chinese (Traditional) + Arabic + Hindi + Dutch + Polish + Turkish + Swedish + Norwegian Bokmål + Danish + Finnish + + + + auto + en + es + fr + de + it + pt + ru + ja + ko + zh-CN + zh-TW + ar + hi + nl + pl + tr + sv + nb + da + fi + + diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 302003b965..13b26d36a4 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -1610,4 +1610,20 @@ This comment is removed as spam Copying + + + Translation Feature + Translation Backend + Backend + General Settings + Target Language + Google Cloud API Key + Enter your Google Cloud Translation API key + DeepL API Key + Enter your DeepL API key (free or pro) + Translate + Translated + Translation failed + Translating… + Show Original diff --git a/app/src/main/res/xml/main_preferences.xml b/app/src/main/res/xml/main_preferences.xml index c7eff06c3a..b226ff9933 100644 --- a/app/src/main/res/xml/main_preferences.xml +++ b/app/src/main/res/xml/main_preferences.xml @@ -86,6 +86,11 @@ app:title="@string/settings_miscellaneous_title" app:fragment="ml.docilealligator.infinityforreddit.settings.MiscellaneousPreferenceFragment" /> + + + + + + + + + + + + + + + + + + + + +