diff --git a/mastodon/src/androidTest/java/org/joinmastodon/android/test/StoreScreenshotsGenerator.java b/mastodon/src/androidTest/java/org/joinmastodon/android/test/StoreScreenshotsGenerator.java index 301771b7e..2f5d36cc1 100644 --- a/mastodon/src/androidTest/java/org/joinmastodon/android/test/StoreScreenshotsGenerator.java +++ b/mastodon/src/androidTest/java/org/joinmastodon/android/test/StoreScreenshotsGenerator.java @@ -1,10 +1,8 @@ package org.joinmastodon.android.test; -import android.app.Instrumentation; import android.graphics.Bitmap; import android.net.Uri; import android.os.Bundle; -import android.os.Environment; import android.view.View; import android.view.inputmethod.InputMethodManager; @@ -14,7 +12,7 @@ import org.joinmastodon.android.GlobalUserPreferences; import org.joinmastodon.android.MainActivity; import org.joinmastodon.android.MastodonApp; import org.joinmastodon.android.R; -import org.joinmastodon.android.api.requests.instance.GetInstance; +import org.joinmastodon.android.api.requests.instance.GetInstanceV2; import org.joinmastodon.android.api.requests.statuses.GetStatusByID; import org.joinmastodon.android.api.session.AccountSession; import org.joinmastodon.android.api.session.AccountSessionManager; @@ -22,6 +20,7 @@ import org.joinmastodon.android.fragments.ComposeFragment; import org.joinmastodon.android.fragments.ThreadFragment; import org.joinmastodon.android.fragments.onboarding.InstanceRulesFragment; import org.joinmastodon.android.model.Instance; +import org.joinmastodon.android.model.InstanceV2; import org.joinmastodon.android.model.Status; import org.joinmastodon.android.ui.utils.UiUtils; import org.junit.Assert; @@ -32,12 +31,9 @@ import org.parceler.Parcels; import java.io.File; import java.io.IOException; -import java.util.concurrent.BrokenBarrierException; -import java.util.concurrent.CountDownLatch; import java.util.concurrent.CyclicBarrier; import java.util.concurrent.TimeoutException; -import androidx.test.core.app.ActivityScenario; import androidx.test.espresso.PerformException; import androidx.test.espresso.UiController; import androidx.test.espresso.ViewAction; @@ -47,19 +43,19 @@ import androidx.test.ext.junit.rules.ActivityScenarioRule; import androidx.test.ext.junit.runners.AndroidJUnit4; import androidx.test.filters.LargeTest; import androidx.test.platform.app.InstrumentationRegistry; -import androidx.test.runner.screenshot.ScreenCapture; import androidx.test.runner.screenshot.Screenshot; import me.grishka.appkit.api.Callback; import me.grishka.appkit.api.ErrorResponse; import okio.BufferedSink; import okio.Okio; -import okio.Sink; import okio.Source; -import static androidx.test.espresso.Espresso.*; -import static androidx.test.espresso.action.ViewActions.*; -import static androidx.test.espresso.assertion.ViewAssertions.*; -import static androidx.test.espresso.matcher.ViewMatchers.*; +import static androidx.test.espresso.Espresso.onView; +import static androidx.test.espresso.action.ViewActions.typeText; +import static androidx.test.espresso.matcher.ViewMatchers.Visibility; +import static androidx.test.espresso.matcher.ViewMatchers.isRoot; +import static androidx.test.espresso.matcher.ViewMatchers.withEffectiveVisibility; +import static androidx.test.espresso.matcher.ViewMatchers.withId; @RunWith(AndroidJUnit4.class) @LargeTest @@ -148,10 +144,10 @@ public class StoreScreenshotsGenerator{ takeScreenshot("Thread"); Instance[] _instance={null}; - new GetInstance() + new GetInstanceV2() .setCallback(new Callback<>(){ @Override - public void onSuccess(Instance result){ + public void onSuccess(InstanceV2 result){ _instance[0]=result; try{ barrier.await(); diff --git a/mastodon/src/main/java/org/joinmastodon/android/OAuthActivity.java b/mastodon/src/main/java/org/joinmastodon/android/OAuthActivity.java index 553074599..38dca2075 100644 --- a/mastodon/src/main/java/org/joinmastodon/android/OAuthActivity.java +++ b/mastodon/src/main/java/org/joinmastodon/android/OAuthActivity.java @@ -6,7 +6,6 @@ import android.content.Intent; import android.net.Uri; import android.os.Bundle; import android.text.TextUtils; -import android.util.Log; import android.widget.Toast; import org.joinmastodon.android.api.requests.accounts.GetOwnAccount; @@ -79,7 +78,7 @@ public class OAuthActivity extends Activity{ progress.dismiss(); } }) - .exec(instance.uri, token); + .exec(instance.getDomain(), token); } @Override @@ -88,7 +87,7 @@ public class OAuthActivity extends Activity{ progress.dismiss(); } }) - .execNoAuth(instance.uri); + .execNoAuth(instance.getDomain()); } private void handleError(ErrorResponse error){ diff --git a/mastodon/src/main/java/org/joinmastodon/android/api/WrapperRequest.java b/mastodon/src/main/java/org/joinmastodon/android/api/WrapperRequest.java new file mode 100644 index 000000000..4c9c090d2 --- /dev/null +++ b/mastodon/src/main/java/org/joinmastodon/android/api/WrapperRequest.java @@ -0,0 +1,21 @@ +package org.joinmastodon.android.api; + +import me.grishka.appkit.api.APIRequest; + +/** + * Wraps a different API request to allow a chain of requests to be canceled + */ +public class WrapperRequest extends APIRequest{ + public APIRequest wrappedRequest; + + @Override + public void cancel(){ + if(wrappedRequest!=null) + wrappedRequest.cancel(); + } + + @Override + public APIRequest exec(){ + throw new UnsupportedOperationException(); + } +} diff --git a/mastodon/src/main/java/org/joinmastodon/android/api/requests/instance/GetInstance.java b/mastodon/src/main/java/org/joinmastodon/android/api/requests/instance/GetInstance.java deleted file mode 100644 index 84273ce22..000000000 --- a/mastodon/src/main/java/org/joinmastodon/android/api/requests/instance/GetInstance.java +++ /dev/null @@ -1,10 +0,0 @@ -package org.joinmastodon.android.api.requests.instance; - -import org.joinmastodon.android.api.MastodonAPIRequest; -import org.joinmastodon.android.model.Instance; - -public class GetInstance extends MastodonAPIRequest{ - public GetInstance(){ - super(HttpMethod.GET, "/instance", Instance.class); - } -} diff --git a/mastodon/src/main/java/org/joinmastodon/android/api/requests/instance/GetInstanceV1.java b/mastodon/src/main/java/org/joinmastodon/android/api/requests/instance/GetInstanceV1.java new file mode 100644 index 000000000..693f5db1d --- /dev/null +++ b/mastodon/src/main/java/org/joinmastodon/android/api/requests/instance/GetInstanceV1.java @@ -0,0 +1,10 @@ +package org.joinmastodon.android.api.requests.instance; + +import org.joinmastodon.android.api.MastodonAPIRequest; +import org.joinmastodon.android.model.InstanceV1; + +public class GetInstanceV1 extends MastodonAPIRequest{ + public GetInstanceV1(){ + super(HttpMethod.GET, "/instance", InstanceV1.class); + } +} diff --git a/mastodon/src/main/java/org/joinmastodon/android/api/requests/instance/GetInstanceV2.java b/mastodon/src/main/java/org/joinmastodon/android/api/requests/instance/GetInstanceV2.java new file mode 100644 index 000000000..3167c06d4 --- /dev/null +++ b/mastodon/src/main/java/org/joinmastodon/android/api/requests/instance/GetInstanceV2.java @@ -0,0 +1,15 @@ +package org.joinmastodon.android.api.requests.instance; + +import org.joinmastodon.android.api.MastodonAPIRequest; +import org.joinmastodon.android.model.InstanceV2; + +public class GetInstanceV2 extends MastodonAPIRequest{ + public GetInstanceV2(){ + super(HttpMethod.GET, "/instance", InstanceV2.class); + } + + @Override + protected String getPathPrefix(){ + return "/api/v2"; + } +} 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 9f80fbda4..15c73fdbe 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 @@ -32,12 +32,15 @@ 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.MastodonErrorResponse; import org.joinmastodon.android.api.PushSubscriptionManager; +import org.joinmastodon.android.api.WrapperRequest; import org.joinmastodon.android.api.gson.JsonObjectBuilder; 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.instance.GetInstance; +import org.joinmastodon.android.api.requests.instance.GetInstanceV1; +import org.joinmastodon.android.api.requests.instance.GetInstanceV2; import org.joinmastodon.android.api.requests.oauth.CreateOAuthApp; import org.joinmastodon.android.events.EmojiUpdatedEvent; import org.joinmastodon.android.model.Account; @@ -45,6 +48,8 @@ import org.joinmastodon.android.model.Application; import org.joinmastodon.android.model.Emoji; import org.joinmastodon.android.model.EmojiCategory; import org.joinmastodon.android.model.Instance; +import org.joinmastodon.android.model.InstanceV1; +import org.joinmastodon.android.model.InstanceV2; import org.joinmastodon.android.model.LegacyFilter; import org.joinmastodon.android.model.Preferences; import org.joinmastodon.android.model.Token; @@ -67,6 +72,7 @@ import java.util.stream.Collectors; import androidx.annotation.NonNull; import androidx.annotation.Nullable; import androidx.browser.customtabs.CustomTabsIntent; +import me.grishka.appkit.api.APIRequest; import me.grishka.appkit.api.Callback; import me.grishka.appkit.api.ErrorResponse; @@ -74,7 +80,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=2; + private static final int DB_VERSION=3; private static final AccountSessionManager instance=new AccountSessionManager(); @@ -116,8 +122,8 @@ public class AccountSessionManager{ } public void addAccount(Instance instance, Token token, Account self, Application app, AccountActivationInfo activationInfo){ - instances.put(instance.uri, instance); - AccountSession session=new AccountSession(token, self, app, instance.uri, activationInfo==null, activationInfo); + instances.put(instance.getDomain(), instance); + AccountSession session=new AccountSession(token, self, app, instance.getDomain(), activationInfo==null, activationInfo); sessions.put(session.getID(), session); lastActiveAccountID=session.getID(); runOnDbThread(db->{ @@ -125,7 +131,7 @@ public class AccountSessionManager{ session.toContentValues(values); db.insertWithOnConflict("accounts", null, values, SQLiteDatabase.CONFLICT_REPLACE); }); - updateInstanceEmojis(instance, instance.uri); + updateInstanceEmojis(instance, instance.getDomain()); if(PushSubscriptionManager.arePushNotificationsAvailable()){ session.getPushSubscriptionManager().registerAccountForPush(null); } @@ -224,7 +230,7 @@ public class AccountSessionManager{ authenticatingApp=result; Uri uri=new Uri.Builder() .scheme("https") - .authority(instance.uri) + .authority(instance.getDomain()) .path("/oauth/authorize") .appendQueryParameter("response_type", "code") .appendQueryParameter("client_id", result.clientId) @@ -245,7 +251,7 @@ public class AccountSessionManager{ } }) .wrapProgress(activity, R.string.preparing_auth, false) - .execNoAuth(instance.uri); + .execNoAuth(instance.getDomain()); } public boolean isSelf(String id, Account other){ @@ -337,8 +343,7 @@ public class AccountSessionManager{ } public void updateInstanceInfo(String domain){ - new GetInstance() - .setCallback(new Callback<>(){ + loadInstanceInfo(domain, new Callback<>(){ @Override public void onSuccess(Instance instance){ instances.put(domain, instance); @@ -349,8 +354,7 @@ public class AccountSessionManager{ public void onError(ErrorResponse error){ } - }) - .execNoAuth(domain); + }); } private void updateInstanceEmojis(Instance instance, String domain){ @@ -379,7 +383,12 @@ public class AccountSessionManager{ while(cursor.moveToNext()){ DatabaseUtils.cursorRowToContentValues(cursor, values); String domain=values.getAsString("domain"); - Instance instance=MastodonAPIController.gson.fromJson(values.getAsString("instance_obj"), Instance.class); + int version=values.getAsInteger("version"); + Instance instance=MastodonAPIController.gson.fromJson(values.getAsString("instance_obj"), switch(version){ + case 1 -> InstanceV1.class; + case 2 -> InstanceV2.class; + default -> throw new IllegalStateException("Unexpected value: " + version); + }); List emojis=MastodonAPIController.gson.fromJson(values.getAsString("emojis"), new TypeToken>(){}.getType()); instances.put(domain, instance); customEmojis.put(domain, groupCustomEmojis(emojis)); @@ -575,9 +584,49 @@ public class AccountSessionManager{ values.put("instance_obj", MastodonAPIController.gson.toJson(instance)); values.put("emojis", MastodonAPIController.gson.toJson(emojis)); values.put("last_updated", lastUpdated); + values.put("version", instance.getVersion()); db.insertWithOnConflict("instances", null, values, SQLiteDatabase.CONFLICT_REPLACE); } + public static APIRequest loadInstanceInfo(String domain, Callback callback){ + final WrapperRequest wrapper=new WrapperRequest<>(); + wrapper.wrappedRequest=new GetInstanceV2() + .setCallback(new Callback<>(){ + @Override + public void onSuccess(InstanceV2 result){ + wrapper.wrappedRequest=null; + callback.onSuccess(result); + } + + @Override + public void onError(ErrorResponse error){ + if(error instanceof MastodonErrorResponse mr && mr.httpStatus==404){ + // Mastodon pre-4.0 or a non-Mastodon server altogether. Let's try /api/v1/instance + wrapper.wrappedRequest=new GetInstanceV1() + .setCallback(new Callback<>(){ + @Override + public void onSuccess(InstanceV1 result){ + wrapper.wrappedRequest=null; + callback.onSuccess(result); + } + + @Override + public void onError(ErrorResponse error){ + wrapper.wrappedRequest=null; + callback.onError(error); + } + }) + .execNoAuth(domain); + }else{ + wrapper.wrappedRequest=null; + callback.onError(error); + } + } + }) + .execNoAuth(domain); + return wrapper; + } + private static class DatabaseHelper extends SQLiteOpenHelper{ public DatabaseHelper(){ super(MastodonApp.context, "accounts.db", null, DB_VERSION); @@ -590,13 +639,28 @@ public class AccountSessionManager{ `id` text PRIMARY KEY, `dismissed_at` bigint )"""); - createAccountsAndInstancesTables(db); + createAccountsTable(db); + db.execSQL(""" + CREATE TABLE `instances` ( + `domain` text PRIMARY KEY, + `instance_obj` text, + `emojis` text, + `last_updated` bigint, + `version` integer NOT NULL DEFAULT 1 + )"""); } @Override public void onUpgrade(SQLiteDatabase db, int oldVersion, int newVersion){ if(oldVersion<2){ - createAccountsAndInstancesTables(db); + createAccountsTable(db); + db.execSQL(""" + CREATE TABLE `instances` ( + `domain` text PRIMARY KEY, + `instance_obj` text, + `emojis` text, + `last_updated` bigint + )"""); File accountsFile=new File(MastodonApp.context.getFilesDir(), "accounts.json"); if(accountsFile.exists()){ @@ -629,9 +693,12 @@ public class AccountSessionManager{ } } } + if(oldVersion<3){ + db.execSQL("ALTER TABLE `instances` ADD `version` integer NOT NULL DEFAULT 1"); + } } - private void createAccountsAndInstancesTables(SQLiteDatabase db){ + private void createAccountsTable(SQLiteDatabase db){ db.execSQL(""" CREATE TABLE `accounts` ( `id` text PRIMARY KEY, @@ -648,13 +715,6 @@ public class AccountSessionManager{ `activation_info` text, `preferences` text )"""); - db.execSQL(""" - CREATE TABLE `instances` ( - `domain` text PRIMARY KEY, - `instance_obj` text, - `emojis` text, - `last_updated` bigint - )"""); } } } diff --git a/mastodon/src/main/java/org/joinmastodon/android/fragments/SplashFragment.java b/mastodon/src/main/java/org/joinmastodon/android/fragments/SplashFragment.java index b4964f040..30f03caca 100644 --- a/mastodon/src/main/java/org/joinmastodon/android/fragments/SplashFragment.java +++ b/mastodon/src/main/java/org/joinmastodon/android/fragments/SplashFragment.java @@ -19,11 +19,13 @@ import org.joinmastodon.android.R; import org.joinmastodon.android.api.MastodonErrorResponse; import org.joinmastodon.android.api.requests.accounts.CheckInviteLink; import org.joinmastodon.android.api.requests.catalog.GetCatalogDefaultInstances; -import org.joinmastodon.android.api.requests.instance.GetInstance; +import org.joinmastodon.android.api.requests.instance.GetInstanceV1; +import org.joinmastodon.android.api.session.AccountSessionManager; import org.joinmastodon.android.fragments.onboarding.InstanceCatalogSignupFragment; import org.joinmastodon.android.fragments.onboarding.InstanceChooserLoginFragment; import org.joinmastodon.android.fragments.onboarding.InstanceRulesFragment; import org.joinmastodon.android.model.Instance; +import org.joinmastodon.android.model.InstanceV1; import org.joinmastodon.android.model.catalog.CatalogDefaultInstance; import org.joinmastodon.android.ui.InterpolatingMotionEffect; import org.joinmastodon.android.ui.M3AlertDialogBuilder; @@ -172,15 +174,14 @@ public class SplashFragment extends AppKitFragment{ } private void proceedWithServerDomain(String domain){ - new GetInstance() - .setCallback(new Callback<>(){ + AccountSessionManager.loadInstanceInfo(domain, new Callback<>(){ @Override public void onSuccess(Instance result){ if(getActivity()==null) return; instanceLoadingProgress.dismiss(); instanceLoadingProgress=null; - if(!result.registrations && TextUtils.isEmpty(inviteCode)){ + if(!result.areRegistrationsOpen() && TextUtils.isEmpty(inviteCode)){ new M3AlertDialogBuilder(getActivity()) .setTitle(R.string.error) .setMessage(R.string.instance_signup_closed) @@ -203,8 +204,7 @@ public class SplashFragment extends AppKitFragment{ instanceLoadingProgress=null; error.showToast(getActivity()); } - }) - .execNoAuth(domain); + }); } private void onLearnMoreClick(View v){ diff --git a/mastodon/src/main/java/org/joinmastodon/android/fragments/onboarding/GoogleMadeMeAddThisFragment.java b/mastodon/src/main/java/org/joinmastodon/android/fragments/onboarding/GoogleMadeMeAddThisFragment.java index 03d0c3557..ee5a6aaae 100644 --- a/mastodon/src/main/java/org/joinmastodon/android/fragments/onboarding/GoogleMadeMeAddThisFragment.java +++ b/mastodon/src/main/java/org/joinmastodon/android/fragments/onboarding/GoogleMadeMeAddThisFragment.java @@ -1,9 +1,6 @@ package org.joinmastodon.android.fragments.onboarding; import android.app.Activity; -import android.graphics.Paint; -import android.graphics.Rect; -import android.os.Build; import android.os.Bundle; import android.text.TextUtils; import android.view.LayoutInflater; @@ -11,14 +8,11 @@ import android.view.View; import android.view.ViewGroup; import android.view.WindowInsets; import android.widget.Button; -import android.widget.ImageView; import android.widget.TextView; import org.joinmastodon.android.R; import org.joinmastodon.android.api.MastodonAPIController; import org.joinmastodon.android.model.Instance; -import org.joinmastodon.android.ui.DividerItemDecoration; -import org.joinmastodon.android.ui.OutlineProviders; import org.joinmastodon.android.ui.utils.UiUtils; import org.joinmastodon.android.utils.ElevationOnScrollListener; import org.jsoup.Jsoup; @@ -26,7 +20,6 @@ import org.jsoup.nodes.Document; import org.parceler.Parcels; import java.io.IOException; -import java.nio.charset.Charset; import java.nio.charset.StandardCharsets; import java.util.ArrayList; import java.util.Locale; @@ -36,14 +29,10 @@ import androidx.annotation.NonNull; import androidx.recyclerview.widget.LinearLayoutManager; import androidx.recyclerview.widget.RecyclerView; import me.grishka.appkit.Nav; -import me.grishka.appkit.fragments.AppKitFragment; import me.grishka.appkit.fragments.ToolbarFragment; -import me.grishka.appkit.imageloader.ViewImageLoader; -import me.grishka.appkit.imageloader.requests.UrlImageLoaderRequest; import me.grishka.appkit.utils.BindableViewHolder; import me.grishka.appkit.utils.MergeRecyclerAdapter; import me.grishka.appkit.utils.SingleViewRecyclerAdapter; -import me.grishka.appkit.utils.V; import me.grishka.appkit.views.FragmentRootLinearLayout; import me.grishka.appkit.views.UsableRecyclerView; import okhttp3.Call; @@ -99,7 +88,7 @@ public class GoogleMadeMeAddThisFragment extends ToolbarFragment{ list.setLayoutManager(new LinearLayoutManager(getActivity())); View headerView=inflater.inflate(R.layout.item_list_header_simple, list, false); TextView text=headerView.findViewById(R.id.text); - text.setText(getString(R.string.privacy_policy_subtitle, instance.uri)); + text.setText(getString(R.string.privacy_policy_subtitle, instance.getDomain())); adapter=new MergeRecyclerAdapter(); adapter.addAdapter(new SingleViewRecyclerAdapter(headerView)); @@ -111,7 +100,7 @@ public class GoogleMadeMeAddThisFragment extends ToolbarFragment{ buttonBar=view.findViewById(R.id.button_bar); Button backBtn=view.findViewById(R.id.btn_back); - backBtn.setText(getString(R.string.server_policy_disagree, instance.uri)); + backBtn.setText(getString(R.string.server_policy_disagree, instance.getDomain())); backBtn.setOnClickListener(v->{ setResult(false, null); Nav.finish(this); @@ -159,7 +148,7 @@ public class GoogleMadeMeAddThisFragment extends ToolbarFragment{ private void loadServerPrivacyPolicy(){ Request req=new Request.Builder() - .url("https://"+instance.uri+"/terms") + .url("https://"+instance.getDomain()+"/terms") .addHeader("Accept-Language", Locale.getDefault().toLanguageTag()) .build(); currentRequest=MastodonAPIController.getHttpClient().newCall(req); @@ -176,7 +165,7 @@ public class GoogleMadeMeAddThisFragment extends ToolbarFragment{ if(!response.isSuccessful()) return; Document doc=Jsoup.parse(Objects.requireNonNull(body).byteStream(), Objects.requireNonNull(body.contentType()).charset(StandardCharsets.UTF_8).name(), req.url().toString()); - final Item item=new Item(doc.title(), null, instance.uri, req.url().toString(), "https://"+instance.uri+"/favicon.ico"); + final Item item=new Item(doc.title(), null, instance.getDomain(), req.url().toString(), "https://"+instance.getDomain()+"/favicon.ico"); Activity activity=getActivity(); if(activity!=null){ activity.runOnUiThread(()->{ diff --git a/mastodon/src/main/java/org/joinmastodon/android/fragments/onboarding/InstanceCatalogFragment.java b/mastodon/src/main/java/org/joinmastodon/android/fragments/onboarding/InstanceCatalogFragment.java index 9d5281f3e..85d24e03f 100644 --- a/mastodon/src/main/java/org/joinmastodon/android/fragments/onboarding/InstanceCatalogFragment.java +++ b/mastodon/src/main/java/org/joinmastodon/android/fragments/onboarding/InstanceCatalogFragment.java @@ -15,8 +15,11 @@ import android.widget.TextView; import org.joinmastodon.android.R; import org.joinmastodon.android.api.MastodonAPIController; import org.joinmastodon.android.api.MastodonErrorResponse; -import org.joinmastodon.android.api.requests.instance.GetInstance; +import org.joinmastodon.android.api.requests.instance.GetInstanceV1; +import org.joinmastodon.android.api.session.AccountSessionManager; import org.joinmastodon.android.model.Instance; +import org.joinmastodon.android.model.InstanceV1; +import org.joinmastodon.android.model.InstanceV2; import org.joinmastodon.android.model.catalog.CatalogInstance; import org.joinmastodon.android.ui.M3AlertDialogBuilder; import org.joinmastodon.android.ui.utils.UiUtils; @@ -43,6 +46,7 @@ import javax.xml.parsers.DocumentBuilderFactory; import androidx.annotation.NonNull; import androidx.recyclerview.widget.RecyclerView; +import me.grishka.appkit.api.APIRequest; import me.grishka.appkit.api.Callback; import me.grishka.appkit.api.ErrorResponse; import me.grishka.appkit.fragments.BaseRecyclerFragment; @@ -63,7 +67,7 @@ abstract class InstanceCatalogFragment extends BaseRecyclerFragment instancesCache=new HashMap<>(); protected View buttonBar; protected List filteredData=new ArrayList<>(); - protected GetInstance loadingInstanceRequest; + protected APIRequest loadingInstanceRequest; protected Call loadingInstanceRedirectRequest; protected ProgressDialog instanceProgressDialog; protected HashMap redirects=new HashMap<>(); @@ -181,7 +185,7 @@ abstract class InstanceCatalogFragment extends BaseRecyclerFragment0 && filteredData.get(0)==fakeInstance){ + if(!filteredData.isEmpty() && filteredData.get(0)==fakeInstance){ if(list.findViewHolderForAdapterPosition(1) instanceof BindableViewHolder ivh){ ivh.rebind(); } @@ -190,13 +194,15 @@ abstract class InstanceCatalogFragment extends BaseRecyclerFragment(){ + loadingInstanceRequest=AccountSessionManager.loadInstanceInfo(domain, new Callback<>(){ @Override public void onSuccess(Instance result){ loadingInstanceRequest=null; loadingInstanceDomain=null; - result.uri=domain; // needed for instances that use domain redirection + if(result instanceof InstanceV1 v1) + v1.uri=domain; // needed for instances that use domain redirection + else if(result instanceof InstanceV2 v2) + v2.domain=domain; instancesCache.put(domain, result); if(instanceProgressDialog!=null || onError!=null) proceedWithAuthOrSignup(result); @@ -246,7 +252,7 @@ abstract class InstanceCatalogFragment extends BaseRecyclerFragment(){ @@ -368,9 +368,9 @@ public class InstanceCatalogSignupFragment extends InstanceCatalogFragment{ } } getActivity().getSystemService(InputMethodManager.class).hideSoftInputFromWindow(contentView.getWindowToken(), 0); - if(!instance.registrations && (TextUtils.isEmpty(inviteCode) || !Objects.equals(instance.uri, inviteCodeHost))){ - if(instance.invitesEnabled){ - showInviteLinkAlert(instance.uri); + if(!instance.areRegistrationsOpen() && (TextUtils.isEmpty(inviteCode) || !Objects.equals(instance.getDomain(), inviteCodeHost))){ + if(instance.areInvitesEnabled()){ + showInviteLinkAlert(instance.getDomain()); }else{ new M3AlertDialogBuilder(getActivity()) .setTitle(R.string.error) @@ -382,7 +382,7 @@ public class InstanceCatalogSignupFragment extends InstanceCatalogFragment{ } Bundle args=new Bundle(); args.putParcelable("instance", Parcels.wrap(instance)); - if(!TextUtils.isEmpty(inviteCode) && Objects.equals(instance.uri, inviteCodeHost)) + if(!TextUtils.isEmpty(inviteCode) && Objects.equals(instance.getDomain(), inviteCodeHost)) args.putString("inviteCode", inviteCode); Nav.go(getActivity(), InstanceRulesFragment.class, args); } @@ -667,7 +667,7 @@ public class InstanceCatalogSignupFragment extends InstanceCatalogFragment{ radioButton.setChecked(chosenInstance==item); Instance realInstance=instancesCache.get(item.normalizedDomain); float alpha; - if(realInstance!=null && !realInstance.registrations){ + if(realInstance!=null && !realInstance.areRegistrationsOpen()){ alpha=0.38f; description.setText(R.string.not_accepting_new_members); enabled=false; diff --git a/mastodon/src/main/java/org/joinmastodon/android/fragments/onboarding/InstanceRulesFragment.java b/mastodon/src/main/java/org/joinmastodon/android/fragments/onboarding/InstanceRulesFragment.java index 7ccc8e091..16a10f277 100644 --- a/mastodon/src/main/java/org/joinmastodon/android/fragments/onboarding/InstanceRulesFragment.java +++ b/mastodon/src/main/java/org/joinmastodon/android/fragments/onboarding/InstanceRulesFragment.java @@ -12,7 +12,6 @@ import android.widget.TextView; import org.joinmastodon.android.R; import org.joinmastodon.android.model.Instance; -import org.joinmastodon.android.ui.DividerItemDecoration; import org.joinmastodon.android.ui.adapters.InstanceRulesAdapter; import org.joinmastodon.android.ui.utils.UiUtils; import org.joinmastodon.android.utils.ElevationOnScrollListener; @@ -58,7 +57,7 @@ public class InstanceRulesFragment extends ToolbarFragment{ list.setLayoutManager(new LinearLayoutManager(getActivity())); View headerView=inflater.inflate(R.layout.item_list_header_simple, list, false); TextView text=headerView.findViewById(R.id.text); - text.setText(Html.fromHtml(getString(R.string.instance_rules_subtitle, ""+Html.escapeHtml(instance.uri)+""))); + text.setText(Html.fromHtml(getString(R.string.instance_rules_subtitle, ""+Html.escapeHtml(instance.getDomain())+""))); adapter=new MergeRecyclerAdapter(); adapter.addAdapter(new SingleViewRecyclerAdapter(headerView)); diff --git a/mastodon/src/main/java/org/joinmastodon/android/fragments/onboarding/SignupFragment.java b/mastodon/src/main/java/org/joinmastodon/android/fragments/onboarding/SignupFragment.java index ea008cbf7..d3516bbc4 100644 --- a/mastodon/src/main/java/org/joinmastodon/android/fragments/onboarding/SignupFragment.java +++ b/mastodon/src/main/java/org/joinmastodon/android/fragments/onboarding/SignupFragment.java @@ -115,7 +115,7 @@ public class SignupFragment extends ToolbarFragment{ passwordConfirmWrap=view.findViewById(R.id.password_confirm_wrap); reasonWrap=view.findViewById(R.id.reason_wrap); - domain.setText('@'+instance.uri); + domain.setText('@'+instance.getDomain()); username.getViewTreeObserver().addOnPreDrawListener(new ViewTreeObserver.OnPreDrawListener(){ @Override @@ -143,7 +143,7 @@ public class SignupFragment extends ToolbarFragment{ passwordConfirm.addTextChangedListener(new ErrorClearingListener(passwordConfirm)); reason.addTextChangedListener(new ErrorClearingListener(reason)); - if(!instance.approvalRequired){ + if(!instance.isApprovalRequired()){ reason.setVisibility(View.GONE); reasonExplain.setVisibility(View.GONE); } @@ -285,7 +285,7 @@ public class SignupFragment extends ToolbarFragment{ progressDialog.dismiss(); } }) - .exec(instance.uri, apiToken); + .exec(instance.getDomain(), apiToken); } private CharSequence makeLinkInErrorMessage(String source, LinkSpan.OnLinkClickListener onClick){ @@ -317,7 +317,7 @@ public class SignupFragment extends ToolbarFragment{ case "email" -> switch(error.error){ case "ERR_BLOCKED" -> { String emailAddr=email.getText().toString(); - String s=getResources().getString(R.string.signup_email_domain_blocked, TextUtils.htmlEncode(instance.uri), TextUtils.htmlEncode(emailAddr.substring(emailAddr.lastIndexOf('@')+1))); + String s=getResources().getString(R.string.signup_email_domain_blocked, TextUtils.htmlEncode(instance.getDomain()), TextUtils.htmlEncode(emailAddr.substring(emailAddr.lastIndexOf('@')+1))); yield makeLinkInErrorMessage(s, this::onGoBackLinkClick); } case "ERR_INVALID" -> getString(R.string.signup_email_invalid); @@ -364,7 +364,7 @@ public class SignupFragment extends ToolbarFragment{ private void updateButtonState(){ btn.setEnabled(username.length()>0 && email.length()>0 && emailRegex.matcher(email.getText()).find() && password.length()>=8 && passwordConfirm.length()>=8 && password.getText().toString().equals(passwordConfirm.getText().toString()) - && (!instance.approvalRequired || reason.length()>0)); + && (!instance.isApprovalRequired() || reason.length()>0)); } private void createAppAndGetToken(){ @@ -386,7 +386,7 @@ public class SignupFragment extends ToolbarFragment{ } } }) - .execNoAuth(instance.uri); + .execNoAuth(instance.getDomain()); } private void getToken(){ @@ -412,7 +412,7 @@ public class SignupFragment extends ToolbarFragment{ } } }) - .execNoAuth(instance.uri); + .execNoAuth(instance.getDomain()); } @Override @@ -426,7 +426,7 @@ public class SignupFragment extends ToolbarFragment{ } private void onForgotPasswordLinkClick(LinkSpan span){ - UiUtils.launchWebBrowser(getActivity(), "https://"+instance.uri+"/auth/password/new"); + UiUtils.launchWebBrowser(getActivity(), "https://"+instance.getDomain()+"/auth/password/new"); } private void onPasswordFieldFocusChange(View v, boolean hasFocus){ diff --git a/mastodon/src/main/java/org/joinmastodon/android/fragments/settings/SettingsServerAboutFragment.java b/mastodon/src/main/java/org/joinmastodon/android/fragments/settings/SettingsServerAboutFragment.java index 307dfd837..f9dd1935d 100644 --- a/mastodon/src/main/java/org/joinmastodon/android/fragments/settings/SettingsServerAboutFragment.java +++ b/mastodon/src/main/java/org/joinmastodon/android/fragments/settings/SettingsServerAboutFragment.java @@ -100,13 +100,13 @@ public class SettingsServerAboutFragment extends LoaderFragment{ scroller.setClipToPadding(false); scroller.addView(scrollingLayout); - if(!TextUtils.isEmpty(instance.thumbnail)){ + if(!TextUtils.isEmpty(instance.getThumbnailURL())){ FixedAspectRatioImageView banner=new FixedAspectRatioImageView(getActivity()); banner.setAspectRatio(1.914893617f); banner.setScaleType(ImageView.ScaleType.CENTER_CROP); banner.setOutlineProvider(OutlineProviders.bottomRoundedRect(16)); banner.setClipToOutline(true); - ViewImageLoader.loadWithoutAnimation(banner, getResources().getDrawable(R.drawable.image_placeholder, getActivity().getTheme()), new UrlImageLoaderRequest(instance.thumbnail)); + ViewImageLoader.loadWithoutAnimation(banner, getResources().getDrawable(R.drawable.image_placeholder, getActivity().getTheme()), new UrlImageLoaderRequest(instance.getThumbnailURL())); LinearLayout.LayoutParams blp=new LinearLayout.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.WRAP_CONTENT); blp.bottomMargin=V.dp(24); scrollingLayout.addView(banner, blp); @@ -115,7 +115,7 @@ public class SettingsServerAboutFragment extends LoaderFragment{ } boolean needDivider=false; - if(instance.contactAccount!=null){ + if(instance.getContactAccount()!=null){ needDivider=true; TextView heading=new TextView(getActivity()); heading.setTextAppearance(R.style.m3_title_small); @@ -128,7 +128,7 @@ public class SettingsServerAboutFragment extends LoaderFragment{ hlp.leftMargin=hlp.rightMargin=V.dp(16); scrollingLayout.addView(heading, hlp); - AccountViewModel model=new AccountViewModel(instance.contactAccount, accountID); + AccountViewModel model=new AccountViewModel(instance.getContactAccount(), accountID); AccountViewHolder holder=new AccountViewHolder(this, scrollingLayout, null); holder.setStyle(AccountViewHolder.AccessoryType.NONE, false); holder.bind(model); @@ -140,7 +140,7 @@ public class SettingsServerAboutFragment extends LoaderFragment{ ViewImageLoader.load(new ViewImageLoaderHolderTarget(holder, i+1), null, model.emojiHelper.getImageRequest(i), false); } } - if(!TextUtils.isEmpty(instance.email)){ + if(!TextUtils.isEmpty(instance.getContactEmail())){ needDivider=true; SimpleListItemViewHolder holder=new SimpleListItemViewHolder(getActivity(), scrollingLayout); ListItem item=new ListItem<>(R.string.send_email_to_server_admin, 0, R.drawable.ic_mail_24px, i->{}); @@ -208,7 +208,7 @@ public class SettingsServerAboutFragment extends LoaderFragment{ public void onRefresh(){} private void openAdminEmail(){ - Intent intent=new Intent(Intent.ACTION_VIEW, Uri.fromParts("mailto", instance.email, null)); + Intent intent=new Intent(Intent.ACTION_VIEW, Uri.fromParts("mailto", instance.getContactEmail(), null)); intent.setFlags(Intent.FLAG_ACTIVITY_NEW_TASK); try{ startActivity(intent); diff --git a/mastodon/src/main/java/org/joinmastodon/android/model/Instance.java b/mastodon/src/main/java/org/joinmastodon/android/model/Instance.java index b4d523420..25f5ec709 100644 --- a/mastodon/src/main/java/org/joinmastodon/android/model/Instance.java +++ b/mastodon/src/main/java/org/joinmastodon/android/model/Instance.java @@ -1,7 +1,5 @@ package org.joinmastodon.android.model; -import android.text.Html; - import org.joinmastodon.android.api.ObjectValidationException; import org.joinmastodon.android.api.RequiredField; import org.joinmastodon.android.model.catalog.CatalogInstance; @@ -10,15 +8,8 @@ import org.parceler.Parcel; import java.net.IDN; import java.util.Collections; import java.util.List; -import java.util.Map; -@Parcel -public class Instance extends BaseModel{ - /** - * The domain name of the instance. - */ - @RequiredField - public String uri; +public abstract class Instance extends BaseModel{ /** * The title of the website. */ @@ -29,16 +20,6 @@ public class Instance extends BaseModel{ */ @RequiredField public String description; - /** - * A shorter description defined by the admin. - */ -// @RequiredField - public String shortDescription; - /** - * An email that may be contacted for any inquiries. - */ - @RequiredField - public String email; /** * The version of Mastodon installed on the instance. */ @@ -49,32 +30,7 @@ public class Instance extends BaseModel{ */ // @RequiredField public List languages; - /** - * Whether registrations are enabled. - */ - public boolean registrations; - /** - * Whether registrations require moderator approval. - */ - public boolean approvalRequired; - /** - * Whether invites are enabled. - */ - public boolean invitesEnabled; - /** - * URLs of interest for clients apps. - */ - public Map urls; - /** - * Banner image for the website. - */ - public String thumbnail; - /** - * A user that can be contacted, as an alternative to email. - */ - public Account contactAccount; - public Stats stats; public List rules; public Configuration configuration; @@ -85,51 +41,38 @@ public class Instance extends BaseModel{ @Override public void postprocess() throws ObjectValidationException{ super.postprocess(); - if(contactAccount!=null) - contactAccount.postprocess(); if(rules==null) rules=Collections.emptyList(); - if(shortDescription==null) - shortDescription=""; - } - - @Override - public String toString(){ - return "Instance{"+ - "uri='"+uri+'\''+ - ", title='"+title+'\''+ - ", description='"+description+'\''+ - ", shortDescription='"+shortDescription+'\''+ - ", email='"+email+'\''+ - ", version='"+version+'\''+ - ", languages="+languages+ - ", registrations="+registrations+ - ", approvalRequired="+approvalRequired+ - ", invitesEnabled="+invitesEnabled+ - ", urls="+urls+ - ", thumbnail='"+thumbnail+'\''+ - ", contactAccount="+contactAccount+ - '}'; } public CatalogInstance toCatalogInstance(){ CatalogInstance ci=new CatalogInstance(); - ci.domain=uri; - ci.normalizedDomain=IDN.toUnicode(uri); - ci.description=Html.fromHtml(shortDescription).toString().trim(); - if(languages!=null&&languages.size()>0){ + ci.domain=getDomain(); + ci.normalizedDomain=IDN.toUnicode(getDomain()); + ci.description=description.trim(); + if(languages!=null && !languages.isEmpty()){ ci.language=languages.get(0); ci.languages=languages; }else{ - ci.languages=Collections.emptyList(); + ci.languages=List.of(); ci.language="unknown"; } - ci.proxiedThumbnail=thumbnail; - if(stats!=null) - ci.totalUsers=stats.userCount; + ci.proxiedThumbnail=getThumbnailURL(); +// if(stats!=null) +// ci.totalUsers=stats.userCount; return ci; } + public abstract String getDomain(); + public abstract Account getContactAccount(); + public abstract String getContactEmail(); + public abstract boolean areRegistrationsOpen(); + public abstract boolean isApprovalRequired(); + public abstract boolean areInvitesEnabled(); + public abstract String getThumbnailURL(); + public abstract int getVersion(); + public abstract long getApiVersion(String name); + @Parcel public static class Rule{ public String id; @@ -138,13 +81,6 @@ public class Instance extends BaseModel{ public transient CharSequence parsedText; } - @Parcel - public static class Stats{ - public int userCount; - public int statusCount; - public int domainCount; - } - @Parcel public static class Configuration{ public StatusesConfiguration statuses; diff --git a/mastodon/src/main/java/org/joinmastodon/android/model/InstanceV1.java b/mastodon/src/main/java/org/joinmastodon/android/model/InstanceV1.java new file mode 100644 index 000000000..0cfe822e2 --- /dev/null +++ b/mastodon/src/main/java/org/joinmastodon/android/model/InstanceV1.java @@ -0,0 +1,112 @@ +package org.joinmastodon.android.model; + +import org.joinmastodon.android.api.ObjectValidationException; +import org.joinmastodon.android.api.RequiredField; +import org.parceler.Parcel; + +import java.util.Map; + +@Parcel +public class InstanceV1 extends Instance{ + /** + * The domain name of the instance. + */ + @RequiredField + public String uri; + /** + * A shorter description defined by the admin. + */ +// @RequiredField + public String shortDescription; + /** + * An email that may be contacted for any inquiries. + */ + @RequiredField + public String email; + /** + * Whether registrations are enabled. + */ + public boolean registrations; + /** + * Whether registrations require moderator approval. + */ + public boolean approvalRequired; + /** + * Whether invites are enabled. + */ + public boolean invitesEnabled; + /** + * URLs of interest for clients apps. + */ + public Map urls; + /** + * A user that can be contacted, as an alternative to email. + */ + public Account contactAccount; + public Stats stats; + /** + * Banner image for the website. + */ + public String thumbnail; + + @Override + public String getDomain(){ + return uri; + } + + @Override + public Account getContactAccount(){ + return contactAccount; + } + + @Override + public String getContactEmail(){ + return email; + } + + @Override + public boolean areInvitesEnabled(){ + return invitesEnabled; + } + + @Override + public boolean areRegistrationsOpen(){ + return registrations; + } + + @Override + public boolean isApprovalRequired(){ + return approvalRequired; + } + + @Override + public String getThumbnailURL(){ + return thumbnail; + } + + @Override + public int getVersion(){ + return 1; + } + + @Override + public long getApiVersion(String name){ + return 0; + } + + @Override + public void postprocess() throws ObjectValidationException{ + super.postprocess(); + if(shortDescription==null) + shortDescription=""; + if(contactAccount!=null) + contactAccount.postprocess(); + } + + @Parcel + public static class Stats{ + public int userCount; + public int statusCount; + public int domainCount; + } +} diff --git a/mastodon/src/main/java/org/joinmastodon/android/model/InstanceV2.java b/mastodon/src/main/java/org/joinmastodon/android/model/InstanceV2.java new file mode 100644 index 000000000..20d99de29 --- /dev/null +++ b/mastodon/src/main/java/org/joinmastodon/android/model/InstanceV2.java @@ -0,0 +1,93 @@ +package org.joinmastodon.android.model; + +import org.joinmastodon.android.api.ObjectValidationException; +import org.joinmastodon.android.api.RequiredField; +import org.parceler.Parcel; + +import java.util.Map; + +@Parcel +public class InstanceV2 extends Instance{ + @RequiredField + public String domain; + public Thumbnail thumbnail; + @RequiredField + public Registrations registrations; + public Contact contact; + public Map apiVersions; + + @Override + public String getDomain(){ + return domain; + } + + @Override + public Account getContactAccount(){ + return contact!=null ? contact.account : null; + } + + @Override + public String getContactEmail(){ + return contact!=null ? contact.email : null; + } + + @Override + public boolean areRegistrationsOpen(){ + return registrations.enabled; + } + + @Override + public boolean isApprovalRequired(){ + return registrations.approvalRequired; + } + + @Override + public boolean areInvitesEnabled(){ + return true; // TODO are they though? + } + + @Override + public String getThumbnailURL(){ + return thumbnail!=null ? thumbnail.url : null; + } + + @Override + public int getVersion(){ + return 2; + } + + @Override + public long getApiVersion(String name){ + if(apiVersions==null) + return 0; + Long v=apiVersions.get(name); + return v==null ? 0 : v; + } + + @Override + public void postprocess() throws ObjectValidationException{ + super.postprocess(); + if(contact!=null && contact.account!=null) + contact.account.postprocess(); + } + + @Parcel + public static class Thumbnail{ + public String url; + public String blurhash; + } + + @Parcel + public static class Registrations{ + public boolean enabled; + public boolean approvalRequired; + public String message; + public String url; + } + + @Parcel + public static class Contact{ + public String email; + public Account account; + } +}