convert EmojiPreference and EmojiCompatFont to Kotlin (#1922)

* convert EmojiPreference and EmojiCompatFont to Kotlin

* move preference related to to dedicated preference package

* update proguard-rules.pro

* reformat & add comment

* maintain disposable information in EmojiPreference instead of EmojiCompatFont
This commit is contained in:
Konrad Pozniak 2020-09-02 12:27:51 +02:00 committed by GitHub
parent fc7b02d987
commit 1d309850b0
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
21 changed files with 709 additions and 906 deletions

View File

@ -55,6 +55,9 @@
public static *** v(...); public static *** v(...);
public static *** i(...); public static *** i(...);
} }
-assumenosideeffects class java.lang.String {
public static java.lang.String format(...);
}
# remove some kotlin overhead # remove some kotlin overhead
-assumenosideeffects class kotlin.jvm.internal.Intrinsics { -assumenosideeffects class kotlin.jvm.internal.Intrinsics {
@ -62,8 +65,3 @@
static void checkExpressionValueIsNotNull(java.lang.Object, java.lang.String); static void checkExpressionValueIsNotNull(java.lang.Object, java.lang.String);
static void throwUninitializedPropertyAccessException(java.lang.String); static void throwUninitializedPropertyAccessException(java.lang.String);
} }
# without this emoji font downloading fails with AbstractMethodError
-keep class * extends android.os.AsyncTask {
public *;
}

View File

@ -117,7 +117,7 @@
android:name=".AccountActivity" android:name=".AccountActivity"
android:configChanges="orientation|screenSize|keyboardHidden|screenLayout|smallestScreenSize" /> android:configChanges="orientation|screenSize|keyboardHidden|screenLayout|smallestScreenSize" />
<activity android:name=".EditProfileActivity" /> <activity android:name=".EditProfileActivity" />
<activity android:name=".PreferencesActivity" /> <activity android:name=".components.preference.PreferencesActivity" />
<activity android:name=".StatusListActivity" /> <activity android:name=".StatusListActivity" />
<activity android:name=".AccountListActivity" /> <activity android:name=".AccountListActivity" />
<activity android:name=".AboutActivity" /> <activity android:name=".AboutActivity" />

View File

@ -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<RadioButton> 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();
}
}
}

View File

@ -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.compose.ComposeActivity.Companion.canHandleMimeType
import com.keylesspalace.tusky.components.conversation.ConversationsRepository import com.keylesspalace.tusky.components.conversation.ConversationsRepository
import com.keylesspalace.tusky.components.notifications.NotificationHelper 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.scheduled.ScheduledTootActivity
import com.keylesspalace.tusky.components.search.SearchActivity import com.keylesspalace.tusky.components.search.SearchActivity
import com.keylesspalace.tusky.db.AccountEntity import com.keylesspalace.tusky.db.AccountEntity

View File

@ -24,6 +24,7 @@ import androidx.preference.PreferenceManager
import androidx.work.WorkManager import androidx.work.WorkManager
import com.keylesspalace.tusky.components.notifications.NotificationWorkerFactory import com.keylesspalace.tusky.components.notifications.NotificationWorkerFactory
import com.keylesspalace.tusky.di.AppInjector import com.keylesspalace.tusky.di.AppInjector
import com.keylesspalace.tusky.settings.PrefKeys
import com.keylesspalace.tusky.util.* import com.keylesspalace.tusky.util.*
import com.uber.autodispose.AutoDisposePlugins import com.uber.autodispose.AutoDisposePlugins
import dagger.android.DispatchingAndroidInjector import dagger.android.DispatchingAndroidInjector
@ -53,7 +54,7 @@ class TuskyApplication : Application(), HasAndroidInjector {
val preferences = PreferenceManager.getDefaultSharedPreferences(this) val preferences = PreferenceManager.getDefaultSharedPreferences(this)
// init the custom emoji fonts // 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) val emojiConfig = EmojiCompatFont.byId(emojiSelection)
.getConfig(this) .getConfig(this)
.setReplaceAll(true) .setReplaceAll(true)

View File

@ -13,7 +13,7 @@
* You should have received a copy of the GNU General Public License along with Tusky; if not, * You should have received a copy of the GNU General Public License along with Tusky; if not,
* see <http://www.gnu.org/licenses>. */ * see <http://www.gnu.org/licenses>. */
package com.keylesspalace.tusky.fragment.preference package com.keylesspalace.tusky.components.preference
import android.content.Intent import android.content.Intent
import android.graphics.drawable.Drawable import android.graphics.drawable.Drawable

View File

@ -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<RadioButton>()
private var updated = false
private var currentNeedsUpdate = false
private val downloadDisposables = MutableList<Disposable?>(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
)
}
}

