A fork of the Mastodon Android client with Bluesky/ATProto support.

Initial

Grishka 42d5f52f

+2815
+10
.gitignore
···
··· 1 + *.iml 2 + .gradle 3 + /local.properties 4 + /.idea 5 + .DS_Store 6 + /build 7 + /captures 8 + .externalNativeBuild 9 + .cxx 10 + local.properties
+17
build.gradle
···
··· 1 + // Top-level build file where you can add configuration options common to all sub-projects/modules. 2 + buildscript { 3 + repositories { 4 + google() 5 + mavenCentral() 6 + } 7 + dependencies { 8 + classpath "com.android.tools.build:gradle:7.0.4" 9 + 10 + // NOTE: Do not place your application dependencies here; they belong 11 + // in the individual module build.gradle files 12 + } 13 + } 14 + 15 + task clean(type: Delete) { 16 + delete rootProject.buildDir 17 + }
+19
gradle.properties
···
··· 1 + # Project-wide Gradle settings. 2 + # IDE (e.g. Android Studio) users: 3 + # Gradle settings configured through the IDE *will override* 4 + # any settings specified in this file. 5 + # For more details on how to configure your build environment visit 6 + # http://www.gradle.org/docs/current/userguide/build_environment.html 7 + # Specifies the JVM arguments used for the daemon process. 8 + # The setting is particularly useful for tweaking memory settings. 9 + org.gradle.jvmargs=-Xmx2048m -Dfile.encoding=UTF-8 10 + # When configured, Gradle will run in incubating parallel mode. 11 + # This option should only be used with decoupled projects. More details, visit 12 + # http://www.gradle.org/docs/current/userguide/multi_project_builds.html#sec:decoupled_projects 13 + # org.gradle.parallel=true 14 + # AndroidX package structure to make it clearer which packages are bundled with the 15 + # Android operating system, and which are packaged with your app"s APK 16 + # https://developer.android.com/topic/libraries/support-library/androidx-rn 17 + android.useAndroidX=true 18 + # Automatically convert third-party libraries to use AndroidX 19 + android.enableJetifier=true
gradle/wrapper/gradle-wrapper.jar

This is a binary file and will not be displayed.

