Rewrite 'messages' to Kotlin

This commit is contained in:
Niko Diamadis
2022-12-26 01:22:11 +01:00
parent ec5761a948
commit b75cb740b9
26 changed files with 1318 additions and 1541 deletions

View File

@@ -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);
}
}

View File

@@ -0,0 +1,44 @@
package com.github.gotify.messages
import com.github.gotify.client.model.Message
object Extras {
fun useMarkdown(message: Message): Boolean {
return 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)
}
}

View File

@@ -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);
}
}

View File

@@ -0,0 +1,184 @@
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.*
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) {
var image: ImageView? = null
var message: TextView? = null
var title: TextView? = null
var date: TextView? = null
var delete: ImageButton? = null
private var relativeTimeFormat = true
private var dateTime: OffsetDateTime? = null
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() {
var text = "?"
if (dateTime != null) {
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
}
}
}
interface Delete {
fun delete(position: Int, message: Message, listAnimation: Boolean)
}
}

View File

@@ -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.Companion.getNEW_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();
}
}

View File

@@ -0,0 +1,667 @@
package com.github.gotify.messages
import android.app.NotificationManager
import android.content.*
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.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 androidx.swiperefreshlayout.widget.SwipeRefreshLayout
import com.github.gotify.BuildConfig
import com.github.gotify.MissedMessageUtil
import com.github.gotify.R
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.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.ListMessageAdapter.Delete
import com.github.gotify.messages.provider.*
import com.github.gotify.service.WebSocketService
import com.github.gotify.service.WebSocketService.Companion.NEW_MESSAGE_BROADCAST
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 java.io.IOException
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
)
NewSingleMessage().execute(message)
}
}
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
binding = ActivityMessagesBinding.inflate(layoutInflater)
setContentView(binding.root)
viewModel = ViewModelProvider(this, MessagesModelFactory(this))
.get(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(),
object : Delete {
override fun delete(position: Int, message: Message, listAnimation: Boolean) {
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: SwipeRefreshLayout = binding.swipeRefresh
swipeRefreshLayout.setOnRefreshListener { onRefresh() }
binding.drawerLayout.addDrawerListener(
object : SimpleDrawerListener() {
override fun onDrawerClosed(drawerView: View) {
if (updateAppOnDrawerClose != null) {
viewModel.appId = updateAppOnDrawerClose!!
UpdateMessagesForApplication(true).execute(updateAppOnDrawerClose)
updateAppOnDrawerClose = null
invalidateOptionsMenu()
}
}
})
swipeRefreshLayout.isEnabled = false
messagesView
.viewTreeObserver
.addOnScrollChangedListener {
val topChild = messagesView.getChildAt(0)
if (topChild != null) {
swipeRefreshLayout.isEnabled = topChild.top == 0
} else {
swipeRefreshLayout.isEnabled = true
}
}
UpdateMessagesForApplication(true).execute(viewModel.appId)
}
override fun onPostCreate(savedInstanceState: Bundle?) {
super.onPostCreate(savedInstanceState)
binding.learnGotify.setOnClickListener { openDocumentation() }
}
fun onRefreshAll(view: View?) {
refreshAll()
}
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()
LoadMore().execute(viewModel.appId)
}
private fun openDocumentation() {
val browserIntent = Intent(Intent.ACTION_VIEW, Uri.parse("https://gotify.net/docs/pushmsg"))
startActivity(browserIntent)
}
fun commitDelete() {
CommitDeleteMessage().execute()
}
protected 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)
for (i in applications.indices) {
val app = applications[i]
val item = menu.add(R.id.apps, i, APPLICATION_ORDER, app.name)
item.isCheckable = true
if (app.id == viewModel.appId) selectedItem = item
val t = Utils.toDrawable(
resources
) { icon: Drawable? -> 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: View = 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 { view: View? ->
onRefreshAll(
view
)
}
}
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 = if (app != null) app.id else MessageState.ALL_MESSAGES
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) { a, b -> }
.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
}
fun doLogout() {
setContentView(R.layout.splash)
DeleteClientAndNavigateToLogin().execute()
}
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(NEW_MESSAGE_BROADCAST)
registerReceiver(receiver, filter)
UpdateMissedMessages().execute(viewModel.messages.getLastReceivedMessage())
var selectedIndex: Int = R.id.nav_all_messages
val appId = viewModel.appId
if (appId != MessageState.ALL_MESSAGES) {
val apps = viewModel.appsHolder.get()
for (i in apps.indices) {
if (apps[i].id == appId) {
selectedIndex = i
}
}
}
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 =
Snackbar.make(view, R.string.snackbar_deleted, Snackbar.LENGTH_LONG)
snackbar.setAction(R.string.snackbar_undo) { v -> 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.
commitDelete()
}
}
}
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
): Boolean {
return 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
) {
if (icon != null) {
val itemView = viewHolder.itemView
val iconHeight = itemView.height / 3
val scale = iconHeight / icon!!.intrinsicHeight.toDouble()
val iconWidth = (icon!!.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
icon!!.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
icon!!.setBounds(iconLeft, iconTop, iconRight, iconBottom)
background.setBounds(
itemView.right + dX.toInt(),
itemView.top,
itemView.right,
itemView.bottom
)
} 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 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
LoadMore().execute(viewModel.appId)
}
}
}
}
}
private inner class UpdateMissedMessages : AsyncTask<Long?, Void?, Boolean>() {
override fun doInBackground(vararg ids: Long?): Boolean {
val id = Utils.first<Long>(ids)
if (id == -1L) {
return false
}
val newMessages = MissedMessageUtil(
viewModel.client.createService(
MessageApi::class.java
)
)
.missingMessages(id)
viewModel.messages.addMessages(newMessages)
return newMessages.isNotEmpty()
}
override fun onPostExecute(update: Boolean) {
if (update) {
UpdateMessagesForApplication(true).execute(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) {
DeleteMessages().execute(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) { dialog, _ -> dialog.dismiss() }
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,
{ refreshAll() }
) {
Utils.showSnackBar(
this, getString(R.string.error_delete_app)
)
})
}
private inner class LoadMore : AsyncTask<Long?, Void?, List<MessageWithImage>>() {
override fun doInBackground(vararg appId: Long?): List<MessageWithImage> {
return viewModel.messages.loadMore(appId.first()!!)
}
override fun onPostExecute(messageWithImages: List<MessageWithImage>) {
updateMessagesAndStopLoading(messageWithImages)
}
}
private inner class UpdateMessagesForApplication(withLoadingSpinner: Boolean) :
AsyncTask<Long?, Void?, Long>() {
init {
if (withLoadingSpinner) {
startLoading()
}
}
override fun doInBackground(vararg appIds: Long?): Long {
val appId = Utils.first<Long>(appIds)
viewModel.messages.loadMoreIfNotPresent(appId)
return appId
}
override fun onPostExecute(appId: Long) {
updateMessagesAndStopLoading(viewModel.messages[appId])
}
}
private inner class NewSingleMessage : AsyncTask<Message?, Void?, Void?>() {
override fun doInBackground(vararg newMessages: Message?): Void? {
viewModel.messages.addMessages(listOfNotNull(*newMessages))
return null
}
override fun onPostExecute(data: Void?) {
UpdateMessagesForApplication(false).execute(viewModel.appId)
}
}
private inner class CommitDeleteMessage : AsyncTask<Void?, Void?, Void?>() {
override fun doInBackground(vararg messages: Void?): Void? {
viewModel.messages.commitDelete()
return null
}
override fun onPostExecute(data: Void?) {
UpdateMessagesForApplication(false).execute(viewModel.appId)
}
}
private inner class DeleteMessages : AsyncTask<Long?, Void?, Boolean>() {
init {
startLoading()
}
override fun doInBackground(vararg appId: Long?): Boolean {
return viewModel.messages.deleteAll(appId.first()!!)
}
override fun onPostExecute(success: Boolean) {
if (!success) {
Utils.showSnackBar(this@MessagesActivity, "Delete failed :(")
}
UpdateMessagesForApplication(false).execute(viewModel.appId)
}
}
private inner class DeleteClientAndNavigateToLogin :
AsyncTask<Void?, Void?, Void?>() {
override fun doInBackground(vararg ignore: Void?): Void? {
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)
}
return null
}
override fun onPostExecute(aVoid: Void?) {
viewModel.settings.clear()
startActivity(Intent(this@MessagesActivity, LoginActivity::class.java))
finish()
super.onPostExecute(aVoid)
}
}
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
}
}

