Merge pull request #258 from cyb3rko/kotlin-rewrite
Codebase rewrite to Kotlin
This commit is contained in:
@@ -1,7 +1,8 @@
|
|||||||
plugins {
|
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 {
|
android {
|
||||||
namespace "com.github.gotify"
|
namespace "com.github.gotify"
|
||||||
@@ -82,12 +83,3 @@ configurations {
|
|||||||
exclude group: 'androidx.lifecycle', module: 'lifecycle-viewmodel-ktx'
|
exclude group: 'androidx.lifecycle', module: 'lifecycle-viewmodel-ktx'
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
spotless {
|
|
||||||
java {
|
|
||||||
target '**/*.java'
|
|
||||||
googleJavaFormat().aosp()
|
|
||||||
removeUnusedImports()
|
|
||||||
importOrder('', 'static *')
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -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();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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<Long> 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<Message> missingMessages(long till) {
|
|
||||||
List<Message> result = new ArrayList<>();
|
|
||||||
try {
|
|
||||||
|
|
||||||
Long since = null;
|
|
||||||
while (true) {
|
|
||||||
PagedMessages pagedMessages = Api.execute(api.getMessages(10, since));
|
|
||||||
List<Message> messages = pagedMessages.getMessages();
|
|
||||||
List<Message> 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<Message> filter(List<Message> messages, long till) {
|
|
||||||
List<Message> result = new ArrayList<>();
|
|
||||||
|
|
||||||
for (Message message : messages) {
|
|
||||||
if (message.getId() > till) {
|
|
||||||
result.add(message);
|
|
||||||
} else {
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return result;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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.
|
|
||||||
*
|
|
||||||
* <pre>
|
|
||||||
* Gotify Priority | Android Importance
|
|
||||||
* <= 0 | min
|
|
||||||
* 1-3 | low
|
|
||||||
* 4-7 | default
|
|
||||||
* >= 8 | high
|
|
||||||
* </pre>
|
|
||||||
*
|
|
||||||
* @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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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> T first(T[] data) {
|
|
||||||
if (data.length != 1) {
|
|
||||||
throw new IllegalArgumentException("must be one element");
|
|
||||||
}
|
|
||||||
return data[0];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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> T execute(Call<T> call) throws ApiException {
|
|
||||||
try {
|
|
||||||
Response<T> response = call.execute();
|
|
||||||
|
|
||||||
if (response.isSuccessful()) {
|
|
||||||
return response.body();
|
|
||||||
} else {
|
|
||||||
throw new ApiException(response);
|
|
||||||
}
|
|
||||||
} catch (IOException e) {
|
|
||||||
throw new ApiException(e);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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)));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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<T> {
|
|
||||||
private final SuccessCallback<T> onSuccess;
|
|
||||||
private final ErrorCallback onError;
|
|
||||||
|
|
||||||
private Callback(SuccessCallback<T> onSuccess, ErrorCallback onError) {
|
|
||||||
this.onSuccess = onSuccess;
|
|
||||||
this.onError = onError;
|
|
||||||
}
|
|
||||||
|
|
||||||
public static <T> retrofit2.Callback<T> callInUI(
|
|
||||||
Activity context, SuccessCallback<T> onSuccess, ErrorCallback onError) {
|
|
||||||
return call(
|
|
||||||
(data) -> context.runOnUiThread(() -> onSuccess.onSuccess(data)),
|
|
||||||
(e) -> context.runOnUiThread(() -> onError.onError(e)));
|
|
||||||
}
|
|
||||||
|
|
||||||
public static <T> retrofit2.Callback<T> call() {
|
|
||||||
return call((e) -> {}, (e) -> {});
|
|
||||||
}
|
|
||||||
|
|
||||||
public static <T> retrofit2.Callback<T> call(
|
|
||||||
SuccessCallback<T> onSuccess, ErrorCallback onError) {
|
|
||||||
return new RetrofitCallback<>(merge(of(onSuccess, onError), errorCallback()));
|
|
||||||
}
|
|
||||||
|
|
||||||
private static <T> Callback<T> of(SuccessCallback<T> onSuccess, ErrorCallback onError) {
|
|
||||||
return new Callback<>(onSuccess, onError);
|
|
||||||
}
|
|
||||||
|
|
||||||
private static <T> Callback<T> errorCallback() {
|
|
||||||
return new Callback<>((ignored) -> {}, (error) -> Log.e("Error while api call", error));
|
|
||||||
}
|
|
||||||
|
|
||||||
private static <T> Callback<T> merge(Callback<T> left, Callback<T> 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<T> {
|
|
||||||
void onSuccess(T data);
|
|
||||||
}
|
|
||||||
|
|
||||||
public interface ErrorCallback {
|
|
||||||
void onError(ApiException t);
|
|
||||||
}
|
|
||||||
|
|
||||||
private static final class RetrofitCallback<T> implements retrofit2.Callback<T> {
|
|
||||||
|
|
||||||
private Callback<T> callback;
|
|
||||||
|
|
||||||
private RetrofitCallback(Callback<T> callback) {
|
|
||||||
this.callback = callback;
|
|
||||||
}
|
|
||||||
|
|
||||||
@Override
|
|
||||||
public void onResponse(Call<T> call, Response<T> response) {
|
|
||||||
if (response.isSuccessful()) {
|
|
||||||
callback.onSuccess.onSuccess(response.body());
|
|
||||||
} else {
|
|
||||||
callback.onError.onError(new ApiException(response));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@Override
|
|
||||||
public void onFailure(Call<T> call, Throwable t) {
|
|
||||||
callback.onError.onError(new ApiException(t));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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<? extends Certificate> 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);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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<VersionInfo> callback,
|
|
||||||
final Callback.ErrorCallback errorCallback) {
|
|
||||||
ClientFactory.versionApi(settings.url(), settings.sslSettings())
|
|
||||||
.getVersion()
|
|
||||||
.enqueue(callInUI(this, callback, errorCallback));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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<String> 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();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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<Void, Void, String> {
|
|
||||||
|
|
||||||
@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);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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<VersionInfo> 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);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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<String, Object> 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> T getNestedValue(Class<T> clazz, Message message, String... keys) {
|
|
||||||
return getNestedValue(clazz, message.getExtras(), keys);
|
|
||||||
}
|
|
||||||
|
|
||||||
public static <T> T getNestedValue(Class<T> clazz, Map<String, Object> 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);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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<ListMessageAdapter.ViewHolder> {
|
|
||||||
|
|
||||||
private Context context;
|
|
||||||
private SharedPreferences prefs;
|
|
||||||
private Picasso picasso;
|
|
||||||
private List<MessageWithImage> 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<MessageWithImage> 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<MessageWithImage> getItems() {
|
|
||||||
return items;
|
|
||||||
}
|
|
||||||
|
|
||||||
public void setItems(List<MessageWithImage> 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);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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<Application> 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<Application> 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<Snackbar> {
|
|
||||||
@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<Long, Void, Boolean> {
|
|
||||||
@Override
|
|
||||||
protected Boolean doInBackground(Long... ids) {
|
|
||||||
Long id = first(ids);
|
|
||||||
if (id == -1) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
List<Message> 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<Long, Void, List<MessageWithImage>> {
|
|
||||||
|
|
||||||
@Override
|
|
||||||
protected List<MessageWithImage> doInBackground(Long... appId) {
|
|
||||||
return viewModel.getMessages().loadMore(first(appId));
|
|
||||||
}
|
|
||||||
|
|
||||||
@Override
|
|
||||||
protected void onPostExecute(List<MessageWithImage> messageWithImages) {
|
|
||||||
updateMessagesAndStopLoading(messageWithImages);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private class UpdateMessagesForApplication extends AsyncTask<Long, Void, Long> {
|
|
||||||
|
|
||||||
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<Message, Void, Void> {
|
|
||||||
|
|
||||||
@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<Void, Void, Void> {
|
|
||||||
|
|
||||||
@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<Long, Void, Boolean> {
|
|
||||||
|
|
||||||
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<Void, Void, Void> {
|
|
||||||
|
|
||||||
@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<Client> 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<MessageWithImage> 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();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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<Target> 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<Target> getTargetReferences() {
|
|
||||||
return targetReferences;
|
|
||||||
}
|
|
||||||
|
|
||||||
public long getAppId() {
|
|
||||||
return appId;
|
|
||||||
}
|
|
||||||
|
|
||||||
public void setAppId(long appId) {
|
|
||||||
this.appId = appId;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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 extends ViewModel> T create(@NonNull Class<T> 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()));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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<Application> 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<Application> 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<Application> get() {
|
|
||||||
return state == null ? Collections.emptyList() : state;
|
|
||||||
}
|
|
||||||
|
|
||||||
public void onUpdate(Runnable runnable) {
|
|
||||||
this.onUpdate = runnable;
|
|
||||||
}
|
|
||||||
|
|
||||||
public void onUpdateFailed(Runnable runnable) {
|
|
||||||
this.onUpdateFailed = runnable;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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<MessageWithImage> get(long appId) {
|
|
||||||
return combiner.combine(state.state(appId).messages, applicationHolder.get());
|
|
||||||
}
|
|
||||||
|
|
||||||
public synchronized void addMessages(List<Message> messages) {
|
|
||||||
for (Message message : messages) {
|
|
||||||
state.newMessage(message);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
public synchronized List<MessageWithImage> 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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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<MessageWithImage> combine(List<Message> messages, List<Application> applications) {
|
|
||||||
Map<Long, String> appIdToImage = appIdToImage(applications);
|
|
||||||
|
|
||||||
List<MessageWithImage> 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<Long, String> appIdToImage(List<Application> applications) {
|
|
||||||
Map<Long, String> map = new ConcurrentHashMap<>();
|
|
||||||
for (Application app : applications) {
|
|
||||||
map.put(app.getId(), app.getImage());
|
|
||||||
}
|
|
||||||
return map;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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<Message> messages = new ArrayList<>();
|
|
||||||
}
|
|
||||||
@@ -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<Long, MessageState> 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);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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;
|
|
||||||
}
|
|
||||||
@@ -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);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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<Long, String> 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();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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<Message> 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<Message> 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
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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<Message> messages = missingMessageUtil.missingMessages(messageId);
|
|
||||||
|
|
||||||
if (messages.size() > 5) {
|
|
||||||
onGroupedMessages(messages);
|
|
||||||
} else {
|
|
||||||
for (Message message : messages) {
|
|
||||||
onMessage(message);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private void onGroupedMessages(List<Message> 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<String, Object> extras) {
|
|
||||||
showNotification(id, title, message, priority, extras, -1L);
|
|
||||||
}
|
|
||||||
|
|
||||||
private void showNotification(
|
|
||||||
long id,
|
|
||||||
String title,
|
|
||||||
String message,
|
|
||||||
long priority,
|
|
||||||
Map<String, Object> 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());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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<Application> 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<Application> apps) {
|
|
||||||
List<String> appNameList = new ArrayList<>();
|
|
||||||
for (Application app : apps) {
|
|
||||||
appNameList.add(app.getName());
|
|
||||||
}
|
|
||||||
|
|
||||||
ArrayAdapter<String> adapter =
|
|
||||||
new ArrayAdapter<>(
|
|
||||||
this, android.R.layout.simple_spinner_dropdown_item, appNameList);
|
|
||||||
binding.appSpinner.setAdapter(adapter);
|
|
||||||
}
|
|
||||||
|
|
||||||
private class PushMessage extends AsyncTask<Message, String, String> {
|
|
||||||
private String token;
|
|
||||||
|
|
||||||
public PushMessage(String token) {
|
|
||||||
this.token = token;
|
|
||||||
}
|
|
||||||
|
|
||||||
@Override
|
|
||||||
protected String doInBackground(Message... messages) {
|
|
||||||
List<Application> 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();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
99
app/src/main/kotlin/com/github/gotify/MarkwonFactory.kt
Normal file
99
app/src/main/kotlin/com/github/gotify/MarkwonFactory.kt
Normal file
@@ -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<Any>(
|
||||||
|
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<Any>(
|
||||||
|
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()
|
||||||
|
}
|
||||||
|
}
|
||||||
64
app/src/main/kotlin/com/github/gotify/MissedMessageUtil.kt
Normal file
64
app/src/main/kotlin/com/github/gotify/MissedMessageUtil.kt
Normal file
@@ -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<Message?> {
|
||||||
|
val result = mutableListOf<Message?>()
|
||||||
|
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<Message>, till: Long): List<Message?> {
|
||||||
|
val result = mutableListOf<Message?>()
|
||||||
|
for (message in messages) {
|
||||||
|
if (message.id > till) {
|
||||||
|
result.add(message)
|
||||||
|
} else {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
const val NO_MESSAGES = 0L
|
||||||
|
}
|
||||||
|
}
|
||||||
106
app/src/main/kotlin/com/github/gotify/NotificationSupport.kt
Normal file
106
app/src/main/kotlin/com/github/gotify/NotificationSupport.kt
Normal file
@@ -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.
|
||||||
|
*
|
||||||
|
* <pre>
|
||||||
|
* Gotify Priority | Android Importance
|
||||||
|
* <= 0 | min
|
||||||
|
* 1-3 | low
|
||||||
|
* 4-7 | default
|
||||||
|
* >= 8 | high
|
||||||
|
* </pre>
|
||||||
|
*
|
||||||
|
* @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
|
||||||
|
}
|
||||||
|
}
|
||||||
3
app/src/main/kotlin/com/github/gotify/SSLSettings.kt
Normal file
3
app/src/main/kotlin/com/github/gotify/SSLSettings.kt
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
package com.github.gotify
|
||||||
|
|
||||||
|
internal class SSLSettings(val validateSSL: Boolean, val cert: String?)
|
||||||
56
app/src/main/kotlin/com/github/gotify/Settings.kt
Normal file
56
app/src/main/kotlin/com/github/gotify/Settings.kt
Normal file
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
118
app/src/main/kotlin/com/github/gotify/Utils.kt
Normal file
118
app/src/main/kotlin/com/github/gotify/Utils.kt
Normal file
@@ -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<View>(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?)
|
||||||
|
}
|
||||||
|
}
|
||||||
34
app/src/main/kotlin/com/github/gotify/api/Api.kt
Normal file
34
app/src/main/kotlin/com/github/gotify/api/Api.kt
Normal file
@@ -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<Void>) {
|
||||||
|
try {
|
||||||
|
val response = call.execute()
|
||||||
|
|
||||||
|
if (!response.isSuccessful) {
|
||||||
|
throw ApiException(response)
|
||||||
|
}
|
||||||
|
} catch (e: IOException) {
|
||||||
|
throw ApiException(e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Throws(ApiException::class)
|
||||||
|
fun <T> execute(call: Call<T>): 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
31
app/src/main/kotlin/com/github/gotify/api/ApiException.kt
Normal file
31
app/src/main/kotlin/com/github/gotify/api/ApiException.kt
Normal file
@@ -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)}"
|
||||||
|
}
|
||||||
82
app/src/main/kotlin/com/github/gotify/api/Callback.kt
Normal file
82
app/src/main/kotlin/com/github/gotify/api/Callback.kt
Normal file
@@ -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<T> private constructor(
|
||||||
|
private val onSuccess: SuccessCallback<T>,
|
||||||
|
private val onError: ErrorCallback
|
||||||
|
) {
|
||||||
|
fun interface SuccessCallback<T> {
|
||||||
|
fun onSuccess(response: Response<T>)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun interface SuccessBody<T> : SuccessCallback<T> {
|
||||||
|
override fun onSuccess(response: Response<T>) {
|
||||||
|
onResultSuccess(response.body() ?: throw ApiException("null response", response))
|
||||||
|
}
|
||||||
|
|
||||||
|
fun onResultSuccess(data: T)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun interface ErrorCallback {
|
||||||
|
fun onError(t: ApiException)
|
||||||
|
}
|
||||||
|
|
||||||
|
private class RetrofitCallback<T>(private val callback: Callback<T>) : retrofit2.Callback<T> {
|
||||||
|
override fun onResponse(call: Call<T>, response: Response<T>) {
|
||||||
|
if (response.isSuccessful) {
|
||||||
|
callback.onSuccess.onSuccess(response)
|
||||||
|
} else {
|
||||||
|
callback.onError.onError(ApiException(response))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onFailure(call: Call<T>, t: Throwable) {
|
||||||
|
callback.onError.onError(ApiException(t))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
fun <T> callInUI(
|
||||||
|
context: Activity,
|
||||||
|
onSuccess: SuccessCallback<T>,
|
||||||
|
onError: ErrorCallback
|
||||||
|
): retrofit2.Callback<T> {
|
||||||
|
return call(
|
||||||
|
onSuccess = { response -> context.runOnUiThread { onSuccess.onSuccess(response) } },
|
||||||
|
onError = { exception -> context.runOnUiThread { onError.onError(exception) } }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun <T> call(onSuccess: SuccessCallback<T> = SuccessCallback {}, onError: ErrorCallback = ErrorCallback {}): retrofit2.Callback<T> {
|
||||||
|
return RetrofitCallback(merge(of(onSuccess, onError), errorCallback()))
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun <T> of(onSuccess: SuccessCallback<T>, onError: ErrorCallback): Callback<T> {
|
||||||
|
return Callback(onSuccess, onError)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun <T> errorCallback(): Callback<T> {
|
||||||
|
return Callback(
|
||||||
|
onSuccess = {},
|
||||||
|
onError = { exception -> Log.e("Error while api call", exception) }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun <T> merge(left: Callback<T>, right: Callback<T>): Callback<T> {
|
||||||
|
return Callback(
|
||||||
|
onSuccess = { data ->
|
||||||
|
left.onSuccess.onSuccess(data)
|
||||||
|
right.onSuccess.onSuccess(data)
|
||||||
|
},
|
||||||
|
onError = { exception ->
|
||||||
|
left.onError.onError(exception)
|
||||||
|
right.onError.onError(exception)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
96
app/src/main/kotlin/com/github/gotify/api/CertUtils.kt
Normal file
96
app/src/main/kotlin/com/github/gotify/api/CertUtils.kt
Normal file
@@ -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<X509Certificate>, authType: String) {}
|
||||||
|
|
||||||
|
@SuppressLint("TrustAllX509TrustManager")
|
||||||
|
override fun checkServerTrusted(chain: Array<X509Certificate>, authType: String) {}
|
||||||
|
|
||||||
|
override fun getAcceptedIssuers() = arrayOf<X509Certificate>()
|
||||||
|
}
|
||||||
|
|
||||||
|
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<TrustManager>(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<TrustManager> {
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
67
app/src/main/kotlin/com/github/gotify/api/ClientFactory.kt
Normal file
67
app/src/main/kotlin/com/github/gotify/api/ClientFactory.kt
Normal file
@@ -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<String>,
|
||||||
|
baseUrl: String,
|
||||||
|
sslSettings: SSLSettings
|
||||||
|
): ApiClient {
|
||||||
|
val client = ApiClient(authentications)
|
||||||
|
CertUtils.applySslSettings(client.okBuilder, sslSettings)
|
||||||
|
client.adapterBuilder.baseUrl(baseUrl)
|
||||||
|
return client
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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<VersionInfo>,
|
||||||
|
errorCallback: Callback.ErrorCallback
|
||||||
|
) {
|
||||||
|
ClientFactory.versionApi(settings.url, settings.sslSettings())
|
||||||
|
.version
|
||||||
|
.enqueue(Callback.callInUI(this, callback, errorCallback))
|
||||||
|
}
|
||||||
|
}
|
||||||
16
app/src/main/kotlin/com/github/gotify/log/Format.kt
Normal file
16
app/src/main/kotlin/com/github/gotify/log/Format.kt
Normal file
@@ -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"
|
||||||
|
}
|
||||||
47
app/src/main/kotlin/com/github/gotify/log/Log.kt
Normal file
47
app/src/main/kotlin/com/github/gotify/log/Log.kt
Normal file
@@ -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()
|
||||||
|
}
|
||||||
|
}
|
||||||
82
app/src/main/kotlin/com/github/gotify/log/LogsActivity.kt
Normal file
82
app/src/main/kotlin/com/github/gotify/log/LogsActivity.kt
Normal file
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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!!)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
299
app/src/main/kotlin/com/github/gotify/login/LoginActivity.kt
Normal file
299
app/src/main/kotlin/com/github/gotify/login/LoginActivity.kt
Normal file
@@ -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<VersionInfo> {
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
42
app/src/main/kotlin/com/github/gotify/messages/Extras.kt
Normal file
42
app/src/main/kotlin/com/github/gotify/messages/Extras.kt
Normal file
@@ -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<String, Any>?): Boolean {
|
||||||
|
if (extras == null) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
val display: Any? = extras["client::display"]
|
||||||
|
if (display !is Map<*, *>) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
return "text/markdown" == display["contentType"]
|
||||||
|
}
|
||||||
|
|
||||||
|
fun <T> getNestedValue(
|
||||||
|
clazz: Class<T>,
|
||||||
|
extras: Map<String, Any>?,
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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<MessageWithImage>,
|
||||||
|
private val delete: Delete
|
||||||
|
) : RecyclerView.Adapter<ListMessageAdapter.ViewHolder>() {
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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<Application>) {
|
||||||
|
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<TextView>(R.id.header_user)
|
||||||
|
user.text = settings.user?.name
|
||||||
|
|
||||||
|
val connection = headerView.findViewById<TextView>(R.id.header_connection)
|
||||||
|
connection.text = getString(R.string.connection, settings.user?.name, settings.url)
|
||||||
|
|
||||||
|
val version = headerView.findViewById<TextView>(R.id.header_version)
|
||||||
|
version.text =
|
||||||
|
getString(R.string.versions, BuildConfig.VERSION_NAME, settings.serverVersion)
|
||||||
|
|
||||||
|
val refreshAll = headerView.findViewById<ImageButton>(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<Snackbar?>() {
|
||||||
|
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<MessageWithImage>) {
|
||||||
|
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
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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<Target>()
|
||||||
|
|
||||||
|
var appId = MessageState.ALL_MESSAGES
|
||||||
|
}
|
||||||
@@ -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 <T : ViewModel> create(modelClass: Class<T>): 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}"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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<Application>()
|
||||||
|
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<Application>) {
|
||||||
|
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
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
)
|
||||||
@@ -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<MessageWithImage> {
|
||||||
|
return MessageImageCombiner.combine(state.state(appId).messages, applicationHolder.get())
|
||||||
|
}
|
||||||
|
|
||||||
|
@Synchronized
|
||||||
|
fun addMessages(messages: List<Message>) {
|
||||||
|
messages.forEach {
|
||||||
|
state.newMessage(it)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Synchronized
|
||||||
|
fun loadMore(appId: Long): List<MessageWithImage> {
|
||||||
|
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
|
||||||
|
}
|
||||||
@@ -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<Message>, applications: List<Application>): List<MessageWithImage> {
|
||||||
|
val appIdToImage = appIdToImage(applications)
|
||||||
|
val result = mutableListOf<MessageWithImage>()
|
||||||
|
messages.forEach {
|
||||||
|
val messageWithImage = MessageWithImage()
|
||||||
|
messageWithImage.message = it
|
||||||
|
messageWithImage.image = appIdToImage[it.appid]!!
|
||||||
|
result.add(messageWithImage)
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
fun appIdToImage(applications: List<Application>): Map<Long, String> {
|
||||||
|
val map = mutableMapOf<Long, String>()
|
||||||
|
applications.forEach {
|
||||||
|
map[it.id] = it.image
|
||||||
|
}
|
||||||
|
return map
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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<Message>()
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
const val ALL_MESSAGES = -1L
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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<Long, MessageState>()
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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<Long, String>()
|
||||||
|
|
||||||
|
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()
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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<Message>) {
|
||||||
|
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<String, Any>?
|
||||||
|
) {
|
||||||
|
showNotification(id.toLong(), title, message, priority, extras, -1L)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun showNotification(
|
||||||
|
id: Long,
|
||||||
|
title: String,
|
||||||
|
message: String,
|
||||||
|
priority: Long,
|
||||||
|
extras: Map<String, Any>?,
|
||||||
|
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())
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
163
app/src/main/kotlin/com/github/gotify/sharing/ShareActivity.kt
Normal file
163
app/src/main/kotlin/com/github/gotify/sharing/ShareActivity.kt
Normal file
@@ -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<Application>) {
|
||||||
|
val appNameList = mutableListOf<String>()
|
||||||
|
apps.forEach {
|
||||||
|
appNameList.add(it.name)
|
||||||
|
}
|
||||||
|
|
||||||
|
val adapter = ArrayAdapter(this, android.R.layout.simple_spinner_dropdown_item, appNameList)
|
||||||
|
binding.appSpinner.adapter = adapter
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -12,7 +12,7 @@
|
|||||||
android:layout_width="match_parent"
|
android:layout_width="match_parent"
|
||||||
android:layout_height="match_parent">
|
android:layout_height="match_parent">
|
||||||
|
|
||||||
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
|
<LinearLayout
|
||||||
android:layout_width="match_parent"
|
android:layout_width="match_parent"
|
||||||
app:layout_behavior="@string/appbar_scrolling_view_behavior"
|
app:layout_behavior="@string/appbar_scrolling_view_behavior"
|
||||||
android:layout_height="match_parent">
|
android:layout_height="match_parent">
|
||||||
@@ -24,6 +24,7 @@
|
|||||||
</LinearLayout>
|
</LinearLayout>
|
||||||
|
|
||||||
<include
|
<include
|
||||||
|
android:id="@+id/app_bar_drawer"
|
||||||
layout="@layout/app_bar_drawer"
|
layout="@layout/app_bar_drawer"
|
||||||
android:layout_width="match_parent"
|
android:layout_width="match_parent"
|
||||||
android:layout_height="wrap_content" />
|
android:layout_height="wrap_content" />
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ buildscript {
|
|||||||
}
|
}
|
||||||
dependencies {
|
dependencies {
|
||||||
classpath 'com.android.tools.build:gradle:7.3.1'
|
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
|
// NOTE: Do not place your application dependencies here; they belong
|
||||||
// in the individual module build.gradle files
|
// in the individual module build.gradle files
|
||||||
|
|||||||
Reference in New Issue
Block a user