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 EmojiCompatFontmain
parent
0b494c5011
commit
ac00c62e5e
@ -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(); |
||||
} |
||||
|
||||
} |
||||
|
||||
} |
@ -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 |
||||
) |
||||
} |
||||
} |
@ -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<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 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<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; |
||||
} |
||||
} |
@ -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<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 { |
||||
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<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") |
||||
} |
||||
} |
||||
|
||||
} |
||||
|
||||
} |
@ -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) |
||||
) |
||||
) |
||||
} |
||||
} |
Loading…
Reference in new issue