View File

@ -13,7 +13,7 @@
* You should have received a copy of the GNU General Public License along with Tusky; if not, * You should have received a copy of the GNU General Public License along with Tusky; if not,
* see <http://www.gnu.org/licenses>. */ * see <http://www.gnu.org/licenses>. */
package com.keylesspalace.tusky.fragment.preference package com.keylesspalace.tusky.components.preference
import android.os.Bundle import android.os.Bundle
import androidx.preference.PreferenceFragmentCompat import androidx.preference.PreferenceFragmentCompat

View File

@ -13,7 +13,7 @@
* You should have received a copy of the GNU General Public License along with Tusky; if not, * You should have received a copy of the GNU General Public License along with Tusky; if not,
* see <http://www.gnu.org/licenses>. */ * see <http://www.gnu.org/licenses>. */
package com.keylesspalace.tusky package com.keylesspalace.tusky.components.preference
import android.content.Context import android.content.Context
import android.content.Intent import android.content.Intent
@ -23,9 +23,11 @@ import android.util.Log
import android.view.MenuItem import android.view.MenuItem
import androidx.fragment.app.Fragment import androidx.fragment.app.Fragment
import androidx.preference.PreferenceManager 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.EventHub
import com.keylesspalace.tusky.appstore.PreferenceChangedEvent import com.keylesspalace.tusky.appstore.PreferenceChangedEvent
import com.keylesspalace.tusky.fragment.preference.*
import com.keylesspalace.tusky.settings.PrefKeys import com.keylesspalace.tusky.settings.PrefKeys
import com.keylesspalace.tusky.util.ThemeUtils import com.keylesspalace.tusky.util.ThemeUtils
import com.keylesspalace.tusky.util.getNonNullString import com.keylesspalace.tusky.util.getNonNullString

View File

