diff --git a/app/proguard-rules.pro b/app/proguard-rules.pro index e25a4564..78b4be88 100644 --- a/app/proguard-rules.pro +++ b/app/proguard-rules.pro @@ -55,6 +55,9 @@ public static *** v(...); public static *** i(...); } +-assumenosideeffects class java.lang.String { + public static java.lang.String format(...); +} # remove some kotlin overhead -assumenosideeffects class kotlin.jvm.internal.Intrinsics { @@ -62,8 +65,3 @@ static void checkExpressionValueIsNotNull(java.lang.Object, java.lang.String); static void throwUninitializedPropertyAccessException(java.lang.String); } - -# without this emoji font downloading fails with AbstractMethodError --keep class * extends android.os.AsyncTask { - public *; -} diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 217fde72..a5ffc8b1 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -120,7 +120,7 @@ android:name=".AccountActivity" android:configChanges="orientation|screenSize|keyboardHidden|screenLayout|smallestScreenSize" /> - + diff --git a/app/src/main/java/com/keylesspalace/tusky/EmojiPreference.java b/app/src/main/java/com/keylesspalace/tusky/EmojiPreference.java deleted file mode 100644 index 8837c7d8..00000000 --- a/app/src/main/java/com/keylesspalace/tusky/EmojiPreference.java +++ /dev/null @@ -1,291 +0,0 @@ -package com.keylesspalace.tusky; - -import android.app.AlarmManager; -import android.app.PendingIntent; -import android.content.Context; -import android.content.Intent; -import android.os.Build; -import android.util.AttributeSet; -import android.util.Log; -import android.view.LayoutInflater; -import android.view.View; -import android.widget.ImageButton; -import android.widget.ImageView; -import android.widget.ProgressBar; -import android.widget.RadioButton; -import android.widget.TextView; -import android.widget.Toast; - -import androidx.appcompat.app.AlertDialog; -import androidx.preference.Preference; -import androidx.preference.PreferenceManager; - -import com.keylesspalace.tusky.util.EmojiCompatFont; - -import java.util.ArrayList; - -/** - * This Preference lets the user select their preferred emoji font - */ -public class EmojiPreference extends Preference { - private static final String TAG = "EmojiPreference"; - private EmojiCompatFont selected, original; - static final String FONT_PREFERENCE = "selected_emoji_font"; - private static final EmojiCompatFont[] FONTS = EmojiCompatFont.FONTS; - // Please note that this array should be sorted in the same way as their fonts. - private static final int[] viewIds = { - R.id.item_nomoji, - R.id.item_blobmoji, - R.id.item_twemoji, - R.id.item_notoemoji}; - - private ArrayList radioButtons = new ArrayList<>(); - - private boolean updated, currentNeedsUpdate; - - public EmojiPreference(Context context) { - super(context); - - // Find out which font is currently active - this.selected = EmojiCompatFont.byId(PreferenceManager - .getDefaultSharedPreferences(context) - .getInt(FONT_PREFERENCE, 0)); - // We'll use this later to determine if anything has changed - this.original = this.selected; - - setSummary(selected.getDisplay(context)); - } - - public EmojiPreference(Context context, AttributeSet attrs) { - super(context, attrs); - - // Find out which font is currently active - this.selected = EmojiCompatFont.byId(PreferenceManager - .getDefaultSharedPreferences(context) - .getInt(FONT_PREFERENCE, 0)); - // We'll use this later to determine if anything has changed - this.original = this.selected; - - setSummary(selected.getDisplay(context)); - } - - @Override - protected void onClick() { - - View view = LayoutInflater.from(getContext()).inflate(R.layout.dialog_emojicompat, null); - - for (int i = 0; i < viewIds.length; i++) { - setupItem(view.findViewById(viewIds[i]), FONTS[i]); - } - - new AlertDialog.Builder(getContext()) - .setView(view) - .setPositiveButton(android.R.string.ok, (dialog, which) -> onDialogOk()) - .setNegativeButton(android.R.string.cancel, null) - .show(); - } - - private void setupItem(View container, EmojiCompatFont font) { - Context context = container.getContext(); - - TextView title = container.findViewById(R.id.emojicompat_name); - TextView caption = container.findViewById(R.id.emojicompat_caption); - ImageView thumb = container.findViewById(R.id.emojicompat_thumb); - ImageButton download = container.findViewById(R.id.emojicompat_download); - ImageButton cancel = container.findViewById(R.id.emojicompat_download_cancel); - RadioButton radio = container.findViewById(R.id.emojicompat_radio); - - // Initialize all the views - title.setText(font.getDisplay(context)); - caption.setText(font.getCaption(context)); - thumb.setImageDrawable(font.getThumb(context)); - - // There needs to be a list of all the radio buttons in order to uncheck them when one is selected - radioButtons.add(radio); - - updateItem(font, container); - - // Set actions - download.setOnClickListener((downloadButton) -> - startDownload(font, container)); - - cancel.setOnClickListener((cancelButton) -> - cancelDownload(font, container)); - - radio.setOnClickListener((radioButton) -> - select(font, (RadioButton) radioButton)); - - container.setOnClickListener((containterView) -> - select(font, - containterView.findViewById(R.id.emojicompat_radio - ))); - } - - private void startDownload(EmojiCompatFont font, View container) { - ImageButton download = container.findViewById(R.id.emojicompat_download); - TextView caption = container.findViewById(R.id.emojicompat_caption); - - ProgressBar progressBar = container.findViewById(R.id.emojicompat_progress); - ImageButton cancel = container.findViewById(R.id.emojicompat_download_cancel); - - // Switch to downloading style - download.setVisibility(View.GONE); - caption.setVisibility(View.INVISIBLE); - progressBar.setVisibility(View.VISIBLE); - cancel.setVisibility(View.VISIBLE); - - - font.downloadFont(getContext(), new EmojiCompatFont.Downloader.EmojiDownloadListener() { - @Override - public void onDownloaded(EmojiCompatFont font) { - finishDownload(font, container); - } - - @Override - public void onProgress(float progress) { - // The progress is returned as a float between 0 and 1 - progress *= progressBar.getMax(); - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) { - progressBar.setProgress((int) progress, true); - } else { - progressBar.setProgress((int) progress); - } - } - - @Override - public void onFailed() { - Toast.makeText(getContext(), R.string.download_failed, Toast.LENGTH_SHORT).show(); - updateItem(font, container); - } - }); - } - - private void cancelDownload(EmojiCompatFont font, View container) { - font.cancelDownload(); - updateItem(font, container); - } - - private void finishDownload(EmojiCompatFont font, View container) { - select(font, container.findViewById(R.id.emojicompat_radio)); - updateItem(font, container); - // Set the flag to restart the app (because an update has been downloaded) - if (selected == original && currentNeedsUpdate) { - updated = true; - currentNeedsUpdate = false; - } - } - - /** - * Select a font both visually and logically - * - * @param font The font to be selected - * @param radio The radio button associated with it's visual item - */ - private void select(EmojiCompatFont font, RadioButton radio) { - selected = font; - // Uncheck all the other buttons - for (RadioButton other : radioButtons) { - if (other != radio) { - other.setChecked(false); - } - } - radio.setChecked(true); - } - - /** - * Called when a "consistent" state is reached, i.e. it's not downloading the font - * - * @param font The font to be displayed - * @param container The ConstraintLayout containing the item - */ - private void updateItem(EmojiCompatFont font, View container) { - // Assignments - ImageButton download = container.findViewById(R.id.emojicompat_download); - TextView caption = container.findViewById(R.id.emojicompat_caption); - - ProgressBar progress = container.findViewById(R.id.emojicompat_progress); - ImageButton cancel = container.findViewById(R.id.emojicompat_download_cancel); - - RadioButton radio = container.findViewById(R.id.emojicompat_radio); - - // There's no download going on - progress.setVisibility(View.GONE); - cancel.setVisibility(View.GONE); - caption.setVisibility(View.VISIBLE); - - if (font.isDownloaded(getContext())) { - // Make it selectable - download.setVisibility(View.GONE); - radio.setVisibility(View.VISIBLE); - container.setClickable(true); - } else { - // Make it downloadable - download.setVisibility(View.VISIBLE); - radio.setVisibility(View.GONE); - container.setClickable(false); - } - - // Select it if necessary - if (font == selected) { - radio.setChecked(true); - // Update available - if (!font.isDownloaded(getContext())) { - currentNeedsUpdate = true; - } - } else { - radio.setChecked(false); - } - } - - - /** - * In order to be able to use this font later on, it needs to be saved first. - */ - private void saveSelectedFont() { - int index = selected.getId(); - Log.i(TAG, "saveSelectedFont: Font ID: " + index); - // It's saved using the key FONT_PREFERENCE - PreferenceManager - .getDefaultSharedPreferences(getContext()) - .edit() - .putInt(FONT_PREFERENCE, index) - .apply(); - setSummary(selected.getDisplay(getContext())); - } - - /** - * That's it. The user doesn't want to switch between these amazing radio buttons anymore! - * That means, the selected font can be saved (if the user hit OK) - */ - private void onDialogOk() { - saveSelectedFont(); - if (selected != original || updated) { - new AlertDialog.Builder(getContext()) - .setTitle(R.string.restart_required) - .setMessage(R.string.restart_emoji) - .setNegativeButton(R.string.later, null) - .setPositiveButton(R.string.restart, ((dialog, which) -> { - // Restart the app - // From https://stackoverflow.com/a/17166729/5070653 - Intent launchIntent = new Intent(getContext(), SplashActivity.class); - PendingIntent mPendingIntent = PendingIntent.getActivity( - getContext(), - // This is the codepoint of the party face emoji :D - 0x1f973, - launchIntent, - PendingIntent.FLAG_CANCEL_CURRENT); - AlarmManager mgr = - (AlarmManager) getContext().getSystemService(Context.ALARM_SERVICE); - if (mgr != null) { - mgr.set( - AlarmManager.RTC, - System.currentTimeMillis() + 100, - mPendingIntent); - } - System.exit(0); - })).show(); - } - - } - -} diff --git a/app/src/main/java/com/keylesspalace/tusky/MainActivity.kt b/app/src/main/java/com/keylesspalace/tusky/MainActivity.kt index 23222aa3..e661ae1f 100644 --- a/app/src/main/java/com/keylesspalace/tusky/MainActivity.kt +++ b/app/src/main/java/com/keylesspalace/tusky/MainActivity.kt @@ -52,6 +52,7 @@ import com.keylesspalace.tusky.components.compose.ComposeActivity import com.keylesspalace.tusky.components.compose.ComposeActivity.Companion.canHandleMimeType import com.keylesspalace.tusky.components.conversation.ConversationsRepository import com.keylesspalace.tusky.components.notifications.NotificationHelper +import com.keylesspalace.tusky.components.preference.PreferencesActivity import com.keylesspalace.tusky.components.scheduled.ScheduledTootActivity import com.keylesspalace.tusky.components.search.SearchActivity import com.keylesspalace.tusky.db.AccountEntity diff --git a/app/src/main/java/com/keylesspalace/tusky/TuskyApplication.kt b/app/src/main/java/com/keylesspalace/tusky/TuskyApplication.kt index 2039c0e1..8e5a5dd9 100644 --- a/app/src/main/java/com/keylesspalace/tusky/TuskyApplication.kt +++ b/app/src/main/java/com/keylesspalace/tusky/TuskyApplication.kt @@ -26,6 +26,7 @@ import com.github.piasy.biv.BigImageViewer import com.github.piasy.biv.loader.glide.GlideCustomImageLoader import com.keylesspalace.tusky.components.notifications.NotificationWorkerFactory import com.keylesspalace.tusky.di.AppInjector +import com.keylesspalace.tusky.settings.PrefKeys import com.keylesspalace.tusky.util.* import com.uber.autodispose.AutoDisposePlugins import dagger.android.DispatchingAndroidInjector @@ -55,7 +56,7 @@ class TuskyApplication : Application(), HasAndroidInjector { val preferences = PreferenceManager.getDefaultSharedPreferences(this) // init the custom emoji fonts - val emojiSelection = preferences.getInt(EmojiPreference.FONT_PREFERENCE, 0) + val emojiSelection = preferences.getInt(PrefKeys.EMOJI, 0) val emojiConfig = EmojiCompatFont.byId(emojiSelection) .getConfig(this) .setReplaceAll(true) diff --git a/app/src/main/java/com/keylesspalace/tusky/fragment/preference/AccountPreferencesFragment.kt b/app/src/main/java/com/keylesspalace/tusky/components/preference/AccountPreferencesFragment.kt similarity index 99% rename from app/src/main/java/com/keylesspalace/tusky/fragment/preference/AccountPreferencesFragment.kt rename to app/src/main/java/com/keylesspalace/tusky/components/preference/AccountPreferencesFragment.kt index d24cf96d..b48596d4 100644 --- a/app/src/main/java/com/keylesspalace/tusky/fragment/preference/AccountPreferencesFragment.kt +++ b/app/src/main/java/com/keylesspalace/tusky/components/preference/AccountPreferencesFragment.kt @@ -13,7 +13,7 @@ * You should have received a copy of the GNU General Public License along with Tusky; if not, * see . */ -package com.keylesspalace.tusky.fragment.preference +package com.keylesspalace.tusky.components.preference import android.content.Intent import android.graphics.drawable.Drawable diff --git a/app/src/main/java/com/keylesspalace/tusky/components/preference/EmojiPreference.kt b/app/src/main/java/com/keylesspalace/tusky/components/preference/EmojiPreference.kt new file mode 100644 index 00000000..c0e538a9 --- /dev/null +++ b/app/src/main/java/com/keylesspalace/tusky/components/preference/EmojiPreference.kt @@ -0,0 +1,258 @@ +package com.keylesspalace.tusky.components.preference + +import android.app.AlarmManager +import android.app.PendingIntent +import android.content.Context +import android.content.Intent +import android.os.Build +import android.util.Log +import android.view.LayoutInflater +import android.view.View +import android.widget.* +import androidx.appcompat.app.AlertDialog +import androidx.preference.Preference +import androidx.preference.PreferenceManager +import com.keylesspalace.tusky.R +import com.keylesspalace.tusky.SplashActivity +import com.keylesspalace.tusky.util.EmojiCompatFont +import com.keylesspalace.tusky.util.EmojiCompatFont.Companion.FONTS +import io.reactivex.android.schedulers.AndroidSchedulers +import io.reactivex.disposables.Disposable +import okhttp3.OkHttpClient +import kotlin.system.exitProcess + +/** + * This Preference lets the user select their preferred emoji font + */ +class EmojiPreference( + context: Context, + private val okHttpClient: OkHttpClient +) : Preference(context) { + + private lateinit var selected: EmojiCompatFont + private lateinit var original: EmojiCompatFont + private val radioButtons = mutableListOf() + private var updated = false + private var currentNeedsUpdate = false + + private val downloadDisposables = MutableList(FONTS.size) { null } + + override fun onAttachedToHierarchy(preferenceManager: PreferenceManager) { + super.onAttachedToHierarchy(preferenceManager) + + // Find out which font is currently active + selected = EmojiCompatFont.byId( + PreferenceManager.getDefaultSharedPreferences(context).getInt(key, 0) + ) + // We'll use this later to determine if anything has changed + original = selected + summary = selected.getDisplay(context) + } + + override fun onClick() { + val view = LayoutInflater.from(context).inflate(R.layout.dialog_emojicompat, null) + viewIds.forEachIndexed { index, viewId -> + setupItem(view.findViewById(viewId), FONTS[index]) + } + AlertDialog.Builder(context) + .setView(view) + .setPositiveButton(android.R.string.ok) { _, _ -> onDialogOk() } + .setNegativeButton(android.R.string.cancel, null) + .show() + } + + private fun setupItem(container: View, font: EmojiCompatFont) { + val title: TextView = container.findViewById(R.id.emojicompat_name) + val caption: TextView = container.findViewById(R.id.emojicompat_caption) + val thumb: ImageView = container.findViewById(R.id.emojicompat_thumb) + val download: ImageButton = container.findViewById(R.id.emojicompat_download) + val cancel: ImageButton = container.findViewById(R.id.emojicompat_download_cancel) + val radio: RadioButton = container.findViewById(R.id.emojicompat_radio) + + // Initialize all the views + title.text = font.getDisplay(container.context) + caption.setText(font.caption) + thumb.setImageResource(font.img) + + // There needs to be a list of all the radio buttons in order to uncheck them when one is selected + radioButtons.add(radio) + updateItem(font, container) + + // Set actions + download.setOnClickListener { startDownload(font, container) } + cancel.setOnClickListener { cancelDownload(font, container) } + radio.setOnClickListener { radioButton: View -> select(font, radioButton as RadioButton) } + container.setOnClickListener { containerView: View -> + select(font, containerView.findViewById(R.id.emojicompat_radio)) + } + } + + private fun startDownload(font: EmojiCompatFont, container: View) { + val download: ImageButton = container.findViewById(R.id.emojicompat_download) + val caption: TextView = container.findViewById(R.id.emojicompat_caption) + val progressBar: ProgressBar = container.findViewById(R.id.emojicompat_progress) + val cancel: ImageButton = container.findViewById(R.id.emojicompat_download_cancel) + + // Switch to downloading style + download.visibility = View.GONE + caption.visibility = View.INVISIBLE + progressBar.visibility = View.VISIBLE + progressBar.progress = 0 + cancel.visibility = View.VISIBLE + font.downloadFontFile(context, okHttpClient) + .observeOn(AndroidSchedulers.mainThread()) + .subscribe( + { progress -> + // The progress is returned as a float between 0 and 1, or -1 if it could not determined + if (progress >= 0) { + progressBar.isIndeterminate = false + val max = progressBar.max.toFloat() + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) { + progressBar.setProgress((max * progress).toInt(), true) + } else { + progressBar.progress = (max * progress).toInt() + } + } else { + progressBar.isIndeterminate = true + } + }, + { + Toast.makeText(context, R.string.download_failed, Toast.LENGTH_SHORT).show() + updateItem(font, container) + }, + { + finishDownload(font, container) + } + ).also { downloadDisposables[font.id] = it } + + + } + + private fun cancelDownload(font: EmojiCompatFont, container: View) { + font.deleteDownloadedFile(container.context) + downloadDisposables[font.id]?.dispose() + downloadDisposables[font.id] = null + updateItem(font, container) + } + + private fun finishDownload(font: EmojiCompatFont, container: View) { + select(font, container.findViewById(R.id.emojicompat_radio)) + updateItem(font, container) + // Set the flag to restart the app (because an update has been downloaded) + if (selected === original && currentNeedsUpdate) { + updated = true + currentNeedsUpdate = false + } + } + + /** + * Select a font both visually and logically + * + * @param font The font to be selected + * @param radio The radio button associated with it's visual item + */ + private fun select(font: EmojiCompatFont, radio: RadioButton) { + selected = font + // Uncheck all the other buttons + for (other in radioButtons) { + if (other !== radio) { + other.isChecked = false + } + } + radio.isChecked = true + } + + /** + * Called when a "consistent" state is reached, i.e. it's not downloading the font + * + * @param font The font to be displayed + * @param container The ConstraintLayout containing the item + */ + private fun updateItem(font: EmojiCompatFont, container: View) { + // Assignments + val download: ImageButton = container.findViewById(R.id.emojicompat_download) + val caption: TextView = container.findViewById(R.id.emojicompat_caption) + val progress: ProgressBar = container.findViewById(R.id.emojicompat_progress) + val cancel: ImageButton = container.findViewById(R.id.emojicompat_download_cancel) + val radio: RadioButton = container.findViewById(R.id.emojicompat_radio) + + // There's no download going on + progress.visibility = View.GONE + cancel.visibility = View.GONE + caption.visibility = View.VISIBLE + if (font.isDownloaded(context)) { + // Make it selectable + download.visibility = View.GONE + radio.visibility = View.VISIBLE + container.isClickable = true + } else { + // Make it downloadable + download.visibility = View.VISIBLE + radio.visibility = View.GONE + container.isClickable = false + } + + // Select it if necessary + if (font === selected) { + radio.isChecked = true + // Update available + if (!font.isDownloaded(context)) { + currentNeedsUpdate = true + } + } else { + radio.isChecked = false + } + } + + private fun saveSelectedFont() { + val index = selected.id + Log.i(TAG, "saveSelectedFont: Font ID: $index") + PreferenceManager + .getDefaultSharedPreferences(context) + .edit() + .putInt(key, index) + .apply() + summary = selected.getDisplay(context) + } + + /** + * User clicked ok -> save the selected font and offer to restart the app if something changed + */ + private fun onDialogOk() { + saveSelectedFont() + if (selected !== original || updated) { + AlertDialog.Builder(context) + .setTitle(R.string.restart_required) + .setMessage(R.string.restart_emoji) + .setNegativeButton(R.string.later, null) + .setPositiveButton(R.string.restart) { _, _ -> + // Restart the app + // From https://stackoverflow.com/a/17166729/5070653 + val launchIntent = Intent(context, SplashActivity::class.java) + val mPendingIntent = PendingIntent.getActivity( + context, + 0x1f973, // This is the codepoint of the party face emoji :D + launchIntent, + PendingIntent.FLAG_CANCEL_CURRENT) + val mgr = context.getSystemService(Context.ALARM_SERVICE) as AlarmManager + mgr.set( + AlarmManager.RTC, + System.currentTimeMillis() + 100, + mPendingIntent) + exitProcess(0) + }.show() + } + } + + companion object { + private const val TAG = "EmojiPreference" + + // Please note that this array must sorted in the same way as the fonts. + private val viewIds = intArrayOf( + R.id.item_nomoji, + R.id.item_blobmoji, + R.id.item_twemoji, + R.id.item_notoemoji + ) + } +} \ No newline at end of file diff --git a/app/src/main/java/com/keylesspalace/tusky/fragment/preference/NotificationPreferencesFragment.kt b/app/src/main/java/com/keylesspalace/tusky/components/preference/NotificationPreferencesFragment.kt similarity index 99% rename from app/src/main/java/com/keylesspalace/tusky/fragment/preference/NotificationPreferencesFragment.kt rename to app/src/main/java/com/keylesspalace/tusky/components/preference/NotificationPreferencesFragment.kt index 9d2aba2b..35f6ded3 100644 --- a/app/src/main/java/com/keylesspalace/tusky/fragment/preference/NotificationPreferencesFragment.kt +++ b/app/src/main/java/com/keylesspalace/tusky/components/preference/NotificationPreferencesFragment.kt @@ -13,7 +13,7 @@ * You should have received a copy of the GNU General Public License along with Tusky; if not, * see . */ -package com.keylesspalace.tusky.fragment.preference +package com.keylesspalace.tusky.components.preference import android.os.Bundle import androidx.preference.PreferenceFragmentCompat diff --git a/app/src/main/java/com/keylesspalace/tusky/PreferencesActivity.kt b/app/src/main/java/com/keylesspalace/tusky/components/preference/PreferencesActivity.kt similarity index 97% rename from app/src/main/java/com/keylesspalace/tusky/PreferencesActivity.kt rename to app/src/main/java/com/keylesspalace/tusky/components/preference/PreferencesActivity.kt index 054418aa..a791f920 100644 --- a/app/src/main/java/com/keylesspalace/tusky/PreferencesActivity.kt +++ b/app/src/main/java/com/keylesspalace/tusky/components/preference/PreferencesActivity.kt @@ -13,7 +13,7 @@ * You should have received a copy of the GNU General Public License along with Tusky; if not, * see . */ -package com.keylesspalace.tusky +package com.keylesspalace.tusky.components.preference import android.content.Context import android.content.Intent @@ -23,9 +23,11 @@ import android.util.Log import android.view.MenuItem import androidx.fragment.app.Fragment import androidx.preference.PreferenceManager +import com.keylesspalace.tusky.BaseActivity +import com.keylesspalace.tusky.MainActivity +import com.keylesspalace.tusky.R import com.keylesspalace.tusky.appstore.EventHub import com.keylesspalace.tusky.appstore.PreferenceChangedEvent -import com.keylesspalace.tusky.fragment.preference.* import com.keylesspalace.tusky.settings.PrefKeys import com.keylesspalace.tusky.util.ThemeUtils import com.keylesspalace.tusky.util.getNonNullString diff --git a/app/src/main/java/com/keylesspalace/tusky/fragment/preference/PreferencesFragment.kt b/app/src/main/java/com/keylesspalace/tusky/components/preference/PreferencesFragment.kt similarity index 97% rename from app/src/main/java/com/keylesspalace/tusky/fragment/preference/PreferencesFragment.kt rename to app/src/main/java/com/keylesspalace/tusky/components/preference/PreferencesFragment.kt index cbb30bab..3ca3e8b8 100644 --- a/app/src/main/java/com/keylesspalace/tusky/fragment/preference/PreferencesFragment.kt +++ b/app/src/main/java/com/keylesspalace/tusky/components/preference/PreferencesFragment.kt @@ -13,13 +13,13 @@ * You should have received a copy of the GNU General Public License along with Tusky; if not, * see . */ -package com.keylesspalace.tusky.fragment.preference +package com.keylesspalace.tusky.components.preference import android.os.Bundle import androidx.preference.Preference import androidx.preference.PreferenceFragmentCompat -import com.keylesspalace.tusky.PreferencesActivity import com.keylesspalace.tusky.R +import com.keylesspalace.tusky.di.Injectable import com.keylesspalace.tusky.settings.* import com.keylesspalace.tusky.util.ThemeUtils import com.keylesspalace.tusky.util.getNonNullString @@ -27,8 +27,13 @@ import com.mikepenz.iconics.IconicsDrawable import com.mikepenz.iconics.typeface.library.googlematerial.GoogleMaterial import com.mikepenz.iconics.utils.colorInt import com.mikepenz.iconics.utils.sizePx +import okhttp3.OkHttpClient +import javax.inject.Inject -class PreferencesFragment : PreferenceFragmentCompat() { +class PreferencesFragment : PreferenceFragmentCompat(), Injectable { + + @Inject + lateinit var okhttpclient: OkHttpClient private val iconSize by lazy { resources.getDimensionPixelSize(R.dimen.preference_icon_size) } private var httpProxyPref: Preference? = null @@ -47,7 +52,7 @@ class PreferencesFragment : PreferenceFragmentCompat() { icon = makeIcon(GoogleMaterial.Icon.gmd_palette) } - emojiPreference { + emojiPreference(okhttpclient) { setDefaultValue("system_default") setIcon(R.drawable.ic_emoji_24dp) key = PrefKeys.EMOJI diff --git a/app/src/main/java/com/keylesspalace/tusky/fragment/preference/ProxyPreferencesFragment.kt b/app/src/main/java/com/keylesspalace/tusky/components/preference/ProxyPreferencesFragment.kt similarity index 97% rename from app/src/main/java/com/keylesspalace/tusky/fragment/preference/ProxyPreferencesFragment.kt rename to app/src/main/java/com/keylesspalace/tusky/components/preference/ProxyPreferencesFragment.kt index e7ee7ade..922d5a7a 100644 --- a/app/src/main/java/com/keylesspalace/tusky/fragment/preference/ProxyPreferencesFragment.kt +++ b/app/src/main/java/com/keylesspalace/tusky/components/preference/ProxyPreferencesFragment.kt @@ -13,7 +13,7 @@ * You should have received a copy of the GNU General Public License along with Tusky; if not, * see . */ -package com.keylesspalace.tusky.fragment.preference +package com.keylesspalace.tusky.components.preference import android.os.Bundle import androidx.preference.PreferenceFragmentCompat diff --git a/app/src/main/java/com/keylesspalace/tusky/fragment/preference/TabFilterPreferencesFragment.kt b/app/src/main/java/com/keylesspalace/tusky/components/preference/TabFilterPreferencesFragment.kt similarity index 97% rename from app/src/main/java/com/keylesspalace/tusky/fragment/preference/TabFilterPreferencesFragment.kt rename to app/src/main/java/com/keylesspalace/tusky/components/preference/TabFilterPreferencesFragment.kt index cd76300d..71c5e10e 100644 --- a/app/src/main/java/com/keylesspalace/tusky/fragment/preference/TabFilterPreferencesFragment.kt +++ b/app/src/main/java/com/keylesspalace/tusky/components/preference/TabFilterPreferencesFragment.kt @@ -13,7 +13,7 @@ * You should have received a copy of the GNU General Public License along with Tusky; if not, * see . */ -package com.keylesspalace.tusky.fragment.preference +package com.keylesspalace.tusky.components.preference import android.os.Bundle import androidx.preference.PreferenceFragmentCompat diff --git a/app/src/main/java/com/keylesspalace/tusky/di/ActivitiesModule.kt b/app/src/main/java/com/keylesspalace/tusky/di/ActivitiesModule.kt index a8387bca..1d486369 100644 --- a/app/src/main/java/com/keylesspalace/tusky/di/ActivitiesModule.kt +++ b/app/src/main/java/com/keylesspalace/tusky/di/ActivitiesModule.kt @@ -19,6 +19,7 @@ import com.keylesspalace.tusky.* import com.keylesspalace.tusky.components.chat.ChatActivity import com.keylesspalace.tusky.components.compose.ComposeActivity import com.keylesspalace.tusky.components.instancemute.InstanceListActivity +import com.keylesspalace.tusky.components.preference.PreferencesActivity import com.keylesspalace.tusky.components.report.ReportActivity import com.keylesspalace.tusky.components.scheduled.ScheduledTootActivity import com.keylesspalace.tusky.components.search.SearchActivity diff --git a/app/src/main/java/com/keylesspalace/tusky/di/FragmentBuildersModule.kt b/app/src/main/java/com/keylesspalace/tusky/di/FragmentBuildersModule.kt index 3eef4972..ca95183a 100644 --- a/app/src/main/java/com/keylesspalace/tusky/di/FragmentBuildersModule.kt +++ b/app/src/main/java/com/keylesspalace/tusky/di/FragmentBuildersModule.kt @@ -20,14 +20,15 @@ import com.keylesspalace.tusky.AccountsInListFragment import com.keylesspalace.tusky.components.conversation.ConversationsFragment import com.keylesspalace.tusky.components.instancemute.fragment.InstanceListFragment import com.keylesspalace.tusky.fragment.* -import com.keylesspalace.tusky.fragment.preference.AccountPreferencesFragment -import com.keylesspalace.tusky.fragment.preference.NotificationPreferencesFragment +import com.keylesspalace.tusky.components.preference.AccountPreferencesFragment +import com.keylesspalace.tusky.components.preference.NotificationPreferencesFragment import com.keylesspalace.tusky.components.report.fragments.ReportDoneFragment import com.keylesspalace.tusky.components.report.fragments.ReportNoteFragment import com.keylesspalace.tusky.components.report.fragments.ReportStatusesFragment import com.keylesspalace.tusky.components.search.fragments.SearchAccountsFragment import com.keylesspalace.tusky.components.search.fragments.SearchHashtagsFragment import com.keylesspalace.tusky.components.search.fragments.SearchStatusesFragment +import com.keylesspalace.tusky.components.preference.PreferencesFragment import dagger.Module import dagger.android.ContributesAndroidInjector @@ -88,4 +89,7 @@ abstract class FragmentBuildersModule { @ContributesAndroidInjector abstract fun searchHashtagsFragment(): SearchHashtagsFragment + @ContributesAndroidInjector + abstract fun preferencesFragment(): PreferencesFragment + } diff --git a/app/src/main/java/com/keylesspalace/tusky/settings/SettingsConstants.kt b/app/src/main/java/com/keylesspalace/tusky/settings/SettingsConstants.kt index d912254c..42d67e76 100644 --- a/app/src/main/java/com/keylesspalace/tusky/settings/SettingsConstants.kt +++ b/app/src/main/java/com/keylesspalace/tusky/settings/SettingsConstants.kt @@ -17,7 +17,7 @@ object PrefKeys { // each preference a key for it to work. const val APP_THEME = "appTheme" - const val EMOJI = "emojiCompat" + const val EMOJI = "selected_emoji_font" const val FAB_HIDE = "fabHide" const val LANGUAGE = "language" const val STATUS_TEXT_SIZE = "statusTextSize" diff --git a/app/src/main/java/com/keylesspalace/tusky/settings/SettingsDSL.kt b/app/src/main/java/com/keylesspalace/tusky/settings/SettingsDSL.kt index 438580ee..82dfa14e 100644 --- a/app/src/main/java/com/keylesspalace/tusky/settings/SettingsDSL.kt +++ b/app/src/main/java/com/keylesspalace/tusky/settings/SettingsDSL.kt @@ -3,7 +3,8 @@ package com.keylesspalace.tusky.settings import android.content.Context import androidx.annotation.StringRes import androidx.preference.* -import com.keylesspalace.tusky.EmojiPreference +import com.keylesspalace.tusky.components.preference.EmojiPreference +import okhttp3.OkHttpClient class PreferenceParent( val context: Context, @@ -24,8 +25,8 @@ inline fun PreferenceParent.listPreference(builder: ListPreference.() -> Unit): return pref } -inline fun PreferenceParent.emojiPreference(builder: EmojiPreference.() -> Unit): EmojiPreference { - val pref = EmojiPreference(context) +inline fun PreferenceParent.emojiPreference(okHttpClient: OkHttpClient, builder: EmojiPreference.() -> Unit): EmojiPreference { + val pref = EmojiPreference(context, okHttpClient) builder(pref) addPref(pref) return pref diff --git a/app/src/main/java/com/keylesspalace/tusky/util/EmojiCompatFont.java b/app/src/main/java/com/keylesspalace/tusky/util/EmojiCompatFont.java deleted file mode 100644 index a0daedb5..00000000 --- a/app/src/main/java/com/keylesspalace/tusky/util/EmojiCompatFont.java +++ /dev/null @@ -1,569 +0,0 @@ -package com.keylesspalace.tusky.util; - -import android.content.Context; -import android.graphics.drawable.Drawable; -import android.os.AsyncTask; -import android.util.Log; -import android.util.Pair; - -import androidx.annotation.NonNull; -import androidx.annotation.Nullable; -import androidx.core.content.ContextCompat; -import androidx.emoji.text.EmojiCompat; -import androidx.emoji.bundled.BundledEmojiCompatConfig; - -import com.keylesspalace.tusky.R; - -import java.io.EOFException; -import java.io.File; -import java.io.FilenameFilter; -import java.io.IOException; -import java.util.ArrayList; -import java.util.Arrays; -import java.util.Collections; -import java.util.Comparator; -import java.util.List; -import java.util.regex.Matcher; -import java.util.regex.Pattern; - -import de.c1710.filemojicompat.FileEmojiCompatConfig; -import okhttp3.OkHttpClient; -import okhttp3.Request; -import okhttp3.Response; -import okhttp3.ResponseBody; -import okio.BufferedSink; -import okio.Okio; -import okio.Source; - - -/** - * This class bundles information about an emoji font as well as many convenient actions. - */ -public class EmojiCompatFont { - private static final String TAG = "EmojiCompatFont"; - /** - * This String represents the sub-directory the fonts are stored in. - */ - private static final String DIRECTORY = "emoji"; - - // These are the items which are also present in the JSON files - private final String name, display, url; - // The thumbnail image and the caption are provided as resource ids - private final int img, caption; - // The version is stored as a String in the x.xx.xx format (to be able to compare versions) - private final String version; - private final int[] versionCode; - private AsyncTask fontDownloader; - // The system font gets some special behavior... - private static final EmojiCompatFont SYSTEM_DEFAULT = - new EmojiCompatFont("system-default", - "System Default", - R.string.caption_systememoji, - R.drawable.ic_emoji_34dp, - "", - "0"); - private static final EmojiCompatFont BLOBMOJI = - new EmojiCompatFont("Blobmoji", - "Blobmoji", - R.string.caption_blobmoji, - R.drawable.ic_blobmoji, - "https://tusky.app/hosted/emoji/BlobmojiCompat.ttf", - "12.0.0" - ); - private static final EmojiCompatFont TWEMOJI = - new EmojiCompatFont("Twemoji", - "Twemoji", - R.string.caption_twemoji, - R.drawable.ic_twemoji, - "https://tusky.app/hosted/emoji/TwemojiCompat.ttf", - "12.0.0" - ); - private static final EmojiCompatFont NOTOEMOJI = - new EmojiCompatFont("NotoEmoji", - "Noto Emoji", - R.string.caption_notoemoji, - R.drawable.ic_notoemoji, - "https://tusky.app/hosted/emoji/NotoEmojiCompat.ttf", - "11.0.0" - ); - - /** - * This array stores all available EmojiCompat fonts. - * References to them can simply be saved by saving their indices - */ - public static final EmojiCompatFont[] FONTS = {SYSTEM_DEFAULT, BLOBMOJI, TWEMOJI, NOTOEMOJI}; - // A list of all available font files and whether they are older than the current version or not - // They are ordered by there version codes in ascending order - private ArrayList> existingFontFiles; - - private EmojiCompatFont(String name, - String display, - int caption, - int img, - String url, - String version) { - this.name = name; - this.display = display; - this.caption = caption; - this.img = img; - this.url = url; - this.version = version; - this.versionCode = getVersionCode(version); - } - - /** - * Returns the Emoji font associated with this ID - * - * @param id the ID of this font - * @return the corresponding font. Will default to SYSTEM_DEFAULT if not in range. - */ - public static EmojiCompatFont byId(int id) { - if (id >= 0 && id < FONTS.length) { - return FONTS[id]; - } else { - return SYSTEM_DEFAULT; - } - } - - public int getId() { - return Arrays.asList(FONTS).indexOf(this); - } - - public String getName() { - return name; - } - - - public String getDisplay(Context context) { - return this != SYSTEM_DEFAULT ? display : context.getString(R.string.system_default); - } - - public String getCaption(Context context) { - return context.getResources().getString(caption); - } - - public String getUrl() { - return url; - } - - public Drawable getThumb(Context context) { - return ContextCompat.getDrawable(context, img); - } - - public String getVersion() { - return version; - } - - public int[] getVersionCode() { - return versionCode; - } - - /** - * This method will return the actual font file (regardless of its existence) for - * the current version (not necessarily the latest!). - * - * @return The font (TTF) file or null if called on SYSTEM_FONT - */ - @Nullable - private File getFont(Context context) { - if (this != SYSTEM_DEFAULT) { - File directory = new File(context.getExternalFilesDir(null), DIRECTORY); - return new File(directory, this.getName() + this.getVersion() + ".ttf"); - } else { - return null; - } - } - - - public EmojiCompat.Config getConfig(Context context) { - if(this == SYSTEM_DEFAULT) { - return new BundledEmojiCompatConfig(context); - } - return new FileEmojiCompatConfig(context, getLatestFontFile(context)); - } - - public boolean isDownloaded(Context context) { - // The existence of the current version is actually checked twice, although the first method should - // be much faster and more common. - return this == SYSTEM_DEFAULT || getFont(context) != null - && (getFont(context).exists() || newerFileExists(context)); - } - - /** - * Checks whether there is already a font version that satisfies the current version, i.e. it - * has a higher or equal version code. - * - * @param context The Context - * @return Whether there is a font file with a higher or equal version code to the current - */ - private boolean newerFileExists(Context context) { - loadExistingFontFiles(context); - if (!existingFontFiles.isEmpty()) - // The last file is already the newest one... - return compareVersions(existingFontFiles.get(existingFontFiles.size() - 1).second, - getVersionCode()) >= 0; - return false; - } - - /** - * Downloads the TTF file for this font - * - * @param listeners The listeners which will be notified when the download has been finished - */ - public void downloadFont(Context context, Downloader.EmojiDownloadListener... listeners) { - if (this != SYSTEM_DEFAULT) { - // Additionally run a cleanup process after the download has been successful. - Downloader.EmojiDownloadListener cleanup = font -> deleteOldVersions(context); - - List allListeners - = new ArrayList<>(Arrays.asList(listeners)); - allListeners.add(cleanup); - Downloader.EmojiDownloadListener[] allListenersA = - new Downloader.EmojiDownloadListener[allListeners.size()]; - - fontDownloader = new Downloader( - this, - allListeners.toArray(allListenersA)) - .execute(getFont(context)); - } else { - for (Downloader.EmojiDownloadListener listener : listeners) { - // The system emoji font is always downloaded... - listener.onDownloaded(this); - } - } - } - - /** - * Deletes any older version of a font - * - * @param context The current Context - */ - private void deleteOldVersions(Context context) { - loadExistingFontFiles(context); - Log.d(TAG, "deleting old versions..."); - - Log.d(TAG, String.format("deleteOldVersions: Found %d other font files", existingFontFiles.size())); - for (Pair fileExists : existingFontFiles) { - if (compareVersions(fileExists.second, getVersionCode()) < 0) { - File file = fileExists.first; - // Uses side effects! - Log.d(TAG, String.format("Deleted %s successfully: %s", file.getAbsolutePath(), - file.delete())); - } - } - } - - private static final Comparator> pairComparator = (o1, o2) -> compareVersions(o1.second, o2.second); - - - /** - * Loads all font files that are inside the files directory into an ArrayList with the information - * on whether they are older than the currently available version or not. - * - * @param context The Context - */ - private void loadExistingFontFiles(Context context) { - // Only load it once - if (this.existingFontFiles == null) { - // If we call this on the system default font, just return nothing... - if (this == SYSTEM_DEFAULT) { - existingFontFiles = new ArrayList<>(0); - } - - File directory = new File(context.getExternalFilesDir(null), DIRECTORY); - // It will search for old versions using a regex that matches the font's name plus - // (if present) a version code. No version code will be regarded as version 0. - Pattern fontRegex = Pattern.compile(getName() + "(\\d+(\\.\\d+)*)?" + "\\.ttf"); - - - FilenameFilter ttfFilter = (dir, name) -> name.endsWith(".ttf"); - File[] existingFontFiles = directory.isDirectory() ? directory.listFiles(ttfFilter) : new File[0]; - Log.d(TAG, String.format("loadExistingFontFiles: %d other font files found", - existingFontFiles.length)); - // This is actually the upper bound - this.existingFontFiles = new ArrayList<>(existingFontFiles.length); - - - for (File file : existingFontFiles) { - Matcher matcher = fontRegex.matcher(file.getName()); - if (matcher.matches()) { - String version = matcher.group(1); - int[] versionCode = getVersionCode(version); - Pair entry = new Pair<>(file, versionCode); - // https://stackoverflow.com/a/51893026 - // Insert it in a sorted way - int index = Collections.binarySearch(this.existingFontFiles, entry, pairComparator); - if (index < 0) { - index = -index - 1; - } - this.existingFontFiles.add(index, entry); - } - } - } - } - - /** - * Returns the current or latest version of this font file (if there is any) - * - * @param context The Context - * @return The file for this font with the current or (if not existent) highest version code or null if there is no file for this font. - */ - private File getLatestFontFile(@NonNull Context context) { - File current = getFont(context); - if (current != null && current.exists()) - return current; - loadExistingFontFiles(context); - try { - return existingFontFiles.get(existingFontFiles.size() - 1).first; - } catch (IndexOutOfBoundsException e) { - return getFont(context); - } - } - - private @Nullable - int[] getVersionCode(@Nullable String version) { - if (version == null) - return null; - String[] versions = version.split("\\."); - int[] versionCodes = new int[versions.length]; - for (int i = 0; i < versions.length; i++) - versionCodes[i] = parseInt(versions[i], 0); - return versionCodes; - } - - /** - * A small helper method to convert a String to an int with a default value - * - * @param value The String to be parsed - * @param def The default value - * @return Either the String parsed to an int or - if this is not possible - the default value - */ - private int parseInt(@Nullable String value, int def) { - try { - return Integer.parseInt(value); - } catch (NumberFormatException | NullPointerException e) { - e.printStackTrace(); - return def; - } - } - - /** - * Compares two version codes to each other - * - * @param versionA The first version - * @param versionB The second version - * @return -1 if versionA < versionB, 1 if versionA > versionB and 0 otherwise - */ - private static int compareVersions(int[] versionA, int[] versionB) { - // This saves us much headache about handling a null version - if (versionA == null) - versionA = new int[]{0}; - - int len = Math.max(versionB.length, versionA.length); - - int vA, vB; - // Compare the versions - for (int i = 0; i < len; i++) { - // Just to make sure there is something specified here - if (versionA.length > i) { - vA = versionA[i]; - } else { - vA = 0; - } - if (versionB.length > i) { - vB = versionB[i]; - } else { - vB = 0; - } - - // It needs to be decided on the next level - if (vB == vA) - continue; - // Okay, is version B newer or version A? - return Integer.compare(vA, vB); - } - - // The versions are equal - return 0; - } - - /** - * Stops downloading the font. If no one started a font download, nothing happens. - */ - public void cancelDownload() { - if (fontDownloader != null) { - fontDownloader.cancel(false); - fontDownloader = null; - } - } - - /** - * This class is used to easily manage the download of a font - */ - public static class Downloader extends AsyncTask { - // All interested objects/methods - private final EmojiDownloadListener[] listeners; - // The MIME-Type which might be unnecessary - private static final String MIME = "application/woff"; - // The font belonging to this download - private final EmojiCompatFont font; - private static final String TAG = "Emoji-Font Downloader"; - private static long CHUNK_SIZE = 4096; - private boolean failed = false; - - Downloader(EmojiCompatFont font, EmojiDownloadListener... listeners) { - super(); - this.listeners = listeners; - this.font = font; - } - - @Override - protected File doInBackground(File... files) { - // Only download to one file... - File downloadFile = files[0]; - try { - // It is possible (and very likely) that the file does not exist yet - if (!downloadFile.exists()) { - downloadFile.getParentFile().mkdirs(); - downloadFile.createNewFile(); - } - OkHttpClient client = new OkHttpClient(); - Request request = new Request.Builder().url(font.getUrl()) - .addHeader("Content-Type", MIME) - .build(); - Response response = client.newCall(request).execute(); - BufferedSink sink = Okio.buffer(Okio.sink(downloadFile)); - Source source = null; - try { - long size; - // Download! - if (response.body() != null - && response.isSuccessful() - && (size = networkResponseLength(response)) > 0) { - float progress = 0; - source = response.body().source(); - try { - while (!isCancelled()) { - sink.write(response.body().source(), CHUNK_SIZE); - progress += CHUNK_SIZE; - publishProgress(progress / size); - } - } catch (EOFException ex) { - /* - This means we've finished downloading the file since sink.write - will throw an EOFException when the file to be read is empty. - */ - } - } else { - Log.e(TAG, "downloading " + font.getUrl() + " failed. No content to download."); - Log.e(TAG, "Status code: " + response.code()); - failed = true; - } - } finally { - if (source != null) { - source.close(); - } - sink.close(); - // This 'if' uses side effects to delete the File. - if (isCancelled() && !downloadFile.delete()) { - Log.e(TAG, "Could not delete file " + downloadFile); - } - } - } catch (IOException ex) { - ex.printStackTrace(); - failed = true; - } - return downloadFile; - } - - @Override - public void onProgressUpdate(Float... progress) { - for (EmojiDownloadListener listener : listeners) { - listener.onProgress(progress[0]); - } - } - - @Override - public void onPostExecute(File downloadedFile) { - if (!failed && downloadedFile.exists()) { - for (EmojiDownloadListener listener : listeners) { - listener.onDownloaded(font); - } - } else { - fail(downloadedFile); - } - } - - private void fail(File failedFile) { - if (failedFile.exists() && !failedFile.delete()) { - Log.e(TAG, "Could not delete file " + failedFile); - } - for (EmojiDownloadListener listener : listeners) { - listener.onFailed(); - } - } - - /** - * This interfaced is used to get notified when a download has been finished - */ - public interface EmojiDownloadListener { - /** - * Called after successfully finishing a download. - * - * @param font The font related to this download. This will help identifying the download - */ - void onDownloaded(EmojiCompatFont font); - - // TODO: Add functionality - - /** - * Called when something went wrong with the download. - * This one won't be called when the download has been cancelled though. - */ - default void onFailed() { - // Oh no! D: - } - - /** - * Called whenever the progress changed - * - * @param Progress A value between 0 and 1 representing the current progress - */ - default void onProgress(float Progress) { - // ARE WE THERE YET? - } - } - - - /** - * This method is needed because when transparent compression is used OkHttp reports - * {@link ResponseBody#contentLength()} as -1. We try to get the header which server sent - * us manually here. - * - * @see OkHttp issue 259 - */ - private long networkResponseLength(Response response) { - Response networkResponse = response.networkResponse(); - if (networkResponse == null) { - // In case it's a fully cached response - ResponseBody body = response.body(); - return body == null ? -1 : body.contentLength(); - } - String header = networkResponse.header("Content-Length"); - if (header == null) { - return -1; - } - try { - return Integer.parseInt(header); - } catch (NumberFormatException e) { - return -1; - } - } - } - - @Override - @NonNull - public String toString() { - return display; - } -} diff --git a/app/src/main/java/com/keylesspalace/tusky/util/EmojiCompatFont.kt b/app/src/main/java/com/keylesspalace/tusky/util/EmojiCompatFont.kt new file mode 100644 index 00000000..37de7d98 --- /dev/null +++ b/app/src/main/java/com/keylesspalace/tusky/util/EmojiCompatFont.kt @@ -0,0 +1,355 @@ +package com.keylesspalace.tusky.util + +import android.content.Context +import android.util.Log +import android.util.Pair +import androidx.annotation.DrawableRes +import androidx.annotation.StringRes +import androidx.annotation.VisibleForTesting +import androidx.emoji.text.EmojiCompat; +import androidx.emoji.bundled.BundledEmojiCompatConfig; +import com.keylesspalace.tusky.R +import de.c1710.filemojicompat.FileEmojiCompatConfig +import io.reactivex.Observable +import io.reactivex.ObservableEmitter +import io.reactivex.schedulers.Schedulers +import okhttp3.OkHttpClient +import okhttp3.Request +import okhttp3.Response +import okhttp3.ResponseBody +import okhttp3.internal.toLongOrDefault +import okio.Source +import okio.buffer +import okio.sink +import java.io.EOFException +import java.io.File +import java.io.FilenameFilter +import java.io.IOException +import kotlin.math.max + +/** + * This class bundles information about an emoji font as well as many convenient actions. + */ +class EmojiCompatFont( + val name: String, + private val display: String, + @StringRes val caption: Int, + @DrawableRes val img: Int, + val url: String, + // The version is stored as a String in the x.xx.xx format (to be able to compare versions) + val version: String) { + + private val versionCode = getVersionCode(version) + + // A list of all available font files and whether they are older than the current version or not + // They are ordered by their version codes in ascending order + private var existingFontFileCache: List>>? = null + + val id: Int + get() = FONTS.indexOf(this) + + fun getDisplay(context: Context): String { + return if (this !== SYSTEM_DEFAULT) display else context.getString(R.string.system_default) + } + + /** + * This method will return the actual font file (regardless of its existence) for + * the current version (not necessarily the latest!). + * + * @return The font (TTF) file or null if called on SYSTEM_FONT + */ + private fun getFontFile(context: Context): File? { + return if (this !== SYSTEM_DEFAULT) { + val directory = File(context.getExternalFilesDir(null), DIRECTORY) + File(directory, "$name$version.ttf") + } else { + null + } + } + + fun getConfig(context: Context): FileEmojiCompatConfig { + if(this === SYSTEM_DEFAULT) + return BundledEmojiCompatConfig(context); + return FileEmojiCompatConfig(context, getLatestFontFile(context)) + } + + fun isDownloaded(context: Context): Boolean { + return this === SYSTEM_DEFAULT || getFontFile(context)?.exists() == true || fontFileExists(context) + } + + /** + * Checks whether there is already a font version that satisfies the current version, i.e. it + * has a higher or equal version code. + * + * @param context The Context + * @return Whether there is a font file with a higher or equal version code to the current + */ + private fun fontFileExists(context: Context): Boolean { + val existingFontFiles = getExistingFontFiles(context) + return if (existingFontFiles.isNotEmpty()) { + compareVersions(existingFontFiles.last().second, versionCode) >= 0 + } else { + false + } + } + + /** + * Deletes any older version of a font + * + * @param context The current Context + */ + private fun deleteOldVersions(context: Context) { + val existingFontFiles = getExistingFontFiles(context) + Log.d(TAG, "deleting old versions...") + Log.d(TAG, String.format("deleteOldVersions: Found %d other font files", existingFontFiles.size)) + for (fileExists in existingFontFiles) { + if (compareVersions(fileExists.second, versionCode) < 0) { + val file = fileExists.first + // Uses side effects! + Log.d(TAG, String.format("Deleted %s successfully: %s", file.absolutePath, + file.delete())) + } + } + } + + /** + * Loads all font files that are inside the files directory into an ArrayList with the information + * on whether they are older than the currently available version or not. + * + * @param context The Context + */ + private fun getExistingFontFiles(context: Context): List>> { + // Only load it once + existingFontFileCache?.let { + return it + } + // If we call this on the system default font, just return nothing... + if (this === SYSTEM_DEFAULT) { + existingFontFileCache = emptyList() + return emptyList() + } + + val directory = File(context.getExternalFilesDir(null), DIRECTORY) + // It will search for old versions using a regex that matches the font's name plus + // (if present) a version code. No version code will be regarded as version 0. + val fontRegex = "$name(\\d+(\\.\\d+)*)?\\.ttf".toPattern() + val ttfFilter = FilenameFilter { _, name: String -> name.endsWith(".ttf") } + val foundFontFiles = directory.listFiles(ttfFilter).orEmpty() + Log.d(TAG, String.format("loadExistingFontFiles: %d other font files found", + foundFontFiles.size)) + + return foundFontFiles.map { file -> + val matcher = fontRegex.matcher(file.name) + val versionCode = if (matcher.matches()) { + val version = matcher.group(1) + getVersionCode(version) + } else { + listOf(0) + } + Pair(file, versionCode) + }.sortedWith( + Comparator>> { a, b -> compareVersions(a.second, b.second) } + ).also { + existingFontFileCache = it + } + } + + /** + * Returns the current or latest version of this font file (if there is any) + * + * @param context The Context + * @return The file for this font with the current or (if not existent) highest version code or null if there is no file for this font. + */ + private fun getLatestFontFile(context: Context): File? { + val current = getFontFile(context) + if (current != null && current.exists()) return current + val existingFontFiles = getExistingFontFiles(context) + return existingFontFiles.firstOrNull()?.first + } + + private fun getVersionCode(version: String?): List { + if (version == null) return listOf(0) + return version.split(".").map { + it.toIntOrNull() ?: 0 + } + } + + fun downloadFontFile(context: Context, + okHttpClient: OkHttpClient): Observable { + return Observable.create { emitter: ObservableEmitter -> + // It is possible (and very likely) that the file does not exist yet + val downloadFile = getFontFile(context)!! + if (!downloadFile.exists()) { + downloadFile.parentFile?.mkdirs() + downloadFile.createNewFile() + } + val request = Request.Builder().url(url) + .build() + + val sink = downloadFile.sink().buffer() + var source: Source? = null + try { + // Download! + val response = okHttpClient.newCall(request).execute() + + val responseBody = response.body + if (response.isSuccessful && responseBody != null) { + val size = response.length() + var progress = 0f + source = responseBody.source() + try { + while (!emitter.isDisposed) { + sink.write(source, CHUNK_SIZE) + progress += CHUNK_SIZE.toFloat() + if(size > 0) { + emitter.onNext(progress / size) + } else { + emitter.onNext(-1f) + } + } + } catch (ex: EOFException) { + /* + This means we've finished downloading the file since sink.write + will throw an EOFException when the file to be read is empty. + */ + } + } else { + Log.e(TAG, "Downloading $url failed. Status code: ${response.code}") + emitter.tryOnError(Exception()) + } + + } catch (ex: IOException) { + Log.e(TAG, "Downloading $url failed.", ex) + downloadFile.deleteIfExists() + emitter.tryOnError(ex) + } finally { + source?.close() + sink.close() + if (emitter.isDisposed) { + downloadFile.deleteIfExists() + } else { + deleteOldVersions(context) + emitter.onComplete() + } + } + + } + .subscribeOn(Schedulers.io()) + + } + + /** + * Deletes the downloaded file, if it exists. Should be called when a download gets cancelled. + */ + fun deleteDownloadedFile(context: Context) { + getFontFile(context)?.deleteIfExists() + } + + override fun toString(): String { + return display + } + + companion object { + private const val TAG = "EmojiCompatFont" + + /** + * This String represents the sub-directory the fonts are stored in. + */ + private const val DIRECTORY = "emoji" + + private const val CHUNK_SIZE = 4096L + + // The system font gets some special behavior... + private val SYSTEM_DEFAULT = EmojiCompatFont("system-default", + "System Default", + R.string.caption_systememoji, + R.drawable.ic_emoji_34dp, + "", + "0") + private val BLOBMOJI = EmojiCompatFont("Blobmoji", + "Blobmoji", + R.string.caption_blobmoji, + R.drawable.ic_blobmoji, + "https://tusky.app/hosted/emoji/BlobmojiCompat.ttf", + "12.0.0" + ) + private val TWEMOJI = EmojiCompatFont("Twemoji", + "Twemoji", + R.string.caption_twemoji, + R.drawable.ic_twemoji, + "https://tusky.app/hosted/emoji/TwemojiCompat.ttf", + "12.0.0" + ) + private val NOTOEMOJI = EmojiCompatFont("NotoEmoji", + "Noto Emoji", + R.string.caption_notoemoji, + R.drawable.ic_notoemoji, + "https://tusky.app/hosted/emoji/NotoEmojiCompat.ttf", + "11.0.0" + ) + + /** + * This array stores all available EmojiCompat fonts. + * References to them can simply be saved by saving their indices + */ + val FONTS = listOf(SYSTEM_DEFAULT, BLOBMOJI, TWEMOJI, NOTOEMOJI) + + /** + * Returns the Emoji font associated with this ID + * + * @param id the ID of this font + * @return the corresponding font. Will default to SYSTEM_DEFAULT if not in range. + */ + fun byId(id: Int): EmojiCompatFont = FONTS.getOrElse(id) { SYSTEM_DEFAULT } + + /** + * Compares two version codes to each other + * + * @param versionA The first version + * @param versionB The second version + * @return -1 if versionA < versionB, 1 if versionA > versionB and 0 otherwise + */ + @VisibleForTesting + fun compareVersions(versionA: List, versionB: List): Int { + val len = max(versionB.size, versionA.size) + for (i in 0 until len) { + + val vA = versionA.getOrElse(i) { 0 } + val vB = versionB.getOrElse(i) { 0 } + + // It needs to be decided on the next level + if (vA == vB) continue + // Okay, is version B newer or version A? + return vA.compareTo(vB) + } + + // The versions are equal + return 0 + } + + /** + * This method is needed because when transparent compression is used OkHttp reports + * [ResponseBody.contentLength] as -1. We try to get the header which server sent + * us manually here. + * + * @see [OkHttp issue 259](https://github.com/square/okhttp/issues/259) + */ + private fun Response.length(): Long { + networkResponse?.let { + val header = it.header("Content-Length") ?: return -1 + return header.toLongOrDefault(-1) + } + + // In case it's a fully cached response + return body?.contentLength() ?: -1 + } + + private fun File.deleteIfExists() { + if(exists() && !delete()) { + Log.e(TAG, "Could not delete file $this") + } + } + + } + +} diff --git a/app/src/main/res/layout/activity_preferences.xml b/app/src/main/res/layout/activity_preferences.xml index 3fa27457..1931fe08 100644 --- a/app/src/main/res/layout/activity_preferences.xml +++ b/app/src/main/res/layout/activity_preferences.xml @@ -5,7 +5,7 @@ android:id="@+id/activity_view_thread" android:layout_width="match_parent" android:layout_height="match_parent" - tools:context="com.keylesspalace.tusky.PreferencesActivity"> + tools:context="com.keylesspalace.tusky.components.preference.PreferencesActivity"> diff --git a/app/src/main/res/layout/dialog_emojicompat.xml b/app/src/main/res/layout/dialog_emojicompat.xml index 923b69ea..6850e045 100644 --- a/app/src/main/res/layout/dialog_emojicompat.xml +++ b/app/src/main/res/layout/dialog_emojicompat.xml @@ -1,33 +1,25 @@ - - - - + - + - + - - + + android:textColor="?android:attr/textColorSecondary" /> - \ No newline at end of file + \ No newline at end of file diff --git a/app/src/test/java/com/keylesspalace/tusky/util/EmojiCompatFontTest.kt b/app/src/test/java/com/keylesspalace/tusky/util/EmojiCompatFontTest.kt new file mode 100644 index 00000000..badaa709 --- /dev/null +++ b/app/src/test/java/com/keylesspalace/tusky/util/EmojiCompatFontTest.kt @@ -0,0 +1,47 @@ +package com.keylesspalace.tusky.util + +import org.junit.Assert.* +import org.junit.Test + +class EmojiCompatFontTest { + + @Test + fun testCompareVersions() { + + assertEquals( + -1, + EmojiCompatFont.compareVersions( + listOf(0), + listOf(1, 2, 3) + ) + ) + assertEquals( + 1, + EmojiCompatFont.compareVersions( + listOf(1, 2, 3), + listOf(0, 0, 0) + ) + ) + assertEquals( + -1, + EmojiCompatFont.compareVersions( + listOf(1, 0, 1), + listOf(1, 1, 0) + ) + ) + assertEquals( + 0, + EmojiCompatFont.compareVersions( + listOf(4, 5, 6), + listOf(4, 5, 6) + ) + ) + assertEquals( + 0, + EmojiCompatFont.compareVersions( + listOf(0, 0), + listOf(0) + ) + ) + } +} \ No newline at end of file