diff --git a/app/build.gradle b/app/build.gradle index 91a1e653b..d8d7bb45c 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -71,6 +71,10 @@ dependencies { } implementation 'com.evernote:android-job:1.2.5' implementation 'com.android.support.constraint:constraint-layout:1.1.0' + // EmojiCompat + implementation "com.android.support:support-emoji:$supportLibraryVersion" + implementation "com.android.support:support-emoji-appcompat:$supportLibraryVersion" + implementation "de.c1710:filemojicompat:1.0.5" //room implementation 'android.arch.persistence.room:runtime:1.0.0' kapt 'android.arch.persistence.room:compiler:1.0.0' diff --git a/app/src/main/assets/LICENSE_APACHE b/app/src/main/assets/LICENSE_APACHE new file mode 100644 index 000000000..8e66e5790 --- /dev/null +++ b/app/src/main/assets/LICENSE_APACHE @@ -0,0 +1,201 @@ + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + +TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + +1. Definitions. + +"License" shall mean the terms and conditions for use, reproduction, +and distribution as defined by Sections 1 through 9 of this document. + +"Licensor" shall mean the copyright owner or entity authorized by +the copyright owner that is granting the License. + +"Legal Entity" shall mean the union of the acting entity and all +other entities that control, are controlled by, or are under common +control with that entity. For the purposes of this definition, +"control" means (i) the power, direct or indirect, to cause the +direction or management of such entity, whether by contract or +otherwise, or (ii) ownership of fifty percent (50%) or more of the +outstanding shares, or (iii) beneficial ownership of such entity. + +"You" (or "Your") shall mean an individual or Legal Entity +exercising permissions granted by this License. + +"Source" form shall mean the preferred form for making modifications, +including but not limited to software source code, documentation +source, and configuration files. + +"Object" form shall mean any form resulting from mechanical +transformation or translation of a Source form, including but +not limited to compiled object code, generated documentation, +and conversions to other media types. + +"Work" shall mean the work of authorship, whether in Source or +Object form, made available under the License, as indicated by a +copyright notice that is included in or attached to the work +(an example is provided in the Appendix below). + +"Derivative Works" shall mean any work, whether in Source or Object +form, that is based on (or derived from) the Work and for which the +editorial revisions, annotations, elaborations, or other modifications +represent, as a whole, an original work of authorship. For the purposes +of this License, Derivative Works shall not include works that remain +separable from, or merely link (or bind by name) to the interfaces of, +the Work and Derivative Works thereof. + +"Contribution" shall mean any work of authorship, including +the original version of the Work and any modifications or additions +to that Work or Derivative Works thereof, that is intentionally +submitted to Licensor for inclusion in the Work by the copyright owner +or by an individual or Legal Entity authorized to submit on behalf of +the copyright owner. For the purposes of this definition, "submitted" +means any form of electronic, verbal, or written communication sent +to the Licensor or its representatives, including but not limited to +communication on electronic mailing lists, source code control systems, +and issue tracking systems that are managed by, or on behalf of, the +Licensor for the purpose of discussing and improving the Work, but +excluding communication that is conspicuously marked or otherwise +designated in writing by the copyright owner as "Not a Contribution." + +"Contributor" shall mean Licensor and any individual or Legal Entity +on behalf of whom a Contribution has been received by Licensor and +subsequently incorporated within the Work. + +2. Grant of Copyright License. Subject to the terms and conditions of +this License, each Contributor hereby grants to You a perpetual, +worldwide, non-exclusive, no-charge, royalty-free, irrevocable +copyright license to reproduce, prepare Derivative Works of, +publicly display, publicly perform, sublicense, and distribute the +Work and such Derivative Works in Source or Object form. + +3. Grant of Patent License. Subject to the terms and conditions of +this License, each Contributor hereby grants to You a perpetual, +worldwide, non-exclusive, no-charge, royalty-free, irrevocable +(except as stated in this section) patent license to make, have made, +use, offer to sell, sell, import, and otherwise transfer the Work, +where such license applies only to those patent claims licensable +by such Contributor that are necessarily infringed by their +Contribution(s) alone or by combination of their Contribution(s) +with the Work to which such Contribution(s) was submitted. If You +institute patent litigation against any entity (including a +cross-claim or counterclaim in a lawsuit) alleging that the Work +or a Contribution incorporated within the Work constitutes direct +or contributory patent infringement, then any patent licenses +granted to You under this License for that Work shall terminate +as of the date such litigation is filed. + +4. Redistribution. You may reproduce and distribute copies of the +Work or Derivative Works thereof in any medium, with or without +modifications, and in Source or Object form, provided that You +meet the following conditions: + +(a) You must give any other recipients of the Work or +Derivative Works a copy of this License; and + +(b) You must cause any modified files to carry prominent notices +stating that You changed the files; and + +(c) You must retain, in the Source form of any Derivative Works +that You distribute, all copyright, patent, trademark, and +attribution notices from the Source form of the Work, +excluding those notices that do not pertain to any part of +the Derivative Works; and + +(d) If the Work includes a "NOTICE" text file as part of its +distribution, then any Derivative Works that You distribute must +include a readable copy of the attribution notices contained +within such NOTICE file, excluding those notices that do not +pertain to any part of the Derivative Works, in at least one +of the following places: within a NOTICE text file distributed +as part of the Derivative Works; within the Source form or +documentation, if provided along with the Derivative Works; or, +within a display generated by the Derivative Works, if and +wherever such third-party notices normally appear. The contents +of the NOTICE file are for informational purposes only and +do not modify the License. You may add Your own attribution +notices within Derivative Works that You distribute, alongside +or as an addendum to the NOTICE text from the Work, provided +that such additional attribution notices cannot be construed +as modifying the License. + +You may add Your own copyright statement to Your modifications and +may provide additional or different license terms and conditions +for use, reproduction, or distribution of Your modifications, or +for any such Derivative Works as a whole, provided Your use, +reproduction, and distribution of the Work otherwise complies with +the conditions stated in this License. + +5. Submission of Contributions. Unless You explicitly state otherwise, +any Contribution intentionally submitted for inclusion in the Work +by You to the Licensor shall be under the terms and conditions of +this License, without any additional terms or conditions. +Notwithstanding the above, nothing herein shall supersede or modify +the terms of any separate license agreement you may have executed +with Licensor regarding such Contributions. + +6. Trademarks. This License does not grant permission to use the trade +names, trademarks, service marks, or product names of the Licensor, +except as required for reasonable and customary use in describing the +origin of the Work and reproducing the content of the NOTICE file. + +7. Disclaimer of Warranty. Unless required by applicable law or +agreed to in writing, Licensor provides the Work (and each +Contributor provides its Contributions) on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or +implied, including, without limitation, any warranties or conditions +of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A +PARTICULAR PURPOSE. You are solely responsible for determining the +appropriateness of using or redistributing the Work and assume any +risks associated with Your exercise of permissions under this License. + +8. Limitation of Liability. In no event and under no legal theory, +whether in tort (including negligence), contract, or otherwise, +unless required by applicable law (such as deliberate and grossly +negligent acts) or agreed to in writing, shall any Contributor be +liable to You for damages, including any direct, indirect, special, +incidental, or consequential damages of any character arising as a +result of this License or out of the use or inability to use the +Work (including but not limited to damages for loss of goodwill, +work stoppage, computer failure or malfunction, or any and all +other commercial damages or losses), even if such Contributor +has been advised of the possibility of such damages. + +9. Accepting Warranty or Additional Liability. While redistributing +the Work or Derivative Works thereof, You may choose to offer, +and charge a fee for, acceptance of support, warranty, indemnity, +or other liability obligations and/or rights consistent with this +License. However, in accepting such obligations, You may act only +on Your own behalf and on Your sole responsibility, not on behalf +of any other Contributor, and only if You agree to indemnify, +defend, and hold each Contributor harmless for any liability +incurred by, or claims asserted against, such Contributor by reason +of your accepting any such warranty or additional liability. + +END OF TERMS AND CONDITIONS + +APPENDIX: How to apply the Apache License to your work. + +To apply the Apache License to your work, attach the following +boilerplate notice, with the fields enclosed by brackets "[]" +replaced with your own identifying information. (Don't include +the brackets!) The text should be enclosed in the appropriate +comment syntax for the file format. We also recommend that a +file or class name and description of purpose be included on the +same "printed page" as the copyright notice for easier +identification within third-party archives. + +Copyright [yyyy] [name of copyright owner] + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + +http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. \ No newline at end of file diff --git a/app/src/main/assets/about_emojicompat.html b/app/src/main/assets/about_emojicompat.html new file mode 100644 index 000000000..43c5fd9e1 --- /dev/null +++ b/app/src/main/assets/about_emojicompat.html @@ -0,0 +1,18 @@ + + +

