diff --git a/mastodon/build.gradle b/mastodon/build.gradle
index c50342df4..c68f9d958 100644
--- a/mastodon/build.gradle
+++ b/mastodon/build.gradle
@@ -8,11 +8,11 @@ android {
generateLocaleConfig = true
}
- compileSdk 33
+ compileSdk 34
defaultConfig {
applicationId "org.joinmastodon.android"
minSdk 23
- targetSdk 33
+ targetSdk 34
versionCode 108
versionName "2.5.6"
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
@@ -101,7 +101,7 @@ dependencies {
annotationProcessor 'org.parceler:parceler:1.1.12'
coreLibraryDesugaring 'com.android.tools:desugar_jdk_libs:2.0.3'
- def appCenterSdkVersion = "4.4.2"
+ def appCenterSdkVersion = "5.0.4"
appcenterPrivateBetaImplementation "com.microsoft.appcenter:appcenter-crashes:${appCenterSdkVersion}"
appcenterPrivateBetaImplementation "com.microsoft.appcenter:appcenter-distribute:${appCenterSdkVersion}"
appcenterPublicBetaImplementation "com.microsoft.appcenter:appcenter-crashes:${appCenterSdkVersion}"
diff --git a/mastodon/src/main/AndroidManifest.xml b/mastodon/src/main/AndroidManifest.xml
index 7a97cfcee..f8cefa88c 100644
--- a/mastodon/src/main/AndroidManifest.xml
+++ b/mastodon/src/main/AndroidManifest.xml
@@ -4,6 +4,7 @@
+
@@ -79,6 +80,7 @@
+
diff --git a/mastodon/src/main/java/org/joinmastodon/android/AudioPlayerService.java b/mastodon/src/main/java/org/joinmastodon/android/AudioPlayerService.java
index 4a01c13f0..de8bc1894 100644
--- a/mastodon/src/main/java/org/joinmastodon/android/AudioPlayerService.java
+++ b/mastodon/src/main/java/org/joinmastodon/android/AudioPlayerService.java
@@ -88,8 +88,13 @@ public class AudioPlayerService extends Service{
nm=getSystemService(NotificationManager.class);
// registerReceiver(receiver, new IntentFilter(Intent.ACTION_MEDIA_BUTTON));
registerReceiver(receiver, new IntentFilter(AudioManager.ACTION_AUDIO_BECOMING_NOISY));
- registerReceiver(receiver, new IntentFilter(ACTION_PLAY_PAUSE));
- registerReceiver(receiver, new IntentFilter(ACTION_STOP));
+ if(Build.VERSION.SDK_INT>=Build.VERSION_CODES.TIRAMISU){
+ registerReceiver(receiver, new IntentFilter(ACTION_PLAY_PAUSE), RECEIVER_EXPORTED);
+ registerReceiver(receiver, new IntentFilter(ACTION_STOP), RECEIVER_EXPORTED);
+ }else{
+ registerReceiver(receiver, new IntentFilter(ACTION_PLAY_PAUSE));
+ registerReceiver(receiver, new IntentFilter(ACTION_STOP));
+ }
instance=this;
}
diff --git a/mastodon/src/main/java/org/joinmastodon/android/DonationFragmentActivity.java b/mastodon/src/main/java/org/joinmastodon/android/DonationFragmentActivity.java
new file mode 100644
index 000000000..5a72cd463
--- /dev/null
+++ b/mastodon/src/main/java/org/joinmastodon/android/DonationFragmentActivity.java
@@ -0,0 +1,29 @@
+package org.joinmastodon.android;
+
+import android.os.Bundle;
+
+import org.joinmastodon.android.fragments.DonationWebViewFragment;
+
+import androidx.annotation.Nullable;
+import me.grishka.appkit.FragmentStackActivity;
+
+// This exists because our designer wanted to avoid extra sheet showing/hiding animations.
+// This is the only way to show a fragment on top of a sheet without having to rewrite way too many things.
+public class DonationFragmentActivity extends FragmentStackActivity{
+ @Override
+ protected void onCreate(@Nullable Bundle savedInstanceState){
+ super.onCreate(savedInstanceState);
+ if(savedInstanceState==null){
+ DonationWebViewFragment fragment=new DonationWebViewFragment();
+ fragment.setArguments(getIntent().getBundleExtra("fragmentArgs"));
+ showFragment(fragment);
+ overridePendingTransition(R.anim.fragment_enter, R.anim.no_op_300ms);
+ }
+ }
+
+ @Override
+ public void finish(){
+ super.finish();
+ overridePendingTransition(0, R.anim.fragment_exit);
+ }
+}
diff --git a/mastodon/src/main/java/org/joinmastodon/android/api/CacheController.java b/mastodon/src/main/java/org/joinmastodon/android/api/CacheController.java
index 6e226bb65..3a027e7e2 100644
--- a/mastodon/src/main/java/org/joinmastodon/android/api/CacheController.java
+++ b/mastodon/src/main/java/org/joinmastodon/android/api/CacheController.java
@@ -24,7 +24,6 @@ import org.joinmastodon.android.model.Notification;
import org.joinmastodon.android.model.PaginatedResponse;
import org.joinmastodon.android.model.SearchResult;
import org.joinmastodon.android.model.Status;
-import org.joinmastodon.android.ui.utils.UiUtils;
import java.io.File;
import java.io.FileInputStream;
@@ -45,8 +44,8 @@ import me.grishka.appkit.utils.WorkerThread;
public class CacheController{
private static final String TAG="CacheController";
private static final int DB_VERSION=3;
- private static final WorkerThread databaseThread=new WorkerThread("databaseThread");
- private static final Handler uiHandler=new Handler(Looper.getMainLooper());
+ public static final WorkerThread databaseThread=new WorkerThread("databaseThread");
+ public static final Handler uiHandler=new Handler(Looper.getMainLooper());
private final String accountID;
private DatabaseHelper db;
@@ -467,9 +466,4 @@ public class CacheController{
db.execSQL("ALTER TABLE `notifications_mentions` ADD `time` INTEGER NOT NULL DEFAULT 0");
}
}
-
- @FunctionalInterface
- private interface DatabaseRunnable{
- void run(SQLiteDatabase db) throws IOException;
- }
}
diff --git a/mastodon/src/main/java/org/joinmastodon/android/api/DatabaseRunnable.java b/mastodon/src/main/java/org/joinmastodon/android/api/DatabaseRunnable.java
new file mode 100644
index 000000000..bf8b711c9
--- /dev/null
+++ b/mastodon/src/main/java/org/joinmastodon/android/api/DatabaseRunnable.java
@@ -0,0 +1,10 @@
+package org.joinmastodon.android.api;
+
+import android.database.sqlite.SQLiteDatabase;
+
+import java.io.IOException;
+
+@FunctionalInterface
+public interface DatabaseRunnable{
+ void run(SQLiteDatabase db) throws IOException;
+}
diff --git a/mastodon/src/main/java/org/joinmastodon/android/api/MastodonAPIController.java b/mastodon/src/main/java/org/joinmastodon/android/api/MastodonAPIController.java
index 9ea3bb769..128588304 100644
--- a/mastodon/src/main/java/org/joinmastodon/android/api/MastodonAPIController.java
+++ b/mastodon/src/main/java/org/joinmastodon/android/api/MastodonAPIController.java
@@ -12,10 +12,12 @@ import com.google.gson.JsonParser;
import com.google.gson.JsonSyntaxException;
import org.joinmastodon.android.BuildConfig;
+import org.joinmastodon.android.MastodonApp;
import org.joinmastodon.android.api.gson.IsoInstantTypeAdapter;
import org.joinmastodon.android.api.gson.IsoLocalDateTypeAdapter;
import org.joinmastodon.android.api.session.AccountSession;
+import java.io.File;
import java.io.IOException;
import java.io.Reader;
import java.time.Instant;
@@ -29,6 +31,8 @@ import java.util.concurrent.TimeUnit;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import me.grishka.appkit.utils.WorkerThread;
+import okhttp3.Cache;
+import okhttp3.CacheControl;
import okhttp3.Call;
import okhttp3.Callback;
import okhttp3.OkHttpClient;
@@ -49,8 +53,11 @@ public class MastodonAPIController{
.connectTimeout(60, TimeUnit.SECONDS)
.writeTimeout(60, TimeUnit.SECONDS)
.readTimeout(60, TimeUnit.SECONDS)
+ .cache(new Cache(new File(MastodonApp.context.getCacheDir(), "http"), 10*1024*1024))
.build();
+ private static final CacheControl NO_CACHE_WHATSOEVER=new CacheControl.Builder().noCache().noStore().build();
+
private AccountSession session;
static{
@@ -80,6 +87,9 @@ public class MastodonAPIController{
if(token!=null)
builder.header("Authorization", "Bearer "+token);
+ if(!req.cacheable)
+ builder.cacheControl(NO_CACHE_WHATSOEVER);
+
if(req.headers!=null){
for(Map.Entry header:req.headers.entrySet()){
builder.header(header.getKey(), header.getValue());
diff --git a/mastodon/src/main/java/org/joinmastodon/android/api/MastodonAPIRequest.java b/mastodon/src/main/java/org/joinmastodon/android/api/MastodonAPIRequest.java
index f308d8720..c780bbcf1 100644
--- a/mastodon/src/main/java/org/joinmastodon/android/api/MastodonAPIRequest.java
+++ b/mastodon/src/main/java/org/joinmastodon/android/api/MastodonAPIRequest.java
@@ -46,6 +46,7 @@ public abstract class MastodonAPIRequest extends APIRequest{
boolean canceled;
Map headers;
long timeout;
+ boolean cacheable;
private ProgressDialog progressDialog;
protected boolean removeUnsupportedItems;
@@ -132,6 +133,10 @@ public abstract class MastodonAPIRequest extends APIRequest{
this.timeout=timeout;
}
+ protected void setCacheable(){
+ cacheable=true;
+ }
+
protected String getPathPrefix(){
return "/api/v1";
}
diff --git a/mastodon/src/main/java/org/joinmastodon/android/api/requests/catalog/GetDonationCampaigns.java b/mastodon/src/main/java/org/joinmastodon/android/api/requests/catalog/GetDonationCampaigns.java
new file mode 100644
index 000000000..3ff647fd8
--- /dev/null
+++ b/mastodon/src/main/java/org/joinmastodon/android/api/requests/catalog/GetDonationCampaigns.java
@@ -0,0 +1,40 @@
+package org.joinmastodon.android.api.requests.catalog;
+
+import android.net.Uri;
+import android.text.TextUtils;
+
+import org.joinmastodon.android.api.MastodonAPIRequest;
+import org.joinmastodon.android.model.donations.DonationCampaign;
+
+public class GetDonationCampaigns extends MastodonAPIRequest{
+ private final String locale, seed, source;
+ private boolean staging;
+
+ public GetDonationCampaigns(String locale, String seed, String source){
+ super(HttpMethod.GET, null, DonationCampaign.class);
+ this.locale=locale;
+ this.seed=seed;
+ this.source=source;
+ setCacheable();
+ }
+
+ public void setStaging(boolean staging){
+ this.staging=staging;
+ }
+
+ @Override
+ public Uri getURL(){
+ Uri.Builder builder=new Uri.Builder()
+ .scheme("https")
+ .authority("api.joinmastodon.org")
+ .path("/v1/donations/campaigns/active")
+ .appendQueryParameter("platform", "android")
+ .appendQueryParameter("locale", locale)
+ .appendQueryParameter("seed", seed);
+ if(staging)
+ builder.appendQueryParameter("environment", "staging");
+ if(!TextUtils.isEmpty(source))
+ builder.appendQueryParameter("source", source);
+ return builder.build();
+ }
+}
diff --git a/mastodon/src/main/java/org/joinmastodon/android/api/session/AccountSession.java b/mastodon/src/main/java/org/joinmastodon/android/api/session/AccountSession.java
index 43190c73d..1276bf81e 100644
--- a/mastodon/src/main/java/org/joinmastodon/android/api/session/AccountSession.java
+++ b/mastodon/src/main/java/org/joinmastodon/android/api/session/AccountSession.java
@@ -33,7 +33,8 @@ import org.joinmastodon.android.model.TimelineMarkers;
import org.joinmastodon.android.model.Token;
import org.joinmastodon.android.utils.ObjectIdComparator;
-import java.io.File;
+import java.time.Instant;
+import java.time.temporal.ChronoUnit;
import java.util.ArrayList;
import java.util.List;
import java.util.function.Consumer;
@@ -44,6 +45,7 @@ import me.grishka.appkit.api.ErrorResponse;
public class AccountSession{
private static final String TAG="AccountSession";
+ private static final int MIN_DAYS_ACCOUNT_AGE_FOR_DONATIONS=28;
public Token token;
public Account self;
@@ -276,4 +278,12 @@ public class AccountSession{
public void setNotificationsMentionsOnly(boolean mentionsOnly){
getRawLocalPreferences().edit().putBoolean("notificationsMentionsOnly", mentionsOnly).apply();
}
+
+ public boolean isEligibleForDonations(){
+ return ("mastodon.social".equalsIgnoreCase(domain) || "mastodon.online".equalsIgnoreCase(domain)) && self.createdAt.isBefore(Instant.now().minus(MIN_DAYS_ACCOUNT_AGE_FOR_DONATIONS, ChronoUnit.DAYS));
+ }
+
+ public int getDonationSeed(){
+ return Math.abs(getFullUsername().hashCode())%100;
+ }
}
diff --git a/mastodon/src/main/java/org/joinmastodon/android/api/session/AccountSessionManager.java b/mastodon/src/main/java/org/joinmastodon/android/api/session/AccountSessionManager.java
index 0d4211319..b55e07b61 100644
--- a/mastodon/src/main/java/org/joinmastodon/android/api/session/AccountSessionManager.java
+++ b/mastodon/src/main/java/org/joinmastodon/android/api/session/AccountSessionManager.java
@@ -3,11 +3,16 @@ package org.joinmastodon.android.api.session;
import android.app.Activity;
import android.app.NotificationManager;
import android.content.ComponentName;
+import android.content.ContentValues;
import android.content.Context;
import android.content.Intent;
import android.content.SharedPreferences;
import android.content.pm.ShortcutInfo;
import android.content.pm.ShortcutManager;
+import android.database.Cursor;
+import android.database.sqlite.SQLiteDatabase;
+import android.database.sqlite.SQLiteException;
+import android.database.sqlite.SQLiteOpenHelper;
import android.graphics.drawable.Icon;
import android.net.Uri;
import android.os.Build;
@@ -18,11 +23,13 @@ import org.joinmastodon.android.E;
import org.joinmastodon.android.MainActivity;
import org.joinmastodon.android.MastodonApp;
import org.joinmastodon.android.R;
+import org.joinmastodon.android.api.CacheController;
+import org.joinmastodon.android.api.DatabaseRunnable;
import org.joinmastodon.android.api.MastodonAPIController;
import org.joinmastodon.android.api.PushSubscriptionManager;
+import org.joinmastodon.android.api.requests.accounts.GetOwnAccount;
import org.joinmastodon.android.api.requests.filters.GetLegacyFilters;
import org.joinmastodon.android.api.requests.instance.GetCustomEmojis;
-import org.joinmastodon.android.api.requests.accounts.GetOwnAccount;
import org.joinmastodon.android.api.requests.instance.GetInstance;
import org.joinmastodon.android.api.requests.oauth.CreateOAuthApp;
import org.joinmastodon.android.events.EmojiUpdatedEvent;
@@ -30,9 +37,10 @@ import org.joinmastodon.android.model.Account;
import org.joinmastodon.android.model.Application;
import org.joinmastodon.android.model.Emoji;
import org.joinmastodon.android.model.EmojiCategory;
-import org.joinmastodon.android.model.LegacyFilter;
import org.joinmastodon.android.model.Instance;
+import org.joinmastodon.android.model.LegacyFilter;
import org.joinmastodon.android.model.Token;
+import org.joinmastodon.android.ui.utils.UiUtils;
import java.io.File;
import java.io.FileInputStream;
@@ -60,6 +68,7 @@ public class AccountSessionManager{
private static final String TAG="AccountSessionManager";
public static final String SCOPE="read write follow push";
public static final String REDIRECT_URI="mastodon-android-auth://callback";
+ private static final int DB_VERSION=1;
private static final AccountSessionManager instance=new AccountSessionManager();
@@ -73,6 +82,8 @@ public class AccountSessionManager{
private String lastActiveAccountID;
private SharedPreferences prefs;
private boolean loadedInstances;
+ private DatabaseHelper db;
+ private final Runnable databaseCloseRunnable=this::closeDatabase;
public static AccountSessionManager getInstance(){
return instance;
@@ -450,6 +461,68 @@ public class AccountSessionManager{
}
}
+ private void closeDelayed(){
+ CacheController.databaseThread.postRunnable(databaseCloseRunnable, 10_000);
+ }
+
+ public void closeDatabase(){
+ if(db!=null){
+ if(BuildConfig.DEBUG)
+ Log.d(TAG, "closeDatabase");
+ db.close();
+ db=null;
+ }
+ }
+
+ private void cancelDelayedClose(){
+ if(db!=null){
+ CacheController.databaseThread.handler.removeCallbacks(databaseCloseRunnable);
+ }
+ }
+
+ private SQLiteDatabase getOrOpenDatabase(){
+ if(db==null)
+ db=new DatabaseHelper();
+ return db.getWritableDatabase();
+ }
+
+ private void runOnDbThread(DatabaseRunnable r){
+ cancelDelayedClose();
+ CacheController.databaseThread.postRunnable(()->{
+ try{
+ SQLiteDatabase db=getOrOpenDatabase();
+ r.run(db);
+ }catch(SQLiteException|IOException x){
+ Log.w(TAG, x);
+ }finally{
+ closeDelayed();
+ }
+ }, 0);
+ }
+
+ public void runIfDonationCampaignNotDismissed(String id, Runnable action){
+ runOnDbThread(db->{
+ try(Cursor cursor=db.query("dismissed_donation_campaigns", null, "id=?", new String[]{id}, null, null, null)){
+ if(!cursor.moveToFirst()){
+ UiUtils.runOnUiThread(action);
+ }
+ }
+ });
+ }
+
+ public void markDonationCampaignAsDismissed(String id){
+ runOnDbThread(db->{
+ ContentValues values=new ContentValues();
+ values.put("id", id);
+ values.put("dismissed_at", System.currentTimeMillis());
+ db.insert("dismissed_donation_campaigns", null, values);
+ });
+ }
+
+ public void clearDismissedDonationCampaigns(){
+ runOnDbThread(db->db.delete("dismissed_donation_campaigns", null, null));
+ }
+
private static class SessionsStorageWrapper{
public List accounts;
}
@@ -459,4 +532,24 @@ public class AccountSessionManager{
public List emojis;
public long lastUpdated;
}
+
+ private static class DatabaseHelper extends SQLiteOpenHelper{
+ public DatabaseHelper(){
+ super(MastodonApp.context, "accounts.db", null, DB_VERSION);
+ }
+
+ @Override
+ public void onCreate(SQLiteDatabase db){
+ db.execSQL("""
+ CREATE TABLE `dismissed_donation_campaigns` (
+ `id` text PRIMARY KEY,
+ `dismissed_at` bigint
+ )""");
+ }
+
+ @Override
+ public void onUpgrade(SQLiteDatabase db, int oldVersion, int newVersion){
+
+ }
+ }
}
diff --git a/mastodon/src/main/java/org/joinmastodon/android/events/DismissDonationCampaignBannerEvent.java b/mastodon/src/main/java/org/joinmastodon/android/events/DismissDonationCampaignBannerEvent.java
new file mode 100644
index 000000000..101369e17
--- /dev/null
+++ b/mastodon/src/main/java/org/joinmastodon/android/events/DismissDonationCampaignBannerEvent.java
@@ -0,0 +1,9 @@
+package org.joinmastodon.android.events;
+
+public class DismissDonationCampaignBannerEvent{
+ public final String campaignID;
+
+ public DismissDonationCampaignBannerEvent(String campaignID){
+ this.campaignID=campaignID;
+ }
+}
diff --git a/mastodon/src/main/java/org/joinmastodon/android/fragments/DonationWebViewFragment.java b/mastodon/src/main/java/org/joinmastodon/android/fragments/DonationWebViewFragment.java
new file mode 100644
index 000000000..7f02bf73a
--- /dev/null
+++ b/mastodon/src/main/java/org/joinmastodon/android/fragments/DonationWebViewFragment.java
@@ -0,0 +1,96 @@
+package org.joinmastodon.android.fragments;
+
+import android.app.Activity;
+import android.content.Intent;
+import android.os.Bundle;
+import android.view.Menu;
+import android.view.MenuInflater;
+import android.view.MenuItem;
+import android.view.View;
+import android.webkit.WebResourceRequest;
+
+import org.joinmastodon.android.BuildConfig;
+import org.joinmastodon.android.E;
+import org.joinmastodon.android.R;
+import org.joinmastodon.android.api.session.AccountSessionManager;
+import org.joinmastodon.android.events.DismissDonationCampaignBannerEvent;
+import org.joinmastodon.android.ui.M3AlertDialogBuilder;
+
+import java.util.Objects;
+
+import me.grishka.appkit.Nav;
+
+public class DonationWebViewFragment extends WebViewFragment{
+ public static final String SUCCESS_URL="https://sponsor.joinmastodon.org/donate/success";
+ public static final String FAILURE_URL="https://sponsor.joinmastodon.org/donate/failure";
+ public static final String CANCEL_URL="https://sponsor.joinmastodon.org/donate/cancel";
+
+ @Override
+ public void onCreate(Bundle savedInstanceState){
+ super.onCreate(savedInstanceState);
+ if(BuildConfig.DEBUG){
+ setHasOptionsMenu(true);
+ }
+ }
+
+ @Override
+ public void onViewCreated(View view, Bundle savedInstanceState){
+ super.onViewCreated(view, savedInstanceState);
+ webView.loadUrl(Objects.requireNonNull(getArguments().getString("url")));
+ }
+
+ @Override
+ protected boolean shouldOverrideUrlLoading(WebResourceRequest req){
+ String url=req.getUrl().buildUpon().clearQuery().fragment(null).build().toString();
+ if(url.equalsIgnoreCase(SUCCESS_URL)){
+ onSuccess();
+ return true;
+ }else if(url.equalsIgnoreCase(FAILURE_URL)){
+ onFailure();
+ return true;
+ }else if(url.equalsIgnoreCase(CANCEL_URL)){
+ onCancel();
+ return true;
+ }
+ return false;
+ }
+
+ @Override
+ public void onCreateOptionsMenu(Menu menu, MenuInflater inflater){
+ super.onCreateOptionsMenu(menu, inflater);
+ if(BuildConfig.DEBUG){
+ menu.add(0, 0, 0, "Simulate success");
+ menu.add(0, 1, 0, "Simulate failure");
+ }
+ }
+
+ @Override
+ public boolean onOptionsItemSelected(MenuItem item){
+ if(item.getItemId()==0)
+ onSuccess();
+ else if(item.getItemId()==1)
+ onFailure();
+ return super.onOptionsItemSelected(item);
+ }
+
+ private void onFailure(){
+ new M3AlertDialogBuilder(getActivity())
+ .setTitle(R.string.error)
+ .setMessage(R.string.donation_server_error)
+ .setPositiveButton(R.string.ok, null)
+ .setOnDismissListener(dlg->Nav.finish(this))
+ .show();
+ }
+
+ private void onSuccess(){
+ String campaignID=getArguments().getString("campaignID");
+ AccountSessionManager.getInstance().markDonationCampaignAsDismissed(campaignID);
+ E.post(new DismissDonationCampaignBannerEvent(campaignID));
+ getActivity().setResult(Activity.RESULT_OK, new Intent().putExtra("postText", getArguments().getString("successPostText")));
+ getActivity().finish();
+ }
+
+ private void onCancel(){
+ getActivity().finish();
+ }
+}
diff --git a/mastodon/src/main/java/org/joinmastodon/android/fragments/HomeTimelineFragment.java b/mastodon/src/main/java/org/joinmastodon/android/fragments/HomeTimelineFragment.java
index 174aa2335..0a4205b8c 100644
--- a/mastodon/src/main/java/org/joinmastodon/android/fragments/HomeTimelineFragment.java
+++ b/mastodon/src/main/java/org/joinmastodon/android/fragments/HomeTimelineFragment.java
@@ -5,15 +5,23 @@ import android.animation.AnimatorListenerAdapter;
import android.animation.AnimatorSet;
import android.animation.ObjectAnimator;
import android.app.Activity;
+import android.content.Context;
+import android.content.Intent;
import android.content.res.Configuration;
import android.os.Bundle;
+import android.text.SpannableStringBuilder;
import android.text.TextUtils;
+import android.text.style.ForegroundColorSpan;
+import android.text.style.TypefaceSpan;
+import android.text.style.UnderlineSpan;
import android.view.Gravity;
import android.view.Menu;
import android.view.MenuInflater;
import android.view.MenuItem;
import android.view.View;
import android.view.ViewGroup;
+import android.view.ViewStub;
+import android.view.ViewTreeObserver;
import android.view.accessibility.AccessibilityNodeInfo;
import android.view.animation.AnimationUtils;
import android.widget.Button;
@@ -26,14 +34,17 @@ import android.widget.Toolbar;
import com.squareup.otto.Subscribe;
+import org.joinmastodon.android.BuildConfig;
import org.joinmastodon.android.E;
import org.joinmastodon.android.R;
import org.joinmastodon.android.api.MastodonAPIRequest;
+import org.joinmastodon.android.api.requests.catalog.GetDonationCampaigns;
import org.joinmastodon.android.api.requests.markers.SaveMarkers;
import org.joinmastodon.android.api.requests.timelines.GetHomeTimeline;
import org.joinmastodon.android.api.requests.timelines.GetListTimeline;
import org.joinmastodon.android.api.requests.timelines.GetPublicTimeline;
import org.joinmastodon.android.api.session.AccountSessionManager;
+import org.joinmastodon.android.events.DismissDonationCampaignBannerEvent;
import org.joinmastodon.android.events.SelfUpdateStateChangedEvent;
import org.joinmastodon.android.fragments.settings.SettingsMainFragment;
import org.joinmastodon.android.model.CacheablePaginatedResponse;
@@ -41,8 +52,11 @@ import org.joinmastodon.android.model.FilterContext;
import org.joinmastodon.android.model.FollowList;
import org.joinmastodon.android.model.Status;
import org.joinmastodon.android.model.TimelineMarkers;
+import org.joinmastodon.android.model.donations.DonationCampaign;
import org.joinmastodon.android.ui.displayitems.GapStatusDisplayItem;
import org.joinmastodon.android.ui.displayitems.StatusDisplayItem;
+import org.joinmastodon.android.ui.sheets.DonationSheet;
+import org.joinmastodon.android.ui.sheets.DonationSuccessfulSheet;
import org.joinmastodon.android.ui.utils.DiscoverInfoBannerHelper;
import org.joinmastodon.android.ui.viewcontrollers.HomeTimelineMenuController;
import org.joinmastodon.android.ui.viewcontrollers.ToolbarDropdownMenuController;
@@ -53,6 +67,7 @@ import org.parceler.Parcels;
import java.util.Collections;
import java.util.HashSet;
import java.util.List;
+import java.util.Locale;
import java.util.Set;
import androidx.annotation.NonNull;
@@ -64,8 +79,11 @@ import me.grishka.appkit.api.SimpleCallback;
import me.grishka.appkit.utils.CubicBezierInterpolator;
import me.grishka.appkit.utils.MergeRecyclerAdapter;
import me.grishka.appkit.utils.V;
+import me.grishka.appkit.views.BottomSheet;
public class HomeTimelineFragment extends StatusListFragment implements ToolbarDropdownMenuController.HostFragment{
+ private static final int DONATION_RESULT=211;
+
private ImageButton fab;
private LinearLayout listsDropdown;
private FixedAspectRatioImageView listsDropdownArrow;
@@ -81,9 +99,13 @@ public class HomeTimelineFragment extends StatusListFragment implements ToolbarD
private FollowList currentList;
private MergeRecyclerAdapter mergeAdapter;
private DiscoverInfoBannerHelper localTimelineBannerHelper;
+ private View donationBanner;
+ private boolean donationBannerDismissing;
private String maxID;
private String lastSavedMarkerID;
+ private DonationCampaign currentDonationCampaign;
+ private BottomSheet donationSheet;
public HomeTimelineFragment(){
setListLayoutId(R.layout.fragment_timeline);
@@ -93,6 +115,32 @@ public class HomeTimelineFragment extends StatusListFragment implements ToolbarD
public void onCreate(Bundle savedInstanceState){
super.onCreate(savedInstanceState);
localTimelineBannerHelper=new DiscoverInfoBannerHelper(DiscoverInfoBannerHelper.BannerType.LOCAL_TIMELINE, accountID);
+
+ if(AccountSessionManager.get(accountID).isEligibleForDonations()){
+ GetDonationCampaigns req=new GetDonationCampaigns(Locale.getDefault().toLanguageTag().replace('-', '_'), String.valueOf(AccountSessionManager.get(accountID).getDonationSeed()), null);
+ if(getActivity().getSharedPreferences("debug", Context.MODE_PRIVATE).getBoolean("donationsStaging", false)){
+ req.setStaging(true);
+ }
+ req.setCallback(new Callback<>(){
+ @Override
+ public void onSuccess(DonationCampaign result){
+ if(result==null)
+ return;
+ AccountSessionManager.getInstance().runIfDonationCampaignNotDismissed(result.id, ()->showDonationBanner(result));
+ }
+
+ @Override
+ public void onError(ErrorResponse error){}
+ })
+ .execNoAuth("");
+ }
+ E.register(this);
+ }
+
+ @Override
+ public void onDestroy(){
+ super.onDestroy();
+ E.unregister(this);
}
@Override
@@ -233,6 +281,8 @@ public class HomeTimelineFragment extends StatusListFragment implements ToolbarD
E.register(this);
updateUpdateState(GithubSelfUpdater.getInstance().getState());
}
+ if(currentDonationCampaign!=null)
+ showDonationBanner(currentDonationCampaign);
}
@Override
@@ -587,6 +637,8 @@ public class HomeTimelineFragment extends StatusListFragment implements ToolbarD
if(GithubSelfUpdater.needSelfUpdating()){
E.unregister(this);
}
+ donationBanner=null;
+ donationBannerDismissing=false;
}
private void updateUpdateState(GithubSelfUpdater.UpdateState state){
@@ -599,6 +651,13 @@ public class HomeTimelineFragment extends StatusListFragment implements ToolbarD
updateUpdateState(ev.state);
}
+ @Subscribe
+ public void onDismissDonationCampaignBanner(DismissDonationCampaignBannerEvent ev){
+ if(currentDonationCampaign!=null && ev.campaignID.equals(currentDonationCampaign.id)){
+ dismissDonationBanner();
+ }
+ }
+
@Override
protected boolean shouldRemoveAccountPostsWhenUnfollowing(){
return true;
@@ -653,6 +712,17 @@ public class HomeTimelineFragment extends StatusListFragment implements ToolbarD
super.onDataLoaded(d, more);
}
+ @Override
+ public void onActivityResult(int requestCode, int resultCode, Intent data){
+ if(requestCode==DONATION_RESULT){
+ if(donationSheet!=null)
+ donationSheet.dismissWithoutAnimation();
+ if(resultCode==Activity.RESULT_OK){
+ new DonationSuccessfulSheet(getActivity(), accountID, data.getStringExtra("postText")).showWithoutAnimation();
+ }
+ }
+ }
+
private String getCurrentListTitle(){
return switch(listMode){
case FOLLOWING -> getString(R.string.timeline_following);
@@ -661,6 +731,77 @@ public class HomeTimelineFragment extends StatusListFragment implements ToolbarD
};
}
+ private void showDonationBanner(DonationCampaign campaign){
+ if(getActivity()==null)
+ return;
+ currentDonationCampaign=campaign;
+ if(donationBanner==null){
+ ViewStub stub=contentView.findViewById(R.id.donation_banner);
+ donationBanner=stub.inflate();
+ donationBanner.findViewById(R.id.banner_dismiss).setOnClickListener(v->{
+ AccountSessionManager.getInstance().markDonationCampaignAsDismissed(currentDonationCampaign.id);
+ dismissDonationBanner();
+ });
+ donationBanner.setOnClickListener(v->openDonationSheet());
+ }else{
+ donationBanner.setVisibility(View.VISIBLE);
+ }
+ TextView text=donationBanner.findViewById(R.id.banner_text);
+ SpannableStringBuilder ssb=new SpannableStringBuilder(campaign.bannerMessage);
+ ssb.append(' ');
+ int start=ssb.length();
+ ssb.append(campaign.bannerButtonText);
+ ssb.setSpan(new ForegroundColorSpan(getResources().getColor(R.color.masterialDark_colorGoldenrodContainer, getActivity().getTheme())), start, ssb.length(), 0);
+ ssb.setSpan(new UnderlineSpan(), start, ssb.length(), 0);
+ ssb.setSpan(new TypefaceSpan("sans-serif-medium"), start, ssb.length(), 0);
+ text.setText(ssb);
+ donationBanner.getViewTreeObserver().addOnPreDrawListener(new ViewTreeObserver.OnPreDrawListener(){
+ @Override
+ public boolean onPreDraw(){
+ donationBanner.getViewTreeObserver().removeOnPreDrawListener(this);
+
+ AnimatorSet set=new AnimatorSet();
+ set.playTogether(
+ ObjectAnimator.ofFloat(donationBanner, View.TRANSLATION_Y, donationBanner.getHeight(), 0),
+ ObjectAnimator.ofFloat(fab, View.TRANSLATION_Y, -donationBanner.getHeight())
+ );
+ set.setDuration(250);
+ set.setInterpolator(CubicBezierInterpolator.DEFAULT);
+ set.start();
+
+ return true;
+ }
+ });
+ }
+
+ private void dismissDonationBanner(){
+ if(donationBanner==null || donationBannerDismissing)
+ return;
+ AnimatorSet set=new AnimatorSet();
+ set.playTogether(
+ ObjectAnimator.ofFloat(donationBanner, View.TRANSLATION_Y, donationBanner.getHeight()),
+ ObjectAnimator.ofFloat(fab, View.TRANSLATION_Y, 0)
+ );
+ set.setDuration(250);
+ set.setInterpolator(CubicBezierInterpolator.DEFAULT);
+ set.addListener(new AnimatorListenerAdapter(){
+ @Override
+ public void onAnimationEnd(Animator animation){
+ donationBanner.setVisibility(View.GONE);
+ donationBannerDismissing=false;
+ }
+ });
+ donationBannerDismissing=true;
+ set.start();
+ currentDonationCampaign=null;
+ }
+
+ private void openDonationSheet(){
+ donationSheet=new DonationSheet(getActivity(), currentDonationCampaign, accountID, intent->startActivityForResult(intent, DONATION_RESULT));
+ donationSheet.setOnDismissListener(dialog->donationSheet=null);
+ donationSheet.show();
+ }
+
private enum ListMode{
FOLLOWING,
LOCAL,
diff --git a/mastodon/src/main/java/org/joinmastodon/android/fragments/WebViewFragment.java b/mastodon/src/main/java/org/joinmastodon/android/fragments/WebViewFragment.java
new file mode 100644
index 000000000..7cd3022a7
--- /dev/null
+++ b/mastodon/src/main/java/org/joinmastodon/android/fragments/WebViewFragment.java
@@ -0,0 +1,100 @@
+package org.joinmastodon.android.fragments;
+
+import android.os.Bundle;
+import android.util.Log;
+import android.view.LayoutInflater;
+import android.view.View;
+import android.view.ViewGroup;
+import android.webkit.WebChromeClient;
+import android.webkit.WebResourceError;
+import android.webkit.WebResourceRequest;
+import android.webkit.WebView;
+import android.webkit.WebViewClient;
+
+import org.joinmastodon.android.BuildConfig;
+import org.joinmastodon.android.api.MastodonErrorResponse;
+
+import me.grishka.appkit.Nav;
+import me.grishka.appkit.fragments.LoaderFragment;
+
+public abstract class WebViewFragment extends LoaderFragment{
+ private static final String TAG="WebViewFragment";
+
+ protected WebView webView;
+ private Runnable backCallback=this::onGoBack;
+ private boolean backCallbackSet;
+
+ @Override
+ public View onCreateContentView(LayoutInflater inflater, ViewGroup container, Bundle savedInstanceState){
+ webView=new WebView(getActivity());
+ webView.setWebChromeClient(new WebChromeClient(){
+ @Override
+ public void onReceivedTitle(WebView view, String title){
+ setTitle(title);
+ }
+ });
+ webView.setWebViewClient(new WebViewClient(){
+ @Override
+ public void onPageFinished(WebView view, String url){
+ if(BuildConfig.DEBUG){
+ Log.d(TAG, "onPageFinished: "+url);
+ }
+ dataLoaded();
+ updateBackCallback();
+ }
+
+ @Override
+ public void onReceivedError(WebView view, WebResourceRequest request, WebResourceError error){
+ onError(new MastodonErrorResponse(error.getDescription().toString(), -1, null));
+ updateBackCallback();
+ }
+
+ @Override
+ public boolean shouldOverrideUrlLoading(WebView view, WebResourceRequest request){
+ return WebViewFragment.this.shouldOverrideUrlLoading(request);
+ }
+
+ @Override
+ public void doUpdateVisitedHistory(WebView view, String url, boolean isReload){
+ updateBackCallback();
+ }
+ });
+ webView.getSettings().setJavaScriptEnabled(true);
+ return webView;
+ }
+
+ @Override
+ protected void doLoadData(){
+
+ }
+
+ @Override
+ public void onRefresh(){
+ webView.reload();
+ }
+
+ @Override
+ public void onToolbarNavigationClick(){
+ Nav.finish(this);
+ }
+
+ private void updateBackCallback(){
+ boolean canGoBack=webView.canGoBack();
+ if(canGoBack!=backCallbackSet){
+ if(canGoBack){
+ addBackCallback(backCallback);
+ backCallbackSet=true;
+ }else{
+ removeBackCallback(backCallback);
+ backCallbackSet=false;
+ }
+ }
+ }
+
+ private void onGoBack(){
+ if(webView.canGoBack())
+ webView.goBack();
+ }
+
+ protected abstract boolean shouldOverrideUrlLoading(WebResourceRequest req);
+}
diff --git a/mastodon/src/main/java/org/joinmastodon/android/fragments/settings/SettingsDebugFragment.java b/mastodon/src/main/java/org/joinmastodon/android/fragments/settings/SettingsDebugFragment.java
index 755238e83..ce6e0add9 100644
--- a/mastodon/src/main/java/org/joinmastodon/android/fragments/settings/SettingsDebugFragment.java
+++ b/mastodon/src/main/java/org/joinmastodon/android/fragments/settings/SettingsDebugFragment.java
@@ -1,5 +1,7 @@
package org.joinmastodon.android.fragments.settings;
+import android.content.Context;
+import android.content.SharedPreferences;
import android.os.Bundle;
import android.widget.Toast;
@@ -9,6 +11,7 @@ import org.joinmastodon.android.api.session.AccountSession;
import org.joinmastodon.android.api.session.AccountSessionManager;
import org.joinmastodon.android.fragments.HomeFragment;
import org.joinmastodon.android.fragments.onboarding.AccountActivationFragment;
+import org.joinmastodon.android.model.viewmodel.CheckableListItem;
import org.joinmastodon.android.model.viewmodel.ListItem;
import org.joinmastodon.android.ui.utils.DiscoverInfoBannerHelper;
import org.joinmastodon.android.updater.GithubSelfUpdater;
@@ -18,6 +21,8 @@ import java.util.List;
import me.grishka.appkit.Nav;
public class SettingsDebugFragment extends BaseSettingsFragment{
+ private CheckableListItem donationsStagingItem;
+
@Override
public void onCreate(Bundle savedInstanceState){
super.onCreate(savedInstanceState);
@@ -28,7 +33,9 @@ public class SettingsDebugFragment extends BaseSettingsFragment{
selfUpdateItem=new ListItem<>("Force self-update", null, this::onForceSelfUpdateClick),
resetUpdateItem=new ListItem<>("Reset self-updater", null, this::onResetUpdaterClick),
new ListItem<>("Reset search info banners", null, this::onResetDiscoverBannersClick),
- new ListItem<>("Reset pre-reply sheets", null, this::onResetPreReplySheetsClick)
+ new ListItem<>("Reset pre-reply sheets", null, this::onResetPreReplySheetsClick),
+ new ListItem<>("Clear dismissed donation campaigns", null, this::onClearDismissedCampaignsClick),
+ donationsStagingItem=new CheckableListItem<>("Use staging environment for donations", null, CheckableListItem.Style.SWITCH, getPrefs().getBoolean("donationsStaging", false), this::toggleCheckableItem)
));
if(!GithubSelfUpdater.needSelfUpdating()){
resetUpdateItem.isEnabled=selfUpdateItem.isEnabled=false;
@@ -39,6 +46,12 @@ public class SettingsDebugFragment extends BaseSettingsFragment{
@Override
protected void doLoadData(int offset, int count){}
+ @Override
+ public void onStop(){
+ super.onStop();
+ getPrefs().edit().putBoolean("donationsStaging", donationsStagingItem.checked).apply();
+ }
+
private void onTestEmailConfirmClick(ListItem> item){
AccountSession sess=AccountSessionManager.getInstance().getAccount(accountID);
sess.activated=false;
@@ -70,9 +83,18 @@ public class SettingsDebugFragment extends BaseSettingsFragment{
Toast.makeText(getActivity(), "Pre-reply sheets were reset", Toast.LENGTH_SHORT).show();
}
+ private void onClearDismissedCampaignsClick(ListItem> item){
+ AccountSessionManager.getInstance().clearDismissedDonationCampaigns();
+ Toast.makeText(getActivity(), "Dismissed campaigns cleared. Restart app to see your current campaign, if any", Toast.LENGTH_LONG).show();
+ }
+
private void restartUI(){
Bundle args=new Bundle();
args.putString("account", accountID);
Nav.goClearingStack(getActivity(), HomeFragment.class, args);
}
+
+ private SharedPreferences getPrefs(){
+ return getActivity().getSharedPreferences("debug", Context.MODE_PRIVATE);
+ }
}
diff --git a/mastodon/src/main/java/org/joinmastodon/android/fragments/settings/SettingsMainFragment.java b/mastodon/src/main/java/org/joinmastodon/android/fragments/settings/SettingsMainFragment.java
index bb5a806d7..78f3fede3 100644
--- a/mastodon/src/main/java/org/joinmastodon/android/fragments/settings/SettingsMainFragment.java
+++ b/mastodon/src/main/java/org/joinmastodon/android/fragments/settings/SettingsMainFragment.java
@@ -1,10 +1,14 @@
package org.joinmastodon.android.fragments.settings;
+import android.app.Activity;
+import android.content.Context;
+import android.content.Intent;
import android.os.Bundle;
import android.view.View;
import android.widget.Button;
import android.widget.ImageView;
import android.widget.TextView;
+import android.widget.Toast;
import com.squareup.otto.Subscribe;
@@ -12,27 +16,38 @@ import org.joinmastodon.android.BuildConfig;
import org.joinmastodon.android.E;
import org.joinmastodon.android.MainActivity;
import org.joinmastodon.android.R;
+import org.joinmastodon.android.api.requests.catalog.GetDonationCampaigns;
import org.joinmastodon.android.api.session.AccountSession;
import org.joinmastodon.android.api.session.AccountSessionManager;
import org.joinmastodon.android.events.SelfUpdateStateChangedEvent;
+import org.joinmastodon.android.model.donations.DonationCampaign;
import org.joinmastodon.android.model.viewmodel.ListItem;
-import org.joinmastodon.android.ui.sheets.AccountSwitcherSheet;
import org.joinmastodon.android.ui.M3AlertDialogBuilder;
+import org.joinmastodon.android.ui.sheets.AccountSwitcherSheet;
+import org.joinmastodon.android.ui.sheets.DonationSheet;
+import org.joinmastodon.android.ui.sheets.DonationSuccessfulSheet;
import org.joinmastodon.android.ui.utils.HideableSingleViewRecyclerAdapter;
import org.joinmastodon.android.ui.utils.UiUtils;
import org.joinmastodon.android.updater.GithubSelfUpdater;
+import java.util.ArrayList;
import java.util.List;
+import java.util.Locale;
import androidx.recyclerview.widget.RecyclerView;
import me.grishka.appkit.Nav;
+import me.grishka.appkit.api.Callback;
+import me.grishka.appkit.api.ErrorResponse;
import me.grishka.appkit.utils.MergeRecyclerAdapter;
public class SettingsMainFragment extends BaseSettingsFragment{
+ private static final int DONATION_RESULT=433;
+
private boolean loggedOut;
private HideableSingleViewRecyclerAdapter bannerAdapter;
private Button updateButton1, updateButton2;
private TextView updateText;
+ private DonationSheet donationSheet;
private Runnable updateDownloadProgressUpdater=new Runnable(){
@Override
public void run(){
@@ -49,21 +64,26 @@ public class SettingsMainFragment extends BaseSettingsFragment{
super.onCreate(savedInstanceState);
setTitle(R.string.settings);
setSubtitle(AccountSessionManager.get(accountID).getFullUsername());
- onDataLoaded(List.of(
+ ArrayList> items=new ArrayList<>();
+ if(BuildConfig.DEBUG || BuildConfig.BUILD_TYPE.equals("appcenterPrivateBeta")){
+ items.add(new ListItem<>("Debug settings", null, R.drawable.ic_settings_24px, i->Nav.go(getActivity(), SettingsDebugFragment.class, makeFragmentArgs()), null, 0, true));
+ }
+ items.addAll(List.of(
new ListItem<>(R.string.settings_behavior, 0, R.drawable.ic_settings_24px, this::onBehaviorClick),
new ListItem<>(R.string.settings_display, 0, R.drawable.ic_style_24px, this::onDisplayClick),
new ListItem<>(R.string.settings_privacy, 0, R.drawable.ic_privacy_tip_24px, this::onPrivacyClick),
new ListItem<>(R.string.settings_filters, 0, R.drawable.ic_filter_alt_24px, this::onFiltersClick),
new ListItem<>(R.string.settings_notifications, 0, R.drawable.ic_notifications_24px, this::onNotificationsClick),
new ListItem<>(AccountSessionManager.get(accountID).domain, getString(R.string.settings_server_explanation), R.drawable.ic_dns_24px, this::onServerClick),
- new ListItem<>(getString(R.string.about_app, getString(R.string.app_name)), null, R.drawable.ic_info_24px, this::onAboutClick, null, 0, true),
- new ListItem<>(R.string.manage_accounts, 0, R.drawable.ic_switch_account_24px, this::onManageAccountsClick),
- new ListItem<>(R.string.log_out, 0, R.drawable.ic_logout_24px, this::onLogOutClick, R.attr.colorM3Error, false)
+ new ListItem<>(getString(R.string.about_app, getString(R.string.app_name)), null, R.drawable.ic_info_24px, this::onAboutClick, null, 0, true)
));
-
- if(BuildConfig.DEBUG || BuildConfig.BUILD_TYPE.equals("appcenterPrivateBeta")){
- data.add(0, new ListItem<>("Debug settings", null, R.drawable.ic_settings_24px, i->Nav.go(getActivity(), SettingsDebugFragment.class, makeFragmentArgs()), null, 0, true));
+ if(AccountSessionManager.get(accountID).isEligibleForDonations()){
+ items.add(new ListItem<>(R.string.settings_donate, 0, R.drawable.ic_volunteer_activism_24px, this::onDonateClick));
+ items.add(new ListItem<>(R.string.settings_manage_donations, 0, R.drawable.ic_settings_heart_24px, this::onManageDonationClick, 0, true));
}
+ items.add(new ListItem<>(R.string.manage_accounts, 0, R.drawable.ic_switch_account_24px, this::onManageAccountsClick));
+ items.add(new ListItem<>(R.string.log_out, 0, R.drawable.ic_logout_24px, this::onLogOutClick, R.attr.colorM3Error, false));
+ onDataLoaded(items);
AccountSession session=AccountSessionManager.get(accountID);
session.reloadPreferences(null);
@@ -117,6 +137,17 @@ public class SettingsMainFragment extends BaseSettingsFragment{
}
}
+ @Override
+ public void onActivityResult(int requestCode, int resultCode, Intent data){
+ if(requestCode==DONATION_RESULT){
+ if(donationSheet!=null)
+ donationSheet.dismissWithoutAnimation();
+ if(resultCode==Activity.RESULT_OK){
+ new DonationSuccessfulSheet(getActivity(), accountID, data.getStringExtra("postText")).showWithoutAnimation();
+ }
+ }
+ }
+
private Bundle makeFragmentArgs(){
Bundle args=new Bundle();
args.putString("account", accountID);
@@ -167,6 +198,39 @@ public class SettingsMainFragment extends BaseSettingsFragment{
.show();
}
+ private void onDonateClick(ListItem> item){
+ GetDonationCampaigns req=new GetDonationCampaigns(Locale.getDefault().toLanguageTag().replace('-', '_'), String.valueOf(AccountSessionManager.get(accountID).getDonationSeed()), null);
+ if(getActivity().getSharedPreferences("debug", Context.MODE_PRIVATE).getBoolean("donationsStaging", false)){
+ req.setStaging(true);
+ }
+ req.setCallback(new Callback<>(){
+ @Override
+ public void onSuccess(DonationCampaign result){
+ Activity activity=getActivity();
+ if(activity==null)
+ return;
+ if(result==null){
+ Toast.makeText(activity, "No campaign available (server misconfiguration?)", Toast.LENGTH_SHORT).show();
+ return;
+ }
+ donationSheet=new DonationSheet(getActivity(), result, accountID, intent->startActivityForResult(intent, DONATION_RESULT));
+ donationSheet.setOnDismissListener(dialog->donationSheet=null);
+ donationSheet.show();
+ }
+
+ @Override
+ public void onError(ErrorResponse error){
+ error.showToast(getActivity());
+ }
+ })
+ .wrapProgress(getActivity(), R.string.loading, true)
+ .execNoAuth("");
+ }
+
+ private void onManageDonationClick(ListItem> item){
+ UiUtils.launchWebBrowser(getActivity(), "https://sponsor.staging.joinmastodon.org/donate/manage");
+ }
+
@Subscribe
public void onSelfUpdateStateChanged(SelfUpdateStateChangedEvent ev){
updateUpdateBanner();
diff --git a/mastodon/src/main/java/org/joinmastodon/android/model/donations/DonationCampaign.java b/mastodon/src/main/java/org/joinmastodon/android/model/donations/DonationCampaign.java
new file mode 100644
index 000000000..fe672bc0a
--- /dev/null
+++ b/mastodon/src/main/java/org/joinmastodon/android/model/donations/DonationCampaign.java
@@ -0,0 +1,34 @@
+package org.joinmastodon.android.model.donations;
+
+import org.joinmastodon.android.api.AllFieldsAreRequired;
+import org.joinmastodon.android.api.ObjectValidationException;
+import org.joinmastodon.android.api.RequiredField;
+import org.joinmastodon.android.model.BaseModel;
+
+import java.util.Map;
+
+@AllFieldsAreRequired
+public class DonationCampaign extends BaseModel{
+ public String id;
+ public String bannerMessage;
+ public String bannerButtonText;
+ public String donationMessage;
+ public String donationButtonText;
+ public Amounts amounts;
+ public String defaultCurrency;
+ public String donationUrl;
+ public String donationSuccessPost;
+
+ @Override
+ public void postprocess() throws ObjectValidationException{
+ super.postprocess();
+ amounts.postprocess();
+ }
+
+ public static class Amounts extends BaseModel{
+ public Map oneTime;
+ @RequiredField
+ public Map monthly;
+ public Map yearly;
+ }
+}
diff --git a/mastodon/src/main/java/org/joinmastodon/android/ui/sheets/DonationSheet.java b/mastodon/src/main/java/org/joinmastodon/android/ui/sheets/DonationSheet.java
new file mode 100644
index 000000000..a6e0ef89f
--- /dev/null
+++ b/mastodon/src/main/java/org/joinmastodon/android/ui/sheets/DonationSheet.java
@@ -0,0 +1,353 @@
+package org.joinmastodon.android.ui.sheets;
+
+import android.app.Activity;
+import android.content.Context;
+import android.content.Intent;
+import android.graphics.drawable.ColorDrawable;
+import android.net.Uri;
+import android.os.Bundle;
+import android.util.AttributeSet;
+import android.view.LayoutInflater;
+import android.view.View;
+import android.view.ViewGroup;
+import android.view.Window;
+import android.view.WindowManager;
+import android.widget.TextView;
+import android.widget.Toast;
+import android.widget.ToggleButton;
+
+import org.joinmastodon.android.DonationFragmentActivity;
+import org.joinmastodon.android.R;
+import org.joinmastodon.android.fragments.DonationWebViewFragment;
+import org.joinmastodon.android.model.donations.DonationCampaign;
+import org.joinmastodon.android.ui.M3AlertDialogBuilder;
+import org.joinmastodon.android.ui.OutlineProviders;
+import org.joinmastodon.android.ui.utils.UiUtils;
+import org.joinmastodon.android.ui.views.CurrencyAmountInput;
+
+import java.text.NumberFormat;
+import java.util.Arrays;
+import java.util.Currency;
+import java.util.List;
+import java.util.Locale;
+import java.util.function.Consumer;
+import java.util.stream.Collectors;
+
+import androidx.annotation.NonNull;
+import me.grishka.appkit.utils.CustomViewHelper;
+import me.grishka.appkit.utils.V;
+import me.grishka.appkit.views.BottomSheet;
+
+public class DonationSheet extends BottomSheet{
+ private final DonationCampaign campaign;
+ private final String accountID;
+ private final Consumer startCallback;
+ private DonationFrequency frequency=DonationFrequency.MONTHLY;
+
+ private View onceTab, monthlyTab, yearlyTab;
+ private int currentTab;
+ private CurrencyAmountInput amountField;
+ private ToggleButton[] suggestedAmountButtons=new ToggleButton[6];
+ private View button;
+ private TextView buttonText;
+ private Activity activity;
+
+ public DonationSheet(@NonNull Activity activity, DonationCampaign campaign, String accountID, Consumer startCallback){
+ super(activity);
+ this.campaign=campaign;
+ this.accountID=accountID;
+ this.activity=activity;
+ this.startCallback=startCallback;
+ Context context=activity;
+
+ View content=context.getSystemService(LayoutInflater.class).inflate(R.layout.sheet_donation, null);
+ setContentView(content);
+ setNavigationBarBackground(new ColorDrawable(UiUtils.alphaBlendColors(UiUtils.getThemeColor(context, R.attr.colorM3Surface),
+ UiUtils.getThemeColor(context, R.attr.colorM3Primary), 0.05f)), !UiUtils.isDarkTheme());
+
+ TextView text=findViewById(R.id.text);
+ text.setText(campaign.donationMessage);
+
+ onceTab=findViewById(R.id.once);
+ monthlyTab=findViewById(R.id.monthly);
+ yearlyTab=findViewById(R.id.yearly);
+ onceTab.setOnClickListener(this::onTabClick);
+ monthlyTab.setOnClickListener(this::onTabClick);
+ yearlyTab.setOnClickListener(this::onTabClick);
+
+ if(campaign.amounts.yearly==null)
+ yearlyTab.setVisibility(View.GONE);
+ if(campaign.amounts.oneTime==null)
+ onceTab.setVisibility(View.GONE);
+ if(campaign.amounts.monthly==null){
+ monthlyTab.setVisibility(View.GONE);
+ if(campaign.amounts.oneTime!=null){
+ onceTab.setSelected(true);
+ currentTab=R.id.once;
+ frequency=DonationFrequency.ONCE;
+ }else if(campaign.amounts.yearly!=null){
+ yearlyTab.setSelected(true);
+ currentTab=R.id.yearly;
+ frequency=DonationFrequency.YEARLY;
+ }else{
+ Toast.makeText(context, "Amounts object is empty", Toast.LENGTH_SHORT).show();
+ dismiss();
+ return;
+ }
+ }else{
+ monthlyTab.setSelected(true);
+ currentTab=R.id.monthly;
+ }
+
+
+ View tabBarItself=findViewById(R.id.tabbar_inner);
+ tabBarItself.setOutlineProvider(OutlineProviders.roundedRect(20));
+ tabBarItself.setClipToOutline(true);
+
+ amountField=findViewById(R.id.amount);
+ List availableCurrencies=campaign.amounts.monthly.keySet().stream().sorted().collect(Collectors.toList());
+ amountField.setCurrencies(availableCurrencies);
+ try{
+ amountField.setSelectedCurrency(campaign.defaultCurrency);
+ }catch(IllegalArgumentException x){
+ new M3AlertDialogBuilder(context)
+ .setTitle(R.string.error)
+ .setMessage("Default currency "+campaign.defaultCurrency+" not in list of available currencies "+availableCurrencies)
+ .show();
+ dismiss();
+ return;
+ }
+ amountField.setChangeListener(new CurrencyAmountInput.ChangeListener(){
+ @Override
+ public void onCurrencyChanged(String code){
+ updateSuggestedAmounts(code);
+ button.setEnabled(amountField.getAmount()>=getMinimumChargeAmount(code));
+ updateSuggestedButtonsState();
+ }
+
+ @Override
+ public void onAmountChanged(long amount){
+ button.setEnabled(amount>=getMinimumChargeAmount(amountField.getCurrency()));
+ updateSuggestedButtonsState();
+ }
+ });
+ button=findViewById(R.id.button);
+ buttonText=findViewById(R.id.button_text);
+
+ ViewGroup suggestedAmounts=findViewById(R.id.suggested_amounts);
+ for(int i=0;iopenWebView());
+
+ Arrays.stream(getCurrentSuggestedAmounts(campaign.defaultCurrency)).min().ifPresent(amountField::setAmount);
+ }
+
+ @Override
+ protected void onCreate(Bundle savedInstanceState){
+ super.onCreate(savedInstanceState);
+ Window window=getWindow();
+ window.setSoftInputMode(WindowManager.LayoutParams.SOFT_INPUT_ADJUST_RESIZE);
+ }
+
+ private void onTabClick(View v){
+ if(v.getId()==currentTab)
+ return;
+ findViewById(currentTab).setSelected(false);
+ v.setSelected(true);
+ currentTab=v.getId();
+ if(currentTab==R.id.once)
+ frequency=DonationFrequency.ONCE;
+ else if(currentTab==R.id.monthly)
+ frequency=DonationFrequency.MONTHLY;
+ else if(currentTab==R.id.yearly)
+ frequency=DonationFrequency.YEARLY;
+ updateSuggestedAmounts(amountField.getCurrency());
+ }
+
+ private long[] getCurrentSuggestedAmounts(String currency){
+ long[] amounts=(switch(frequency){
+ case ONCE -> campaign.amounts.oneTime;
+ case MONTHLY -> campaign.amounts.monthly;
+ case YEARLY -> campaign.amounts.yearly;
+ }).get(currency);
+ if(amounts==null){
+ amounts=new long[0];
+ }
+ return amounts;
+ }
+
+ private void updateSuggestedAmounts(String currency){
+ NumberFormat format=NumberFormat.getCurrencyInstance();
+ try{
+ format.setCurrency(Currency.getInstance(currency));
+ }catch(IllegalArgumentException ignore){}
+ int defaultFractionDigits=format.getMinimumFractionDigits();
+ long[] amounts=getCurrentSuggestedAmounts(currency);
+ for(int i=0;i=amounts.length){
+ btn.setVisibility(View.GONE);
+ continue;
+ }
+ btn.setVisibility(View.VISIBLE);
+ long amount=amounts[i];
+ format.setMinimumFractionDigits(amount%100==0 ? 0 : defaultFractionDigits);
+ btn.setText(format.format(amount/100.0));
+ }
+ updateSuggestedButtonsState();
+ }
+
+ private void onSuggestedAmountClick(View v){
+ int index=(int) v.getTag();
+ long[] amounts=getCurrentSuggestedAmounts(amountField.getCurrency());
+ amountField.setAmount(amounts[index]);
+ }
+
+ private void updateSuggestedButtonsState(){
+ long amount=amountField.getAmount();
+ long[] amounts=getCurrentSuggestedAmounts(amountField.getCurrency());
+ for(int i=0;i "one_time";
+ case MONTHLY -> "monthly";
+ case YEARLY -> "yearly";
+ })
+ .appendQueryParameter("success_callback_url", DonationWebViewFragment.SUCCESS_URL)
+ .appendQueryParameter("cancel_callback_url", DonationWebViewFragment.CANCEL_URL)
+ .appendQueryParameter("failure_callback_url", DonationWebViewFragment.FAILURE_URL);
+ Bundle args=new Bundle();
+ args.putString("url", builder.build().toString());
+ args.putString("account", accountID);
+ args.putString("campaignID", campaign.id);
+ args.putString("successPostText", campaign.donationSuccessPost);
+ args.putBoolean("_can_go_back", true);
+ startCallback.accept(new Intent(activity, DonationFragmentActivity.class).putExtra("fragmentArgs", args));
+ }
+
+ private static long getMinimumChargeAmount(String currency){
+ // https://docs.stripe.com/currencies#minimum-and-maximum-charge-amounts
+ // values are in cents
+ return switch(currency){
+ case "USD" -> 50;
+ case "AED" -> 2_00;
+ case "AUD" -> 50;
+ case "BGN" -> 1_00;
+ case "BRL" -> 50;
+ case "CAD" -> 50;
+ case "CHF" -> 50;
+ case "CZK" -> 15_00;
+ case "DKK" -> 2_50;
+ case "EUR" -> 50;
+ case "GBP" -> 30;
+ case "HKD" -> 4_00;
+ case "HUF" -> 175_00;
+ case "INR" -> 50;
+ case "JPY" -> 50_00;
+ case "MXN" -> 10_00;
+ case "MYR" -> 2_00;
+ case "NOK" -> 3_00;
+ case "NZD" -> 50;
+ case "PLN" -> 2_00;
+ case "RON" -> 2_00;
+ case "SEK" -> 3_00;
+ case "SGD" -> 50;
+ case "THB" -> 10_00;
+
+ default -> 50;
+ };
+ }
+
+ private enum DonationFrequency{
+ ONCE,
+ MONTHLY,
+ YEARLY
+ }
+
+ public static class SuggestedAmountsLayout extends ViewGroup implements CustomViewHelper{
+ private int visibleChildCount;
+ private static final int H_GAP=24;
+ private static final int V_GAP=8;
+ private static final int ROW_HEIGHT=32;
+
+ public SuggestedAmountsLayout(Context context){
+ this(context, null);
+ }
+
+ public SuggestedAmountsLayout(Context context, AttributeSet attrs){
+ this(context, attrs, 0);
+ }
+
+ public SuggestedAmountsLayout(Context context, AttributeSet attrs, int defStyle){
+ super(context, attrs, defStyle);
+ }
+
+ @Override
+ protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec){
+ visibleChildCount=0;
+ for(int i=0;i4 ? dp(ROW_HEIGHT*2+V_GAP) : dp(ROW_HEIGHT));
+ int buttonsPerRow=visibleChildCount>4 ? 3 : visibleChildCount;
+ int buttonWidth=(width-dp(H_GAP)*(buttonsPerRow-1))/buttonsPerRow;
+ for(int i=0;i4 ? 3 : visibleChildCount;
+ int buttonWidth=(width-dp(H_GAP)*(buttonsPerRow-1))/buttonsPerRow;
+ for(int i=0;idismiss());
+ View shareButton=content.findViewById(R.id.btn_share);
+ if(postText==null){
+ shareButton.setEnabled(false);
+ }
+ shareButton.setOnClickListener(v->{
+ Bundle args=new Bundle();
+ args.putString("account", accountID);
+ args.putString("prefilledText", postText);
+ Nav.go((Activity) context, ComposeFragment.class, args);
+ dismiss();
+ });
+ }
+}
diff --git a/mastodon/src/main/java/org/joinmastodon/android/ui/views/CurrencyAmountInput.java b/mastodon/src/main/java/org/joinmastodon/android/ui/views/CurrencyAmountInput.java
new file mode 100644
index 000000000..bae0f93c8
--- /dev/null
+++ b/mastodon/src/main/java/org/joinmastodon/android/ui/views/CurrencyAmountInput.java
@@ -0,0 +1,340 @@
+package org.joinmastodon.android.ui.views;
+
+import android.annotation.SuppressLint;
+import android.content.Context;
+import android.graphics.Canvas;
+import android.graphics.Paint;
+import android.text.Editable;
+import android.text.InputFilter;
+import android.text.InputType;
+import android.text.Spannable;
+import android.text.SpannableString;
+import android.text.Spanned;
+import android.text.TextWatcher;
+import android.text.method.DigitsKeyListener;
+import android.text.style.ReplacementSpan;
+import android.util.AttributeSet;
+import android.view.Gravity;
+import android.view.View;
+import android.view.ViewGroup;
+import android.widget.ArrayAdapter;
+import android.widget.Button;
+import android.widget.EditText;
+import android.widget.LinearLayout;
+import android.widget.TextView;
+
+import org.joinmastodon.android.R;
+import org.joinmastodon.android.ui.M3AlertDialogBuilder;
+import org.joinmastodon.android.ui.OutlineProviders;
+import org.joinmastodon.android.ui.utils.UiUtils;
+
+import java.text.NumberFormat;
+import java.text.ParseException;
+import java.util.Currency;
+import java.util.List;
+import java.util.stream.Collectors;
+
+import androidx.annotation.NonNull;
+import androidx.annotation.Nullable;
+import me.grishka.appkit.utils.CustomViewHelper;
+
+public class CurrencyAmountInput extends LinearLayout implements CustomViewHelper{
+ private ActualEditText edit;
+ private Button currencyBtn;
+ private List currencies;
+ private CurrencyInfo currentCurrency;
+ private boolean spanAdded;
+ private CurrencySymbolSpan symbolSpan;
+ private boolean symbolBeforeAmount;
+ private ChangeListener changeListener;
+ private long lastAmount=0;
+ private NumberFormat numberFormat=NumberFormat.getNumberInstance();
+ private boolean allowSymbolToBeDeleted;
+
+ public CurrencyAmountInput(Context context){
+ this(context, null);
+ }
+
+ public CurrencyAmountInput(Context context, AttributeSet attrs){
+ this(context, attrs, 0);
+ }
+
+ public CurrencyAmountInput(Context context, AttributeSet attrs, int defStyle){
+ super(context, attrs, defStyle);
+ setForeground(getResources().getDrawable(R.drawable.fg_currency_input, context.getTheme()));
+ setAddStatesFromChildren(true);
+
+ if(!isInEditMode())
+ setOutlineProvider(OutlineProviders.roundedRect(8));
+ setClipToOutline(true);
+
+ currencyBtn=new Button(context);
+ currencyBtn.setTextAppearance(R.style.m3_label_large);
+ currencyBtn.setSingleLine();
+ currencyBtn.setTextColor(UiUtils.getThemeColor(context, R.attr.colorM3OnSurfaceVariant));
+ int pad=dp(12);
+ currencyBtn.setPadding(pad, 0, pad, 0);
+ currencyBtn.setBackgroundColor(UiUtils.getThemeColor(context, R.attr.colorM3SurfaceVariant));
+ currencyBtn.setMinimumWidth(0);
+ currencyBtn.setMinWidth(0);
+ currencyBtn.setOnClickListener(v->showCurrencySelector());
+ currencyBtn.setCompoundDrawablesRelativeWithIntrinsicBounds(R.drawable.ic_unfold_more_wght600_15pt_8x20px, 0, 0, 0);
+ currencyBtn.setCompoundDrawableTintList(currencyBtn.getTextColors());
+ currencyBtn.setCompoundDrawablePadding(dp(4));
+ addView(currencyBtn, new LayoutParams(ViewGroup.LayoutParams.WRAP_CONTENT, ViewGroup.LayoutParams.MATCH_PARENT));
+
+ edit=new ActualEditText(context);
+ edit.setBackgroundColor(UiUtils.getThemeColor(context, R.attr.colorM3Surface));
+ pad=dp(16);
+ edit.setPadding(pad, 0, pad, 0);
+ edit.setSingleLine();
+ edit.setTextAppearance(R.style.m3_title_large);
+ edit.setTextColor(UiUtils.getThemeColor(context, R.attr.colorM3OnSurface));
+ edit.setGravity(Gravity.END |Gravity.CENTER_VERTICAL);
+ edit.setInputType(InputType.TYPE_CLASS_NUMBER | InputType.TYPE_NUMBER_FLAG_DECIMAL);
+ InputFilter[] filters=edit.getText().getFilters();
+ for(int i=0;i0;
+
+ edit.addTextChangedListener(new TextWatcher(){
+ private boolean ignore;
+
+ @Override
+ public void beforeTextChanged(CharSequence s, int start, int count, int after){
+
+ }
+
+ @Override
+ public void onTextChanged(CharSequence s, int start, int before, int count){
+
+ }
+
+ @Override
+ public void afterTextChanged(Editable e){
+ if(ignore)
+ return;
+ ignore=true;
+ if(e.length()>0 && !spanAdded){
+ SpannableString ss=new SpannableString(" ");
+ ss.setSpan(symbolSpan, 0, 1, Spannable.SPAN_EXCLUSIVE_EXCLUSIVE);
+ if(symbolBeforeAmount)
+ e.insert(0, ss);
+ else
+ e.append(ss);
+ spanAdded=true;
+ }else if(spanAdded && e.length()<=1){
+ spanAdded=false;
+ if(e.length()>0){
+ allowSymbolToBeDeleted=true;
+ e.clear();
+ allowSymbolToBeDeleted=false;
+ }
+ }
+ ignore=false;
+
+ updateAmount();
+ }
+ });
+ }
+
+ public void setCurrencies(List currencies){
+ this.currencies=currencies.stream().map(CurrencyInfo::new).collect(Collectors.toList());
+ }
+
+ public void setSelectedCurrency(String code){
+ CurrencyInfo info=null;
+ for(CurrencyInfo c:currencies){
+ if(c.code.equals(code)){
+ info=c;
+ break;
+ }
+ }
+ if(info==null)
+ throw new IllegalArgumentException();
+ setCurrency(info);
+ }
+
+ private void setCurrency(CurrencyInfo info){
+ currencyBtn.setText(info.code);
+ currentCurrency=info;
+ edit.invalidate();
+ if(changeListener!=null)
+ changeListener.onCurrencyChanged(info.code);
+ }
+
+ private void showCurrencySelector(){
+ ArrayAdapter adapter=new ArrayAdapter<>(getContext(), R.layout.item_alert_single_choice_2lines_but_different, R.id.text, currencies){
+ @Override
+ public boolean hasStableIds(){
+ return true;
+ }
+
+ @NonNull
+ @Override
+ public View getView(int position, @Nullable View convertView, @NonNull ViewGroup parent){
+ View view=super.getView(position, convertView, parent);
+ TextView subtitle=view.findViewById(R.id.subtitle);
+ CurrencyInfo item=getItem(position);
+ if(item.jCurrency==null || item.jCurrency.getDisplayName().equals(item.code)){
+ subtitle.setVisibility(View.GONE);
+ }else{
+ subtitle.setVisibility(View.VISIBLE);
+ subtitle.setText(item.jCurrency.getDisplayName());
+ }
+ return view;
+ }
+ };
+ new M3AlertDialogBuilder(getContext())
+ .setTitle(R.string.currency)
+ .setSingleChoiceItems(adapter, currencies.indexOf(currentCurrency), (dlg, item)->{
+ setCurrency(currencies.get(item));
+ dlg.dismiss();
+ })
+ .show();
+ }
+
+ public void setChangeListener(ChangeListener changeListener){
+ this.changeListener=changeListener;
+ }
+
+ private void updateAmount(){
+ long newAmount;
+ try{
+ Number n=numberFormat.parse(edit.getText().toString().trim());
+ if(n instanceof Long l){
+ newAmount=l*100L;
+ }else if(n instanceof Double d){
+ newAmount=(long)(d*100);
+ }else{
+ newAmount=0;
+ }
+ }catch(ParseException x){
+ newAmount=0;
+ }
+ if(newAmount!=lastAmount){
+ lastAmount=newAmount;
+ if(changeListener!=null)
+ changeListener.onAmountChanged(lastAmount);
+ }
+ }
+
+ public long getAmount(){
+ return lastAmount;
+ }
+
+ public String getCurrency(){
+ return currentCurrency.code;
+ }
+
+ @SuppressLint("DefaultLocale")
+ public void setAmount(long amount){
+ String value;
+ if(amount%100==0)
+ value=String.valueOf(amount/100);
+ else
+ value=String.format("%.2f", amount/100.0);
+ int start=spanAdded ? 1 : 0;
+ edit.getText().replace(start, edit.length(), value);
+ }
+
+ private class ActualEditText extends EditText{
+ public ActualEditText(Context context){
+ super(context);
+ setClipToPadding(false);
+ }
+
+ @Override
+ protected void onSelectionChanged(int selStart, int selEnd){
+ super.onSelectionChanged(selStart, selEnd);
+ // Adjust the selection to prevent the symbol span being selected
+ if(spanAdded){
+ int newSelStart=symbolBeforeAmount ? Math.max(selStart, 1) : Math.min(selStart, length()-1);
+ int newSelEnd=symbolBeforeAmount ? Math.max(selEnd, 1) : Math.min(selEnd, length()-1);
+ if(newSelStart!=selStart || newSelEnd!=selEnd){
+ setSelection(newSelStart, newSelEnd);
+ }
+ }
+ }
+ }
+
+ private static class CurrencyInfo{
+ public String code;
+ public String symbol;
+ public Currency jCurrency;
+
+ public CurrencyInfo(String code){
+ this.code=code;
+ try{
+ jCurrency=Currency.getInstance(code);
+ symbol=jCurrency.getSymbol();
+ }catch(IllegalArgumentException x){
+ symbol=code;
+ }
+ }
+
+ @NonNull
+ @Override
+ public String toString(){
+ return code;
+ }
+ }
+
+ private class CurrencySymbolSpan extends ReplacementSpan{
+ private Paint paint;
+ public CurrencySymbolSpan(Paint paint){
+ this.paint=new Paint(paint);
+ this.paint.setTextSize(paint.getTextSize()*0.66f);
+ }
+
+ @Override
+ public int getSize(@NonNull Paint paint, CharSequence text, int start, int end, @Nullable Paint.FontMetricsInt fm){
+ return Math.round(this.paint.measureText(currentCurrency.symbol))+dp(2);
+ }
+
+ @Override
+ public void draw(@NonNull Canvas canvas, CharSequence text, int start, int end, float x, int top, int y, int bottom, @NonNull Paint paint){
+ this.paint.setColor(paint.getColor());
+ this.paint.setAlpha(77);
+ if(!symbolBeforeAmount)
+ x+=dp(2);
+ canvas.drawText(currentCurrency.symbol, x, top+dp(1.5f)-this.paint.ascent(), this.paint);
+ }
+ }
+
+ private class FormattingFriendlyDigitsKeyListener extends DigitsKeyListener{
+ public FormattingFriendlyDigitsKeyListener(){
+ super(false, true);
+ }
+
+ @Override
+ public CharSequence filter(CharSequence source, int start, int end, Spanned dest, int dstart, int dend){
+ // Allow the currency symbol to be inserted (always done as a separate insertion operation)
+ if(source instanceof Spannable s && s.getSpans(start, end, CurrencySymbolSpan.class).length>0){
+ return source;
+ }
+ // Don't allow the currency symbol to be deleted
+ if(!allowSymbolToBeDeleted && end-start0){
+ return dest.subSequence(dstart, dend);
+ }
+ return super.filter(source, start, end, dest, dstart, dend);
+ }
+ }
+
+ public interface ChangeListener{
+ void onCurrencyChanged(String code);
+ void onAmountChanged(long amount);
+ }
+}
diff --git a/mastodon/src/main/res/anim/fragment_enter.xml b/mastodon/src/main/res/anim/fragment_enter.xml
new file mode 100644
index 000000000..073d62d12
--- /dev/null
+++ b/mastodon/src/main/res/anim/fragment_enter.xml
@@ -0,0 +1,13 @@
+
+
+
+
+
\ No newline at end of file
diff --git a/mastodon/src/main/res/anim/fragment_exit.xml b/mastodon/src/main/res/anim/fragment_exit.xml
new file mode 100644
index 000000000..8dcf659c9
--- /dev/null
+++ b/mastodon/src/main/res/anim/fragment_exit.xml
@@ -0,0 +1,13 @@
+
+
+
+
+
\ No newline at end of file
diff --git a/mastodon/src/main/res/anim/no_op_300ms.xml b/mastodon/src/main/res/anim/no_op_300ms.xml
new file mode 100644
index 000000000..86216b32a
--- /dev/null
+++ b/mastodon/src/main/res/anim/no_op_300ms.xml
@@ -0,0 +1,4 @@
+
+
+
+
\ No newline at end of file
diff --git a/mastodon/src/main/res/drawable-nodpi/donation_successful_art.webp b/mastodon/src/main/res/drawable-nodpi/donation_successful_art.webp
new file mode 100644
index 000000000..133a13f4e
Binary files /dev/null and b/mastodon/src/main/res/drawable-nodpi/donation_successful_art.webp differ
diff --git a/mastodon/src/main/res/drawable-xxxhdpi/scribble.webp b/mastodon/src/main/res/drawable-xxxhdpi/scribble.webp
new file mode 100644
index 000000000..abfe4d274
Binary files /dev/null and b/mastodon/src/main/res/drawable-xxxhdpi/scribble.webp differ
diff --git a/mastodon/src/main/res/drawable/bg_donation_banner.xml b/mastodon/src/main/res/drawable/bg_donation_banner.xml
new file mode 100644
index 000000000..de2ddedd8
--- /dev/null
+++ b/mastodon/src/main/res/drawable/bg_donation_banner.xml
@@ -0,0 +1,13 @@
+
+
+ -
+
+
+ -
+
+
-
+
+
+
+
+
\ No newline at end of file
diff --git a/mastodon/src/main/res/drawable/bg_filter_chip.xml b/mastodon/src/main/res/drawable/bg_filter_chip.xml
index d11366254..3072547e7 100644
--- a/mastodon/src/main/res/drawable/bg_filter_chip.xml
+++ b/mastodon/src/main/res/drawable/bg_filter_chip.xml
@@ -1,5 +1,15 @@
+ -
+
+
-
+
+
+
+
+
+
+
-
-
diff --git a/mastodon/src/main/res/drawable/fg_currency_input.xml b/mastodon/src/main/res/drawable/fg_currency_input.xml
new file mode 100644
index 000000000..13f79a328
--- /dev/null
+++ b/mastodon/src/main/res/drawable/fg_currency_input.xml
@@ -0,0 +1,15 @@
+
+
+
-
+
+
+
+
+
+ -
+
+
+
+
+
+
\ No newline at end of file
diff --git a/mastodon/src/main/res/drawable/ic_campaign_20px.xml b/mastodon/src/main/res/drawable/ic_campaign_20px.xml
new file mode 100644
index 000000000..b386285c5
--- /dev/null
+++ b/mastodon/src/main/res/drawable/ic_campaign_20px.xml
@@ -0,0 +1,9 @@
+
+
+
diff --git a/mastodon/src/main/res/drawable/ic_donation_monthly.xml b/mastodon/src/main/res/drawable/ic_donation_monthly.xml
new file mode 100644
index 000000000..2e796f13f
--- /dev/null
+++ b/mastodon/src/main/res/drawable/ic_donation_monthly.xml
@@ -0,0 +1,5 @@
+
+
+
+
+
\ No newline at end of file
diff --git a/mastodon/src/main/res/drawable/ic_favorite_18px.xml b/mastodon/src/main/res/drawable/ic_favorite_18px.xml
new file mode 100644
index 000000000..c4066a427
--- /dev/null
+++ b/mastodon/src/main/res/drawable/ic_favorite_18px.xml
@@ -0,0 +1,9 @@
+
+
+
diff --git a/mastodon/src/main/res/drawable/ic_settings_heart_24px.xml b/mastodon/src/main/res/drawable/ic_settings_heart_24px.xml
new file mode 100644
index 000000000..4d6527773
--- /dev/null
+++ b/mastodon/src/main/res/drawable/ic_settings_heart_24px.xml
@@ -0,0 +1,9 @@
+
+
+
diff --git a/mastodon/src/main/res/drawable/ic_unfold_more_wght600_15pt_8x20px.xml b/mastodon/src/main/res/drawable/ic_unfold_more_wght600_15pt_8x20px.xml
new file mode 100644
index 000000000..2e09a03dd
--- /dev/null
+++ b/mastodon/src/main/res/drawable/ic_unfold_more_wght600_15pt_8x20px.xml
@@ -0,0 +1,11 @@
+
+
+
+
+
diff --git a/mastodon/src/main/res/drawable/ic_volunteer_activism_20px.xml b/mastodon/src/main/res/drawable/ic_volunteer_activism_20px.xml
new file mode 100644
index 000000000..5f252b533
--- /dev/null
+++ b/mastodon/src/main/res/drawable/ic_volunteer_activism_20px.xml
@@ -0,0 +1,9 @@
+
+
+
diff --git a/mastodon/src/main/res/drawable/ic_volunteer_activism_24px.xml b/mastodon/src/main/res/drawable/ic_volunteer_activism_24px.xml
new file mode 100644
index 000000000..5a95829b8
--- /dev/null
+++ b/mastodon/src/main/res/drawable/ic_volunteer_activism_24px.xml
@@ -0,0 +1,9 @@
+
+
+
diff --git a/mastodon/src/main/res/interpolator-v21/cubic_bezier_default.xml b/mastodon/src/main/res/interpolator-v21/cubic_bezier_default.xml
new file mode 100644
index 000000000..9d568940b
--- /dev/null
+++ b/mastodon/src/main/res/interpolator-v21/cubic_bezier_default.xml
@@ -0,0 +1,3 @@
+
+
diff --git a/mastodon/src/main/res/layout/donation_banner.xml b/mastodon/src/main/res/layout/donation_banner.xml
new file mode 100644
index 000000000..c25e4be12
--- /dev/null
+++ b/mastodon/src/main/res/layout/donation_banner.xml
@@ -0,0 +1,32 @@
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/mastodon/src/main/res/layout/fragment_timeline.xml b/mastodon/src/main/res/layout/fragment_timeline.xml
index defa53bcf..e39c20575 100644
--- a/mastodon/src/main/res/layout/fragment_timeline.xml
+++ b/mastodon/src/main/res/layout/fragment_timeline.xml
@@ -58,5 +58,12 @@
android:text="@string/see_new_posts"/>
+
+
\ No newline at end of file
diff --git a/mastodon/src/main/res/layout/sheet_donation.xml b/mastodon/src/main/res/layout/sheet_donation.xml
new file mode 100644
index 000000000..7b4fe156b
--- /dev/null
+++ b/mastodon/src/main/res/layout/sheet_donation.xml
@@ -0,0 +1,114 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/mastodon/src/main/res/layout/sheet_donation_success.xml b/mastodon/src/main/res/layout/sheet_donation_success.xml
new file mode 100644
index 000000000..323fb6798
--- /dev/null
+++ b/mastodon/src/main/res/layout/sheet_donation_success.xml
@@ -0,0 +1,84 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/mastodon/src/main/res/values-hdpi/misc.xml b/mastodon/src/main/res/values-hdpi/misc.xml
new file mode 100644
index 000000000..0539fe643
--- /dev/null
+++ b/mastodon/src/main/res/values-hdpi/misc.xml
@@ -0,0 +1,4 @@
+
+
+ 150
+
\ No newline at end of file
diff --git a/mastodon/src/main/res/values-tvdpi/misc.xml b/mastodon/src/main/res/values-tvdpi/misc.xml
new file mode 100644
index 000000000..b712242e7
--- /dev/null
+++ b/mastodon/src/main/res/values-tvdpi/misc.xml
@@ -0,0 +1,4 @@
+
+
+ 133
+
\ No newline at end of file
diff --git a/mastodon/src/main/res/values-xhdpi/misc.xml b/mastodon/src/main/res/values-xhdpi/misc.xml
new file mode 100644
index 000000000..cc9bd8bae
--- /dev/null
+++ b/mastodon/src/main/res/values-xhdpi/misc.xml
@@ -0,0 +1,4 @@
+
+
+ 200
+
\ No newline at end of file
diff --git a/mastodon/src/main/res/values-xxhdpi/misc.xml b/mastodon/src/main/res/values-xxhdpi/misc.xml
new file mode 100644
index 000000000..37e9754f1
--- /dev/null
+++ b/mastodon/src/main/res/values-xxhdpi/misc.xml
@@ -0,0 +1,4 @@
+
+
+ 300
+
\ No newline at end of file
diff --git a/mastodon/src/main/res/values-xxxhdpi/misc.xml b/mastodon/src/main/res/values-xxxhdpi/misc.xml
new file mode 100644
index 000000000..19cd19f41
--- /dev/null
+++ b/mastodon/src/main/res/values-xxxhdpi/misc.xml
@@ -0,0 +1,4 @@
+
+
+ 400
+
\ No newline at end of file
diff --git a/mastodon/src/main/res/values/colors_masterial.xml b/mastodon/src/main/res/values/colors_masterial.xml
new file mode 100644
index 000000000..88fce83a7
--- /dev/null
+++ b/mastodon/src/main/res/values/colors_masterial.xml
@@ -0,0 +1,336 @@
+
+
+
+ #4000DD
+ #FFFFFF
+ #6648FF
+ #FFFFFF
+ #5D51AF
+ #FFFFFF
+ #B0A5FF
+ #220C73
+ #810082
+ #FFFFFF
+ #B722B7
+ #FFFFFF
+ #BA1A1A
+ #FFFFFF
+ #FFDAD6
+ #410002
+ #FCF8FF
+ #1C1A25
+ #FCF8FF
+ #1C1A25
+ #E5DFF6
+ #474557
+ #787588
+ #C9C4DA
+ #000000
+ #312F3B
+ #F3EEFE
+ #C7BFFF
+ #E5DEFF
+ #180065
+ #C7BFFF
+ #4000DC
+ #E5DEFF
+ #180065
+ #C7BFFF
+ #453895
+ #FFD7F6
+ #380039
+ #FFAAF5
+ #800082
+ #DCD8E7
+ #FCF8FF
+ #FFFFFF
+ #F6F1FF
+ #F1EBFB
+ #EBE6F6
+ #E5E0F0
+ #3C00D1
+ #FFFFFF
+ #6648FF
+ #FFFFFF
+ #413391
+ #FFFFFF
+ #7368C7
+ #FFFFFF
+ #7A007B
+ #FFFFFF
+ #B722B7
+ #FFFFFF
+ #8C0009
+ #FFFFFF
+ #DA342E
+ #FFFFFF
+ #FCF8FF
+ #1C1A25
+ #FCF8FF
+ #1C1A25
+ #E5DFF6
+ #434153
+ #605D70
+ #7C788C
+ #000000
+ #312F3B
+ #F3EEFE
+ #C7BFFF
+ #7058FF
+ #FFFFFF
+ #552BFB
+ #FFFFFF
+ #7368C7
+ #FFFFFF
+ #5A4EAC
+ #FFFFFF
+ #C330C2
+ #FFFFFF
+ #A400A6
+ #FFFFFF
+ #DCD8E7
+ #FCF8FF
+ #FFFFFF
+ #F6F1FF
+ #F1EBFB
+ #EBE6F6
+ #E5E0F0
+ #1E0077
+ #FFFFFF
+ #3C00D1
+ #FFFFFF
+ #1F0671
+ #FFFFFF
+ #413391
+ #FFFFFF
+ #430044
+ #FFFFFF
+ #7A007B
+ #FFFFFF
+ #4E0002
+ #FFFFFF
+ #8C0009
+ #FFFFFF
+ #FCF8FF
+ #1C1A25
+ #FCF8FF
+ #000000
+ #E5DFF6
+ #242232
+ #434153
+ #434153
+ #000000
+ #312F3B
+ #FFFFFF
+ #EFE9FF
+ #3C00D1
+ #FFFFFF
+ #280094
+ #FFFFFF
+ #413391
+ #FFFFFF
+ #2A197A
+ #FFFFFF
+ #7A007B
+ #FFFFFF
+ #550056
+ #FFFFFF
+ #DCD8E7
+ #FCF8FF
+ #FFFFFF
+ #F6F1FF
+ #F1EBFB
+ #EBE6F6
+ #E5E0F0
+ #7B5800
+ #FFFFFF
+ #FFC758
+ #503800
+ #476800
+ #FFFFFF
+ #CAFF71
+ #3A5700
+ #583E00
+ #FFFFFF
+ #986D00
+ #FFFFFF
+ #314A00
+ #FFFFFF
+ #588000
+ #FFFFFF
+ #2F1F00
+ #FFFFFF
+ #583E00
+ #FFFFFF
+ #182600
+ #FFFFFF
+ #314A00
+ #FFFFFF
+
+
+ #C7BFFF
+ #2B009E
+ #4D1AF4
+ #FAF5FF
+ #C7BFFF
+ #2E1E7E
+ #3D308E
+ #D6CFFF
+ #FFAAF5
+ #5B005C
+ #970099
+ #FFF5F9
+ #FFB4AB
+ #690005
+ #93000A
+ #FFDAD6
+ #13121D
+ #E5E0F0
+ #13121D
+ #E5E0F0
+ #474557
+ #C9C4DA
+ #928EA3
+ #474557
+ #000000
+ #E5E0F0
+ #312F3B
+ #582FFE
+ #E5DEFF
+ #180065
+ #C7BFFF
+ #4000DC
+ #E5DEFF
+ #180065
+ #C7BFFF
+ #453895
+ #FFD7F6
+ #380039
+ #FFAAF5
+ #800082
+ #13121D
+ #3A3844
+ #0E0D17
+ #1C1A25
+ #201E29
+ #2A2934
+ #35333F
+ #CCC4FF
+ #130056
+ #8F7FFF
+ #000000
+ #CCC4FF
+ #130056
+ #9084E6
+ #000000
+ #FFB1F5
+ #2F0030
+ #E552E1
+ #000000
+ #FFBAB1
+ #370001
+ #FF5449
+ #000000
+ #13121D
+ #E5E0F0
+ #13121D
+ #FEF9FF
+ #474557
+ #CDC8DE
+ #A4A0B5
+ #848195
+ #000000
+ #E5E0F0
+ #2A2934
+ #4100DF
+ #E5DEFF
+ #0F0048
+ #C7BFFF
+ #3000AE
+ #E5DEFF
+ #0F0048
+ #C7BFFF
+ #342584
+ #FFD7F6
+ #260027
+ #FFAAF5
+ #650066
+ #13121D
+ #3A3844
+ #0E0D17
+ #1C1A25
+ #201E29
+ #2A2934
+ #35333F
+ #FEF9FF
+ #000000
+ #CCC4FF
+ #000000
+ #FEF9FF
+ #000000
+ #CCC4FF
+ #000000
+ #FFF9FA
+ #000000
+ #FFB1F5
+ #000000
+ #FFF9F9
+ #000000
+ #FFBAB1
+ #000000
+ #13121D
+ #E5E0F0
+ #13121D
+ #FFFFFF
+ #474557
+ #FEF9FF
+ #CDC8DE
+ #CDC8DE
+ #000000
+ #E5E0F0
+ #000000
+ #25008C
+ #E9E3FF
+ #000000
+ #CCC4FF
+ #130056
+ #E9E3FF
+ #000000
+ #CCC4FF
+ #130056
+ #FFDDF6
+ #000000
+ #FFB1F5
+ #2F0030
+ #13121D
+ #3A3844
+ #0E0D17
+ #1C1A25
+ #201E29
+ #2A2934
+ #35333F
+ #FFEBCE
+ #412D00
+ #F9B928
+ #463100
+ #FFFFFF
+ #233600
+ #A5E820
+ #2E4600
+ #FFEBCE
+ #412D00
+ #F9B928
+ #150C00
+ #FFFFFF
+ #233600
+ #A5E820
+ #162300
+ #FFFAF7
+ #000000
+ #FFC03B
+ #000000
+ #FFFFFF
+ #000000
+ #A5E820
+ #000000
+
\ No newline at end of file
diff --git a/mastodon/src/main/res/values/misc.xml b/mastodon/src/main/res/values/misc.xml
new file mode 100644
index 000000000..ff1f7fbee
--- /dev/null
+++ b/mastodon/src/main/res/values/misc.xml
@@ -0,0 +1,4 @@
+
+
+ 100
+
\ No newline at end of file
diff --git a/mastodon/src/main/res/values/strings.xml b/mastodon/src/main/res/values/strings.xml
index a7967c886..525e3dd35 100644
--- a/mastodon/src/main/res/values/strings.xml
+++ b/mastodon/src/main/res/values/strings.xml
@@ -766,4 +766,15 @@
- %,d new notification
- %,d new notifications
+ Dismiss
+ Just once
+ Monthly
+ Yearly
+ Currency
+ Spread the word
+ Thank you for your contribution!
+ You should receive an email confirming your donation soon.
+ We are sorry, an error occurred and we have not been able to process your donation.\n\nPlease retry in a few minutes.
+ Donate to Mastodon
+ Manage donations
\ No newline at end of file