+6
gradle/wrapper/gradle-wrapper.properties
···
··· 1 + #Thu Jan 13 11:33:43 MSK 2022 2 + distributionBase=GRADLE_USER_HOME 3 + distributionUrl=https\://services.gradle.org/distributions/gradle-7.3.3-bin.zip 4 + distributionPath=wrapper/dists 5 + zipStorePath=wrapper/dists 6 + zipStoreBase=GRADLE_USER_HOME
+185
gradlew
···
··· 1 + #!/usr/bin/env sh 2 + 3 + # 4 + # Copyright 2015 the original author or authors. 5 + # 6 + # Licensed under the Apache License, Version 2.0 (the "License"); 7 + # you may not use this file except in compliance with the License. 8 + # You may obtain a copy of the License at 9 + # 10 + # https://www.apache.org/licenses/LICENSE-2.0 11 + # 12 + # Unless required by applicable law or agreed to in writing, software 13 + # distributed under the License is distributed on an "AS IS" BASIS, 14 + # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 15 + # See the License for the specific language governing permissions and 16 + # limitations under the License. 17 + # 18 + 19 + ############################################################################## 20 + ## 21 + ## Gradle start up script for UN*X 22 + ## 23 + ############################################################################## 24 + 25 + # Attempt to set APP_HOME 26 + # Resolve links: $0 may be a link 27 + PRG="$0" 28 + # Need this for relative symlinks. 29 + while [ -h "$PRG" ] ; do 30 + ls=`ls -ld "$PRG"` 31 + link=`expr "$ls" : '.*-> \(.*\)$'` 32 + if expr "$link" : '/.*' > /dev/null; then 33 + PRG="$link" 34 + else 35 + PRG=`dirname "$PRG"`"/$link" 36 + fi 37 + done 38 + SAVED="`pwd`" 39 + cd "`dirname \"$PRG\"`/" >/dev/null 40 + APP_HOME="`pwd -P`" 41 + cd "$SAVED" >/dev/null 42 + 43 + APP_NAME="Gradle" 44 + APP_BASE_NAME=`basename "$0"` 45 + 46 + # Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. 47 + DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"' 48 + 49 + # Use the maximum available, or set MAX_FD != -1 to use that value. 50 + MAX_FD="maximum" 51 + 52 + warn () { 53 + echo "$*" 54 + } 55 + 56 + die () { 57 + echo 58 + echo "$*" 59 + echo 60 + exit 1 61 + } 62 + 63 + # OS specific support (must be 'true' or 'false'). 64 + cygwin=false 65 + msys=false 66 + darwin=false 67 + nonstop=false 68 + case "`uname`" in 69 + CYGWIN* ) 70 + cygwin=true 71 + ;; 72 + Darwin* ) 73 + darwin=true 74 + ;; 75 + MINGW* ) 76 + msys=true 77 + ;; 78 + NONSTOP* ) 79 + nonstop=true 80 + ;; 81 + esac 82 + 83 + CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar 84 + 85 + 86 + # Determine the Java command to use to start the JVM. 87 + if [ -n "$JAVA_HOME" ] ; then 88 + if [ -x "$JAVA_HOME/jre/sh/java" ] ; then 89 + # IBM's JDK on AIX uses strange locations for the executables 90 + JAVACMD="$JAVA_HOME/jre/sh/java" 91 + else 92 + JAVACMD="$JAVA_HOME/bin/java" 93 + fi 94 + if [ ! -x "$JAVACMD" ] ; then 95 + die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME 96 + 97 + Please set the JAVA_HOME variable in your environment to match the 98 + location of your Java installation." 99 + fi 100 + else 101 + JAVACMD="java" 102 + which java >/dev/null 2>&1 || die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 103 + 104 + Please set the JAVA_HOME variable in your environment to match the 105 + location of your Java installation." 106 + fi 107 + 108 + # Increase the maximum file descriptors if we can. 109 + if [ "$cygwin" = "false" -a "$darwin" = "false" -a "$nonstop" = "false" ] ; then 110 + MAX_FD_LIMIT=`ulimit -H -n` 111 + if [ $? -eq 0 ] ; then 112 + if [ "$MAX_FD" = "maximum" -o "$MAX_FD" = "max" ] ; then 113 + MAX_FD="$MAX_FD_LIMIT" 114 + fi 115 + ulimit -n $MAX_FD 116 + if [ $? -ne 0 ] ; then 117 + warn "Could not set maximum file descriptor limit: $MAX_FD" 118 + fi 119 + else 120 + warn "Could not query maximum file descriptor limit: $MAX_FD_LIMIT" 121 + fi 122 + fi 123 + 124 + # For Darwin, add options to specify how the application appears in the dock 125 + if $darwin; then 126 + GRADLE_OPTS="$GRADLE_OPTS \"-Xdock:name=$APP_NAME\" \"-Xdock:icon=$APP_HOME/media/gradle.icns\"" 127 + fi 128 + 129 + # For Cygwin or MSYS, switch paths to Windows format before running java 130 + if [ "$cygwin" = "true" -o "$msys" = "true" ] ; then 131 + APP_HOME=`cygpath --path --mixed "$APP_HOME"` 132 + CLASSPATH=`cygpath --path --mixed "$CLASSPATH"` 133 + 134 + JAVACMD=`cygpath --unix "$JAVACMD"` 135 + 136 + # We build the pattern for arguments to be converted via cygpath 137 + ROOTDIRSRAW=`find -L / -maxdepth 1 -mindepth 1 -type d 2>/dev/null` 138 + SEP="" 139 + for dir in $ROOTDIRSRAW ; do 140 + ROOTDIRS="$ROOTDIRS$SEP$dir" 141 + SEP="|" 142 + done 143 + OURCYGPATTERN="(^($ROOTDIRS))" 144 + # Add a user-defined pattern to the cygpath arguments 145 + if [ "$GRADLE_CYGPATTERN" != "" ] ; then 146 + OURCYGPATTERN="$OURCYGPATTERN|($GRADLE_CYGPATTERN)" 147 + fi 148 + # Now convert the arguments - kludge to limit ourselves to /bin/sh 149 + i=0 150 + for arg in "$@" ; do 151 + CHECK=`echo "$arg"|egrep -c "$OURCYGPATTERN" -` 152 + CHECK2=`echo "$arg"|egrep -c "^-"` ### Determine if an option 153 + 154 + if [ $CHECK -ne 0 ] && [ $CHECK2 -eq 0 ] ; then ### Added a condition 155 + eval `echo args$i`=`cygpath --path --ignore --mixed "$arg"` 156 + else 157 + eval `echo args$i`="\"$arg\"" 158 + fi 159 + i=`expr $i + 1` 160 + done 161 + case $i in 162 + 0) set -- ;; 163 + 1) set -- "$args0" ;; 164 + 2) set -- "$args0" "$args1" ;; 165 + 3) set -- "$args0" "$args1" "$args2" ;; 166 + 4) set -- "$args0" "$args1" "$args2" "$args3" ;; 167 + 5) set -- "$args0" "$args1" "$args2" "$args3" "$args4" ;; 168 + 6) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" ;; 169 + 7) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" ;; 170 + 8) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" "$args7" ;; 171 + 9) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" "$args7" "$args8" ;; 172 + esac 173 + fi 174 + 175 + # Escape application args 176 + save () { 177 + for i do printf %s\\n "$i" | sed "s/'/'\\\\''/g;1s/^/'/;\$s/\$/' \\\\/" ; done 178 + echo " " 179 + } 180 + APP_ARGS=`save "$@"` 181 + 182 + # Collect all arguments for the java command, following the shell quoting and substitution rules 183 + eval set -- $DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS "\"-Dorg.gradle.appname=$APP_BASE_NAME\"" -classpath "\"$CLASSPATH\"" org.gradle.wrapper.GradleWrapperMain "$APP_ARGS" 184 + 185 + exec "$JAVACMD" "$@"
+89
gradlew.bat
···
··· 1 + @rem 2 + @rem Copyright 2015 the original author or authors. 3 + @rem 4 + @rem Licensed under the Apache License, Version 2.0 (the "License"); 5 + @rem you may not use this file except in compliance with the License. 6 + @rem You may obtain a copy of the License at 7 + @rem 8 + @rem https://www.apache.org/licenses/LICENSE-2.0 9 + @rem 10 + @rem Unless required by applicable law or agreed to in writing, software 11 + @rem distributed under the License is distributed on an "AS IS" BASIS, 12 + @rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 + @rem See the License for the specific language governing permissions and 14 + @rem limitations under the License. 15 + @rem 16 + 17 + @if "%DEBUG%" == "" @echo off 18 + @rem ########################################################################## 19 + @rem 20 + @rem Gradle startup script for Windows 21 + @rem 22 + @rem ########################################################################## 23 + 24 + @rem Set local scope for the variables with windows NT shell 25 + if "%OS%"=="Windows_NT" setlocal 26 + 27 + set DIRNAME=%~dp0 28 + if "%DIRNAME%" == "" set DIRNAME=. 29 + set APP_BASE_NAME=%~n0 30 + set APP_HOME=%DIRNAME% 31 + 32 + @rem Resolve any "." and ".." in APP_HOME to make it shorter. 33 + for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi 34 + 35 + @rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. 36 + set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m" 37 + 38 + @rem Find java.exe 39 + if defined JAVA_HOME goto findJavaFromJavaHome 40 + 41 + set JAVA_EXE=java.exe 42 + %JAVA_EXE% -version >NUL 2>&1 43 + if "%ERRORLEVEL%" == "0" goto execute 44 + 45 + echo. 46 + echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 47 + echo. 48 + echo Please set the JAVA_HOME variable in your environment to match the 49 + echo location of your Java installation. 50 + 51 + goto fail 52 + 53 + :findJavaFromJavaHome 54 + set JAVA_HOME=%JAVA_HOME:"=% 55 + set JAVA_EXE=%JAVA_HOME%/bin/java.exe 56 + 57 + if exist "%JAVA_EXE%" goto execute 58 + 59 + echo. 60 + echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 61 + echo. 62 + echo Please set the JAVA_HOME variable in your environment to match the 63 + echo location of your Java installation. 64 + 65 + goto fail 66 + 67 + :execute 68 + @rem Setup the command line 69 + 70 + set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar 71 + 72 + 73 + @rem Execute Gradle 74 + "%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %* 75 + 76 + :end 77 + @rem End local scope for the variables with windows NT shell 78 + if "%ERRORLEVEL%"=="0" goto mainEnd 79 + 80 + :fail 81 + rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of 82 + rem the _cmd.exe /c_ return code! 83 + if not "" == "%GRADLE_EXIT_CONSOLE%" exit 1 84 + exit /b 1 85 + 86 + :mainEnd 87 + if "%OS%"=="Windows_NT" endlocal 88 + 89 + :omega
+1
mastodon/.gitignore
···
··· 1 + /build
+38
mastodon/build.gradle
···
··· 1 + plugins { 2 + id 'com.android.application' 3 + } 4 + 5 + android { 6 + compileSdk 31 7 + buildToolsVersion "32.0.0" 8 + defaultConfig { 9 + applicationId "org.joinmastodon.android" 10 + minSdk 23 11 + targetSdk 31 12 + versionCode 1 13 + versionName "1.0" 14 + } 15 + 16 + buildTypes { 17 + release { 18 + minifyEnabled false 19 + proguardFiles getDefaultProguardFile('proguard-android-optimize.txt'), 'proguard-rules.pro' 20 + } 21 + } 22 + compileOptions { 23 + sourceCompatibility JavaVersion.VERSION_15 24 + targetCompatibility JavaVersion.VERSION_15 25 + coreLibraryDesugaringEnabled true 26 + } 27 + } 28 + 29 + dependencies { 30 + api 'androidx.annotation:annotation:1.3.0' 31 + implementation 'com.squareup.okhttp3:okhttp:3.14.9' 32 + implementation 'me.grishka.litex:recyclerview:1.2.1' 33 + implementation 'me.grishka.litex:swiperefreshlayout:1.1.0' 34 + implementation 'me.grishka.litex:browser:1.4.0' 35 + implementation 'me.grishka.appkit:appkit:1.0' 36 + implementation 'com.google.code.gson:gson:2.8.9' 37 + coreLibraryDesugaring 'com.android.tools:desugar_jdk_libs:1.1.5' 38 + }
+21
mastodon/proguard-rules.pro
···
··· 1 + # Add project specific ProGuard rules here. 2 + # You can control the set of applied configuration files using the 3 + # proguardFiles setting in build.gradle. 4 + # 5 + # For more details, see 6 + # http://developer.android.com/guide/developing/tools/proguard.html 7 + 8 + # If your project uses WebView with JS, uncomment the following 9 + # and specify the fully qualified class name to the JavaScript interface 10 + # class: 11 + #-keepclassmembers class fqcn.of.javascript.interface.for.webview { 12 + # public *; 13 + #} 14 + 15 + # Uncomment this to preserve the line number information for 16 + # debugging stack traces. 17 + #-keepattributes SourceFile,LineNumberTable 18 + 19 + # If you keep the line number information, uncomment this to 20 + # hide the original source file name. 21 + #-renamesourcefileattribute SourceFile
+31
mastodon/src/main/AndroidManifest.xml
···
··· 1 + <?xml version="1.0" encoding="utf-8"?> 2 + <manifest xmlns:android="http://schemas.android.com/apk/res/android" 3 + package="org.joinmastodon.android"> 4 + 5 + <uses-permission android:name="android.permission.INTERNET"/> 6 + 7 + <application 8 + android:name=".MastodonApp" 9 + android:allowBackup="true" 10 + android:label="@string/app_name" 11 + android:supportsRtl="true" 12 + android:theme="@style/Theme.Mastodon"> 13 + 14 + <activity android:name=".MainActivity" android:exported="true" android:configChanges="orientation|screenSize"> 15 + <intent-filter> 16 + <action android:name="android.intent.action.MAIN"/> 17 + <category android:name="android.intent.category.LAUNCHER"/> 18 + </intent-filter> 19 + </activity> 20 + <activity android:name=".OAuthActivity" android:exported="true" android:configChanges="orientation|screenSize" android:launchMode="singleTask"> 21 + <intent-filter> 22 + <action android:name="android.intent.action.VIEW"/> 23 + <category android:name="android.intent.category.BROWSABLE"/> 24 + <category android:name="android.intent.category.DEFAULT"/> 25 + <data android:scheme="mastodon-android-auth" android:host="callback"/> 26 + </intent-filter> 27 + </activity> 28 + 29 + </application> 30 + 31 + </manifest>
+29
mastodon/src/main/java/org/joinmastodon/android/MainActivity.java
···
··· 1 + package org.joinmastodon.android; 2 + 3 + import android.os.Bundle; 4 + 5 + import org.joinmastodon.android.api.session.AccountSessionManager; 6 + import org.joinmastodon.android.fragments.HomeFragment; 7 + import org.joinmastodon.android.fragments.SplashFragment; 8 + 9 + import androidx.annotation.Nullable; 10 + import me.grishka.appkit.FragmentStackActivity; 11 + 12 + public class MainActivity extends FragmentStackActivity{ 13 + @Override 14 + protected void onCreate(@Nullable Bundle savedInstanceState){ 15 + super.onCreate(savedInstanceState); 16 + 17 + if(savedInstanceState==null){ 18 + if(AccountSessionManager.getInstance().getLoggedInAccounts().isEmpty()){ 19 + showFragmentClearingBackStack(new SplashFragment()); 20 + }else{ 21 + Bundle args=new Bundle(); 22 + args.putString("account", AccountSessionManager.getInstance().getLastActiveAccountID()); 23 + HomeFragment fragment=new HomeFragment(); 24 + fragment.setArguments(args); 25 + showFragmentClearingBackStack(fragment); 26 + } 27 + } 28 + } 29 + }
+17
mastodon/src/main/java/org/joinmastodon/android/MastodonApp.java
···
··· 1 + package org.joinmastodon.android; 2 + 3 + import android.annotation.SuppressLint; 4 + import android.app.Application; 5 + import android.content.Context; 6 + 7 + public class MastodonApp extends Application{ 8 + 9 + @SuppressLint("StaticFieldLeak") // it's not a leak 10 + public static Context context; 11 + 12 + @Override 13 + public void onCreate(){ 14 + super.onCreate(); 15 + context=getApplicationContext(); 16 + } 17 + }
+103
mastodon/src/main/java/org/joinmastodon/android/OAuthActivity.java
···
··· 1 + package org.joinmastodon.android; 2 + 3 + import android.app.Activity; 4 + import android.app.ProgressDialog; 5 + import android.content.Intent; 6 + import android.net.Uri; 7 + import android.os.Bundle; 8 + import android.text.TextUtils; 9 + import android.util.Log; 10 + import android.widget.Toast; 11 + 12 + import org.joinmastodon.android.api.requests.accounts.GetOwnAccount; 13 + import org.joinmastodon.android.api.requests.oauth.GetOauthToken; 14 + import org.joinmastodon.android.api.session.AccountSessionManager; 15 + import org.joinmastodon.android.model.Account; 16 + import org.joinmastodon.android.model.Application; 17 + import org.joinmastodon.android.model.Instance; 18 + import org.joinmastodon.android.model.Token; 19 + 20 + import androidx.annotation.Nullable; 21 + import me.grishka.appkit.api.Callback; 22 + import me.grishka.appkit.api.ErrorResponse; 23 + 24 + public class OAuthActivity extends Activity{ 25 + @Override 26 + protected void onCreate(@Nullable Bundle savedInstanceState){ 27 + super.onCreate(savedInstanceState); 28 + Uri uri=getIntent().getData(); 29 + if(uri==null || isTaskRoot()){ 30 + finish(); 31 + return; 32 + } 33 + if(uri.getQueryParameter("error")!=null){ 34 + String error=uri.getQueryParameter("error_description"); 35 + if(TextUtils.isEmpty(error)) 36 + error=uri.getQueryParameter("error"); 37 + Toast.makeText(this, error, Toast.LENGTH_LONG).show(); 38 + finish(); 39 + restartMainActivity(); 40 + return; 41 + } 42 + String code=uri.getQueryParameter("code"); 43 + if(TextUtils.isEmpty(code)){ 44 + finish(); 45 + return; 46 + } 47 + Instance instance=AccountSessionManager.getInstance().getAuthenticatingInstance(); 48 + Application app=AccountSessionManager.getInstance().getAuthenticatingApp(); 49 + if(instance==null || app==null){ 50 + finish(); 51 + return; 52 + } 53 + ProgressDialog progress=new ProgressDialog(this); 54 + progress.setMessage(getString(R.string.finishing_auth)); 55 + progress.setCancelable(false); 56 + progress.show(); 57 + new GetOauthToken(app.clientId, app.clientSecret, code) 58 + .setCallback(new Callback<>(){ 59 + @Override 60 + public void onSuccess(Token token){ 61 + new GetOwnAccount() 62 + .setCallback(new Callback<>(){ 63 + @Override 64 + public void onSuccess(Account account){ 65 + AccountSessionManager.getInstance().addAccount(instance, token, account, app); 66 + progress.dismiss(); 67 + finish(); 68 + // not calling restartMainActivity() here on purpose to have it recreated (notice different flags) 69 + Intent intent=new Intent(OAuthActivity.this, MainActivity.class); 70 + intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK | Intent.FLAG_ACTIVITY_CLEAR_TASK); 71 + startActivity(intent); 72 + } 73 + 74 + @Override 75 + public void onError(ErrorResponse error){ 76 + handleError(error); 77 + progress.dismiss(); 78 + } 79 + }) 80 + .exec(instance.uri, token); 81 + } 82 + 83 + @Override 84 + public void onError(ErrorResponse error){ 85 + handleError(error); 86 + progress.dismiss(); 87 + } 88 + }) 89 + .execNoAuth(instance.uri); 90 + } 91 + 92 + private void handleError(ErrorResponse error){ 93 + error.showToast(OAuthActivity.this); 94 + finish(); 95 + restartMainActivity(); 96 + } 97 + 98 + private void restartMainActivity(){ 99 + Intent intent=new Intent(this, MainActivity.class); 100 + intent.addFlags(Intent.FLAG_ACTIVITY_CLEAR_TOP | Intent.FLAG_ACTIVITY_SINGLE_TOP); 101 + startActivity(intent); 102 + } 103 + }
+11
mastodon/src/main/java/org/joinmastodon/android/api/AllFieldsAreRequired.java
···
··· 1 + package org.joinmastodon.android.api; 2 + 3 + import java.lang.annotation.ElementType; 4 + import java.lang.annotation.Retention; 5 + import java.lang.annotation.RetentionPolicy; 6 + import java.lang.annotation.Target; 7 + 8 + @Retention(RetentionPolicy.RUNTIME) 9 + @Target(ElementType.TYPE) 10 + public @interface AllFieldsAreRequired{ 11 + }
+35
mastodon/src/main/java/org/joinmastodon/android/api/JsonObjectRequestBody.java
···
··· 1 + package org.joinmastodon.android.api; 2 + 3 + import com.google.gson.JsonIOException; 4 + 5 + import java.io.IOException; 6 + import java.io.OutputStreamWriter; 7 + import java.nio.charset.StandardCharsets; 8 + 9 + import okhttp3.MediaType; 10 + import okhttp3.RequestBody; 11 + import okio.BufferedSink; 12 + 13 + public class JsonObjectRequestBody extends RequestBody{ 14 + private final Object obj; 15 + 16 + public JsonObjectRequestBody(Object obj){ 17 + this.obj=obj; 18 + } 19 + 20 + @Override 21 + public MediaType contentType(){ 22 + return MediaType.get("application/json;charset=utf-8"); 23 + } 24 + 25 + @Override 26 + public void writeTo(BufferedSink sink) throws IOException{ 27 + try{ 28 + OutputStreamWriter writer=new OutputStreamWriter(sink.outputStream(), StandardCharsets.UTF_8); 29 + MastodonAPIController.gson.toJson(obj, writer); 30 + writer.flush(); 31 + }catch(JsonIOException x){ 32 + throw new IOException(x); 33 + } 34 + } 35 + }
+157
mastodon/src/main/java/org/joinmastodon/android/api/MastodonAPIController.java
···
··· 1 + package org.joinmastodon.android.api; 2 + 3 + import android.content.pm.ApplicationInfo; 4 + import android.content.pm.PackageInfo; 5 + import android.content.pm.PackageManager; 6 + import android.util.Log; 7 + 8 + import com.google.gson.FieldNamingPolicy; 9 + import com.google.gson.Gson; 10 + import com.google.gson.GsonBuilder; 11 + import com.google.gson.JsonIOException; 12 + import com.google.gson.JsonObject; 13 + import com.google.gson.JsonParser; 14 + import com.google.gson.JsonSyntaxException; 15 + 16 + import org.joinmastodon.android.BuildConfig; 17 + import org.joinmastodon.android.MastodonApp; 18 + import org.joinmastodon.android.api.gson.IsoInstantTypeAdapter; 19 + import org.joinmastodon.android.api.gson.IsoLocalDateTypeAdapter; 20 + import org.joinmastodon.android.api.session.AccountSession; 21 + import org.joinmastodon.android.model.BaseModel; 22 + 23 + import java.io.IOException; 24 + import java.io.Reader; 25 + import java.time.Instant; 26 + import java.time.LocalDate; 27 + import java.util.List; 28 + 29 + import androidx.annotation.NonNull; 30 + import androidx.annotation.Nullable; 31 + import me.grishka.appkit.utils.WorkerThread; 32 + import okhttp3.Call; 33 + import okhttp3.Callback; 34 + import okhttp3.OkHttpClient; 35 + import okhttp3.Request; 36 + import okhttp3.Response; 37 + import okhttp3.ResponseBody; 38 + 39 + public class MastodonAPIController{ 40 + private static final String TAG="MastodonAPIController"; 41 + public static final Gson gson=new GsonBuilder() 42 + .disableHtmlEscaping() 43 + .setFieldNamingPolicy(FieldNamingPolicy.LOWER_CASE_WITH_UNDERSCORES) 44 + .registerTypeAdapter(Instant.class, new IsoInstantTypeAdapter()) 45 + .registerTypeAdapter(LocalDate.class, new IsoLocalDateTypeAdapter()) 46 + .create(); 47 + private static WorkerThread thread=new WorkerThread("MastodonAPIController"); 48 + private static OkHttpClient httpClient=new OkHttpClient.Builder().build(); 49 + 50 + private AccountSession session; 51 + 52 + static{ 53 + thread.start(); 54 + } 55 + 56 + public MastodonAPIController(@Nullable AccountSession session){ 57 + this.session=session; 58 + } 59 + 60 + public <T> void submitRequest(final MastodonAPIRequest<T> req){ 61 + thread.postRunnable(()->{ 62 + try{ 63 + Request.Builder builder=new Request.Builder() 64 + .url(req.getURL().toString()) 65 + .method(req.getMethod(), req.getRequestBody()) 66 + .header("User-Agent", "MastodonAndroid/"+BuildConfig.VERSION_NAME); 67 + 68 + String token=null; 69 + if(session!=null) 70 + token=session.token.accessToken; 71 + else if(req.token!=null) 72 + token=req.token.accessToken; 73 + 74 + if(token!=null) 75 + builder.header("Authorization", "Bearer "+token); 76 + 77 + Request hreq=builder.build(); 78 + Call call=httpClient.newCall(hreq); 79 + synchronized(req){ 80 + req.okhttpCall=call; 81 + } 82 + 83 + if(BuildConfig.DEBUG) 84 + Log.d(TAG, "["+(session==null ? "no-auth" : session.getID())+"] Sending request: "+hreq); 85 + 86 + call.enqueue(new Callback(){ 87 + @Override 88 + public void onFailure(@NonNull Call call, @NonNull IOException e){ 89 + if(call.isCanceled()) 90 + return; 91 + if(BuildConfig.DEBUG) 92 + Log.w(TAG, "["+(session==null ? "no-auth" : session.getID())+"] "+hreq+" failed: "+e); 93 + synchronized(req){ 94 + req.okhttpCall=null; 95 + } 96 + req.onError(e.getLocalizedMessage()); 97 + } 98 + 99 + @Override 100 + public void onResponse(@NonNull Call call, @NonNull Response response) throws IOException{ 101 + if(call.isCanceled()) 102 + return; 103 + if(BuildConfig.DEBUG) 104 + Log.d(TAG, "["+(session==null ? "no-auth" : session.getID())+"] "+hreq+" received response: "+response); 105 + synchronized(req){ 106 + req.okhttpCall=null; 107 + } 108 + try(ResponseBody body=response.body()){ 109 + Reader reader=body.charStream(); 110 + if(response.isSuccessful()){ 111 + T respObj; 112 + try{ 113 + if(req.respTypeToken!=null) 114 + respObj=gson.fromJson(reader, req.respTypeToken.getType()); 115 + else 116 + respObj=gson.fromJson(reader, req.respClass); 117 + }catch(JsonIOException|JsonSyntaxException x){ 118 + if(BuildConfig.DEBUG) 119 + Log.w(TAG, "["+(session==null ? "no-auth" : session.getID())+"] "+response+" error parsing or reading body", x); 120 + req.onError(x.getLocalizedMessage()); 121 + return; 122 + } 123 + 124 + try{ 125 + req.validateAndPostprocessResponse(respObj); 126 + }catch(IOException x){ 127 + if(BuildConfig.DEBUG) 128 + Log.w(TAG, "["+(session==null ? "no-auth" : session.getID())+"] "+response+" error post-processing or validating response", x); 129 + req.onError(x.getLocalizedMessage()); 130 + return; 131 + } 132 + 133 + if(BuildConfig.DEBUG) 134 + Log.d(TAG, "["+(session==null ? "no-auth" : session.getID())+"] "+response+" parsed successfully: "+respObj); 135 + 136 + req.onSuccess(respObj); 137 + }else{ 138 + try{ 139 + JsonObject error=JsonParser.parseReader(reader).getAsJsonObject(); 140 + req.onError(error.get("error").getAsString()); 141 + }catch(JsonIOException|JsonSyntaxException x){ 142 + req.onError(response.code()+" "+response.message()); 143 + }catch(IllegalStateException x){ 144 + req.onError("Error parsing an API error"); 145 + } 146 + } 147 + } 148 + } 149 + }); 150 + }catch(Exception x){ 151 + if(BuildConfig.DEBUG) 152 + Log.w(TAG, "["+(session==null ? "no-auth" : session.getID())+"] error creating and sending http request", x); 153 + req.onError(x.getLocalizedMessage()); 154 + } 155 + }, 0); 156 + } 157 + }
+147
mastodon/src/main/java/org/joinmastodon/android/api/MastodonAPIRequest.java
···
··· 1 + package org.joinmastodon.android.api; 2 + 3 + import android.net.Uri; 4 + 5 + import com.google.gson.reflect.TypeToken; 6 + 7 + import org.joinmastodon.android.api.session.AccountSession; 8 + import org.joinmastodon.android.api.session.AccountSessionManager; 9 + import org.joinmastodon.android.model.BaseModel; 10 + import org.joinmastodon.android.model.Token; 11 + 12 + import java.io.IOException; 13 + import java.util.HashMap; 14 + import java.util.List; 15 + import java.util.Map; 16 + 17 + import androidx.annotation.CallSuper; 18 + import me.grishka.appkit.api.APIRequest; 19 + import me.grishka.appkit.api.Callback; 20 + import okhttp3.Call; 21 + import okhttp3.RequestBody; 22 + 23 + public abstract class MastodonAPIRequest<T> extends APIRequest<T>{ 24 + 25 + private String domain; 26 + private AccountSession account; 27 + private String path; 28 + private String method; 29 + private Object requestBody; 30 + private Map<String, String> queryParams; 31 + Class<T> respClass; 32 + TypeToken<T> respTypeToken; 33 + Call okhttpCall; 34 + Token token; 35 + 36 + public MastodonAPIRequest(HttpMethod method, String path, Class<T> respClass){ 37 + this.path=path; 38 + this.method=method.toString(); 39 + this.respClass=respClass; 40 + } 41 + 42 + public MastodonAPIRequest(HttpMethod method, String path, TypeToken<T> respTypeToken){ 43 + this.path=path; 44 + this.method=method.toString(); 45 + this.respTypeToken=respTypeToken; 46 + } 47 + 48 + @Override 49 + public synchronized void cancel(){ 50 + if(okhttpCall!=null){ 51 + okhttpCall.cancel(); 52 + } 53 + } 54 + 55 + @Override 56 + public APIRequest<T> exec(){ 57 + throw new UnsupportedOperationException("Use exec(accountID) or execNoAuth(domain)"); 58 + } 59 + 60 + public MastodonAPIRequest<T> exec(String accountID){ 61 + account=AccountSessionManager.getInstance().getAccount(accountID); 62 + domain=account.domain; 63 + account.getApiController().submitRequest(this); 64 + return this; 65 + } 66 + 67 + public MastodonAPIRequest<T> execNoAuth(String domain){ 68 + this.domain=domain; 69 + AccountSessionManager.getInstance().getUnauthenticatedApiController().submitRequest(this); 70 + return this; 71 + } 72 + 73 + public MastodonAPIRequest<T> exec(String domain, Token token){ 74 + this.domain=domain; 75 + this.token=token; 76 + AccountSessionManager.getInstance().getUnauthenticatedApiController().submitRequest(this); 77 + return this; 78 + } 79 + 80 + protected void setRequestBody(Object body){ 81 + requestBody=body; 82 + } 83 + 84 + protected void addQueryParameter(String key, String value){ 85 + if(queryParams==null) 86 + queryParams=new HashMap<>(); 87 + queryParams.put(key, value); 88 + } 89 + 90 + protected String getPathPrefix(){ 91 + return "/api/v1"; 92 + } 93 + 94 + public Uri getURL(){ 95 + Uri.Builder builder=new Uri.Builder() 96 + .scheme("https") 97 + .authority(domain) 98 + .path(getPathPrefix()+path); 99 + if(queryParams!=null){ 100 + for(Map.Entry<String, String> param:queryParams.entrySet()){ 101 + builder.appendQueryParameter(param.getKey(), param.getValue()); 102 + } 103 + } 104 + return builder.build(); 105 + } 106 + 107 + public String getMethod(){ 108 + return method; 109 + } 110 + 111 + public RequestBody getRequestBody(){ 112 + return requestBody==null ? null : new JsonObjectRequestBody(requestBody); 113 + } 114 + 115 + @Override 116 + public MastodonAPIRequest<T> setCallback(Callback<T> callback){ 117 + super.setCallback(callback); 118 + return this; 119 + } 120 + 121 + @CallSuper 122 + public void validateAndPostprocessResponse(T respObj) throws IOException{ 123 + if(respObj instanceof BaseModel){ 124 + ((BaseModel) respObj).postprocess(); 125 + }else if(respObj instanceof List){ 126 + for(Object item : ((List) respObj)){ 127 + if(item instanceof BaseModel) 128 + ((BaseModel) item).postprocess(); 129 + } 130 + } 131 + } 132 + 133 + void onError(String msg){ 134 + invokeErrorCallback(new MastodonErrorResponse(msg)); 135 + } 136 + 137 + void onSuccess(T resp){ 138 + invokeSuccessCallback(resp); 139 + } 140 + 141 + public enum HttpMethod{ 142 + GET, 143 + POST, 144 + PUT, 145 + DELETE 146 + } 147 + }
+29
mastodon/src/main/java/org/joinmastodon/android/api/MastodonErrorResponse.java
···
··· 1 + package org.joinmastodon.android.api; 2 + 3 + import android.content.Context; 4 + import android.view.View; 5 + import android.widget.TextView; 6 + import android.widget.Toast; 7 + 8 + import org.joinmastodon.android.R; 9 + 10 + import me.grishka.appkit.api.ErrorResponse; 11 + 12 + public class MastodonErrorResponse extends ErrorResponse{ 13 + public final String error; 14 + 15 + public MastodonErrorResponse(String error){ 16 + this.error=error; 17 + } 18 + 19 + @Override 20 + public void bindErrorView(View view){ 21 + TextView text=view.findViewById(R.id.error_text); 22 + text.setText(error); 23 + } 24 + 25 + @Override 26 + public void showToast(Context context){ 27 + Toast.makeText(context, error, Toast.LENGTH_SHORT).show(); 28 + } 29 + }
+20
mastodon/src/main/java/org/joinmastodon/android/api/ObjectValidationException.java
···
··· 1 + package org.joinmastodon.android.api; 2 + 3 + import java.io.IOException; 4 + 5 + public class ObjectValidationException extends IOException{ 6 + public ObjectValidationException(){ 7 + } 8 + 9 + public ObjectValidationException(String message){ 10 + super(message); 11 + } 12 + 13 + public ObjectValidationException(String message, Throwable cause){ 14 + super(message, cause); 15 + } 16 + 17 + public ObjectValidationException(Throwable cause){ 18 + super(cause); 19 + } 20 + }
+11
mastodon/src/main/java/org/joinmastodon/android/api/RequiredField.java
···
··· 1 + package org.joinmastodon.android.api; 2 + 3 + import java.lang.annotation.ElementType; 4 + import java.lang.annotation.Retention; 5 + import java.lang.annotation.RetentionPolicy; 6 + import java.lang.annotation.Target; 7 + 8 + @Retention(RetentionPolicy.RUNTIME) 9 + @Target(ElementType.FIELD) 10 + public @interface RequiredField{ 11 + }
+34
mastodon/src/main/java/org/joinmastodon/android/api/gson/IsoInstantTypeAdapter.java
···
··· 1 + package org.joinmastodon.android.api.gson; 2 + 3 + import com.google.gson.TypeAdapter; 4 + import com.google.gson.stream.JsonReader; 5 + import com.google.gson.stream.JsonToken; 6 + import com.google.gson.stream.JsonWriter; 7 + 8 + import java.io.IOException; 9 + import java.time.Instant; 10 + import java.time.format.DateTimeFormatter; 11 + import java.time.format.DateTimeParseException; 12 + 13 + public class IsoInstantTypeAdapter extends TypeAdapter<Instant>{ 14 + @Override 15 + public void write(JsonWriter out, Instant value) throws IOException{ 16 + if(value==null) 17 + out.nullValue(); 18 + else 19 + out.value(DateTimeFormatter.ISO_INSTANT.format(value)); 20 + } 21 + 22 + @Override 23 + public Instant read(JsonReader in) throws IOException{ 24 + if(in.peek()==JsonToken.NULL){ 25 + in.nextNull(); 26 + return null; 27 + } 28 + try{ 29 + return DateTimeFormatter.ISO_INSTANT.parse(in.nextString(), Instant::from); 30 + }catch(DateTimeParseException x){ 31 + return null; 32 + } 33 + } 34 + }
+33
mastodon/src/main/java/org/joinmastodon/android/api/gson/IsoLocalDateTypeAdapter.java
···
··· 1 + package org.joinmastodon.android.api.gson; 2 + 3 + import com.google.gson.TypeAdapter; 4 + import com.google.gson.stream.JsonReader; 5 + import com.google.gson.stream.JsonToken; 6 + import com.google.gson.stream.JsonWriter; 7 + 8 + import java.io.IOException; 9 + import java.time.LocalDate; 10 + import java.time.format.DateTimeParseException; 11 + 12 + public class IsoLocalDateTypeAdapter extends TypeAdapter<LocalDate>{ 13 + @Override 14 + public void write(JsonWriter out, LocalDate value) throws IOException{ 15 + if(value==null) 16 + out.nullValue(); 17 + else 18 + out.value(value.toString()); 19 + } 20 + 21 + @Override 22 + public LocalDate read(JsonReader in) throws IOException{ 23 + if(in.peek()==JsonToken.NULL){ 24 + in.nextNull(); 25 + return null; 26 + } 27 + try{ 28 + return LocalDate.parse(in.nextString()); 29 + }catch(DateTimeParseException x){ 30 + return null; 31 + } 32 + } 33 + }
+10
mastodon/src/main/java/org/joinmastodon/android/api/requests/GetInstance.java
···
··· 1 + package org.joinmastodon.android.api.requests; 2 + 3 + import org.joinmastodon.android.api.MastodonAPIRequest; 4 + import org.joinmastodon.android.model.Instance; 5 + 6 + public class GetInstance extends MastodonAPIRequest<Instance>{ 7 + public GetInstance(){ 8 + super(HttpMethod.GET, "/instance", Instance.class); 9 + } 10 + }
+10
mastodon/src/main/java/org/joinmastodon/android/api/requests/accounts/GetOwnAccount.java
···
··· 1 + package org.joinmastodon.android.api.requests.accounts; 2 + 3 + import org.joinmastodon.android.api.MastodonAPIRequest; 4 + import org.joinmastodon.android.model.Account; 5 + 6 + public class GetOwnAccount extends MastodonAPIRequest<Account>{ 7 + public GetOwnAccount(){ 8 + super(HttpMethod.GET, "/accounts/verify_credentials", Account.class); 9 + } 10 + }
+32
mastodon/src/main/java/org/joinmastodon/android/api/requests/catalog/GetCatalogCategories.java
···
··· 1 + package org.joinmastodon.android.api.requests.catalog; 2 + 3 + import android.net.Uri; 4 + import android.text.TextUtils; 5 + 6 + import com.google.gson.reflect.TypeToken; 7 + 8 + import org.joinmastodon.android.api.MastodonAPIRequest; 9 + import org.joinmastodon.android.model.catalog.CatalogCategory; 10 + import org.joinmastodon.android.model.catalog.CatalogInstance; 11 + 12 + import java.util.List; 13 + 14 + public class GetCatalogCategories extends MastodonAPIRequest<List<CatalogCategory>>{ 15 + private String lang; 16 + 17 + public GetCatalogCategories(String lang){ 18 + super(HttpMethod.GET, null, new TypeToken<>(){}); 19 + this.lang=lang; 20 + } 21 + 22 + @Override 23 + public Uri getURL(){ 24 + Uri.Builder builder=new Uri.Builder() 25 + .scheme("https") 26 + .authority("api.joinmastodon.org") 27 + .path("/categories"); 28 + if(!TextUtils.isEmpty(lang)) 29 + builder.appendQueryParameter("language", lang); 30 + return builder.build(); 31 + } 32 + }
+35
mastodon/src/main/java/org/joinmastodon/android/api/requests/catalog/GetCatalogInstances.java
···
··· 1 + package org.joinmastodon.android.api.requests.catalog; 2 + 3 + import android.net.Uri; 4 + import android.text.TextUtils; 5 + 6 + import com.google.gson.reflect.TypeToken; 7 + 8 + import org.joinmastodon.android.api.MastodonAPIRequest; 9 + import org.joinmastodon.android.model.catalog.CatalogInstance; 10 + 11 + import java.util.List; 12 + 13 + public class GetCatalogInstances extends MastodonAPIRequest<List<CatalogInstance>>{ 14 + 15 + private String lang, category; 16 + 17 + public GetCatalogInstances(String lang, String category){ 18 + super(HttpMethod.GET, null, new TypeToken<>(){}); 19 + this.lang=lang; 20 + this.category=category; 21 + } 22 + 23 + @Override 24 + public Uri getURL(){ 25 + Uri.Builder builder=new Uri.Builder() 26 + .scheme("https") 27 + .authority("api.joinmastodon.org") 28 + .path("/servers"); 29 + if(!TextUtils.isEmpty(lang)) 30 + builder.appendQueryParameter("language", lang); 31 + if(!TextUtils.isEmpty(category)) 32 + builder.appendQueryParameter("category", category); 33 + return builder.build(); 34 + } 35 + }
+21
mastodon/src/main/java/org/joinmastodon/android/api/requests/oauth/CreateOAuthApp.java
···
··· 1 + package org.joinmastodon.android.api.requests.oauth; 2 + 3 + import android.net.Uri; 4 + 5 + import org.joinmastodon.android.api.MastodonAPIRequest; 6 + import org.joinmastodon.android.api.session.AccountSessionManager; 7 + import org.joinmastodon.android.model.Application; 8 + 9 + public class CreateOAuthApp extends MastodonAPIRequest<Application>{ 10 + public CreateOAuthApp(){ 11 + super(HttpMethod.POST, "/apps", Application.class); 12 + setRequestBody(new Request()); 13 + } 14 + 15 + private static class Request{ 16 + public String clientName="Mastodon for Android"; 17 + public String redirectUris=AccountSessionManager.REDIRECT_URI; 18 + public String scopes=AccountSessionManager.SCOPE; 19 + public String website="https://joinmastodon.org"; 20 + } 21 + }
+32
mastodon/src/main/java/org/joinmastodon/android/api/requests/oauth/GetOauthToken.java
···
··· 1 + package org.joinmastodon.android.api.requests.oauth; 2 + 3 + import org.joinmastodon.android.api.MastodonAPIRequest; 4 + import org.joinmastodon.android.api.session.AccountSessionManager; 5 + import org.joinmastodon.android.model.Token; 6 + 7 + public class GetOauthToken extends MastodonAPIRequest<Token>{ 8 + public GetOauthToken(String clientID, String clientSecret, String code){ 9 + super(HttpMethod.POST, "/oauth/token", Token.class); 10 + setRequestBody(new Request(clientID, clientSecret, code)); 11 + } 12 + 13 + @Override 14 + protected String getPathPrefix(){ 15 + return ""; 16 + } 17 + 18 + private static class Request{ 19 + public String grantType="authorization_code"; 20 + public String clientId; 21 + public String clientSecret; 22 + public String redirectUri=AccountSessionManager.REDIRECT_URI; 23 + public String scope=AccountSessionManager.SCOPE; 24 + public String code; 25 + 26 + public Request(String clientId, String clientSecret, String code){ 27 + this.clientId=clientId; 28 + this.clientSecret=clientSecret; 29 + this.code=code; 30 + } 31 + } 32 + }
+35
mastodon/src/main/java/org/joinmastodon/android/api/session/AccountSession.java
···
··· 1 + package org.joinmastodon.android.api.session; 2 + 3 + import org.joinmastodon.android.api.MastodonAPIController; 4 + import org.joinmastodon.android.model.Account; 5 + import org.joinmastodon.android.model.Application; 6 + import org.joinmastodon.android.model.Token; 7 + 8 + public class AccountSession{ 9 + public Token token; 10 + public Account self; 11 + public String domain; 12 + public int tootCharLimit; 13 + public Application app; 14 + private transient MastodonAPIController apiController; 15 + 16 + AccountSession(Token token, Account self, Application app, String domain, int tootCharLimit){ 17 + this.token=token; 18 + this.self=self; 19 + this.domain=domain; 20 + this.app=app; 21 + this.tootCharLimit=tootCharLimit; 22 + } 23 + 24 + AccountSession(){} 25 + 26 + public String getID(){ 27 + return domain+"_"+self.id; 28 + } 29 + 30 + public MastodonAPIController getApiController(){ 31 + if(apiController==null) 32 + apiController=new MastodonAPIController(this); 33 + return apiController; 34 + } 35 + }
+186
mastodon/src/main/java/org/joinmastodon/android/api/session/AccountSessionManager.java
···
··· 1 + package org.joinmastodon.android.api.session; 2 + 3 + import android.app.ProgressDialog; 4 + import android.content.Context; 5 + import android.content.Intent; 6 + import android.content.SharedPreferences; 7 + import android.net.Uri; 8 + import android.util.Log; 9 + 10 + import com.google.gson.Gson; 11 + import com.google.gson.GsonBuilder; 12 + 13 + import org.joinmastodon.android.MastodonApp; 14 + import org.joinmastodon.android.OAuthActivity; 15 + import org.joinmastodon.android.R; 16 + import org.joinmastodon.android.api.MastodonAPIController; 17 + import org.joinmastodon.android.api.requests.oauth.CreateOAuthApp; 18 + import org.joinmastodon.android.model.Account; 19 + import org.joinmastodon.android.model.Application; 20 + import org.joinmastodon.android.model.Instance; 21 + import org.joinmastodon.android.model.Token; 22 + 23 + import java.io.File; 24 + import java.io.FileInputStream; 25 + import java.io.FileOutputStream; 26 + import java.io.IOException; 27 + import java.io.InputStreamReader; 28 + import java.io.OutputStreamWriter; 29 + import java.nio.charset.StandardCharsets; 30 + import java.util.ArrayList; 31 + import java.util.HashMap; 32 + import java.util.List; 33 + import java.util.stream.Collectors; 34 + 35 + import androidx.annotation.NonNull; 36 + import androidx.annotation.Nullable; 37 + import androidx.browser.customtabs.CustomTabsIntent; 38 + import me.grishka.appkit.api.Callback; 39 + import me.grishka.appkit.api.ErrorResponse; 40 + 41 + public class AccountSessionManager{ 42 + private static final String TAG="AccountSessionManager"; 43 + public static final String SCOPE="read write follow push"; 44 + public static final String REDIRECT_URI="mastodon-android-auth://callback"; 45 + 46 + private static final AccountSessionManager instance=new AccountSessionManager(); 47 + 48 + private HashMap<String, AccountSession> sessions=new HashMap<>(); 49 + private MastodonAPIController unauthenticatedApiController=new MastodonAPIController(null); 50 + private Instance authenticatingInstance; 51 + private Application authenticatingApp; 52 + private String lastActiveAccountID; 53 + private SharedPreferences prefs; 54 + 55 + public static AccountSessionManager getInstance(){ 56 + return instance; 57 + } 58 + 59 + private AccountSessionManager(){ 60 + prefs=MastodonApp.context.getSharedPreferences("account_manager", Context.MODE_PRIVATE); 61 + File file=new File(MastodonApp.context.getFilesDir(), "accounts.json"); 62 + if(!file.exists()) 63 + return; 64 + try(FileInputStream in=new FileInputStream(file)){ 65 + SessionsStorageWrapper w=MastodonAPIController.gson.fromJson(new InputStreamReader(in, StandardCharsets.UTF_8), SessionsStorageWrapper.class); 66 + for(AccountSession session:w.accounts){ 67 + sessions.put(session.getID(), session); 68 + } 69 + }catch(IOException x){ 70 + Log.e(TAG, "Error loading accounts", x); 71 + } 72 + lastActiveAccountID=prefs.getString("lastActiveAccount", null); 73 + } 74 + 75 + public void addAccount(Instance instance, Token token, Account self, Application app){ 76 + AccountSession session=new AccountSession(token, self, app, instance.uri, instance.maxTootChars); 77 + sessions.put(session.getID(), session); 78 + lastActiveAccountID=session.getID(); 79 + writeAccountsFile(); 80 + } 81 + 82 + private void writeAccountsFile(){ 83 + File file=new File(MastodonApp.context.getFilesDir(), "accounts.json"); 84 + try{ 85 + try(FileOutputStream out=new FileOutputStream(file)){ 86 + SessionsStorageWrapper w=new SessionsStorageWrapper(); 87 + w.accounts=new ArrayList<>(sessions.values()); 88 + OutputStreamWriter writer=new OutputStreamWriter(out, StandardCharsets.UTF_8); 89 + MastodonAPIController.gson.toJson(w, writer); 90 + writer.flush(); 91 + } 92 + }catch(IOException x){ 93 + Log.e(TAG, "Error writing accounts file", x); 94 + } 95 + prefs.edit().putString("lastActiveAccount", lastActiveAccountID).apply(); 96 + } 97 + 98 + @NonNull 99 + public List<AccountSession> getLoggedInAccounts(){ 100 + return new ArrayList<>(sessions.values()); 101 + } 102 + 103 + @NonNull 104 + public AccountSession getAccount(String id){ 105 + AccountSession session=sessions.get(id); 106 + if(session==null) 107 + throw new IllegalStateException("Account session "+id+" not found"); 108 + return session; 109 + } 110 + 111 + @Nullable 112 + public AccountSession getLastActiveAccount(){ 113 + if(sessions.isEmpty() || lastActiveAccountID==null) 114 + return null; 115 + return getAccount(lastActiveAccountID); 116 + } 117 + 118 + public String getLastActiveAccountID(){ 119 + return lastActiveAccountID; 120 + } 121 + 122 + public void removeAccount(String id){ 123 + AccountSession session=getAccount(id); 124 + sessions.remove(id); 125 + if(lastActiveAccountID.equals(id)){ 126 + if(sessions.isEmpty()) 127 + lastActiveAccountID=null; 128 + else 129 + lastActiveAccountID=getLoggedInAccounts().get(0).getID(); 130 + } 131 + writeAccountsFile(); 132 + } 133 + 134 + @NonNull 135 + public MastodonAPIController getUnauthenticatedApiController(){ 136 + return unauthenticatedApiController; 137 + } 138 + 139 + public void authenticate(Context context, Instance instance){ 140 + authenticatingInstance=instance; 141 + ProgressDialog progress=new ProgressDialog(context); 142 + progress.setMessage(context.getString(R.string.preparing_auth)); 143 + progress.setCancelable(false); 144 + progress.show(); 145 + new CreateOAuthApp() 146 + .setCallback(new Callback<Application>(){ 147 + @Override 148 + public void onSuccess(Application result){ 149 + authenticatingApp=result; 150 + progress.dismiss(); 151 + Uri uri=new Uri.Builder() 152 + .scheme("https") 153 + .authority(instance.uri) 154 + .path("/oauth/authorize") 155 + .appendQueryParameter("response_type", "code") 156 + .appendQueryParameter("client_id", result.clientId) 157 + .appendQueryParameter("redirect_uri", "mastodon-android-auth://callback") 158 + .appendQueryParameter("scope", "read write follow push") 159 + .build(); 160 + 161 + new CustomTabsIntent.Builder() 162 + .build() 163 + .launchUrl(context, uri); 164 + } 165 + 166 + @Override 167 + public void onError(ErrorResponse error){ 168 + error.showToast(context); 169 + progress.dismiss(); 170 + } 171 + }) 172 + .execNoAuth(instance.uri); 173 + } 174 + 175 + public Instance getAuthenticatingInstance(){ 176 + return authenticatingInstance; 177 + } 178 + 179 + public Application getAuthenticatingApp(){ 180 + return authenticatingApp; 181 + } 182 + 183 + private static class SessionsStorageWrapper{ 184 + public List<AccountSession> accounts; 185 + } 186 + }
+18
mastodon/src/main/java/org/joinmastodon/android/fragments/HomeFragment.java
···
··· 1 + package org.joinmastodon.android.fragments; 2 + 3 + import android.os.Bundle; 4 + import android.view.LayoutInflater; 5 + import android.view.View; 6 + import android.view.ViewGroup; 7 + 8 + import androidx.annotation.Nullable; 9 + import me.grishka.appkit.fragments.AppKitFragment; 10 + import me.grishka.appkit.fragments.ToolbarFragment; 11 + 12 + public class HomeFragment extends ToolbarFragment{ 13 + @Nullable 14 + @Override 15 + public View onCreateContentView(LayoutInflater inflater, @Nullable ViewGroup container, Bundle savedInstanceState){ 16 + return new View(getActivity()); 17 + } 18 + }
+42
mastodon/src/main/java/org/joinmastodon/android/fragments/SplashFragment.java
···
··· 1 + package org.joinmastodon.android.fragments; 2 + 3 + import android.app.Fragment; 4 + import android.os.Bundle; 5 + import android.view.LayoutInflater; 6 + import android.view.View; 7 + import android.view.ViewGroup; 8 + import android.view.WindowInsets; 9 + 10 + import org.joinmastodon.android.R; 11 + import org.joinmastodon.android.fragments.onboarding.InstanceCatalogFragment; 12 + 13 + import androidx.annotation.Nullable; 14 + import me.grishka.appkit.Nav; 15 + import me.grishka.appkit.fragments.AppKitFragment; 16 + import me.grishka.appkit.views.FragmentRootLinearLayout; 17 + 18 + public class SplashFragment extends AppKitFragment{ 19 + 20 + private View contentView; 21 + 22 + @Nullable 23 + @Override 24 + public View onCreateView(LayoutInflater inflater, @Nullable ViewGroup container, Bundle savedInstanceState){ 25 + contentView= inflater.inflate(R.layout.fragment_splash, container, false); 26 + contentView.findViewById(R.id.btn_get_started).setOnClickListener(this::onButtonClick); 27 + contentView.findViewById(R.id.btn_log_in).setOnClickListener(this::onButtonClick); 28 + return contentView; 29 + } 30 + 31 + private void onButtonClick(View v){ 32 + Bundle extras=new Bundle(); 33 + extras.putBoolean("signup", v.getId()==R.id.btn_get_started); 34 + Nav.go(getActivity(), InstanceCatalogFragment.class, extras); 35 + } 36 + // 37 + // @Override 38 + // public void onApplyWindowInsets(WindowInsets insets){ 39 + // if(contentView!=null) 40 + // contentView.dispatchApplyWindowInsets(insets); 41 + // } 42 + }
+492
mastodon/src/main/java/org/joinmastodon/android/fragments/onboarding/InstanceCatalogFragment.java
···
··· 1 + package org.joinmastodon.android.fragments.onboarding; 2 + 3 + import android.app.AlertDialog; 4 + import android.app.ProgressDialog; 5 + import android.content.Context; 6 + import android.content.DialogInterface; 7 + import android.os.Build; 8 + import android.os.Bundle; 9 + import android.os.LocaleList; 10 + import android.text.Editable; 11 + import android.text.TextUtils; 12 + import android.text.TextWatcher; 13 + import android.view.KeyEvent; 14 + import android.view.View; 15 + import android.view.ViewGroup; 16 + import android.widget.Button; 17 + import android.widget.EditText; 18 + import android.widget.RadioButton; 19 + import android.widget.TextView; 20 + import android.widget.Toast; 21 + 22 + import org.joinmastodon.android.R; 23 + import org.joinmastodon.android.api.MastodonAPIRequest; 24 + import org.joinmastodon.android.api.MastodonErrorResponse; 25 + import org.joinmastodon.android.api.requests.GetInstance; 26 + import org.joinmastodon.android.api.requests.catalog.GetCatalogCategories; 27 + import org.joinmastodon.android.api.requests.catalog.GetCatalogInstances; 28 + import org.joinmastodon.android.api.session.AccountSessionManager; 29 + import org.joinmastodon.android.model.Instance; 30 + import org.joinmastodon.android.model.catalog.CatalogCategory; 31 + import org.joinmastodon.android.model.catalog.CatalogInstance; 32 + 33 + import java.net.IDN; 34 + import java.util.ArrayList; 35 + import java.util.Collections; 36 + import java.util.Comparator; 37 + import java.util.HashMap; 38 + import java.util.List; 39 + import java.util.Map; 40 + import java.util.stream.Collectors; 41 + 42 + import androidx.annotation.NonNull; 43 + import androidx.recyclerview.widget.DiffUtil; 44 + import androidx.recyclerview.widget.LinearLayoutManager; 45 + import androidx.recyclerview.widget.RecyclerView; 46 + import me.grishka.appkit.api.Callback; 47 + import me.grishka.appkit.api.ErrorResponse; 48 + import me.grishka.appkit.fragments.BaseRecyclerFragment; 49 + import me.grishka.appkit.utils.BindableViewHolder; 50 + import me.grishka.appkit.utils.MergeRecyclerAdapter; 51 + import me.grishka.appkit.utils.SingleViewRecyclerAdapter; 52 + import me.grishka.appkit.views.UsableRecyclerView; 53 + 54 + public class InstanceCatalogFragment extends BaseRecyclerFragment<CatalogInstance>{ 55 + private InstancesAdapter adapter; 56 + private MergeRecyclerAdapter mergeAdapter; 57 + private View headerView; 58 + private CatalogInstance chosenInstance; 59 + private List<CatalogInstance> filteredData=new ArrayList<>(); 60 + private Button nextButton; 61 + private MastodonAPIRequest<?> getCategoriesRequest; 62 + private EditText searchEdit; 63 + private UsableRecyclerView categoriesList; 64 + private Runnable searchDebouncer=this::onSearchChangedDebounced; 65 + private String currentSearchQuery; 66 + private String currentCategory="all"; 67 + private List<CatalogCategory> categories=new ArrayList<>(); 68 + private String loadingInstanceDomain; 69 + private GetInstance loadingInstanceRequest; 70 + private HashMap<String, Instance> instancesCache=new HashMap<>(); 71 + private ProgressDialog instanceProgressDialog; 72 + 73 + private boolean isSignup; 74 + 75 + public InstanceCatalogFragment(){ 76 + super(R.layout.fragment_onboarding_common, 10); 77 + } 78 + 79 + @Override 80 + public void onCreate(Bundle savedInstanceState){ 81 + super.onCreate(savedInstanceState); 82 + isSignup=getArguments().getBoolean("signup"); 83 + } 84 + 85 + @Override 86 + public void onAttach(Context context){ 87 + super.onAttach(context); 88 + setRefreshEnabled(false); 89 + loadData(); 90 + } 91 + 92 + @Override 93 + protected void doLoadData(int offset, int count){ 94 + currentRequest=new GetCatalogInstances(null, null) 95 + .setCallback(new Callback<>(){ 96 + @Override 97 + public void onSuccess(List<CatalogInstance> result){ 98 + Map<String, List<CatalogInstance>> byLang=result.stream().collect(Collectors.groupingBy(ci->ci.language)); 99 + // get the list of user-configured system languages 100 + List<String> userLangs; 101 + if(Build.VERSION.SDK_INT<24){ 102 + userLangs=Collections.singletonList(getResources().getConfiguration().locale.getLanguage()); 103 + }else{ 104 + LocaleList ll=getResources().getConfiguration().getLocales(); 105 + userLangs=new ArrayList<>(ll.size()); 106 + for(int i=0;i<ll.size();i++){ 107 + userLangs.add(ll.get(i).getLanguage()); 108 + } 109 + } 110 + // add instances in preferred languages to the top of the list, in the order of preference 111 + ArrayList<CatalogInstance> sortedList=new ArrayList<>(); 112 + for(String lang:userLangs){ 113 + List<CatalogInstance> langInstances=byLang.remove(lang); 114 + if(langInstances!=null){ 115 + sortedList.addAll(langInstances); 116 + } 117 + } 118 + // sort the remaining language groups by aggregate lastWeekUsers 119 + class InstanceGroup{ 120 + public int activeUsers; 121 + public List<CatalogInstance> instances; 122 + } 123 + byLang.values().stream().map(il->{ 124 + InstanceGroup group=new InstanceGroup(); 125 + group.instances=il; 126 + for(CatalogInstance instance:il){ 127 + group.activeUsers+=instance.lastWeekUsers; 128 + } 129 + return group; 130 + }).sorted(Comparator.comparingInt(g->g.activeUsers)).forEachOrdered(ig->sortedList.addAll(ig.instances)); 131 + onDataLoaded(sortedList, false); 132 + updateFilteredList(); 133 + } 134 + 135 + @Override 136 + public void onError(ErrorResponse error){ 137 + InstanceCatalogFragment.this.onError(error); 138 + } 139 + }) 140 + .execNoAuth(""); 141 + getCategoriesRequest=new GetCatalogCategories(null) 142 + .setCallback(new Callback<>(){ 143 + @Override 144 + public void onSuccess(List<CatalogCategory> result){ 145 + getCategoriesRequest=null; 146 + CatalogCategory all=new CatalogCategory(); 147 + all.category="all"; 148 + categories.add(all); 149 + categories.addAll(result); 150 + categoriesList.getAdapter().notifyItemRangeInserted(0, categories.size()); 151 + } 152 + 153 + @Override 154 + public void onError(ErrorResponse error){ 155 + getCategoriesRequest=null; 156 + error.showToast(getActivity()); 157 + } 158 + }) 159 + .execNoAuth(""); 160 + } 161 + 162 + @Override 163 + public void onDestroy(){ 164 + super.onDestroy(); 165 + if(getCategoriesRequest!=null) 166 + getCategoriesRequest.cancel(); 167 + } 168 + 169 + @Override 170 + protected RecyclerView.Adapter getAdapter(){ 171 + headerView=getActivity().getLayoutInflater().inflate(R.layout.header_onboarding_instance_catalog, list, false); 172 + searchEdit=headerView.findViewById(R.id.search_edit); 173 + categoriesList=headerView.findViewById(R.id.categories_list); 174 + searchEdit.setOnEditorActionListener(this::onSearchEnterPressed); 175 + searchEdit.addTextChangedListener(new TextWatcher(){ 176 + @Override 177 + public void beforeTextChanged(CharSequence s, int start, int count, int after){ 178 + 179 + } 180 + 181 + @Override 182 + public void onTextChanged(CharSequence s, int start, int before, int count){ 183 + searchEdit.removeCallbacks(searchDebouncer); 184 + searchEdit.postDelayed(searchDebouncer, 300); 185 + } 186 + 187 + @Override 188 + public void afterTextChanged(Editable s){ 189 + } 190 + }); 191 + categoriesList.setAdapter(new CategoriesAdapter()); 192 + categoriesList.setLayoutManager(new LinearLayoutManager(getActivity(), LinearLayoutManager.HORIZONTAL, false)); 193 + 194 + mergeAdapter=new MergeRecyclerAdapter(); 195 + mergeAdapter.addAdapter(new SingleViewRecyclerAdapter(headerView)); 196 + mergeAdapter.addAdapter(adapter=new InstancesAdapter()); 197 + return mergeAdapter; 198 + } 199 + 200 + @Override 201 + public void onViewCreated(View view, Bundle savedInstanceState){ 202 + super.onViewCreated(view, savedInstanceState); 203 + nextButton=view.findViewById(R.id.btn_next); 204 + nextButton.setOnClickListener(this::onNextClick); 205 + } 206 + 207 + private void onNextClick(View v){ 208 + String domain=chosenInstance.domain; 209 + Instance instance=instancesCache.get(domain); 210 + if(instance!=null){ 211 + proceedWithAuthOrSignup(instance); 212 + }else{ 213 + showProgressDialog(); 214 + if(!domain.equals(loadingInstanceDomain)){ 215 + loadInstanceInfo(domain); 216 + } 217 + } 218 + } 219 + 220 + private void proceedWithAuthOrSignup(Instance instance){ 221 + if(isSignup){ 222 + Toast.makeText(getActivity(), "not implemented yet", Toast.LENGTH_SHORT).show(); 223 + }else{ 224 + AccountSessionManager.getInstance().authenticate(getActivity(), instance); 225 + } 226 + } 227 + 228 + private boolean onSearchEnterPressed(TextView v, int actionId, KeyEvent event){ 229 + if(event!=null && event.getAction()!=KeyEvent.ACTION_DOWN) 230 + return true; 231 + searchEdit.removeCallbacks(searchDebouncer); 232 + Instance instance=instancesCache.get(currentSearchQuery); 233 + if(instance==null){ 234 + showProgressDialog(); 235 + loadInstanceInfo(currentSearchQuery); 236 + }else{ 237 + proceedWithAuthOrSignup(instance); 238 + } 239 + return true; 240 + } 241 + 242 + private void onSearchChangedDebounced(){ 243 + currentSearchQuery=searchEdit.getText().toString().toLowerCase(); 244 + updateFilteredList(); 245 + loadInstanceInfo(currentSearchQuery); 246 + } 247 + 248 + private void updateFilteredList(){ 249 + ArrayList<CatalogInstance> prevData=new ArrayList<>(filteredData); 250 + filteredData.clear(); 251 + for(CatalogInstance instance:data){ 252 + if(currentCategory.equals("all") || instance.categories.contains(currentCategory)){ 253 + if(TextUtils.isEmpty(currentSearchQuery) || instance.domain.contains(currentSearchQuery)){ 254 + if(instance.domain.equals(currentSearchQuery) || !instance.approvalRequired) 255 + filteredData.add(instance); 256 + } 257 + } 258 + } 259 + DiffUtil.calculateDiff(new DiffUtil.Callback(){ 260 + @Override 261 + public int getOldListSize(){ 262 + return prevData.size(); 263 + } 264 + 265 + @Override 266 + public int getNewListSize(){ 267 + return filteredData.size(); 268 + } 269 + 270 + @Override 271 + public boolean areItemsTheSame(int oldItemPosition, int newItemPosition){ 272 + return prevData.get(oldItemPosition)==filteredData.get(newItemPosition); 273 + } 274 + 275 + @Override 276 + public boolean areContentsTheSame(int oldItemPosition, int newItemPosition){ 277 + return prevData.get(oldItemPosition)==filteredData.get(newItemPosition); 278 + } 279 + }).dispatchUpdatesTo(adapter); 280 + } 281 + 282 + private void showProgressDialog(){ 283 + instanceProgressDialog=new ProgressDialog(getActivity()); 284 + instanceProgressDialog.setMessage(getString(R.string.loading_instance)); 285 + instanceProgressDialog.setOnCancelListener(dialog->{ 286 + loadingInstanceRequest.cancel(); 287 + loadingInstanceRequest=null; 288 + loadingInstanceDomain=null; 289 + }); 290 + instanceProgressDialog.show(); 291 + } 292 + 293 + private void loadInstanceInfo(String _domain){ 294 + String domain; 295 + try{ 296 + domain=IDN.toASCII(_domain); 297 + }catch(IllegalArgumentException x){ 298 + return; 299 + } 300 + Instance cachedInstance=instancesCache.get(domain); 301 + if(cachedInstance!=null){ 302 + boolean found=false; 303 + for(CatalogInstance ci:filteredData){ 304 + if(ci.domain.equals(currentSearchQuery)){ 305 + found=true; 306 + break; 307 + } 308 + } 309 + if(!found){ 310 + CatalogInstance ci=cachedInstance.toCatalogInstance(); 311 + filteredData.add(0, ci); 312 + adapter.notifyItemInserted(0); 313 + } 314 + return; 315 + } 316 + if(loadingInstanceDomain!=null){ 317 + if(loadingInstanceDomain.equals(domain)) 318 + return; 319 + else 320 + loadingInstanceRequest.cancel(); 321 + } 322 + loadingInstanceDomain=domain; 323 + loadingInstanceRequest=new GetInstance(); 324 + loadingInstanceRequest.setCallback(new Callback<>(){ 325 + @Override 326 + public void onSuccess(Instance result){ 327 + loadingInstanceRequest=null; 328 + loadingInstanceDomain=null; 329 + result.uri=domain; // needed for instances that use domain redirection 330 + instancesCache.put(domain, result); 331 + if(instanceProgressDialog!=null){ 332 + instanceProgressDialog.dismiss(); 333 + instanceProgressDialog=null; 334 + proceedWithAuthOrSignup(result); 335 + } 336 + if(domain.equals(currentSearchQuery)){ 337 + boolean found=false; 338 + for(CatalogInstance ci:filteredData){ 339 + if(ci.domain.equals(currentSearchQuery)){ 340 + found=true; 341 + break; 342 + } 343 + } 344 + if(!found){ 345 + CatalogInstance ci=result.toCatalogInstance(); 346 + filteredData.add(0, ci); 347 + adapter.notifyItemInserted(0); 348 + } 349 + } 350 + } 351 + 352 + @Override 353 + public void onError(ErrorResponse error){ 354 + loadingInstanceRequest=null; 355 + loadingInstanceDomain=null; 356 + if(instanceProgressDialog!=null){ 357 + instanceProgressDialog.dismiss(); 358 + instanceProgressDialog=null; 359 + new AlertDialog.Builder(getActivity()) 360 + .setTitle(R.string.error) 361 + .setMessage(getString(R.string.not_a_mastodon_instance, domain)+"\n\n"+((MastodonErrorResponse)error).error) 362 + .setPositiveButton(R.string.ok, null) 363 + .show(); 364 + } 365 + } 366 + }).execNoAuth(domain); 367 + } 368 + 369 + private class InstancesAdapter extends UsableRecyclerView.Adapter<InstanceViewHolder>{ 370 + public InstancesAdapter(){ 371 + super(imgLoader); 372 + } 373 + 374 + @NonNull 375 + @Override 376 + public InstanceViewHolder onCreateViewHolder(@NonNull ViewGroup parent, int viewType){ 377 + return new InstanceViewHolder(); 378 + } 379 + 380 + @Override 381 + public void onBindViewHolder(InstanceViewHolder holder, int position){ 382 + holder.bind(filteredData.get(position)); 383 + super.onBindViewHolder(holder, position); 384 + } 385 + 386 + @Override 387 + public int getItemCount(){ 388 + return filteredData.size(); 389 + } 390 + 391 + @Override 392 + public int getItemViewType(int position){ 393 + return 1; 394 + } 395 + } 396 + 397 + private class InstanceViewHolder extends BindableViewHolder<CatalogInstance> implements UsableRecyclerView.Clickable{ 398 + private final TextView title, description, userCount, lang; 399 + private final RadioButton radioButton; 400 + 401 + public InstanceViewHolder(){ 402 + super(getActivity(), R.layout.item_instance_catalog, list); 403 + title=findViewById(R.id.title); 404 + description=findViewById(R.id.description); 405 + userCount=findViewById(R.id.user_count); 406 + lang=findViewById(R.id.lang); 407 + radioButton=findViewById(R.id.radiobtn); 408 + } 409 + 410 + @Override 411 + public void onBind(CatalogInstance item){ 412 + title.setText(item.normalizedDomain); 413 + description.setText(item.description); 414 + userCount.setText(""+item.totalUsers); 415 + lang.setText(item.language.toUpperCase()); 416 + radioButton.setChecked(chosenInstance==item); 417 + } 418 + 419 + @Override 420 + public void onClick(){ 421 + if(chosenInstance==item) 422 + return; 423 + if(chosenInstance!=null){ 424 + int idx=filteredData.indexOf(chosenInstance); 425 + if(idx!=-1){ 426 + RecyclerView.ViewHolder holder=list.findViewHolderForAdapterPosition(mergeAdapter.getPositionForAdapter(adapter)+idx); 427 + if(holder instanceof InstanceViewHolder){ 428 + ((InstanceViewHolder)holder).radioButton.setChecked(false); 429 + } 430 + } 431 + } 432 + radioButton.setChecked(true); 433 + if(chosenInstance==null) 434 + nextButton.setEnabled(true); 435 + chosenInstance=item; 436 + loadInstanceInfo(chosenInstance.domain); 437 + } 438 + } 439 + 440 + private class CategoriesAdapter extends RecyclerView.Adapter<CategoryViewHolder>{ 441 + @NonNull 442 + @Override 443 + public CategoryViewHolder onCreateViewHolder(@NonNull ViewGroup parent, int viewType){ 444 + return new CategoryViewHolder(); 445 + } 446 + 447 + @Override 448 + public void onBindViewHolder(@NonNull CategoryViewHolder holder, int position){ 449 + holder.bind(categories.get(position)); 450 + } 451 + 452 + @Override 453 + public int getItemCount(){ 454 + return categories.size(); 455 + } 456 + } 457 + 458 + private class CategoryViewHolder extends BindableViewHolder<CatalogCategory> implements UsableRecyclerView.Clickable{ 459 + private final RadioButton radioButton; 460 + 461 + public CategoryViewHolder(){ 462 + super(getActivity(), R.layout.item_instance_category, categoriesList); 463 + radioButton=findViewById(R.id.radiobtn); 464 + } 465 + 466 + @Override 467 + public void onBind(CatalogCategory item){ 468 + radioButton.setText(item.category); 469 + radioButton.setChecked(item.category.equals(currentCategory)); 470 + } 471 + 472 + @Override 473 + public void onClick(){ 474 + if(currentCategory.equals(item.category)) 475 + return; 476 + int i=0; 477 + for(CatalogCategory c:categories){ 478 + if(c.category.equals(currentCategory)){ 479 + RecyclerView.ViewHolder holder=categoriesList.findViewHolderForAdapterPosition(i); 480 + if(holder!=null){ 481 + ((CategoryViewHolder)holder).radioButton.setChecked(false); 482 + } 483 + break; 484 + } 485 + i++; 486 + } 487 + currentCategory=item.category; 488 + radioButton.setChecked(true); 489 + updateFilteredList(); 490 + } 491 + } 492 + }
+177
mastodon/src/main/java/org/joinmastodon/android/model/Account.java
···
··· 1 + package org.joinmastodon.android.model; 2 + 3 + import org.joinmastodon.android.api.ObjectValidationException; 4 + import org.joinmastodon.android.api.RequiredField; 5 + 6 + import java.time.Instant; 7 + import java.time.LocalDate; 8 + import java.util.List; 9 + 10 + /** 11 + * Represents a user of Mastodon and their associated profile. 12 + */ 13 + public class Account extends BaseModel{ 14 + // Base attributes 15 + 16 + /** 17 + * The account id 18 + */ 19 + @RequiredField 20 + public String id; 21 + /** 22 + * The username of the account, not including domain. 23 + */ 24 + @RequiredField 25 + public String username; 26 + /** 27 + * The Webfinger account URI. Equal to username for local users, or username@domain for remote users. 28 + */ 29 + @RequiredField 30 + public String acct; 31 + /** 32 + * The location of the user's profile page. 33 + */ 34 + @RequiredField 35 + public String url; 36 + 37 + // Display attributes 38 + 39 + /** 40 + * The profile's display name. 41 + */ 42 + @RequiredField 43 + public String displayName; 44 + /** 45 + * The profile's bio / description. 46 + */ 47 + @RequiredField 48 + public String note; 49 + /** 50 + * An image icon that is shown next to statuses and in the profile. 51 + */ 52 + @RequiredField 53 + public String avatar; 54 + /** 55 + * A static version of the avatar. Equal to avatar if its value is a static image; different if avatar is an animated GIF. 56 + */ 57 + public String avatarStatic; 58 + /** 59 + * An image banner that is shown above the profile and in profile cards. 60 + */ 61 + @RequiredField 62 + public String header; 63 + /** 64 + * A static version of the header. Equal to header if its value is a static image; different if header is an animated GIF. 65 + */ 66 + public String headerStatic; 67 + /** 68 + * Whether the account manually approves follow requests. 69 + */ 70 + public boolean locked; 71 + /** 72 + * Custom emoji entities to be used when rendering the profile. If none, an empty array will be returned. 73 + */ 74 + public List<Emoji> emojis; 75 + /** 76 + * Whether the account has opted into discovery features such as the profile directory. 77 + */ 78 + public boolean discoverable; 79 + 80 + // Statistical attributes 81 + 82 + /** 83 + * When the account was created. 84 + */ 85 + @RequiredField 86 + public Instant createdAt; 87 + /** 88 + * When the most recent status was posted. 89 + */ 90 + // @RequiredField 91 + public LocalDate lastStatusAt; 92 + /** 93 + * How many statuses are attached to this account. 94 + */ 95 + public int statusesCount; 96 + /** 97 + * The reported followers of this profile. 98 + */ 99 + public int followersCount; 100 + /** 101 + * The reported follows of this profile. 102 + */ 103 + public int followingCount; 104 + 105 + // Optional attributes 106 + 107 + /** 108 + * Indicates that the profile is currently inactive and that its user has moved to a new account. 109 + */ 110 + public Account moved; 111 + /** 112 + * Additional metadata attached to a profile as name-value pairs. 113 + */ 114 + public List<AccountField> fields; 115 + /** 116 + * A presentational flag. Indicates that the account may perform automated actions, may not be monitored, or identifies as a robot. 117 + */ 118 + public boolean bot; 119 + /** 120 + * An extra entity to be used with API methods to verify credentials and update credentials. 121 + */ 122 + public Source source; 123 + /** 124 + * An extra entity returned when an account is suspended. 125 + */ 126 + public boolean suspended; 127 + /** 128 + * When a timed mute will expire, if applicable. 129 + */ 130 + public Instant muteExpiresAt; 131 + 132 + 133 + @Override 134 + public void postprocess() throws ObjectValidationException{ 135 + super.postprocess(); 136 + if(fields!=null){ 137 + for(AccountField f:fields) 138 + f.postprocess(); 139 + } 140 + if(emojis!=null){ 141 + for(Emoji e:emojis) 142 + e.postprocess(); 143 + } 144 + if(moved!=null) 145 + moved.postprocess(); 146 + } 147 + 148 + @Override 149 + public String toString(){ 150 + return "Account{"+ 151 + "id='"+id+'\''+ 152 + ", username='"+username+'\''+ 153 + ", acct='"+acct+'\''+ 154 + ", url='"+url+'\''+ 155 + ", displayName='"+displayName+'\''+ 156 + ", note='"+note+'\''+ 157 + ", avatar='"+avatar+'\''+ 158 + ", avatarStatic='"+avatarStatic+'\''+ 159 + ", header='"+header+'\''+ 160 + ", headerStatic='"+headerStatic+'\''+ 161 + ", locked="+locked+ 162 + ", emojis="+emojis+ 163 + ", discoverable="+discoverable+ 164 + ", createdAt="+createdAt+ 165 + ", lastStatusAt="+lastStatusAt+ 166 + ", statusesCount="+statusesCount+ 167 + ", followersCount="+followersCount+ 168 + ", followingCount="+followingCount+ 169 + ", moved="+moved+ 170 + ", fields="+fields+ 171 + ", bot="+bot+ 172 + ", source="+source+ 173 + ", suspended="+suspended+ 174 + ", muteExpiresAt="+muteExpiresAt+ 175 + '}'; 176 + } 177 + }
+25
mastodon/src/main/java/org/joinmastodon/android/model/AccountField.java
···
··· 1 + package org.joinmastodon.android.model; 2 + 3 + import org.joinmastodon.android.api.RequiredField; 4 + 5 + import java.time.Instant; 6 + 7 + /** 8 + * Represents a profile field as a name-value pair with optional verification. 9 + */ 10 + public class AccountField extends BaseModel{ 11 + /** 12 + * The key of a given field's key-value pair. 13 + */ 14 + @RequiredField 15 + public String name; 16 + /** 17 + * The value associated with the name key. 18 + */ 19 + @RequiredField 20 + public String value; 21 + /** 22 + * Timestamp of when the server verified a URL value for a rel="me” link. 23 + */ 24 + public Instant verifiedAt; 25 + }
+23
mastodon/src/main/java/org/joinmastodon/android/model/Application.java
···
··· 1 + package org.joinmastodon.android.model; 2 + 3 + import org.joinmastodon.android.api.RequiredField; 4 + 5 + public class Application extends BaseModel{ 6 + @RequiredField 7 + public String name; 8 + public String website; 9 + public String vapidKey; 10 + public String clientId; 11 + public String clientSecret; 12 + 13 + @Override 14 + public String toString(){ 15 + return "Application{"+ 16 + "name='"+name+'\''+ 17 + ", website='"+website+'\''+ 18 + ", vapidKey='"+vapidKey+'\''+ 19 + ", clientId='"+clientId+'\''+ 20 + ", clientSecret='"+clientSecret+'\''+ 21 + '}'; 22 + } 23 + }
+26
mastodon/src/main/java/org/joinmastodon/android/model/BaseModel.java
···
··· 1 + package org.joinmastodon.android.model; 2 + 3 + import org.joinmastodon.android.api.AllFieldsAreRequired; 4 + import org.joinmastodon.android.api.ObjectValidationException; 5 + import org.joinmastodon.android.api.RequiredField; 6 + 7 + import java.lang.reflect.Field; 8 + import java.lang.reflect.Modifier; 9 + 10 + import androidx.annotation.CallSuper; 11 + 12 + public abstract class BaseModel{ 13 + @CallSuper 14 + public void postprocess() throws ObjectValidationException{ 15 + try{ 16 + boolean allRequired=getClass().isAnnotationPresent(AllFieldsAreRequired.class); 17 + for(Field fld:getClass().getFields()){ 18 + if(!fld.getType().isPrimitive() && !Modifier.isTransient(fld.getModifiers()) && (allRequired || fld.isAnnotationPresent(RequiredField.class))){ 19 + if(fld.get(this)==null){ 20 + throw new ObjectValidationException("Required field '"+fld.getName()+"' of type "+fld.getType().getSimpleName()+" was null in "+getClass().getSimpleName()); 21 + } 22 + } 23 + } 24 + }catch(IllegalAccessException ignore){} 25 + } 26 + }
+33
mastodon/src/main/java/org/joinmastodon/android/model/Emoji.java
···
··· 1 + package org.joinmastodon.android.model; 2 + 3 + import org.joinmastodon.android.api.RequiredField; 4 + 5 + /** 6 + * Represents a custom emoji. 7 + */ 8 + public class Emoji extends BaseModel{ 9 + /** 10 + * The name of the custom emoji. 11 + */ 12 + @RequiredField 13 + public String shortcode; 14 + /** 15 + * A link to the custom emoji. 16 + */ 17 + @RequiredField 18 + public String url; 19 + /** 20 + * A link to a static copy of the custom emoji. 21 + */ 22 + @RequiredField 23 + public String staticUrl; 24 + /** 25 + * Whether this Emoji should be visible in the picker or unlisted. 26 + */ 27 + @RequiredField 28 + public boolean visibleInPicker; 29 + /** 30 + * Used for sorting custom emoji in the picker. 31 + */ 32 + public String category; 33 + }
+215
mastodon/src/main/java/org/joinmastodon/android/model/Instance.java
···
··· 1 + package org.joinmastodon.android.model; 2 + 3 + import android.os.Parcel; 4 + import android.os.Parcelable; 5 + import android.text.Html; 6 + 7 + import org.joinmastodon.android.api.ObjectValidationException; 8 + import org.joinmastodon.android.api.RequiredField; 9 + import org.joinmastodon.android.model.catalog.CatalogInstance; 10 + 11 + import java.net.IDN; 12 + import java.util.Collections; 13 + import java.util.List; 14 + import java.util.Map; 15 + 16 + public class Instance extends BaseModel{ 17 + /** 18 + * The domain name of the instance. 19 + */ 20 + @RequiredField 21 + public String uri; 22 + /** 23 + * The title of the website. 24 + */ 25 + @RequiredField 26 + public String title; 27 + /** 28 + * Admin-defined description of the Mastodon site. 29 + */ 30 + @RequiredField 31 + public String description; 32 + /** 33 + * A shorter description defined by the admin. 34 + */ 35 + @RequiredField 36 + public String shortDescription; 37 + /** 38 + * An email that may be contacted for any inquiries. 39 + */ 40 + @RequiredField 41 + public String email; 42 + /** 43 + * The version of Mastodon installed on the instance. 44 + */ 45 + @RequiredField 46 + public String version; 47 + /** 48 + * Primary langauges of the website and its staff. 49 + */ 50 + // @RequiredField 51 + public List<String> languages; 52 + /** 53 + * Whether registrations are enabled. 54 + */ 55 + public boolean registrations; 56 + /** 57 + * Whether registrations require moderator approval. 58 + */ 59 + public boolean approvalRequired; 60 + /** 61 + * Whether invites are enabled. 62 + */ 63 + public boolean invitesEnabled; 64 + /** 65 + * URLs of interest for clients apps. 66 + */ 67 + public Map<String, String> urls; 68 + 69 + /** 70 + * Banner image for the website. 71 + */ 72 + public String thumbnail; 73 + /** 74 + * A user that can be contacted, as an alternative to email. 75 + */ 76 + public Account contactAccount; 77 + public Stats stats; 78 + 79 + public int maxTootChars; 80 + public List<Rule> rules; 81 + 82 + @Override 83 + public void postprocess() throws ObjectValidationException{ 84 + super.postprocess(); 85 + if(contactAccount!=null) 86 + contactAccount.postprocess(); 87 + } 88 + 89 + @Override 90 + public String toString(){ 91 + return "Instance{"+ 92 + "uri='"+uri+'\''+ 93 + ", title='"+title+'\''+ 94 + ", description='"+description+'\''+ 95 + ", shortDescription='"+shortDescription+'\''+ 96 + ", email='"+email+'\''+ 97 + ", version='"+version+'\''+ 98 + ", languages="+languages+ 99 + ", registrations="+registrations+ 100 + ", approvalRequired="+approvalRequired+ 101 + ", invitesEnabled="+invitesEnabled+ 102 + ", urls="+urls+ 103 + ", thumbnail='"+thumbnail+'\''+ 104 + ", contactAccount="+contactAccount+ 105 + '}'; 106 + } 107 + 108 + public CatalogInstance toCatalogInstance(){ 109 + CatalogInstance ci=new CatalogInstance(); 110 + ci.domain=uri; 111 + ci.normalizedDomain=IDN.toUnicode(uri); 112 + ci.description=Html.fromHtml(shortDescription).toString().trim(); 113 + if(languages!=null){ 114 + ci.language=languages.get(0); 115 + ci.languages=languages; 116 + }else{ 117 + ci.languages=Collections.emptyList(); 118 + ci.language="unknown"; 119 + } 120 + ci.proxiedThumbnail=thumbnail; 121 + if(stats!=null) 122 + ci.totalUsers=stats.userCount; 123 + return ci; 124 + } 125 + 126 + 127 + 128 + public static class Rule implements Parcelable{ 129 + public String id; 130 + public String text; 131 + 132 + 133 + @Override 134 + public int describeContents(){ 135 + return 0; 136 + } 137 + 138 + @Override 139 + public void writeToParcel(Parcel dest, int flags){ 140 + dest.writeString(this.id); 141 + dest.writeString(this.text); 142 + } 143 + 144 + public void readFromParcel(Parcel source){ 145 + this.id=source.readString(); 146 + this.text=source.readString(); 147 + } 148 + 149 + public Rule(){ 150 + } 151 + 152 + protected Rule(Parcel in){ 153 + this.id=in.readString(); 154 + this.text=in.readString(); 155 + } 156 + 157 + public static final Parcelable.Creator<Rule> CREATOR=new Parcelable.Creator<Rule>(){ 158 + @Override 159 + public Rule createFromParcel(Parcel source){ 160 + return new Rule(source); 161 + } 162 + 163 + @Override 164 + public Rule[] newArray(int size){ 165 + return new Rule[size]; 166 + } 167 + }; 168 + } 169 + 170 + public static class Stats implements Parcelable{ 171 + public int userCount; 172 + public int statusCount; 173 + public int domainCount; 174 + 175 + 176 + @Override 177 + public int describeContents(){ 178 + return 0; 179 + } 180 + 181 + @Override 182 + public void writeToParcel(Parcel dest, int flags){ 183 + dest.writeInt(this.userCount); 184 + dest.writeInt(this.statusCount); 185 + dest.writeInt(this.domainCount); 186 + } 187 + 188 + public void readFromParcel(Parcel source){ 189 + this.userCount=source.readInt(); 190 + this.statusCount=source.readInt(); 191 + this.domainCount=source.readInt(); 192 + } 193 + 194 + public Stats(){ 195 + } 196 + 197 + protected Stats(Parcel in){ 198 + this.userCount=in.readInt(); 199 + this.statusCount=in.readInt(); 200 + this.domainCount=in.readInt(); 201 + } 202 + 203 + public static final Parcelable.Creator<Stats> CREATOR=new Parcelable.Creator<Stats>(){ 204 + @Override 205 + public Stats createFromParcel(Parcel source){ 206 + return new Stats(source); 207 + } 208 + 209 + @Override 210 + public Stats[] newArray(int size){ 211 + return new Stats[size]; 212 + } 213 + }; 214 + } 215 + }
+45
mastodon/src/main/java/org/joinmastodon/android/model/Source.java
···
··· 1 + package org.joinmastodon.android.model; 2 + 3 + import org.joinmastodon.android.api.ObjectValidationException; 4 + import org.joinmastodon.android.api.RequiredField; 5 + 6 + import java.util.List; 7 + 8 + /** 9 + * 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. 10 + */ 11 + public class Source extends BaseModel{ 12 + /** 13 + * Profile bio. 14 + */ 15 + @RequiredField 16 + public String note; 17 + /** 18 + * Metadata about the account. 19 + */ 20 + @RequiredField 21 + public List<AccountField> fields; 22 + /** 23 + * The default post privacy to be used for new statuses. 24 + */ 25 + public StatusPrivacy privacy; 26 + /** 27 + * Whether new statuses should be marked sensitive by default. 28 + */ 29 + public boolean sensitive; 30 + /** 31 + * The default posting language for new statuses. 32 + */ 33 + public String language; 34 + /** 35 + * The number of pending follow requests. 36 + */ 37 + public int followRequestCount; 38 + 39 + @Override 40 + public void postprocess() throws ObjectValidationException{ 41 + super.postprocess(); 42 + for(AccountField f:fields) 43 + f.postprocess(); 44 + } 45 + }
+14
mastodon/src/main/java/org/joinmastodon/android/model/StatusPrivacy.java
···
··· 1 + package org.joinmastodon.android.model; 2 + 3 + import com.google.gson.annotations.SerializedName; 4 + 5 + public enum StatusPrivacy{ 6 + @SerializedName("public") 7 + PUBLIC, 8 + @SerializedName("unlisted") 9 + UNLISTED, 10 + @SerializedName("private") 11 + PRIVATE, 12 + @SerializedName("direct") 13 + DIRECT; 14 + }
+27
mastodon/src/main/java/org/joinmastodon/android/model/Token.java
···
··· 1 + package org.joinmastodon.android.model; 2 + 3 + import org.joinmastodon.android.api.AllFieldsAreRequired; 4 + 5 + /** 6 + * Represents an OAuth token used for authenticating with the API and performing actions. 7 + */ 8 + @AllFieldsAreRequired 9 + public class Token extends BaseModel{ 10 + /** 11 + * An OAuth token to be used for authorization. 12 + */ 13 + public String accessToken; 14 + /** 15 + * The OAuth token type. Mastodon uses Bearer tokens. 16 + */ 17 + public String tokenType; 18 + /** 19 + * The OAuth scopes granted by this token, space-separated. 20 + */ 21 + public String scope; 22 + /** 23 + * When the token was generated. 24 + * (unixtime) 25 + */ 26 + public long createdAt; 27 + }
+18
mastodon/src/main/java/org/joinmastodon/android/model/catalog/CatalogCategory.java
···
··· 1 + package org.joinmastodon.android.model.catalog; 2 + 3 + import org.joinmastodon.android.api.AllFieldsAreRequired; 4 + import org.joinmastodon.android.model.BaseModel; 5 + 6 + @AllFieldsAreRequired 7 + public class CatalogCategory extends BaseModel{ 8 + public String category; 9 + public int serversCount; 10 + 11 + @Override 12 + public String toString(){ 13 + return "CatalogCategory{"+ 14 + "category='"+category+'\''+ 15 + ", serversCount="+serversCount+ 16 + '}'; 17 + } 18 + }
+53
mastodon/src/main/java/org/joinmastodon/android/model/catalog/CatalogInstance.java
···
··· 1 + package org.joinmastodon.android.model.catalog; 2 + 3 + import org.joinmastodon.android.api.AllFieldsAreRequired; 4 + import org.joinmastodon.android.api.ObjectValidationException; 5 + import org.joinmastodon.android.model.BaseModel; 6 + 7 + import java.net.IDN; 8 + import java.util.List; 9 + 10 + @AllFieldsAreRequired 11 + public class CatalogInstance extends BaseModel{ 12 + public String domain; 13 + public String version; 14 + public String description; 15 + public List<String> languages; 16 + public String region; 17 + public List<String> categories; 18 + public String proxiedThumbnail; 19 + public int totalUsers; 20 + public int lastWeekUsers; 21 + public boolean approvalRequired; 22 + public String language; 23 + public String category; 24 + 25 + public transient String normalizedDomain; 26 + 27 + @Override 28 + public void postprocess() throws ObjectValidationException{ 29 + super.postprocess(); 30 + if(domain.startsWith("xn--") || domain.contains(".xn--")) 31 + normalizedDomain=IDN.toUnicode(domain); 32 + else 33 + normalizedDomain=domain; 34 + } 35 + 36 + @Override 37 + public String toString(){ 38 + return "CatalogInstance{"+ 39 + "domain='"+domain+'\''+ 40 + ", version='"+version+'\''+ 41 + ", description='"+description+'\''+ 42 + ", languages="+languages+ 43 + ", region='"+region+'\''+ 44 + ", categories="+categories+ 45 + ", proxiedThumbnail='"+proxiedThumbnail+'\''+ 46 + ", totalUsers="+totalUsers+ 47 + ", lastWeekUsers="+lastWeekUsers+ 48 + ", approvalRequired="+approvalRequired+ 49 + ", language='"+language+'\''+ 50 + ", category='"+category+'\''+ 51 + '}'; 52 + } 53 + }
+40
mastodon/src/main/res/layout/fragment_onboarding_common.xml
···
··· 1 + <?xml version="1.0" encoding="utf-8"?> 2 + <me.grishka.appkit.views.FragmentRootLinearLayout 3 + android:layout_width="match_parent" 4 + android:layout_height="match_parent" 5 + xmlns:app="http://schemas.android.com/apk/res-auto" 6 + android:orientation="vertical" 7 + android:id="@+id/appkit_loader_root" 8 + xmlns:android="http://schemas.android.com/apk/res/android" 9 + android:background="?android:windowBackground"> 10 + 11 + <include layout="@layout/appkit_toolbar"/> 12 + 13 + <FrameLayout 14 + android:id="@+id/appkit_loader_content" 15 + android:layout_width="match_parent" 16 + android:layout_height="0px" 17 + android:layout_weight="1"> 18 + 19 + <include layout="@layout/loading" 20 + android:id="@+id/loading"/> 21 + 22 + <ViewStub android:layout="?errorViewLayout" 23 + android:layout_width="match_parent" 24 + android:layout_height="match_parent" 25 + android:id="@+id/error" 26 + android:visibility="gone"/> 27 + 28 + <View 29 + android:layout_width="match_parent" 30 + android:layout_height="match_parent" 31 + android:id="@+id/content_stub"/> 32 + 33 + </FrameLayout> 34 + <Button 35 + android:id="@+id/btn_next" 36 + android:layout_width="match_parent" 37 + android:layout_height="wrap_content" 38 + android:enabled="false" 39 + android:text="@string/next"/> 40 + </me.grishka.appkit.views.FragmentRootLinearLayout>
+22
mastodon/src/main/res/layout/fragment_splash.xml
···
··· 1 + <?xml version="1.0" encoding="utf-8"?> 2 + <LinearLayout 3 + xmlns:android="http://schemas.android.com/apk/res/android" 4 + android:orientation="vertical" 5 + android:layout_width="match_parent" 6 + android:layout_height="match_parent" 7 + android:fitsSystemWindows="true" 8 + android:gravity="bottom" 9 + android:background="#808080"> 10 + 11 + <Button 12 + android:id="@+id/btn_get_started" 13 + android:layout_width="match_parent" 14 + android:layout_height="wrap_content" 15 + android:text="@string/get_started"/> 16 + <Button 17 + android:id="@+id/btn_log_in" 18 + android:layout_width="match_parent" 19 + android:layout_height="wrap_content" 20 + android:text="@string/log_in"/> 21 + 22 + </LinearLayout>
+32
mastodon/src/main/res/layout/header_onboarding_instance_catalog.xml
···
··· 1 + <?xml version="1.0" encoding="utf-8"?> 2 + <LinearLayout xmlns:android="http://schemas.android.com/apk/res/android" 3 + android:orientation="vertical" 4 + android:layout_width="match_parent" 5 + android:layout_height="wrap_content"> 6 + 7 + <TextView 8 + android:layout_width="match_parent" 9 + android:layout_height="wrap_content" 10 + android:textStyle="bold" 11 + android:text="title text"/> 12 + 13 + <TextView 14 + android:layout_width="match_parent" 15 + android:layout_height="wrap_content" 16 + android:text="explanation text"/> 17 + 18 + <me.grishka.appkit.views.UsableRecyclerView 19 + android:id="@+id/categories_list" 20 + android:layout_width="match_parent" 21 + android:layout_height="50dp"/> 22 + 23 + <EditText 24 + android:id="@+id/search_edit" 25 + android:layout_width="match_parent" 26 + android:layout_height="wrap_content" 27 + android:inputType="textFilter" 28 + android:singleLine="true" 29 + android:imeOptions="actionGo" 30 + android:hint="search"/> 31 + 32 + </LinearLayout>
+51
mastodon/src/main/res/layout/item_instance_catalog.xml
···
··· 1 + <?xml version="1.0" encoding="utf-8"?> 2 + <RelativeLayout xmlns:android="http://schemas.android.com/apk/res/android" 3 + xmlns:tools="http://schemas.android.com/tools" 4 + android:layout_width="match_parent" 5 + android:layout_height="wrap_content" 6 + android:padding="8dp"> 7 + 8 + <RadioButton 9 + android:id="@+id/radiobtn" 10 + android:layout_width="wrap_content" 11 + android:layout_height="wrap_content" 12 + android:layout_marginRight="8dp" 13 + android:layout_centerVertical="true" 14 + android:layout_alignParentStart="true" 15 + android:clickable="false"/> 16 + 17 + <TextView 18 + android:id="@+id/title" 19 + android:layout_width="match_parent" 20 + android:layout_height="wrap_content" 21 + android:layout_toEndOf="@id/radiobtn" 22 + android:layout_alignParentTop="true" 23 + android:textStyle="bold" 24 + tools:text="mastodon.social"/> 25 + 26 + <TextView 27 + android:id="@+id/description" 28 + android:layout_width="match_parent" 29 + android:layout_height="wrap_content" 30 + android:layout_toEndOf="@id/radiobtn" 31 + android:layout_below="@id/title" 32 + tools:text="General-purpose server run by the lead developer of Mastodon"/> 33 + 34 + <TextView 35 + android:id="@+id/user_count" 36 + android:layout_width="wrap_content" 37 + android:layout_height="wrap_content" 38 + android:layout_toEndOf="@id/radiobtn" 39 + android:layout_below="@id/description" 40 + tools:text="588.8K"/> 41 + 42 + <TextView 43 + android:id="@+id/lang" 44 + android:layout_width="wrap_content" 45 + android:layout_height="wrap_content" 46 + android:layout_toEndOf="@id/user_count" 47 + android:layout_below="@id/description" 48 + android:layout_marginStart="8dp" 49 + tools:text="EN"/> 50 + 51 + </RelativeLayout>
+14
mastodon/src/main/res/layout/item_instance_category.xml
···
··· 1 + <?xml version="1.0" encoding="utf-8"?> 2 + <LinearLayout xmlns:android="http://schemas.android.com/apk/res/android" 3 + android:orientation="vertical" 4 + android:layout_width="wrap_content" 5 + android:layout_height="wrap_content" 6 + android:padding="8dp"> 7 + 8 + <RadioButton 9 + android:id="@+id/radiobtn" 10 + android:layout_width="wrap_content" 11 + android:layout_height="wrap_content" 12 + android:clickable="false"/> 13 + 14 + </LinearLayout>
+10
mastodon/src/main/res/values/colors.xml
···
··· 1 + <?xml version="1.0" encoding="utf-8"?> 2 + <resources> 3 + <color name="purple_200">#FFBB86FC</color> 4 + <color name="purple_500">#FF6200EE</color> 5 + <color name="purple_700">#FF3700B3</color> 6 + <color name="teal_200">#FF03DAC5</color> 7 + <color name="teal_700">#FF018786</color> 8 + <color name="black">#FF000000</color> 9 + <color name="white">#FFFFFFFF</color> 10 + </resources>
+4
mastodon/src/main/res/values/ids.xml
···
··· 1 + <?xml version="1.0" encoding="utf-8"?> 2 + <resources> 3 + <item name="header" type="id"/> 4 + </resources>
+13
mastodon/src/main/res/values/strings.xml
···
··· 1 + <resources> 2 + <string name="app_name">Mastodon</string> 3 + 4 + <string name="get_started">Get started</string> 5 + <string name="log_in">Log in</string> 6 + <string name="next">Next</string> 7 + <string name="loading_instance">Getting instance info…</string> 8 + <string name="error">Error</string> 9 + <string name="not_a_mastodon_instance">%s doesn\'t appear to be a Mastodon instance.</string> 10 + <string name="ok">OK</string> 11 + <string name="preparing_auth">Preparing for authentication…</string> 12 + <string name="finishing_auth">Finishing authentication…</string> 13 + </resources>
+8
mastodon/src/main/res/values/styles.xml
···
··· 1 + <?xml version="1.0" encoding="utf-8"?> 2 + <resources> 3 + <style name="Theme.Mastodon" parent="Theme.AppKit.Light"> 4 + <!-- needed to disable scrim on API 29+ --> 5 + <item name="android:enforceNavigationBarContrast">false</item> 6 + <item name="android:enforceStatusBarContrast">false</item> 7 + </style> 8 + </resources>
+9
settings.gradle
···
··· 1 + dependencyResolutionManagement { 2 + repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS) 3 + repositories { 4 + google() 5 + mavenCentral() 6 + } 7 + } 8 + rootProject.name = "Mastodon" 9 + include ':mastodon'