View File

@@ -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;
}
}

View File

@@ -0,0 +1,34 @@
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
class MessagesModel(parentView: Activity) : ViewModel() {
val settings: Settings
val picassoHandler: PicassoHandler
val client: ApiClient
val appsHolder: ApplicationHolder
val messages: MessageFacade
// 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
init {
settings = Settings(parentView)
picassoHandler = PicassoHandler(parentView, settings)
client = ClientFactory.clientToken(settings.url(), settings.sslSettings(), settings.token())
appsHolder = ApplicationHolder(parentView, client)
messages = MessageFacade(client.createService(MessageApi::class.java), appsHolder)
}
}

View File

@@ -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()));
}
}

View File

@@ -0,0 +1,16 @@
package com.github.gotify.messages
import android.app.Activity
import androidx.lifecycle.ViewModel
import androidx.lifecycle.ViewModelProvider
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}"
)
}
}

View File

@@ -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;
}
}

View File

@@ -0,0 +1,53 @@
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
class ApplicationHolder(private val activity: Activity, private val client: ApiClient) {
private var state: List<Application> = listOf()
private var onUpdate: Runnable? = null
private var onUpdateFailed: Runnable? = null
fun wasRequested(): Boolean {
return state.isNotEmpty()
}
fun request() {
client.createService(ApplicationApi::class.java)
.apps
.enqueue(
Callback.callInUI(
activity,
{ apps: List<Application> ->
onReceiveApps(
apps
)
}
) { e: ApiException -> onFailedApps(e) })
}
private fun onReceiveApps(apps: List<Application>) {
state = apps
if (onUpdate != null) onUpdate!!.run()
}
private fun onFailedApps(e: ApiException) {
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
}
}