About these Emoji fonts

+ In order to display other emojis than your system's default set, you'll need to download additional emoji fonts.
+ The fonts currently supported are: + + + \ No newline at end of file diff --git a/app/src/main/java/com/keylesspalace/tusky/AboutActivity.java b/app/src/main/java/com/keylesspalace/tusky/AboutActivity.java index d0adb8c99..46df8d585 100644 --- a/app/src/main/java/com/keylesspalace/tusky/AboutActivity.java +++ b/app/src/main/java/com/keylesspalace/tusky/AboutActivity.java @@ -7,13 +7,19 @@ import android.support.design.widget.Snackbar; import android.support.v7.app.ActionBar; import android.support.v7.widget.Toolbar; import android.view.MenuItem; +import android.view.View; import android.widget.Button; +import android.widget.ImageButton; import android.widget.TextView; import com.keylesspalace.tusky.di.Injectable; import com.keylesspalace.tusky.entity.Account; import com.keylesspalace.tusky.network.MastodonApi; +import java.io.BufferedReader; +import java.io.IOException; +import java.io.InputStream; +import java.io.InputStreamReader; import java.util.List; import javax.inject.Inject; @@ -50,6 +56,7 @@ public class AboutActivity extends BaseActivity implements Injectable { appAccountButton = findViewById(R.id.tusky_profile_button); appAccountButton.setOnClickListener(v -> onAccountButtonClick()); + setupAboutEmoji(); } private void onAccountButtonClick() { @@ -109,4 +116,39 @@ public class AboutActivity extends BaseActivity implements Injectable { } return super.onOptionsItemSelected(item); } + + private void setupAboutEmoji() { + // Inflate the TextView containing the Apache 2.0 license text. + TextView apacheView = findViewById(R.id.license_apache); + BufferedReader reader = null; + try { + InputStream apacheLicense = getAssets().open("LICENSE_APACHE"); + StringBuilder builder = new StringBuilder(); + reader = new BufferedReader( + new InputStreamReader(apacheLicense, "UTF-8")); + String line; + while((line = reader.readLine()) != null) { + builder.append(line); + builder.append('\n'); + } + reader.close(); + apacheView.setText(builder); + } catch (IOException e) { + e.printStackTrace(); + } + + // Set up the button action + ImageButton expand = findViewById(R.id.about_blobmoji_expand); + expand.setOnClickListener(v -> + { + if(apacheView.getVisibility() == View.GONE) { + apacheView.setVisibility(View.VISIBLE); + ((ImageButton) v).setImageResource(R.drawable.ic_arrow_drop_up_black_24dp); + } + else { + apacheView.setVisibility(View.GONE); + ((ImageButton) v).setImageResource(R.drawable.ic_arrow_drop_down_black_24dp); + } + }); + } } diff --git a/app/src/main/java/com/keylesspalace/tusky/AccountActivity.java b/app/src/main/java/com/keylesspalace/tusky/AccountActivity.java index 1000031d3..630f87e84 100644 --- a/app/src/main/java/com/keylesspalace/tusky/AccountActivity.java +++ b/app/src/main/java/com/keylesspalace/tusky/AccountActivity.java @@ -31,6 +31,7 @@ import android.support.design.widget.CollapsingToolbarLayout; import android.support.design.widget.FloatingActionButton; import android.support.design.widget.Snackbar; import android.support.design.widget.TabLayout; +import android.support.text.emoji.EmojiCompat; import android.support.v4.app.Fragment; import android.support.v4.content.LocalBroadcastManager; import android.support.v4.view.ViewCompat; @@ -302,7 +303,7 @@ public final class AccountActivity extends BottomSheetActivity implements Action displayName.setText(account.getName()); if (getSupportActionBar() != null) { - getSupportActionBar().setTitle(account.getName()); + getSupportActionBar().setTitle(EmojiCompat.get().process(account.getName())); String subtitle = String.format(getString(R.string.status_username_format), account.getUsername()); diff --git a/app/src/main/java/com/keylesspalace/tusky/EmojiPreference.java b/app/src/main/java/com/keylesspalace/tusky/EmojiPreference.java new file mode 100644 index 000000000..0a682ec6d --- /dev/null +++ b/app/src/main/java/com/keylesspalace/tusky/EmojiPreference.java @@ -0,0 +1,278 @@ +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.preference.DialogPreference; +import android.preference.PreferenceManager; +import android.support.v7.app.AlertDialog; +import android.util.AttributeSet; +import android.util.Log; +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 com.keylesspalace.tusky.util.EmojiCompatFont; + +import java.util.ArrayList; + +/** + * This Preference lets the user select their preferred emoji font + */ +public class EmojiPreference extends DialogPreference { + private static final String TAG = "EmojiPreference"; + private final Context context; + 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}; + + private ArrayList radioButtons = new ArrayList<>(); + + + public EmojiPreference(Context context, AttributeSet attrs) { + super(context, attrs); + this.context = context; + + setDialogLayoutResource(R.layout.dialog_emojicompat); + + setPositiveButtonText(android.R.string.ok); + setNegativeButtonText(android.R.string.cancel); + setDialogIcon(null); + + // 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 onBindDialogView(View view) { + super.onBindDialogView(view); + for(int i = 0; i < viewIds.length; i++) { + setupItem(view.findViewById(viewIds[i]), FONTS[i]); + } + } + + 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.GONE); + progressBar.setVisibility(View.VISIBLE); + cancel.setVisibility(View.VISIBLE); + + + font.downloadFont(context, 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); + } + + /** + * 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(context)) { + // 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); + } + 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(context) + .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) + * @param positiveResult if OK has been selected. + */ + @Override + public void onDialogClosed(boolean positiveResult) { + if(positiveResult) { + saveSelectedFont(); + if(selected != original) { + new AlertDialog.Builder(context) + .setTitle(R.string.restart_required) + .setMessage(R.string.restart_emoji) + .setNegativeButton(R.string.later, null) + .setPositiveButton(R.string.restart, ((dialog, which) -> { + // Restart the app + // TODO: I'm not sure if this is a good solution but it seems to work + // From https://stackoverflow.com/a/17166729/5070653 + Intent launchIntent = new Intent(context, MainActivity.class); + PendingIntent mPendingIntent = PendingIntent.getActivity( + context, + // This is the codepoint of the party face emoji :D + 0x1f973, + launchIntent, + PendingIntent.FLAG_CANCEL_CURRENT); + AlarmManager mgr = + (AlarmManager) context.getSystemService(Context.ALARM_SERVICE); + if (mgr != null) { + mgr.set( + AlarmManager.RTC, + System.currentTimeMillis() + 100, + mPendingIntent); + } + System.exit(0); + })).show(); + } + } + else { + // This line is needed in order to reset the radio buttons later + selected = original; + } + } + +} diff --git a/app/src/main/java/com/keylesspalace/tusky/TuskyApplication.java b/app/src/main/java/com/keylesspalace/tusky/TuskyApplication.java index 7596a993e..11628516d 100644 --- a/app/src/main/java/com/keylesspalace/tusky/TuskyApplication.java +++ b/app/src/main/java/com/keylesspalace/tusky/TuskyApplication.java @@ -21,7 +21,9 @@ import android.app.Service; import android.arch.persistence.room.Room; import android.content.BroadcastReceiver; import android.content.Context; +import android.preference.PreferenceManager; import android.support.annotation.NonNull; +import android.support.text.emoji.EmojiCompat; import android.support.v7.app.AppCompatDelegate; import com.evernote.android.job.JobManager; @@ -29,6 +31,7 @@ import com.jakewharton.picasso.OkHttp3Downloader; import com.keylesspalace.tusky.db.AccountManager; import com.keylesspalace.tusky.db.AppDatabase; import com.keylesspalace.tusky.di.AppInjector; +import com.keylesspalace.tusky.util.EmojiCompatFont; import com.squareup.picasso.Picasso; import javax.inject.Inject; @@ -86,13 +89,31 @@ public class TuskyApplication extends Application implements HasActivityInjector initAppInjector(); initPicasso(); + initEmojiCompat(); JobManager.create(this).addJobCreator(notificationPullJobCreator); - //necessary for Android < APi 21 + //necessary for Android < API 21 AppCompatDelegate.setCompatVectorFromResourcesEnabled(true); } + /** + * This method will load the EmojiCompat font which has been selected. + * If this font does not work or if the user hasn't selected one (yet), it will use a + * fallback solution instead which won't make any visible difference to using no EmojiCompat at all. + */ + private void initEmojiCompat() { + int emojiSelection = PreferenceManager + .getDefaultSharedPreferences(getApplicationContext()) + .getInt(EmojiPreference.FONT_PREFERENCE, 0); + EmojiCompatFont font = EmojiCompatFont.byId(emojiSelection); + // FileEmojiCompat will handle any non-existing font and provide a fallback solution. + EmojiCompat.Config config = font.getConfig(getApplicationContext()) + // The user probably wants to get a consistent experience + .setReplaceAll(true); + EmojiCompat.init(config); + } + protected void initAppInjector() { AppInjector.INSTANCE.init(this); } diff --git a/app/src/main/java/com/keylesspalace/tusky/util/EmojiCompatFont.java b/app/src/main/java/com/keylesspalace/tusky/util/EmojiCompatFont.java new file mode 100644 index 000000000..25054ee29 --- /dev/null +++ b/app/src/main/java/com/keylesspalace/tusky/util/EmojiCompatFont.java @@ -0,0 +1,322 @@ +package com.keylesspalace.tusky.util; + +import android.content.Context; +import android.graphics.drawable.Drawable; +import android.os.AsyncTask; +import android.support.annotation.Nullable; +import android.util.Log; + +import com.keylesspalace.tusky.R; + +import java.io.EOFException; +import java.io.File; +import java.io.IOException; +import java.util.Arrays; + +import de.c1710.filemojicompat.FileEmojiCompatConfig; +import okhttp3.OkHttpClient; +import okhttp3.Request; +import okhttp3.Response; +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 { + /** + * 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, src; + // The thumbnail image and the caption are provided as resource ids + private final int img, caption; + private AsyncTask fontDownloader; + // The system font gets some special behavior... + public static final EmojiCompatFont SYSTEM_DEFAULT = + new EmojiCompatFont("system-default", + "System Default", + R.string.caption_systememoji, + R.drawable.ic_emoji_24dp, + "", + ""); + private static final EmojiCompatFont BLOBMOJI = + new EmojiCompatFont("Blobmoji", + "Blobmoji", + R.string.caption_blobmoji, + R.drawable.ic_blobmoji, + "https://tuskyapp.github.io/hosted/emoji/BlobmojiCompat.ttf", + "https://github.com/c1710/blobmoji" + ); + private static final EmojiCompatFont TWEMOJI = + new EmojiCompatFont("Twemoji", + "Twemoji", + R.string.caption_twemoji, + R.drawable.ic_twemoji, + "https://tuskyapp.github.io/hosted/emoji/TwemojiCompat.ttf", + "https://github.com/twitter/twemoji" + ); + + /** + * 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}; + + + private EmojiCompatFont(String name, + String display, + int caption, + int img, + String url, + String src) { + this.name = name; + this.display = display; + this.caption = caption; + this.img = img; + this.url = url; + this.src = src; + } + + /** + * 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 String getSrc() { + return src; + } + + public Drawable getThumb(Context context) { + return context.getResources().getDrawable(img); + } + + + /** + * This method will return the actual font file (regardless of its existence). + * @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() + ".ttf"); + } + else { + return null; + } + } + + public FileEmojiCompatConfig getConfig(Context context) { + return new FileEmojiCompatConfig(context, getFont(context)); + } + + public boolean isDownloaded(Context context) { + return this == SYSTEM_DEFAULT || getFont(context) != null && getFont(context).exists(); + } + + /** + * 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) { + fontDownloader = new Downloader( + this, + listeners) + .execute(getFont(context)); + } + else { + for(Downloader.EmojiDownloadListener listener: listeners) { + // The system emoji font is always downloaded... + listener.onDownloaded(this); + } + } + } + + /** + * Stops downloading the font. If no one started a font download, nothing happens. + */ + public void cancelDownload() { + if(fontDownloader != null) { + fontDownloader.cancel(false); + fontDownloader = null; + } + } + + /** + * This class is used to easily manage the download of a font + */ + public static class Downloader extends AsyncTask { + // All interested objects/methods + private final EmojiDownloadListener[] listeners; + // The MIME-Type which might be unnecessary + private static final String MIME = "application/woff"; + // The font belonging to this download + private final EmojiCompatFont font; + private static final String TAG = "Emoji-Font Downloader"; + private static long CHUNK_SIZE = 4096; + private boolean failed = false; + + Downloader(EmojiCompatFont font, EmojiDownloadListener... listeners) { + super(); + this.listeners = listeners; + this.font = font; + } + + @Override + protected File doInBackground(File... files){ + // Only download to one file... + File downloadFile = files[0]; + try { + // It is possible (and very likely) that the file does not exist yet + if (!downloadFile.exists()) { + downloadFile.getParentFile().mkdirs(); + downloadFile.createNewFile(); + } + OkHttpClient client = new OkHttpClient(); + Request request = new Request.Builder().url(font.getUrl()) + .addHeader("Content-Type", MIME) + .build(); + Response response = client.newCall(request).execute(); + BufferedSink sink = Okio.buffer(Okio.sink(downloadFile)); + Source source = null; + try { + long size = 0; + // Download! + if (response.body() != null + && response.isSuccessful() + && (size = response.body().contentLength()) > 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? + } + } + } + + @Override + public String toString() { + return display; + } +} diff --git a/app/src/main/java/com/keylesspalace/tusky/view/EditTextTyped.kt b/app/src/main/java/com/keylesspalace/tusky/view/EditTextTyped.kt index fb837b153..4160f8b20 100644 --- a/app/src/main/java/com/keylesspalace/tusky/view/EditTextTyped.kt +++ b/app/src/main/java/com/keylesspalace/tusky/view/EditTextTyped.kt @@ -16,10 +16,12 @@ package com.keylesspalace.tusky.view import android.content.Context +import android.support.text.emoji.widget.EmojiEditTextHelper import android.support.v13.view.inputmethod.EditorInfoCompat import android.support.v13.view.inputmethod.InputConnectionCompat import android.support.v7.widget.AppCompatMultiAutoCompleteTextView import android.text.InputType +import android.text.method.KeyListener import android.util.AttributeSet import android.view.inputmethod.EditorInfo import android.view.inputmethod.InputConnection @@ -29,11 +31,17 @@ class EditTextTyped @JvmOverloads constructor(context: Context, : AppCompatMultiAutoCompleteTextView(context, attributeSet) { private var onCommitContentListener: InputConnectionCompat.OnCommitContentListener? = null + private val emojiEditTextHelper: EmojiEditTextHelper = EmojiEditTextHelper(this) init { //fix a bug with autocomplete and some keyboards val newInputType = inputType and (inputType xor InputType.TYPE_TEXT_FLAG_AUTO_COMPLETE) inputType = newInputType + super.setKeyListener(getEmojiEditTextHelper().getKeyListener(keyListener)) + } + + override fun setKeyListener(input: KeyListener) { + super.setKeyListener(getEmojiEditTextHelper().getKeyListener(input)) } fun setOnCommitContentListener(listener: InputConnectionCompat.OnCommitContentListener) { @@ -44,10 +52,14 @@ class EditTextTyped @JvmOverloads constructor(context: Context, val connection = super.onCreateInputConnection(editorInfo) return if (onCommitContentListener != null) { EditorInfoCompat.setContentMimeTypes(editorInfo, arrayOf("image/*")) - InputConnectionCompat.createWrapper(connection, editorInfo, - onCommitContentListener!!) + getEmojiEditTextHelper().onCreateInputConnection(InputConnectionCompat.createWrapper(connection, editorInfo, + onCommitContentListener!!), editorInfo)!! } else { connection } } + + private fun getEmojiEditTextHelper(): EmojiEditTextHelper { + return emojiEditTextHelper + } } diff --git a/app/src/main/res/drawable/ic_arrow_drop_down_black_24dp.xml b/app/src/main/res/drawable/ic_arrow_drop_down_black_24dp.xml new file mode 100644 index 000000000..88a73064b --- /dev/null +++ b/app/src/main/res/drawable/ic_arrow_drop_down_black_24dp.xml @@ -0,0 +1,4 @@ + + + diff --git a/app/src/main/res/drawable/ic_arrow_drop_up_black_24dp.xml b/app/src/main/res/drawable/ic_arrow_drop_up_black_24dp.xml new file mode 100644 index 000000000..49be73b78 --- /dev/null +++ b/app/src/main/res/drawable/ic_arrow_drop_up_black_24dp.xml @@ -0,0 +1,4 @@ + + + diff --git a/app/src/main/res/drawable/ic_blobmoji.xml b/app/src/main/res/drawable/ic_blobmoji.xml new file mode 100644 index 000000000..2d475710d --- /dev/null +++ b/app/src/main/res/drawable/ic_blobmoji.xml @@ -0,0 +1,11 @@ + + + + + + + diff --git a/app/src/main/res/drawable/ic_cancel_black_24dp.xml b/app/src/main/res/drawable/ic_cancel_black_24dp.xml new file mode 100644 index 000000000..7d2b57eb2 --- /dev/null +++ b/app/src/main/res/drawable/ic_cancel_black_24dp.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/ic_twemoji.xml b/app/src/main/res/drawable/ic_twemoji.xml new file mode 100644 index 000000000..0888d26fb --- /dev/null +++ b/app/src/main/res/drawable/ic_twemoji.xml @@ -0,0 +1,9 @@ + + + + + + + + diff --git a/app/src/main/res/layout/about_emoji.xml b/app/src/main/res/layout/about_emoji.xml new file mode 100644 index 000000000..8dbeba250 --- /dev/null +++ b/app/src/main/res/layout/about_emoji.xml @@ -0,0 +1,63 @@ + + + + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/layout/activity_about.xml b/app/src/main/res/layout/activity_about.xml index f25eaaa4c..53beddb07 100644 --- a/app/src/main/res/layout/activity_about.xml +++ b/app/src/main/res/layout/activity_about.xml @@ -31,7 +31,7 @@ android:gravity="center_vertical" android:textAppearance="@style/TextAppearance.AppCompat.Large" android:textIsSelectable="true" - android:textStyle="bold" /> + android:textStyle="bold"/> + android:textIsSelectable="true"/> + + + diff --git a/app/src/main/res/layout/activity_account.xml b/app/src/main/res/layout/activity_account.xml index 5c2efaf3d..140c779cf 100644 --- a/app/src/main/res/layout/activity_account.xml +++ b/app/src/main/res/layout/activity_account.xml @@ -80,7 +80,7 @@ app:layout_constraintEnd_toEndOf="@id/follow_btn" app:layout_constraintTop_toBottomOf="@id/follow_btn" /> - - - - - + + + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/layout/item_account.xml b/app/src/main/res/layout/item_account.xml index d3de75fdb..2a0fd16f1 100644 --- a/app/src/main/res/layout/item_account.xml +++ b/app/src/main/res/layout/item_account.xml @@ -22,7 +22,7 @@ android:gravity="center_vertical" android:orientation="vertical"> - - + + - + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/layout/item_follow.xml b/app/src/main/res/layout/item_follow.xml index dc8f695f8..d3a19338f 100644 --- a/app/src/main/res/layout/item_follow.xml +++ b/app/src/main/res/layout/item_follow.xml @@ -38,7 +38,7 @@ android:scaleType="fitCenter" android:textSize="?attr/status_text_medium" /> - - - - - - - - - - + - - - - + - + - + - + + - - - Alle Beiträge aus/einklappen Antworten Nachschlagen… + Emoji-Stil + System-Standard + Du musst diese Emoji-Sets zunächst herunterladen. + App-Neustart erforderlich + Du musst Tusky neustarten um die Änderungen anzuwenden + Später + Neustarten + Die Standard-Emojis deines Geräts + Ein Emoji–Set, das auf den "Blob"–Emojis aus Android 4.4–7.1 basiert + Die Standard-Emojis von Mastodon + + Tusky benutzt die folgenden Emoji-Schriftarten:\n + Twemoji von Twitter - https://github.com/twitter/Twemoji\n + Blobmoji von C1710, basierend auf Noto-Emoji - https://github.com/c1710/Blobmoji\n + Twemoji wurde auf die Nutzung mit der EmojiCompat-Bibliothek angepasst. + + Blobmoji ist lizenziert nach der Apache 2.0-Lizenz + Twemoji ist als CC-BY-4.0 lizenziert - https://creativecommons.org/licenses/by/4.0/ + Download fehlgeschlagen. diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index c3bcb8b0f..92da96ccc 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -310,7 +310,26 @@ Your instance %s does not have any custom emojis Copied to clipboard + Emoji style + System default + You\'ll need to download these emoji sets first Performing lookup... Expand/Collapse all statuses + App restart required + You\'ll need to restart Tusky in order to apply these changes + Later + Restart + Your device\'s default emoji set + An emoji set based on the Blob emojis known from Android 4.4–7.1 + Mastodon\'s standard emoji set + + Tusky uses the following emoji sets:\n + Twemoji by Twitter - https://github.com/twitter/Twemoji\n + Blobmoji by C1710, based on Noto-Emoji - https://github.com/c1710/Blobmoji\n + Twemoji has been modified in order to use it with the EmojiCompat library + + Blobmoji is licensed under the Apache license 2.0 + Twemoji is licensed as CC-BY 4.0 - https://creativecommons.org/licenses/by/4.0/ + Download failed diff --git a/app/src/main/res/xml/preferences.xml b/app/src/main/res/xml/preferences.xml index 504d05b13..1dd472b85 100644 --- a/app/src/main/res/xml/preferences.xml +++ b/app/src/main/res/xml/preferences.xml @@ -12,6 +12,12 @@ android:summary="%s" android:title="@string/pref_title_app_theme" /> + +