commit 42d5f52ff5bfc605ed7fe65a73d5af8b685bb587 Author: Grishka Date: Fri Jan 14 15:02:10 2022 +0300 Initial diff --git a/.gitignore b/.gitignore new file mode 100644 index 00000000..10cfdbfa --- /dev/null +++ b/.gitignore @@ -0,0 +1,10 @@ +*.iml +.gradle +/local.properties +/.idea +.DS_Store +/build +/captures +.externalNativeBuild +.cxx +local.properties diff --git a/build.gradle b/build.gradle new file mode 100644 index 00000000..f5b23f03 --- /dev/null +++ b/build.gradle @@ -0,0 +1,17 @@ +// Top-level build file where you can add configuration options common to all sub-projects/modules. +buildscript { + repositories { + google() + mavenCentral() + } + dependencies { + classpath "com.android.tools.build:gradle:7.0.4" + + // NOTE: Do not place your application dependencies here; they belong + // in the individual module build.gradle files + } +} + +task clean(type: Delete) { + delete rootProject.buildDir +} \ No newline at end of file diff --git a/gradle.properties b/gradle.properties new file mode 100644 index 00000000..52f5917c --- /dev/null +++ b/gradle.properties @@ -0,0 +1,19 @@ +# Project-wide Gradle settings. +# IDE (e.g. Android Studio) users: +# Gradle settings configured through the IDE *will override* +# any settings specified in this file. +# For more details on how to configure your build environment visit +# http://www.gradle.org/docs/current/userguide/build_environment.html +# Specifies the JVM arguments used for the daemon process. +# The setting is particularly useful for tweaking memory settings. +org.gradle.jvmargs=-Xmx2048m -Dfile.encoding=UTF-8 +# When configured, Gradle will run in incubating parallel mode. +# This option should only be used with decoupled projects. More details, visit +# http://www.gradle.org/docs/current/userguide/multi_project_builds.html#sec:decoupled_projects +# org.gradle.parallel=true +# AndroidX package structure to make it clearer which packages are bundled with the +# Android operating system, and which are packaged with your app"s APK +# https://developer.android.com/topic/libraries/support-library/androidx-rn +android.useAndroidX=true +# Automatically convert third-party libraries to use AndroidX +android.enableJetifier=true \ No newline at end of file diff --git a/gradle/wrapper/gradle-wrapper.jar b/gradle/wrapper/gradle-wrapper.jar new file mode 100644 index 00000000..e708b1c0 Binary files /dev/null and b/gradle/wrapper/gradle-wrapper.jar differ diff --git a/gradle/wrapper/gradle-wrapper.properties b/gradle/wrapper/gradle-wrapper.properties new file mode 100644 index 00000000..464f9805 --- /dev/null +++ b/gradle/wrapper/gradle-wrapper.properties @@ -0,0 +1,6 @@ +#Thu Jan 13 11:33:43 MSK 2022 +distributionBase=GRADLE_USER_HOME +distributionUrl=https\://services.gradle.org/distributions/gradle-7.3.3-bin.zip +distributionPath=wrapper/dists +zipStorePath=wrapper/dists +zipStoreBase=GRADLE_USER_HOME diff --git a/gradlew b/gradlew new file mode 100755 index 00000000..4f906e0c --- /dev/null +++ b/gradlew @@ -0,0 +1,185 @@ +#!/usr/bin/env sh + +# +# Copyright 2015 the original author or authors. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# https://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# + +############################################################################## +## +## Gradle start up script for UN*X +## +############################################################################## + +# Attempt to set APP_HOME +# Resolve links: $0 may be a link +PRG="$0" +# Need this for relative symlinks. +while [ -h "$PRG" ] ; do + ls=`ls -ld "$PRG"` + link=`expr "$ls" : '.*-> \(.*\)$'` + if expr "$link" : '/.*' > /dev/null; then + PRG="$link" + else + PRG=`dirname "$PRG"`"/$link" + fi +done +SAVED="`pwd`" +cd "`dirname \"$PRG\"`/" >/dev/null +APP_HOME="`pwd -P`" +cd "$SAVED" >/dev/null + +APP_NAME="Gradle" +APP_BASE_NAME=`basename "$0"` + +# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"' + +# Use the maximum available, or set MAX_FD != -1 to use that value. +MAX_FD="maximum" + +warn () { + echo "$*" +} + +die () { + echo + echo "$*" + echo + exit 1 +} + +# OS specific support (must be 'true' or 'false'). +cygwin=false +msys=false +darwin=false +nonstop=false +case "`uname`" in + CYGWIN* ) + cygwin=true + ;; + Darwin* ) + darwin=true + ;; + MINGW* ) + msys=true + ;; + NONSTOP* ) + nonstop=true + ;; +esac + +CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar + + +# Determine the Java command to use to start the JVM. +if [ -n "$JAVA_HOME" ] ; then + if [ -x "$JAVA_HOME/jre/sh/java" ] ; then + # IBM's JDK on AIX uses strange locations for the executables + JAVACMD="$JAVA_HOME/jre/sh/java" + else + JAVACMD="$JAVA_HOME/bin/java" + fi + if [ ! -x "$JAVACMD" ] ; then + die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." + fi +else + JAVACMD="java" + which java >/dev/null 2>&1 || die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." +fi + +# Increase the maximum file descriptors if we can. +if [ "$cygwin" = "false" -a "$darwin" = "false" -a "$nonstop" = "false" ] ; then + MAX_FD_LIMIT=`ulimit -H -n` + if [ $? -eq 0 ] ; then + if [ "$MAX_FD" = "maximum" -o "$MAX_FD" = "max" ] ; then + MAX_FD="$MAX_FD_LIMIT" + fi + ulimit -n $MAX_FD + if [ $? -ne 0 ] ; then + warn "Could not set maximum file descriptor limit: $MAX_FD" + fi + else + warn "Could not query maximum file descriptor limit: $MAX_FD_LIMIT" + fi +fi + +# For Darwin, add options to specify how the application appears in the dock +if $darwin; then + GRADLE_OPTS="$GRADLE_OPTS \"-Xdock:name=$APP_NAME\" \"-Xdock:icon=$APP_HOME/media/gradle.icns\"" +fi + +# For Cygwin or MSYS, switch paths to Windows format before running java +if [ "$cygwin" = "true" -o "$msys" = "true" ] ; then + APP_HOME=`cygpath --path --mixed "$APP_HOME"` + CLASSPATH=`cygpath --path --mixed "$CLASSPATH"` + + JAVACMD=`cygpath --unix "$JAVACMD"` + + # We build the pattern for arguments to be converted via cygpath + ROOTDIRSRAW=`find -L / -maxdepth 1 -mindepth 1 -type d 2>/dev/null` + SEP="" + for dir in $ROOTDIRSRAW ; do + ROOTDIRS="$ROOTDIRS$SEP$dir" + SEP="|" + done + OURCYGPATTERN="(^($ROOTDIRS))" + # Add a user-defined pattern to the cygpath arguments + if [ "$GRADLE_CYGPATTERN" != "" ] ; then + OURCYGPATTERN="$OURCYGPATTERN|($GRADLE_CYGPATTERN)" + fi + # Now convert the arguments - kludge to limit ourselves to /bin/sh + i=0 + for arg in "$@" ; do + CHECK=`echo "$arg"|egrep -c "$OURCYGPATTERN" -` + CHECK2=`echo "$arg"|egrep -c "^-"` ### Determine if an option + + if [ $CHECK -ne 0 ] && [ $CHECK2 -eq 0 ] ; then ### Added a condition + eval `echo args$i`=`cygpath --path --ignore --mixed "$arg"` + else + eval `echo args$i`="\"$arg\"" + fi + i=`expr $i + 1` + done + case $i in + 0) set -- ;; + 1) set -- "$args0" ;; + 2) set -- "$args0" "$args1" ;; + 3) set -- "$args0" "$args1" "$args2" ;; + 4) set -- "$args0" "$args1" "$args2" "$args3" ;; + 5) set -- "$args0" "$args1" "$args2" "$args3" "$args4" ;; + 6) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" ;; + 7) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" ;; + 8) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" "$args7" ;; + 9) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" "$args7" "$args8" ;; + esac +fi + +# Escape application args +save () { + for i do printf %s\\n "$i" | sed "s/'/'\\\\''/g;1s/^/'/;\$s/\$/' \\\\/" ; done + echo " " +} +APP_ARGS=`save "$@"` + +# Collect all arguments for the java command, following the shell quoting and substitution rules +eval set -- $DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS "\"-Dorg.gradle.appname=$APP_BASE_NAME\"" -classpath "\"$CLASSPATH\"" org.gradle.wrapper.GradleWrapperMain "$APP_ARGS" + +exec "$JAVACMD" "$@" diff --git a/gradlew.bat b/gradlew.bat new file mode 100644 index 00000000..ac1b06f9 --- /dev/null +++ b/gradlew.bat @@ -0,0 +1,89 @@ +@rem +@rem Copyright 2015 the original author or authors. +@rem +@rem Licensed under the Apache License, Version 2.0 (the "License"); +@rem you may not use this file except in compliance with the License. +@rem You may obtain a copy of the License at +@rem +@rem https://www.apache.org/licenses/LICENSE-2.0 +@rem +@rem Unless required by applicable law or agreed to in writing, software +@rem distributed under the License is distributed on an "AS IS" BASIS, +@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +@rem See the License for the specific language governing permissions and +@rem limitations under the License. +@rem + +@if "%DEBUG%" == "" @echo off +@rem ########################################################################## +@rem +@rem Gradle startup script for Windows +@rem +@rem ########################################################################## + +@rem Set local scope for the variables with windows NT shell +if "%OS%"=="Windows_NT" setlocal + +set DIRNAME=%~dp0 +if "%DIRNAME%" == "" set DIRNAME=. +set APP_BASE_NAME=%~n0 +set APP_HOME=%DIRNAME% + +@rem Resolve any "." and ".." in APP_HOME to make it shorter. +for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi + +@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m" + +@rem Find java.exe +if defined JAVA_HOME goto findJavaFromJavaHome + +set JAVA_EXE=java.exe +%JAVA_EXE% -version >NUL 2>&1 +if "%ERRORLEVEL%" == "0" goto execute + +echo. +echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. +echo. +echo Please set the JAVA_HOME variable in your environment to match the +echo location of your Java installation. + +goto fail + +:findJavaFromJavaHome +set JAVA_HOME=%JAVA_HOME:"=% +set JAVA_EXE=%JAVA_HOME%/bin/java.exe + +if exist "%JAVA_EXE%" goto execute + +echo. +echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% +echo. +echo Please set the JAVA_HOME variable in your environment to match the +echo location of your Java installation. + +goto fail + +:execute +@rem Setup the command line + +set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar + + +@rem Execute Gradle +"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %* + +:end +@rem End local scope for the variables with windows NT shell +if "%ERRORLEVEL%"=="0" goto mainEnd + +:fail +rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of +rem the _cmd.exe /c_ return code! +if not "" == "%GRADLE_EXIT_CONSOLE%" exit 1 +exit /b 1 + +:mainEnd +if "%OS%"=="Windows_NT" endlocal + +:omega diff --git a/mastodon/.gitignore b/mastodon/.gitignore new file mode 100644 index 00000000..42afabfd --- /dev/null +++ b/mastodon/.gitignore @@ -0,0 +1 @@ +/build \ No newline at end of file diff --git a/mastodon/build.gradle b/mastodon/build.gradle new file mode 100644 index 00000000..d41aa042 --- /dev/null +++ b/mastodon/build.gradle @@ -0,0 +1,38 @@ +plugins { + id 'com.android.application' +} + +android { + compileSdk 31 + buildToolsVersion "32.0.0" + defaultConfig { + applicationId "org.joinmastodon.android" + minSdk 23 + targetSdk 31 + versionCode 1 + versionName "1.0" + } + + buildTypes { + release { + minifyEnabled false + proguardFiles getDefaultProguardFile('proguard-android-optimize.txt'), 'proguard-rules.pro' + } + } + compileOptions { + sourceCompatibility JavaVersion.VERSION_15 + targetCompatibility JavaVersion.VERSION_15 + coreLibraryDesugaringEnabled true + } +} + +dependencies { + api 'androidx.annotation:annotation:1.3.0' + implementation 'com.squareup.okhttp3:okhttp:3.14.9' + implementation 'me.grishka.litex:recyclerview:1.2.1' + implementation 'me.grishka.litex:swiperefreshlayout:1.1.0' + implementation 'me.grishka.litex:browser:1.4.0' + implementation 'me.grishka.appkit:appkit:1.0' + implementation 'com.google.code.gson:gson:2.8.9' + coreLibraryDesugaring 'com.android.tools:desugar_jdk_libs:1.1.5' +} \ No newline at end of file diff --git a/mastodon/proguard-rules.pro b/mastodon/proguard-rules.pro new file mode 100644 index 00000000..481bb434 --- /dev/null +++ b/mastodon/proguard-rules.pro @@ -0,0 +1,21 @@ +# Add project specific ProGuard rules here. +# You can control the set of applied configuration files using the +# proguardFiles setting in build.gradle. +# +# For more details, see +# http://developer.android.com/guide/developing/tools/proguard.html + +# If your project uses WebView with JS, uncomment the following +# and specify the fully qualified class name to the JavaScript interface +# class: +#-keepclassmembers class fqcn.of.javascript.interface.for.webview { +# public *; +#} + +# Uncomment this to preserve the line number information for +# debugging stack traces. +#-keepattributes SourceFile,LineNumberTable + +# If you keep the line number information, uncomment this to +# hide the original source file name. +#-renamesourcefileattribute SourceFile \ No newline at end of file diff --git a/mastodon/src/main/AndroidManifest.xml b/mastodon/src/main/AndroidManifest.xml new file mode 100644 index 00000000..5a9324cd --- /dev/null +++ b/mastodon/src/main/AndroidManifest.xml @@ -0,0 +1,31 @@ + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/mastodon/src/main/java/org/joinmastodon/android/MainActivity.java b/mastodon/src/main/java/org/joinmastodon/android/MainActivity.java new file mode 100644 index 00000000..c1e19369 --- /dev/null +++ b/mastodon/src/main/java/org/joinmastodon/android/MainActivity.java @@ -0,0 +1,29 @@ +package org.joinmastodon.android; + +import android.os.Bundle; + +import org.joinmastodon.android.api.session.AccountSessionManager; +import org.joinmastodon.android.fragments.HomeFragment; +import org.joinmastodon.android.fragments.SplashFragment; + +import androidx.annotation.Nullable; +import me.grishka.appkit.FragmentStackActivity; + +public class MainActivity extends FragmentStackActivity{ + @Override + protected void onCreate(@Nullable Bundle savedInstanceState){ + super.onCreate(savedInstanceState); + + if(savedInstanceState==null){ + if(AccountSessionManager.getInstance().getLoggedInAccounts().isEmpty()){ + showFragmentClearingBackStack(new SplashFragment()); + }else{ + Bundle args=new Bundle(); + args.putString("account", AccountSessionManager.getInstance().getLastActiveAccountID()); + HomeFragment fragment=new HomeFragment(); + fragment.setArguments(args); + showFragmentClearingBackStack(fragment); + } + } + } +} diff --git a/mastodon/src/main/java/org/joinmastodon/android/MastodonApp.java b/mastodon/src/main/java/org/joinmastodon/android/MastodonApp.java new file mode 100644 index 00000000..af439943 --- /dev/null +++ b/mastodon/src/main/java/org/joinmastodon/android/MastodonApp.java @@ -0,0 +1,17 @@ +package org.joinmastodon.android; + +import android.annotation.SuppressLint; +import android.app.Application; +import android.content.Context; + +public class MastodonApp extends Application{ + + @SuppressLint("StaticFieldLeak") // it's not a leak + public static Context context; + + @Override + public void onCreate(){ + super.onCreate(); + context=getApplicationContext(); + } +} diff --git a/mastodon/src/main/java/org/joinmastodon/android/OAuthActivity.java b/mastodon/src/main/java/org/joinmastodon/android/OAuthActivity.java new file mode 100644 index 00000000..f22cf76e --- /dev/null +++ b/mastodon/src/main/java/org/joinmastodon/android/OAuthActivity.java @@ -0,0 +1,103 @@ +package org.joinmastodon.android; + +import android.app.Activity; +import android.app.ProgressDialog; +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; +import org.joinmastodon.android.api.requests.oauth.GetOauthToken; +import org.joinmastodon.android.api.session.AccountSessionManager; +import org.joinmastodon.android.model.Account; +import org.joinmastodon.android.model.Application; +import org.joinmastodon.android.model.Instance; +import org.joinmastodon.android.model.Token; + +import androidx.annotation.Nullable; +import me.grishka.appkit.api.Callback; +import me.grishka.appkit.api.ErrorResponse; + +public class OAuthActivity extends Activity{ + @Override + protected void onCreate(@Nullable Bundle savedInstanceState){ + super.onCreate(savedInstanceState); + Uri uri=getIntent().getData(); + if(uri==null || isTaskRoot()){ + finish(); + return; + } + if(uri.getQueryParameter("error")!=null){ + String error=uri.getQueryParameter("error_description"); + if(TextUtils.isEmpty(error)) + error=uri.getQueryParameter("error"); + Toast.makeText(this, error, Toast.LENGTH_LONG).show(); + finish(); + restartMainActivity(); + return; + } + String code=uri.getQueryParameter("code"); + if(TextUtils.isEmpty(code)){ + finish(); + return; + } + Instance instance=AccountSessionManager.getInstance().getAuthenticatingInstance(); + Application app=AccountSessionManager.getInstance().getAuthenticatingApp(); + if(instance==null || app==null){ + finish(); + return; + } + ProgressDialog progress=new ProgressDialog(this); + progress.setMessage(getString(R.string.finishing_auth)); + progress.setCancelable(false); + progress.show(); + new GetOauthToken(app.clientId, app.clientSecret, code) + .setCallback(new Callback<>(){ + @Override + public void onSuccess(Token token){ + new GetOwnAccount() + .setCallback(new Callback<>(){ + @Override + public void onSuccess(Account account){ + AccountSessionManager.getInstance().addAccount(instance, token, account, app); + progress.dismiss(); + finish(); + // not calling restartMainActivity() here on purpose to have it recreated (notice different flags) + Intent intent=new Intent(OAuthActivity.this, MainActivity.class); + intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK | Intent.FLAG_ACTIVITY_CLEAR_TASK); + startActivity(intent); + } + + @Override + public void onError(ErrorResponse error){ + handleError(error); + progress.dismiss(); + } + }) + .exec(instance.uri, token); + } + + @Override + public void onError(ErrorResponse error){ + handleError(error); + progress.dismiss(); + } + }) + .execNoAuth(instance.uri); + } + + private void handleError(ErrorResponse error){ + error.showToast(OAuthActivity.this); + finish(); + restartMainActivity(); + } + + private void restartMainActivity(){ + Intent intent=new Intent(this, MainActivity.class); + intent.addFlags(Intent.FLAG_ACTIVITY_CLEAR_TOP | Intent.FLAG_ACTIVITY_SINGLE_TOP); + startActivity(intent); + } +} diff --git a/mastodon/src/main/java/org/joinmastodon/android/api/AllFieldsAreRequired.java b/mastodon/src/main/java/org/joinmastodon/android/api/AllFieldsAreRequired.java new file mode 100644 index 00000000..86291867 --- /dev/null +++ b/mastodon/src/main/java/org/joinmastodon/android/api/AllFieldsAreRequired.java @@ -0,0 +1,11 @@ +package org.joinmastodon.android.api; + +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +@Retention(RetentionPolicy.RUNTIME) +@Target(ElementType.TYPE) +public @interface AllFieldsAreRequired{ +} diff --git a/mastodon/src/main/java/org/joinmastodon/android/api/JsonObjectRequestBody.java b/mastodon/src/main/java/org/joinmastodon/android/api/JsonObjectRequestBody.java new file mode 100644 index 00000000..66f0c78d --- /dev/null +++ b/mastodon/src/main/java/org/joinmastodon/android/api/JsonObjectRequestBody.java @@ -0,0 +1,35 @@ +package org.joinmastodon.android.api; + +import com.google.gson.JsonIOException; + +import java.io.IOException; +import java.io.OutputStreamWriter; +import java.nio.charset.StandardCharsets; + +import okhttp3.MediaType; +import okhttp3.RequestBody; +import okio.BufferedSink; + +public class JsonObjectRequestBody extends RequestBody{ + private final Object obj; + + public JsonObjectRequestBody(Object obj){ + this.obj=obj; + } + + @Override + public MediaType contentType(){ + return MediaType.get("application/json;charset=utf-8"); + } + + @Override + public void writeTo(BufferedSink sink) throws IOException{ + try{ + OutputStreamWriter writer=new OutputStreamWriter(sink.outputStream(), StandardCharsets.UTF_8); + MastodonAPIController.gson.toJson(obj, writer); + writer.flush(); + }catch(JsonIOException x){ + throw new IOException(x); + } + } +} diff --git a/mastodon/src/main/java/org/joinmastodon/android/api/MastodonAPIController.java b/mastodon/src/main/java/org/joinmastodon/android/api/MastodonAPIController.java new file mode 100644 index 00000000..02fd3d48 --- /dev/null +++ b/mastodon/src/main/java/org/joinmastodon/android/api/MastodonAPIController.java @@ -0,0 +1,157 @@ +package org.joinmastodon.android.api; + +import android.content.pm.ApplicationInfo; +import android.content.pm.PackageInfo; +import android.content.pm.PackageManager; +import android.util.Log; + +import com.google.gson.FieldNamingPolicy; +import com.google.gson.Gson; +import com.google.gson.GsonBuilder; +import com.google.gson.JsonIOException; +import com.google.gson.JsonObject; +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 org.joinmastodon.android.model.BaseModel; + +import java.io.IOException; +import java.io.Reader; +import java.time.Instant; +import java.time.LocalDate; +import java.util.List; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import me.grishka.appkit.utils.WorkerThread; +import okhttp3.Call; +import okhttp3.Callback; +import okhttp3.OkHttpClient; +import okhttp3.Request; +import okhttp3.Response; +import okhttp3.ResponseBody; + +public class MastodonAPIController{ + private static final String TAG="MastodonAPIController"; + public static final Gson gson=new GsonBuilder() + .disableHtmlEscaping() + .setFieldNamingPolicy(FieldNamingPolicy.LOWER_CASE_WITH_UNDERSCORES) + .registerTypeAdapter(Instant.class, new IsoInstantTypeAdapter()) + .registerTypeAdapter(LocalDate.class, new IsoLocalDateTypeAdapter()) + .create(); + private static WorkerThread thread=new WorkerThread("MastodonAPIController"); + private static OkHttpClient httpClient=new OkHttpClient.Builder().build(); + + private AccountSession session; + + static{ + thread.start(); + } + + public MastodonAPIController(@Nullable AccountSession session){ + this.session=session; + } + + public void submitRequest(final MastodonAPIRequest req){ + thread.postRunnable(()->{ + try{ + Request.Builder builder=new Request.Builder() + .url(req.getURL().toString()) + .method(req.getMethod(), req.getRequestBody()) + .header("User-Agent", "MastodonAndroid/"+BuildConfig.VERSION_NAME); + + String token=null; + if(session!=null) + token=session.token.accessToken; + else if(req.token!=null) + token=req.token.accessToken; + + if(token!=null) + builder.header("Authorization", "Bearer "+token); + + Request hreq=builder.build(); + Call call=httpClient.newCall(hreq); + synchronized(req){ + req.okhttpCall=call; + } + + if(BuildConfig.DEBUG) + Log.d(TAG, "["+(session==null ? "no-auth" : session.getID())+"] Sending request: "+hreq); + + call.enqueue(new Callback(){ + @Override + public void onFailure(@NonNull Call call, @NonNull IOException e){ + if(call.isCanceled()) + return; + if(BuildConfig.DEBUG) + Log.w(TAG, "["+(session==null ? "no-auth" : session.getID())+"] "+hreq+" failed: "+e); + synchronized(req){ + req.okhttpCall=null; + } + req.onError(e.getLocalizedMessage()); + } + + @Override + public void onResponse(@NonNull Call call, @NonNull Response response) throws IOException{ + if(call.isCanceled()) + return; + if(BuildConfig.DEBUG) + Log.d(TAG, "["+(session==null ? "no-auth" : session.getID())+"] "+hreq+" received response: "+response); + synchronized(req){ + req.okhttpCall=null; + } + try(ResponseBody body=response.body()){ + Reader reader=body.charStream(); + if(response.isSuccessful()){ + T respObj; + try{ + if(req.respTypeToken!=null) + respObj=gson.fromJson(reader, req.respTypeToken.getType()); + else + respObj=gson.fromJson(reader, req.respClass); + }catch(JsonIOException|JsonSyntaxException x){ + if(BuildConfig.DEBUG) + Log.w(TAG, "["+(session==null ? "no-auth" : session.getID())+"] "+response+" error parsing or reading body", x); + req.onError(x.getLocalizedMessage()); + return; + } + + try{ + req.validateAndPostprocessResponse(respObj); + }catch(IOException x){ + if(BuildConfig.DEBUG) + Log.w(TAG, "["+(session==null ? "no-auth" : session.getID())+"] "+response+" error post-processing or validating response", x); + req.onError(x.getLocalizedMessage()); + return; + } + + if(BuildConfig.DEBUG) + Log.d(TAG, "["+(session==null ? "no-auth" : session.getID())+"] "+response+" parsed successfully: "+respObj); + + req.onSuccess(respObj); + }else{ + try{ + JsonObject error=JsonParser.parseReader(reader).getAsJsonObject(); + req.onError(error.get("error").getAsString()); + }catch(JsonIOException|JsonSyntaxException x){ + req.onError(response.code()+" "+response.message()); + }catch(IllegalStateException x){ + req.onError("Error parsing an API error"); + } + } + } + } + }); + }catch(Exception x){ + if(BuildConfig.DEBUG) + Log.w(TAG, "["+(session==null ? "no-auth" : session.getID())+"] error creating and sending http request", x); + req.onError(x.getLocalizedMessage()); + } + }, 0); + } +} diff --git a/mastodon/src/main/java/org/joinmastodon/android/api/MastodonAPIRequest.java b/mastodon/src/main/java/org/joinmastodon/android/api/MastodonAPIRequest.java new file mode 100644 index 00000000..ba1bbd69 --- /dev/null +++ b/mastodon/src/main/java/org/joinmastodon/android/api/MastodonAPIRequest.java @@ -0,0 +1,147 @@ +package org.joinmastodon.android.api; + +import android.net.Uri; + +import com.google.gson.reflect.TypeToken; + +import org.joinmastodon.android.api.session.AccountSession; +import org.joinmastodon.android.api.session.AccountSessionManager; +import org.joinmastodon.android.model.BaseModel; +import org.joinmastodon.android.model.Token; + +import java.io.IOException; +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +import androidx.annotation.CallSuper; +import me.grishka.appkit.api.APIRequest; +import me.grishka.appkit.api.Callback; +import okhttp3.Call; +import okhttp3.RequestBody; + +public abstract class MastodonAPIRequest extends APIRequest{ + + private String domain; + private AccountSession account; + private String path; + private String method; + private Object requestBody; + private Map queryParams; + Class respClass; + TypeToken respTypeToken; + Call okhttpCall; + Token token; + + public MastodonAPIRequest(HttpMethod method, String path, Class respClass){ + this.path=path; + this.method=method.toString(); + this.respClass=respClass; + } + + public MastodonAPIRequest(HttpMethod method, String path, TypeToken respTypeToken){ + this.path=path; + this.method=method.toString(); + this.respTypeToken=respTypeToken; + } + + @Override + public synchronized void cancel(){ + if(okhttpCall!=null){ + okhttpCall.cancel(); + } + } + + @Override + public APIRequest exec(){ + throw new UnsupportedOperationException("Use exec(accountID) or execNoAuth(domain)"); + } + + public MastodonAPIRequest exec(String accountID){ + account=AccountSessionManager.getInstance().getAccount(accountID); + domain=account.domain; + account.getApiController().submitRequest(this); + return this; + } + + public MastodonAPIRequest execNoAuth(String domain){ + this.domain=domain; + AccountSessionManager.getInstance().getUnauthenticatedApiController().submitRequest(this); + return this; + } + + public MastodonAPIRequest exec(String domain, Token token){ + this.domain=domain; + this.token=token; + AccountSessionManager.getInstance().getUnauthenticatedApiController().submitRequest(this); + return this; + } + + protected void setRequestBody(Object body){ + requestBody=body; + } + + protected void addQueryParameter(String key, String value){ + if(queryParams==null) + queryParams=new HashMap<>(); + queryParams.put(key, value); + } + + protected String getPathPrefix(){ + return "/api/v1"; + } + + public Uri getURL(){ + Uri.Builder builder=new Uri.Builder() + .scheme("https") + .authority(domain) + .path(getPathPrefix()+path); + if(queryParams!=null){ + for(Map.Entry param:queryParams.entrySet()){ + builder.appendQueryParameter(param.getKey(), param.getValue()); + } + } + return builder.build(); + } + + public String getMethod(){ + return method; + } + + public RequestBody getRequestBody(){ + return requestBody==null ? null : new JsonObjectRequestBody(requestBody); + } + + @Override + public MastodonAPIRequest setCallback(Callback callback){ + super.setCallback(callback); + return this; + } + + @CallSuper + public void validateAndPostprocessResponse(T respObj) throws IOException{ + if(respObj instanceof BaseModel){ + ((BaseModel) respObj).postprocess(); + }else if(respObj instanceof List){ + for(Object item : ((List) respObj)){ + if(item instanceof BaseModel) + ((BaseModel) item).postprocess(); + } + } + } + + void onError(String msg){ + invokeErrorCallback(new MastodonErrorResponse(msg)); + } + + void onSuccess(T resp){ + invokeSuccessCallback(resp); + } + + public enum HttpMethod{ + GET, + POST, + PUT, + DELETE + } +} diff --git a/mastodon/src/main/java/org/joinmastodon/android/api/MastodonErrorResponse.java b/mastodon/src/main/java/org/joinmastodon/android/api/MastodonErrorResponse.java new file mode 100644 index 00000000..efb8a848 --- /dev/null +++ b/mastodon/src/main/java/org/joinmastodon/android/api/MastodonErrorResponse.java @@ -0,0 +1,29 @@ +package org.joinmastodon.android.api; + +import android.content.Context; +import android.view.View; +import android.widget.TextView; +import android.widget.Toast; + +import org.joinmastodon.android.R; + +import me.grishka.appkit.api.ErrorResponse; + +public class MastodonErrorResponse extends ErrorResponse{ + public final String error; + + public MastodonErrorResponse(String error){ + this.error=error; + } + + @Override + public void bindErrorView(View view){ + TextView text=view.findViewById(R.id.error_text); + text.setText(error); + } + + @Override + public void showToast(Context context){ + Toast.makeText(context, error, Toast.LENGTH_SHORT).show(); + } +} diff --git a/mastodon/src/main/java/org/joinmastodon/android/api/ObjectValidationException.java b/mastodon/src/main/java/org/joinmastodon/android/api/ObjectValidationException.java new file mode 100644 index 00000000..e947042f --- /dev/null +++ b/mastodon/src/main/java/org/joinmastodon/android/api/ObjectValidationException.java @@ -0,0 +1,20 @@ +package org.joinmastodon.android.api; + +import java.io.IOException; + +public class ObjectValidationException extends IOException{ + public ObjectValidationException(){ + } + + public ObjectValidationException(String message){ + super(message); + } + + public ObjectValidationException(String message, Throwable cause){ + super(message, cause); + } + + public ObjectValidationException(Throwable cause){ + super(cause); + } +} diff --git a/mastodon/src/main/java/org/joinmastodon/android/api/RequiredField.java b/mastodon/src/main/java/org/joinmastodon/android/api/RequiredField.java new file mode 100644 index 00000000..da657b61 --- /dev/null +++ b/mastodon/src/main/java/org/joinmastodon/android/api/RequiredField.java @@ -0,0 +1,11 @@ +package org.joinmastodon.android.api; + +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +@Retention(RetentionPolicy.RUNTIME) +@Target(ElementType.FIELD) +public @interface RequiredField{ +} diff --git a/mastodon/src/main/java/org/joinmastodon/android/api/gson/IsoInstantTypeAdapter.java b/mastodon/src/main/java/org/joinmastodon/android/api/gson/IsoInstantTypeAdapter.java new file mode 100644 index 00000000..bfd7743a --- /dev/null +++ b/mastodon/src/main/java/org/joinmastodon/android/api/gson/IsoInstantTypeAdapter.java @@ -0,0 +1,34 @@ +package org.joinmastodon.android.api.gson; + +import com.google.gson.TypeAdapter; +import com.google.gson.stream.JsonReader; +import com.google.gson.stream.JsonToken; +import com.google.gson.stream.JsonWriter; + +import java.io.IOException; +import java.time.Instant; +import java.time.format.DateTimeFormatter; +import java.time.format.DateTimeParseException; + +public class IsoInstantTypeAdapter extends TypeAdapter{ + @Override + public void write(JsonWriter out, Instant value) throws IOException{ + if(value==null) + out.nullValue(); + else + out.value(DateTimeFormatter.ISO_INSTANT.format(value)); + } + + @Override + public Instant read(JsonReader in) throws IOException{ + if(in.peek()==JsonToken.NULL){ + in.nextNull(); + return null; + } + try{ + return DateTimeFormatter.ISO_INSTANT.parse(in.nextString(), Instant::from); + }catch(DateTimeParseException x){ + return null; + } + } +} diff --git a/mastodon/src/main/java/org/joinmastodon/android/api/gson/IsoLocalDateTypeAdapter.java b/mastodon/src/main/java/org/joinmastodon/android/api/gson/IsoLocalDateTypeAdapter.java new file mode 100644 index 00000000..3972c4a8 --- /dev/null +++ b/mastodon/src/main/java/org/joinmastodon/android/api/gson/IsoLocalDateTypeAdapter.java @@ -0,0 +1,33 @@ +package org.joinmastodon.android.api.gson; + +import com.google.gson.TypeAdapter; +import com.google.gson.stream.JsonReader; +import com.google.gson.stream.JsonToken; +import com.google.gson.stream.JsonWriter; + +import java.io.IOException; +import java.time.LocalDate; +import java.time.format.DateTimeParseException; + +public class IsoLocalDateTypeAdapter extends TypeAdapter{ + @Override + public void write(JsonWriter out, LocalDate value) throws IOException{ + if(value==null) + out.nullValue(); + else + out.value(value.toString()); + } + + @Override + public LocalDate read(JsonReader in) throws IOException{ + if(in.peek()==JsonToken.NULL){ + in.nextNull(); + return null; + } + try{ + return LocalDate.parse(in.nextString()); + }catch(DateTimeParseException x){ + return null; + } + } +} diff --git a/mastodon/src/main/java/org/joinmastodon/android/api/requests/GetInstance.java b/mastodon/src/main/java/org/joinmastodon/android/api/requests/GetInstance.java new file mode 100644 index 00000000..4d5d24f0 --- /dev/null +++ b/mastodon/src/main/java/org/joinmastodon/android/api/requests/GetInstance.java @@ -0,0 +1,10 @@ +package org.joinmastodon.android.api.requests; + +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/accounts/GetOwnAccount.java b/mastodon/src/main/java/org/joinmastodon/android/api/requests/accounts/GetOwnAccount.java new file mode 100644 index 00000000..932ef9ba --- /dev/null +++ b/mastodon/src/main/java/org/joinmastodon/android/api/requests/accounts/GetOwnAccount.java @@ -0,0 +1,10 @@ +package org.joinmastodon.android.api.requests.accounts; + +import org.joinmastodon.android.api.MastodonAPIRequest; +import org.joinmastodon.android.model.Account; + +public class GetOwnAccount extends MastodonAPIRequest{ + public GetOwnAccount(){ + super(HttpMethod.GET, "/accounts/verify_credentials", Account.class); + } +} diff --git a/mastodon/src/main/java/org/joinmastodon/android/api/requests/catalog/GetCatalogCategories.java b/mastodon/src/main/java/org/joinmastodon/android/api/requests/catalog/GetCatalogCategories.java new file mode 100644 index 00000000..28485a89 --- /dev/null +++ b/mastodon/src/main/java/org/joinmastodon/android/api/requests/catalog/GetCatalogCategories.java @@ -0,0 +1,32 @@ +package org.joinmastodon.android.api.requests.catalog; + +import android.net.Uri; +import android.text.TextUtils; + +import com.google.gson.reflect.TypeToken; + +import org.joinmastodon.android.api.MastodonAPIRequest; +import org.joinmastodon.android.model.catalog.CatalogCategory; +import org.joinmastodon.android.model.catalog.CatalogInstance; + +import java.util.List; + +public class GetCatalogCategories extends MastodonAPIRequest>{ + private String lang; + + public GetCatalogCategories(String lang){ + super(HttpMethod.GET, null, new TypeToken<>(){}); + this.lang=lang; + } + + @Override + public Uri getURL(){ + Uri.Builder builder=new Uri.Builder() + .scheme("https") + .authority("api.joinmastodon.org") + .path("/categories"); + if(!TextUtils.isEmpty(lang)) + builder.appendQueryParameter("language", lang); + return builder.build(); + } +} diff --git a/mastodon/src/main/java/org/joinmastodon/android/api/requests/catalog/GetCatalogInstances.java b/mastodon/src/main/java/org/joinmastodon/android/api/requests/catalog/GetCatalogInstances.java new file mode 100644 index 00000000..54a55df5 --- /dev/null +++ b/mastodon/src/main/java/org/joinmastodon/android/api/requests/catalog/GetCatalogInstances.java @@ -0,0 +1,35 @@ +package org.joinmastodon.android.api.requests.catalog; + +import android.net.Uri; +import android.text.TextUtils; + +import com.google.gson.reflect.TypeToken; + +import org.joinmastodon.android.api.MastodonAPIRequest; +import org.joinmastodon.android.model.catalog.CatalogInstance; + +import java.util.List; + +public class GetCatalogInstances extends MastodonAPIRequest>{ + + private String lang, category; + + public GetCatalogInstances(String lang, String category){ + super(HttpMethod.GET, null, new TypeToken<>(){}); + this.lang=lang; + this.category=category; + } + + @Override + public Uri getURL(){ + Uri.Builder builder=new Uri.Builder() + .scheme("https") + .authority("api.joinmastodon.org") + .path("/servers"); + if(!TextUtils.isEmpty(lang)) + builder.appendQueryParameter("language", lang); + if(!TextUtils.isEmpty(category)) + builder.appendQueryParameter("category", category); + return builder.build(); + } +} diff --git a/mastodon/src/main/java/org/joinmastodon/android/api/requests/oauth/CreateOAuthApp.java b/mastodon/src/main/java/org/joinmastodon/android/api/requests/oauth/CreateOAuthApp.java new file mode 100644 index 00000000..65bc5e44 --- /dev/null +++ b/mastodon/src/main/java/org/joinmastodon/android/api/requests/oauth/CreateOAuthApp.java @@ -0,0 +1,21 @@ +package org.joinmastodon.android.api.requests.oauth; + +import android.net.Uri; + +import org.joinmastodon.android.api.MastodonAPIRequest; +import org.joinmastodon.android.api.session.AccountSessionManager; +import org.joinmastodon.android.model.Application; + +public class CreateOAuthApp extends MastodonAPIRequest{ + public CreateOAuthApp(){ + super(HttpMethod.POST, "/apps", Application.class); + setRequestBody(new Request()); + } + + private static class Request{ + public String clientName="Mastodon for Android"; + public String redirectUris=AccountSessionManager.REDIRECT_URI; + public String scopes=AccountSessionManager.SCOPE; + public String website="https://joinmastodon.org"; + } +} diff --git a/mastodon/src/main/java/org/joinmastodon/android/api/requests/oauth/GetOauthToken.java b/mastodon/src/main/java/org/joinmastodon/android/api/requests/oauth/GetOauthToken.java new file mode 100644 index 00000000..26b429eb --- /dev/null +++ b/mastodon/src/main/java/org/joinmastodon/android/api/requests/oauth/GetOauthToken.java @@ -0,0 +1,32 @@ +package org.joinmastodon.android.api.requests.oauth; + +import org.joinmastodon.android.api.MastodonAPIRequest; +import org.joinmastodon.android.api.session.AccountSessionManager; +import org.joinmastodon.android.model.Token; + +public class GetOauthToken extends MastodonAPIRequest{ + public GetOauthToken(String clientID, String clientSecret, String code){ + super(HttpMethod.POST, "/oauth/token", Token.class); + setRequestBody(new Request(clientID, clientSecret, code)); + } + + @Override + protected String getPathPrefix(){ + return ""; + } + + private static class Request{ + public String grantType="authorization_code"; + public String clientId; + public String clientSecret; + public String redirectUri=AccountSessionManager.REDIRECT_URI; + public String scope=AccountSessionManager.SCOPE; + public String code; + + public Request(String clientId, String clientSecret, String code){ + this.clientId=clientId; + this.clientSecret=clientSecret; + this.code=code; + } + } +} 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 new file mode 100644 index 00000000..f007235d --- /dev/null +++ b/mastodon/src/main/java/org/joinmastodon/android/api/session/AccountSession.java @@ -0,0 +1,35 @@ +package org.joinmastodon.android.api.session; + +import org.joinmastodon.android.api.MastodonAPIController; +import org.joinmastodon.android.model.Account; +import org.joinmastodon.android.model.Application; +import org.joinmastodon.android.model.Token; + +public class AccountSession{ + public Token token; + public Account self; + public String domain; + public int tootCharLimit; + public Application app; + private transient MastodonAPIController apiController; + + AccountSession(Token token, Account self, Application app, String domain, int tootCharLimit){ + this.token=token; + this.self=self; + this.domain=domain; + this.app=app; + this.tootCharLimit=tootCharLimit; + } + + AccountSession(){} + + public String getID(){ + return domain+"_"+self.id; + } + + public MastodonAPIController getApiController(){ + if(apiController==null) + apiController=new MastodonAPIController(this); + return apiController; + } +} 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 new file mode 100644 index 00000000..2bee9447 --- /dev/null +++ b/mastodon/src/main/java/org/joinmastodon/android/api/session/AccountSessionManager.java @@ -0,0 +1,186 @@ +package org.joinmastodon.android.api.session; + +import android.app.ProgressDialog; +import android.content.Context; +import android.content.Intent; +import android.content.SharedPreferences; +import android.net.Uri; +import android.util.Log; + +import com.google.gson.Gson; +import com.google.gson.GsonBuilder; + +import org.joinmastodon.android.MastodonApp; +import org.joinmastodon.android.OAuthActivity; +import org.joinmastodon.android.R; +import org.joinmastodon.android.api.MastodonAPIController; +import org.joinmastodon.android.api.requests.oauth.CreateOAuthApp; +import org.joinmastodon.android.model.Account; +import org.joinmastodon.android.model.Application; +import org.joinmastodon.android.model.Instance; +import org.joinmastodon.android.model.Token; + +import java.io.File; +import java.io.FileInputStream; +import java.io.FileOutputStream; +import java.io.IOException; +import java.io.InputStreamReader; +import java.io.OutputStreamWriter; +import java.nio.charset.StandardCharsets; +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.stream.Collectors; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import androidx.browser.customtabs.CustomTabsIntent; +import me.grishka.appkit.api.Callback; +import me.grishka.appkit.api.ErrorResponse; + +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 AccountSessionManager instance=new AccountSessionManager(); + + private HashMap sessions=new HashMap<>(); + private MastodonAPIController unauthenticatedApiController=new MastodonAPIController(null); + private Instance authenticatingInstance; + private Application authenticatingApp; + private String lastActiveAccountID; + private SharedPreferences prefs; + + public static AccountSessionManager getInstance(){ + return instance; + } + + private AccountSessionManager(){ + prefs=MastodonApp.context.getSharedPreferences("account_manager", Context.MODE_PRIVATE); + File file=new File(MastodonApp.context.getFilesDir(), "accounts.json"); + if(!file.exists()) + return; + try(FileInputStream in=new FileInputStream(file)){ + SessionsStorageWrapper w=MastodonAPIController.gson.fromJson(new InputStreamReader(in, StandardCharsets.UTF_8), SessionsStorageWrapper.class); + for(AccountSession session:w.accounts){ + sessions.put(session.getID(), session); + } + }catch(IOException x){ + Log.e(TAG, "Error loading accounts", x); + } + lastActiveAccountID=prefs.getString("lastActiveAccount", null); + } + + public void addAccount(Instance instance, Token token, Account self, Application app){ + AccountSession session=new AccountSession(token, self, app, instance.uri, instance.maxTootChars); + sessions.put(session.getID(), session); + lastActiveAccountID=session.getID(); + writeAccountsFile(); + } + + private void writeAccountsFile(){ + File file=new File(MastodonApp.context.getFilesDir(), "accounts.json"); + try{ + try(FileOutputStream out=new FileOutputStream(file)){ + SessionsStorageWrapper w=new SessionsStorageWrapper(); + w.accounts=new ArrayList<>(sessions.values()); + OutputStreamWriter writer=new OutputStreamWriter(out, StandardCharsets.UTF_8); + MastodonAPIController.gson.toJson(w, writer); + writer.flush(); + } + }catch(IOException x){ + Log.e(TAG, "Error writing accounts file", x); + } + prefs.edit().putString("lastActiveAccount", lastActiveAccountID).apply(); + } + + @NonNull + public List getLoggedInAccounts(){ + return new ArrayList<>(sessions.values()); + } + + @NonNull + public AccountSession getAccount(String id){ + AccountSession session=sessions.get(id); + if(session==null) + throw new IllegalStateException("Account session "+id+" not found"); + return session; + } + + @Nullable + public AccountSession getLastActiveAccount(){ + if(sessions.isEmpty() || lastActiveAccountID==null) + return null; + return getAccount(lastActiveAccountID); + } + + public String getLastActiveAccountID(){ + return lastActiveAccountID; + } + + public void removeAccount(String id){ + AccountSession session=getAccount(id); + sessions.remove(id); + if(lastActiveAccountID.equals(id)){ + if(sessions.isEmpty()) + lastActiveAccountID=null; + else + lastActiveAccountID=getLoggedInAccounts().get(0).getID(); + } + writeAccountsFile(); + } + + @NonNull + public MastodonAPIController getUnauthenticatedApiController(){ + return unauthenticatedApiController; + } + + public void authenticate(Context context, Instance instance){ + authenticatingInstance=instance; + ProgressDialog progress=new ProgressDialog(context); + progress.setMessage(context.getString(R.string.preparing_auth)); + progress.setCancelable(false); + progress.show(); + new CreateOAuthApp() + .setCallback(new Callback(){ + @Override + public void onSuccess(Application result){ + authenticatingApp=result; + progress.dismiss(); + Uri uri=new Uri.Builder() + .scheme("https") + .authority(instance.uri) + .path("/oauth/authorize") + .appendQueryParameter("response_type", "code") + .appendQueryParameter("client_id", result.clientId) + .appendQueryParameter("redirect_uri", "mastodon-android-auth://callback") + .appendQueryParameter("scope", "read write follow push") + .build(); + + new CustomTabsIntent.Builder() + .build() + .launchUrl(context, uri); + } + + @Override + public void onError(ErrorResponse error){ + error.showToast(context); + progress.dismiss(); + } + }) + .execNoAuth(instance.uri); + } + + public Instance getAuthenticatingInstance(){ + return authenticatingInstance; + } + + public Application getAuthenticatingApp(){ + return authenticatingApp; + } + + private static class SessionsStorageWrapper{ + public List accounts; + } +} diff --git a/mastodon/src/main/java/org/joinmastodon/android/fragments/HomeFragment.java b/mastodon/src/main/java/org/joinmastodon/android/fragments/HomeFragment.java new file mode 100644 index 00000000..60c881ab --- /dev/null +++ b/mastodon/src/main/java/org/joinmastodon/android/fragments/HomeFragment.java @@ -0,0 +1,18 @@ +package org.joinmastodon.android.fragments; + +import android.os.Bundle; +import android.view.LayoutInflater; +import android.view.View; +import android.view.ViewGroup; + +import androidx.annotation.Nullable; +import me.grishka.appkit.fragments.AppKitFragment; +import me.grishka.appkit.fragments.ToolbarFragment; + +public class HomeFragment extends ToolbarFragment{ + @Nullable + @Override + public View onCreateContentView(LayoutInflater inflater, @Nullable ViewGroup container, Bundle savedInstanceState){ + return new View(getActivity()); + } +} diff --git a/mastodon/src/main/java/org/joinmastodon/android/fragments/SplashFragment.java b/mastodon/src/main/java/org/joinmastodon/android/fragments/SplashFragment.java new file mode 100644 index 00000000..fe950df4 --- /dev/null +++ b/mastodon/src/main/java/org/joinmastodon/android/fragments/SplashFragment.java @@ -0,0 +1,42 @@ +package org.joinmastodon.android.fragments; + +import android.app.Fragment; +import android.os.Bundle; +import android.view.LayoutInflater; +import android.view.View; +import android.view.ViewGroup; +import android.view.WindowInsets; + +import org.joinmastodon.android.R; +import org.joinmastodon.android.fragments.onboarding.InstanceCatalogFragment; + +import androidx.annotation.Nullable; +import me.grishka.appkit.Nav; +import me.grishka.appkit.fragments.AppKitFragment; +import me.grishka.appkit.views.FragmentRootLinearLayout; + +public class SplashFragment extends AppKitFragment{ + + private View contentView; + + @Nullable + @Override + public View onCreateView(LayoutInflater inflater, @Nullable ViewGroup container, Bundle savedInstanceState){ + contentView= inflater.inflate(R.layout.fragment_splash, container, false); + contentView.findViewById(R.id.btn_get_started).setOnClickListener(this::onButtonClick); + contentView.findViewById(R.id.btn_log_in).setOnClickListener(this::onButtonClick); + return contentView; + } + + private void onButtonClick(View v){ + Bundle extras=new Bundle(); + extras.putBoolean("signup", v.getId()==R.id.btn_get_started); + Nav.go(getActivity(), InstanceCatalogFragment.class, extras); + } +// +// @Override +// public void onApplyWindowInsets(WindowInsets insets){ +// if(contentView!=null) +// contentView.dispatchApplyWindowInsets(insets); +// } +} 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 new file mode 100644 index 00000000..5fca0777 --- /dev/null +++ b/mastodon/src/main/java/org/joinmastodon/android/fragments/onboarding/InstanceCatalogFragment.java @@ -0,0 +1,492 @@ +package org.joinmastodon.android.fragments.onboarding; + +import android.app.AlertDialog; +import android.app.ProgressDialog; +import android.content.Context; +import android.content.DialogInterface; +import android.os.Build; +import android.os.Bundle; +import android.os.LocaleList; +import android.text.Editable; +import android.text.TextUtils; +import android.text.TextWatcher; +import android.view.KeyEvent; +import android.view.View; +import android.view.ViewGroup; +import android.widget.Button; +import android.widget.EditText; +import android.widget.RadioButton; +import android.widget.TextView; +import android.widget.Toast; + +import org.joinmastodon.android.R; +import org.joinmastodon.android.api.MastodonAPIRequest; +import org.joinmastodon.android.api.MastodonErrorResponse; +import org.joinmastodon.android.api.requests.GetInstance; +import org.joinmastodon.android.api.requests.catalog.GetCatalogCategories; +import org.joinmastodon.android.api.requests.catalog.GetCatalogInstances; +import org.joinmastodon.android.api.session.AccountSessionManager; +import org.joinmastodon.android.model.Instance; +import org.joinmastodon.android.model.catalog.CatalogCategory; +import org.joinmastodon.android.model.catalog.CatalogInstance; + +import java.net.IDN; +import java.util.ArrayList; +import java.util.Collections; +import java.util.Comparator; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.stream.Collectors; + +import androidx.annotation.NonNull; +import androidx.recyclerview.widget.DiffUtil; +import androidx.recyclerview.widget.LinearLayoutManager; +import androidx.recyclerview.widget.RecyclerView; +import me.grishka.appkit.api.Callback; +import me.grishka.appkit.api.ErrorResponse; +import me.grishka.appkit.fragments.BaseRecyclerFragment; +import me.grishka.appkit.utils.BindableViewHolder; +import me.grishka.appkit.utils.MergeRecyclerAdapter; +import me.grishka.appkit.utils.SingleViewRecyclerAdapter; +import me.grishka.appkit.views.UsableRecyclerView; + +public class InstanceCatalogFragment extends BaseRecyclerFragment{ + private InstancesAdapter adapter; + private MergeRecyclerAdapter mergeAdapter; + private View headerView; + private CatalogInstance chosenInstance; + private List filteredData=new ArrayList<>(); + private Button nextButton; + private MastodonAPIRequest getCategoriesRequest; + private EditText searchEdit; + private UsableRecyclerView categoriesList; + private Runnable searchDebouncer=this::onSearchChangedDebounced; + private String currentSearchQuery; + private String currentCategory="all"; + private List categories=new ArrayList<>(); + private String loadingInstanceDomain; + private GetInstance loadingInstanceRequest; + private HashMap instancesCache=new HashMap<>(); + private ProgressDialog instanceProgressDialog; + + private boolean isSignup; + + public InstanceCatalogFragment(){ + super(R.layout.fragment_onboarding_common, 10); + } + + @Override + public void onCreate(Bundle savedInstanceState){ + super.onCreate(savedInstanceState); + isSignup=getArguments().getBoolean("signup"); + } + + @Override + public void onAttach(Context context){ + super.onAttach(context); + setRefreshEnabled(false); + loadData(); + } + + @Override + protected void doLoadData(int offset, int count){ + currentRequest=new GetCatalogInstances(null, null) + .setCallback(new Callback<>(){ + @Override + public void onSuccess(List result){ + Map> byLang=result.stream().collect(Collectors.groupingBy(ci->ci.language)); + // get the list of user-configured system languages + List userLangs; + if(Build.VERSION.SDK_INT<24){ + userLangs=Collections.singletonList(getResources().getConfiguration().locale.getLanguage()); + }else{ + LocaleList ll=getResources().getConfiguration().getLocales(); + userLangs=new ArrayList<>(ll.size()); + for(int i=0;i sortedList=new ArrayList<>(); + for(String lang:userLangs){ + List langInstances=byLang.remove(lang); + if(langInstances!=null){ + sortedList.addAll(langInstances); + } + } + // sort the remaining language groups by aggregate lastWeekUsers + class InstanceGroup{ + public int activeUsers; + public List instances; + } + byLang.values().stream().map(il->{ + InstanceGroup group=new InstanceGroup(); + group.instances=il; + for(CatalogInstance instance:il){ + group.activeUsers+=instance.lastWeekUsers; + } + return group; + }).sorted(Comparator.comparingInt(g->g.activeUsers)).forEachOrdered(ig->sortedList.addAll(ig.instances)); + onDataLoaded(sortedList, false); + updateFilteredList(); + } + + @Override + public void onError(ErrorResponse error){ + InstanceCatalogFragment.this.onError(error); + } + }) + .execNoAuth(""); + getCategoriesRequest=new GetCatalogCategories(null) + .setCallback(new Callback<>(){ + @Override + public void onSuccess(List result){ + getCategoriesRequest=null; + CatalogCategory all=new CatalogCategory(); + all.category="all"; + categories.add(all); + categories.addAll(result); + categoriesList.getAdapter().notifyItemRangeInserted(0, categories.size()); + } + + @Override + public void onError(ErrorResponse error){ + getCategoriesRequest=null; + error.showToast(getActivity()); + } + }) + .execNoAuth(""); + } + + @Override + public void onDestroy(){ + super.onDestroy(); + if(getCategoriesRequest!=null) + getCategoriesRequest.cancel(); + } + + @Override + protected RecyclerView.Adapter getAdapter(){ + headerView=getActivity().getLayoutInflater().inflate(R.layout.header_onboarding_instance_catalog, list, false); + searchEdit=headerView.findViewById(R.id.search_edit); + categoriesList=headerView.findViewById(R.id.categories_list); + searchEdit.setOnEditorActionListener(this::onSearchEnterPressed); + searchEdit.addTextChangedListener(new TextWatcher(){ + @Override + public void beforeTextChanged(CharSequence s, int start, int count, int after){ + + } + + @Override + public void onTextChanged(CharSequence s, int start, int before, int count){ + searchEdit.removeCallbacks(searchDebouncer); + searchEdit.postDelayed(searchDebouncer, 300); + } + + @Override + public void afterTextChanged(Editable s){ + } + }); + categoriesList.setAdapter(new CategoriesAdapter()); + categoriesList.setLayoutManager(new LinearLayoutManager(getActivity(), LinearLayoutManager.HORIZONTAL, false)); + + mergeAdapter=new MergeRecyclerAdapter(); + mergeAdapter.addAdapter(new SingleViewRecyclerAdapter(headerView)); + mergeAdapter.addAdapter(adapter=new InstancesAdapter()); + return mergeAdapter; + } + + @Override + public void onViewCreated(View view, Bundle savedInstanceState){ + super.onViewCreated(view, savedInstanceState); + nextButton=view.findViewById(R.id.btn_next); + nextButton.setOnClickListener(this::onNextClick); + } + + private void onNextClick(View v){ + String domain=chosenInstance.domain; + Instance instance=instancesCache.get(domain); + if(instance!=null){ + proceedWithAuthOrSignup(instance); + }else{ + showProgressDialog(); + if(!domain.equals(loadingInstanceDomain)){ + loadInstanceInfo(domain); + } + } + } + + private void proceedWithAuthOrSignup(Instance instance){ + if(isSignup){ + Toast.makeText(getActivity(), "not implemented yet", Toast.LENGTH_SHORT).show(); + }else{ + AccountSessionManager.getInstance().authenticate(getActivity(), instance); + } + } + + private boolean onSearchEnterPressed(TextView v, int actionId, KeyEvent event){ + if(event!=null && event.getAction()!=KeyEvent.ACTION_DOWN) + return true; + searchEdit.removeCallbacks(searchDebouncer); + Instance instance=instancesCache.get(currentSearchQuery); + if(instance==null){ + showProgressDialog(); + loadInstanceInfo(currentSearchQuery); + }else{ + proceedWithAuthOrSignup(instance); + } + return true; + } + + private void onSearchChangedDebounced(){ + currentSearchQuery=searchEdit.getText().toString().toLowerCase(); + updateFilteredList(); + loadInstanceInfo(currentSearchQuery); + } + + private void updateFilteredList(){ + ArrayList prevData=new ArrayList<>(filteredData); + filteredData.clear(); + for(CatalogInstance instance:data){ + if(currentCategory.equals("all") || instance.categories.contains(currentCategory)){ + if(TextUtils.isEmpty(currentSearchQuery) || instance.domain.contains(currentSearchQuery)){ + if(instance.domain.equals(currentSearchQuery) || !instance.approvalRequired) + filteredData.add(instance); + } + } + } + DiffUtil.calculateDiff(new DiffUtil.Callback(){ + @Override + public int getOldListSize(){ + return prevData.size(); + } + + @Override + public int getNewListSize(){ + return filteredData.size(); + } + + @Override + public boolean areItemsTheSame(int oldItemPosition, int newItemPosition){ + return prevData.get(oldItemPosition)==filteredData.get(newItemPosition); + } + + @Override + public boolean areContentsTheSame(int oldItemPosition, int newItemPosition){ + return prevData.get(oldItemPosition)==filteredData.get(newItemPosition); + } + }).dispatchUpdatesTo(adapter); + } + + private void showProgressDialog(){ + instanceProgressDialog=new ProgressDialog(getActivity()); + instanceProgressDialog.setMessage(getString(R.string.loading_instance)); + instanceProgressDialog.setOnCancelListener(dialog->{ + loadingInstanceRequest.cancel(); + loadingInstanceRequest=null; + loadingInstanceDomain=null; + }); + instanceProgressDialog.show(); + } + + private void loadInstanceInfo(String _domain){ + String domain; + try{ + domain=IDN.toASCII(_domain); + }catch(IllegalArgumentException x){ + return; + } + Instance cachedInstance=instancesCache.get(domain); + if(cachedInstance!=null){ + boolean found=false; + for(CatalogInstance ci:filteredData){ + if(ci.domain.equals(currentSearchQuery)){ + found=true; + break; + } + } + if(!found){ + CatalogInstance ci=cachedInstance.toCatalogInstance(); + filteredData.add(0, ci); + adapter.notifyItemInserted(0); + } + return; + } + if(loadingInstanceDomain!=null){ + if(loadingInstanceDomain.equals(domain)) + return; + else + loadingInstanceRequest.cancel(); + } + loadingInstanceDomain=domain; + loadingInstanceRequest=new GetInstance(); + loadingInstanceRequest.setCallback(new Callback<>(){ + @Override + public void onSuccess(Instance result){ + loadingInstanceRequest=null; + loadingInstanceDomain=null; + result.uri=domain; // needed for instances that use domain redirection + instancesCache.put(domain, result); + if(instanceProgressDialog!=null){ + instanceProgressDialog.dismiss(); + instanceProgressDialog=null; + proceedWithAuthOrSignup(result); + } + if(domain.equals(currentSearchQuery)){ + boolean found=false; + for(CatalogInstance ci:filteredData){ + if(ci.domain.equals(currentSearchQuery)){ + found=true; + break; + } + } + if(!found){ + CatalogInstance ci=result.toCatalogInstance(); + filteredData.add(0, ci); + adapter.notifyItemInserted(0); + } + } + } + + @Override + public void onError(ErrorResponse error){ + loadingInstanceRequest=null; + loadingInstanceDomain=null; + if(instanceProgressDialog!=null){ + instanceProgressDialog.dismiss(); + instanceProgressDialog=null; + new AlertDialog.Builder(getActivity()) + .setTitle(R.string.error) + .setMessage(getString(R.string.not_a_mastodon_instance, domain)+"\n\n"+((MastodonErrorResponse)error).error) + .setPositiveButton(R.string.ok, null) + .show(); + } + } + }).execNoAuth(domain); + } + + private class InstancesAdapter extends UsableRecyclerView.Adapter{ + public InstancesAdapter(){ + super(imgLoader); + } + + @NonNull + @Override + public InstanceViewHolder onCreateViewHolder(@NonNull ViewGroup parent, int viewType){ + return new InstanceViewHolder(); + } + + @Override + public void onBindViewHolder(InstanceViewHolder holder, int position){ + holder.bind(filteredData.get(position)); + super.onBindViewHolder(holder, position); + } + + @Override + public int getItemCount(){ + return filteredData.size(); + } + + @Override + public int getItemViewType(int position){ + return 1; + } + } + + private class InstanceViewHolder extends BindableViewHolder implements UsableRecyclerView.Clickable{ + private final TextView title, description, userCount, lang; + private final RadioButton radioButton; + + public InstanceViewHolder(){ + super(getActivity(), R.layout.item_instance_catalog, list); + title=findViewById(R.id.title); + description=findViewById(R.id.description); + userCount=findViewById(R.id.user_count); + lang=findViewById(R.id.lang); + radioButton=findViewById(R.id.radiobtn); + } + + @Override + public void onBind(CatalogInstance item){ + title.setText(item.normalizedDomain); + description.setText(item.description); + userCount.setText(""+item.totalUsers); + lang.setText(item.language.toUpperCase()); + radioButton.setChecked(chosenInstance==item); + } + + @Override + public void onClick(){ + if(chosenInstance==item) + return; + if(chosenInstance!=null){ + int idx=filteredData.indexOf(chosenInstance); + if(idx!=-1){ + RecyclerView.ViewHolder holder=list.findViewHolderForAdapterPosition(mergeAdapter.getPositionForAdapter(adapter)+idx); + if(holder instanceof InstanceViewHolder){ + ((InstanceViewHolder)holder).radioButton.setChecked(false); + } + } + } + radioButton.setChecked(true); + if(chosenInstance==null) + nextButton.setEnabled(true); + chosenInstance=item; + loadInstanceInfo(chosenInstance.domain); + } + } + + private class CategoriesAdapter extends RecyclerView.Adapter{ + @NonNull + @Override + public CategoryViewHolder onCreateViewHolder(@NonNull ViewGroup parent, int viewType){ + return new CategoryViewHolder(); + } + + @Override + public void onBindViewHolder(@NonNull CategoryViewHolder holder, int position){ + holder.bind(categories.get(position)); + } + + @Override + public int getItemCount(){ + return categories.size(); + } + } + + private class CategoryViewHolder extends BindableViewHolder implements UsableRecyclerView.Clickable{ + private final RadioButton radioButton; + + public CategoryViewHolder(){ + super(getActivity(), R.layout.item_instance_category, categoriesList); + radioButton=findViewById(R.id.radiobtn); + } + + @Override + public void onBind(CatalogCategory item){ + radioButton.setText(item.category); + radioButton.setChecked(item.category.equals(currentCategory)); + } + + @Override + public void onClick(){ + if(currentCategory.equals(item.category)) + return; + int i=0; + for(CatalogCategory c:categories){ + if(c.category.equals(currentCategory)){ + RecyclerView.ViewHolder holder=categoriesList.findViewHolderForAdapterPosition(i); + if(holder!=null){ + ((CategoryViewHolder)holder).radioButton.setChecked(false); + } + break; + } + i++; + } + currentCategory=item.category; + radioButton.setChecked(true); + updateFilteredList(); + } + } +} diff --git a/mastodon/src/main/java/org/joinmastodon/android/model/Account.java b/mastodon/src/main/java/org/joinmastodon/android/model/Account.java new file mode 100644 index 00000000..a1239f7c --- /dev/null +++ b/mastodon/src/main/java/org/joinmastodon/android/model/Account.java @@ -0,0 +1,177 @@ +package org.joinmastodon.android.model; + +import org.joinmastodon.android.api.ObjectValidationException; +import org.joinmastodon.android.api.RequiredField; + +import java.time.Instant; +import java.time.LocalDate; +import java.util.List; + +/** + * Represents a user of Mastodon and their associated profile. + */ +public class Account extends BaseModel{ + // Base attributes + + /** + * The account id + */ + @RequiredField + public String id; + /** + * The username of the account, not including domain. + */ + @RequiredField + public String username; + /** + * The Webfinger account URI. Equal to username for local users, or username@domain for remote users. + */ + @RequiredField + public String acct; + /** + * The location of the user's profile page. + */ + @RequiredField + public String url; + + // Display attributes + + /** + * The profile's display name. + */ + @RequiredField + public String displayName; + /** + * The profile's bio / description. + */ + @RequiredField + public String note; + /** + * An image icon that is shown next to statuses and in the profile. + */ + @RequiredField + public String avatar; + /** + * A static version of the avatar. Equal to avatar if its value is a static image; different if avatar is an animated GIF. + */ + public String avatarStatic; + /** + * An image banner that is shown above the profile and in profile cards. + */ + @RequiredField + public String header; + /** + * A static version of the header. Equal to header if its value is a static image; different if header is an animated GIF. + */ + public String headerStatic; + /** + * Whether the account manually approves follow requests. + */ + public boolean locked; + /** + * Custom emoji entities to be used when rendering the profile. If none, an empty array will be returned. + */ + public List emojis; + /** + * Whether the account has opted into discovery features such as the profile directory. + */ + public boolean discoverable; + + // Statistical attributes + + /** + * When the account was created. + */ + @RequiredField + public Instant createdAt; + /** + * When the most recent status was posted. + */ +// @RequiredField + public LocalDate lastStatusAt; + /** + * How many statuses are attached to this account. + */ + public int statusesCount; + /** + * The reported followers of this profile. + */ + public int followersCount; + /** + * The reported follows of this profile. + */ + public int followingCount; + + // Optional attributes + + /** + * Indicates that the profile is currently inactive and that its user has moved to a new account. + */ + public Account moved; + /** + * Additional metadata attached to a profile as name-value pairs. + */ + public List fields; + /** + * A presentational flag. Indicates that the account may perform automated actions, may not be monitored, or identifies as a robot. + */ + public boolean bot; + /** + * An extra entity to be used with API methods to verify credentials and update credentials. + */ + public Source source; + /** + * An extra entity returned when an account is suspended. + */ + public boolean suspended; + /** + * When a timed mute will expire, if applicable. + */ + public Instant muteExpiresAt; + + + @Override + public void postprocess() throws ObjectValidationException{ + super.postprocess(); + if(fields!=null){ + for(AccountField f:fields) + f.postprocess(); + } + if(emojis!=null){ + for(Emoji e:emojis) + e.postprocess(); + } + if(moved!=null) + moved.postprocess(); + } + + @Override + public String toString(){ + return "Account{"+ + "id='"+id+'\''+ + ", username='"+username+'\''+ + ", acct='"+acct+'\''+ + ", url='"+url+'\''+ + ", displayName='"+displayName+'\''+ + ", note='"+note+'\''+ + ", avatar='"+avatar+'\''+ + ", avatarStatic='"+avatarStatic+'\''+ + ", header='"+header+'\''+ + ", headerStatic='"+headerStatic+'\''+ + ", locked="+locked+ + ", emojis="+emojis+ + ", discoverable="+discoverable+ + ", createdAt="+createdAt+ + ", lastStatusAt="+lastStatusAt+ + ", statusesCount="+statusesCount+ + ", followersCount="+followersCount+ + ", followingCount="+followingCount+ + ", moved="+moved+ + ", fields="+fields+ + ", bot="+bot+ + ", source="+source+ + ", suspended="+suspended+ + ", muteExpiresAt="+muteExpiresAt+ + '}'; + } +} diff --git a/mastodon/src/main/java/org/joinmastodon/android/model/AccountField.java b/mastodon/src/main/java/org/joinmastodon/android/model/AccountField.java new file mode 100644 index 00000000..4198520a --- /dev/null +++ b/mastodon/src/main/java/org/joinmastodon/android/model/AccountField.java @@ -0,0 +1,25 @@ +package org.joinmastodon.android.model; + +import org.joinmastodon.android.api.RequiredField; + +import java.time.Instant; + +/** + * Represents a profile field as a name-value pair with optional verification. + */ +public class AccountField extends BaseModel{ + /** + * The key of a given field's key-value pair. + */ + @RequiredField + public String name; + /** + * The value associated with the name key. + */ + @RequiredField + public String value; + /** + * Timestamp of when the server verified a URL value for a rel="me” link. + */ + public Instant verifiedAt; +} diff --git a/mastodon/src/main/java/org/joinmastodon/android/model/Application.java b/mastodon/src/main/java/org/joinmastodon/android/model/Application.java new file mode 100644 index 00000000..8f837afe --- /dev/null +++ b/mastodon/src/main/java/org/joinmastodon/android/model/Application.java @@ -0,0 +1,23 @@ +package org.joinmastodon.android.model; + +import org.joinmastodon.android.api.RequiredField; + +public class Application extends BaseModel{ + @RequiredField + public String name; + public String website; + public String vapidKey; + public String clientId; + public String clientSecret; + + @Override + public String toString(){ + return "Application{"+ + "name='"+name+'\''+ + ", website='"+website+'\''+ + ", vapidKey='"+vapidKey+'\''+ + ", clientId='"+clientId+'\''+ + ", clientSecret='"+clientSecret+'\''+ + '}'; + } +} diff --git a/mastodon/src/main/java/org/joinmastodon/android/model/BaseModel.java b/mastodon/src/main/java/org/joinmastodon/android/model/BaseModel.java new file mode 100644 index 00000000..e68026c9 --- /dev/null +++ b/mastodon/src/main/java/org/joinmastodon/android/model/BaseModel.java @@ -0,0 +1,26 @@ +package org.joinmastodon.android.model; + +import org.joinmastodon.android.api.AllFieldsAreRequired; +import org.joinmastodon.android.api.ObjectValidationException; +import org.joinmastodon.android.api.RequiredField; + +import java.lang.reflect.Field; +import java.lang.reflect.Modifier; + +import androidx.annotation.CallSuper; + +public abstract class BaseModel{ + @CallSuper + public void postprocess() throws ObjectValidationException{ + try{ + boolean allRequired=getClass().isAnnotationPresent(AllFieldsAreRequired.class); + for(Field fld:getClass().getFields()){ + if(!fld.getType().isPrimitive() && !Modifier.isTransient(fld.getModifiers()) && (allRequired || fld.isAnnotationPresent(RequiredField.class))){ + if(fld.get(this)==null){ + throw new ObjectValidationException("Required field '"+fld.getName()+"' of type "+fld.getType().getSimpleName()+" was null in "+getClass().getSimpleName()); + } + } + } + }catch(IllegalAccessException ignore){} + } +} diff --git a/mastodon/src/main/java/org/joinmastodon/android/model/Emoji.java b/mastodon/src/main/java/org/joinmastodon/android/model/Emoji.java new file mode 100644 index 00000000..b883cea3 --- /dev/null +++ b/mastodon/src/main/java/org/joinmastodon/android/model/Emoji.java @@ -0,0 +1,33 @@ +package org.joinmastodon.android.model; + +import org.joinmastodon.android.api.RequiredField; + +/** + * Represents a custom emoji. + */ +public class Emoji extends BaseModel{ + /** + * The name of the custom emoji. + */ + @RequiredField + public String shortcode; + /** + * A link to the custom emoji. + */ + @RequiredField + public String url; + /** + * A link to a static copy of the custom emoji. + */ + @RequiredField + public String staticUrl; + /** + * Whether this Emoji should be visible in the picker or unlisted. + */ + @RequiredField + public boolean visibleInPicker; + /** + * Used for sorting custom emoji in the picker. + */ + public String category; +} diff --git a/mastodon/src/main/java/org/joinmastodon/android/model/Instance.java b/mastodon/src/main/java/org/joinmastodon/android/model/Instance.java new file mode 100644 index 00000000..21d040f5 --- /dev/null +++ b/mastodon/src/main/java/org/joinmastodon/android/model/Instance.java @@ -0,0 +1,215 @@ +package org.joinmastodon.android.model; + +import android.os.Parcel; +import android.os.Parcelable; +import android.text.Html; + +import org.joinmastodon.android.api.ObjectValidationException; +import org.joinmastodon.android.api.RequiredField; +import org.joinmastodon.android.model.catalog.CatalogInstance; + +import java.net.IDN; +import java.util.Collections; +import java.util.List; +import java.util.Map; + +public class Instance extends BaseModel{ + /** + * The domain name of the instance. + */ + @RequiredField + public String uri; + /** + * The title of the website. + */ + @RequiredField + public String title; + /** + * Admin-defined description of the Mastodon site. + */ + @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. + */ + @RequiredField + public String version; + /** + * Primary langauges of the website and its staff. + */ +// @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 int maxTootChars; + public List rules; + + @Override + public void postprocess() throws ObjectValidationException{ + super.postprocess(); + if(contactAccount!=null) + contactAccount.postprocess(); + } + + @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){ + ci.language=languages.get(0); + ci.languages=languages; + }else{ + ci.languages=Collections.emptyList(); + ci.language="unknown"; + } + ci.proxiedThumbnail=thumbnail; + if(stats!=null) + ci.totalUsers=stats.userCount; + return ci; + } + + + + public static class Rule implements Parcelable{ + public String id; + public String text; + + + @Override + public int describeContents(){ + return 0; + } + + @Override + public void writeToParcel(Parcel dest, int flags){ + dest.writeString(this.id); + dest.writeString(this.text); + } + + public void readFromParcel(Parcel source){ + this.id=source.readString(); + this.text=source.readString(); + } + + public Rule(){ + } + + protected Rule(Parcel in){ + this.id=in.readString(); + this.text=in.readString(); + } + + public static final Parcelable.Creator CREATOR=new Parcelable.Creator(){ + @Override + public Rule createFromParcel(Parcel source){ + return new Rule(source); + } + + @Override + public Rule[] newArray(int size){ + return new Rule[size]; + } + }; + } + + public static class Stats implements Parcelable{ + public int userCount; + public int statusCount; + public int domainCount; + + + @Override + public int describeContents(){ + return 0; + } + + @Override + public void writeToParcel(Parcel dest, int flags){ + dest.writeInt(this.userCount); + dest.writeInt(this.statusCount); + dest.writeInt(this.domainCount); + } + + public void readFromParcel(Parcel source){ + this.userCount=source.readInt(); + this.statusCount=source.readInt(); + this.domainCount=source.readInt(); + } + + public Stats(){ + } + + protected Stats(Parcel in){ + this.userCount=in.readInt(); + this.statusCount=in.readInt(); + this.domainCount=in.readInt(); + } + + public static final Parcelable.Creator CREATOR=new Parcelable.Creator(){ + @Override + public Stats createFromParcel(Parcel source){ + return new Stats(source); + } + + @Override + public Stats[] newArray(int size){ + return new Stats[size]; + } + }; + } +} diff --git a/mastodon/src/main/java/org/joinmastodon/android/model/Source.java b/mastodon/src/main/java/org/joinmastodon/android/model/Source.java new file mode 100644 index 00000000..dda6a946 --- /dev/null +++ b/mastodon/src/main/java/org/joinmastodon/android/model/Source.java @@ -0,0 +1,45 @@ +package org.joinmastodon.android.model; + +import org.joinmastodon.android.api.ObjectValidationException; +import org.joinmastodon.android.api.RequiredField; + +import java.util.List; + +/** + * Represents display or publishing preferences of user's own account. Returned as an additional entity when verifying and updated credentials, as an attribute of Account. + */ +public class Source extends BaseModel{ + /** + * Profile bio. + */ + @RequiredField + public String note; + /** + * Metadata about the account. + */ + @RequiredField + public List fields; + /** + * The default post privacy to be used for new statuses. + */ + public StatusPrivacy privacy; + /** + * Whether new statuses should be marked sensitive by default. + */ + public boolean sensitive; + /** + * The default posting language for new statuses. + */ + public String language; + /** + * The number of pending follow requests. + */ + public int followRequestCount; + + @Override + public void postprocess() throws ObjectValidationException{ + super.postprocess(); + for(AccountField f:fields) + f.postprocess(); + } +} diff --git a/mastodon/src/main/java/org/joinmastodon/android/model/StatusPrivacy.java b/mastodon/src/main/java/org/joinmastodon/android/model/StatusPrivacy.java new file mode 100644 index 00000000..fd205e96 --- /dev/null +++ b/mastodon/src/main/java/org/joinmastodon/android/model/StatusPrivacy.java @@ -0,0 +1,14 @@ +package org.joinmastodon.android.model; + +import com.google.gson.annotations.SerializedName; + +public enum StatusPrivacy{ + @SerializedName("public") + PUBLIC, + @SerializedName("unlisted") + UNLISTED, + @SerializedName("private") + PRIVATE, + @SerializedName("direct") + DIRECT; +} diff --git a/mastodon/src/main/java/org/joinmastodon/android/model/Token.java b/mastodon/src/main/java/org/joinmastodon/android/model/Token.java new file mode 100644 index 00000000..5c6ffddc --- /dev/null +++ b/mastodon/src/main/java/org/joinmastodon/android/model/Token.java @@ -0,0 +1,27 @@ +package org.joinmastodon.android.model; + +import org.joinmastodon.android.api.AllFieldsAreRequired; + +/** + * Represents an OAuth token used for authenticating with the API and performing actions. + */ +@AllFieldsAreRequired +public class Token extends BaseModel{ + /** + * An OAuth token to be used for authorization. + */ + public String accessToken; + /** + * The OAuth token type. Mastodon uses Bearer tokens. + */ + public String tokenType; + /** + * The OAuth scopes granted by this token, space-separated. + */ + public String scope; + /** + * When the token was generated. + * (unixtime) + */ + public long createdAt; +} diff --git a/mastodon/src/main/java/org/joinmastodon/android/model/catalog/CatalogCategory.java b/mastodon/src/main/java/org/joinmastodon/android/model/catalog/CatalogCategory.java new file mode 100644 index 00000000..92c5b3cb --- /dev/null +++ b/mastodon/src/main/java/org/joinmastodon/android/model/catalog/CatalogCategory.java @@ -0,0 +1,18 @@ +package org.joinmastodon.android.model.catalog; + +import org.joinmastodon.android.api.AllFieldsAreRequired; +import org.joinmastodon.android.model.BaseModel; + +@AllFieldsAreRequired +public class CatalogCategory extends BaseModel{ + public String category; + public int serversCount; + + @Override + public String toString(){ + return "CatalogCategory{"+ + "category='"+category+'\''+ + ", serversCount="+serversCount+ + '}'; + } +} diff --git a/mastodon/src/main/java/org/joinmastodon/android/model/catalog/CatalogInstance.java b/mastodon/src/main/java/org/joinmastodon/android/model/catalog/CatalogInstance.java new file mode 100644 index 00000000..9d16dc7c --- /dev/null +++ b/mastodon/src/main/java/org/joinmastodon/android/model/catalog/CatalogInstance.java @@ -0,0 +1,53 @@ +package org.joinmastodon.android.model.catalog; + +import org.joinmastodon.android.api.AllFieldsAreRequired; +import org.joinmastodon.android.api.ObjectValidationException; +import org.joinmastodon.android.model.BaseModel; + +import java.net.IDN; +import java.util.List; + +@AllFieldsAreRequired +public class CatalogInstance extends BaseModel{ + public String domain; + public String version; + public String description; + public List languages; + public String region; + public List categories; + public String proxiedThumbnail; + public int totalUsers; + public int lastWeekUsers; + public boolean approvalRequired; + public String language; + public String category; + + public transient String normalizedDomain; + + @Override + public void postprocess() throws ObjectValidationException{ + super.postprocess(); + if(domain.startsWith("xn--") || domain.contains(".xn--")) + normalizedDomain=IDN.toUnicode(domain); + else + normalizedDomain=domain; + } + + @Override + public String toString(){ + return "CatalogInstance{"+ + "domain='"+domain+'\''+ + ", version='"+version+'\''+ + ", description='"+description+'\''+ + ", languages="+languages+ + ", region='"+region+'\''+ + ", categories="+categories+ + ", proxiedThumbnail='"+proxiedThumbnail+'\''+ + ", totalUsers="+totalUsers+ + ", lastWeekUsers="+lastWeekUsers+ + ", approvalRequired="+approvalRequired+ + ", language='"+language+'\''+ + ", category='"+category+'\''+ + '}'; + } +} diff --git a/mastodon/src/main/res/layout/fragment_onboarding_common.xml b/mastodon/src/main/res/layout/fragment_onboarding_common.xml new file mode 100644 index 00000000..6eba1d84 --- /dev/null +++ b/mastodon/src/main/res/layout/fragment_onboarding_common.xml @@ -0,0 +1,40 @@ + + + + + + + + + + + + + + +