View File

@@ -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;
}
}

View File

@@ -0,0 +1,9 @@
package com.github.gotify.messages.provider
import com.github.gotify.client.model.Message
class MessageDeletion(
val message: Message,
val allPosition: Int,
val appPosition: Int
)

View File

@@ -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;
}
}

View File

@@ -0,0 +1,82 @@
package com.github.gotify.messages.provider
import com.github.gotify.client.api.MessageApi
import com.github.gotify.client.model.Message
class MessageFacade(api: MessageApi, private val applicationHolder: ApplicationHolder) {
private val requester: MessageRequester
private val state: MessageStateHolder
init {
requester = MessageRequester(api)
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
}

View File

@@ -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;
}
}

View File

@@ -0,0 +1,26 @@
package com.github.gotify.messages.provider
import com.github.gotify.client.model.Application
import com.github.gotify.client.model.Message
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
}
}

View File

@@ -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;
}
}
}

View File

@@ -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
}
}

View File

@@ -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<>();
}

View File

@@ -0,0 +1,15 @@
package com.github.gotify.messages.provider
import com.github.gotify.client.model.Message
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
}
}

View File

@@ -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);
}
}
}

View File

@@ -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)
}
}
}

View File

@@ -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;
}

View File

@@ -0,0 +1,8 @@
package com.github.gotify.messages.provider
import com.github.gotify.client.model.Message
class MessageWithImage {
lateinit var message: Message
lateinit var image: String
}