@ -13,13 +13,13 @@
* You should have received a copy of the GNU General Public License along with Tusky; if not, * You should have received a copy of the GNU General Public License along with Tusky; if not,
* see <http://www.gnu.org/licenses>. */ * see <http://www.gnu.org/licenses>. */
package com.keylesspalace.tusky.fragment.preference package com.keylesspalace.tusky.components.preference
import android.os.Bundle import android.os.Bundle
import androidx.preference.Preference import androidx.preference.Preference
import androidx.preference.PreferenceFragmentCompat import androidx.preference.PreferenceFragmentCompat
import com.keylesspalace.tusky.PreferencesActivity
import com.keylesspalace.tusky.R import com.keylesspalace.tusky.R
import com.keylesspalace.tusky.di.Injectable
import com.keylesspalace.tusky.settings.* import com.keylesspalace.tusky.settings.*
import com.keylesspalace.tusky.util.ThemeUtils import com.keylesspalace.tusky.util.ThemeUtils
import com.keylesspalace.tusky.util.getNonNullString 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.typeface.library.googlematerial.GoogleMaterial
import com.mikepenz.iconics.utils.colorInt import com.mikepenz.iconics.utils.colorInt
import com.mikepenz.iconics.utils.sizePx 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 val iconSize by lazy { resources.getDimensionPixelSize(R.dimen.preference_icon_size) }
private var httpProxyPref: Preference? = null private var httpProxyPref: Preference? = null
@ -47,7 +52,7 @@ class PreferencesFragment : PreferenceFragmentCompat() {
icon = makeIcon(GoogleMaterial.Icon.gmd_palette) icon = makeIcon(GoogleMaterial.Icon.gmd_palette)
} }
emojiPreference { emojiPreference(okhttpclient) {
setDefaultValue("system_default") setDefaultValue("system_default")
setIcon(R.drawable.ic_emoji_24dp) setIcon(R.drawable.ic_emoji_24dp)
key = PrefKeys.EMOJI key = PrefKeys.EMOJI

View File

@ -13,7 +13,7 @@
* You should have received a copy of the GNU General Public License along with Tusky; if not, * You should have received a copy of the GNU General Public License along with Tusky; if not,
* see <http://www.gnu.org/licenses>. */ * see <http://www.gnu.org/licenses>. */
package com.keylesspalace.tusky.fragment.preference package com.keylesspalace.tusky.components.preference
import android.os.Bundle import android.os.Bundle
import androidx.preference.PreferenceFragmentCompat import androidx.preference.PreferenceFragmentCompat

View File

@ -13,7 +13,7 @@
* You should have received a copy of the GNU General Public License along with Tusky; if not, * You should have received a copy of the GNU General Public License along with Tusky; if not,
* see <http://www.gnu.org/licenses>. */ * see <http://www.gnu.org/licenses>. */
package com.keylesspalace.tusky.fragment.preference package com.keylesspalace.tusky.components.preference
import android.os.Bundle import android.os.Bundle
import androidx.preference.PreferenceFragmentCompat import androidx.preference.PreferenceFragmentCompat

View File

@ -18,6 +18,7 @@ package com.keylesspalace.tusky.di
import com.keylesspalace.tusky.* import com.keylesspalace.tusky.*
import com.keylesspalace.tusky.components.compose.ComposeActivity import com.keylesspalace.tusky.components.compose.ComposeActivity
import com.keylesspalace.tusky.components.instancemute.InstanceListActivity 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.report.ReportActivity
import com.keylesspalace.tusky.components.scheduled.ScheduledTootActivity import com.keylesspalace.tusky.components.scheduled.ScheduledTootActivity
import com.keylesspalace.tusky.components.search.SearchActivity import com.keylesspalace.tusky.components.search.SearchActivity

View File

@ -20,14 +20,15 @@ import com.keylesspalace.tusky.AccountsInListFragment
import com.keylesspalace.tusky.components.conversation.ConversationsFragment import com.keylesspalace.tusky.components.conversation.ConversationsFragment
import com.keylesspalace.tusky.components.instancemute.fragment.InstanceListFragment import com.keylesspalace.tusky.components.instancemute.fragment.InstanceListFragment
import com.keylesspalace.tusky.fragment.* import com.keylesspalace.tusky.fragment.*
import com.keylesspalace.tusky.fragment.preference.AccountPreferencesFragment import com.keylesspalace.tusky.components.preference.AccountPreferencesFragment
import com.keylesspalace.tusky.fragment.preference.NotificationPreferencesFragment import com.keylesspalace.tusky.components.preference.NotificationPreferencesFragment
import com.keylesspalace.tusky.components.report.fragments.ReportDoneFragment import com.keylesspalace.tusky.components.report.fragments.ReportDoneFragment
import com.keylesspalace.tusky.components.report.fragments.ReportNoteFragment import com.keylesspalace.tusky.components.report.fragments.ReportNoteFragment
import com.keylesspalace.tusky.components.report.fragments.ReportStatusesFragment import com.keylesspalace.tusky.components.report.fragments.ReportStatusesFragment
import com.keylesspalace.tusky.components.search.fragments.SearchAccountsFragment import com.keylesspalace.tusky.components.search.fragments.SearchAccountsFragment
import com.keylesspalace.tusky.components.search.fragments.SearchHashtagsFragment import com.keylesspalace.tusky.components.search.fragments.SearchHashtagsFragment
import com.keylesspalace.tusky.components.search.fragments.SearchStatusesFragment import com.keylesspalace.tusky.components.search.fragments.SearchStatusesFragment
import com.keylesspalace.tusky.components.preference.PreferencesFragment
import dagger.Module import dagger.Module
import dagger.android.ContributesAndroidInjector import dagger.android.ContributesAndroidInjector
@ -85,4 +86,7 @@ abstract class FragmentBuildersModule {
@ContributesAndroidInjector @ContributesAndroidInjector
abstract fun searchHashtagsFragment(): SearchHashtagsFragment abstract fun searchHashtagsFragment(): SearchHashtagsFragment
@ContributesAndroidInjector
abstract fun preferencesFragment(): PreferencesFragment
} }

View File

@ -17,7 +17,7 @@ object PrefKeys {
// each preference a key for it to work. // each preference a key for it to work.
const val APP_THEME = "appTheme" const val APP_THEME = "appTheme"
const val EMOJI = "emojiCompat" const val EMOJI = "selected_emoji_font"
const val FAB_HIDE = "fabHide" const val FAB_HIDE = "fabHide"
const val LANGUAGE = "language" const val LANGUAGE = "language"
const val STATUS_TEXT_SIZE = "statusTextSize" const val STATUS_TEXT_SIZE = "statusTextSize"

View File

@ -3,7 +3,8 @@ package com.keylesspalace.tusky.settings
import android.content.Context import android.content.Context
import androidx.annotation.StringRes import androidx.annotation.StringRes
import androidx.preference.* import androidx.preference.*
import com.keylesspalace.tusky.EmojiPreference import com.keylesspalace.tusky.components.preference.EmojiPreference
import okhttp3.OkHttpClient
class PreferenceParent( class PreferenceParent(
val context: Context, val context: Context,
@ -24,8 +25,8 @@ inline fun PreferenceParent.listPreference(builder: ListPreference.() -> Unit):
return pref return pref
} }
inline fun PreferenceParent.emojiPreference(builder: EmojiPreference.() -> Unit): EmojiPreference { inline fun PreferenceParent.emojiPreference(okHttpClient: OkHttpClient, builder: EmojiPreference.() -> Unit): EmojiPreference {
val pref = EmojiPreference(context) val pref = EmojiPreference(context, okHttpClient)
builder(pref) builder(pref)
addPref(pref) addPref(pref)
return pref return pref

View File

@ -1,564 +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 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<Pair<File, int[]>> 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 FileEmojiCompatConfig getConfig(Context 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<Downloader.EmojiDownloadListener> 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<File, int[]> 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<Pair<File, int[]>> 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<File, int[]> 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<File, Float, File> {
// 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 <a href="https://github.com/square/okhttp/issues/259">OkHttp issue 259</a>
*/
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;
}
}

View File

@ -0,0 +1,351 @@
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 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<Pair<File, List<Int>>>? = 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 {
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<Pair<File, List<Int>>> {
// 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<Pair<File, List<Int>>> { 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<Int> {
if (version == null) return listOf(0)
return version.split(".").map {
it.toIntOrNull() ?: 0
}
}
fun downloadFontFile(context: Context,
okHttpClient: OkHttpClient): Observable<Float> {
return Observable.create { emitter: ObservableEmitter<Float> ->
// 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<Int>, versionB: List<Int>): 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")
}
}
}
}

View File

@ -5,7 +5,7 @@
android:id="@+id/activity_view_thread" android:id="@+id/activity_view_thread"
android:layout_width="match_parent" android:layout_width="match_parent"
android:layout_height="match_parent" android:layout_height="match_parent"
tools:context="com.keylesspalace.tusky.PreferencesActivity"> tools:context="com.keylesspalace.tusky.components.preference.PreferencesActivity">
<include layout="@layout/toolbar_basic" /> <include layout="@layout/toolbar_basic" />

View File

@ -1,33 +1,25 @@
<?xml version="1.0" encoding="utf-8"?> <?xml version="1.0" encoding="utf-8"?>
<androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android" <LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
android:layout_width="match_parent" android:layout_width="match_parent"
android:layout_height="match_parent" android:layout_height="match_parent"
android:orientation="vertical"
android:paddingTop="16dp"> android:paddingTop="16dp">
<LinearLayout <include
android:id="@+id/emoji_font_list" android:id="@+id/item_blobmoji"
android:layout_width="match_parent" layout="@layout/item_emoji_pref" />
android:layout_height="wrap_content"
android:orientation="vertical"
app:layout_constraintTop_toTopOf="parent">
<include <include
android:id="@+id/item_blobmoji" android:id="@+id/item_twemoji"
layout="@layout/item_emoji_pref" /> layout="@layout/item_emoji_pref" />
<include <include
android:id="@+id/item_twemoji" android:id="@+id/item_notoemoji"
layout="@layout/item_emoji_pref" /> layout="@layout/item_emoji_pref" />
<include <include
android:id="@+id/item_notoemoji" android:id="@+id/item_nomoji"
layout="@layout/item_emoji_pref" /> layout="@layout/item_emoji_pref" />
<include
android:id="@+id/item_nomoji"
layout="@layout/item_emoji_pref" />
</LinearLayout>
<TextView <TextView
android:id="@+id/emoji_download_label" android:id="@+id/emoji_download_label"
@ -39,9 +31,6 @@
android:paddingEnd="24dp" android:paddingEnd="24dp"
android:paddingBottom="8dp" android:paddingBottom="8dp"
android:text="@string/download_fonts" android:text="@string/download_fonts"
android:textColor="?android:attr/textColorSecondary" android:textColor="?android:attr/textColorSecondary" />
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@+id/emoji_font_list" />
</androidx.constraintlayout.widget.ConstraintLayout> </LinearLayout>

View File

@ -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)
)
)
}
}