diff --git a/app/build.gradle b/app/build.gradle index 285a311..0448d24 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -1,7 +1,8 @@ plugins { - id "com.diffplug.spotless" version "6.11.0" + id 'com.android.application' + id 'kotlin-android' + id 'org.jmailen.kotlinter' version '3.13.0' } -apply plugin: 'com.android.application' android { namespace "com.github.gotify" @@ -82,12 +83,3 @@ configurations { exclude group: 'androidx.lifecycle', module: 'lifecycle-viewmodel-ktx' } } - -spotless { - java { - target '**/*.java' - googleJavaFormat().aosp() - removeUnusedImports() - importOrder('', 'static *') - } -} diff --git a/app/src/main/java/com/github/gotify/MarkwonFactory.java b/app/src/main/java/com/github/gotify/MarkwonFactory.java deleted file mode 100644 index 877e0b9..0000000 --- a/app/src/main/java/com/github/gotify/MarkwonFactory.java +++ /dev/null @@ -1,130 +0,0 @@ -package com.github.gotify; - -import android.content.Context; -import android.graphics.Color; -import android.graphics.Typeface; -import android.text.style.BackgroundColorSpan; -import android.text.style.BulletSpan; -import android.text.style.QuoteSpan; -import android.text.style.RelativeSizeSpan; -import android.text.style.StyleSpan; -import android.text.style.TypefaceSpan; -import androidx.annotation.NonNull; -import androidx.core.content.ContextCompat; -import com.squareup.picasso.Picasso; -import io.noties.markwon.AbstractMarkwonPlugin; -import io.noties.markwon.Markwon; -import io.noties.markwon.MarkwonSpansFactory; -import io.noties.markwon.MarkwonVisitor; -import io.noties.markwon.core.CorePlugin; -import io.noties.markwon.core.CoreProps; -import io.noties.markwon.core.MarkwonTheme; -import io.noties.markwon.ext.strikethrough.StrikethroughPlugin; -import io.noties.markwon.ext.tables.TableAwareMovementMethod; -import io.noties.markwon.ext.tables.TablePlugin; -import io.noties.markwon.image.picasso.PicassoImagesPlugin; -import io.noties.markwon.movement.MovementMethodPlugin; -import java.util.Collections; -import org.commonmark.ext.gfm.tables.TableCell; -import org.commonmark.ext.gfm.tables.TablesExtension; -import org.commonmark.node.BlockQuote; -import org.commonmark.node.Code; -import org.commonmark.node.Emphasis; -import org.commonmark.node.Heading; -import org.commonmark.node.Link; -import org.commonmark.node.ListItem; -import org.commonmark.node.StrongEmphasis; -import org.commonmark.parser.Parser; - -public class MarkwonFactory { - public static Markwon createForMessage(Context context, Picasso picasso) { - return Markwon.builder(context) - .usePlugin(CorePlugin.create()) - .usePlugin(MovementMethodPlugin.create(TableAwareMovementMethod.create())) - .usePlugin(PicassoImagesPlugin.create(picasso)) - .usePlugin(StrikethroughPlugin.create()) - .usePlugin(TablePlugin.create(context)) - .usePlugin( - new AbstractMarkwonPlugin() { - @Override - public void configureTheme(@NonNull MarkwonTheme.Builder builder) { - builder.linkColor( - ContextCompat.getColor(context, R.color.hyperLink)) - .isLinkUnderlined(true); - } - }) - .build(); - } - - public static Markwon createForNotification(Context context, Picasso picasso) { - final float[] headingSizes = { - 2.F, 1.5F, 1.17F, 1.F, .83F, .67F, - }; - - final int bulletGapWidth = - (int) (8 * context.getResources().getDisplayMetrics().density + 0.5F); - - return Markwon.builder(context) - .usePlugin(CorePlugin.create()) - .usePlugin(PicassoImagesPlugin.create(picasso)) - .usePlugin(StrikethroughPlugin.create()) - .usePlugin( - new AbstractMarkwonPlugin() { - @Override - public void configureSpansFactory( - @NonNull MarkwonSpansFactory.Builder builder) { - builder.setFactory( - Heading.class, - (configuration, props) -> - new Object[] { - new RelativeSizeSpan( - headingSizes[ - CoreProps.HEADING_LEVEL - .require( - props) - - 1]), - new StyleSpan(Typeface.BOLD) - }) - .setFactory( - Emphasis.class, - (configuration, props) -> - new StyleSpan(Typeface.ITALIC)) - .setFactory( - StrongEmphasis.class, - (configuration, props) -> - new StyleSpan(Typeface.BOLD)) - .setFactory( - BlockQuote.class, - (configuration, props) -> new QuoteSpan()) - .setFactory( - Code.class, - (configuration, props) -> - new Object[] { - new BackgroundColorSpan(Color.LTGRAY), - new TypefaceSpan("monospace") - }) - .setFactory( - ListItem.class, - (configuration, props) -> - new BulletSpan(bulletGapWidth)) - .setFactory(Link.class, ((configuration, props) -> null)); - } - - @Override - public void configureParser(@NonNull Parser.Builder builder) { - builder.extensions(Collections.singleton(TablesExtension.create())); - } - - @Override - public void configureVisitor(@NonNull MarkwonVisitor.Builder builder) { - builder.on( - TableCell.class, - (visitor, node) -> { - visitor.visitChildren(node); - visitor.builder().append(' '); - }); - } - }) - .build(); - } -} diff --git a/app/src/main/java/com/github/gotify/MissedMessageUtil.java b/app/src/main/java/com/github/gotify/MissedMessageUtil.java deleted file mode 100644 index d197830..0000000 --- a/app/src/main/java/com/github/gotify/MissedMessageUtil.java +++ /dev/null @@ -1,77 +0,0 @@ -package com.github.gotify; - -import com.github.gotify.api.Api; -import com.github.gotify.api.ApiException; -import com.github.gotify.api.Callback; -import com.github.gotify.client.api.MessageApi; -import com.github.gotify.client.model.Message; -import com.github.gotify.client.model.PagedMessages; -import com.github.gotify.log.Log; -import java.util.ArrayList; -import java.util.Collections; -import java.util.List; - -import static com.github.gotify.api.Callback.call; - -public class MissedMessageUtil { - static final long NO_MESSAGES = 0; - - private final MessageApi api; - - public MissedMessageUtil(MessageApi api) { - this.api = api; - } - - public void lastReceivedMessage(Callback.SuccessCallback successCallback) { - api.getMessages(1, 0L) - .enqueue( - call( - (messages) -> { - if (messages.getMessages().size() == 1) { - successCallback.onSuccess( - messages.getMessages().get(0).getId()); - } else { - successCallback.onSuccess(NO_MESSAGES); - } - }, - (e) -> {})); - } - - public List missingMessages(long till) { - List result = new ArrayList<>(); - try { - - Long since = null; - while (true) { - PagedMessages pagedMessages = Api.execute(api.getMessages(10, since)); - List messages = pagedMessages.getMessages(); - List filtered = filter(messages, till); - result.addAll(filtered); - if (messages.size() != filtered.size() - || messages.size() == 0 - || pagedMessages.getPaging().getNext() == null) { - break; - } - since = pagedMessages.getPaging().getSince(); - } - } catch (ApiException e) { - Log.e("cannot retrieve missing messages", e); - } - Collections.reverse(result); - return result; - } - - private List filter(List messages, long till) { - List result = new ArrayList<>(); - - for (Message message : messages) { - if (message.getId() > till) { - result.add(message); - } else { - break; - } - } - - return result; - } -} diff --git a/app/src/main/java/com/github/gotify/NotificationSupport.java b/app/src/main/java/com/github/gotify/NotificationSupport.java deleted file mode 100644 index 694f31f..0000000 --- a/app/src/main/java/com/github/gotify/NotificationSupport.java +++ /dev/null @@ -1,107 +0,0 @@ -package com.github.gotify; - -import android.app.NotificationChannel; -import android.app.NotificationManager; -import android.graphics.Color; -import android.os.Build; -import androidx.annotation.RequiresApi; -import com.github.gotify.log.Log; - -public class NotificationSupport { - public static final class Group { - public static final String MESSAGES = "GOTIFY_GROUP_MESSAGES"; - } - - public static final class Channel { - public static final String FOREGROUND = "gotify_foreground"; - public static final String MESSAGES_IMPORTANCE_MIN = "gotify_messages_min_importance"; - public static final String MESSAGES_IMPORTANCE_LOW = "gotify_messages_low_importance"; - public static final String MESSAGES_IMPORTANCE_DEFAULT = - "gotify_messages_default_importance"; - public static final String MESSAGES_IMPORTANCE_HIGH = "gotify_messages_high_importance"; - } - - public static final class ID { - public static final int FOREGROUND = -1; - public static final int GROUPED = -2; - } - - @RequiresApi(Build.VERSION_CODES.O) - public static void createChannels(NotificationManager notificationManager) { - try { - // Low importance so that persistent notification can be sorted towards bottom of - // notification shade. Also prevents vibrations caused by persistent notification - NotificationChannel foreground = - new NotificationChannel( - Channel.FOREGROUND, - "Gotify foreground notification", - NotificationManager.IMPORTANCE_LOW); - foreground.setShowBadge(false); - - NotificationChannel messagesImportanceMin = - new NotificationChannel( - Channel.MESSAGES_IMPORTANCE_MIN, - "Min priority messages (<1)", - NotificationManager.IMPORTANCE_MIN); - - NotificationChannel messagesImportanceLow = - new NotificationChannel( - Channel.MESSAGES_IMPORTANCE_LOW, - "Low priority messages (1-3)", - NotificationManager.IMPORTANCE_LOW); - - NotificationChannel messagesImportanceDefault = - new NotificationChannel( - Channel.MESSAGES_IMPORTANCE_DEFAULT, - "Normal priority messages (4-7)", - NotificationManager.IMPORTANCE_DEFAULT); - messagesImportanceDefault.enableLights(true); - messagesImportanceDefault.setLightColor(Color.CYAN); - messagesImportanceDefault.enableVibration(true); - - NotificationChannel messagesImportanceHigh = - new NotificationChannel( - Channel.MESSAGES_IMPORTANCE_HIGH, - "High priority messages (>7)", - NotificationManager.IMPORTANCE_HIGH); - messagesImportanceHigh.enableLights(true); - messagesImportanceHigh.setLightColor(Color.CYAN); - messagesImportanceHigh.enableVibration(true); - - notificationManager.createNotificationChannel(foreground); - notificationManager.createNotificationChannel(messagesImportanceMin); - notificationManager.createNotificationChannel(messagesImportanceLow); - notificationManager.createNotificationChannel(messagesImportanceDefault); - notificationManager.createNotificationChannel(messagesImportanceHigh); - } catch (Exception e) { - Log.e("Could not create channel", e); - } - } - - /** - * Map {@link com.github.gotify.client.model.Message#getPriority() Gotify message priorities to - * Android channels. - * - *
-     * Gotify Priority  | Android Importance
-     * <= 0             | min
-     * 1-3              | low
-     * 4-7              | default
-     * >= 8             | high
-     * 
- * - * @param priority the Gotify priority to convert to a notification channel as a long. - * @return the identifier of the notification channel as a String. - */ - public static String convertPriorityToChannel(long priority) { - if (priority < 1) { - return Channel.MESSAGES_IMPORTANCE_MIN; - } else if (priority < 4) { - return Channel.MESSAGES_IMPORTANCE_LOW; - } else if (priority < 8) { - return Channel.MESSAGES_IMPORTANCE_DEFAULT; - } else { - return Channel.MESSAGES_IMPORTANCE_HIGH; - } - } -} diff --git a/app/src/main/java/com/github/gotify/SSLSettings.java b/app/src/main/java/com/github/gotify/SSLSettings.java deleted file mode 100644 index cb8c4a6..0000000 --- a/app/src/main/java/com/github/gotify/SSLSettings.java +++ /dev/null @@ -1,11 +0,0 @@ -package com.github.gotify; - -public class SSLSettings { - public boolean validateSSL; - public String cert; - - public SSLSettings(boolean validateSSL, String cert) { - this.validateSSL = validateSSL; - this.cert = cert; - } -} diff --git a/app/src/main/java/com/github/gotify/Settings.java b/app/src/main/java/com/github/gotify/Settings.java deleted file mode 100644 index 14139ff..0000000 --- a/app/src/main/java/com/github/gotify/Settings.java +++ /dev/null @@ -1,82 +0,0 @@ -package com.github.gotify; - -import android.content.Context; -import android.content.SharedPreferences; -import com.github.gotify.client.model.User; - -public class Settings { - private final SharedPreferences sharedPreferences; - - public Settings(Context context) { - sharedPreferences = context.getSharedPreferences("gotify", Context.MODE_PRIVATE); - } - - public void url(String url) { - sharedPreferences.edit().putString("url", url).apply(); - } - - public String url() { - return sharedPreferences.getString("url", null); - } - - public boolean tokenExists() { - return token() != null; - } - - public String token() { - return sharedPreferences.getString("token", null); - } - - public void token(String token) { - sharedPreferences.edit().putString("token", token).apply(); - } - - public void clear() { - url(null); - token(null); - validateSSL(true); - cert(null); - } - - public void user(String name, boolean admin) { - sharedPreferences.edit().putString("username", name).putBoolean("admin", admin).apply(); - } - - public User user() { - String username = sharedPreferences.getString("username", null); - boolean admin = sharedPreferences.getBoolean("admin", false); - if (username != null) { - return new User().name(username).admin(admin); - } else { - return new User().name("UNKNOWN").admin(false); - } - } - - public String serverVersion() { - return sharedPreferences.getString("version", "UNKNOWN"); - } - - public void serverVersion(String version) { - sharedPreferences.edit().putString("version", version).apply(); - } - - private boolean validateSSL() { - return sharedPreferences.getBoolean("validateSSL", true); - } - - public void validateSSL(boolean validateSSL) { - sharedPreferences.edit().putBoolean("validateSSL", validateSSL).apply(); - } - - private String cert() { - return sharedPreferences.getString("cert", null); - } - - public void cert(String cert) { - sharedPreferences.edit().putString("cert", cert).apply(); - } - - public SSLSettings sslSettings() { - return new SSLSettings(validateSSL(), cert()); - } -} diff --git a/app/src/main/java/com/github/gotify/Utils.java b/app/src/main/java/com/github/gotify/Utils.java deleted file mode 100644 index 2a3dd48..0000000 --- a/app/src/main/java/com/github/gotify/Utils.java +++ /dev/null @@ -1,110 +0,0 @@ -package com.github.gotify; - -import android.app.Activity; -import android.content.res.Resources; -import android.graphics.Bitmap; -import android.graphics.drawable.BitmapDrawable; -import android.graphics.drawable.Drawable; -import android.text.format.DateUtils; -import android.view.View; -import androidx.annotation.NonNull; -import com.github.gotify.client.JSON; -import com.github.gotify.log.Log; -import com.google.android.material.snackbar.Snackbar; -import com.google.gson.Gson; -import com.squareup.picasso.Picasso; -import com.squareup.picasso.Target; -import java.io.BufferedReader; -import java.io.IOException; -import java.io.InputStream; -import java.io.InputStreamReader; -import java.net.MalformedURLException; -import java.net.URI; -import java.net.URISyntaxException; -import java.net.URL; -import okio.Buffer; -import org.threeten.bp.OffsetDateTime; - -public class Utils { - public static final Gson JSON = new JSON().getGson(); - - public static void showSnackBar(Activity activity, String message) { - View rootView = activity.getWindow().getDecorView().findViewById(android.R.id.content); - Snackbar.make(rootView, message, Snackbar.LENGTH_SHORT).show(); - } - - public static int longToInt(long value) { - return (int) (value % Integer.MAX_VALUE); - } - - public static String dateToRelative(OffsetDateTime data) { - long time = data.toInstant().toEpochMilli(); - long now = System.currentTimeMillis(); - return DateUtils.getRelativeTimeSpanString(time, now, DateUtils.MINUTE_IN_MILLIS) - .toString(); - } - - public static String resolveAbsoluteUrl(String baseURL, String target) { - if (target == null) { - return null; - } - try { - URI targetUri = new URI(target); - if (targetUri.isAbsolute()) { - return target; - } - return new URL(new URL(baseURL), target).toString(); - } catch (MalformedURLException | URISyntaxException e) { - Log.e("Could not resolve absolute url", e); - return target; - } - } - - public static Target toDrawable(Resources resources, DrawableReceiver drawableReceiver) { - return new Target() { - @Override - public void onBitmapLoaded(Bitmap bitmap, Picasso.LoadedFrom from) { - drawableReceiver.loaded(new BitmapDrawable(resources, bitmap)); - } - - @Override - public void onBitmapFailed(Exception e, Drawable errorDrawable) { - Log.e("Bitmap failed", e); - } - - @Override - public void onPrepareLoad(Drawable placeHolderDrawable) {} - }; - } - - public static String readFileFromStream(@NonNull InputStream inputStream) { - StringBuilder sb = new StringBuilder(); - String currentLine; - - try (BufferedReader reader = new BufferedReader(new InputStreamReader(inputStream))) { - while ((currentLine = reader.readLine()) != null) { - sb.append(currentLine).append("\n"); - } - } catch (IOException e) { - throw new IllegalArgumentException("failed to read input"); - } - - return sb.toString(); - } - - public interface DrawableReceiver { - void loaded(Drawable drawable); - } - - public static InputStream stringToInputStream(String str) { - if (str == null) return null; - return new Buffer().writeUtf8(str).inputStream(); - } - - public static T first(T[] data) { - if (data.length != 1) { - throw new IllegalArgumentException("must be one element"); - } - return data[0]; - } -} diff --git a/app/src/main/java/com/github/gotify/api/Api.java b/app/src/main/java/com/github/gotify/api/Api.java deleted file mode 100644 index 0bac2e3..0000000 --- a/app/src/main/java/com/github/gotify/api/Api.java +++ /dev/null @@ -1,21 +0,0 @@ -package com.github.gotify.api; - -import java.io.IOException; -import retrofit2.Call; -import retrofit2.Response; - -public class Api { - public static T execute(Call call) throws ApiException { - try { - Response response = call.execute(); - - if (response.isSuccessful()) { - return response.body(); - } else { - throw new ApiException(response); - } - } catch (IOException e) { - throw new ApiException(e); - } - } -} diff --git a/app/src/main/java/com/github/gotify/api/ApiException.java b/app/src/main/java/com/github/gotify/api/ApiException.java deleted file mode 100644 index bf73da1..0000000 --- a/app/src/main/java/com/github/gotify/api/ApiException.java +++ /dev/null @@ -1,44 +0,0 @@ -package com.github.gotify.api; - -import java.io.IOException; -import java.util.Locale; -import retrofit2.Response; - -public final class ApiException extends Exception { - - private String body; - private int code; - - ApiException(Response response) { - super("Api Error", null); - try { - this.body = response.errorBody() != null ? response.errorBody().string() : ""; - } catch (IOException e) { - this.body = "Error while getting error body :("; - } - this.code = response.code(); - } - - ApiException(Throwable cause) { - super("Request failed.", cause); - this.body = ""; - this.code = 0; - } - - public String body() { - return body; - } - - public int code() { - return code; - } - - @Override - public String toString() { - return String.format( - Locale.ENGLISH, - "Code(%d) Response: %s", - code(), - body().substring(0, Math.min(body().length(), 200))); - } -} diff --git a/app/src/main/java/com/github/gotify/api/Callback.java b/app/src/main/java/com/github/gotify/api/Callback.java deleted file mode 100644 index 7b26292..0000000 --- a/app/src/main/java/com/github/gotify/api/Callback.java +++ /dev/null @@ -1,83 +0,0 @@ -package com.github.gotify.api; - -import android.app.Activity; -import com.github.gotify.log.Log; -import retrofit2.Call; -import retrofit2.Response; - -public class Callback { - private final SuccessCallback onSuccess; - private final ErrorCallback onError; - - private Callback(SuccessCallback onSuccess, ErrorCallback onError) { - this.onSuccess = onSuccess; - this.onError = onError; - } - - public static retrofit2.Callback callInUI( - Activity context, SuccessCallback onSuccess, ErrorCallback onError) { - return call( - (data) -> context.runOnUiThread(() -> onSuccess.onSuccess(data)), - (e) -> context.runOnUiThread(() -> onError.onError(e))); - } - - public static retrofit2.Callback call() { - return call((e) -> {}, (e) -> {}); - } - - public static retrofit2.Callback call( - SuccessCallback onSuccess, ErrorCallback onError) { - return new RetrofitCallback<>(merge(of(onSuccess, onError), errorCallback())); - } - - private static Callback of(SuccessCallback onSuccess, ErrorCallback onError) { - return new Callback<>(onSuccess, onError); - } - - private static Callback errorCallback() { - return new Callback<>((ignored) -> {}, (error) -> Log.e("Error while api call", error)); - } - - private static Callback merge(Callback left, Callback right) { - return new Callback<>( - (data) -> { - left.onSuccess.onSuccess(data); - right.onSuccess.onSuccess(data); - }, - (error) -> { - left.onError.onError(error); - right.onError.onError(error); - }); - } - - public interface SuccessCallback { - void onSuccess(T data); - } - - public interface ErrorCallback { - void onError(ApiException t); - } - - private static final class RetrofitCallback implements retrofit2.Callback { - - private Callback callback; - - private RetrofitCallback(Callback callback) { - this.callback = callback; - } - - @Override - public void onResponse(Call call, Response response) { - if (response.isSuccessful()) { - callback.onSuccess.onSuccess(response.body()); - } else { - callback.onError.onError(new ApiException(response)); - } - } - - @Override - public void onFailure(Call call, Throwable t) { - callback.onError.onError(new ApiException(t)); - } - } -} diff --git a/app/src/main/java/com/github/gotify/api/CertUtils.java b/app/src/main/java/com/github/gotify/api/CertUtils.java deleted file mode 100644 index 0ca7010..0000000 --- a/app/src/main/java/com/github/gotify/api/CertUtils.java +++ /dev/null @@ -1,106 +0,0 @@ -package com.github.gotify.api; - -import android.annotation.SuppressLint; -import com.github.gotify.SSLSettings; -import com.github.gotify.Utils; -import com.github.gotify.log.Log; -import java.io.IOException; -import java.security.GeneralSecurityException; -import java.security.KeyStore; -import java.security.SecureRandom; -import java.security.cert.Certificate; -import java.security.cert.CertificateFactory; -import java.security.cert.X509Certificate; -import java.util.Collection; -import javax.net.ssl.KeyManager; -import javax.net.ssl.SSLContext; -import javax.net.ssl.TrustManager; -import javax.net.ssl.TrustManagerFactory; -import javax.net.ssl.X509TrustManager; -import okhttp3.OkHttpClient; - -public class CertUtils { - private static final X509TrustManager trustAll = - new X509TrustManager() { - @SuppressLint("TrustAllX509TrustManager") - @Override - public void checkClientTrusted(X509Certificate[] chain, String authType) {} - - @SuppressLint("TrustAllX509TrustManager") - @Override - public void checkServerTrusted(X509Certificate[] chain, String authType) {} - - @Override - public X509Certificate[] getAcceptedIssuers() { - return new X509Certificate[] {}; - } - }; - - public static Certificate parseCertificate(String cert) { - try { - CertificateFactory certificateFactory = CertificateFactory.getInstance("X509"); - - return certificateFactory.generateCertificate(Utils.stringToInputStream(cert)); - } catch (Exception e) { - throw new IllegalArgumentException("certificate is invalid"); - } - } - - public static void applySslSettings(OkHttpClient.Builder builder, SSLSettings settings) { - // Modified from ApiClient.applySslSettings in the client package. - - try { - if (!settings.validateSSL) { - SSLContext context = SSLContext.getInstance("TLS"); - context.init( - new KeyManager[] {}, new TrustManager[] {trustAll}, new SecureRandom()); - builder.sslSocketFactory(context.getSocketFactory(), trustAll); - builder.hostnameVerifier((a, b) -> true); - return; - } - - if (settings.cert != null) { - TrustManager[] trustManagers = certToTrustManager(settings.cert); - - if (trustManagers != null && trustManagers.length > 0) { - SSLContext context = SSLContext.getInstance("TLS"); - context.init(new KeyManager[] {}, trustManagers, new SecureRandom()); - builder.sslSocketFactory( - context.getSocketFactory(), (X509TrustManager) trustManagers[0]); - } - } - } catch (Exception e) { - // We shouldn't have issues since the cert is verified on login. - Log.e("Failed to apply SSL settings", e); - } - } - - private static TrustManager[] certToTrustManager(String cert) throws GeneralSecurityException { - CertificateFactory certificateFactory = CertificateFactory.getInstance("X.509"); - Collection certificates = - certificateFactory.generateCertificates(Utils.stringToInputStream(cert)); - if (certificates.isEmpty()) { - throw new IllegalArgumentException("expected non-empty set of trusted certificates"); - } - KeyStore caKeyStore = newEmptyKeyStore(); - int index = 0; - for (Certificate certificate : certificates) { - String certificateAlias = "ca" + Integer.toString(index++); - caKeyStore.setCertificateEntry(certificateAlias, certificate); - } - TrustManagerFactory trustManagerFactory = - TrustManagerFactory.getInstance(TrustManagerFactory.getDefaultAlgorithm()); - trustManagerFactory.init(caKeyStore); - return trustManagerFactory.getTrustManagers(); - } - - private static KeyStore newEmptyKeyStore() throws GeneralSecurityException { - try { - KeyStore keyStore = KeyStore.getInstance(KeyStore.getDefaultType()); - keyStore.load(null, null); - return keyStore; - } catch (IOException e) { - throw new AssertionError(e); - } - } -} diff --git a/app/src/main/java/com/github/gotify/api/ClientFactory.java b/app/src/main/java/com/github/gotify/api/ClientFactory.java deleted file mode 100644 index 1e10e7c..0000000 --- a/app/src/main/java/com/github/gotify/api/ClientFactory.java +++ /dev/null @@ -1,50 +0,0 @@ -package com.github.gotify.api; - -import com.github.gotify.SSLSettings; -import com.github.gotify.Settings; -import com.github.gotify.client.ApiClient; -import com.github.gotify.client.api.UserApi; -import com.github.gotify.client.api.VersionApi; -import com.github.gotify.client.auth.ApiKeyAuth; -import com.github.gotify.client.auth.HttpBasicAuth; - -public class ClientFactory { - public static com.github.gotify.client.ApiClient unauthorized( - String baseUrl, SSLSettings sslSettings) { - return defaultClient(new String[0], baseUrl + "/", sslSettings); - } - - public static ApiClient basicAuth( - String baseUrl, SSLSettings sslSettings, String username, String password) { - ApiClient client = defaultClient(new String[] {"basicAuth"}, baseUrl + "/", sslSettings); - HttpBasicAuth auth = (HttpBasicAuth) client.getApiAuthorizations().get("basicAuth"); - auth.setUsername(username); - auth.setPassword(password); - return client; - } - - public static ApiClient clientToken(String baseUrl, SSLSettings sslSettings, String token) { - ApiClient client = - defaultClient(new String[] {"clientTokenHeader"}, baseUrl + "/", sslSettings); - ApiKeyAuth tokenAuth = (ApiKeyAuth) client.getApiAuthorizations().get("clientTokenHeader"); - tokenAuth.setApiKey(token); - return client; - } - - public static VersionApi versionApi(String baseUrl, SSLSettings sslSettings) { - return unauthorized(baseUrl, sslSettings).createService(VersionApi.class); - } - - public static UserApi userApiWithToken(Settings settings) { - return clientToken(settings.url(), settings.sslSettings(), settings.token()) - .createService(UserApi.class); - } - - private static ApiClient defaultClient( - String[] authentications, String baseUrl, SSLSettings sslSettings) { - ApiClient client = new ApiClient(authentications); - CertUtils.applySslSettings(client.getOkBuilder(), sslSettings); - client.getAdapterBuilder().baseUrl(baseUrl); - return client; - } -} diff --git a/app/src/main/java/com/github/gotify/init/BootCompletedReceiver.java b/app/src/main/java/com/github/gotify/init/BootCompletedReceiver.java deleted file mode 100644 index dfe5da8..0000000 --- a/app/src/main/java/com/github/gotify/init/BootCompletedReceiver.java +++ /dev/null @@ -1,26 +0,0 @@ -package com.github.gotify.init; - -import android.content.BroadcastReceiver; -import android.content.Context; -import android.content.Intent; -import android.os.Build; -import com.github.gotify.Settings; -import com.github.gotify.service.WebSocketService; - -public class BootCompletedReceiver extends BroadcastReceiver { - - @Override - public void onReceive(Context context, Intent intent) { - Settings settings = new Settings(context); - - if (!settings.tokenExists()) { - return; - } - - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { - context.startForegroundService(new Intent(context, WebSocketService.class)); - } else { - context.startService(new Intent(context, WebSocketService.class)); - } - } -} diff --git a/app/src/main/java/com/github/gotify/init/InitializationActivity.java b/app/src/main/java/com/github/gotify/init/InitializationActivity.java deleted file mode 100644 index b43b067..0000000 --- a/app/src/main/java/com/github/gotify/init/InitializationActivity.java +++ /dev/null @@ -1,133 +0,0 @@ -package com.github.gotify.init; - -import android.app.NotificationManager; -import android.content.Context; -import android.content.Intent; -import android.os.Build; -import android.os.Bundle; -import androidx.annotation.Nullable; -import androidx.appcompat.app.AlertDialog; -import androidx.appcompat.app.AppCompatActivity; -import androidx.preference.PreferenceManager; -import com.github.gotify.NotificationSupport; -import com.github.gotify.R; -import com.github.gotify.Settings; -import com.github.gotify.api.ApiException; -import com.github.gotify.api.Callback; -import com.github.gotify.api.ClientFactory; -import com.github.gotify.client.model.User; -import com.github.gotify.client.model.VersionInfo; -import com.github.gotify.log.Log; -import com.github.gotify.log.UncaughtExceptionHandler; -import com.github.gotify.login.LoginActivity; -import com.github.gotify.messages.MessagesActivity; -import com.github.gotify.service.WebSocketService; -import com.github.gotify.settings.ThemeHelper; - -import static com.github.gotify.api.Callback.callInUI; - -public class InitializationActivity extends AppCompatActivity { - private Settings settings; - - @Override - protected void onCreate(@Nullable Bundle savedInstanceState) { - super.onCreate(savedInstanceState); - Log.init(this); - String theme = - PreferenceManager.getDefaultSharedPreferences(this) - .getString( - getString(R.string.setting_key_theme), - getString(R.string.theme_default)); - ThemeHelper.setTheme(this, theme); - - setContentView(R.layout.splash); - - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { - NotificationSupport.createChannels( - (NotificationManager) this.getSystemService(Context.NOTIFICATION_SERVICE)); - } - - UncaughtExceptionHandler.registerCurrentThread(); - settings = new Settings(this); - Log.i("Entering " + getClass().getSimpleName()); - - if (settings.tokenExists()) { - tryAuthenticate(); - } else { - showLogin(); - } - } - - private void showLogin() { - startActivity(new Intent(this, LoginActivity.class)); - finish(); - } - - private void tryAuthenticate() { - ClientFactory.userApiWithToken(settings) - .currentUser() - .enqueue(callInUI(this, this::authenticated, this::failed)); - } - - private void failed(ApiException exception) { - if (exception.code() == 0) { - dialog(getString(R.string.not_available, settings.url())); - return; - } - - if (exception.code() == 401) { - dialog(getString(R.string.auth_failed)); - return; - } - - String response = exception.body(); - response = response.substring(0, Math.min(200, response.length())); - dialog(getString(R.string.other_error, settings.url(), exception.code(), response)); - } - - private void dialog(String message) { - new AlertDialog.Builder(this) - .setTitle(R.string.oops) - .setMessage(message) - .setPositiveButton(R.string.retry, (a, b) -> tryAuthenticate()) - .setNegativeButton(R.string.logout, (a, b) -> showLogin()) - .show(); - } - - private void authenticated(User user) { - Log.i("Authenticated as " + user.getName()); - - settings.user(user.getName(), user.isAdmin()); - requestVersion( - () -> { - startActivity(new Intent(this, MessagesActivity.class)); - finish(); - }); - - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { - startForegroundService(new Intent(this, WebSocketService.class)); - } else { - startService(new Intent(this, WebSocketService.class)); - } - } - - private void requestVersion(Runnable runnable) { - requestVersion( - (version) -> { - Log.i("Server version: " + version.getVersion() + "@" + version.getBuildDate()); - settings.serverVersion(version.getVersion()); - runnable.run(); - }, - (e) -> { - runnable.run(); - }); - } - - private void requestVersion( - final Callback.SuccessCallback callback, - final Callback.ErrorCallback errorCallback) { - ClientFactory.versionApi(settings.url(), settings.sslSettings()) - .getVersion() - .enqueue(callInUI(this, callback, errorCallback)); - } -} diff --git a/app/src/main/java/com/github/gotify/log/Format.java b/app/src/main/java/com/github/gotify/log/Format.java deleted file mode 100644 index c8f055c..0000000 --- a/app/src/main/java/com/github/gotify/log/Format.java +++ /dev/null @@ -1,23 +0,0 @@ -package com.github.gotify.log; - -import android.content.Context; -import com.hypertrack.hyperlog.LogFormat; -import java.util.Locale; - -public class Format extends LogFormat { - Format(Context context) { - super(context); - } - - @Override - public String getFormattedLogMessage( - String logLevelName, - String tag, - String message, - String timeStamp, - String senderName, - String osVersion, - String deviceUUID) { - return String.format(Locale.ENGLISH, "%s %s: %s", timeStamp, logLevelName, message); - } -} diff --git a/app/src/main/java/com/github/gotify/log/Log.java b/app/src/main/java/com/github/gotify/log/Log.java deleted file mode 100644 index acb7b6e..0000000 --- a/app/src/main/java/com/github/gotify/log/Log.java +++ /dev/null @@ -1,50 +0,0 @@ -package com.github.gotify.log; - -import android.content.Context; -import android.text.TextUtils; -import com.hypertrack.hyperlog.HyperLog; -import java.util.Collections; -import java.util.List; - -public class Log { - private static String TAG = "gotify"; - - public static void init(Context content) { - HyperLog.initialize(content, new Format(content)); - HyperLog.setLogLevel(android.util.Log.INFO); // TODO configurable - } - - public static String get() { - List logs = HyperLog.getDeviceLogsAsStringList(false); - Collections.reverse(logs); - return TextUtils.join("\n", logs.subList(0, Math.min(200, logs.size()))); - } - - public static void e(String message) { - HyperLog.e(TAG, message); - } - - public static void e(String message, Throwable e) { - HyperLog.e(TAG, message + '\n' + android.util.Log.getStackTraceString(e)); - } - - public static void i(String message) { - HyperLog.i(TAG, message); - } - - public static void i(String message, Throwable e) { - HyperLog.i(TAG, message + '\n' + android.util.Log.getStackTraceString(e)); - } - - public static void w(String message) { - HyperLog.w(TAG, message); - } - - public static void w(String message, Throwable e) { - HyperLog.w(TAG, message + '\n' + android.util.Log.getStackTraceString(e)); - } - - public static void clear() { - HyperLog.deleteLogs(); - } -} diff --git a/app/src/main/java/com/github/gotify/log/LogsActivity.java b/app/src/main/java/com/github/gotify/log/LogsActivity.java deleted file mode 100644 index 2cc3161..0000000 --- a/app/src/main/java/com/github/gotify/log/LogsActivity.java +++ /dev/null @@ -1,87 +0,0 @@ -package com.github.gotify.log; - -import android.content.ClipData; -import android.content.ClipboardManager; -import android.content.Context; -import android.os.AsyncTask; -import android.os.Bundle; -import android.os.Handler; -import android.view.Menu; -import android.view.MenuItem; -import android.widget.TextView; -import androidx.appcompat.app.ActionBar; -import androidx.appcompat.app.AppCompatActivity; -import com.github.gotify.R; -import com.github.gotify.Utils; -import com.github.gotify.databinding.ActivityLogsBinding; - -public class LogsActivity extends AppCompatActivity { - - private ActivityLogsBinding binding; - private Handler handler = new Handler(); - - @Override - protected void onCreate(Bundle savedInstanceState) { - super.onCreate(savedInstanceState); - binding = ActivityLogsBinding.inflate(getLayoutInflater()); - setContentView(binding.getRoot()); - Log.i("Entering " + getClass().getSimpleName()); - updateLogs(); - setSupportActionBar(binding.appBarDrawer.toolbar); - ActionBar actionBar = getSupportActionBar(); - if (actionBar != null) { - actionBar.setDisplayHomeAsUpEnabled(true); - actionBar.setDisplayShowCustomEnabled(true); - } - } - - private void updateLogs() { - new RefreshLogs().execute(); - if (!isDestroyed()) { - handler.postDelayed(this::updateLogs, 5000); - } - } - - @Override - public boolean onCreateOptionsMenu(Menu menu) { - getMenuInflater().inflate(R.menu.logs_action, menu); - return super.onCreateOptionsMenu(menu); - } - - @Override - public boolean onOptionsItemSelected(MenuItem item) { - if (item.getItemId() == android.R.id.home) { - finish(); - } - if (item.getItemId() == R.id.action_delete_logs) { - Log.clear(); - binding.logContent.setText(null); - } - if (item.getItemId() == R.id.action_copy_logs) { - TextView content = binding.logContent; - ClipboardManager clipboardManager = - (ClipboardManager) getSystemService(Context.CLIPBOARD_SERVICE); - ClipData clipData = ClipData.newPlainText("GotifyLog", content.getText().toString()); - clipboardManager.setPrimaryClip(clipData); - Utils.showSnackBar(this, getString(R.string.logs_copied)); - } - return super.onOptionsItemSelected(item); - } - - class RefreshLogs extends AsyncTask { - - @Override - protected String doInBackground(Void... voids) { - return com.github.gotify.log.Log.get(); - } - - @Override - protected void onPostExecute(String s) { - TextView content = binding.logContent; - if (content.getSelectionStart() == content.getSelectionEnd()) { - content.setText(s); - } - super.onPostExecute(s); - } - } -} diff --git a/app/src/main/java/com/github/gotify/log/UncaughtExceptionHandler.java b/app/src/main/java/com/github/gotify/log/UncaughtExceptionHandler.java deleted file mode 100644 index 134f276..0000000 --- a/app/src/main/java/com/github/gotify/log/UncaughtExceptionHandler.java +++ /dev/null @@ -1,7 +0,0 @@ -package com.github.gotify.log; - -public class UncaughtExceptionHandler { - public static void registerCurrentThread() { - Thread.setDefaultUncaughtExceptionHandler((t, e) -> Log.e("uncaught exception", e)); - } -} diff --git a/app/src/main/java/com/github/gotify/login/AdvancedDialog.java b/app/src/main/java/com/github/gotify/login/AdvancedDialog.java deleted file mode 100644 index d031f59..0000000 --- a/app/src/main/java/com/github/gotify/login/AdvancedDialog.java +++ /dev/null @@ -1,75 +0,0 @@ -package com.github.gotify.login; - -import android.app.AlertDialog; -import android.content.Context; -import android.view.LayoutInflater; -import android.widget.CompoundButton; -import androidx.annotation.Nullable; -import com.github.gotify.R; -import com.github.gotify.databinding.AdvancedSettingsDialogBinding; - -class AdvancedDialog { - - private Context context; - private LayoutInflater layoutInflater; - private AdvancedSettingsDialogBinding binding; - private CompoundButton.OnCheckedChangeListener onCheckedChangeListener; - private Runnable onClickSelectCaCertificate; - private Runnable onClickRemoveCaCertificate; - - AdvancedDialog(Context context, LayoutInflater layoutInflater) { - this.context = context; - this.layoutInflater = layoutInflater; - } - - AdvancedDialog onDisableSSLChanged( - CompoundButton.OnCheckedChangeListener onCheckedChangeListener) { - this.onCheckedChangeListener = onCheckedChangeListener; - return this; - } - - AdvancedDialog onClickSelectCaCertificate(Runnable onClickSelectCaCertificate) { - this.onClickSelectCaCertificate = onClickSelectCaCertificate; - return this; - } - - AdvancedDialog onClickRemoveCaCertificate(Runnable onClickRemoveCaCertificate) { - this.onClickRemoveCaCertificate = onClickRemoveCaCertificate; - return this; - } - - AdvancedDialog show(boolean disableSSL, @Nullable String selectedCertificate) { - binding = AdvancedSettingsDialogBinding.inflate(layoutInflater); - binding.disableSSL.setChecked(disableSSL); - binding.disableSSL.setOnCheckedChangeListener(onCheckedChangeListener); - - if (selectedCertificate == null) { - showSelectCACertificate(); - } else { - showRemoveCACertificate(selectedCertificate); - } - - new AlertDialog.Builder(context) - .setView(binding.getRoot()) - .setTitle(R.string.advanced_settings) - .setPositiveButton(context.getString(R.string.done), (ignored, ignored2) -> {}) - .show(); - return this; - } - - private void showSelectCACertificate() { - binding.toggleCaCert.setText(R.string.select_ca_certificate); - binding.toggleCaCert.setOnClickListener((a) -> onClickSelectCaCertificate.run()); - binding.selecetedCaCert.setText(R.string.no_certificate_selected); - } - - void showRemoveCACertificate(String certificate) { - binding.toggleCaCert.setText(R.string.remove_ca_certificate); - binding.toggleCaCert.setOnClickListener( - (a) -> { - showSelectCACertificate(); - onClickRemoveCaCertificate.run(); - }); - binding.selecetedCaCert.setText(certificate); - } -} diff --git a/app/src/main/java/com/github/gotify/login/LoginActivity.java b/app/src/main/java/com/github/gotify/login/LoginActivity.java deleted file mode 100644 index 292994e..0000000 --- a/app/src/main/java/com/github/gotify/login/LoginActivity.java +++ /dev/null @@ -1,313 +0,0 @@ -package com.github.gotify.login; - -import android.content.ActivityNotFoundException; -import android.content.DialogInterface; -import android.content.Intent; -import android.net.Uri; -import android.os.Build; -import android.os.Bundle; -import android.text.Editable; -import android.text.TextWatcher; -import android.view.View; -import android.widget.EditText; -import androidx.annotation.Nullable; -import androidx.appcompat.app.AlertDialog; -import androidx.appcompat.app.AppCompatActivity; -import androidx.appcompat.view.ContextThemeWrapper; -import com.github.gotify.R; -import com.github.gotify.SSLSettings; -import com.github.gotify.Settings; -import com.github.gotify.Utils; -import com.github.gotify.api.ApiException; -import com.github.gotify.api.Callback; -import com.github.gotify.api.CertUtils; -import com.github.gotify.api.ClientFactory; -import com.github.gotify.client.ApiClient; -import com.github.gotify.client.api.ClientApi; -import com.github.gotify.client.api.UserApi; -import com.github.gotify.client.model.Client; -import com.github.gotify.client.model.VersionInfo; -import com.github.gotify.databinding.ActivityLoginBinding; -import com.github.gotify.init.InitializationActivity; -import com.github.gotify.log.Log; -import com.github.gotify.log.LogsActivity; -import com.github.gotify.log.UncaughtExceptionHandler; -import java.io.InputStream; -import java.security.cert.Certificate; -import java.security.cert.X509Certificate; -import okhttp3.HttpUrl; - -import static com.github.gotify.api.Callback.callInUI; - -public class LoginActivity extends AppCompatActivity { - - // return value from startActivityForResult when choosing a certificate - private final int FILE_SELECT_CODE = 1; - - private ActivityLoginBinding binding; - private Settings settings; - - private boolean disableSSLValidation; - private String caCertContents; - private AdvancedDialog advancedDialog; - - @Override - protected void onCreate(Bundle savedInstanceState) { - super.onCreate(savedInstanceState); - UncaughtExceptionHandler.registerCurrentThread(); - binding = ActivityLoginBinding.inflate(getLayoutInflater()); - setContentView(binding.getRoot()); - Log.i("Entering " + getClass().getSimpleName()); - settings = new Settings(this); - } - - @Override - protected void onPostCreate(@Nullable Bundle savedInstanceState) { - super.onPostCreate(savedInstanceState); - - binding.gotifyUrl.addTextChangedListener( - new TextWatcher() { - @Override - public void beforeTextChanged( - CharSequence charSequence, int i, int i1, int i2) {} - - @Override - public void onTextChanged(CharSequence charSequence, int i, int i1, int i2) { - invalidateUrl(); - } - - @Override - public void afterTextChanged(Editable editable) {} - }); - - binding.checkurl.setOnClickListener(ignored -> doCheckUrl()); - binding.openLogs.setOnClickListener(ignored -> openLogs()); - binding.advancedSettings.setOnClickListener(ignored -> toggleShowAdvanced()); - binding.login.setOnClickListener(ignored -> doLogin()); - } - - private void invalidateUrl() { - binding.username.setVisibility(View.GONE); - binding.password.setVisibility(View.GONE); - binding.login.setVisibility(View.GONE); - binding.checkurl.setText(getString(R.string.check_url)); - } - - public void doCheckUrl() { - String url = binding.gotifyUrl.getText().toString(); - HttpUrl parsedUrl = HttpUrl.parse(url); - if (parsedUrl == null) { - Utils.showSnackBar(LoginActivity.this, "Invalid URL (include http:// or https://)"); - return; - } - - if ("http".equals(parsedUrl.scheme())) { - showHttpWarning(); - } - - binding.checkurlProgress.setVisibility(View.VISIBLE); - binding.checkurl.setVisibility(View.GONE); - - final String trimmedUrl = url.trim(); - final String fixedUrl = - trimmedUrl.endsWith("/") - ? trimmedUrl.substring(0, trimmedUrl.length() - 1) - : trimmedUrl; - - try { - ClientFactory.versionApi(fixedUrl, tempSSLSettings()) - .getVersion() - .enqueue(callInUI(this, onValidUrl(fixedUrl), onInvalidUrl(fixedUrl))); - } catch (Exception e) { - binding.checkurlProgress.setVisibility(View.GONE); - binding.checkurl.setVisibility(View.VISIBLE); - String errorMsg = - getString(R.string.version_failed, fixedUrl + "/version", e.getMessage()); - Utils.showSnackBar(LoginActivity.this, errorMsg); - } - } - - public void showHttpWarning() { - new AlertDialog.Builder(new ContextThemeWrapper(this, R.style.AppTheme_Dialog)) - .setTitle(R.string.warning) - .setCancelable(true) - .setMessage(R.string.http_warning) - .setPositiveButton(R.string.i_understand, (a, b) -> {}) - .show(); - } - - public void openLogs() { - startActivity(new Intent(this, LogsActivity.class)); - } - - void toggleShowAdvanced() { - String selectedCertName = - caCertContents != null ? getNameOfCertContent(caCertContents) : null; - - advancedDialog = - new AdvancedDialog(this, getLayoutInflater()) - .onDisableSSLChanged( - (ignored, disable) -> { - invalidateUrl(); - disableSSLValidation = disable; - }) - .onClickSelectCaCertificate( - () -> { - invalidateUrl(); - doSelectCACertificate(); - }) - .onClickRemoveCaCertificate( - () -> { - invalidateUrl(); - caCertContents = null; - }) - .show(disableSSLValidation, selectedCertName); - } - - private void doSelectCACertificate() { - Intent intent = new Intent(Intent.ACTION_OPEN_DOCUMENT); - // we don't really care what kind of file it is as long as we can parse it - intent.setType("*/*"); - intent.addCategory(Intent.CATEGORY_OPENABLE); - - try { - startActivityForResult( - Intent.createChooser(intent, getString(R.string.select_ca_file)), - FILE_SELECT_CODE); - } catch (ActivityNotFoundException e) { - // case for user not having a file browser installed - Utils.showSnackBar(this, getString(R.string.please_install_file_browser)); - } - } - - @Override - protected void onActivityResult(int requestCode, int resultCode, @Nullable Intent data) { - super.onActivityResult(requestCode, resultCode, data); - try { - if (requestCode == FILE_SELECT_CODE) { - if (resultCode != RESULT_OK) { - throw new IllegalArgumentException(String.format("result was %d", resultCode)); - } else if (data == null) { - throw new IllegalArgumentException("file path was null"); - } - - Uri uri = data.getData(); - if (uri == null) { - throw new IllegalArgumentException("file path was null"); - } - - InputStream fileStream = getContentResolver().openInputStream(uri); - if (fileStream == null) { - throw new IllegalArgumentException("file path was invalid"); - } - - String content = Utils.readFileFromStream(fileStream); - String name = getNameOfCertContent(content); - - // temporarily set the contents (don't store to settings until they decide to login) - caCertContents = content; - advancedDialog.showRemoveCACertificate(name); - } - } catch (Exception e) { - Utils.showSnackBar(this, getString(R.string.select_ca_failed, e.getMessage())); - } - } - - private String getNameOfCertContent(String content) { - Certificate ca = CertUtils.parseCertificate(content); - return ((X509Certificate) ca).getSubjectDN().getName(); - } - - private Callback.SuccessCallback onValidUrl(String url) { - return (version) -> { - settings.url(url); - binding.checkurlProgress.setVisibility(View.GONE); - binding.checkurl.setVisibility(View.VISIBLE); - binding.checkurl.setText( - getString(R.string.found_gotify_version, version.getVersion())); - binding.username.setVisibility(View.VISIBLE); - binding.username.requestFocus(); - binding.password.setVisibility(View.VISIBLE); - binding.login.setVisibility(View.VISIBLE); - }; - } - - private Callback.ErrorCallback onInvalidUrl(String url) { - return (exception) -> { - binding.checkurlProgress.setVisibility(View.GONE); - binding.checkurl.setVisibility(View.VISIBLE); - Utils.showSnackBar(LoginActivity.this, versionError(url, exception)); - }; - } - - public void doLogin() { - String username = binding.username.getText().toString(); - String password = binding.password.getText().toString(); - - binding.login.setVisibility(View.GONE); - binding.loginProgress.setVisibility(View.VISIBLE); - - ApiClient client = - ClientFactory.basicAuth(settings.url(), tempSSLSettings(), username, password); - client.createService(UserApi.class) - .currentUser() - .enqueue(callInUI(this, (user) -> newClientDialog(client), this::onInvalidLogin)); - } - - private void onInvalidLogin(ApiException e) { - binding.login.setVisibility(View.VISIBLE); - binding.loginProgress.setVisibility(View.GONE); - Utils.showSnackBar(this, getString(R.string.wronguserpw)); - } - - private void newClientDialog(ApiClient client) { - EditText clientName = new EditText(this); - clientName.setText(Build.MODEL); - - new AlertDialog.Builder(new ContextThemeWrapper(this, R.style.AppTheme_Dialog)) - .setTitle(R.string.create_client_title) - .setMessage(R.string.create_client_message) - .setView(clientName) - .setPositiveButton(R.string.create, doCreateClient(client, clientName)) - .setNegativeButton(R.string.cancel, this::onCancelClientDialog) - .show(); - } - - public DialogInterface.OnClickListener doCreateClient(ApiClient client, EditText nameProvider) { - return (a, b) -> { - Client newClient = new Client().name(nameProvider.getText().toString()); - client.createService(ClientApi.class) - .createClient(newClient) - .enqueue(callInUI(this, this::onCreatedClient, this::onFailedToCreateClient)); - }; - } - - private void onCreatedClient(Client client) { - settings.token(client.getToken()); - settings.validateSSL(!disableSSLValidation); - settings.cert(caCertContents); - - Utils.showSnackBar(this, getString(R.string.created_client)); - startActivity(new Intent(this, InitializationActivity.class)); - finish(); - } - - private void onFailedToCreateClient(ApiException e) { - Utils.showSnackBar(this, getString(R.string.create_client_failed)); - binding.loginProgress.setVisibility(View.GONE); - binding.login.setVisibility(View.VISIBLE); - } - - private void onCancelClientDialog(DialogInterface dialog, int which) { - binding.loginProgress.setVisibility(View.GONE); - binding.login.setVisibility(View.VISIBLE); - } - - private String versionError(String url, ApiException exception) { - return getString(R.string.version_failed_status_code, url + "/version", exception.code()); - } - - private SSLSettings tempSSLSettings() { - return new SSLSettings(!disableSSLValidation, caCertContents); - } -} diff --git a/app/src/main/java/com/github/gotify/messages/Extras.java b/app/src/main/java/com/github/gotify/messages/Extras.java deleted file mode 100644 index 1bc2612..0000000 --- a/app/src/main/java/com/github/gotify/messages/Extras.java +++ /dev/null @@ -1,51 +0,0 @@ -package com.github.gotify.messages; - -import com.github.gotify.client.model.Message; -import java.util.Map; - -public final class Extras { - private Extras() {} - - public static boolean useMarkdown(Message message) { - return useMarkdown(message.getExtras()); - } - - public static boolean useMarkdown(Map extras) { - if (extras == null) { - return false; - } - - Object display = extras.get("client::display"); - if (!(display instanceof Map)) { - return false; - } - - return "text/markdown".equals(((Map) display).get("contentType")); - } - - public static T getNestedValue(Class clazz, Message message, String... keys) { - return getNestedValue(clazz, message.getExtras(), keys); - } - - public static T getNestedValue(Class clazz, Map extras, String... keys) { - Object value = extras; - - for (String key : keys) { - if (value == null) { - return null; - } - - if (!(value instanceof Map)) { - return null; - } - - value = ((Map) value).get(key); - } - - if (!clazz.isInstance(value)) { - return null; - } - - return clazz.cast(value); - } -} diff --git a/app/src/main/java/com/github/gotify/messages/ListMessageAdapter.java b/app/src/main/java/com/github/gotify/messages/ListMessageAdapter.java deleted file mode 100644 index d0d27fd..0000000 --- a/app/src/main/java/com/github/gotify/messages/ListMessageAdapter.java +++ /dev/null @@ -1,240 +0,0 @@ -package com.github.gotify.messages; - -import android.content.ClipData; -import android.content.ClipboardManager; -import android.content.Context; -import android.content.SharedPreferences; -import android.text.format.DateUtils; -import android.text.util.Linkify; -import android.view.LayoutInflater; -import android.view.ViewGroup; -import android.widget.ImageButton; -import android.widget.ImageView; -import android.widget.TextView; -import android.widget.Toast; -import androidx.annotation.NonNull; -import androidx.preference.PreferenceManager; -import androidx.recyclerview.widget.RecyclerView; -import androidx.viewbinding.ViewBinding; -import com.github.gotify.MarkwonFactory; -import com.github.gotify.R; -import com.github.gotify.Settings; -import com.github.gotify.Utils; -import com.github.gotify.client.model.Message; -import com.github.gotify.databinding.MessageItemBinding; -import com.github.gotify.databinding.MessageItemCompactBinding; -import com.github.gotify.messages.provider.MessageWithImage; -import com.squareup.picasso.Picasso; -import io.noties.markwon.Markwon; -import java.text.DateFormat; -import java.util.Date; -import java.util.List; -import org.threeten.bp.OffsetDateTime; - -public class ListMessageAdapter extends RecyclerView.Adapter { - - private Context context; - private SharedPreferences prefs; - private Picasso picasso; - private List items; - private Delete delete; - private Settings settings; - private Markwon markwon; - private int messageLayout; - - private final String TIME_FORMAT_RELATIVE; - private final String TIME_FORMAT_PREFS_KEY; - - ListMessageAdapter( - Context context, - Settings settings, - Picasso picasso, - List items, - Delete delete) { - super(); - this.context = context; - this.settings = settings; - this.picasso = picasso; - this.items = items; - this.delete = delete; - - this.prefs = PreferenceManager.getDefaultSharedPreferences(context); - this.markwon = MarkwonFactory.createForMessage(context, picasso); - - TIME_FORMAT_RELATIVE = - context.getResources().getString(R.string.time_format_value_relative); - TIME_FORMAT_PREFS_KEY = context.getResources().getString(R.string.setting_key_time_format); - - String message_layout_prefs_key = - context.getResources().getString(R.string.setting_key_message_layout); - String messageLayoutNormal = - context.getResources().getString(R.string.message_layout_value_normal); - String messageLayoutSetting = - prefs.getString(message_layout_prefs_key, messageLayoutNormal); - - if (messageLayoutSetting.equals(messageLayoutNormal)) { - messageLayout = R.layout.message_item; - } else { - messageLayout = R.layout.message_item_compact; - } - } - - public List getItems() { - return items; - } - - public void setItems(List items) { - this.items = items; - } - - @NonNull - @Override - public ViewHolder onCreateViewHolder(@NonNull ViewGroup parent, int viewType) { - ViewHolder holder; - LayoutInflater layoutInflater = LayoutInflater.from(parent.getContext()); - if (messageLayout == R.layout.message_item) { - MessageItemBinding binding = MessageItemBinding.inflate(layoutInflater, parent, false); - holder = new ViewHolder(binding); - } else { - MessageItemCompactBinding binding = - MessageItemCompactBinding.inflate(layoutInflater, parent, false); - holder = new ViewHolder(binding); - } - - return holder; - } - - @Override - public void onBindViewHolder(@NonNull ViewHolder holder, int position) { - final MessageWithImage message = items.get(position); - if (Extras.useMarkdown(message.message)) { - holder.message.setAutoLinkMask(0); - markwon.setMarkdown(holder.message, message.message.getMessage()); - } else { - holder.message.setAutoLinkMask(Linkify.WEB_URLS); - holder.message.setText(message.message.getMessage()); - } - holder.title.setText(message.message.getTitle()); - picasso.load(Utils.resolveAbsoluteUrl(settings.url() + "/", message.image)) - .error(R.drawable.ic_alarm) - .placeholder(R.drawable.ic_placeholder) - .into(holder.image); - - SharedPreferences prefs = PreferenceManager.getDefaultSharedPreferences(context); - String timeFormat = prefs.getString(TIME_FORMAT_PREFS_KEY, TIME_FORMAT_RELATIVE); - holder.setDateTime(message.message.getDate(), timeFormat.equals(TIME_FORMAT_RELATIVE)); - holder.date.setOnClickListener((ignored) -> holder.switchTimeFormat()); - - holder.delete.setOnClickListener( - (ignored) -> delete.delete(holder.getAdapterPosition(), message.message, false)); - } - - @Override - public int getItemCount() { - return items.size(); - } - - @Override - public long getItemId(int position) { - MessageWithImage currentItem = items.get(position); - return currentItem.message.getId(); - } - - static class ViewHolder extends RecyclerView.ViewHolder { - ImageView image; - TextView message; - TextView title; - TextView date; - ImageButton delete; - - private boolean relativeTimeFormat; - private OffsetDateTime dateTime; - - ViewHolder(final ViewBinding binding) { - super(binding.getRoot()); - relativeTimeFormat = true; - dateTime = null; - enableCopyToClipboard(); - - if (binding instanceof MessageItemBinding) { - MessageItemBinding localBinding = (MessageItemBinding) binding; - image = localBinding.messageImage; - message = localBinding.messageText; - title = localBinding.messageTitle; - date = localBinding.messageDate; - delete = localBinding.messageDelete; - } else if (binding instanceof MessageItemCompactBinding) { - MessageItemCompactBinding localBinding = (MessageItemCompactBinding) binding; - image = localBinding.messageImage; - message = localBinding.messageText; - title = localBinding.messageTitle; - date = localBinding.messageDate; - delete = localBinding.messageDelete; - } - } - - void switchTimeFormat() { - relativeTimeFormat = !relativeTimeFormat; - updateDate(); - } - - void setDateTime(OffsetDateTime dateTime, boolean relativeTimeFormatPreference) { - this.dateTime = dateTime; - relativeTimeFormat = relativeTimeFormatPreference; - updateDate(); - } - - void updateDate() { - String text = "?"; - if (dateTime != null) { - if (relativeTimeFormat) { - // Relative time format - text = Utils.dateToRelative(dateTime); - } else { - // Absolute time format - long time = dateTime.toInstant().toEpochMilli(); - Date date = new Date(time); - if (DateUtils.isToday(time)) { - text = DateFormat.getTimeInstance(DateFormat.SHORT).format(date); - } else { - text = - DateFormat.getDateTimeInstance(DateFormat.MEDIUM, DateFormat.SHORT) - .format(date); - } - } - } - date.setText(text); - } - - private void enableCopyToClipboard() { - super.itemView.setOnLongClickListener( - view -> { - ClipboardManager clipboard = - (ClipboardManager) - view.getContext() - .getSystemService(Context.CLIPBOARD_SERVICE); - ClipData clip = - ClipData.newPlainText( - "GotifyMessageContent", message.getText().toString()); - - if (clipboard != null) { - clipboard.setPrimaryClip(clip); - Toast toast = - Toast.makeText( - view.getContext(), - view.getContext() - .getString( - R.string.message_copied_to_clipboard), - Toast.LENGTH_SHORT); - toast.show(); - } - - return true; - }); - } - } - - public interface Delete { - void delete(int position, Message message, boolean listAnimation); - } -} diff --git a/app/src/main/java/com/github/gotify/messages/MessagesActivity.java b/app/src/main/java/com/github/gotify/messages/MessagesActivity.java deleted file mode 100644 index bd93ad8..0000000 --- a/app/src/main/java/com/github/gotify/messages/MessagesActivity.java +++ /dev/null @@ -1,740 +0,0 @@ -package com.github.gotify.messages; - -import android.app.NotificationManager; -import android.content.BroadcastReceiver; -import android.content.Context; -import android.content.DialogInterface; -import android.content.Intent; -import android.content.IntentFilter; -import android.graphics.Canvas; -import android.graphics.drawable.ColorDrawable; -import android.graphics.drawable.Drawable; -import android.net.Uri; -import android.os.AsyncTask; -import android.os.Bundle; -import android.view.Menu; -import android.view.MenuItem; -import android.view.View; -import android.widget.ImageButton; -import android.widget.TextView; -import androidx.annotation.NonNull; -import androidx.annotation.Nullable; -import androidx.appcompat.app.ActionBarDrawerToggle; -import androidx.appcompat.app.AlertDialog; -import androidx.appcompat.app.AppCompatActivity; -import androidx.appcompat.view.ContextThemeWrapper; -import androidx.core.content.ContextCompat; -import androidx.core.graphics.drawable.DrawableCompat; -import androidx.core.view.GravityCompat; -import androidx.drawerlayout.widget.DrawerLayout; -import androidx.lifecycle.ViewModelProvider; -import androidx.recyclerview.widget.DividerItemDecoration; -import androidx.recyclerview.widget.ItemTouchHelper; -import androidx.recyclerview.widget.LinearLayoutManager; -import androidx.recyclerview.widget.RecyclerView; -import androidx.swiperefreshlayout.widget.SwipeRefreshLayout; -import com.github.gotify.BuildConfig; -import com.github.gotify.MissedMessageUtil; -import com.github.gotify.R; -import com.github.gotify.Settings; -import com.github.gotify.Utils; -import com.github.gotify.api.Api; -import com.github.gotify.api.ApiException; -import com.github.gotify.api.Callback; -import com.github.gotify.api.ClientFactory; -import com.github.gotify.client.ApiClient; -import com.github.gotify.client.api.ApplicationApi; -import com.github.gotify.client.api.ClientApi; -import com.github.gotify.client.api.MessageApi; -import com.github.gotify.client.model.Application; -import com.github.gotify.client.model.Client; -import com.github.gotify.client.model.Message; -import com.github.gotify.databinding.ActivityMessagesBinding; -import com.github.gotify.init.InitializationActivity; -import com.github.gotify.log.Log; -import com.github.gotify.log.LogsActivity; -import com.github.gotify.login.LoginActivity; -import com.github.gotify.messages.provider.ApplicationHolder; -import com.github.gotify.messages.provider.MessageDeletion; -import com.github.gotify.messages.provider.MessageFacade; -import com.github.gotify.messages.provider.MessageState; -import com.github.gotify.messages.provider.MessageWithImage; -import com.github.gotify.service.WebSocketService; -import com.github.gotify.settings.SettingsActivity; -import com.github.gotify.sharing.ShareActivity; -import com.google.android.material.navigation.NavigationView; -import com.google.android.material.snackbar.BaseTransientBottomBar; -import com.google.android.material.snackbar.Snackbar; -import com.squareup.picasso.Target; -import java.io.IOException; -import java.util.Arrays; -import java.util.List; - -import static com.github.gotify.Utils.first; -import static java.util.Collections.emptyList; - -public class MessagesActivity extends AppCompatActivity - implements NavigationView.OnNavigationItemSelectedListener { - - private final BroadcastReceiver receiver = - new BroadcastReceiver() { - @Override - public void onReceive(Context context, Intent intent) { - String messageJson = intent.getStringExtra("message"); - Message message = Utils.JSON.fromJson(messageJson, Message.class); - new NewSingleMessage().execute(message); - } - }; - - private static final int APPLICATION_ORDER = 1; - - private ActivityMessagesBinding binding; - private MessagesModel viewModel; - - private boolean isLoadMore = false; - private Long updateAppOnDrawerClose = null; - - private ListMessageAdapter listMessageAdapter; - - @Override - protected void onCreate(Bundle savedInstanceState) { - super.onCreate(savedInstanceState); - binding = ActivityMessagesBinding.inflate(getLayoutInflater()); - setContentView(binding.getRoot()); - viewModel = - new ViewModelProvider(this, new MessagesModelFactory(this)) - .get(MessagesModel.class); - Log.i("Entering " + getClass().getSimpleName()); - - initDrawer(); - - LinearLayoutManager layoutManager = new LinearLayoutManager(this); - RecyclerView messagesView = binding.messagesView; - DividerItemDecoration dividerItemDecoration = - new DividerItemDecoration( - messagesView.getContext(), layoutManager.getOrientation()); - listMessageAdapter = - new ListMessageAdapter( - this, - viewModel.getSettings(), - viewModel.getPicassoHandler().get(), - emptyList(), - this::scheduleDeletion); - - messagesView.addItemDecoration(dividerItemDecoration); - messagesView.setHasFixedSize(true); - messagesView.setLayoutManager(layoutManager); - messagesView.addOnScrollListener(new MessageListOnScrollListener()); - messagesView.setAdapter(listMessageAdapter); - - ApplicationHolder appsHolder = viewModel.getAppsHolder(); - appsHolder.onUpdate(() -> onUpdateApps(appsHolder.get())); - if (appsHolder.wasRequested()) onUpdateApps(appsHolder.get()); - else appsHolder.request(); - - ItemTouchHelper itemTouchHelper = - new ItemTouchHelper(new SwipeToDeleteCallback(listMessageAdapter)); - itemTouchHelper.attachToRecyclerView(messagesView); - - SwipeRefreshLayout swipeRefreshLayout = binding.swipeRefresh; - swipeRefreshLayout.setOnRefreshListener(this::onRefresh); - binding.drawerLayout.addDrawerListener( - new DrawerLayout.SimpleDrawerListener() { - @Override - public void onDrawerClosed(View drawerView) { - if (updateAppOnDrawerClose != null) { - viewModel.setAppId(updateAppOnDrawerClose); - new UpdateMessagesForApplication(true).execute(updateAppOnDrawerClose); - updateAppOnDrawerClose = null; - invalidateOptionsMenu(); - } - } - }); - - swipeRefreshLayout.setEnabled(false); - messagesView - .getViewTreeObserver() - .addOnScrollChangedListener( - () -> { - View topChild = messagesView.getChildAt(0); - if (topChild != null) { - swipeRefreshLayout.setEnabled(topChild.getTop() == 0); - } else { - swipeRefreshLayout.setEnabled(true); - } - }); - - new UpdateMessagesForApplication(true).execute(viewModel.getAppId()); - } - - @Override - protected void onPostCreate(@Nullable Bundle savedInstanceState) { - super.onPostCreate(savedInstanceState); - binding.learnGotify.setOnClickListener(view -> openDocumentation()); - } - - public void onRefreshAll(View view) { - refreshAll(); - } - - public void refreshAll() { - try { - viewModel.getPicassoHandler().evict(); - } catch (IOException e) { - Log.e("Problem evicting Picasso cache", e); - } - startActivity(new Intent(this, InitializationActivity.class)); - finish(); - } - - private void onRefresh() { - viewModel.getMessages().clear(); - new LoadMore().execute(viewModel.getAppId()); - } - - public void openDocumentation() { - Intent browserIntent = - new Intent(Intent.ACTION_VIEW, Uri.parse("https://gotify.net/docs/pushmsg")); - startActivity(browserIntent); - } - - public void commitDelete() { - new CommitDeleteMessage().execute(); - } - - protected void onUpdateApps(List applications) { - Menu menu = binding.navView.getMenu(); - menu.removeGroup(R.id.apps); - viewModel.getTargetReferences().clear(); - updateMessagesAndStopLoading(viewModel.getMessages().get(viewModel.getAppId())); - - MenuItem selectedItem = menu.findItem(R.id.nav_all_messages); - for (int i = 0; i < applications.size(); i++) { - Application app = applications.get(i); - MenuItem item = menu.add(R.id.apps, i, APPLICATION_ORDER, app.getName()); - item.setCheckable(true); - if (app.getId() == viewModel.getAppId()) selectedItem = item; - Target t = Utils.toDrawable(getResources(), item::setIcon); - viewModel.getTargetReferences().add(t); - viewModel - .getPicassoHandler() - .get() - .load( - Utils.resolveAbsoluteUrl( - viewModel.getSettings().url() + "/", app.getImage())) - .error(R.drawable.ic_alarm) - .placeholder(R.drawable.ic_placeholder) - .resize(100, 100) - .into(t); - } - selectAppInMenu(selectedItem); - } - - private void initDrawer() { - setSupportActionBar(binding.appBarDrawer.toolbar); - binding.navView.setItemIconTintList(null); - ActionBarDrawerToggle toggle = - new ActionBarDrawerToggle( - this, - binding.drawerLayout, - binding.appBarDrawer.toolbar, - R.string.navigation_drawer_open, - R.string.navigation_drawer_close); - binding.drawerLayout.addDrawerListener(toggle); - toggle.syncState(); - - binding.navView.setNavigationItemSelectedListener(this); - View headerView = binding.navView.getHeaderView(0); - - Settings settings = viewModel.getSettings(); - - TextView user = headerView.findViewById(R.id.header_user); - user.setText(settings.user().getName()); - - TextView connection = headerView.findViewById(R.id.header_connection); - connection.setText( - getString(R.string.connection, settings.user().getName(), settings.url())); - - TextView version = headerView.findViewById(R.id.header_version); - version.setText( - getString(R.string.versions, BuildConfig.VERSION_NAME, settings.serverVersion())); - - ImageButton refreshAll = headerView.findViewById(R.id.refresh_all); - refreshAll.setOnClickListener(this::onRefreshAll); - } - - @Override - public void onBackPressed() { - if (binding.drawerLayout.isDrawerOpen(GravityCompat.START)) { - binding.drawerLayout.closeDrawer(GravityCompat.START); - } else { - super.onBackPressed(); - } - } - - @Override - public boolean onNavigationItemSelected(MenuItem item) { - // Handle navigation view item clicks here. - int id = item.getItemId(); - - if (item.getGroupId() == R.id.apps) { - Application app = viewModel.getAppsHolder().get().get(id); - updateAppOnDrawerClose = app != null ? app.getId() : MessageState.ALL_MESSAGES; - startLoading(); - binding.appBarDrawer.toolbar.setSubtitle(item.getTitle()); - } else if (id == R.id.nav_all_messages) { - updateAppOnDrawerClose = MessageState.ALL_MESSAGES; - startLoading(); - binding.appBarDrawer.toolbar.setSubtitle(""); - } else if (id == R.id.logout) { - new AlertDialog.Builder(new ContextThemeWrapper(this, R.style.AppTheme_Dialog)) - .setTitle(R.string.logout) - .setMessage(getString(R.string.logout_confirm)) - .setPositiveButton(R.string.yes, this::doLogout) - .setNegativeButton(R.string.cancel, (a, b) -> {}) - .show(); - } else if (id == R.id.nav_logs) { - startActivity(new Intent(this, LogsActivity.class)); - } else if (id == R.id.settings) { - startActivity(new Intent(this, SettingsActivity.class)); - } else if (id == R.id.push_message) { - Intent intent = new Intent(MessagesActivity.this, ShareActivity.class); - startActivity(intent); - } - - binding.drawerLayout.closeDrawer(GravityCompat.START); - return true; - } - - public void doLogout(DialogInterface dialog, int which) { - setContentView(R.layout.splash); - new DeleteClientAndNavigateToLogin().execute(); - } - - private void startLoading() { - binding.swipeRefresh.setRefreshing(true); - binding.messagesView.setVisibility(View.GONE); - } - - private void stopLoading() { - binding.swipeRefresh.setRefreshing(false); - binding.messagesView.setVisibility(View.VISIBLE); - } - - @Override - protected void onResume() { - - Context context = getApplicationContext(); - NotificationManager nManager = - ((NotificationManager) context.getSystemService(Context.NOTIFICATION_SERVICE)); - nManager.cancelAll(); - - IntentFilter filter = new IntentFilter(); - filter.addAction(WebSocketService.NEW_MESSAGE_BROADCAST); - registerReceiver(receiver, filter); - new UpdateMissedMessages().execute(viewModel.getMessages().getLastReceivedMessage()); - - int selectedIndex = R.id.nav_all_messages; - long appId = viewModel.getAppId(); - if (appId != MessageState.ALL_MESSAGES) { - List apps = viewModel.getAppsHolder().get(); - for (int i = 0; i < apps.size(); i++) { - if (apps.get(i).getId() == appId) { - selectedIndex = i; - } - } - } - - listMessageAdapter.notifyDataSetChanged(); - selectAppInMenu(binding.navView.getMenu().findItem(selectedIndex)); - super.onResume(); - } - - @Override - protected void onPause() { - unregisterReceiver(receiver); - super.onPause(); - } - - private void selectAppInMenu(MenuItem appItem) { - if (appItem != null) { - appItem.setChecked(true); - if (appItem.getItemId() != R.id.nav_all_messages) - binding.appBarDrawer.toolbar.setSubtitle(appItem.getTitle()); - } - } - - private void scheduleDeletion(int position, Message message, boolean listAnimation) { - ListMessageAdapter adapter = (ListMessageAdapter) binding.messagesView.getAdapter(); - - MessageFacade messages = viewModel.getMessages(); - messages.deleteLocal(message); - adapter.setItems(messages.get(viewModel.getAppId())); - - if (listAnimation) adapter.notifyItemRemoved(position); - else adapter.notifyDataSetChanged(); - - showDeletionSnackbar(); - } - - private void undoDelete() { - MessageFacade messages = viewModel.getMessages(); - MessageDeletion deletion = messages.undoDeleteLocal(); - if (deletion != null) { - ListMessageAdapter adapter = (ListMessageAdapter) binding.messagesView.getAdapter(); - long appId = viewModel.getAppId(); - adapter.setItems(messages.get(appId)); - int insertPosition = - appId == MessageState.ALL_MESSAGES - ? deletion.getAllPosition() - : deletion.getAppPosition(); - adapter.notifyItemInserted(insertPosition); - } - } - - private void showDeletionSnackbar() { - View view = binding.swipeRefresh; - Snackbar snackbar = Snackbar.make(view, R.string.snackbar_deleted, Snackbar.LENGTH_LONG); - snackbar.setAction(R.string.snackbar_undo, v -> undoDelete()); - snackbar.addCallback(new SnackbarCallback()); - snackbar.show(); - } - - private class SnackbarCallback extends BaseTransientBottomBar.BaseCallback { - @Override - public void onDismissed(Snackbar transientBottomBar, int event) { - super.onDismissed(transientBottomBar, event); - if (event != DISMISS_EVENT_ACTION && event != DISMISS_EVENT_CONSECUTIVE) { - // Execute deletion when the snackbar disappeared without pressing the undo button - // DISMISS_EVENT_CONSECUTIVE should be excluded as well, because it would cause the - // deletion to be sent to the server twice, since the deletion is sent to the server - // in MessageFacade if a message is deleted while another message was already - // waiting for deletion. - MessagesActivity.this.commitDelete(); - } - } - } - - private class SwipeToDeleteCallback extends ItemTouchHelper.SimpleCallback { - private final ListMessageAdapter adapter; - private Drawable icon; - private final ColorDrawable background; - - public SwipeToDeleteCallback(ListMessageAdapter adapter) { - super(0, ItemTouchHelper.LEFT | ItemTouchHelper.RIGHT); - this.adapter = adapter; - - int backgroundColorId = - ContextCompat.getColor(MessagesActivity.this, R.color.swipeBackground); - int iconColorId = ContextCompat.getColor(MessagesActivity.this, R.color.swipeIcon); - - Drawable drawable = - ContextCompat.getDrawable(MessagesActivity.this, R.drawable.ic_delete); - icon = null; - if (drawable != null) { - icon = DrawableCompat.wrap(drawable.mutate()); - DrawableCompat.setTint(icon, iconColorId); - } - - background = new ColorDrawable(backgroundColorId); - } - - @Override - public boolean onMove( - @NonNull RecyclerView recyclerView, - @NonNull RecyclerView.ViewHolder viewHolder, - @NonNull RecyclerView.ViewHolder target) { - return false; - } - - @Override - public void onSwiped(@NonNull RecyclerView.ViewHolder viewHolder, int direction) { - int position = viewHolder.getAdapterPosition(); - MessageWithImage message = adapter.getItems().get(position); - scheduleDeletion(position, message.message, true); - } - - @Override - public void onChildDraw( - @NonNull Canvas c, - @NonNull RecyclerView recyclerView, - @NonNull RecyclerView.ViewHolder viewHolder, - float dX, - float dY, - int actionState, - boolean isCurrentlyActive) { - if (icon != null) { - View itemView = viewHolder.itemView; - - int iconHeight = itemView.getHeight() / 3; - double scale = iconHeight / (double) icon.getIntrinsicHeight(); - int iconWidth = (int) (icon.getIntrinsicWidth() * scale); - - int iconMarginLeftRight = 50; - int iconMarginTopBottom = (itemView.getHeight() - iconHeight) / 2; - int iconTop = itemView.getTop() + iconMarginTopBottom; - int iconBottom = itemView.getBottom() - iconMarginTopBottom; - - if (dX > 0) { - // Swiping to the right - int iconLeft = itemView.getLeft() + iconMarginLeftRight; - int iconRight = itemView.getLeft() + iconMarginLeftRight + iconWidth; - icon.setBounds(iconLeft, iconTop, iconRight, iconBottom); - - background.setBounds( - itemView.getLeft(), - itemView.getTop(), - itemView.getLeft() + ((int) dX), - itemView.getBottom()); - } else if (dX < 0) { - // Swiping to the left - int iconLeft = itemView.getRight() - iconMarginLeftRight - iconWidth; - int iconRight = itemView.getRight() - iconMarginLeftRight; - icon.setBounds(iconLeft, iconTop, iconRight, iconBottom); - - background.setBounds( - itemView.getRight() + ((int) dX), - itemView.getTop(), - itemView.getRight(), - itemView.getBottom()); - } else { - // View is unswiped - icon.setBounds(0, 0, 0, 0); - background.setBounds(0, 0, 0, 0); - } - - background.draw(c); - icon.draw(c); - } - - super.onChildDraw(c, recyclerView, viewHolder, dX, dY, actionState, isCurrentlyActive); - } - } - - private class MessageListOnScrollListener extends RecyclerView.OnScrollListener { - @Override - public void onScrollStateChanged(@NonNull RecyclerView view, int scrollState) {} - - @Override - public void onScrolled(RecyclerView view, int dx, int dy) { - LinearLayoutManager linearLayoutManager = (LinearLayoutManager) view.getLayoutManager(); - if (linearLayoutManager != null) { - int lastVisibleItem = linearLayoutManager.findLastVisibleItemPosition(); - int totalItemCount = view.getAdapter().getItemCount(); - - if (lastVisibleItem > totalItemCount - 15 - && totalItemCount != 0 - && viewModel.getMessages().canLoadMore(viewModel.getAppId())) { - if (!isLoadMore) { - isLoadMore = true; - new LoadMore().execute(viewModel.getAppId()); - } - } - } - } - } - - private class UpdateMissedMessages extends AsyncTask { - @Override - protected Boolean doInBackground(Long... ids) { - Long id = first(ids); - if (id == -1) { - return false; - } - - List newMessages = - new MissedMessageUtil(viewModel.getClient().createService(MessageApi.class)) - .missingMessages(id); - viewModel.getMessages().addMessages(newMessages); - return !newMessages.isEmpty(); - } - - @Override - protected void onPostExecute(Boolean update) { - if (update) { - new UpdateMessagesForApplication(true).execute(viewModel.getAppId()); - } - } - } - - @Override - public boolean onCreateOptionsMenu(Menu menu) { - getMenuInflater().inflate(R.menu.messages_action, menu); - menu.findItem(R.id.action_delete_app) - .setVisible(viewModel.getAppId() != MessageState.ALL_MESSAGES); - return super.onCreateOptionsMenu(menu); - } - - @Override - public boolean onOptionsItemSelected(MenuItem item) { - if (item.getItemId() == R.id.action_delete_all) { - new DeleteMessages().execute(viewModel.getAppId()); - } - if (item.getItemId() == R.id.action_delete_app) { - android.app.AlertDialog.Builder alert = new android.app.AlertDialog.Builder(this); - alert.setTitle(R.string.delete_app); - alert.setMessage(R.string.ack); - alert.setPositiveButton( - R.string.yes, (dialog, which) -> deleteApp(viewModel.getAppId())); - alert.setNegativeButton(R.string.no, (dialog, which) -> dialog.dismiss()); - alert.show(); - } - return super.onContextItemSelected(item); - } - - private void deleteApp(Long appId) { - Settings settings = viewModel.getSettings(); - ApiClient client = - ClientFactory.clientToken(settings.url(), settings.sslSettings(), settings.token()); - - client.createService(ApplicationApi.class) - .deleteApp(appId) - .enqueue( - Callback.callInUI( - this, - (ignored) -> refreshAll(), - (e) -> - Utils.showSnackBar( - this, getString(R.string.error_delete_app)))); - } - - private class LoadMore extends AsyncTask> { - - @Override - protected List doInBackground(Long... appId) { - return viewModel.getMessages().loadMore(first(appId)); - } - - @Override - protected void onPostExecute(List messageWithImages) { - updateMessagesAndStopLoading(messageWithImages); - } - } - - private class UpdateMessagesForApplication extends AsyncTask { - - private UpdateMessagesForApplication(boolean withLoadingSpinner) { - if (withLoadingSpinner) { - startLoading(); - } - } - - @Override - protected Long doInBackground(Long... appIds) { - Long appId = first(appIds); - viewModel.getMessages().loadMoreIfNotPresent(appId); - return appId; - } - - @Override - protected void onPostExecute(Long appId) { - updateMessagesAndStopLoading(viewModel.getMessages().get(appId)); - } - } - - private class NewSingleMessage extends AsyncTask { - - @Override - protected Void doInBackground(Message... newMessages) { - viewModel.getMessages().addMessages(Arrays.asList(newMessages)); - return null; - } - - @Override - protected void onPostExecute(Void data) { - new UpdateMessagesForApplication(false).execute(viewModel.getAppId()); - } - } - - private class CommitDeleteMessage extends AsyncTask { - - @Override - protected Void doInBackground(Void... messages) { - viewModel.getMessages().commitDelete(); - return null; - } - - @Override - protected void onPostExecute(Void data) { - new UpdateMessagesForApplication(false).execute(viewModel.getAppId()); - } - } - - private class DeleteMessages extends AsyncTask { - - DeleteMessages() { - startLoading(); - } - - @Override - protected Boolean doInBackground(Long... appId) { - return viewModel.getMessages().deleteAll(first(appId)); - } - - @Override - protected void onPostExecute(Boolean success) { - if (!success) { - Utils.showSnackBar(MessagesActivity.this, "Delete failed :("); - } - new UpdateMessagesForApplication(false).execute(viewModel.getAppId()); - } - } - - private class DeleteClientAndNavigateToLogin extends AsyncTask { - - @Override - protected Void doInBackground(Void... ignore) { - Settings settings = viewModel.getSettings(); - ClientApi api = - ClientFactory.clientToken( - settings.url(), settings.sslSettings(), settings.token()) - .createService(ClientApi.class); - stopService(new Intent(MessagesActivity.this, WebSocketService.class)); - try { - List clients = Api.execute(api.getClients()); - - Client currentClient = null; - for (Client client : clients) { - if (client.getToken().equals(settings.token())) { - currentClient = client; - break; - } - } - - if (currentClient != null) { - Log.i("Delete client with id " + currentClient.getId()); - Api.execute(api.deleteClient(currentClient.getId())); - } else { - Log.e("Could not delete client, client does not exist."); - } - - } catch (ApiException e) { - Log.e("Could not delete client", e); - } - return null; - } - - @Override - protected void onPostExecute(Void aVoid) { - viewModel.getSettings().clear(); - startActivity(new Intent(MessagesActivity.this, LoginActivity.class)); - finish(); - super.onPostExecute(aVoid); - } - } - - private void updateMessagesAndStopLoading(List messageWithImages) { - isLoadMore = false; - stopLoading(); - - if (messageWithImages.isEmpty()) { - binding.flipper.setDisplayedChild(1); - } else { - binding.flipper.setDisplayedChild(0); - } - - ListMessageAdapter adapter = (ListMessageAdapter) binding.messagesView.getAdapter(); - adapter.setItems(messageWithImages); - adapter.notifyDataSetChanged(); - } -} diff --git a/app/src/main/java/com/github/gotify/messages/MessagesModel.java b/app/src/main/java/com/github/gotify/messages/MessagesModel.java deleted file mode 100644 index 542f09e..0000000 --- a/app/src/main/java/com/github/gotify/messages/MessagesModel.java +++ /dev/null @@ -1,70 +0,0 @@ -package com.github.gotify.messages; - -import android.app.Activity; -import androidx.lifecycle.ViewModel; -import com.github.gotify.Settings; -import com.github.gotify.api.ClientFactory; -import com.github.gotify.client.ApiClient; -import com.github.gotify.client.api.MessageApi; -import com.github.gotify.messages.provider.ApplicationHolder; -import com.github.gotify.messages.provider.MessageFacade; -import com.github.gotify.messages.provider.MessageState; -import com.github.gotify.picasso.PicassoHandler; -import com.squareup.picasso.Target; -import java.util.ArrayList; -import java.util.List; - -public class MessagesModel extends ViewModel { - private final Settings settings; - private final PicassoHandler picassoHandler; - private final ApiClient client; - private final ApplicationHolder appsHolder; - private final MessageFacade messages; - - // we need to keep the target references otherwise they get gc'ed before they can be called. - @SuppressWarnings("MismatchedQueryAndUpdateOfCollection") - private final List targetReferences = new ArrayList<>(); - - private long appId = MessageState.ALL_MESSAGES; - - public MessagesModel(Activity parentView) { - settings = new Settings(parentView); - picassoHandler = new PicassoHandler(parentView, settings); - client = - ClientFactory.clientToken(settings.url(), settings.sslSettings(), settings.token()); - appsHolder = new ApplicationHolder(parentView, client); - messages = new MessageFacade(client.createService(MessageApi.class), appsHolder); - } - - public Settings getSettings() { - return settings; - } - - public PicassoHandler getPicassoHandler() { - return picassoHandler; - } - - public ApiClient getClient() { - return client; - } - - public ApplicationHolder getAppsHolder() { - return appsHolder; - } - - public MessageFacade getMessages() { - return messages; - } - - public List getTargetReferences() { - return targetReferences; - } - - public long getAppId() { - return appId; - } - - public void setAppId(long appId) { - this.appId = appId; - } -} diff --git a/app/src/main/java/com/github/gotify/messages/MessagesModelFactory.java b/app/src/main/java/com/github/gotify/messages/MessagesModelFactory.java deleted file mode 100644 index ec28f8b..0000000 --- a/app/src/main/java/com/github/gotify/messages/MessagesModelFactory.java +++ /dev/null @@ -1,28 +0,0 @@ -package com.github.gotify.messages; - -import android.app.Activity; -import androidx.annotation.NonNull; -import androidx.lifecycle.ViewModel; -import androidx.lifecycle.ViewModelProvider; -import java.util.Objects; - -public class MessagesModelFactory implements ViewModelProvider.Factory { - - Activity modelParameterActivity; - - public MessagesModelFactory(Activity activity) { - modelParameterActivity = activity; - } - - @NonNull - @Override - public T create(@NonNull Class modelClass) { - if (modelClass == MessagesModel.class) { - return Objects.requireNonNull( - modelClass.cast(new MessagesModel(modelParameterActivity))); - } - throw new IllegalArgumentException( - String.format( - "modelClass parameter must be of type %s", MessagesModel.class.getName())); - } -} diff --git a/app/src/main/java/com/github/gotify/messages/provider/ApplicationHolder.java b/app/src/main/java/com/github/gotify/messages/provider/ApplicationHolder.java deleted file mode 100644 index 043c757..0000000 --- a/app/src/main/java/com/github/gotify/messages/provider/ApplicationHolder.java +++ /dev/null @@ -1,56 +0,0 @@ -package com.github.gotify.messages.provider; - -import android.app.Activity; -import com.github.gotify.Utils; -import com.github.gotify.api.ApiException; -import com.github.gotify.api.Callback; -import com.github.gotify.client.ApiClient; -import com.github.gotify.client.api.ApplicationApi; -import com.github.gotify.client.model.Application; -import java.util.Collections; -import java.util.List; - -public class ApplicationHolder { - private List state; - private Runnable onUpdate; - private Runnable onUpdateFailed; - private Activity activity; - private ApiClient client; - - public ApplicationHolder(Activity activity, ApiClient client) { - this.activity = activity; - this.client = client; - } - - public boolean wasRequested() { - return state != null; - } - - public void request() { - client.createService(ApplicationApi.class) - .getApps() - .enqueue(Callback.callInUI(activity, this::onReceiveApps, this::onFailedApps)); - } - - private void onReceiveApps(List apps) { - state = apps; - if (onUpdate != null) onUpdate.run(); - } - - private void onFailedApps(ApiException e) { - Utils.showSnackBar(activity, "Could not request applications, see logs."); - if (onUpdateFailed != null) onUpdateFailed.run(); - } - - public List get() { - return state == null ? Collections.emptyList() : state; - } - - public void onUpdate(Runnable runnable) { - this.onUpdate = runnable; - } - - public void onUpdateFailed(Runnable runnable) { - this.onUpdateFailed = runnable; - } -} diff --git a/app/src/main/java/com/github/gotify/messages/provider/MessageDeletion.java b/app/src/main/java/com/github/gotify/messages/provider/MessageDeletion.java deleted file mode 100644 index 471f77b..0000000 --- a/app/src/main/java/com/github/gotify/messages/provider/MessageDeletion.java +++ /dev/null @@ -1,27 +0,0 @@ -package com.github.gotify.messages.provider; - -import com.github.gotify.client.model.Message; - -public final class MessageDeletion { - private final Message message; - private final int allPosition; - private final int appPosition; - - public MessageDeletion(Message message, int allPosition, int appPosition) { - this.message = message; - this.allPosition = allPosition; - this.appPosition = appPosition; - } - - public int getAllPosition() { - return allPosition; - } - - public int getAppPosition() { - return appPosition; - } - - public Message getMessage() { - return message; - } -} diff --git a/app/src/main/java/com/github/gotify/messages/provider/MessageFacade.java b/app/src/main/java/com/github/gotify/messages/provider/MessageFacade.java deleted file mode 100644 index 992bfba..0000000 --- a/app/src/main/java/com/github/gotify/messages/provider/MessageFacade.java +++ /dev/null @@ -1,84 +0,0 @@ -package com.github.gotify.messages.provider; - -import com.github.gotify.client.api.MessageApi; -import com.github.gotify.client.model.Message; -import com.github.gotify.client.model.PagedMessages; -import java.util.List; - -public class MessageFacade { - private final ApplicationHolder applicationHolder; - private final MessageRequester requester; - private final MessageStateHolder state; - private final MessageImageCombiner combiner; - - public MessageFacade(MessageApi api, ApplicationHolder applicationHolder) { - this.applicationHolder = applicationHolder; - this.requester = new MessageRequester(api); - this.combiner = new MessageImageCombiner(); - this.state = new MessageStateHolder(); - } - - public synchronized List get(long appId) { - return combiner.combine(state.state(appId).messages, applicationHolder.get()); - } - - public synchronized void addMessages(List messages) { - for (Message message : messages) { - state.newMessage(message); - } - } - - public synchronized List loadMore(long appId) { - MessageState state = this.state.state(appId); - if (state.hasNext || !state.loaded) { - PagedMessages pagedMessages = requester.loadMore(state); - if (pagedMessages != null) { - this.state.newMessages(appId, pagedMessages); - } - } - return get(appId); - } - - public synchronized void loadMoreIfNotPresent(long appId) { - MessageState state = this.state.state(appId); - if (!state.loaded) { - loadMore(appId); - } - } - - public synchronized void clear() { - this.state.clear(); - } - - public long getLastReceivedMessage() { - return state.getLastReceivedMessage(); - } - - public synchronized void deleteLocal(Message message) { - // If there is already a deletion pending, that one should be executed before scheduling the - // next deletion. - if (this.state.deletionPending()) commitDelete(); - this.state.deleteMessage(message); - } - - public synchronized void commitDelete() { - if (this.state.deletionPending()) { - MessageDeletion deletion = this.state.purgePendingDeletion(); - this.requester.asyncRemoveMessage(deletion.getMessage()); - } - } - - public synchronized MessageDeletion undoDeleteLocal() { - return this.state.undoPendingDeletion(); - } - - public synchronized boolean deleteAll(long appId) { - boolean success = this.requester.deleteAll(appId); - this.state.deleteAll(appId); - return success; - } - - public synchronized boolean canLoadMore(long appId) { - return state.state(appId).hasNext; - } -} diff --git a/app/src/main/java/com/github/gotify/messages/provider/MessageImageCombiner.java b/app/src/main/java/com/github/gotify/messages/provider/MessageImageCombiner.java deleted file mode 100644 index a258b02..0000000 --- a/app/src/main/java/com/github/gotify/messages/provider/MessageImageCombiner.java +++ /dev/null @@ -1,36 +0,0 @@ -package com.github.gotify.messages.provider; - -import com.github.gotify.client.model.Application; -import com.github.gotify.client.model.Message; -import java.util.ArrayList; -import java.util.List; -import java.util.Map; -import java.util.concurrent.ConcurrentHashMap; - -public class MessageImageCombiner { - - List combine(List messages, List applications) { - Map appIdToImage = appIdToImage(applications); - - List result = new ArrayList<>(); - - for (Message message : messages) { - MessageWithImage messageWithImage = new MessageWithImage(); - - messageWithImage.message = message; - messageWithImage.image = appIdToImage.get(message.getAppid()); - - result.add(messageWithImage); - } - - return result; - } - - public static Map appIdToImage(List applications) { - Map map = new ConcurrentHashMap<>(); - for (Application app : applications) { - map.put(app.getId(), app.getImage()); - } - return map; - } -} diff --git a/app/src/main/java/com/github/gotify/messages/provider/MessageRequester.java b/app/src/main/java/com/github/gotify/messages/provider/MessageRequester.java deleted file mode 100644 index 707ffc8..0000000 --- a/app/src/main/java/com/github/gotify/messages/provider/MessageRequester.java +++ /dev/null @@ -1,52 +0,0 @@ -package com.github.gotify.messages.provider; - -import com.github.gotify.api.Api; -import com.github.gotify.api.ApiException; -import com.github.gotify.api.Callback; -import com.github.gotify.client.api.MessageApi; -import com.github.gotify.client.model.Message; -import com.github.gotify.client.model.PagedMessages; -import com.github.gotify.log.Log; - -class MessageRequester { - private static final Integer LIMIT = 100; - private MessageApi messageApi; - - MessageRequester(MessageApi messageApi) { - this.messageApi = messageApi; - } - - PagedMessages loadMore(MessageState state) { - try { - Log.i("Loading more messages for " + state.appId); - if (MessageState.ALL_MESSAGES == state.appId) { - return Api.execute(messageApi.getMessages(LIMIT, state.nextSince)); - } else { - return Api.execute(messageApi.getAppMessages(state.appId, LIMIT, state.nextSince)); - } - } catch (ApiException apiException) { - Log.e("failed requesting messages", apiException); - return null; - } - } - - void asyncRemoveMessage(Message message) { - Log.i("Removing message with id " + message.getId()); - messageApi.deleteMessage(message.getId()).enqueue(Callback.call()); - } - - boolean deleteAll(Long appId) { - try { - Log.i("Deleting all messages for " + appId); - if (MessageState.ALL_MESSAGES == appId) { - Api.execute(messageApi.deleteMessages()); - } else { - Api.execute(messageApi.deleteAppMessages(appId)); - } - return true; - } catch (ApiException e) { - Log.e("Could not delete messages", e); - return false; - } - } -} diff --git a/app/src/main/java/com/github/gotify/messages/provider/MessageState.java b/app/src/main/java/com/github/gotify/messages/provider/MessageState.java deleted file mode 100644 index d90da5f..0000000 --- a/app/src/main/java/com/github/gotify/messages/provider/MessageState.java +++ /dev/null @@ -1,15 +0,0 @@ -package com.github.gotify.messages.provider; - -import com.github.gotify.client.model.Message; -import java.util.ArrayList; -import java.util.List; - -public class MessageState { - public static final long ALL_MESSAGES = -1; - - long appId; - boolean loaded; - boolean hasNext; - long nextSince = 0; - List messages = new ArrayList<>(); -} diff --git a/app/src/main/java/com/github/gotify/messages/provider/MessageStateHolder.java b/app/src/main/java/com/github/gotify/messages/provider/MessageStateHolder.java deleted file mode 100644 index 4065d46..0000000 --- a/app/src/main/java/com/github/gotify/messages/provider/MessageStateHolder.java +++ /dev/null @@ -1,134 +0,0 @@ -package com.github.gotify.messages.provider; - -import com.github.gotify.client.model.Message; -import com.github.gotify.client.model.PagedMessages; -import java.util.HashMap; -import java.util.Map; - -class MessageStateHolder { - private long lastReceivedMessage = -1; - private Map states = new HashMap<>(); - - private MessageDeletion pendingDeletion = null; - - synchronized void clear() { - states = new HashMap<>(); - } - - synchronized void newMessages(Long appId, PagedMessages pagedMessages) { - MessageState state = state(appId); - - if (!state.loaded && pagedMessages.getMessages().size() > 0) { - lastReceivedMessage = - Math.max(pagedMessages.getMessages().get(0).getId(), lastReceivedMessage); - } - - state.loaded = true; - state.messages.addAll(pagedMessages.getMessages()); - state.hasNext = pagedMessages.getPaging().getNext() != null; - state.nextSince = pagedMessages.getPaging().getSince(); - state.appId = appId; - states.put(appId, state); - - // If there is a message with pending deletion, it should not reappear in the list in case - // it is added again. - if (deletionPending()) { - deleteMessage(pendingDeletion.getMessage()); - } - } - - synchronized void newMessage(Message message) { - // If there is a message with pending deletion, its indices are going to change. To keep - // them consistent the deletion is undone first and redone again after adding the new - // message. - MessageDeletion deletion = undoPendingDeletion(); - - addMessage(message, 0, 0); - lastReceivedMessage = message.getId(); - - if (deletion != null) deleteMessage(deletion.getMessage()); - } - - synchronized MessageState state(Long appId) { - MessageState state = states.get(appId); - if (state == null) { - return emptyState(appId); - } - return state; - } - - synchronized void deleteAll(Long appId) { - clear(); - MessageState state = state(appId); - state.loaded = true; - states.put(appId, state); - } - - private MessageState emptyState(Long appId) { - MessageState emptyState = new MessageState(); - emptyState.loaded = false; - emptyState.hasNext = false; - emptyState.nextSince = 0; - emptyState.appId = appId; - return emptyState; - } - - synchronized long getLastReceivedMessage() { - return lastReceivedMessage; - } - - synchronized void deleteMessage(Message message) { - MessageState allMessages = state(MessageState.ALL_MESSAGES); - MessageState appMessages = state(message.getAppid()); - - int pendingDeletedAllPosition = -1; - int pendingDeletedAppPosition = -1; - - if (allMessages.loaded) { - int allPosition = allMessages.messages.indexOf(message); - if (allPosition != -1) allMessages.messages.remove(allPosition); - pendingDeletedAllPosition = allPosition; - } - - if (appMessages.loaded) { - int appPosition = appMessages.messages.indexOf(message); - if (appPosition != -1) appMessages.messages.remove(appPosition); - pendingDeletedAppPosition = appPosition; - } - - pendingDeletion = - new MessageDeletion(message, pendingDeletedAllPosition, pendingDeletedAppPosition); - } - - synchronized MessageDeletion undoPendingDeletion() { - if (pendingDeletion != null) - addMessage( - pendingDeletion.getMessage(), - pendingDeletion.getAllPosition(), - pendingDeletion.getAppPosition()); - return purgePendingDeletion(); - } - - synchronized MessageDeletion purgePendingDeletion() { - MessageDeletion result = pendingDeletion; - pendingDeletion = null; - return result; - } - - synchronized boolean deletionPending() { - return pendingDeletion != null; - } - - private void addMessage(Message message, int allPosition, int appPosition) { - MessageState allMessages = state(MessageState.ALL_MESSAGES); - MessageState appMessages = state(message.getAppid()); - - if (allMessages.loaded && allPosition != -1) { - allMessages.messages.add(allPosition, message); - } - - if (appMessages.loaded && appPosition != -1) { - appMessages.messages.add(appPosition, message); - } - } -} diff --git a/app/src/main/java/com/github/gotify/messages/provider/MessageWithImage.java b/app/src/main/java/com/github/gotify/messages/provider/MessageWithImage.java deleted file mode 100644 index 6a7ef47..0000000 --- a/app/src/main/java/com/github/gotify/messages/provider/MessageWithImage.java +++ /dev/null @@ -1,8 +0,0 @@ -package com.github.gotify.messages.provider; - -import com.github.gotify.client.model.Message; - -public class MessageWithImage { - public Message message; - public String image; -} diff --git a/app/src/main/java/com/github/gotify/picasso/PicassoDataRequestHandler.java b/app/src/main/java/com/github/gotify/picasso/PicassoDataRequestHandler.java deleted file mode 100644 index 1fb06e1..0000000 --- a/app/src/main/java/com/github/gotify/picasso/PicassoDataRequestHandler.java +++ /dev/null @@ -1,41 +0,0 @@ -package com.github.gotify.picasso; - -import android.graphics.Bitmap; -import android.graphics.BitmapFactory; -import android.util.Base64; -import com.github.gotify.log.Log; -import com.squareup.picasso.Picasso; -import com.squareup.picasso.Request; -import com.squareup.picasso.RequestHandler; - -/** - * Adapted from https://github.com/square/picasso/issues/1395#issuecomment-220929377 By - * https://github.com/SmartDengg - */ -public class PicassoDataRequestHandler extends RequestHandler { - - private static final String DATA_SCHEME = "data"; - - @Override - public boolean canHandleRequest(Request data) { - String scheme = data.uri.getScheme(); - return DATA_SCHEME.equalsIgnoreCase(scheme); - } - - @Override - public Result load(Request request, int networkPolicy) { - String uri = request.uri.toString(); - String imageDataBytes = uri.substring(uri.indexOf(",") + 1); - byte[] bytes = Base64.decode(imageDataBytes.getBytes(), Base64.DEFAULT); - Bitmap bitmap = BitmapFactory.decodeByteArray(bytes, 0, bytes.length); - - if (bitmap == null) { - String show = uri.length() > 50 ? uri.substring(0, 49) + "..." : uri; - RuntimeException malformed = new RuntimeException("Malformed data uri: " + show); - Log.e("Could not load image", malformed); - throw malformed; - } - - return new Result(bitmap, Picasso.LoadedFrom.NETWORK); - } -} diff --git a/app/src/main/java/com/github/gotify/picasso/PicassoHandler.java b/app/src/main/java/com/github/gotify/picasso/PicassoHandler.java deleted file mode 100644 index 995224e..0000000 --- a/app/src/main/java/com/github/gotify/picasso/PicassoHandler.java +++ /dev/null @@ -1,99 +0,0 @@ -package com.github.gotify.picasso; - -import android.content.Context; -import android.graphics.Bitmap; -import android.graphics.BitmapFactory; -import com.github.gotify.R; -import com.github.gotify.Settings; -import com.github.gotify.Utils; -import com.github.gotify.api.Callback; -import com.github.gotify.api.CertUtils; -import com.github.gotify.api.ClientFactory; -import com.github.gotify.client.api.ApplicationApi; -import com.github.gotify.log.Log; -import com.github.gotify.messages.provider.MessageImageCombiner; -import com.squareup.picasso.OkHttp3Downloader; -import com.squareup.picasso.Picasso; -import java.io.File; -import java.io.IOException; -import java.util.Map; -import java.util.concurrent.ConcurrentHashMap; -import okhttp3.Cache; -import okhttp3.OkHttpClient; - -public class PicassoHandler { - - private static final int PICASSO_CACHE_SIZE = 50 * 1024 * 1024; // 50 MB - private static final String PICASSO_CACHE_SUBFOLDER = "picasso-cache"; - - private Context context; - private Settings settings; - - private Cache picassoCache; - - private Picasso picasso; - private Map appIdToAppImage = new ConcurrentHashMap<>(); - - public PicassoHandler(Context context, Settings settings) { - this.context = context; - this.settings = settings; - - picassoCache = - new Cache( - new File(context.getCacheDir(), PICASSO_CACHE_SUBFOLDER), - PICASSO_CACHE_SIZE); - picasso = makePicasso(); - } - - private Picasso makePicasso() { - OkHttpClient.Builder builder = new OkHttpClient.Builder(); - builder.cache(picassoCache); - CertUtils.applySslSettings(builder, settings.sslSettings()); - OkHttp3Downloader downloader = new OkHttp3Downloader(builder.build()); - return new Picasso.Builder(context) - .addRequestHandler(new PicassoDataRequestHandler()) - .downloader(downloader) - .build(); - } - - public Bitmap getImageFromUrl(String url) throws IOException { - return picasso.load(url).get(); - } - - public Bitmap getIcon(Long appId) { - if (appId == -1) { - return BitmapFactory.decodeResource(context.getResources(), R.drawable.gotify); - } - - try { - return getImageFromUrl( - Utils.resolveAbsoluteUrl(settings.url() + "/", appIdToAppImage.get(appId))); - } catch (IOException e) { - Log.e("Could not load image for notification", e); - } - return BitmapFactory.decodeResource(context.getResources(), R.drawable.gotify); - } - - public void updateAppIds() { - ClientFactory.clientToken(settings.url(), settings.sslSettings(), settings.token()) - .createService(ApplicationApi.class) - .getApps() - .enqueue( - Callback.call( - (apps) -> { - appIdToAppImage.clear(); - appIdToAppImage.putAll(MessageImageCombiner.appIdToImage(apps)); - }, - (t) -> { - appIdToAppImage.clear(); - })); - } - - public Picasso get() { - return picasso; - } - - public void evict() throws IOException { - picassoCache.evictAll(); - } -} diff --git a/app/src/main/java/com/github/gotify/service/WebSocketConnection.java b/app/src/main/java/com/github/gotify/service/WebSocketConnection.java deleted file mode 100644 index 30e3e1d..0000000 --- a/app/src/main/java/com/github/gotify/service/WebSocketConnection.java +++ /dev/null @@ -1,255 +0,0 @@ -package com.github.gotify.service; - -import android.app.AlarmManager; -import android.net.ConnectivityManager; -import android.net.NetworkInfo; -import android.os.Build; -import android.os.Handler; -import com.github.gotify.SSLSettings; -import com.github.gotify.Utils; -import com.github.gotify.api.Callback; -import com.github.gotify.api.CertUtils; -import com.github.gotify.client.model.Message; -import com.github.gotify.log.Log; -import java.util.Calendar; -import java.util.concurrent.TimeUnit; -import java.util.concurrent.atomic.AtomicLong; -import okhttp3.HttpUrl; -import okhttp3.OkHttpClient; -import okhttp3.Request; -import okhttp3.Response; -import okhttp3.WebSocket; -import okhttp3.WebSocketListener; - -class WebSocketConnection { - private static final AtomicLong ID = new AtomicLong(0); - private final ConnectivityManager connectivityManager; - private final AlarmManager alarmManager; - private OkHttpClient client; - - private final Handler reconnectHandler = new Handler(); - private Runnable reconnectCallback = this::start; - private int errorCount = 0; - - private final String baseUrl; - private final String token; - private WebSocket webSocket; - private Callback.SuccessCallback onMessage; - private Runnable onClose; - private Runnable onOpen; - private BadRequestRunnable onBadRequest; - private OnNetworkFailureRunnable onNetworkFailure; - private Runnable onReconnected; - private State state; - - WebSocketConnection( - String baseUrl, - SSLSettings settings, - String token, - ConnectivityManager connectivityManager, - AlarmManager alarmManager) { - this.connectivityManager = connectivityManager; - this.alarmManager = alarmManager; - OkHttpClient.Builder builder = - new OkHttpClient.Builder() - .readTimeout(0, TimeUnit.MILLISECONDS) - .pingInterval(1, TimeUnit.MINUTES) - .connectTimeout(10, TimeUnit.SECONDS); - CertUtils.applySslSettings(builder, settings); - - client = builder.build(); - - this.baseUrl = baseUrl; - this.token = token; - } - - synchronized WebSocketConnection onMessage(Callback.SuccessCallback onMessage) { - this.onMessage = onMessage; - return this; - } - - synchronized WebSocketConnection onClose(Runnable onClose) { - this.onClose = onClose; - return this; - } - - synchronized WebSocketConnection onOpen(Runnable onOpen) { - this.onOpen = onOpen; - return this; - } - - synchronized WebSocketConnection onBadRequest(BadRequestRunnable onBadRequest) { - this.onBadRequest = onBadRequest; - return this; - } - - synchronized WebSocketConnection onNetworkFailure(OnNetworkFailureRunnable onNetworkFailure) { - this.onNetworkFailure = onNetworkFailure; - return this; - } - - synchronized WebSocketConnection onReconnected(Runnable onReconnected) { - this.onReconnected = onReconnected; - return this; - } - - private Request request() { - HttpUrl url = - HttpUrl.parse(baseUrl) - .newBuilder() - .addPathSegment("stream") - .addQueryParameter("token", token) - .build(); - return new Request.Builder().url(url).get().build(); - } - - public synchronized WebSocketConnection start() { - if (state == State.Connecting || state == State.Connected) { - return this; - } - close(); - state = State.Connecting; - long nextId = ID.incrementAndGet(); - Log.i("WebSocket(" + nextId + "): starting..."); - - webSocket = client.newWebSocket(request(), new Listener(nextId)); - return this; - } - - public synchronized void close() { - if (webSocket != null) { - Log.i("WebSocket(" + ID.get() + "): closing existing connection."); - state = State.Disconnected; - webSocket.close(1000, ""); - webSocket = null; - } - } - - public synchronized void scheduleReconnect(long seconds) { - if (state == State.Connecting || state == State.Connected) { - return; - } - state = State.Scheduled; - - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) { - Log.i( - "WebSocket: scheduling a restart in " - + seconds - + " second(s) (via alarm manager)"); - final Calendar future = Calendar.getInstance(); - future.add(Calendar.SECOND, (int) seconds); - alarmManager.setExact( - AlarmManager.RTC_WAKEUP, - future.getTimeInMillis(), - "reconnect-tag", - this::start, - null); - } else { - Log.i("WebSocket: scheduling a restart in " + seconds + " second(s)"); - reconnectHandler.removeCallbacks(reconnectCallback); - reconnectHandler.postDelayed(reconnectCallback, TimeUnit.SECONDS.toMillis(seconds)); - } - } - - private class Listener extends WebSocketListener { - private final long id; - - public Listener(long id) { - this.id = id; - } - - @Override - public void onOpen(WebSocket webSocket, Response response) { - syncExec( - () -> { - state = State.Connected; - Log.i("WebSocket(" + id + "): opened"); - onOpen.run(); - - if (errorCount > 0) { - onReconnected.run(); - errorCount = 0; - } - }); - super.onOpen(webSocket, response); - } - - @Override - public void onMessage(WebSocket webSocket, String text) { - syncExec( - () -> { - Log.i("WebSocket(" + id + "): received message " + text); - Message message = Utils.JSON.fromJson(text, Message.class); - onMessage.onSuccess(message); - }); - super.onMessage(webSocket, text); - } - - @Override - public void onClosed(WebSocket webSocket, int code, String reason) { - syncExec( - () -> { - if (state == State.Connected) { - Log.w("WebSocket(" + id + "): closed"); - onClose.run(); - } - state = State.Disconnected; - }); - - super.onClosed(webSocket, code, reason); - } - - @Override - public void onFailure(WebSocket webSocket, Throwable t, Response response) { - String code = response != null ? "StatusCode: " + response.code() : ""; - String message = response != null ? response.message() : ""; - Log.e("WebSocket(" + id + "): failure " + code + " Message: " + message, t); - syncExec( - () -> { - state = State.Disconnected; - if (response != null && response.code() >= 400 && response.code() <= 499) { - onBadRequest.execute(message); - close(); - return; - } - - errorCount++; - - NetworkInfo network = connectivityManager.getActiveNetworkInfo(); - if (network == null || !network.isConnected()) { - Log.i("WebSocket(" + id + "): Network not connected"); - } - - int minutes = Math.min(errorCount * 2 - 1, 20); - - onNetworkFailure.execute(minutes); - scheduleReconnect(TimeUnit.MINUTES.toSeconds(minutes)); - }); - - super.onFailure(webSocket, t, response); - } - - private void syncExec(Runnable runnable) { - synchronized (this) { - if (ID.get() == id) { - runnable.run(); - } - } - } - } - - interface BadRequestRunnable { - void execute(String message); - } - - interface OnNetworkFailureRunnable { - void execute(int minutes); - } - - enum State { - Scheduled, - Connecting, - Connected, - Disconnected - } -} diff --git a/app/src/main/java/com/github/gotify/service/WebSocketService.java b/app/src/main/java/com/github/gotify/service/WebSocketService.java deleted file mode 100644 index 7143112..0000000 --- a/app/src/main/java/com/github/gotify/service/WebSocketService.java +++ /dev/null @@ -1,389 +0,0 @@ -package com.github.gotify.service; - -import android.app.AlarmManager; -import android.app.Notification; -import android.app.NotificationManager; -import android.app.PendingIntent; -import android.app.Service; -import android.content.Context; -import android.content.Intent; -import android.content.IntentFilter; -import android.graphics.Color; -import android.net.ConnectivityManager; -import android.net.Uri; -import android.os.Build; -import android.os.IBinder; -import androidx.annotation.Nullable; -import androidx.annotation.RequiresApi; -import androidx.core.app.NotificationCompat; -import androidx.core.content.ContextCompat; -import com.github.gotify.MarkwonFactory; -import com.github.gotify.MissedMessageUtil; -import com.github.gotify.NotificationSupport; -import com.github.gotify.R; -import com.github.gotify.Settings; -import com.github.gotify.Utils; -import com.github.gotify.api.ClientFactory; -import com.github.gotify.client.ApiClient; -import com.github.gotify.client.api.MessageApi; -import com.github.gotify.client.model.Message; -import com.github.gotify.log.Log; -import com.github.gotify.log.UncaughtExceptionHandler; -import com.github.gotify.messages.Extras; -import com.github.gotify.messages.MessagesActivity; -import com.github.gotify.picasso.PicassoHandler; -import io.noties.markwon.Markwon; -import java.util.List; -import java.util.Map; -import java.util.concurrent.atomic.AtomicLong; - -import static com.github.gotify.api.Callback.call; - -public class WebSocketService extends Service { - - public static final String NEW_MESSAGE_BROADCAST = - WebSocketService.class.getName() + ".NEW_MESSAGE"; - - private static final long NOT_LOADED = -2; - - private Settings settings; - private WebSocketConnection connection; - - private AtomicLong lastReceivedMessage = new AtomicLong(NOT_LOADED); - private MissedMessageUtil missingMessageUtil; - - private PicassoHandler picassoHandler; - private Markwon markwon; - - @Override - public void onCreate() { - super.onCreate(); - settings = new Settings(this); - ApiClient client = - ClientFactory.clientToken(settings.url(), settings.sslSettings(), settings.token()); - missingMessageUtil = new MissedMessageUtil(client.createService(MessageApi.class)); - Log.i("Create " + getClass().getSimpleName()); - picassoHandler = new PicassoHandler(this, settings); - markwon = MarkwonFactory.createForNotification(this, picassoHandler.get()); - } - - @Override - public void onDestroy() { - super.onDestroy(); - if (connection != null) { - connection.close(); - } - Log.w("Destroy " + getClass().getSimpleName()); - } - - @Override - public int onStartCommand(Intent intent, int flags, int startId) { - Log.init(this); - - if (connection != null) { - connection.close(); - } - - Log.i("Starting " + getClass().getSimpleName()); - super.onStartCommand(intent, flags, startId); - new Thread(this::startPushService).run(); - - return START_STICKY; - } - - private void startPushService() { - UncaughtExceptionHandler.registerCurrentThread(); - showForegroundNotification(getString(R.string.websocket_init)); - - if (lastReceivedMessage.get() == NOT_LOADED) { - missingMessageUtil.lastReceivedMessage(lastReceivedMessage::set); - } - - ConnectivityManager cm = - (ConnectivityManager) getSystemService(Context.CONNECTIVITY_SERVICE); - AlarmManager alarmManager = (AlarmManager) getSystemService(Context.ALARM_SERVICE); - - connection = - new WebSocketConnection( - settings.url(), - settings.sslSettings(), - settings.token(), - cm, - alarmManager) - .onOpen(this::onOpen) - .onClose(this::onClose) - .onBadRequest(this::onBadRequest) - .onNetworkFailure(this::onNetworkFailure) - .onMessage(this::onMessage) - .onReconnected(this::notifyMissedNotifications) - .start(); - - IntentFilter intentFilter = new IntentFilter(); - intentFilter.addAction(ConnectivityManager.CONNECTIVITY_ACTION); - - picassoHandler.updateAppIds(); - } - - private void onClose() { - showForegroundNotification( - getString(R.string.websocket_closed), getString(R.string.websocket_reconnect)); - ClientFactory.userApiWithToken(settings) - .currentUser() - .enqueue( - call( - (ignored) -> this.doReconnect(), - (exception) -> { - if (exception.code() == 401) { - showForegroundNotification( - getString(R.string.user_action), - getString(R.string.websocket_closed_logout)); - } else { - Log.i( - "WebSocket closed but the user still authenticated, trying to reconnect"); - this.doReconnect(); - } - })); - } - - private void doReconnect() { - if (connection == null) { - return; - } - - connection.scheduleReconnect(15); - } - - private void onBadRequest(String message) { - showForegroundNotification(getString(R.string.websocket_could_not_connect), message); - } - - private void onNetworkFailure(int minutes) { - String status = getString(R.string.websocket_not_connected); - String intervalUnit = - getResources() - .getQuantityString(R.plurals.websocket_retry_interval, minutes, minutes); - showForegroundNotification( - status, getString(R.string.websocket_reconnect) + ' ' + intervalUnit); - } - - private void onOpen() { - showForegroundNotification(getString(R.string.websocket_listening)); - } - - private void notifyMissedNotifications() { - long messageId = lastReceivedMessage.get(); - if (messageId == NOT_LOADED) { - return; - } - - List messages = missingMessageUtil.missingMessages(messageId); - - if (messages.size() > 5) { - onGroupedMessages(messages); - } else { - for (Message message : messages) { - onMessage(message); - } - } - } - - private void onGroupedMessages(List messages) { - long highestPriority = 0; - for (Message message : messages) { - if (lastReceivedMessage.get() < message.getId()) { - lastReceivedMessage.set(message.getId()); - highestPriority = Math.max(highestPriority, message.getPriority()); - } - broadcast(message); - } - int size = messages.size(); - showNotification( - NotificationSupport.ID.GROUPED, - getString(R.string.missed_messages), - getString(R.string.grouped_message, size), - highestPriority, - null); - } - - private void onMessage(Message message) { - if (lastReceivedMessage.get() < message.getId()) { - lastReceivedMessage.set(message.getId()); - } - - broadcast(message); - showNotification( - message.getId(), - message.getTitle(), - message.getMessage(), - message.getPriority(), - message.getExtras(), - message.getAppid()); - } - - private void broadcast(Message message) { - Intent intent = new Intent(); - intent.setAction(NEW_MESSAGE_BROADCAST); - intent.putExtra("message", Utils.JSON.toJson(message)); - sendBroadcast(intent); - } - - @Nullable - @Override - public IBinder onBind(Intent intent) { - return null; - } - - private void showForegroundNotification(String title) { - showForegroundNotification(title, null); - } - - private void showForegroundNotification(String title, String message) { - Intent notificationIntent = new Intent(this, MessagesActivity.class); - - PendingIntent pendingIntent = - PendingIntent.getActivity( - this, 0, notificationIntent, PendingIntent.FLAG_IMMUTABLE); - - NotificationCompat.Builder notificationBuilder = - new NotificationCompat.Builder(this, NotificationSupport.Channel.FOREGROUND); - notificationBuilder.setSmallIcon(R.drawable.ic_gotify); - notificationBuilder.setOngoing(true); - notificationBuilder.setPriority(NotificationCompat.PRIORITY_MIN); - notificationBuilder.setShowWhen(false); - notificationBuilder.setWhen(0); - notificationBuilder.setContentTitle(title); - - if (message != null) { - notificationBuilder.setContentText(message); - notificationBuilder.setStyle(new NotificationCompat.BigTextStyle().bigText(message)); - } - - notificationBuilder.setContentIntent(pendingIntent); - notificationBuilder.setColor( - ContextCompat.getColor(getApplicationContext(), R.color.colorPrimary)); - - startForeground(NotificationSupport.ID.FOREGROUND, notificationBuilder.build()); - } - - private void showNotification( - int id, String title, String message, long priority, Map extras) { - showNotification(id, title, message, priority, extras, -1L); - } - - private void showNotification( - long id, - String title, - String message, - long priority, - Map extras, - Long appid) { - - Intent intent; - - String intentUrl = - Extras.getNestedValue( - String.class, extras, "android::action", "onReceive", "intentUrl"); - - if (intentUrl != null) { - intent = new Intent(Intent.ACTION_VIEW); - intent.setData(Uri.parse(intentUrl)); - intent.setFlags(Intent.FLAG_ACTIVITY_NEW_TASK); - startActivity(intent); - } - - String url = - Extras.getNestedValue(String.class, extras, "client::notification", "click", "url"); - - if (url != null) { - intent = new Intent(Intent.ACTION_VIEW); - intent.setData(Uri.parse(url)); - } else { - intent = new Intent(this, MessagesActivity.class); - } - - PendingIntent contentIntent = - PendingIntent.getActivity( - this, - 0, - intent, - PendingIntent.FLAG_UPDATE_CURRENT | PendingIntent.FLAG_IMMUTABLE); - - NotificationCompat.Builder b = - new NotificationCompat.Builder( - this, NotificationSupport.convertPriorityToChannel(priority)); - - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) { - showNotificationGroup(priority); - } - - b.setAutoCancel(true) - .setDefaults(Notification.DEFAULT_ALL) - .setWhen(System.currentTimeMillis()) - .setSmallIcon(R.drawable.ic_gotify) - .setLargeIcon(picassoHandler.getIcon(appid)) - .setTicker(getString(R.string.app_name) + " - " + title) - .setGroup(NotificationSupport.Group.MESSAGES) - .setContentTitle(title) - .setDefaults(Notification.DEFAULT_LIGHTS | Notification.DEFAULT_SOUND) - .setLights(Color.CYAN, 1000, 5000) - .setColor(ContextCompat.getColor(getApplicationContext(), R.color.colorPrimary)) - .setContentIntent(contentIntent); - - CharSequence formattedMessage = message; - if (Extras.useMarkdown(extras)) { - formattedMessage = markwon.toMarkdown(message); - message = formattedMessage.toString(); - } - b.setContentText(message); - b.setStyle(new NotificationCompat.BigTextStyle().bigText(formattedMessage)); - - String notificationImageUrl = - Extras.getNestedValue(String.class, extras, "client::notification", "bigImageUrl"); - - if (notificationImageUrl != null) { - try { - b.setStyle( - new NotificationCompat.BigPictureStyle() - .bigPicture(picassoHandler.getImageFromUrl(notificationImageUrl))); - } catch (Exception e) { - Log.e("Error loading bigImageUrl", e); - } - } - - NotificationManager notificationManager = - (NotificationManager) this.getSystemService(Context.NOTIFICATION_SERVICE); - notificationManager.notify(Utils.longToInt(id), b.build()); - } - - @RequiresApi(Build.VERSION_CODES.N) - public void showNotificationGroup(long priority) { - Intent intent = new Intent(this, MessagesActivity.class); - PendingIntent contentIntent = - PendingIntent.getActivity( - this, - 0, - intent, - PendingIntent.FLAG_UPDATE_CURRENT | PendingIntent.FLAG_IMMUTABLE); - - NotificationCompat.Builder b = - new NotificationCompat.Builder( - this, NotificationSupport.convertPriorityToChannel(priority)); - - b.setAutoCancel(true) - .setDefaults(Notification.DEFAULT_ALL) - .setWhen(System.currentTimeMillis()) - .setSmallIcon(R.drawable.ic_gotify) - .setTicker(getString(R.string.app_name)) - .setGroup(NotificationSupport.Group.MESSAGES) - .setGroupAlertBehavior(NotificationCompat.GROUP_ALERT_CHILDREN) - .setContentTitle(getString(R.string.grouped_notification_text)) - .setGroupSummary(true) - .setContentText(getString(R.string.grouped_notification_text)) - .setColor(ContextCompat.getColor(getApplicationContext(), R.color.colorPrimary)) - .setContentIntent(contentIntent); - - NotificationManager notificationManager = - (NotificationManager) this.getSystemService(Context.NOTIFICATION_SERVICE); - notificationManager.notify(-5, b.build()); - } -} diff --git a/app/src/main/java/com/github/gotify/settings/SettingsActivity.java b/app/src/main/java/com/github/gotify/settings/SettingsActivity.java deleted file mode 100644 index 6ad24c8..0000000 --- a/app/src/main/java/com/github/gotify/settings/SettingsActivity.java +++ /dev/null @@ -1,96 +0,0 @@ -package com.github.gotify.settings; - -import android.app.AlertDialog; -import android.content.ComponentName; -import android.content.Intent; -import android.content.SharedPreferences; -import android.content.pm.PackageManager; -import android.os.Bundle; -import android.view.MenuItem; -import android.view.View; -import androidx.annotation.NonNull; -import androidx.annotation.Nullable; -import androidx.appcompat.app.ActionBar; -import androidx.appcompat.app.AppCompatActivity; -import androidx.preference.ListPreference; -import androidx.preference.PreferenceFragmentCompat; -import androidx.preference.PreferenceManager; -import com.github.gotify.R; - -public class SettingsActivity extends AppCompatActivity - implements SharedPreferences.OnSharedPreferenceChangeListener { - - @Override - protected void onCreate(Bundle savedInstanceState) { - super.onCreate(savedInstanceState); - setContentView(R.layout.settings_activity); - getSupportFragmentManager() - .beginTransaction() - .replace(R.id.settings, new SettingsFragment()) - .commit(); - setSupportActionBar(findViewById(R.id.toolbar)); - ActionBar actionBar = getSupportActionBar(); - if (actionBar != null) { - actionBar.setDisplayHomeAsUpEnabled(true); - actionBar.setDisplayShowCustomEnabled(true); - } - SharedPreferences sharedPreferences = PreferenceManager.getDefaultSharedPreferences(this); - sharedPreferences.registerOnSharedPreferenceChangeListener(this); - } - - @Override - public boolean onOptionsItemSelected(@NonNull MenuItem item) { - if (item.getItemId() == android.R.id.home) { - finish(); - } - return super.onOptionsItemSelected(item); - } - - @Override - public void onSharedPreferenceChanged(SharedPreferences sharedPreferences, String key) { - if (getString(R.string.setting_key_theme).equals(key)) { - ThemeHelper.setTheme( - this, sharedPreferences.getString(key, getString(R.string.theme_default))); - } - } - - public static class SettingsFragment extends PreferenceFragmentCompat { - @Override - public void onCreatePreferences(Bundle savedInstanceState, String rootKey) { - setPreferencesFromResource(R.xml.root_preferences, rootKey); - } - - @Override - public void onViewCreated(@NonNull View view, @Nullable Bundle savedInstanceState) { - super.onViewCreated(view, savedInstanceState); - ListPreference message_layout = - findPreference(getString(R.string.setting_key_message_layout)); - message_layout.setOnPreferenceChangeListener( - (ignored, ignored2) -> { - new AlertDialog.Builder(getContext()) - .setTitle(R.string.setting_message_layout_dialog_title) - .setMessage(R.string.setting_message_layout_dialog_message) - .setPositiveButton( - getString(R.string.setting_message_layout_dialog_button1), - (ignored3, ignored4) -> { - restartApp(); - }) - .setNegativeButton( - getString(R.string.setting_message_layout_dialog_button2), - (ignore3, ignored4) -> {}) - .show(); - return true; - }); - } - - private void restartApp() { - PackageManager packageManager = getContext().getPackageManager(); - String packageName = getContext().getPackageName(); - Intent intent = packageManager.getLaunchIntentForPackage(packageName); - ComponentName componentName = intent.getComponent(); - Intent mainIntent = Intent.makeRestartActivityTask(componentName); - startActivity(mainIntent); - Runtime.getRuntime().exit(0); - } - } -} diff --git a/app/src/main/java/com/github/gotify/settings/ThemeHelper.java b/app/src/main/java/com/github/gotify/settings/ThemeHelper.java deleted file mode 100644 index 58af750..0000000 --- a/app/src/main/java/com/github/gotify/settings/ThemeHelper.java +++ /dev/null @@ -1,27 +0,0 @@ -package com.github.gotify.settings; - -import android.content.Context; -import android.os.Build; -import androidx.appcompat.app.AppCompatDelegate; -import com.github.gotify.R; - -public final class ThemeHelper { - private ThemeHelper() {} - - public static void setTheme(Context context, String newTheme) { - AppCompatDelegate.setDefaultNightMode(ofKey(context, newTheme)); - } - - private static int ofKey(Context context, String newTheme) { - if (context.getString(R.string.theme_dark).equals(newTheme)) { - return AppCompatDelegate.MODE_NIGHT_YES; - } - if (context.getString(R.string.theme_light).equals(newTheme)) { - return AppCompatDelegate.MODE_NIGHT_NO; - } - if (Build.VERSION.SDK_INT <= Build.VERSION_CODES.P) { - return AppCompatDelegate.MODE_NIGHT_AUTO_BATTERY; - } - return AppCompatDelegate.MODE_NIGHT_FOLLOW_SYSTEM; - } -} diff --git a/app/src/main/java/com/github/gotify/sharing/ShareActivity.java b/app/src/main/java/com/github/gotify/sharing/ShareActivity.java deleted file mode 100644 index e49fbb2..0000000 --- a/app/src/main/java/com/github/gotify/sharing/ShareActivity.java +++ /dev/null @@ -1,167 +0,0 @@ -package com.github.gotify.sharing; - -import android.content.Intent; -import android.os.AsyncTask; -import android.os.Bundle; -import android.view.MenuItem; -import android.view.View; -import android.widget.ArrayAdapter; -import android.widget.Spinner; -import android.widget.Toast; -import androidx.annotation.Nullable; -import androidx.appcompat.app.ActionBar; -import androidx.appcompat.app.AppCompatActivity; -import com.github.gotify.R; -import com.github.gotify.Settings; -import com.github.gotify.api.Api; -import com.github.gotify.api.ApiException; -import com.github.gotify.api.ClientFactory; -import com.github.gotify.client.ApiClient; -import com.github.gotify.client.api.MessageApi; -import com.github.gotify.client.model.Application; -import com.github.gotify.client.model.Message; -import com.github.gotify.databinding.ActivityShareBinding; -import com.github.gotify.log.Log; -import com.github.gotify.messages.provider.ApplicationHolder; -import java.util.ArrayList; -import java.util.List; - -import static com.github.gotify.Utils.first; - -public class ShareActivity extends AppCompatActivity { - private ActivityShareBinding binding; - private Settings settings; - private ApplicationHolder appsHolder; - - @Override - protected void onCreate(Bundle savedInstanceState) { - super.onCreate(savedInstanceState); - binding = ActivityShareBinding.inflate(getLayoutInflater()); - setContentView(binding.getRoot()); - - Log.i("Entering " + getClass().getSimpleName()); - setSupportActionBar(binding.appBarDrawer.toolbar); - ActionBar actionBar = getSupportActionBar(); - if (actionBar != null) { - actionBar.setDisplayHomeAsUpEnabled(true); - actionBar.setDisplayShowCustomEnabled(true); - } - settings = new Settings(this); - - Intent intent = getIntent(); - String type = intent.getType(); - if (Intent.ACTION_SEND.equals(intent.getAction()) && "text/plain".equals(type)) { - String sharedText = intent.getStringExtra(Intent.EXTRA_TEXT); - if (sharedText != null) { - binding.content.setText(sharedText); - } - } - - if (!settings.tokenExists()) { - Toast.makeText(getApplicationContext(), R.string.not_loggedin_share, Toast.LENGTH_SHORT) - .show(); - finish(); - return; - } - - ApiClient client = - ClientFactory.clientToken(settings.url(), settings.sslSettings(), settings.token()); - appsHolder = new ApplicationHolder(this, client); - appsHolder.onUpdate( - () -> { - List apps = appsHolder.get(); - populateSpinner(apps); - - boolean appsAvailable = !apps.isEmpty(); - binding.pushButton.setEnabled(appsAvailable); - binding.missingAppsContainer.setVisibility( - appsAvailable ? View.GONE : View.VISIBLE); - }); - appsHolder.onUpdateFailed(() -> binding.pushButton.setEnabled(false)); - appsHolder.request(); - } - - @Override - protected void onPostCreate(@Nullable Bundle savedInstanceState) { - super.onPostCreate(savedInstanceState); - binding.pushButton.setOnClickListener(ignored -> pushMessage()); - } - - @Override - public boolean onOptionsItemSelected(MenuItem item) { - if (item.getItemId() == android.R.id.home) { - finish(); - } - return super.onOptionsItemSelected(item); - } - - public void pushMessage() { - String titleText = binding.title.getText().toString(); - String contentText = binding.content.getText().toString(); - String priority = binding.edtTxtPriority.getText().toString(); - int appIndex = binding.appSpinner.getSelectedItemPosition(); - - if (contentText.isEmpty()) { - Toast.makeText(this, "Content should not be empty.", Toast.LENGTH_LONG).show(); - return; - } else if (priority.isEmpty()) { - Toast.makeText(this, "Priority should be number.", Toast.LENGTH_LONG).show(); - return; - } else if (appIndex == Spinner.INVALID_POSITION) { - // For safety, e.g. loading the apps needs too much time (maybe a timeout) and - // the user tries to push without an app selected. - Toast.makeText(this, "An app must be selected.", Toast.LENGTH_LONG).show(); - return; - } - - Message message = new Message(); - if (!titleText.isEmpty()) { - message.setTitle(titleText); - } - message.setMessage(contentText); - message.setPriority(Long.parseLong(priority)); - new PushMessage(appsHolder.get().get(appIndex).getToken()).execute(message); - } - - private void populateSpinner(List apps) { - List appNameList = new ArrayList<>(); - for (Application app : apps) { - appNameList.add(app.getName()); - } - - ArrayAdapter adapter = - new ArrayAdapter<>( - this, android.R.layout.simple_spinner_dropdown_item, appNameList); - binding.appSpinner.setAdapter(adapter); - } - - private class PushMessage extends AsyncTask { - private String token; - - public PushMessage(String token) { - this.token = token; - } - - @Override - protected String doInBackground(Message... messages) { - List apps = appsHolder.get(); - ApiClient pushClient = - ClientFactory.clientToken(settings.url(), settings.sslSettings(), token); - - try { - MessageApi messageApi = pushClient.createService(MessageApi.class); - Api.execute(messageApi.createMessage(first(messages))); - return "Pushed!"; - } catch (ApiException apiException) { - Log.e("Failed sending message", apiException); - return "Oops! Something went wrong..."; - } - } - - @Override - protected void onPostExecute(String message) { - Toast.makeText(ShareActivity.this, message, Toast.LENGTH_LONG).show(); - ShareActivity.this.finish(); - } - } -} diff --git a/app/src/main/kotlin/com/github/gotify/MarkwonFactory.kt b/app/src/main/kotlin/com/github/gotify/MarkwonFactory.kt new file mode 100644 index 0000000..9bee603 --- /dev/null +++ b/app/src/main/kotlin/com/github/gotify/MarkwonFactory.kt @@ -0,0 +1,99 @@ +package com.github.gotify + +import android.content.Context +import android.graphics.Color +import android.graphics.Typeface +import android.text.style.BackgroundColorSpan +import android.text.style.BulletSpan +import android.text.style.QuoteSpan +import android.text.style.RelativeSizeSpan +import android.text.style.StyleSpan +import android.text.style.TypefaceSpan +import androidx.core.content.ContextCompat +import com.squareup.picasso.Picasso +import io.noties.markwon.AbstractMarkwonPlugin +import io.noties.markwon.Markwon +import io.noties.markwon.MarkwonSpansFactory +import io.noties.markwon.MarkwonVisitor +import io.noties.markwon.RenderProps +import io.noties.markwon.core.CorePlugin +import io.noties.markwon.core.CoreProps +import io.noties.markwon.core.MarkwonTheme +import io.noties.markwon.ext.strikethrough.StrikethroughPlugin +import io.noties.markwon.ext.tables.TableAwareMovementMethod +import io.noties.markwon.ext.tables.TablePlugin +import io.noties.markwon.image.picasso.PicassoImagesPlugin +import io.noties.markwon.movement.MovementMethodPlugin +import org.commonmark.ext.gfm.tables.TableCell +import org.commonmark.ext.gfm.tables.TablesExtension +import org.commonmark.node.BlockQuote +import org.commonmark.node.Code +import org.commonmark.node.Emphasis +import org.commonmark.node.Heading +import org.commonmark.node.Link +import org.commonmark.node.ListItem +import org.commonmark.node.StrongEmphasis +import org.commonmark.parser.Parser + +internal object MarkwonFactory { + fun createForMessage(context: Context, picasso: Picasso): Markwon { + return Markwon.builder(context) + .usePlugin(CorePlugin.create()) + .usePlugin(MovementMethodPlugin.create(TableAwareMovementMethod.create())) + .usePlugin(PicassoImagesPlugin.create(picasso)) + .usePlugin(StrikethroughPlugin.create()) + .usePlugin(TablePlugin.create(context)) + .usePlugin(object : AbstractMarkwonPlugin() { + override fun configureTheme(builder: MarkwonTheme.Builder) { + builder.linkColor(ContextCompat.getColor(context, R.color.hyperLink)) + .isLinkUnderlined(true) + } + }) + .build() + } + + fun createForNotification(context: Context, picasso: Picasso): Markwon { + val headingSizes = floatArrayOf(2f, 1.5f, 1.17f, 1f, .83f, .67f) + val bulletGapWidth = (8 * context.resources.displayMetrics.density + 0.5f).toInt() + + return Markwon.builder(context) + .usePlugin(CorePlugin.create()) + .usePlugin(PicassoImagesPlugin.create(picasso)) + .usePlugin(StrikethroughPlugin.create()) + .usePlugin(object : AbstractMarkwonPlugin() { + override fun configureSpansFactory(builder: MarkwonSpansFactory.Builder) { + builder.setFactory(Heading::class.java) { _, props: RenderProps? -> + arrayOf( + RelativeSizeSpan( + headingSizes[CoreProps.HEADING_LEVEL.require(props!!) - 1] + ), + StyleSpan(Typeface.BOLD) + ) + } + .setFactory(Emphasis::class.java) { _, _ -> StyleSpan(Typeface.ITALIC) } + .setFactory(StrongEmphasis::class.java) { _, _ -> StyleSpan(Typeface.BOLD) } + .setFactory(BlockQuote::class.java) { _, _ -> QuoteSpan() } + .setFactory(Code::class.java) { _, _ -> + arrayOf( + BackgroundColorSpan(Color.LTGRAY), + TypefaceSpan("monospace") + ) + } + .setFactory(ListItem::class.java) { _, _ -> BulletSpan(bulletGapWidth) } + .setFactory(Link::class.java) { _, _ -> null } + } + + override fun configureParser(builder: Parser.Builder) { + builder.extensions(setOf(TablesExtension.create())) + } + + override fun configureVisitor(builder: MarkwonVisitor.Builder) { + builder.on(TableCell::class.java) { visitor: MarkwonVisitor, node: TableCell? -> + visitor.visitChildren(node!!) + visitor.builder().append(' ') + } + } + }) + .build() + } +} diff --git a/app/src/main/kotlin/com/github/gotify/MissedMessageUtil.kt b/app/src/main/kotlin/com/github/gotify/MissedMessageUtil.kt new file mode 100644 index 0000000..3a69759 --- /dev/null +++ b/app/src/main/kotlin/com/github/gotify/MissedMessageUtil.kt @@ -0,0 +1,64 @@ +package com.github.gotify + +import com.github.gotify.api.Api +import com.github.gotify.api.ApiException +import com.github.gotify.api.Callback +import com.github.gotify.client.api.MessageApi +import com.github.gotify.client.model.Message +import com.github.gotify.log.Log + +internal class MissedMessageUtil(private val api: MessageApi) { + fun lastReceivedMessage(acceptID: (Long) -> Unit) { + api.getMessages(1, 0L).enqueue( + Callback.call( + onSuccess = Callback.SuccessBody { messages -> + if (messages.messages.size == 1) { + acceptID(messages.messages[0].id) + } else { + acceptID(NO_MESSAGES) + } + }, + onError = {} + ) + ) + } + + fun missingMessages(till: Long): List { + val result = mutableListOf() + try { + var since: Long? = null + while (true) { + val pagedMessages = Api.execute(api.getMessages(10, since)) + val messages = pagedMessages.messages + val filtered = filter(messages, till) + result.addAll(filtered) + if (messages.size != filtered.size || + messages.size == 0 || + pagedMessages.paging.next == null + ) { + break + } + since = pagedMessages.paging.since + } + } catch (e: ApiException) { + Log.e("cannot retrieve missing messages", e) + } + return result.reversed() + } + + private fun filter(messages: List, till: Long): List { + val result = mutableListOf() + for (message in messages) { + if (message.id > till) { + result.add(message) + } else { + break + } + } + return result + } + + companion object { + const val NO_MESSAGES = 0L + } +} diff --git a/app/src/main/kotlin/com/github/gotify/NotificationSupport.kt b/app/src/main/kotlin/com/github/gotify/NotificationSupport.kt new file mode 100644 index 0000000..46a5287 --- /dev/null +++ b/app/src/main/kotlin/com/github/gotify/NotificationSupport.kt @@ -0,0 +1,106 @@ +package com.github.gotify + +import android.app.NotificationChannel +import android.app.NotificationManager +import android.graphics.Color +import android.os.Build +import androidx.annotation.RequiresApi +import com.github.gotify.log.Log + +internal object NotificationSupport { + @RequiresApi(Build.VERSION_CODES.O) + fun createChannels(notificationManager: NotificationManager) { + try { + // Low importance so that persistent notification can be sorted towards bottom of + // notification shade. Also prevents vibrations caused by persistent notification + val foreground = NotificationChannel( + Channel.FOREGROUND, + "Gotify foreground notification", + NotificationManager.IMPORTANCE_LOW + ) + foreground.setShowBadge(false) + + val messagesImportanceMin = NotificationChannel( + Channel.MESSAGES_IMPORTANCE_MIN, + "Min priority messages (<1)", + NotificationManager.IMPORTANCE_MIN + ) + + val messagesImportanceLow = NotificationChannel( + Channel.MESSAGES_IMPORTANCE_LOW, + "Low priority messages (1-3)", + NotificationManager.IMPORTANCE_LOW + ) + + val messagesImportanceDefault = NotificationChannel( + Channel.MESSAGES_IMPORTANCE_DEFAULT, + "Normal priority messages (4-7)", + NotificationManager.IMPORTANCE_DEFAULT + ) + messagesImportanceDefault.enableLights(true) + messagesImportanceDefault.lightColor = Color.CYAN + messagesImportanceDefault.enableVibration(true) + + val messagesImportanceHigh = NotificationChannel( + Channel.MESSAGES_IMPORTANCE_HIGH, + "High priority messages (>7)", + NotificationManager.IMPORTANCE_HIGH + ) + messagesImportanceHigh.enableLights(true) + messagesImportanceHigh.lightColor = Color.CYAN + messagesImportanceHigh.enableVibration(true) + + notificationManager.createNotificationChannel(foreground) + notificationManager.createNotificationChannel(messagesImportanceMin) + notificationManager.createNotificationChannel(messagesImportanceLow) + notificationManager.createNotificationChannel(messagesImportanceDefault) + notificationManager.createNotificationChannel(messagesImportanceHigh) + } catch (e: Exception) { + Log.e("Could not create channel", e) + } + } + + /** + * Map {@link com.github.gotify.client.model.Message#getPriority() Gotify message priorities to + * Android channels. + * + *
+     * Gotify Priority  | Android Importance
+     * <= 0             | min
+     * 1-3              | low
+     * 4-7              | default
+     * >= 8             | high
+     * 
+ * + * @param priority the Gotify priority to convert to a notification channel as a long. + * @return the identifier of the notification channel as a String. + */ + fun convertPriorityToChannel(priority: Long): String { + return if (priority < 1) { + Channel.MESSAGES_IMPORTANCE_MIN + } else if (priority < 4) { + Channel.MESSAGES_IMPORTANCE_LOW + } else if (priority < 8) { + Channel.MESSAGES_IMPORTANCE_DEFAULT + } else { + Channel.MESSAGES_IMPORTANCE_HIGH + } + } + + object Group { + const val MESSAGES = "GOTIFY_GROUP_MESSAGES" + } + + object Channel { + const val FOREGROUND = "gotify_foreground" + const val MESSAGES_IMPORTANCE_MIN = "gotify_messages_min_importance" + const val MESSAGES_IMPORTANCE_LOW = "gotify_messages_low_importance" + const val MESSAGES_IMPORTANCE_DEFAULT = "gotify_messages_default_importance" + const val MESSAGES_IMPORTANCE_HIGH = "gotify_messages_high_importance" + } + + object ID { + const val FOREGROUND = -1 + const val GROUPED = -2 + } +} diff --git a/app/src/main/kotlin/com/github/gotify/SSLSettings.kt b/app/src/main/kotlin/com/github/gotify/SSLSettings.kt new file mode 100644 index 0000000..f9c8bde --- /dev/null +++ b/app/src/main/kotlin/com/github/gotify/SSLSettings.kt @@ -0,0 +1,3 @@ +package com.github.gotify + +internal class SSLSettings(val validateSSL: Boolean, val cert: String?) diff --git a/app/src/main/kotlin/com/github/gotify/Settings.kt b/app/src/main/kotlin/com/github/gotify/Settings.kt new file mode 100644 index 0000000..d2f6a4f --- /dev/null +++ b/app/src/main/kotlin/com/github/gotify/Settings.kt @@ -0,0 +1,56 @@ +package com.github.gotify + +import android.content.Context +import android.content.SharedPreferences +import com.github.gotify.client.model.User + +internal class Settings(context: Context) { + private val sharedPreferences: SharedPreferences + var url: String + get() = sharedPreferences.getString("url", "")!! + set(value) = sharedPreferences.edit().putString("url", value).apply() + var token: String? + get() = sharedPreferences.getString("token", null) + set(value) = sharedPreferences.edit().putString("token", value).apply() + var user: User? = null + get() { + val username = sharedPreferences.getString("username", null) + val admin = sharedPreferences.getBoolean("admin", false) + return if (username != null) { + User().name(username).admin(admin) + } else { + User().name("UNKNOWN").admin(false) + } + } + private set + var serverVersion: String + get() = sharedPreferences.getString("version", "UNKNOWN")!! + set(value) = sharedPreferences.edit().putString("version", value).apply() + var cert: String? + get() = sharedPreferences.getString("cert", null) + set(value) = sharedPreferences.edit().putString("cert", value).apply() + var validateSSL: Boolean + get() = sharedPreferences.getBoolean("validateSSL", true) + set(value) = sharedPreferences.edit().putBoolean("validateSSL", value).apply() + + init { + sharedPreferences = context.getSharedPreferences("gotify", Context.MODE_PRIVATE) + } + + fun tokenExists(): Boolean = !token.isNullOrEmpty() + + fun clear() { + url = "" + token = null + validateSSL = true + cert = null + } + + fun setUser(name: String?, admin: Boolean) { + sharedPreferences.edit().putString("username", name).putBoolean("admin", admin).apply() + } + + fun sslSettings(): SSLSettings { + return SSLSettings(validateSSL, cert) + } +} diff --git a/app/src/main/kotlin/com/github/gotify/Utils.kt b/app/src/main/kotlin/com/github/gotify/Utils.kt new file mode 100644 index 0000000..7c3757b --- /dev/null +++ b/app/src/main/kotlin/com/github/gotify/Utils.kt @@ -0,0 +1,118 @@ +package com.github.gotify + +import android.app.Activity +import android.content.res.Resources +import android.graphics.Bitmap +import android.graphics.drawable.BitmapDrawable +import android.graphics.drawable.Drawable +import android.text.format.DateUtils +import android.view.View +import androidx.appcompat.app.AppCompatActivity +import androidx.lifecycle.lifecycleScope +import com.github.gotify.client.JSON +import com.github.gotify.log.Log +import com.google.android.material.snackbar.Snackbar +import com.google.gson.Gson +import com.squareup.picasso.Picasso.LoadedFrom +import com.squareup.picasso.Target +import kotlinx.coroutines.CoroutineDispatcher +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch +import okio.Buffer +import org.threeten.bp.OffsetDateTime +import java.io.BufferedReader +import java.io.IOException +import java.io.InputStream +import java.io.InputStreamReader +import java.net.MalformedURLException +import java.net.URI +import java.net.URISyntaxException +import java.net.URL + +internal object Utils { + val JSON: Gson = JSON().gson + + fun showSnackBar(activity: Activity, message: String?) { + val rootView = activity.window.decorView.findViewById(android.R.id.content) + Snackbar.make(rootView, message!!, Snackbar.LENGTH_SHORT).show() + } + + fun longToInt(value: Long): Int { + return (value % Int.MAX_VALUE).toInt() + } + + fun dateToRelative(data: OffsetDateTime): String { + val time = data.toInstant().toEpochMilli() + val now = System.currentTimeMillis() + return DateUtils.getRelativeTimeSpanString(time, now, DateUtils.MINUTE_IN_MILLIS) + .toString() + } + + fun resolveAbsoluteUrl(baseURL: String, target: String?): String? { + return if (target == null) { + null + } else { + try { + val targetUri = URI(target) + if (targetUri.isAbsolute) { + target + } else { + URL(URL(baseURL), target).toString() + } + } catch (e: MalformedURLException) { + Log.e("Could not resolve absolute url", e) + target + } catch (e: URISyntaxException) { + Log.e("Could not resolve absolute url", e) + target + } + } + } + + fun toDrawable(resources: Resources?, drawableReceiver: DrawableReceiver): Target { + return object : Target { + override fun onBitmapLoaded(bitmap: Bitmap, from: LoadedFrom) { + drawableReceiver.loaded(BitmapDrawable(resources, bitmap)) + } + + override fun onBitmapFailed(e: Exception, errorDrawable: Drawable) { + Log.e("Bitmap failed", e) + } + + override fun onPrepareLoad(placeHolderDrawable: Drawable) {} + } + } + + fun readFileFromStream(inputStream: InputStream): String { + val sb = StringBuilder() + var currentLine: String? + try { + BufferedReader(InputStreamReader(inputStream)).use { reader -> + while (reader.readLine().also { currentLine = it } != null) { + sb.append(currentLine).append("\n") + } + } + } catch (e: IOException) { + throw IllegalArgumentException("failed to read input") + } + return sb.toString() + } + + fun stringToInputStream(str: String?): InputStream? { + return if (str == null) null else Buffer().writeUtf8(str).inputStream() + } + + fun AppCompatActivity.launchCoroutine( + dispatcher: CoroutineDispatcher = Dispatchers.IO, + action: suspend (coroutineScope: CoroutineScope) -> Unit + ) { + this.lifecycleScope.launch(dispatcher) { + action(this) + } + } + + fun interface DrawableReceiver { + fun loaded(drawable: Drawable?) + } +} diff --git a/app/src/main/kotlin/com/github/gotify/api/Api.kt b/app/src/main/kotlin/com/github/gotify/api/Api.kt new file mode 100644 index 0000000..7b09a5a --- /dev/null +++ b/app/src/main/kotlin/com/github/gotify/api/Api.kt @@ -0,0 +1,34 @@ +package com.github.gotify.api + +import retrofit2.Call +import java.io.IOException + +internal object Api { + @Throws(ApiException::class) + fun execute(call: Call) { + try { + val response = call.execute() + + if (!response.isSuccessful) { + throw ApiException(response) + } + } catch (e: IOException) { + throw ApiException(e) + } + } + + @Throws(ApiException::class) + fun execute(call: Call): T { + try { + val response = call.execute() + + if (response.isSuccessful) { + return response.body() ?: throw ApiException("null response", response) + } else { + throw ApiException(response) + } + } catch (e: IOException) { + throw ApiException(e) + } + } +} diff --git a/app/src/main/kotlin/com/github/gotify/api/ApiException.kt b/app/src/main/kotlin/com/github/gotify/api/ApiException.kt new file mode 100644 index 0000000..e746bfb --- /dev/null +++ b/app/src/main/kotlin/com/github/gotify/api/ApiException.kt @@ -0,0 +1,31 @@ +package com.github.gotify.api + +import retrofit2.Response +import java.io.IOException + +internal class ApiException : Exception { + var body: String = "" + private set + var code: Int + private set + + constructor(response: Response<*>) : super("Api Error", null) { + body = try { + if (response.errorBody() != null) response.errorBody()!!.string() else "" + } catch (e: IOException) { + "Error while getting error body :(" + } + code = response.code() + } + + constructor(exceptionBody: String, response: Response<*>) : super("Api Error", null) { + body = exceptionBody + code = response.code() + } + + constructor(cause: Throwable?) : super("Request failed.", cause) { + code = 0 + } + + override fun toString() = "Code($code) Response: ${body.take(200)}" +} diff --git a/app/src/main/kotlin/com/github/gotify/api/Callback.kt b/app/src/main/kotlin/com/github/gotify/api/Callback.kt new file mode 100644 index 0000000..8c1e1c8 --- /dev/null +++ b/app/src/main/kotlin/com/github/gotify/api/Callback.kt @@ -0,0 +1,82 @@ +package com.github.gotify.api + +import android.app.Activity +import com.github.gotify.log.Log +import retrofit2.Call +import retrofit2.Response + +internal class Callback private constructor( + private val onSuccess: SuccessCallback, + private val onError: ErrorCallback +) { + fun interface SuccessCallback { + fun onSuccess(response: Response) + } + + fun interface SuccessBody : SuccessCallback { + override fun onSuccess(response: Response) { + onResultSuccess(response.body() ?: throw ApiException("null response", response)) + } + + fun onResultSuccess(data: T) + } + + fun interface ErrorCallback { + fun onError(t: ApiException) + } + + private class RetrofitCallback(private val callback: Callback) : retrofit2.Callback { + override fun onResponse(call: Call, response: Response) { + if (response.isSuccessful) { + callback.onSuccess.onSuccess(response) + } else { + callback.onError.onError(ApiException(response)) + } + } + + override fun onFailure(call: Call, t: Throwable) { + callback.onError.onError(ApiException(t)) + } + } + + companion object { + fun callInUI( + context: Activity, + onSuccess: SuccessCallback, + onError: ErrorCallback + ): retrofit2.Callback { + return call( + onSuccess = { response -> context.runOnUiThread { onSuccess.onSuccess(response) } }, + onError = { exception -> context.runOnUiThread { onError.onError(exception) } } + ) + } + + fun call(onSuccess: SuccessCallback = SuccessCallback {}, onError: ErrorCallback = ErrorCallback {}): retrofit2.Callback { + return RetrofitCallback(merge(of(onSuccess, onError), errorCallback())) + } + + private fun of(onSuccess: SuccessCallback, onError: ErrorCallback): Callback { + return Callback(onSuccess, onError) + } + + private fun errorCallback(): Callback { + return Callback( + onSuccess = {}, + onError = { exception -> Log.e("Error while api call", exception) } + ) + } + + private fun merge(left: Callback, right: Callback): Callback { + return Callback( + onSuccess = { data -> + left.onSuccess.onSuccess(data) + right.onSuccess.onSuccess(data) + }, + onError = { exception -> + left.onError.onError(exception) + right.onError.onError(exception) + } + ) + } + } +} diff --git a/app/src/main/kotlin/com/github/gotify/api/CertUtils.kt b/app/src/main/kotlin/com/github/gotify/api/CertUtils.kt new file mode 100644 index 0000000..f75cfc7 --- /dev/null +++ b/app/src/main/kotlin/com/github/gotify/api/CertUtils.kt @@ -0,0 +1,96 @@ +package com.github.gotify.api + +import android.annotation.SuppressLint +import com.github.gotify.SSLSettings +import com.github.gotify.Utils +import com.github.gotify.log.Log +import okhttp3.OkHttpClient +import java.io.IOException +import java.security.GeneralSecurityException +import java.security.KeyStore +import java.security.SecureRandom +import java.security.cert.Certificate +import java.security.cert.CertificateFactory +import java.security.cert.X509Certificate +import javax.net.ssl.SSLContext +import javax.net.ssl.TrustManager +import javax.net.ssl.TrustManagerFactory +import javax.net.ssl.X509TrustManager + +internal object CertUtils { + @SuppressLint("CustomX509TrustManager") + private val trustAll = object : X509TrustManager { + @SuppressLint("TrustAllX509TrustManager") + override fun checkClientTrusted(chain: Array, authType: String) {} + + @SuppressLint("TrustAllX509TrustManager") + override fun checkServerTrusted(chain: Array, authType: String) {} + + override fun getAcceptedIssuers() = arrayOf() + } + + fun parseCertificate(cert: String): Certificate { + try { + val certificateFactory = CertificateFactory.getInstance("X509") + return certificateFactory.generateCertificate(Utils.stringToInputStream(cert)) + } catch (e: Exception) { + throw IllegalArgumentException("certificate is invalid") + } + } + + fun applySslSettings(builder: OkHttpClient.Builder, settings: SSLSettings) { + // Modified from ApiClient.applySslSettings in the client package. + try { + if (!settings.validateSSL) { + val context = SSLContext.getInstance("TLS") + context.init(arrayOf(), arrayOf(trustAll), SecureRandom()) + builder.sslSocketFactory(context.socketFactory, trustAll) + builder.hostnameVerifier { _, _ -> true } + return + } + val cert = settings.cert + if (cert != null) { + val trustManagers = certToTrustManager(cert) + if (trustManagers.isNotEmpty()) { + val context = SSLContext.getInstance("TLS") + context.init(arrayOf(), trustManagers, SecureRandom()) + builder.sslSocketFactory( + context.socketFactory, + trustManagers[0] as X509TrustManager + ) + } + } + } catch (e: Exception) { + // We shouldn't have issues since the cert is verified on login. + Log.e("Failed to apply SSL settings", e) + } + } + + @Throws(GeneralSecurityException::class) + private fun certToTrustManager(cert: String): Array { + val certificateFactory = CertificateFactory.getInstance("X.509") + val certificates = certificateFactory.generateCertificates(Utils.stringToInputStream(cert)) + require(certificates.isNotEmpty()) { "expected non-empty set of trusted certificates" } + + val caKeyStore = newEmptyKeyStore() + certificates.forEachIndexed { index, certificate -> + val certificateAlias = "ca$index" + caKeyStore.setCertificateEntry(certificateAlias, certificate) + } + val trustManagerFactory = + TrustManagerFactory.getInstance(TrustManagerFactory.getDefaultAlgorithm()) + trustManagerFactory.init(caKeyStore) + return trustManagerFactory.trustManagers + } + + @Throws(GeneralSecurityException::class) + private fun newEmptyKeyStore(): KeyStore { + return try { + val keyStore = KeyStore.getInstance(KeyStore.getDefaultType()) + keyStore.load(null, null) + keyStore + } catch (e: IOException) { + throw AssertionError(e) + } + } +} diff --git a/app/src/main/kotlin/com/github/gotify/api/ClientFactory.kt b/app/src/main/kotlin/com/github/gotify/api/ClientFactory.kt new file mode 100644 index 0000000..7a837d2 --- /dev/null +++ b/app/src/main/kotlin/com/github/gotify/api/ClientFactory.kt @@ -0,0 +1,67 @@ +package com.github.gotify.api + +import com.github.gotify.SSLSettings +import com.github.gotify.Settings +import com.github.gotify.client.ApiClient +import com.github.gotify.client.api.UserApi +import com.github.gotify.client.api.VersionApi +import com.github.gotify.client.auth.ApiKeyAuth +import com.github.gotify.client.auth.HttpBasicAuth + +internal object ClientFactory { + private fun unauthorized(baseUrl: String, sslSettings: SSLSettings): ApiClient { + return defaultClient(arrayOf(), "$baseUrl/", sslSettings) + } + + fun basicAuth( + baseUrl: String, + sslSettings: SSLSettings, + username: String, + password: String + ): ApiClient { + val client = defaultClient( + arrayOf("basicAuth"), + "$baseUrl/", + sslSettings + ) + val auth = client.apiAuthorizations["basicAuth"] as HttpBasicAuth + auth.username = username + auth.password = password + return client + } + + fun clientToken( + baseUrl: String, + sslSettings: SSLSettings, + token: String? + ): ApiClient { + val client = defaultClient( + arrayOf("clientTokenHeader"), + "$baseUrl/", + sslSettings + ) + val tokenAuth = client.apiAuthorizations["clientTokenHeader"] as ApiKeyAuth + tokenAuth.apiKey = token + return client + } + + fun versionApi(baseUrl: String, sslSettings: SSLSettings): VersionApi { + return unauthorized(baseUrl, sslSettings).createService(VersionApi::class.java) + } + + fun userApiWithToken(settings: Settings): UserApi { + return clientToken(settings.url, settings.sslSettings(), settings.token) + .createService(UserApi::class.java) + } + + private fun defaultClient( + authentications: Array, + baseUrl: String, + sslSettings: SSLSettings + ): ApiClient { + val client = ApiClient(authentications) + CertUtils.applySslSettings(client.okBuilder, sslSettings) + client.adapterBuilder.baseUrl(baseUrl) + return client + } +} diff --git a/app/src/main/kotlin/com/github/gotify/init/BootCompletedReceiver.kt b/app/src/main/kotlin/com/github/gotify/init/BootCompletedReceiver.kt new file mode 100644 index 0000000..c055c4e --- /dev/null +++ b/app/src/main/kotlin/com/github/gotify/init/BootCompletedReceiver.kt @@ -0,0 +1,25 @@ +package com.github.gotify.init + +import android.content.BroadcastReceiver +import android.content.Context +import android.content.Intent +import android.os.Build +import com.github.gotify.Settings +import com.github.gotify.service.WebSocketService + +internal class BootCompletedReceiver : BroadcastReceiver() { + + override fun onReceive(context: Context, intent: Intent) { + val settings = Settings(context) + + if (!settings.tokenExists()) { + return + } + + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + context.startForegroundService(Intent(context, WebSocketService::class.java)) + } else { + context.startService(Intent(context, WebSocketService::class.java)) + } + } +} diff --git a/app/src/main/kotlin/com/github/gotify/init/InitializationActivity.kt b/app/src/main/kotlin/com/github/gotify/init/InitializationActivity.kt new file mode 100644 index 0000000..6e56ba7 --- /dev/null +++ b/app/src/main/kotlin/com/github/gotify/init/InitializationActivity.kt @@ -0,0 +1,133 @@ +package com.github.gotify.init + +import android.app.NotificationManager +import android.content.Intent +import android.os.Build +import android.os.Bundle +import androidx.appcompat.app.AlertDialog +import androidx.appcompat.app.AppCompatActivity +import androidx.preference.PreferenceManager +import com.github.gotify.NotificationSupport +import com.github.gotify.R +import com.github.gotify.Settings +import com.github.gotify.api.ApiException +import com.github.gotify.api.Callback +import com.github.gotify.api.Callback.SuccessCallback +import com.github.gotify.api.ClientFactory +import com.github.gotify.client.model.User +import com.github.gotify.client.model.VersionInfo +import com.github.gotify.log.Log +import com.github.gotify.log.UncaughtExceptionHandler +import com.github.gotify.login.LoginActivity +import com.github.gotify.messages.MessagesActivity +import com.github.gotify.service.WebSocketService +import com.github.gotify.settings.ThemeHelper + +internal class InitializationActivity : AppCompatActivity() { + + private lateinit var settings: Settings + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + Log.init(this) + val theme = PreferenceManager.getDefaultSharedPreferences(this) + .getString(getString(R.string.setting_key_theme), getString(R.string.theme_default))!! + ThemeHelper.setTheme(this, theme) + + setContentView(R.layout.splash) + + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + NotificationSupport.createChannels( + this.getSystemService(NOTIFICATION_SERVICE) as NotificationManager + ) + } + UncaughtExceptionHandler.registerCurrentThread() + settings = Settings(this) + Log.i("Entering ${javaClass.simpleName}") + + if (settings.tokenExists()) { + tryAuthenticate() + } else { + showLogin() + } + } + + private fun showLogin() { + startActivity(Intent(this, LoginActivity::class.java)) + finish() + } + + private fun tryAuthenticate() { + ClientFactory.userApiWithToken(settings) + .currentUser() + .enqueue( + Callback.callInUI( + this, + onSuccess = Callback.SuccessBody { user -> authenticated(user) }, + onError = { exception -> failed(exception) } + ) + ) + } + + private fun failed(exception: ApiException) { + when (exception.code) { + 0 -> { + dialog(getString(R.string.not_available, settings.url)) + return + } + 401 -> { + dialog(getString(R.string.auth_failed)) + return + } + } + + var response = exception.body + response = response.take(200) + dialog(getString(R.string.other_error, settings.url, exception.code, response)) + } + + private fun dialog(message: String) { + AlertDialog.Builder(this) + .setTitle(R.string.oops) + .setMessage(message) + .setPositiveButton(R.string.retry) { _, _ -> tryAuthenticate() } + .setNegativeButton(R.string.logout) { _, _ -> showLogin() } + .show() + } + + private fun authenticated(user: User) { + Log.i("Authenticated as ${user.name}") + + settings.setUser(user.name, user.isAdmin) + requestVersion { + startActivity(Intent(this, MessagesActivity::class.java)) + finish() + } + + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + startForegroundService(Intent(this, WebSocketService::class.java)) + } else { + startService(Intent(this, WebSocketService::class.java)) + } + } + + private fun requestVersion(runnable: Runnable) { + requestVersion( + callback = Callback.SuccessBody { version: VersionInfo -> + Log.i("Server version: ${version.version}@${version.buildDate}") + settings.serverVersion = version.version + runnable.run() + }, + errorCallback = { runnable.run() } + ) + } + + private fun requestVersion( + callback: SuccessCallback, + errorCallback: Callback.ErrorCallback + ) { + ClientFactory.versionApi(settings.url, settings.sslSettings()) + .version + .enqueue(Callback.callInUI(this, callback, errorCallback)) + } +} diff --git a/app/src/main/kotlin/com/github/gotify/log/Format.kt b/app/src/main/kotlin/com/github/gotify/log/Format.kt new file mode 100644 index 0000000..578d231 --- /dev/null +++ b/app/src/main/kotlin/com/github/gotify/log/Format.kt @@ -0,0 +1,16 @@ +package com.github.gotify.log + +import android.content.Context +import com.hypertrack.hyperlog.LogFormat + +internal class Format(context: Context) : LogFormat(context) { + override fun getFormattedLogMessage( + logLevelName: String, + tag: String, + message: String, + timeStamp: String, + senderName: String, + osVersion: String, + deviceUuid: String + ) = "$timeStamp $logLevelName: $message" +} diff --git a/app/src/main/kotlin/com/github/gotify/log/Log.kt b/app/src/main/kotlin/com/github/gotify/log/Log.kt new file mode 100644 index 0000000..a4f3b91 --- /dev/null +++ b/app/src/main/kotlin/com/github/gotify/log/Log.kt @@ -0,0 +1,47 @@ +package com.github.gotify.log + +import android.content.Context +import android.util.Log +import com.hypertrack.hyperlog.HyperLog + +internal object Log { + private const val TAG = "gotify" + + fun init(content: Context) { + HyperLog.initialize(content, Format(content)) + HyperLog.setLogLevel(Log.INFO) // TODO configurable + } + + fun get(): String { + val logs = HyperLog.getDeviceLogsAsStringList(false) + return logs.takeLast(200).reversed().joinToString("\n") + } + + fun e(message: String) { + HyperLog.e(TAG, message) + } + + fun e(message: String, e: Throwable) { + HyperLog.e(TAG, "$message\n${Log.getStackTraceString(e)}") + } + + fun i(message: String) { + HyperLog.i(TAG, message) + } + + fun i(message: String, e: Throwable) { + HyperLog.i(TAG, "$message\n${Log.getStackTraceString(e)}") + } + + fun w(message: String) { + HyperLog.w(TAG, message) + } + + fun w(message: String, e: Throwable) { + HyperLog.w(TAG, "$message\n${Log.getStackTraceString(e)}") + } + + fun clear() { + HyperLog.deleteLogs() + } +} diff --git a/app/src/main/kotlin/com/github/gotify/log/LogsActivity.kt b/app/src/main/kotlin/com/github/gotify/log/LogsActivity.kt new file mode 100644 index 0000000..d78dbc4 --- /dev/null +++ b/app/src/main/kotlin/com/github/gotify/log/LogsActivity.kt @@ -0,0 +1,82 @@ +package com.github.gotify.log + +import android.content.ClipData +import android.content.ClipboardManager +import android.content.Context +import android.os.Bundle +import android.os.Handler +import android.os.Looper +import android.view.Menu +import android.view.MenuItem +import androidx.appcompat.app.AppCompatActivity +import com.github.gotify.R +import com.github.gotify.Utils +import com.github.gotify.Utils.launchCoroutine +import com.github.gotify.databinding.ActivityLogsBinding +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext + +internal class LogsActivity : AppCompatActivity() { + + private lateinit var binding: ActivityLogsBinding + private val handler = Handler(Looper.getMainLooper()) + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + binding = ActivityLogsBinding.inflate(layoutInflater) + setContentView(binding.root) + Log.i("Entering ${javaClass.simpleName}") + updateLogs() + setSupportActionBar(binding.appBarDrawer.toolbar) + val actionBar = supportActionBar + if (actionBar != null) { + actionBar.setDisplayHomeAsUpEnabled(true) + actionBar.setDisplayShowCustomEnabled(true) + } + } + + private fun updateLogs() { + launchCoroutine { + val log = Log.get() + withContext(Dispatchers.Main) { + val content = binding.logContent + if (content.selectionStart == content.selectionEnd) { + content.text = log + } + } + } + + if (!isDestroyed) { + handler.postDelayed({ updateLogs() }, 5000) + } + } + + override fun onCreateOptionsMenu(menu: Menu?): Boolean { + menuInflater.inflate(R.menu.logs_action, menu) + return true + } + + override fun onOptionsItemSelected(item: MenuItem): Boolean { + return when (item.itemId) { + android.R.id.home -> { + finish() + true + } + R.id.action_delete_logs -> { + Log.clear() + binding.logContent.text = null + true + } + R.id.action_copy_logs -> { + val content = binding.logContent + val clipboardManager = + getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager + val clipData = ClipData.newPlainText("GotifyLog", content.text.toString()) + clipboardManager.setPrimaryClip(clipData) + Utils.showSnackBar(this, getString(R.string.logs_copied)) + true + } + else -> false + } + } +} diff --git a/app/src/main/kotlin/com/github/gotify/log/UncaughtExceptionHandler.kt b/app/src/main/kotlin/com/github/gotify/log/UncaughtExceptionHandler.kt new file mode 100644 index 0000000..024af8a --- /dev/null +++ b/app/src/main/kotlin/com/github/gotify/log/UncaughtExceptionHandler.kt @@ -0,0 +1,11 @@ +package com.github.gotify.log + +import com.github.gotify.log.Log.e + +internal object UncaughtExceptionHandler { + fun registerCurrentThread() { + Thread.setDefaultUncaughtExceptionHandler { _, e: Throwable? -> + e("uncaught exception", e!!) + } + } +} diff --git a/app/src/main/kotlin/com/github/gotify/login/AdvancedDialog.kt b/app/src/main/kotlin/com/github/gotify/login/AdvancedDialog.kt new file mode 100644 index 0000000..6a1910d --- /dev/null +++ b/app/src/main/kotlin/com/github/gotify/login/AdvancedDialog.kt @@ -0,0 +1,67 @@ +package com.github.gotify.login + +import android.app.AlertDialog +import android.content.Context +import android.view.LayoutInflater +import android.widget.CompoundButton +import com.github.gotify.R +import com.github.gotify.databinding.AdvancedSettingsDialogBinding + +internal class AdvancedDialog( + private val context: Context, + private val layoutInflater: LayoutInflater +) { + private lateinit var binding: AdvancedSettingsDialogBinding + private var onCheckedChangeListener: CompoundButton.OnCheckedChangeListener? = null + private lateinit var onClickSelectCaCertificate: Runnable + private lateinit var onClickRemoveCaCertificate: Runnable + + fun onDisableSSLChanged( + onCheckedChangeListener: CompoundButton.OnCheckedChangeListener? + ): AdvancedDialog { + this.onCheckedChangeListener = onCheckedChangeListener + return this + } + + fun onClickSelectCaCertificate(onClickSelectCaCertificate: Runnable): AdvancedDialog { + this.onClickSelectCaCertificate = onClickSelectCaCertificate + return this + } + + fun onClickRemoveCaCertificate(onClickRemoveCaCertificate: Runnable): AdvancedDialog { + this.onClickRemoveCaCertificate = onClickRemoveCaCertificate + return this + } + + fun show(disableSSL: Boolean, selectedCertificate: String?): AdvancedDialog { + binding = AdvancedSettingsDialogBinding.inflate(layoutInflater) + binding.disableSSL.isChecked = disableSSL + binding.disableSSL.setOnCheckedChangeListener(onCheckedChangeListener) + if (selectedCertificate == null) { + showSelectCACertificate() + } else { + showRemoveCACertificate(selectedCertificate) + } + AlertDialog.Builder(context) + .setView(binding.root) + .setTitle(R.string.advanced_settings) + .setPositiveButton(context.getString(R.string.done), null) + .show() + return this + } + + private fun showSelectCACertificate() { + binding.toggleCaCert.setText(R.string.select_ca_certificate) + binding.toggleCaCert.setOnClickListener { onClickSelectCaCertificate.run() } + binding.selecetedCaCert.setText(R.string.no_certificate_selected) + } + + fun showRemoveCACertificate(certificate: String) { + binding.toggleCaCert.setText(R.string.remove_ca_certificate) + binding.toggleCaCert.setOnClickListener { + showSelectCACertificate() + onClickRemoveCaCertificate.run() + } + binding.selecetedCaCert.text = certificate + } +} diff --git a/app/src/main/kotlin/com/github/gotify/login/LoginActivity.kt b/app/src/main/kotlin/com/github/gotify/login/LoginActivity.kt new file mode 100644 index 0000000..ed84f7b --- /dev/null +++ b/app/src/main/kotlin/com/github/gotify/login/LoginActivity.kt @@ -0,0 +1,299 @@ +package com.github.gotify.login + +import android.content.ActivityNotFoundException +import android.content.DialogInterface +import android.content.Intent +import android.os.Build +import android.os.Bundle +import android.text.Editable +import android.text.TextWatcher +import android.view.View +import android.widget.EditText +import androidx.appcompat.app.AlertDialog +import androidx.appcompat.app.AppCompatActivity +import androidx.appcompat.view.ContextThemeWrapper +import com.github.gotify.R +import com.github.gotify.SSLSettings +import com.github.gotify.Settings +import com.github.gotify.Utils +import com.github.gotify.api.ApiException +import com.github.gotify.api.Callback +import com.github.gotify.api.Callback.SuccessCallback +import com.github.gotify.api.CertUtils +import com.github.gotify.api.ClientFactory +import com.github.gotify.client.ApiClient +import com.github.gotify.client.api.ClientApi +import com.github.gotify.client.api.UserApi +import com.github.gotify.client.model.Client +import com.github.gotify.client.model.VersionInfo +import com.github.gotify.databinding.ActivityLoginBinding +import com.github.gotify.init.InitializationActivity +import com.github.gotify.log.Log +import com.github.gotify.log.LogsActivity +import com.github.gotify.log.UncaughtExceptionHandler +import okhttp3.HttpUrl +import java.security.cert.X509Certificate + +internal class LoginActivity : AppCompatActivity() { + companion object { + // return value from startActivityForResult when choosing a certificate + private const val FILE_SELECT_CODE = 1 + } + + private lateinit var binding: ActivityLoginBinding + private lateinit var settings: Settings + + private var disableSslValidation = false + private var caCertContents: String? = null + private lateinit var advancedDialog: AdvancedDialog + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + UncaughtExceptionHandler.registerCurrentThread() + binding = ActivityLoginBinding.inflate(layoutInflater) + setContentView(binding.root) + Log.i("Entering ${javaClass.simpleName}") + settings = Settings(this) + } + + override fun onPostCreate(savedInstanceState: Bundle?) { + super.onPostCreate(savedInstanceState) + + binding.gotifyUrl.addTextChangedListener(object : TextWatcher { + override fun beforeTextChanged(charSequence: CharSequence, i: Int, i1: Int, i2: Int) {} + + override fun onTextChanged(charSequence: CharSequence, i: Int, i1: Int, i2: Int) { + invalidateUrl() + } + + override fun afterTextChanged(editable: Editable) {} + }) + + binding.checkurl.setOnClickListener { doCheckUrl() } + binding.openLogs.setOnClickListener { openLogs() } + binding.advancedSettings.setOnClickListener { toggleShowAdvanced() } + binding.login.setOnClickListener { doLogin() } + } + + private fun invalidateUrl() { + binding.username.visibility = View.GONE + binding.password.visibility = View.GONE + binding.login.visibility = View.GONE + binding.checkurl.text = getString(R.string.check_url) + } + + private fun doCheckUrl() { + val url = binding.gotifyUrl.text.toString().trim().trimEnd('/') + val parsedUrl = HttpUrl.parse(url) + if (parsedUrl == null) { + Utils.showSnackBar(this, "Invalid URL (include http:// or https://)") + return + } + + if ("http" == parsedUrl.scheme()) { + showHttpWarning() + } + + binding.checkurlProgress.visibility = View.VISIBLE + binding.checkurl.visibility = View.GONE + + try { + ClientFactory.versionApi(url, tempSslSettings()) + .version + .enqueue(Callback.callInUI(this, onValidUrl(url), onInvalidUrl(url))) + } catch (e: Exception) { + binding.checkurlProgress.visibility = View.GONE + binding.checkurl.visibility = View.VISIBLE + val errorMsg = getString(R.string.version_failed, "$url/version", e.message) + Utils.showSnackBar(this, errorMsg) + } + } + + private fun showHttpWarning() { + AlertDialog.Builder(ContextThemeWrapper(this, R.style.AppTheme_Dialog)) + .setTitle(R.string.warning) + .setCancelable(true) + .setMessage(R.string.http_warning) + .setPositiveButton(R.string.i_understand, null) + .show() + } + + private fun openLogs() { + startActivity(Intent(this, LogsActivity::class.java)) + } + + private fun toggleShowAdvanced() { + val selectedCertName = if (caCertContents != null) { + getNameOfCertContent(caCertContents!!) + } else { + null + } + + advancedDialog = AdvancedDialog(this, layoutInflater) + .onDisableSSLChanged { _, disable -> + invalidateUrl() + disableSslValidation = disable + } + .onClickSelectCaCertificate { + invalidateUrl() + doSelectCACertificate() + } + .onClickRemoveCaCertificate { + invalidateUrl() + caCertContents = null + } + .show(disableSslValidation, selectedCertName) + } + + private fun doSelectCACertificate() { + val intent = Intent(Intent.ACTION_OPEN_DOCUMENT) + // we don't really care what kind of file it is as long as we can parse it + intent.type = "*/*" + intent.addCategory(Intent.CATEGORY_OPENABLE) + + try { + startActivityForResult( + Intent.createChooser(intent, getString(R.string.select_ca_file)), + FILE_SELECT_CODE + ) + } catch (e: ActivityNotFoundException) { + // case for user not having a file browser installed + Utils.showSnackBar(this, getString(R.string.please_install_file_browser)) + } + } + + override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?) { + super.onActivityResult(requestCode, resultCode, data) + try { + if (requestCode == FILE_SELECT_CODE) { + require(resultCode == RESULT_OK) { "result was $resultCode" } + requireNotNull(data) { "file path was null" } + + val uri = data.data ?: throw IllegalArgumentException("file path was null") + + val fileStream = contentResolver.openInputStream(uri) + ?: throw IllegalArgumentException("file path was invalid") + + val content = Utils.readFileFromStream(fileStream) + val name = getNameOfCertContent(content) + + // temporarily set the contents (don't store to settings until they decide to login) + caCertContents = content + advancedDialog.showRemoveCACertificate(name) + } + } catch (e: Exception) { + Utils.showSnackBar(this, getString(R.string.select_ca_failed, e.message)) + } + } + + private fun getNameOfCertContent(content: String): String { + val ca = CertUtils.parseCertificate(content) + return (ca as X509Certificate).subjectDN.name + } + + private fun onValidUrl(url: String): SuccessCallback { + return Callback.SuccessBody { version -> + settings.url = url + binding.checkurlProgress.visibility = View.GONE + binding.checkurl.visibility = View.VISIBLE + binding.checkurl.text = getString(R.string.found_gotify_version, version.version) + binding.username.visibility = View.VISIBLE + binding.username.requestFocus() + binding.password.visibility = View.VISIBLE + binding.login.visibility = View.VISIBLE + } + } + + private fun onInvalidUrl(url: String): Callback.ErrorCallback { + return Callback.ErrorCallback { exception -> + binding.checkurlProgress.visibility = View.GONE + binding.checkurl.visibility = View.VISIBLE + Utils.showSnackBar(this, versionError(url, exception)) + } + } + + private fun doLogin() { + val username = binding.username.text.toString() + val password = binding.password.text.toString() + + binding.login.visibility = View.GONE + binding.loginProgress.visibility = View.VISIBLE + + val client = ClientFactory.basicAuth(settings.url, tempSslSettings(), username, password) + client.createService(UserApi::class.java) + .currentUser() + .enqueue( + Callback.callInUI( + this, + onSuccess = { newClientDialog(client) }, + onError = { onInvalidLogin() } + ) + ) + } + + private fun onInvalidLogin() { + binding.login.visibility = View.VISIBLE + binding.loginProgress.visibility = View.GONE + Utils.showSnackBar(this, getString(R.string.wronguserpw)) + } + + private fun newClientDialog(client: ApiClient) { + val clientName = EditText(this) + clientName.setText(Build.MODEL) + + AlertDialog.Builder(ContextThemeWrapper(this, R.style.AppTheme_Dialog)) + .setTitle(R.string.create_client_title) + .setMessage(R.string.create_client_message) + .setView(clientName) + .setPositiveButton(R.string.create, doCreateClient(client, clientName)) + .setNegativeButton(R.string.cancel) { _, _ -> onCancelClientDialog() } + .show() + } + + private fun doCreateClient( + client: ApiClient, + nameProvider: EditText + ): DialogInterface.OnClickListener { + return DialogInterface.OnClickListener { _, _ -> + val newClient = Client().name(nameProvider.text.toString()) + client.createService(ClientApi::class.java) + .createClient(newClient) + .enqueue( + Callback.callInUI( + this, + onSuccess = Callback.SuccessBody { client -> onCreatedClient(client) }, + onError = { onFailedToCreateClient() } + ) + ) + } + } + + private fun onCreatedClient(client: Client) { + settings.token = client.token + settings.validateSSL = !disableSslValidation + settings.cert = caCertContents + + Utils.showSnackBar(this, getString(R.string.created_client)) + startActivity(Intent(this, InitializationActivity::class.java)) + finish() + } + + private fun onFailedToCreateClient() { + Utils.showSnackBar(this, getString(R.string.create_client_failed)) + binding.loginProgress.visibility = View.GONE + binding.login.visibility = View.VISIBLE + } + + private fun onCancelClientDialog() { + binding.loginProgress.visibility = View.GONE + binding.login.visibility = View.VISIBLE + } + + private fun versionError(url: String, exception: ApiException): String { + return getString(R.string.version_failed_status_code, "$url/version", exception.code) + } + + private fun tempSslSettings(): SSLSettings { + return SSLSettings(!disableSslValidation, caCertContents) + } +} diff --git a/app/src/main/kotlin/com/github/gotify/messages/Extras.kt b/app/src/main/kotlin/com/github/gotify/messages/Extras.kt new file mode 100644 index 0000000..39a01d9 --- /dev/null +++ b/app/src/main/kotlin/com/github/gotify/messages/Extras.kt @@ -0,0 +1,42 @@ +package com.github.gotify.messages + +import com.github.gotify.client.model.Message + +internal object Extras { + fun useMarkdown(message: Message): Boolean = useMarkdown(message.extras) + + fun useMarkdown(extras: Map?): Boolean { + if (extras == null) { + return false + } + + val display: Any? = extras["client::display"] + if (display !is Map<*, *>) { + return false + } + + return "text/markdown" == display["contentType"] + } + + fun getNestedValue( + clazz: Class, + extras: Map?, + vararg keys: String + ): T? { + var value: Any? = extras + + keys.forEach { key -> + if (value == null) { + return null + } + + value = (value as Map<*, *>)[key] + } + + if (!clazz.isInstance(value)) { + return null + } + + return clazz.cast(value) + } +} diff --git a/app/src/main/kotlin/com/github/gotify/messages/ListMessageAdapter.kt b/app/src/main/kotlin/com/github/gotify/messages/ListMessageAdapter.kt new file mode 100644 index 0000000..e36916d --- /dev/null +++ b/app/src/main/kotlin/com/github/gotify/messages/ListMessageAdapter.kt @@ -0,0 +1,181 @@ +package com.github.gotify.messages + +import android.content.ClipData +import android.content.ClipboardManager +import android.content.Context +import android.content.SharedPreferences +import android.text.format.DateUtils +import android.text.util.Linkify +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import android.widget.ImageButton +import android.widget.ImageView +import android.widget.TextView +import android.widget.Toast +import androidx.preference.PreferenceManager +import androidx.recyclerview.widget.RecyclerView +import androidx.viewbinding.ViewBinding +import com.github.gotify.MarkwonFactory +import com.github.gotify.R +import com.github.gotify.Settings +import com.github.gotify.Utils +import com.github.gotify.client.model.Message +import com.github.gotify.databinding.MessageItemBinding +import com.github.gotify.databinding.MessageItemCompactBinding +import com.github.gotify.messages.provider.MessageWithImage +import com.squareup.picasso.Picasso +import io.noties.markwon.Markwon +import org.threeten.bp.OffsetDateTime +import java.text.DateFormat +import java.util.Date + +internal class ListMessageAdapter( + private val context: Context, + private val settings: Settings, + private val picasso: Picasso, + var items: List, + private val delete: Delete +) : RecyclerView.Adapter() { + private val prefs: SharedPreferences = PreferenceManager.getDefaultSharedPreferences(context) + private val markwon: Markwon = MarkwonFactory.createForMessage(context, picasso) + + private val timeFormatRelative = + context.resources.getString(R.string.time_format_value_relative) + private val timeFormatPrefsKey = context.resources.getString(R.string.setting_key_time_format) + + private var messageLayout = 0 + + init { + val messageLayoutPrefsKey = context.resources.getString(R.string.setting_key_message_layout) + val messageLayoutNormal = context.resources.getString(R.string.message_layout_value_normal) + val messageLayoutSetting = prefs.getString(messageLayoutPrefsKey, messageLayoutNormal) + + messageLayout = if (messageLayoutSetting == messageLayoutNormal) { + R.layout.message_item + } else { + R.layout.message_item_compact + } + } + + override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): ViewHolder { + val layoutInflater = LayoutInflater.from(parent.context) + return if (messageLayout == R.layout.message_item) { + val binding = MessageItemBinding.inflate(layoutInflater, parent, false) + ViewHolder(binding) + } else { + val binding = MessageItemCompactBinding.inflate(layoutInflater, parent, false) + ViewHolder(binding) + } + } + + override fun onBindViewHolder(holder: ViewHolder, position: Int) { + val message = items[position] + if (Extras.useMarkdown(message.message)) { + holder.message.autoLinkMask = 0 + markwon.setMarkdown(holder.message, message.message.message) + } else { + holder.message.autoLinkMask = Linkify.WEB_URLS + holder.message.text = message.message.message + } + holder.title.text = message.message.title + picasso.load(Utils.resolveAbsoluteUrl("${settings.url}/", message.image)) + .error(R.drawable.ic_alarm) + .placeholder(R.drawable.ic_placeholder) + .into(holder.image) + + val prefs = PreferenceManager.getDefaultSharedPreferences(context) + val timeFormat = prefs.getString(timeFormatPrefsKey, timeFormatRelative) + holder.setDateTime(message.message.date, timeFormat == timeFormatRelative) + holder.date.setOnClickListener { holder.switchTimeFormat() } + + holder.delete.setOnClickListener { + delete.delete(holder.adapterPosition, message.message, false) + } + } + + override fun getItemCount() = items.size + + override fun getItemId(position: Int): Long { + val currentItem = items[position] + return currentItem.message.id + } + + class ViewHolder(binding: ViewBinding) : RecyclerView.ViewHolder(binding.root) { + lateinit var image: ImageView + lateinit var message: TextView + lateinit var title: TextView + lateinit var date: TextView + lateinit var delete: ImageButton + + private var relativeTimeFormat = true + private lateinit var dateTime: OffsetDateTime + + init { + enableCopyToClipboard() + if (binding is MessageItemBinding) { + image = binding.messageImage + message = binding.messageText + title = binding.messageTitle + date = binding.messageDate + delete = binding.messageDelete + } else if (binding is MessageItemCompactBinding) { + image = binding.messageImage + message = binding.messageText + title = binding.messageTitle + date = binding.messageDate + delete = binding.messageDelete + } + } + + fun switchTimeFormat() { + relativeTimeFormat = !relativeTimeFormat + updateDate() + } + + fun setDateTime(dateTime: OffsetDateTime, relativeTimeFormatPreference: Boolean) { + this.dateTime = dateTime + relativeTimeFormat = relativeTimeFormatPreference + updateDate() + } + + private fun updateDate() { + val text = if (relativeTimeFormat) { + // Relative time format + Utils.dateToRelative(dateTime) + } else { + // Absolute time format + val time = dateTime.toInstant().toEpochMilli() + val date = Date(time) + if (DateUtils.isToday(time)) { + DateFormat.getTimeInstance(DateFormat.SHORT).format(date) + } else { + DateFormat.getDateTimeInstance(DateFormat.MEDIUM, DateFormat.SHORT).format(date) + } + } + + date.text = text + } + + private fun enableCopyToClipboard() { + super.itemView.setOnLongClickListener { view: View -> + val clipboard = view.context + .getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager? + val clip = ClipData.newPlainText("GotifyMessageContent", message.text.toString()) + if (clipboard != null) { + clipboard.setPrimaryClip(clip) + Toast.makeText( + view.context, + view.context.getString(R.string.message_copied_to_clipboard), + Toast.LENGTH_SHORT + ).show() + } + true + } + } + } + + fun interface Delete { + fun delete(position: Int, message: Message, listAnimation: Boolean) + } +} diff --git a/app/src/main/kotlin/com/github/gotify/messages/MessagesActivity.kt b/app/src/main/kotlin/com/github/gotify/messages/MessagesActivity.kt new file mode 100644 index 0000000..78b13bf --- /dev/null +++ b/app/src/main/kotlin/com/github/gotify/messages/MessagesActivity.kt @@ -0,0 +1,638 @@ +package com.github.gotify.messages + +import android.app.NotificationManager +import android.content.BroadcastReceiver +import android.content.Context +import android.content.Intent +import android.content.IntentFilter +import android.graphics.Canvas +import android.graphics.drawable.ColorDrawable +import android.graphics.drawable.Drawable +import android.net.Uri +import android.os.Bundle +import android.view.Menu +import android.view.MenuItem +import android.view.View +import android.widget.ImageButton +import android.widget.TextView +import androidx.appcompat.app.ActionBarDrawerToggle +import androidx.appcompat.app.AlertDialog +import androidx.appcompat.app.AppCompatActivity +import androidx.appcompat.view.ContextThemeWrapper +import androidx.core.content.ContextCompat +import androidx.core.graphics.drawable.DrawableCompat +import androidx.core.view.GravityCompat +import androidx.drawerlayout.widget.DrawerLayout.SimpleDrawerListener +import androidx.lifecycle.ViewModelProvider +import androidx.recyclerview.widget.DividerItemDecoration +import androidx.recyclerview.widget.ItemTouchHelper +import androidx.recyclerview.widget.LinearLayoutManager +import androidx.recyclerview.widget.RecyclerView +import com.github.gotify.BuildConfig +import com.github.gotify.MissedMessageUtil +import com.github.gotify.R +import com.github.gotify.Utils +import com.github.gotify.Utils.launchCoroutine +import com.github.gotify.api.Api +import com.github.gotify.api.ApiException +import com.github.gotify.api.Callback +import com.github.gotify.api.ClientFactory +import com.github.gotify.client.api.ApplicationApi +import com.github.gotify.client.api.ClientApi +import com.github.gotify.client.api.MessageApi +import com.github.gotify.client.model.Application +import com.github.gotify.client.model.Client +import com.github.gotify.client.model.Message +import com.github.gotify.databinding.ActivityMessagesBinding +import com.github.gotify.init.InitializationActivity +import com.github.gotify.log.Log +import com.github.gotify.log.LogsActivity +import com.github.gotify.login.LoginActivity +import com.github.gotify.messages.provider.MessageState +import com.github.gotify.messages.provider.MessageWithImage +import com.github.gotify.service.WebSocketService +import com.github.gotify.settings.SettingsActivity +import com.github.gotify.sharing.ShareActivity +import com.google.android.material.navigation.NavigationView +import com.google.android.material.snackbar.BaseTransientBottomBar.BaseCallback +import com.google.android.material.snackbar.Snackbar +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext +import java.io.IOException + +internal class MessagesActivity : + AppCompatActivity(), + NavigationView.OnNavigationItemSelectedListener { + private lateinit var binding: ActivityMessagesBinding + private lateinit var viewModel: MessagesModel + private var isLoadMore = false + private var updateAppOnDrawerClose: Long? = null + private lateinit var listMessageAdapter: ListMessageAdapter + + private val receiver: BroadcastReceiver = object : BroadcastReceiver() { + override fun onReceive(context: Context, intent: Intent) { + val messageJson = intent.getStringExtra("message") + val message = Utils.JSON.fromJson( + messageJson, + Message::class.java + ) + launchCoroutine { + addSingleMessage(message) + } + } + } + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + binding = ActivityMessagesBinding.inflate(layoutInflater) + setContentView(binding.root) + viewModel = ViewModelProvider(this, MessagesModelFactory(this))[MessagesModel::class.java] + Log.i("Entering " + javaClass.simpleName) + initDrawer() + + val layoutManager = LinearLayoutManager(this) + val messagesView: RecyclerView = binding.messagesView + val dividerItemDecoration = DividerItemDecoration( + messagesView.context, + layoutManager.orientation + ) + listMessageAdapter = ListMessageAdapter( + this, + viewModel.settings, + viewModel.picassoHandler.get(), + emptyList() + ) { position, message, listAnimation -> + scheduleDeletion( + position, + message, + listAnimation + ) + } + + messagesView.addItemDecoration(dividerItemDecoration) + messagesView.setHasFixedSize(true) + messagesView.layoutManager = layoutManager + messagesView.addOnScrollListener(MessageListOnScrollListener()) + messagesView.adapter = listMessageAdapter + + val appsHolder = viewModel.appsHolder + appsHolder.onUpdate { onUpdateApps(appsHolder.get()) } + if (appsHolder.wasRequested()) onUpdateApps(appsHolder.get()) else appsHolder.request() + + val itemTouchHelper = ItemTouchHelper(SwipeToDeleteCallback(listMessageAdapter)) + itemTouchHelper.attachToRecyclerView(messagesView) + + val swipeRefreshLayout = binding.swipeRefresh + swipeRefreshLayout.setOnRefreshListener { onRefresh() } + binding.drawerLayout.addDrawerListener( + object : SimpleDrawerListener() { + override fun onDrawerClosed(drawerView: View) { + updateAppOnDrawerClose?.let { selectApp -> + updateAppOnDrawerClose = null + viewModel.appId = selectApp + launchCoroutine { + updateMessagesForApplication(true, selectApp) + } + invalidateOptionsMenu() + } + } + } + ) + + swipeRefreshLayout.isEnabled = false + messagesView + .viewTreeObserver + .addOnScrollChangedListener { + val topChild = messagesView.getChildAt(0) + if (topChild != null) { + swipeRefreshLayout.isEnabled = topChild.top == 0 + } else { + swipeRefreshLayout.isEnabled = true + } + } + + launchCoroutine { + updateMessagesForApplication(true, viewModel.appId) + } + } + + override fun onPostCreate(savedInstanceState: Bundle?) { + super.onPostCreate(savedInstanceState) + binding.learnGotify.setOnClickListener { openDocumentation() } + } + + private fun refreshAll() { + try { + viewModel.picassoHandler.evict() + } catch (e: IOException) { + Log.e("Problem evicting Picasso cache", e) + } + startActivity(Intent(this, InitializationActivity::class.java)) + finish() + } + + private fun onRefresh() { + viewModel.messages.clear() + launchCoroutine { + loadMore(viewModel.appId) + } + } + + private fun openDocumentation() { + val browserIntent = Intent(Intent.ACTION_VIEW, Uri.parse("https://gotify.net/docs/pushmsg")) + startActivity(browserIntent) + } + + private fun onUpdateApps(applications: List) { + val menu: Menu = binding.navView.menu + menu.removeGroup(R.id.apps) + viewModel.targetReferences.clear() + updateMessagesAndStopLoading(viewModel.messages[viewModel.appId]) + var selectedItem = menu.findItem(R.id.nav_all_messages) + applications.indices.forEach { index -> + val app = applications[index] + val item = menu.add(R.id.apps, index, APPLICATION_ORDER, app.name) + item.isCheckable = true + if (app.id == viewModel.appId) selectedItem = item + val t = Utils.toDrawable(resources) { icon -> item.icon = icon } + viewModel.targetReferences.add(t) + viewModel.picassoHandler + .get() + .load(Utils.resolveAbsoluteUrl(viewModel.settings.url + "/", app.image)) + .error(R.drawable.ic_alarm) + .placeholder(R.drawable.ic_placeholder) + .resize(100, 100) + .into(t) + } + selectAppInMenu(selectedItem) + } + + private fun initDrawer() { + setSupportActionBar(binding.appBarDrawer.toolbar) + binding.navView.itemIconTintList = null + val toggle = ActionBarDrawerToggle( + this, + binding.drawerLayout, + binding.appBarDrawer.toolbar, + R.string.navigation_drawer_open, + R.string.navigation_drawer_close + ) + binding.drawerLayout.addDrawerListener(toggle) + toggle.syncState() + + binding.navView.setNavigationItemSelectedListener(this) + val headerView = binding.navView.getHeaderView(0) + + val settings = viewModel.settings + + val user = headerView.findViewById(R.id.header_user) + user.text = settings.user?.name + + val connection = headerView.findViewById(R.id.header_connection) + connection.text = getString(R.string.connection, settings.user?.name, settings.url) + + val version = headerView.findViewById(R.id.header_version) + version.text = + getString(R.string.versions, BuildConfig.VERSION_NAME, settings.serverVersion) + + val refreshAll = headerView.findViewById(R.id.refresh_all) + refreshAll.setOnClickListener { refreshAll() } + } + + override fun onBackPressed() { + if (binding.drawerLayout.isDrawerOpen(GravityCompat.START)) { + binding.drawerLayout.closeDrawer(GravityCompat.START) + } else { + super.onBackPressed() + } + } + + override fun onNavigationItemSelected(item: MenuItem): Boolean { + // Handle navigation view item clicks here. + val id = item.itemId + if (item.groupId == R.id.apps) { + val app = viewModel.appsHolder.get()[id] + updateAppOnDrawerClose = app.id + startLoading() + binding.appBarDrawer.toolbar.subtitle = item.title + } else if (id == R.id.nav_all_messages) { + updateAppOnDrawerClose = MessageState.ALL_MESSAGES + startLoading() + binding.appBarDrawer.toolbar.subtitle = "" + } else if (id == R.id.logout) { + AlertDialog.Builder(ContextThemeWrapper(this, R.style.AppTheme_Dialog)) + .setTitle(R.string.logout) + .setMessage(getString(R.string.logout_confirm)) + .setPositiveButton(R.string.yes) { _, _ -> doLogout() } + .setNegativeButton(R.string.cancel, null) + .show() + } else if (id == R.id.nav_logs) { + startActivity(Intent(this, LogsActivity::class.java)) + } else if (id == R.id.settings) { + startActivity(Intent(this, SettingsActivity::class.java)) + } else if (id == R.id.push_message) { + val intent = Intent(this@MessagesActivity, ShareActivity::class.java) + startActivity(intent) + } + binding.drawerLayout.closeDrawer(GravityCompat.START) + return true + } + + private fun doLogout() { + setContentView(R.layout.splash) + launchCoroutine { + deleteClientAndNavigateToLogin() + } + } + + private fun startLoading() { + binding.swipeRefresh.isRefreshing = true + binding.messagesView.visibility = View.GONE + } + + private fun stopLoading() { + binding.swipeRefresh.isRefreshing = false + binding.messagesView.visibility = View.VISIBLE + } + + override fun onResume() { + val context = applicationContext + val nManager = context.getSystemService(NOTIFICATION_SERVICE) as NotificationManager + nManager.cancelAll() + val filter = IntentFilter() + filter.addAction(WebSocketService.NEW_MESSAGE_BROADCAST) + registerReceiver(receiver, filter) + launchCoroutine { + updateMissedMessages(viewModel.messages.getLastReceivedMessage()) + } + var selectedIndex = R.id.nav_all_messages + val appId = viewModel.appId + if (appId != MessageState.ALL_MESSAGES) { + val apps = viewModel.appsHolder.get() + apps.indices.forEach { index -> + if (apps[index].id == appId) { + selectedIndex = index + } + } + } + listMessageAdapter.notifyDataSetChanged() + selectAppInMenu(binding.navView.menu.findItem(selectedIndex)) + super.onResume() + } + + override fun onPause() { + unregisterReceiver(receiver) + super.onPause() + } + + private fun selectAppInMenu(appItem: MenuItem?) { + if (appItem != null) { + appItem.isChecked = true + if (appItem.itemId != R.id.nav_all_messages) { + binding.appBarDrawer.toolbar.subtitle = appItem.title + } + } + } + + private fun scheduleDeletion( + position: Int, + message: Message, + listAnimation: Boolean + ) { + val adapter = binding.messagesView.adapter as ListMessageAdapter + val messages = viewModel.messages + messages.deleteLocal(message) + adapter.items = messages[viewModel.appId] + if (listAnimation) { + adapter.notifyItemRemoved(position) + } else { + adapter.notifyDataSetChanged() + } + showDeletionSnackbar() + } + + private fun undoDelete() { + val messages = viewModel.messages + val deletion = messages.undoDeleteLocal() + if (deletion != null) { + val adapter = binding.messagesView.adapter as ListMessageAdapter + val appId = viewModel.appId + adapter.items = messages[appId] + val insertPosition = if (appId == MessageState.ALL_MESSAGES) { + deletion.allPosition + } else { + deletion.appPosition + } + adapter.notifyItemInserted(insertPosition) + } + } + + private fun showDeletionSnackbar() { + val view: View = binding.swipeRefresh + val snackbar = Snackbar.make(view, R.string.snackbar_deleted, Snackbar.LENGTH_LONG) + snackbar.setAction(R.string.snackbar_undo) { undoDelete() } + snackbar.addCallback(SnackbarCallback()) + snackbar.show() + } + + private inner class SnackbarCallback : BaseCallback() { + override fun onDismissed(transientBottomBar: Snackbar?, event: Int) { + super.onDismissed(transientBottomBar, event) + if (event != DISMISS_EVENT_ACTION && event != DISMISS_EVENT_CONSECUTIVE) { + // Execute deletion when the snackbar disappeared without pressing the undo button + // DISMISS_EVENT_CONSECUTIVE should be excluded as well, because it would cause the + // deletion to be sent to the server twice, since the deletion is sent to the server + // in MessageFacade if a message is deleted while another message was already + // waiting for deletion. + launchCoroutine { + commitDeleteMessage() + } + } + } + } + + private inner class SwipeToDeleteCallback( + private val adapter: ListMessageAdapter + ) : ItemTouchHelper.SimpleCallback(0, ItemTouchHelper.LEFT or ItemTouchHelper.RIGHT) { + private var icon: Drawable? + private val background: ColorDrawable + + init { + val backgroundColorId = + ContextCompat.getColor(this@MessagesActivity, R.color.swipeBackground) + val iconColorId = ContextCompat.getColor(this@MessagesActivity, R.color.swipeIcon) + val drawable = ContextCompat.getDrawable(this@MessagesActivity, R.drawable.ic_delete) + icon = null + if (drawable != null) { + icon = DrawableCompat.wrap(drawable.mutate()) + DrawableCompat.setTint(icon!!, iconColorId) + } + background = ColorDrawable(backgroundColorId) + } + + override fun onMove( + recyclerView: RecyclerView, + viewHolder: RecyclerView.ViewHolder, + target: RecyclerView.ViewHolder + ) = false + + override fun onSwiped(viewHolder: RecyclerView.ViewHolder, direction: Int) { + val position = viewHolder.adapterPosition + val message = adapter.items[position] + scheduleDeletion(position, message.message, true) + } + + override fun onChildDraw( + c: Canvas, + recyclerView: RecyclerView, + viewHolder: RecyclerView.ViewHolder, + dX: Float, + dY: Float, + actionState: Int, + isCurrentlyActive: Boolean + ) { + icon?.let { + val itemView = viewHolder.itemView + val iconHeight = itemView.height / 3 + val scale = iconHeight / it.intrinsicHeight.toDouble() + val iconWidth = (it.intrinsicWidth * scale).toInt() + val iconMarginLeftRight = 50 + val iconMarginTopBottom = (itemView.height - iconHeight) / 2 + val iconTop = itemView.top + iconMarginTopBottom + val iconBottom = itemView.bottom - iconMarginTopBottom + if (dX > 0) { + // Swiping to the right + val iconLeft = itemView.left + iconMarginLeftRight + val iconRight = itemView.left + iconMarginLeftRight + iconWidth + it.setBounds(iconLeft, iconTop, iconRight, iconBottom) + background.setBounds( + itemView.left, + itemView.top, + itemView.left + dX.toInt(), + itemView.bottom + ) + } else if (dX < 0) { + // Swiping to the left + val iconLeft = itemView.right - iconMarginLeftRight - iconWidth + val iconRight = itemView.right - iconMarginLeftRight + it.setBounds(iconLeft, iconTop, iconRight, iconBottom) + background.setBounds( + itemView.right + dX.toInt(), + itemView.top, + itemView.right, + itemView.bottom + ) + } else { + // View is unswiped + it.setBounds(0, 0, 0, 0) + background.setBounds(0, 0, 0, 0) + } + background.draw(c) + it.draw(c) + } + super.onChildDraw(c, recyclerView, viewHolder, dX, dY, actionState, isCurrentlyActive) + } + } + + private inner class MessageListOnScrollListener : RecyclerView.OnScrollListener() { + override fun onScrollStateChanged(view: RecyclerView, scrollState: Int) {} + + override fun onScrolled(view: RecyclerView, dx: Int, dy: Int) { + val linearLayoutManager = view.layoutManager as LinearLayoutManager? + if (linearLayoutManager != null) { + val lastVisibleItem = linearLayoutManager.findLastVisibleItemPosition() + val totalItemCount = view.adapter!!.itemCount + if (lastVisibleItem > totalItemCount - 15 && + totalItemCount != 0 && + viewModel.messages.canLoadMore(viewModel.appId) + ) { + if (!isLoadMore) { + isLoadMore = true + launchCoroutine { + loadMore(viewModel.appId) + } + } + } + } + } + } + + private suspend fun updateMissedMessages(id: Long) { + if (id == -1L) return + + val newMessages = MissedMessageUtil(viewModel.client.createService(MessageApi::class.java)) + .missingMessages(id).filterNotNull() + viewModel.messages.addMessages(newMessages) + + if (newMessages.isNotEmpty()) { + updateMessagesForApplication(true, viewModel.appId) + } + } + + override fun onCreateOptionsMenu(menu: Menu): Boolean { + menuInflater.inflate(R.menu.messages_action, menu) + menu.findItem(R.id.action_delete_app).isVisible = + viewModel.appId != MessageState.ALL_MESSAGES + return super.onCreateOptionsMenu(menu) + } + + override fun onOptionsItemSelected(item: MenuItem): Boolean { + if (item.itemId == R.id.action_delete_all) { + launchCoroutine { + deleteMessages(viewModel.appId) + } + } + if (item.itemId == R.id.action_delete_app) { + val alert = android.app.AlertDialog.Builder(this) + alert.setTitle(R.string.delete_app) + alert.setMessage(R.string.ack) + alert.setPositiveButton(R.string.yes) { _, _ -> deleteApp(viewModel.appId) } + alert.setNegativeButton(R.string.no, null) + alert.show() + } + return super.onContextItemSelected(item) + } + + private fun deleteApp(appId: Long) { + val settings = viewModel.settings + val client = ClientFactory.clientToken(settings.url, settings.sslSettings(), settings.token) + client.createService(ApplicationApi::class.java) + .deleteApp(appId) + .enqueue( + Callback.callInUI( + this, + onSuccess = { refreshAll() }, + onError = { Utils.showSnackBar(this, getString(R.string.error_delete_app)) } + ) + ) + } + + private suspend fun loadMore(appId: Long) { + val messagesWithImages = viewModel.messages.loadMore(appId) + withContext(Dispatchers.Main) { + updateMessagesAndStopLoading(messagesWithImages) + } + } + + private suspend fun updateMessagesForApplication(withLoadingSpinner: Boolean, appId: Long) { + if (withLoadingSpinner) { + withContext(Dispatchers.Main) { + startLoading() + } + } + viewModel.messages.loadMoreIfNotPresent(appId) + withContext(Dispatchers.Main) { + updateMessagesAndStopLoading(viewModel.messages[appId]) + } + } + + private suspend fun addSingleMessage(message: Message) { + viewModel.messages.addMessages(listOf(message)) + updateMessagesForApplication(false, viewModel.appId) + } + + private suspend fun commitDeleteMessage() { + viewModel.messages.commitDelete() + updateMessagesForApplication(false, viewModel.appId) + } + + private suspend fun deleteMessages(appId: Long) { + withContext(Dispatchers.Main) { + startLoading() + } + val success = viewModel.messages.deleteAll(appId) + if (success) { + updateMessagesForApplication(false, viewModel.appId) + } else { + withContext(Dispatchers.Main) { + Utils.showSnackBar(this@MessagesActivity, "Delete failed :(") + } + } + } + + private fun deleteClientAndNavigateToLogin() { + val settings = viewModel.settings + val api = ClientFactory.clientToken(settings.url, settings.sslSettings(), settings.token) + .createService(ClientApi::class.java) + stopService(Intent(this@MessagesActivity, WebSocketService::class.java)) + try { + val clients = Api.execute(api.clients) + var currentClient: Client? = null + for (client in clients) { + if (client.token == settings.token) { + currentClient = client + break + } + } + if (currentClient != null) { + Log.i("Delete client with id " + currentClient.id) + Api.execute(api.deleteClient(currentClient.id)) + } else { + Log.e("Could not delete client, client does not exist.") + } + } catch (e: ApiException) { + Log.e("Could not delete client", e) + } + + viewModel.settings.clear() + startActivity(Intent(this@MessagesActivity, LoginActivity::class.java)) + finish() + } + + private fun updateMessagesAndStopLoading(messageWithImages: List) { + isLoadMore = false + stopLoading() + if (messageWithImages.isEmpty()) { + binding.flipper.displayedChild = 1 + } else { + binding.flipper.displayedChild = 0 + } + val adapter = binding.messagesView.adapter as ListMessageAdapter + adapter.items = messageWithImages + adapter.notifyDataSetChanged() + } + + companion object { + private const val APPLICATION_ORDER = 1 + } +} diff --git a/app/src/main/kotlin/com/github/gotify/messages/MessagesModel.kt b/app/src/main/kotlin/com/github/gotify/messages/MessagesModel.kt new file mode 100644 index 0000000..bcd7c47 --- /dev/null +++ b/app/src/main/kotlin/com/github/gotify/messages/MessagesModel.kt @@ -0,0 +1,25 @@ +package com.github.gotify.messages + +import android.app.Activity +import androidx.lifecycle.ViewModel +import com.github.gotify.Settings +import com.github.gotify.api.ClientFactory +import com.github.gotify.client.api.MessageApi +import com.github.gotify.messages.provider.ApplicationHolder +import com.github.gotify.messages.provider.MessageFacade +import com.github.gotify.messages.provider.MessageState +import com.github.gotify.picasso.PicassoHandler +import com.squareup.picasso.Target + +internal class MessagesModel(parentView: Activity) : ViewModel() { + val settings = Settings(parentView) + val picassoHandler = PicassoHandler(parentView, settings) + val client = ClientFactory.clientToken(settings.url, settings.sslSettings(), settings.token) + val appsHolder = ApplicationHolder(parentView, client) + val messages = MessageFacade(client.createService(MessageApi::class.java), appsHolder) + + // we need to keep the target references otherwise they get gc'ed before they can be called. + val targetReferences = mutableListOf() + + var appId = MessageState.ALL_MESSAGES +} diff --git a/app/src/main/kotlin/com/github/gotify/messages/MessagesModelFactory.kt b/app/src/main/kotlin/com/github/gotify/messages/MessagesModelFactory.kt new file mode 100644 index 0000000..32d8def --- /dev/null +++ b/app/src/main/kotlin/com/github/gotify/messages/MessagesModelFactory.kt @@ -0,0 +1,18 @@ +package com.github.gotify.messages + +import android.app.Activity +import androidx.lifecycle.ViewModel +import androidx.lifecycle.ViewModelProvider + +internal class MessagesModelFactory( + var modelParameterActivity: Activity +) : ViewModelProvider.Factory { + override fun create(modelClass: Class): T { + if (modelClass == MessagesModel::class.java) { + return modelClass.cast(MessagesModel(modelParameterActivity)) as T + } + throw IllegalArgumentException( + "modelClass parameter must be of type ${MessagesModel::class.java.name}" + ) + } +} diff --git a/app/src/main/kotlin/com/github/gotify/messages/provider/ApplicationHolder.kt b/app/src/main/kotlin/com/github/gotify/messages/provider/ApplicationHolder.kt new file mode 100644 index 0000000..68e5c41 --- /dev/null +++ b/app/src/main/kotlin/com/github/gotify/messages/provider/ApplicationHolder.kt @@ -0,0 +1,48 @@ +package com.github.gotify.messages.provider + +import android.app.Activity +import com.github.gotify.Utils +import com.github.gotify.api.Callback +import com.github.gotify.client.ApiClient +import com.github.gotify.client.api.ApplicationApi +import com.github.gotify.client.model.Application + +internal class ApplicationHolder(private val activity: Activity, private val client: ApiClient) { + private var state = listOf() + private var onUpdate: Runnable? = null + private var onUpdateFailed: Runnable? = null + + fun wasRequested() = state.isNotEmpty() + + fun request() { + client.createService(ApplicationApi::class.java) + .apps + .enqueue( + Callback.callInUI( + activity, + onSuccess = Callback.SuccessBody { apps -> onReceiveApps(apps) }, + onError = { onFailedApps() } + ) + ) + } + + private fun onReceiveApps(apps: List) { + state = apps + if (onUpdate != null) onUpdate!!.run() + } + + private fun onFailedApps() { + Utils.showSnackBar(activity, "Could not request applications, see logs.") + if (onUpdateFailed != null) onUpdateFailed!!.run() + } + + fun get() = state + + fun onUpdate(runnable: Runnable?) { + onUpdate = runnable + } + + fun onUpdateFailed(runnable: Runnable?) { + onUpdateFailed = runnable + } +} diff --git a/app/src/main/kotlin/com/github/gotify/messages/provider/MessageDeletion.kt b/app/src/main/kotlin/com/github/gotify/messages/provider/MessageDeletion.kt new file mode 100644 index 0000000..14cc1e4 --- /dev/null +++ b/app/src/main/kotlin/com/github/gotify/messages/provider/MessageDeletion.kt @@ -0,0 +1,9 @@ +package com.github.gotify.messages.provider + +import com.github.gotify.client.model.Message + +internal class MessageDeletion( + val message: Message, + val allPosition: Int, + val appPosition: Int +) diff --git a/app/src/main/kotlin/com/github/gotify/messages/provider/MessageFacade.kt b/app/src/main/kotlin/com/github/gotify/messages/provider/MessageFacade.kt new file mode 100644 index 0000000..3152f79 --- /dev/null +++ b/app/src/main/kotlin/com/github/gotify/messages/provider/MessageFacade.kt @@ -0,0 +1,77 @@ +package com.github.gotify.messages.provider + +import com.github.gotify.client.api.MessageApi +import com.github.gotify.client.model.Message + +internal class MessageFacade(api: MessageApi, private val applicationHolder: ApplicationHolder) { + private val requester = MessageRequester(api) + private val state = MessageStateHolder() + + @Synchronized + operator fun get(appId: Long): List { + return MessageImageCombiner.combine(state.state(appId).messages, applicationHolder.get()) + } + + @Synchronized + fun addMessages(messages: List) { + messages.forEach { + state.newMessage(it) + } + } + + @Synchronized + fun loadMore(appId: Long): List { + val state = state.state(appId) + if (state.hasNext || !state.loaded) { + val pagedMessages = requester.loadMore(state) + if (pagedMessages != null) { + this.state.newMessages(appId, pagedMessages) + } + } + return get(appId) + } + + @Synchronized + fun loadMoreIfNotPresent(appId: Long) { + val state = state.state(appId) + if (!state.loaded) { + loadMore(appId) + } + } + + @Synchronized + fun clear() { + state.clear() + } + + fun getLastReceivedMessage(): Long = state.lastReceivedMessage + + @Synchronized + fun deleteLocal(message: Message) { + // If there is already a deletion pending, that one should be executed before scheduling the + // next deletion. + if (state.deletionPending()) commitDelete() + state.deleteMessage(message) + } + + @Synchronized + fun commitDelete() { + if (state.deletionPending()) { + val deletion = state.purgePendingDeletion() + requester.asyncRemoveMessage(deletion!!.message) + } + } + + @Synchronized + fun undoDeleteLocal(): MessageDeletion? = state.undoPendingDeletion() + + @Synchronized + fun deleteAll(appId: Long): Boolean { + val success = requester.deleteAll(appId) + state.deleteAll(appId) + return success + } + + @Synchronized + fun canLoadMore(appId: Long): Boolean = state.state(appId).hasNext +} diff --git a/app/src/main/kotlin/com/github/gotify/messages/provider/MessageImageCombiner.kt b/app/src/main/kotlin/com/github/gotify/messages/provider/MessageImageCombiner.kt new file mode 100644 index 0000000..7095aec --- /dev/null +++ b/app/src/main/kotlin/com/github/gotify/messages/provider/MessageImageCombiner.kt @@ -0,0 +1,26 @@ +package com.github.gotify.messages.provider + +import com.github.gotify.client.model.Application +import com.github.gotify.client.model.Message + +internal object MessageImageCombiner { + fun combine(messages: List, applications: List): List { + val appIdToImage = appIdToImage(applications) + val result = mutableListOf() + messages.forEach { + val messageWithImage = MessageWithImage() + messageWithImage.message = it + messageWithImage.image = appIdToImage[it.appid]!! + result.add(messageWithImage) + } + return result + } + + fun appIdToImage(applications: List): Map { + val map = mutableMapOf() + applications.forEach { + map[it.id] = it.image + } + return map + } +} diff --git a/app/src/main/kotlin/com/github/gotify/messages/provider/MessageRequester.kt b/app/src/main/kotlin/com/github/gotify/messages/provider/MessageRequester.kt new file mode 100644 index 0000000..973ea1f --- /dev/null +++ b/app/src/main/kotlin/com/github/gotify/messages/provider/MessageRequester.kt @@ -0,0 +1,49 @@ +package com.github.gotify.messages.provider + +import com.github.gotify.api.Api +import com.github.gotify.api.ApiException +import com.github.gotify.api.Callback +import com.github.gotify.client.api.MessageApi +import com.github.gotify.client.model.Message +import com.github.gotify.client.model.PagedMessages +import com.github.gotify.log.Log + +internal class MessageRequester(private val messageApi: MessageApi) { + fun loadMore(state: MessageState): PagedMessages? { + return try { + Log.i("Loading more messages for ${state.appId}") + if (MessageState.ALL_MESSAGES == state.appId) { + Api.execute(messageApi.getMessages(LIMIT, state.nextSince)) + } else { + Api.execute(messageApi.getAppMessages(state.appId, LIMIT, state.nextSince)) + } + } catch (apiException: ApiException) { + Log.e("failed requesting messages", apiException) + null + } + } + + fun asyncRemoveMessage(message: Message) { + Log.i("Removing message with id ${message.id}") + messageApi.deleteMessage(message.id).enqueue(Callback.call()) + } + + fun deleteAll(appId: Long): Boolean { + return try { + Log.i("Deleting all messages for $appId") + if (MessageState.ALL_MESSAGES == appId) { + Api.execute(messageApi.deleteMessages()) + } else { + Api.execute(messageApi.deleteAppMessages(appId)) + } + true + } catch (e: ApiException) { + Log.e("Could not delete messages", e) + false + } + } + + companion object { + private const val LIMIT = 100 + } +} diff --git a/app/src/main/kotlin/com/github/gotify/messages/provider/MessageState.kt b/app/src/main/kotlin/com/github/gotify/messages/provider/MessageState.kt new file mode 100644 index 0000000..3b0bda0 --- /dev/null +++ b/app/src/main/kotlin/com/github/gotify/messages/provider/MessageState.kt @@ -0,0 +1,15 @@ +package com.github.gotify.messages.provider + +import com.github.gotify.client.model.Message + +internal class MessageState { + var appId = 0L + var loaded = false + var hasNext = false + var nextSince = 0L + var messages = mutableListOf() + + companion object { + const val ALL_MESSAGES = -1L + } +} diff --git a/app/src/main/kotlin/com/github/gotify/messages/provider/MessageStateHolder.kt b/app/src/main/kotlin/com/github/gotify/messages/provider/MessageStateHolder.kt new file mode 100644 index 0000000..d8966ba --- /dev/null +++ b/app/src/main/kotlin/com/github/gotify/messages/provider/MessageStateHolder.kt @@ -0,0 +1,131 @@ +package com.github.gotify.messages.provider + +import com.github.gotify.client.model.Message +import com.github.gotify.client.model.PagedMessages +import kotlin.math.max + +internal class MessageStateHolder { + @get:Synchronized + var lastReceivedMessage = -1L + private set + private var states = mutableMapOf() + private var pendingDeletion: MessageDeletion? = null + + @Synchronized + fun clear() { + states = mutableMapOf() + } + + @Synchronized + fun newMessages(appId: Long, pagedMessages: PagedMessages) { + val state = state(appId) + + if (!state.loaded && pagedMessages.messages.size > 0) { + lastReceivedMessage = max(pagedMessages.messages[0].id, lastReceivedMessage) + } + + state.apply { + loaded = true + messages.addAll(pagedMessages.messages) + hasNext = pagedMessages.paging.next != null + nextSince = pagedMessages.paging.since + this.appId = appId + } + states[appId] = state + + // If there is a message with pending deletion, it should not reappear in the list in case + // it is added again. + if (deletionPending()) { + deleteMessage(pendingDeletion!!.message) + } + } + + @Synchronized + fun newMessage(message: Message) { + // If there is a message with pending deletion, its indices are going to change. To keep + // them consistent the deletion is undone first and redone again after adding the new + // message. + val deletion = undoPendingDeletion() + addMessage(message, 0, 0) + lastReceivedMessage = message.id + if (deletion != null) deleteMessage(deletion.message) + } + + @Synchronized + fun state(appId: Long): MessageState = states[appId] ?: emptyState(appId) + + @Synchronized + fun deleteAll(appId: Long) { + clear() + val state = state(appId) + state.loaded = true + states[appId] = state + } + + private fun emptyState(appId: Long): MessageState { + return MessageState().apply { + loaded = false + hasNext = false + nextSince = 0 + this.appId = appId + } + } + + @Synchronized + fun deleteMessage(message: Message) { + val allMessages = state(MessageState.ALL_MESSAGES) + val appMessages = state(message.appid) + var pendingDeletedAllPosition = -1 + var pendingDeletedAppPosition = -1 + + if (allMessages.loaded) { + val allPosition = allMessages.messages.indexOf(message) + if (allPosition != -1) allMessages.messages.removeAt(allPosition) + pendingDeletedAllPosition = allPosition + } + if (appMessages.loaded) { + val appPosition = appMessages.messages.indexOf(message) + if (appPosition != -1) appMessages.messages.removeAt(appPosition) + pendingDeletedAppPosition = appPosition + } + pendingDeletion = MessageDeletion( + message, + pendingDeletedAllPosition, + pendingDeletedAppPosition + ) + } + + @Synchronized + fun undoPendingDeletion(): MessageDeletion? { + if (pendingDeletion != null) { + addMessage( + pendingDeletion!!.message, + pendingDeletion!!.allPosition, + pendingDeletion!!.appPosition + ) + } + return purgePendingDeletion() + } + + @Synchronized + fun purgePendingDeletion(): MessageDeletion? { + val result = pendingDeletion + pendingDeletion = null + return result + } + + @Synchronized + fun deletionPending(): Boolean = pendingDeletion != null + + private fun addMessage(message: Message, allPosition: Int, appPosition: Int) { + val allMessages = state(MessageState.ALL_MESSAGES) + val appMessages = state(message.appid) + + if (allMessages.loaded && allPosition != -1) { + allMessages.messages.add(allPosition, message) + } + if (appMessages.loaded && appPosition != -1) { + appMessages.messages.add(appPosition, message) + } + } +} diff --git a/app/src/main/kotlin/com/github/gotify/messages/provider/MessageWithImage.kt b/app/src/main/kotlin/com/github/gotify/messages/provider/MessageWithImage.kt new file mode 100644 index 0000000..e99f730 --- /dev/null +++ b/app/src/main/kotlin/com/github/gotify/messages/provider/MessageWithImage.kt @@ -0,0 +1,8 @@ +package com.github.gotify.messages.provider + +import com.github.gotify.client.model.Message + +internal class MessageWithImage { + lateinit var message: Message + lateinit var image: String +} diff --git a/app/src/main/kotlin/com/github/gotify/picasso/PicassoDataRequestHandler.kt b/app/src/main/kotlin/com/github/gotify/picasso/PicassoDataRequestHandler.kt new file mode 100644 index 0000000..14f0052 --- /dev/null +++ b/app/src/main/kotlin/com/github/gotify/picasso/PicassoDataRequestHandler.kt @@ -0,0 +1,39 @@ +package com.github.gotify.picasso + +import android.graphics.BitmapFactory +import android.util.Base64 +import com.github.gotify.log.Log +import com.squareup.picasso.Picasso +import com.squareup.picasso.Request +import com.squareup.picasso.RequestHandler + +/** + * Adapted from https://github.com/square/picasso/issues/1395#issuecomment-220929377 By + * https://github.com/SmartDengg + */ +internal class PicassoDataRequestHandler : RequestHandler() { + companion object { + private const val DATA_SCHEME = "data" + } + + override fun canHandleRequest(data: Request): Boolean { + val scheme = data.uri.scheme + return DATA_SCHEME.equals(scheme, ignoreCase = true) + } + + override fun load(request: Request, networkPolicy: Int): Result { + val uri = request.uri.toString() + val imageDataBytes = uri.substring(uri.indexOf(",") + 1) + val bytes = Base64.decode(imageDataBytes.toByteArray(), Base64.DEFAULT) + val bitmap = BitmapFactory.decodeByteArray(bytes, 0, bytes.size) + + if (bitmap == null) { + val show = if (uri.length > 50) uri.take(50) + "..." else uri + val malformed = RuntimeException("Malformed data uri: $show") + Log.e("Could not load image", malformed) + throw malformed + } + + return Result(bitmap, Picasso.LoadedFrom.NETWORK) + } +} diff --git a/app/src/main/kotlin/com/github/gotify/picasso/PicassoHandler.kt b/app/src/main/kotlin/com/github/gotify/picasso/PicassoHandler.kt new file mode 100644 index 0000000..2ece693 --- /dev/null +++ b/app/src/main/kotlin/com/github/gotify/picasso/PicassoHandler.kt @@ -0,0 +1,86 @@ +package com.github.gotify.picasso + +import android.content.Context +import android.graphics.Bitmap +import android.graphics.BitmapFactory +import com.github.gotify.R +import com.github.gotify.Settings +import com.github.gotify.Utils +import com.github.gotify.api.Callback +import com.github.gotify.api.CertUtils +import com.github.gotify.api.ClientFactory +import com.github.gotify.client.api.ApplicationApi +import com.github.gotify.log.Log +import com.github.gotify.messages.provider.MessageImageCombiner +import com.squareup.picasso.OkHttp3Downloader +import com.squareup.picasso.Picasso +import okhttp3.Cache +import okhttp3.OkHttpClient +import java.io.File +import java.io.IOException +import java.util.concurrent.ConcurrentHashMap + +internal class PicassoHandler(private val context: Context, private val settings: Settings) { + companion object { + private const val PICASSO_CACHE_SIZE = 50 * 1024 * 1024 // 50 MB + private const val PICASSO_CACHE_SUBFOLDER = "picasso-cache" + } + + private val picassoCache = Cache( + File(context.cacheDir, PICASSO_CACHE_SUBFOLDER), + PICASSO_CACHE_SIZE.toLong() + ) + + private val picasso = makePicasso() + private val appIdToAppImage = ConcurrentHashMap() + + private fun makePicasso(): Picasso { + val builder = OkHttpClient.Builder() + builder.cache(picassoCache) + CertUtils.applySslSettings(builder, settings.sslSettings()) + val downloader = OkHttp3Downloader(builder.build()) + return Picasso.Builder(context) + .addRequestHandler(PicassoDataRequestHandler()) + .downloader(downloader) + .build() + } + + @Throws(IOException::class) + fun getImageFromUrl(url: String?): Bitmap = picasso.load(url).get() + + fun getIcon(appId: Long): Bitmap { + if (appId == -1L) { + return BitmapFactory.decodeResource(context.resources, R.drawable.gotify) + } + try { + return getImageFromUrl( + Utils.resolveAbsoluteUrl("${settings.url}/", appIdToAppImage[appId]) + ) + } catch (e: IOException) { + Log.e("Could not load image for notification", e) + } + return BitmapFactory.decodeResource(context.resources, R.drawable.gotify) + } + + fun updateAppIds() { + ClientFactory.clientToken(settings.url, settings.sslSettings(), settings.token) + .createService(ApplicationApi::class.java) + .apps + .enqueue( + Callback.call( + onSuccess = Callback.SuccessBody { apps -> + appIdToAppImage.clear() + appIdToAppImage.putAll(MessageImageCombiner.appIdToImage(apps)) + }, + onError = { appIdToAppImage.clear() } + ) + ) + } + + fun get() = picasso + + @Throws(IOException::class) + fun evict() { + picassoCache.evictAll() + } +} diff --git a/app/src/main/kotlin/com/github/gotify/service/WebSocketConnection.kt b/app/src/main/kotlin/com/github/gotify/service/WebSocketConnection.kt new file mode 100644 index 0000000..b76605e --- /dev/null +++ b/app/src/main/kotlin/com/github/gotify/service/WebSocketConnection.kt @@ -0,0 +1,236 @@ +package com.github.gotify.service + +import android.app.AlarmManager +import android.net.ConnectivityManager +import android.os.Build +import android.os.Handler +import android.os.Looper +import com.github.gotify.SSLSettings +import com.github.gotify.Utils +import com.github.gotify.api.CertUtils +import com.github.gotify.client.model.Message +import com.github.gotify.log.Log +import okhttp3.HttpUrl +import okhttp3.OkHttpClient +import okhttp3.Request +import okhttp3.Response +import okhttp3.WebSocket +import okhttp3.WebSocketListener +import java.util.Calendar +import java.util.concurrent.TimeUnit +import java.util.concurrent.atomic.AtomicLong + +internal class WebSocketConnection( + private val baseUrl: String, + settings: SSLSettings, + private val token: String?, + private val connectivityManager: ConnectivityManager, + private val alarmManager: AlarmManager +) { + companion object { + private val ID = AtomicLong(0) + } + + private val client: OkHttpClient + private val reconnectHandler = Handler(Looper.getMainLooper()) + private val reconnectCallback = Runnable { start() } + private var errorCount = 0 + + private var webSocket: WebSocket? = null + private lateinit var onMessageCallback: (Message) -> Unit + private lateinit var onClose: Runnable + private lateinit var onOpen: Runnable + private lateinit var onBadRequest: BadRequestRunnable + private lateinit var onNetworkFailure: OnNetworkFailureRunnable + private lateinit var onReconnected: Runnable + private var state: State? = null + + init { + val builder = OkHttpClient.Builder() + .readTimeout(0, TimeUnit.MILLISECONDS) + .pingInterval(1, TimeUnit.MINUTES) + .connectTimeout(10, TimeUnit.SECONDS) + CertUtils.applySslSettings(builder, settings) + client = builder.build() + } + + @Synchronized + fun onMessage(onMessage: (Message) -> Unit): WebSocketConnection { + this.onMessageCallback = onMessage + return this + } + + @Synchronized + fun onClose(onClose: Runnable): WebSocketConnection { + this.onClose = onClose + return this + } + + @Synchronized + fun onOpen(onOpen: Runnable): WebSocketConnection { + this.onOpen = onOpen + return this + } + + @Synchronized + fun onBadRequest(onBadRequest: BadRequestRunnable): WebSocketConnection { + this.onBadRequest = onBadRequest + return this + } + + @Synchronized + fun onNetworkFailure(onNetworkFailure: OnNetworkFailureRunnable): WebSocketConnection { + this.onNetworkFailure = onNetworkFailure + return this + } + + @Synchronized + fun onReconnected(onReconnected: Runnable): WebSocketConnection { + this.onReconnected = onReconnected + return this + } + + private fun request(): Request { + val url = HttpUrl.parse(baseUrl)!! + .newBuilder() + .addPathSegment("stream") + .addQueryParameter("token", token) + .build() + return Request.Builder().url(url).get().build() + } + + @Synchronized + fun start(): WebSocketConnection { + if (state == State.Connecting || state == State.Connected) { + return this + } + close() + state = State.Connecting + val nextId = ID.incrementAndGet() + Log.i("WebSocket($nextId): starting...") + + webSocket = client.newWebSocket(request(), Listener(nextId)) + return this + } + + @Synchronized + fun close() { + if (webSocket != null) { + Log.i("WebSocket(${ID.get()}): closing existing connection.") + state = State.Disconnected + webSocket!!.close(1000, "") + webSocket = null + } + } + + @Synchronized + fun scheduleReconnect(seconds: Long) { + if (state == State.Connecting || state == State.Connected) { + return + } + state = State.Scheduled + + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) { + Log.i("WebSocket: scheduling a restart in $seconds second(s) (via alarm manager)") + val future = Calendar.getInstance() + future.add(Calendar.SECOND, seconds.toInt()) + alarmManager.setExact( + AlarmManager.RTC_WAKEUP, + future.timeInMillis, + "reconnect-tag", + { start() }, + null + ) + } else { + Log.i("WebSocket: scheduling a restart in $seconds second(s)") + reconnectHandler.removeCallbacks(reconnectCallback) + reconnectHandler.postDelayed(reconnectCallback, TimeUnit.SECONDS.toMillis(seconds)) + } + } + + private inner class Listener(private val id: Long) : WebSocketListener() { + override fun onOpen(webSocket: WebSocket, response: Response) { + syncExec { + state = State.Connected + Log.i("WebSocket($id): opened") + onOpen.run() + + if (errorCount > 0) { + onReconnected.run() + errorCount = 0 + } + } + super.onOpen(webSocket, response) + } + + override fun onMessage(webSocket: WebSocket, text: String) { + syncExec { + Log.i("WebSocket($id): received message $text") + val message = Utils.JSON.fromJson(text, Message::class.java) + onMessageCallback(message) + } + super.onMessage(webSocket, text) + } + + override fun onClosed(webSocket: WebSocket, code: Int, reason: String) { + syncExec { + if (state == State.Connected) { + Log.w("WebSocket($id): closed") + onClose.run() + } + state = State.Disconnected + } + super.onClosed(webSocket, code, reason) + } + + override fun onFailure(webSocket: WebSocket, t: Throwable, response: Response?) { + val code = if (response != null) "StatusCode: ${response.code()}" else "" + val message = if (response != null) response.message() else "" + Log.e("WebSocket($id): failure $code Message: $message", t) + syncExec { + state = State.Disconnected + if (response != null && response.code() >= 400 && response.code() <= 499) { + onBadRequest.execute(message) + close() + return@syncExec + } + + errorCount++ + + val network = connectivityManager.activeNetworkInfo + if (network == null || !network.isConnected) { + Log.i("WebSocket($id): Network not connected") + } + + val minutes = (errorCount * 2 - 1).coerceAtMost(20) + + onNetworkFailure.execute(minutes) + scheduleReconnect(TimeUnit.MINUTES.toSeconds(minutes.toLong())) + } + super.onFailure(webSocket, t, response) + } + + private fun syncExec(runnable: Runnable) { + synchronized(this) { + if (ID.get() == id) { + runnable.run() + } + } + } + } + + internal fun interface BadRequestRunnable { + fun execute(message: String) + } + + internal fun interface OnNetworkFailureRunnable { + fun execute(minutes: Int) + } + + internal enum class State { + Scheduled, + Connecting, + Connected, + Disconnected + } +} diff --git a/app/src/main/kotlin/com/github/gotify/service/WebSocketService.kt b/app/src/main/kotlin/com/github/gotify/service/WebSocketService.kt new file mode 100644 index 0000000..a0c20c9 --- /dev/null +++ b/app/src/main/kotlin/com/github/gotify/service/WebSocketService.kt @@ -0,0 +1,398 @@ +package com.github.gotify.service + +import android.app.AlarmManager +import android.app.Notification +import android.app.NotificationManager +import android.app.PendingIntent +import android.app.Service +import android.content.Intent +import android.content.IntentFilter +import android.graphics.Color +import android.net.ConnectivityManager +import android.net.Uri +import android.os.Build +import android.os.IBinder +import androidx.annotation.RequiresApi +import androidx.core.app.NotificationCompat +import androidx.core.content.ContextCompat +import com.github.gotify.MarkwonFactory +import com.github.gotify.MissedMessageUtil +import com.github.gotify.NotificationSupport +import com.github.gotify.R +import com.github.gotify.Settings +import com.github.gotify.Utils +import com.github.gotify.api.Callback +import com.github.gotify.api.ClientFactory +import com.github.gotify.client.api.MessageApi +import com.github.gotify.client.model.Message +import com.github.gotify.log.Log +import com.github.gotify.log.UncaughtExceptionHandler +import com.github.gotify.messages.Extras +import com.github.gotify.messages.MessagesActivity +import com.github.gotify.picasso.PicassoHandler +import io.noties.markwon.Markwon +import java.util.concurrent.atomic.AtomicLong + +internal class WebSocketService : Service() { + companion object { + val NEW_MESSAGE_BROADCAST = "${WebSocketService::class.java.name}.NEW_MESSAGE" + private const val NOT_LOADED = -2L + } + + private lateinit var settings: Settings + private var connection: WebSocketConnection? = null + + private val lastReceivedMessage = AtomicLong(NOT_LOADED) + private lateinit var missingMessageUtil: MissedMessageUtil + + private lateinit var picassoHandler: PicassoHandler + private lateinit var markwon: Markwon + + override fun onCreate() { + super.onCreate() + settings = Settings(this) + val client = ClientFactory.clientToken( + settings.url, + settings.sslSettings(), + settings.token + ) + missingMessageUtil = MissedMessageUtil(client.createService(MessageApi::class.java)) + Log.i("Create ${javaClass.simpleName}") + picassoHandler = PicassoHandler(this, settings) + markwon = MarkwonFactory.createForNotification(this, picassoHandler.get()) + } + + override fun onDestroy() { + super.onDestroy() + if (connection != null) { + connection!!.close() + } + Log.w("Destroy ${javaClass.simpleName}") + } + + override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int { + Log.init(this) + if (connection != null) { + connection!!.close() + } + Log.i("Starting ${javaClass.simpleName}") + super.onStartCommand(intent, flags, startId) + Thread { startPushService() }.start() + + return START_STICKY + } + + private fun startPushService() { + UncaughtExceptionHandler.registerCurrentThread() + showForegroundNotification(getString(R.string.websocket_init)) + + if (lastReceivedMessage.get() == NOT_LOADED) { + missingMessageUtil.lastReceivedMessage { lastReceivedMessage.set(it) } + } + + val cm = getSystemService(CONNECTIVITY_SERVICE) as ConnectivityManager + val alarmManager = getSystemService(ALARM_SERVICE) as AlarmManager + + connection = WebSocketConnection( + settings.url, + settings.sslSettings(), + settings.token, + cm, + alarmManager + ) + .onOpen { onOpen() } + .onClose { onClose() } + .onBadRequest { message -> onBadRequest(message) } + .onNetworkFailure { minutes -> onNetworkFailure(minutes) } + .onMessage { message -> onMessage(message) } + .onReconnected { notifyMissedNotifications() } + .start() + + val intentFilter = IntentFilter() + intentFilter.addAction(ConnectivityManager.CONNECTIVITY_ACTION) + + picassoHandler.updateAppIds() + } + + private fun onClose() { + showForegroundNotification( + getString(R.string.websocket_closed), + getString(R.string.websocket_reconnect) + ) + ClientFactory.userApiWithToken(settings) + .currentUser() + .enqueue( + Callback.call( + onSuccess = { doReconnect() }, + onError = { exception -> + if (exception.code == 401) { + showForegroundNotification( + getString(R.string.user_action), + getString(R.string.websocket_closed_logout) + ) + } else { + Log.i( + "WebSocket closed but the user still authenticated, " + + "trying to reconnect" + ) + doReconnect() + } + } + ) + ) + } + + private fun doReconnect() { + if (connection == null) { + return + } + connection!!.scheduleReconnect(15) + } + + private fun onBadRequest(message: String) { + showForegroundNotification(getString(R.string.websocket_could_not_connect), message) + } + + private fun onNetworkFailure(minutes: Int) { + val status = getString(R.string.websocket_not_connected) + val intervalUnit = resources + .getQuantityString(R.plurals.websocket_retry_interval, minutes, minutes) + showForegroundNotification( + status, + "${getString(R.string.websocket_reconnect)} $intervalUnit" + ) + } + + private fun onOpen() { + showForegroundNotification(getString(R.string.websocket_listening)) + } + + private fun notifyMissedNotifications() { + val messageId = lastReceivedMessage.get() + if (messageId == NOT_LOADED) { + return + } + + val messages = missingMessageUtil.missingMessages(messageId).filterNotNull() + + if (messages.size > 5) { + onGroupedMessages(messages) + } else { + messages.forEach { + onMessage(it) + } + } + } + + private fun onGroupedMessages(messages: List) { + var highestPriority = 0L + messages.forEach { message -> + if (lastReceivedMessage.get() < message.id) { + lastReceivedMessage.set(message.id) + highestPriority = highestPriority.coerceAtLeast(message.priority) + } + broadcast(message) + } + val size = messages.size + showNotification( + NotificationSupport.ID.GROUPED, + getString(R.string.missed_messages), + getString(R.string.grouped_message, size), + highestPriority, + null + ) + } + + private fun onMessage(message: Message) { + if (lastReceivedMessage.get() < message.id) { + lastReceivedMessage.set(message.id) + } + broadcast(message) + showNotification( + message.id, + message.title, + message.message, + message.priority, + message.extras, + message.appid + ) + } + + private fun broadcast(message: Message) { + val intent = Intent() + intent.action = NEW_MESSAGE_BROADCAST + intent.putExtra("message", Utils.JSON.toJson(message)) + sendBroadcast(intent) + } + + override fun onBind(intent: Intent): IBinder? = null + + private fun showForegroundNotification(title: String, message: String? = null) { + val notificationIntent = Intent(this, MessagesActivity::class.java) + + val pendingIntent = PendingIntent.getActivity( + this, + 0, + notificationIntent, + PendingIntent.FLAG_IMMUTABLE + ) + val notificationBuilder = + NotificationCompat.Builder(this, NotificationSupport.Channel.FOREGROUND) + notificationBuilder.setSmallIcon(R.drawable.ic_gotify) + notificationBuilder.setOngoing(true) + notificationBuilder.priority = NotificationCompat.PRIORITY_MIN + notificationBuilder.setShowWhen(false) + notificationBuilder.setWhen(0) + notificationBuilder.setContentTitle(title) + + if (message != null) { + notificationBuilder.setContentText(message) + notificationBuilder.setStyle(NotificationCompat.BigTextStyle().bigText(message)) + } + + notificationBuilder.setContentIntent(pendingIntent) + notificationBuilder.color = ContextCompat.getColor(applicationContext, R.color.colorPrimary) + startForeground(NotificationSupport.ID.FOREGROUND, notificationBuilder.build()) + } + + private fun showNotification( + id: Int, + title: String, + message: String, + priority: Long, + extras: Map? + ) { + showNotification(id.toLong(), title, message, priority, extras, -1L) + } + + private fun showNotification( + id: Long, + title: String, + message: String, + priority: Long, + extras: Map?, + appId: Long + ) { + var intent: Intent + + val intentUrl = Extras.getNestedValue( + String::class.java, + extras, + "android::action", + "onReceive", + "intentUrl" + ) + + if (intentUrl != null) { + intent = Intent(Intent.ACTION_VIEW) + intent.data = Uri.parse(intentUrl) + intent.flags = Intent.FLAG_ACTIVITY_NEW_TASK + startActivity(intent) + } + + val url = Extras.getNestedValue( + String::class.java, + extras, + "client::notification", + "click", + "url" + ) + + if (url != null) { + intent = Intent(Intent.ACTION_VIEW) + intent.data = Uri.parse(url) + } else { + intent = Intent(this, MessagesActivity::class.java) + } + + val contentIntent = PendingIntent.getActivity( + this, + 0, + intent, + PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE + ) + + val b = NotificationCompat.Builder( + this, + NotificationSupport.convertPriorityToChannel(priority) + ) + + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) { + showNotificationGroup(priority) + } + + b.setAutoCancel(true) + .setDefaults(Notification.DEFAULT_ALL) + .setWhen(System.currentTimeMillis()) + .setSmallIcon(R.drawable.ic_gotify) + .setLargeIcon(picassoHandler.getIcon(appId)) + .setTicker("${getString(R.string.app_name)} - $title") + .setGroup(NotificationSupport.Group.MESSAGES) + .setContentTitle(title) + .setDefaults(Notification.DEFAULT_LIGHTS or Notification.DEFAULT_SOUND) + .setLights(Color.CYAN, 1000, 5000) + .setColor(ContextCompat.getColor(applicationContext, R.color.colorPrimary)) + .setContentIntent(contentIntent) + + var formattedMessage = message as CharSequence + var newMessage: String? = null + if (Extras.useMarkdown(extras)) { + formattedMessage = markwon.toMarkdown(message) + newMessage = formattedMessage.toString() + } + b.setContentText(newMessage ?: message) + b.setStyle(NotificationCompat.BigTextStyle().bigText(formattedMessage)) + + val notificationImageUrl = Extras.getNestedValue( + String::class.java, + extras, + "client::notification", + "bigImageUrl" + ) + + if (notificationImageUrl != null) { + try { + b.setStyle( + NotificationCompat.BigPictureStyle() + .bigPicture(picassoHandler.getImageFromUrl(notificationImageUrl)) + ) + } catch (e: Exception) { + Log.e("Error loading bigImageUrl", e) + } + } + val notificationManager = getSystemService(NOTIFICATION_SERVICE) as NotificationManager + notificationManager.notify(Utils.longToInt(id), b.build()) + } + + @RequiresApi(Build.VERSION_CODES.N) + fun showNotificationGroup(priority: Long) { + val intent = Intent(this, MessagesActivity::class.java) + val contentIntent = PendingIntent.getActivity( + this, + 0, + intent, + PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE + ) + + val builder = NotificationCompat.Builder( + this, + NotificationSupport.convertPriorityToChannel(priority) + ) + + builder.setAutoCancel(true) + .setDefaults(Notification.DEFAULT_ALL) + .setWhen(System.currentTimeMillis()) + .setSmallIcon(R.drawable.ic_gotify) + .setTicker(getString(R.string.app_name)) + .setGroup(NotificationSupport.Group.MESSAGES) + .setGroupAlertBehavior(NotificationCompat.GROUP_ALERT_CHILDREN) + .setContentTitle(getString(R.string.grouped_notification_text)) + .setGroupSummary(true) + .setContentText(getString(R.string.grouped_notification_text)) + .setColor(ContextCompat.getColor(applicationContext, R.color.colorPrimary)) + .setContentIntent(contentIntent) + + val notificationManager = this.getSystemService(NOTIFICATION_SERVICE) as NotificationManager + notificationManager.notify(-5, builder.build()) + } +} diff --git a/app/src/main/kotlin/com/github/gotify/settings/SettingsActivity.kt b/app/src/main/kotlin/com/github/gotify/settings/SettingsActivity.kt new file mode 100644 index 0000000..fa88d2b --- /dev/null +++ b/app/src/main/kotlin/com/github/gotify/settings/SettingsActivity.kt @@ -0,0 +1,94 @@ +package com.github.gotify.settings + +import android.app.AlertDialog +import android.content.Intent +import android.content.SharedPreferences +import android.content.SharedPreferences.OnSharedPreferenceChangeListener +import android.os.Bundle +import android.view.MenuItem +import android.view.View +import androidx.appcompat.app.AppCompatActivity +import androidx.preference.ListPreference +import androidx.preference.Preference +import androidx.preference.PreferenceFragmentCompat +import androidx.preference.PreferenceManager +import com.github.gotify.R +import com.github.gotify.databinding.SettingsActivityBinding + +internal class SettingsActivity : AppCompatActivity(), OnSharedPreferenceChangeListener { + private lateinit var binding: SettingsActivityBinding + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + binding = SettingsActivityBinding.inflate(layoutInflater) + setContentView(binding.root) + supportFragmentManager + .beginTransaction() + .replace(R.id.settings, SettingsFragment()) + .commit() + setSupportActionBar(binding.appBarDrawer.toolbar) + val actionBar = supportActionBar + if (actionBar != null) { + actionBar.setDisplayHomeAsUpEnabled(true) + actionBar.setDisplayShowCustomEnabled(true) + } + val sharedPreferences = PreferenceManager.getDefaultSharedPreferences(this) + sharedPreferences.registerOnSharedPreferenceChangeListener(this) + } + + override fun onOptionsItemSelected(item: MenuItem): Boolean { + if (item.itemId == android.R.id.home) { + finish() + return true + } + return false + } + + override fun onSharedPreferenceChanged(sharedPreferences: SharedPreferences, key: String) { + if (getString(R.string.setting_key_theme) == key) { + ThemeHelper.setTheme( + this, + sharedPreferences.getString(key, getString(R.string.theme_default))!! + ) + } + } + + class SettingsFragment : PreferenceFragmentCompat() { + override fun onCreatePreferences(savedInstanceState: Bundle?, rootKey: String?) { + setPreferencesFromResource(R.xml.root_preferences, rootKey) + } + + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + super.onViewCreated(view, savedInstanceState) + val messageLayout: ListPreference? = + findPreference(getString(R.string.setting_key_message_layout)) + messageLayout?.onPreferenceChangeListener = + Preference.OnPreferenceChangeListener { _, _ -> + AlertDialog.Builder(context) + .setTitle(R.string.setting_message_layout_dialog_title) + .setMessage(R.string.setting_message_layout_dialog_message) + .setPositiveButton( + getString(R.string.setting_message_layout_dialog_button1) + ) { _, _ -> + restartApp() + } + .setNegativeButton( + getString(R.string.setting_message_layout_dialog_button2), + null + ) + .show() + true + } + } + + private fun restartApp() { + val packageManager = requireContext().packageManager + val packageName = requireContext().packageName + val intent = packageManager.getLaunchIntentForPackage(packageName) + val componentName = intent!!.component + val mainIntent = Intent.makeRestartActivityTask(componentName) + startActivity(mainIntent) + Runtime.getRuntime().exit(0) + } + } +} diff --git a/app/src/main/kotlin/com/github/gotify/settings/ThemeHelper.kt b/app/src/main/kotlin/com/github/gotify/settings/ThemeHelper.kt new file mode 100644 index 0000000..a0dd830 --- /dev/null +++ b/app/src/main/kotlin/com/github/gotify/settings/ThemeHelper.kt @@ -0,0 +1,24 @@ +package com.github.gotify.settings + +import android.content.Context +import android.os.Build +import androidx.appcompat.app.AppCompatDelegate +import com.github.gotify.R + +internal object ThemeHelper { + fun setTheme(context: Context, newTheme: String) { + AppCompatDelegate.setDefaultNightMode(ofKey(context, newTheme)) + } + + private fun ofKey(context: Context, newTheme: String): Int { + return if (context.getString(R.string.theme_dark) == newTheme) { + AppCompatDelegate.MODE_NIGHT_YES + } else if (context.getString(R.string.theme_light) == newTheme) { + AppCompatDelegate.MODE_NIGHT_NO + } else if (Build.VERSION.SDK_INT <= Build.VERSION_CODES.P) { + AppCompatDelegate.MODE_NIGHT_AUTO_BATTERY + } else { + AppCompatDelegate.MODE_NIGHT_FOLLOW_SYSTEM + } + } +} diff --git a/app/src/main/kotlin/com/github/gotify/sharing/ShareActivity.kt b/app/src/main/kotlin/com/github/gotify/sharing/ShareActivity.kt new file mode 100644 index 0000000..167509e --- /dev/null +++ b/app/src/main/kotlin/com/github/gotify/sharing/ShareActivity.kt @@ -0,0 +1,163 @@ +package com.github.gotify.sharing + +import android.content.Intent +import android.os.Bundle +import android.view.MenuItem +import android.view.View +import android.widget.ArrayAdapter +import android.widget.Spinner +import android.widget.Toast +import androidx.appcompat.app.AppCompatActivity +import com.github.gotify.R +import com.github.gotify.Settings +import com.github.gotify.Utils.launchCoroutine +import com.github.gotify.api.Api +import com.github.gotify.api.ApiException +import com.github.gotify.api.ClientFactory +import com.github.gotify.client.api.MessageApi +import com.github.gotify.client.model.Application +import com.github.gotify.client.model.Message +import com.github.gotify.databinding.ActivityShareBinding +import com.github.gotify.log.Log +import com.github.gotify.messages.provider.ApplicationHolder +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext + +internal class ShareActivity : AppCompatActivity() { + private lateinit var binding: ActivityShareBinding + private lateinit var settings: Settings + private lateinit var appsHolder: ApplicationHolder + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + binding = ActivityShareBinding.inflate(layoutInflater) + setContentView(binding.root) + + Log.i("Entering ${javaClass.simpleName}") + setSupportActionBar(binding.appBarDrawer.toolbar) + val actionBar = supportActionBar + if (actionBar != null) { + actionBar.setDisplayHomeAsUpEnabled(true) + actionBar.setDisplayShowCustomEnabled(true) + } + settings = Settings(this) + + val intent = intent + val type = intent.type + if (Intent.ACTION_SEND == intent.action && "text/plain" == type) { + val sharedText = intent.getStringExtra(Intent.EXTRA_TEXT) + if (sharedText != null) { + binding.content.setText(sharedText) + } + } + + if (!settings.tokenExists()) { + Toast.makeText( + applicationContext, + R.string.not_loggedin_share, + Toast.LENGTH_SHORT + ).show() + finish() + return + } + + val client = ClientFactory.clientToken( + settings.url, + settings.sslSettings(), + settings.token + ) + appsHolder = ApplicationHolder(this, client) + appsHolder.onUpdate { + val apps = appsHolder.get() + populateSpinner(apps) + + val appsAvailable = apps.isNotEmpty() + binding.pushButton.isEnabled = appsAvailable + binding.missingAppsContainer.visibility = if (appsAvailable) View.GONE else View.VISIBLE + } + appsHolder.onUpdateFailed { binding.pushButton.isEnabled = false } + appsHolder.request() + } + + override fun onPostCreate(savedInstanceState: Bundle?) { + super.onPostCreate(savedInstanceState) + binding.pushButton.setOnClickListener { pushMessage() } + } + + override fun onOptionsItemSelected(item: MenuItem): Boolean { + if (item.itemId == android.R.id.home) { + finish() + return true + } + return false + } + + private fun pushMessage() { + val titleText = binding.title.text.toString() + val contentText = binding.content.text.toString() + val priority = binding.edtTxtPriority.text.toString() + val appIndex = binding.appSpinner.selectedItemPosition + + if (contentText.isEmpty()) { + Toast.makeText(this, "Content should not be empty.", Toast.LENGTH_LONG).show() + return + } else if (priority.isEmpty()) { + Toast.makeText(this, "Priority should be number.", Toast.LENGTH_LONG).show() + return + } else if (appIndex == Spinner.INVALID_POSITION) { + // For safety, e.g. loading the apps needs too much time (maybe a timeout) and + // the user tries to push without an app selected. + Toast.makeText(this, "An app must be selected.", Toast.LENGTH_LONG).show() + return + } + + val message = Message() + if (titleText.isNotEmpty()) { + message.title = titleText + } + message.message = contentText + message.priority = priority.toLong() + + launchCoroutine { + val response = executeMessageCall(appIndex, message) + withContext(Dispatchers.Main) { + if (response) { + Toast.makeText(this@ShareActivity, "Pushed!", Toast.LENGTH_LONG).show() + finish() + } else { + Toast.makeText( + this@ShareActivity, + "Oops! Something went wrong...", + Toast.LENGTH_LONG + ).show() + } + } + } + } + + private fun executeMessageCall(appIndex: Int, message: Message): Boolean { + val pushClient = ClientFactory.clientToken( + settings.url, + settings.sslSettings(), + appsHolder.get()[appIndex].token + ) + return try { + val messageApi = pushClient.createService(MessageApi::class.java) + Api.execute(messageApi.createMessage(message)) + true + } catch (apiException: ApiException) { + Log.e("Failed sending message", apiException) + false + } + } + + private fun populateSpinner(apps: List) { + val appNameList = mutableListOf() + apps.forEach { + appNameList.add(it.name) + } + + val adapter = ArrayAdapter(this, android.R.layout.simple_spinner_dropdown_item, appNameList) + binding.appSpinner.adapter = adapter + } +} diff --git a/app/src/main/res/layout/settings_activity.xml b/app/src/main/res/layout/settings_activity.xml index caf07a7..20b859a 100644 --- a/app/src/main/res/layout/settings_activity.xml +++ b/app/src/main/res/layout/settings_activity.xml @@ -12,7 +12,7 @@ android:layout_width="match_parent" android:layout_height="match_parent"> - @@ -24,6 +24,7 @@ diff --git a/build.gradle b/build.gradle index 8c658bb..e8dddf7 100644 --- a/build.gradle +++ b/build.gradle @@ -7,7 +7,7 @@ buildscript { } dependencies { classpath 'com.android.tools.build:gradle:7.3.1' - + classpath 'org.jetbrains.kotlin:kotlin-gradle-plugin:1.7.20' // NOTE: Do not place your application dependencies here; they belong // in the individual module build.gradle files