diff --git a/app/src/main/java/com/github/gotify/messages/Extras.java b/app/src/main/java/com/github/gotify/messages/Extras.java deleted file mode 100644 index 1bc2612..0000000 --- a/app/src/main/java/com/github/gotify/messages/Extras.java +++ /dev/null @@ -1,51 +0,0 @@ -package com.github.gotify.messages; - -import com.github.gotify.client.model.Message; -import java.util.Map; - -public final class Extras { - private Extras() {} - - public static boolean useMarkdown(Message message) { - return useMarkdown(message.getExtras()); - } - - public static boolean useMarkdown(Map extras) { - if (extras == null) { - return false; - } - - Object display = extras.get("client::display"); - if (!(display instanceof Map)) { - return false; - } - - return "text/markdown".equals(((Map) display).get("contentType")); - } - - public static T getNestedValue(Class clazz, Message message, String... keys) { - return getNestedValue(clazz, message.getExtras(), keys); - } - - public static T getNestedValue(Class clazz, Map extras, String... keys) { - Object value = extras; - - for (String key : keys) { - if (value == null) { - return null; - } - - if (!(value instanceof Map)) { - return null; - } - - value = ((Map) value).get(key); - } - - if (!clazz.isInstance(value)) { - return null; - } - - return clazz.cast(value); - } -} diff --git a/app/src/main/java/com/github/gotify/messages/Extras.kt b/app/src/main/java/com/github/gotify/messages/Extras.kt new file mode 100644 index 0000000..41b98c1 --- /dev/null +++ b/app/src/main/java/com/github/gotify/messages/Extras.kt @@ -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?): Boolean { + if (extras == null) { + return false + } + + val display: Any? = extras["client::display"] + if (display !is Map<*, *>) { + return false + } + + return "text/markdown" == display["contentType"] + } + + fun getNestedValue( + clazz: Class, + extras: Map?, + vararg keys: String + ): T? { + var value: Any? = extras + + keys.forEach { key -> + if (value == null) { + return null + } + + value = (value as Map<*, *>)[key] + } + + if (!clazz.isInstance(value)) { + return null + } + + return clazz.cast(value) + } +} diff --git a/app/src/main/java/com/github/gotify/messages/ListMessageAdapter.java b/app/src/main/java/com/github/gotify/messages/ListMessageAdapter.java deleted file mode 100644 index d0d27fd..0000000 --- a/app/src/main/java/com/github/gotify/messages/ListMessageAdapter.java +++ /dev/null @@ -1,240 +0,0 @@ -package com.github.gotify.messages; - -import android.content.ClipData; -import android.content.ClipboardManager; -import android.content.Context; -import android.content.SharedPreferences; -import android.text.format.DateUtils; -import android.text.util.Linkify; -import android.view.LayoutInflater; -import android.view.ViewGroup; -import android.widget.ImageButton; -import android.widget.ImageView; -import android.widget.TextView; -import android.widget.Toast; -import androidx.annotation.NonNull; -import androidx.preference.PreferenceManager; -import androidx.recyclerview.widget.RecyclerView; -import androidx.viewbinding.ViewBinding; -import com.github.gotify.MarkwonFactory; -import com.github.gotify.R; -import com.github.gotify.Settings; -import com.github.gotify.Utils; -import com.github.gotify.client.model.Message; -import com.github.gotify.databinding.MessageItemBinding; -import com.github.gotify.databinding.MessageItemCompactBinding; -import com.github.gotify.messages.provider.MessageWithImage; -import com.squareup.picasso.Picasso; -import io.noties.markwon.Markwon; -import java.text.DateFormat; -import java.util.Date; -import java.util.List; -import org.threeten.bp.OffsetDateTime; - -public class ListMessageAdapter extends RecyclerView.Adapter { - - private Context context; - private SharedPreferences prefs; - private Picasso picasso; - private List items; - private Delete delete; - private Settings settings; - private Markwon markwon; - private int messageLayout; - - private final String TIME_FORMAT_RELATIVE; - private final String TIME_FORMAT_PREFS_KEY; - - ListMessageAdapter( - Context context, - Settings settings, - Picasso picasso, - List items, - Delete delete) { - super(); - this.context = context; - this.settings = settings; - this.picasso = picasso; - this.items = items; - this.delete = delete; - - this.prefs = PreferenceManager.getDefaultSharedPreferences(context); - this.markwon = MarkwonFactory.createForMessage(context, picasso); - - TIME_FORMAT_RELATIVE = - context.getResources().getString(R.string.time_format_value_relative); - TIME_FORMAT_PREFS_KEY = context.getResources().getString(R.string.setting_key_time_format); - - String message_layout_prefs_key = - context.getResources().getString(R.string.setting_key_message_layout); - String messageLayoutNormal = - context.getResources().getString(R.string.message_layout_value_normal); - String messageLayoutSetting = - prefs.getString(message_layout_prefs_key, messageLayoutNormal); - - if (messageLayoutSetting.equals(messageLayoutNormal)) { - messageLayout = R.layout.message_item; - } else { - messageLayout = R.layout.message_item_compact; - } - } - - public List getItems() { - return items; - } - - public void setItems(List items) { - this.items = items; - } - - @NonNull - @Override - public ViewHolder onCreateViewHolder(@NonNull ViewGroup parent, int viewType) { - ViewHolder holder; - LayoutInflater layoutInflater = LayoutInflater.from(parent.getContext()); - if (messageLayout == R.layout.message_item) { - MessageItemBinding binding = MessageItemBinding.inflate(layoutInflater, parent, false); - holder = new ViewHolder(binding); - } else { - MessageItemCompactBinding binding = - MessageItemCompactBinding.inflate(layoutInflater, parent, false); - holder = new ViewHolder(binding); - } - - return holder; - } - - @Override - public void onBindViewHolder(@NonNull ViewHolder holder, int position) { - final MessageWithImage message = items.get(position); - if (Extras.useMarkdown(message.message)) { - holder.message.setAutoLinkMask(0); - markwon.setMarkdown(holder.message, message.message.getMessage()); - } else { - holder.message.setAutoLinkMask(Linkify.WEB_URLS); - holder.message.setText(message.message.getMessage()); - } - holder.title.setText(message.message.getTitle()); - picasso.load(Utils.resolveAbsoluteUrl(settings.url() + "/", message.image)) - .error(R.drawable.ic_alarm) - .placeholder(R.drawable.ic_placeholder) - .into(holder.image); - - SharedPreferences prefs = PreferenceManager.getDefaultSharedPreferences(context); - String timeFormat = prefs.getString(TIME_FORMAT_PREFS_KEY, TIME_FORMAT_RELATIVE); - holder.setDateTime(message.message.getDate(), timeFormat.equals(TIME_FORMAT_RELATIVE)); - holder.date.setOnClickListener((ignored) -> holder.switchTimeFormat()); - - holder.delete.setOnClickListener( - (ignored) -> delete.delete(holder.getAdapterPosition(), message.message, false)); - } - - @Override - public int getItemCount() { - return items.size(); - } - - @Override - public long getItemId(int position) { - MessageWithImage currentItem = items.get(position); - return currentItem.message.getId(); - } - - static class ViewHolder extends RecyclerView.ViewHolder { - ImageView image; - TextView message; - TextView title; - TextView date; - ImageButton delete; - - private boolean relativeTimeFormat; - private OffsetDateTime dateTime; - - ViewHolder(final ViewBinding binding) { - super(binding.getRoot()); - relativeTimeFormat = true; - dateTime = null; - enableCopyToClipboard(); - - if (binding instanceof MessageItemBinding) { - MessageItemBinding localBinding = (MessageItemBinding) binding; - image = localBinding.messageImage; - message = localBinding.messageText; - title = localBinding.messageTitle; - date = localBinding.messageDate; - delete = localBinding.messageDelete; - } else if (binding instanceof MessageItemCompactBinding) { - MessageItemCompactBinding localBinding = (MessageItemCompactBinding) binding; - image = localBinding.messageImage; - message = localBinding.messageText; - title = localBinding.messageTitle; - date = localBinding.messageDate; - delete = localBinding.messageDelete; - } - } - - void switchTimeFormat() { - relativeTimeFormat = !relativeTimeFormat; - updateDate(); - } - - void setDateTime(OffsetDateTime dateTime, boolean relativeTimeFormatPreference) { - this.dateTime = dateTime; - relativeTimeFormat = relativeTimeFormatPreference; - updateDate(); - } - - void updateDate() { - String text = "?"; - if (dateTime != null) { - if (relativeTimeFormat) { - // Relative time format - text = Utils.dateToRelative(dateTime); - } else { - // Absolute time format - long time = dateTime.toInstant().toEpochMilli(); - Date date = new Date(time); - if (DateUtils.isToday(time)) { - text = DateFormat.getTimeInstance(DateFormat.SHORT).format(date); - } else { - text = - DateFormat.getDateTimeInstance(DateFormat.MEDIUM, DateFormat.SHORT) - .format(date); - } - } - } - date.setText(text); - } - - private void enableCopyToClipboard() { - super.itemView.setOnLongClickListener( - view -> { - ClipboardManager clipboard = - (ClipboardManager) - view.getContext() - .getSystemService(Context.CLIPBOARD_SERVICE); - ClipData clip = - ClipData.newPlainText( - "GotifyMessageContent", message.getText().toString()); - - if (clipboard != null) { - clipboard.setPrimaryClip(clip); - Toast toast = - Toast.makeText( - view.getContext(), - view.getContext() - .getString( - R.string.message_copied_to_clipboard), - Toast.LENGTH_SHORT); - toast.show(); - } - - return true; - }); - } - } - - public interface Delete { - void delete(int position, Message message, boolean listAnimation); - } -} diff --git a/app/src/main/java/com/github/gotify/messages/ListMessageAdapter.kt b/app/src/main/java/com/github/gotify/messages/ListMessageAdapter.kt new file mode 100644 index 0000000..ee268e2 --- /dev/null +++ b/app/src/main/java/com/github/gotify/messages/ListMessageAdapter.kt @@ -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, + private val delete: Delete +) : RecyclerView.Adapter() { + private val prefs: SharedPreferences = PreferenceManager.getDefaultSharedPreferences(context) + private val markwon: Markwon = MarkwonFactory.createForMessage(context, picasso) + + private val timeFormatRelative = + context.resources.getString(R.string.time_format_value_relative) + private val timeFormatPrefsKey = context.resources.getString(R.string.setting_key_time_format) + + private var messageLayout = 0 + + init { + val messageLayoutPrefsKey = context.resources.getString(R.string.setting_key_message_layout) + val messageLayoutNormal = context.resources.getString(R.string.message_layout_value_normal) + val messageLayoutSetting = prefs.getString(messageLayoutPrefsKey, messageLayoutNormal) + + messageLayout = if (messageLayoutSetting == messageLayoutNormal) { + R.layout.message_item + } else { + R.layout.message_item_compact + } + } + + override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): ViewHolder { + val layoutInflater = LayoutInflater.from(parent.context) + return if (messageLayout == R.layout.message_item) { + val binding = MessageItemBinding.inflate(layoutInflater, parent, false) + ViewHolder(binding) + } else { + val binding = MessageItemCompactBinding.inflate(layoutInflater, parent, false) + ViewHolder(binding) + } + } + + override fun onBindViewHolder(holder: ViewHolder, position: Int) { + val message = items[position] + if (Extras.useMarkdown(message.message)) { + holder.message!!.autoLinkMask = 0 + markwon.setMarkdown(holder.message!!, message.message.message) + } else { + holder.message!!.autoLinkMask = Linkify.WEB_URLS + holder.message!!.text = message.message.message + } + holder.title!!.text = message.message.title + picasso.load(Utils.resolveAbsoluteUrl("${settings.url()}/", message.image)) + .error(R.drawable.ic_alarm) + .placeholder(R.drawable.ic_placeholder) + .into(holder.image) + + val prefs = PreferenceManager.getDefaultSharedPreferences(context) + val timeFormat = prefs.getString(timeFormatPrefsKey, timeFormatRelative) + holder.setDateTime(message.message.date, timeFormat == timeFormatRelative) + holder.date!!.setOnClickListener { holder.switchTimeFormat() } + + holder.delete!!.setOnClickListener { + delete.delete(holder.adapterPosition, message.message!!, false) + } + } + + override fun getItemCount() = items.size + + override fun getItemId(position: Int): Long { + val currentItem = items[position] + return currentItem.message.id + } + + class ViewHolder(binding: ViewBinding) : RecyclerView.ViewHolder(binding.root) { + 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) + } +} diff --git a/app/src/main/java/com/github/gotify/messages/MessagesActivity.java b/app/src/main/java/com/github/gotify/messages/MessagesActivity.java deleted file mode 100644 index 48f9432..0000000 --- a/app/src/main/java/com/github/gotify/messages/MessagesActivity.java +++ /dev/null @@ -1,740 +0,0 @@ -package com.github.gotify.messages; - -import android.app.NotificationManager; -import android.content.BroadcastReceiver; -import android.content.Context; -import android.content.DialogInterface; -import android.content.Intent; -import android.content.IntentFilter; -import android.graphics.Canvas; -import android.graphics.drawable.ColorDrawable; -import android.graphics.drawable.Drawable; -import android.net.Uri; -import android.os.AsyncTask; -import android.os.Bundle; -import android.view.Menu; -import android.view.MenuItem; -import android.view.View; -import android.widget.ImageButton; -import android.widget.TextView; -import androidx.annotation.NonNull; -import androidx.annotation.Nullable; -import androidx.appcompat.app.ActionBarDrawerToggle; -import androidx.appcompat.app.AlertDialog; -import androidx.appcompat.app.AppCompatActivity; -import androidx.appcompat.view.ContextThemeWrapper; -import androidx.core.content.ContextCompat; -import androidx.core.graphics.drawable.DrawableCompat; -import androidx.core.view.GravityCompat; -import androidx.drawerlayout.widget.DrawerLayout; -import androidx.lifecycle.ViewModelProvider; -import androidx.recyclerview.widget.DividerItemDecoration; -import androidx.recyclerview.widget.ItemTouchHelper; -import androidx.recyclerview.widget.LinearLayoutManager; -import androidx.recyclerview.widget.RecyclerView; -import androidx.swiperefreshlayout.widget.SwipeRefreshLayout; -import com.github.gotify.BuildConfig; -import com.github.gotify.MissedMessageUtil; -import com.github.gotify.R; -import com.github.gotify.Settings; -import com.github.gotify.Utils; -import com.github.gotify.api.Api; -import com.github.gotify.api.ApiException; -import com.github.gotify.api.Callback; -import com.github.gotify.api.ClientFactory; -import com.github.gotify.client.ApiClient; -import com.github.gotify.client.api.ApplicationApi; -import com.github.gotify.client.api.ClientApi; -import com.github.gotify.client.api.MessageApi; -import com.github.gotify.client.model.Application; -import com.github.gotify.client.model.Client; -import com.github.gotify.client.model.Message; -import com.github.gotify.databinding.ActivityMessagesBinding; -import com.github.gotify.init.InitializationActivity; -import com.github.gotify.log.Log; -import com.github.gotify.log.LogsActivity; -import com.github.gotify.login.LoginActivity; -import com.github.gotify.messages.provider.ApplicationHolder; -import com.github.gotify.messages.provider.MessageDeletion; -import com.github.gotify.messages.provider.MessageFacade; -import com.github.gotify.messages.provider.MessageState; -import com.github.gotify.messages.provider.MessageWithImage; -import com.github.gotify.service.WebSocketService; -import com.github.gotify.settings.SettingsActivity; -import com.github.gotify.sharing.ShareActivity; -import com.google.android.material.navigation.NavigationView; -import com.google.android.material.snackbar.BaseTransientBottomBar; -import com.google.android.material.snackbar.Snackbar; -import com.squareup.picasso.Target; -import java.io.IOException; -import java.util.Arrays; -import java.util.List; - -import static com.github.gotify.Utils.first; -import static java.util.Collections.emptyList; - -public class MessagesActivity extends AppCompatActivity - implements NavigationView.OnNavigationItemSelectedListener { - - private final BroadcastReceiver receiver = - new BroadcastReceiver() { - @Override - public void onReceive(Context context, Intent intent) { - String messageJson = intent.getStringExtra("message"); - Message message = Utils.JSON.fromJson(messageJson, Message.class); - new NewSingleMessage().execute(message); - } - }; - - private static final int APPLICATION_ORDER = 1; - - private ActivityMessagesBinding binding; - private MessagesModel viewModel; - - private boolean isLoadMore = false; - private Long updateAppOnDrawerClose = null; - - private ListMessageAdapter listMessageAdapter; - - @Override - protected void onCreate(Bundle savedInstanceState) { - super.onCreate(savedInstanceState); - binding = ActivityMessagesBinding.inflate(getLayoutInflater()); - setContentView(binding.getRoot()); - viewModel = - new ViewModelProvider(this, new MessagesModelFactory(this)) - .get(MessagesModel.class); - Log.i("Entering " + getClass().getSimpleName()); - - initDrawer(); - - LinearLayoutManager layoutManager = new LinearLayoutManager(this); - RecyclerView messagesView = binding.messagesView; - DividerItemDecoration dividerItemDecoration = - new DividerItemDecoration( - messagesView.getContext(), layoutManager.getOrientation()); - listMessageAdapter = - new ListMessageAdapter( - this, - viewModel.getSettings(), - viewModel.getPicassoHandler().get(), - emptyList(), - this::scheduleDeletion); - - messagesView.addItemDecoration(dividerItemDecoration); - messagesView.setHasFixedSize(true); - messagesView.setLayoutManager(layoutManager); - messagesView.addOnScrollListener(new MessageListOnScrollListener()); - messagesView.setAdapter(listMessageAdapter); - - ApplicationHolder appsHolder = viewModel.getAppsHolder(); - appsHolder.onUpdate(() -> onUpdateApps(appsHolder.get())); - if (appsHolder.wasRequested()) onUpdateApps(appsHolder.get()); - else appsHolder.request(); - - ItemTouchHelper itemTouchHelper = - new ItemTouchHelper(new SwipeToDeleteCallback(listMessageAdapter)); - itemTouchHelper.attachToRecyclerView(messagesView); - - SwipeRefreshLayout swipeRefreshLayout = binding.swipeRefresh; - swipeRefreshLayout.setOnRefreshListener(this::onRefresh); - binding.drawerLayout.addDrawerListener( - new DrawerLayout.SimpleDrawerListener() { - @Override - public void onDrawerClosed(View drawerView) { - if (updateAppOnDrawerClose != null) { - viewModel.setAppId(updateAppOnDrawerClose); - new UpdateMessagesForApplication(true).execute(updateAppOnDrawerClose); - updateAppOnDrawerClose = null; - invalidateOptionsMenu(); - } - } - }); - - swipeRefreshLayout.setEnabled(false); - messagesView - .getViewTreeObserver() - .addOnScrollChangedListener( - () -> { - View topChild = messagesView.getChildAt(0); - if (topChild != null) { - swipeRefreshLayout.setEnabled(topChild.getTop() == 0); - } else { - swipeRefreshLayout.setEnabled(true); - } - }); - - new UpdateMessagesForApplication(true).execute(viewModel.getAppId()); - } - - @Override - protected void onPostCreate(@Nullable Bundle savedInstanceState) { - super.onPostCreate(savedInstanceState); - binding.learnGotify.setOnClickListener(view -> openDocumentation()); - } - - public void onRefreshAll(View view) { - refreshAll(); - } - - public void refreshAll() { - try { - viewModel.getPicassoHandler().evict(); - } catch (IOException e) { - Log.e("Problem evicting Picasso cache", e); - } - startActivity(new Intent(this, InitializationActivity.class)); - finish(); - } - - private void onRefresh() { - viewModel.getMessages().clear(); - new LoadMore().execute(viewModel.getAppId()); - } - - public void openDocumentation() { - Intent browserIntent = - new Intent(Intent.ACTION_VIEW, Uri.parse("https://gotify.net/docs/pushmsg")); - startActivity(browserIntent); - } - - public void commitDelete() { - new CommitDeleteMessage().execute(); - } - - protected void onUpdateApps(List applications) { - Menu menu = binding.navView.getMenu(); - menu.removeGroup(R.id.apps); - viewModel.getTargetReferences().clear(); - updateMessagesAndStopLoading(viewModel.getMessages().get(viewModel.getAppId())); - - MenuItem selectedItem = menu.findItem(R.id.nav_all_messages); - for (int i = 0; i < applications.size(); i++) { - Application app = applications.get(i); - MenuItem item = menu.add(R.id.apps, i, APPLICATION_ORDER, app.getName()); - item.setCheckable(true); - if (app.getId() == viewModel.getAppId()) selectedItem = item; - Target t = Utils.toDrawable(getResources(), item::setIcon); - viewModel.getTargetReferences().add(t); - viewModel - .getPicassoHandler() - .get() - .load( - Utils.resolveAbsoluteUrl( - viewModel.getSettings().url() + "/", app.getImage())) - .error(R.drawable.ic_alarm) - .placeholder(R.drawable.ic_placeholder) - .resize(100, 100) - .into(t); - } - selectAppInMenu(selectedItem); - } - - private void initDrawer() { - setSupportActionBar(binding.appBarDrawer.toolbar); - binding.navView.setItemIconTintList(null); - ActionBarDrawerToggle toggle = - new ActionBarDrawerToggle( - this, - binding.drawerLayout, - binding.appBarDrawer.toolbar, - R.string.navigation_drawer_open, - R.string.navigation_drawer_close); - binding.drawerLayout.addDrawerListener(toggle); - toggle.syncState(); - - binding.navView.setNavigationItemSelectedListener(this); - View headerView = binding.navView.getHeaderView(0); - - Settings settings = viewModel.getSettings(); - - TextView user = headerView.findViewById(R.id.header_user); - user.setText(settings.user().getName()); - - TextView connection = headerView.findViewById(R.id.header_connection); - connection.setText( - getString(R.string.connection, settings.user().getName(), settings.url())); - - TextView version = headerView.findViewById(R.id.header_version); - version.setText( - getString(R.string.versions, BuildConfig.VERSION_NAME, settings.serverVersion())); - - ImageButton refreshAll = headerView.findViewById(R.id.refresh_all); - refreshAll.setOnClickListener(this::onRefreshAll); - } - - @Override - public void onBackPressed() { - if (binding.drawerLayout.isDrawerOpen(GravityCompat.START)) { - binding.drawerLayout.closeDrawer(GravityCompat.START); - } else { - super.onBackPressed(); - } - } - - @Override - public boolean onNavigationItemSelected(MenuItem item) { - // Handle navigation view item clicks here. - int id = item.getItemId(); - - if (item.getGroupId() == R.id.apps) { - Application app = viewModel.getAppsHolder().get().get(id); - updateAppOnDrawerClose = app != null ? app.getId() : MessageState.ALL_MESSAGES; - startLoading(); - binding.appBarDrawer.toolbar.setSubtitle(item.getTitle()); - } else if (id == R.id.nav_all_messages) { - updateAppOnDrawerClose = MessageState.ALL_MESSAGES; - startLoading(); - binding.appBarDrawer.toolbar.setSubtitle(""); - } else if (id == R.id.logout) { - new AlertDialog.Builder(new ContextThemeWrapper(this, R.style.AppTheme_Dialog)) - .setTitle(R.string.logout) - .setMessage(getString(R.string.logout_confirm)) - .setPositiveButton(R.string.yes, this::doLogout) - .setNegativeButton(R.string.cancel, (a, b) -> {}) - .show(); - } else if (id == R.id.nav_logs) { - startActivity(new Intent(this, LogsActivity.class)); - } else if (id == R.id.settings) { - startActivity(new Intent(this, SettingsActivity.class)); - } else if (id == R.id.push_message) { - Intent intent = new Intent(MessagesActivity.this, ShareActivity.class); - startActivity(intent); - } - - binding.drawerLayout.closeDrawer(GravityCompat.START); - return true; - } - - public void doLogout(DialogInterface dialog, int which) { - setContentView(R.layout.splash); - new DeleteClientAndNavigateToLogin().execute(); - } - - private void startLoading() { - binding.swipeRefresh.setRefreshing(true); - binding.messagesView.setVisibility(View.GONE); - } - - private void stopLoading() { - binding.swipeRefresh.setRefreshing(false); - binding.messagesView.setVisibility(View.VISIBLE); - } - - @Override - protected void onResume() { - - Context context = getApplicationContext(); - NotificationManager nManager = - ((NotificationManager) context.getSystemService(Context.NOTIFICATION_SERVICE)); - nManager.cancelAll(); - - IntentFilter filter = new IntentFilter(); - filter.addAction(WebSocketService.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 apps = viewModel.getAppsHolder().get(); - for (int i = 0; i < apps.size(); i++) { - if (apps.get(i).getId() == appId) { - selectedIndex = i; - } - } - } - - listMessageAdapter.notifyDataSetChanged(); - selectAppInMenu(binding.navView.getMenu().findItem(selectedIndex)); - super.onResume(); - } - - @Override - protected void onPause() { - unregisterReceiver(receiver); - super.onPause(); - } - - private void selectAppInMenu(MenuItem appItem) { - if (appItem != null) { - appItem.setChecked(true); - if (appItem.getItemId() != R.id.nav_all_messages) - binding.appBarDrawer.toolbar.setSubtitle(appItem.getTitle()); - } - } - - private void scheduleDeletion(int position, Message message, boolean listAnimation) { - ListMessageAdapter adapter = (ListMessageAdapter) binding.messagesView.getAdapter(); - - MessageFacade messages = viewModel.getMessages(); - messages.deleteLocal(message); - adapter.setItems(messages.get(viewModel.getAppId())); - - if (listAnimation) adapter.notifyItemRemoved(position); - else adapter.notifyDataSetChanged(); - - showDeletionSnackbar(); - } - - private void undoDelete() { - MessageFacade messages = viewModel.getMessages(); - MessageDeletion deletion = messages.undoDeleteLocal(); - if (deletion != null) { - ListMessageAdapter adapter = (ListMessageAdapter) binding.messagesView.getAdapter(); - long appId = viewModel.getAppId(); - adapter.setItems(messages.get(appId)); - int insertPosition = - appId == MessageState.ALL_MESSAGES - ? deletion.getAllPosition() - : deletion.getAppPosition(); - adapter.notifyItemInserted(insertPosition); - } - } - - private void showDeletionSnackbar() { - View view = binding.swipeRefresh; - Snackbar snackbar = Snackbar.make(view, R.string.snackbar_deleted, Snackbar.LENGTH_LONG); - snackbar.setAction(R.string.snackbar_undo, v -> undoDelete()); - snackbar.addCallback(new SnackbarCallback()); - snackbar.show(); - } - - private class SnackbarCallback extends BaseTransientBottomBar.BaseCallback { - @Override - public void onDismissed(Snackbar transientBottomBar, int event) { - super.onDismissed(transientBottomBar, event); - if (event != DISMISS_EVENT_ACTION && event != DISMISS_EVENT_CONSECUTIVE) { - // Execute deletion when the snackbar disappeared without pressing the undo button - // DISMISS_EVENT_CONSECUTIVE should be excluded as well, because it would cause the - // deletion to be sent to the server twice, since the deletion is sent to the server - // in MessageFacade if a message is deleted while another message was already - // waiting for deletion. - MessagesActivity.this.commitDelete(); - } - } - } - - private class SwipeToDeleteCallback extends ItemTouchHelper.SimpleCallback { - private final ListMessageAdapter adapter; - private Drawable icon; - private final ColorDrawable background; - - public SwipeToDeleteCallback(ListMessageAdapter adapter) { - super(0, ItemTouchHelper.LEFT | ItemTouchHelper.RIGHT); - this.adapter = adapter; - - int backgroundColorId = - ContextCompat.getColor(MessagesActivity.this, R.color.swipeBackground); - int iconColorId = ContextCompat.getColor(MessagesActivity.this, R.color.swipeIcon); - - Drawable drawable = - ContextCompat.getDrawable(MessagesActivity.this, R.drawable.ic_delete); - icon = null; - if (drawable != null) { - icon = DrawableCompat.wrap(drawable.mutate()); - DrawableCompat.setTint(icon, iconColorId); - } - - background = new ColorDrawable(backgroundColorId); - } - - @Override - public boolean onMove( - @NonNull RecyclerView recyclerView, - @NonNull RecyclerView.ViewHolder viewHolder, - @NonNull RecyclerView.ViewHolder target) { - return false; - } - - @Override - public void onSwiped(@NonNull RecyclerView.ViewHolder viewHolder, int direction) { - int position = viewHolder.getAdapterPosition(); - MessageWithImage message = adapter.getItems().get(position); - scheduleDeletion(position, message.message, true); - } - - @Override - public void onChildDraw( - @NonNull Canvas c, - @NonNull RecyclerView recyclerView, - @NonNull RecyclerView.ViewHolder viewHolder, - float dX, - float dY, - int actionState, - boolean isCurrentlyActive) { - if (icon != null) { - View itemView = viewHolder.itemView; - - int iconHeight = itemView.getHeight() / 3; - double scale = iconHeight / (double) icon.getIntrinsicHeight(); - int iconWidth = (int) (icon.getIntrinsicWidth() * scale); - - int iconMarginLeftRight = 50; - int iconMarginTopBottom = (itemView.getHeight() - iconHeight) / 2; - int iconTop = itemView.getTop() + iconMarginTopBottom; - int iconBottom = itemView.getBottom() - iconMarginTopBottom; - - if (dX > 0) { - // Swiping to the right - int iconLeft = itemView.getLeft() + iconMarginLeftRight; - int iconRight = itemView.getLeft() + iconMarginLeftRight + iconWidth; - icon.setBounds(iconLeft, iconTop, iconRight, iconBottom); - - background.setBounds( - itemView.getLeft(), - itemView.getTop(), - itemView.getLeft() + ((int) dX), - itemView.getBottom()); - } else if (dX < 0) { - // Swiping to the left - int iconLeft = itemView.getRight() - iconMarginLeftRight - iconWidth; - int iconRight = itemView.getRight() - iconMarginLeftRight; - icon.setBounds(iconLeft, iconTop, iconRight, iconBottom); - - background.setBounds( - itemView.getRight() + ((int) dX), - itemView.getTop(), - itemView.getRight(), - itemView.getBottom()); - } else { - // View is unswiped - icon.setBounds(0, 0, 0, 0); - background.setBounds(0, 0, 0, 0); - } - - background.draw(c); - icon.draw(c); - } - - super.onChildDraw(c, recyclerView, viewHolder, dX, dY, actionState, isCurrentlyActive); - } - } - - private class MessageListOnScrollListener extends RecyclerView.OnScrollListener { - @Override - public void onScrollStateChanged(@NonNull RecyclerView view, int scrollState) {} - - @Override - public void onScrolled(RecyclerView view, int dx, int dy) { - LinearLayoutManager linearLayoutManager = (LinearLayoutManager) view.getLayoutManager(); - if (linearLayoutManager != null) { - int lastVisibleItem = linearLayoutManager.findLastVisibleItemPosition(); - int totalItemCount = view.getAdapter().getItemCount(); - - if (lastVisibleItem > totalItemCount - 15 - && totalItemCount != 0 - && viewModel.getMessages().canLoadMore(viewModel.getAppId())) { - if (!isLoadMore) { - isLoadMore = true; - new LoadMore().execute(viewModel.getAppId()); - } - } - } - } - } - - private class UpdateMissedMessages extends AsyncTask { - @Override - protected Boolean doInBackground(Long... ids) { - Long id = first(ids); - if (id == -1) { - return false; - } - - List newMessages = - new MissedMessageUtil(viewModel.getClient().createService(MessageApi.class)) - .missingMessages(id); - viewModel.getMessages().addMessages(newMessages); - return !newMessages.isEmpty(); - } - - @Override - protected void onPostExecute(Boolean update) { - if (update) { - new UpdateMessagesForApplication(true).execute(viewModel.getAppId()); - } - } - } - - @Override - public boolean onCreateOptionsMenu(Menu menu) { - getMenuInflater().inflate(R.menu.messages_action, menu); - menu.findItem(R.id.action_delete_app) - .setVisible(viewModel.getAppId() != MessageState.ALL_MESSAGES); - return super.onCreateOptionsMenu(menu); - } - - @Override - public boolean onOptionsItemSelected(MenuItem item) { - if (item.getItemId() == R.id.action_delete_all) { - new DeleteMessages().execute(viewModel.getAppId()); - } - if (item.getItemId() == R.id.action_delete_app) { - android.app.AlertDialog.Builder alert = new android.app.AlertDialog.Builder(this); - alert.setTitle(R.string.delete_app); - alert.setMessage(R.string.ack); - alert.setPositiveButton( - R.string.yes, (dialog, which) -> deleteApp(viewModel.getAppId())); - alert.setNegativeButton(R.string.no, (dialog, which) -> dialog.dismiss()); - alert.show(); - } - return super.onContextItemSelected(item); - } - - private void deleteApp(Long appId) { - Settings settings = viewModel.getSettings(); - ApiClient client = - ClientFactory.clientToken(settings.url(), settings.sslSettings(), settings.token()); - - client.createService(ApplicationApi.class) - .deleteApp(appId) - .enqueue( - Callback.callInUI( - this, - (ignored) -> refreshAll(), - (e) -> - Utils.showSnackBar( - this, getString(R.string.error_delete_app)))); - } - - private class LoadMore extends AsyncTask> { - - @Override - protected List doInBackground(Long... appId) { - return viewModel.getMessages().loadMore(first(appId)); - } - - @Override - protected void onPostExecute(List messageWithImages) { - updateMessagesAndStopLoading(messageWithImages); - } - } - - private class UpdateMessagesForApplication extends AsyncTask { - - private UpdateMessagesForApplication(boolean withLoadingSpinner) { - if (withLoadingSpinner) { - startLoading(); - } - } - - @Override - protected Long doInBackground(Long... appIds) { - Long appId = first(appIds); - viewModel.getMessages().loadMoreIfNotPresent(appId); - return appId; - } - - @Override - protected void onPostExecute(Long appId) { - updateMessagesAndStopLoading(viewModel.getMessages().get(appId)); - } - } - - private class NewSingleMessage extends AsyncTask { - - @Override - protected Void doInBackground(Message... newMessages) { - viewModel.getMessages().addMessages(Arrays.asList(newMessages)); - return null; - } - - @Override - protected void onPostExecute(Void data) { - new UpdateMessagesForApplication(false).execute(viewModel.getAppId()); - } - } - - private class CommitDeleteMessage extends AsyncTask { - - @Override - protected Void doInBackground(Void... messages) { - viewModel.getMessages().commitDelete(); - return null; - } - - @Override - protected void onPostExecute(Void data) { - new UpdateMessagesForApplication(false).execute(viewModel.getAppId()); - } - } - - private class DeleteMessages extends AsyncTask { - - DeleteMessages() { - startLoading(); - } - - @Override - protected Boolean doInBackground(Long... appId) { - return viewModel.getMessages().deleteAll(first(appId)); - } - - @Override - protected void onPostExecute(Boolean success) { - if (!success) { - Utils.showSnackBar(MessagesActivity.this, "Delete failed :("); - } - new UpdateMessagesForApplication(false).execute(viewModel.getAppId()); - } - } - - private class DeleteClientAndNavigateToLogin extends AsyncTask { - - @Override - protected Void doInBackground(Void... ignore) { - Settings settings = viewModel.getSettings(); - ClientApi api = - ClientFactory.clientToken( - settings.url(), settings.sslSettings(), settings.token()) - .createService(ClientApi.class); - stopService(new Intent(MessagesActivity.this, WebSocketService.class)); - try { - List clients = Api.execute(api.getClients()); - - Client currentClient = null; - for (Client client : clients) { - if (client.getToken().equals(settings.token())) { - currentClient = client; - break; - } - } - - if (currentClient != null) { - Log.i("Delete client with id " + currentClient.getId()); - Api.execute(api.deleteClient(currentClient.getId())); - } else { - Log.e("Could not delete client, client does not exist."); - } - - } catch (ApiException e) { - Log.e("Could not delete client", e); - } - return null; - } - - @Override - protected void onPostExecute(Void aVoid) { - viewModel.getSettings().clear(); - startActivity(new Intent(MessagesActivity.this, LoginActivity.class)); - finish(); - super.onPostExecute(aVoid); - } - } - - private void updateMessagesAndStopLoading(List messageWithImages) { - isLoadMore = false; - stopLoading(); - - if (messageWithImages.isEmpty()) { - binding.flipper.setDisplayedChild(1); - } else { - binding.flipper.setDisplayedChild(0); - } - - ListMessageAdapter adapter = (ListMessageAdapter) binding.messagesView.getAdapter(); - adapter.setItems(messageWithImages); - adapter.notifyDataSetChanged(); - } -} diff --git a/app/src/main/java/com/github/gotify/messages/MessagesActivity.kt b/app/src/main/java/com/github/gotify/messages/MessagesActivity.kt new file mode 100644 index 0000000..e5df8a5 --- /dev/null +++ b/app/src/main/java/com/github/gotify/messages/MessagesActivity.kt @@ -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) { + 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(R.id.header_user) + user.text = settings.user().name + val connection = headerView.findViewById(R.id.header_connection) + connection.text = getString(R.string.connection, settings.user().name, settings.url()) + val version = headerView.findViewById(R.id.header_version) + version.text = + getString(R.string.versions, BuildConfig.VERSION_NAME, settings.serverVersion()) + val refreshAll = headerView.findViewById(R.id.refresh_all) + refreshAll.setOnClickListener { 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() { + 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() { + override fun doInBackground(vararg ids: Long?): Boolean { + val id = Utils.first(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>() { + override fun doInBackground(vararg appId: Long?): List { + return viewModel.messages.loadMore(appId.first()!!) + } + + override fun onPostExecute(messageWithImages: List) { + updateMessagesAndStopLoading(messageWithImages) + } + } + + private inner class UpdateMessagesForApplication(withLoadingSpinner: Boolean) : + AsyncTask() { + init { + if (withLoadingSpinner) { + startLoading() + } + } + + override fun doInBackground(vararg appIds: Long?): Long { + val appId = Utils.first(appIds) + viewModel.messages.loadMoreIfNotPresent(appId) + return appId + } + + override fun onPostExecute(appId: Long) { + updateMessagesAndStopLoading(viewModel.messages[appId]) + } + } + + private inner class NewSingleMessage : AsyncTask() { + 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() { + 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() { + 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() { + 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) { + isLoadMore = false + stopLoading() + if (messageWithImages.isEmpty()) { + binding.flipper.displayedChild = 1 + } else { + binding.flipper.displayedChild = 0 + } + val adapter = binding.messagesView.adapter as ListMessageAdapter + adapter.items = messageWithImages + adapter.notifyDataSetChanged() + } + + companion object { + private const val APPLICATION_ORDER = 1 + } +} diff --git a/app/src/main/java/com/github/gotify/messages/MessagesModel.java b/app/src/main/java/com/github/gotify/messages/MessagesModel.java deleted file mode 100644 index 542f09e..0000000 --- a/app/src/main/java/com/github/gotify/messages/MessagesModel.java +++ /dev/null @@ -1,70 +0,0 @@ -package com.github.gotify.messages; - -import android.app.Activity; -import androidx.lifecycle.ViewModel; -import com.github.gotify.Settings; -import com.github.gotify.api.ClientFactory; -import com.github.gotify.client.ApiClient; -import com.github.gotify.client.api.MessageApi; -import com.github.gotify.messages.provider.ApplicationHolder; -import com.github.gotify.messages.provider.MessageFacade; -import com.github.gotify.messages.provider.MessageState; -import com.github.gotify.picasso.PicassoHandler; -import com.squareup.picasso.Target; -import java.util.ArrayList; -import java.util.List; - -public class MessagesModel extends ViewModel { - private final Settings settings; - private final PicassoHandler picassoHandler; - private final ApiClient client; - private final ApplicationHolder appsHolder; - private final MessageFacade messages; - - // we need to keep the target references otherwise they get gc'ed before they can be called. - @SuppressWarnings("MismatchedQueryAndUpdateOfCollection") - private final List targetReferences = new ArrayList<>(); - - private long appId = MessageState.ALL_MESSAGES; - - public MessagesModel(Activity parentView) { - settings = new Settings(parentView); - picassoHandler = new PicassoHandler(parentView, settings); - client = - ClientFactory.clientToken(settings.url(), settings.sslSettings(), settings.token()); - appsHolder = new ApplicationHolder(parentView, client); - messages = new MessageFacade(client.createService(MessageApi.class), appsHolder); - } - - public Settings getSettings() { - return settings; - } - - public PicassoHandler getPicassoHandler() { - return picassoHandler; - } - - public ApiClient getClient() { - return client; - } - - public ApplicationHolder getAppsHolder() { - return appsHolder; - } - - public MessageFacade getMessages() { - return messages; - } - - public List getTargetReferences() { - return targetReferences; - } - - public long getAppId() { - return appId; - } - - public void setAppId(long appId) { - this.appId = appId; - } -} diff --git a/app/src/main/java/com/github/gotify/messages/MessagesModel.kt b/app/src/main/java/com/github/gotify/messages/MessagesModel.kt new file mode 100644 index 0000000..bf8377c --- /dev/null +++ b/app/src/main/java/com/github/gotify/messages/MessagesModel.kt @@ -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() + + 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) + } +} diff --git a/app/src/main/java/com/github/gotify/messages/MessagesModelFactory.java b/app/src/main/java/com/github/gotify/messages/MessagesModelFactory.java deleted file mode 100644 index ec28f8b..0000000 --- a/app/src/main/java/com/github/gotify/messages/MessagesModelFactory.java +++ /dev/null @@ -1,28 +0,0 @@ -package com.github.gotify.messages; - -import android.app.Activity; -import androidx.annotation.NonNull; -import androidx.lifecycle.ViewModel; -import androidx.lifecycle.ViewModelProvider; -import java.util.Objects; - -public class MessagesModelFactory implements ViewModelProvider.Factory { - - Activity modelParameterActivity; - - public MessagesModelFactory(Activity activity) { - modelParameterActivity = activity; - } - - @NonNull - @Override - public T create(@NonNull Class modelClass) { - if (modelClass == MessagesModel.class) { - return Objects.requireNonNull( - modelClass.cast(new MessagesModel(modelParameterActivity))); - } - throw new IllegalArgumentException( - String.format( - "modelClass parameter must be of type %s", MessagesModel.class.getName())); - } -} diff --git a/app/src/main/java/com/github/gotify/messages/MessagesModelFactory.kt b/app/src/main/java/com/github/gotify/messages/MessagesModelFactory.kt new file mode 100644 index 0000000..443c987 --- /dev/null +++ b/app/src/main/java/com/github/gotify/messages/MessagesModelFactory.kt @@ -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 create(modelClass: Class): T { + if (modelClass == MessagesModel::class.java) { + return modelClass.cast(MessagesModel(modelParameterActivity)) as T + } + throw IllegalArgumentException( + "modelClass parameter must be of type ${MessagesModel::class.java.name}" + ) + } +} diff --git a/app/src/main/java/com/github/gotify/messages/provider/ApplicationHolder.java b/app/src/main/java/com/github/gotify/messages/provider/ApplicationHolder.java deleted file mode 100644 index 043c757..0000000 --- a/app/src/main/java/com/github/gotify/messages/provider/ApplicationHolder.java +++ /dev/null @@ -1,56 +0,0 @@ -package com.github.gotify.messages.provider; - -import android.app.Activity; -import com.github.gotify.Utils; -import com.github.gotify.api.ApiException; -import com.github.gotify.api.Callback; -import com.github.gotify.client.ApiClient; -import com.github.gotify.client.api.ApplicationApi; -import com.github.gotify.client.model.Application; -import java.util.Collections; -import java.util.List; - -public class ApplicationHolder { - private List state; - private Runnable onUpdate; - private Runnable onUpdateFailed; - private Activity activity; - private ApiClient client; - - public ApplicationHolder(Activity activity, ApiClient client) { - this.activity = activity; - this.client = client; - } - - public boolean wasRequested() { - return state != null; - } - - public void request() { - client.createService(ApplicationApi.class) - .getApps() - .enqueue(Callback.callInUI(activity, this::onReceiveApps, this::onFailedApps)); - } - - private void onReceiveApps(List apps) { - state = apps; - if (onUpdate != null) onUpdate.run(); - } - - private void onFailedApps(ApiException e) { - Utils.showSnackBar(activity, "Could not request applications, see logs."); - if (onUpdateFailed != null) onUpdateFailed.run(); - } - - public List get() { - return state == null ? Collections.emptyList() : state; - } - - public void onUpdate(Runnable runnable) { - this.onUpdate = runnable; - } - - public void onUpdateFailed(Runnable runnable) { - this.onUpdateFailed = runnable; - } -} diff --git a/app/src/main/java/com/github/gotify/messages/provider/ApplicationHolder.kt b/app/src/main/java/com/github/gotify/messages/provider/ApplicationHolder.kt new file mode 100644 index 0000000..84e1139 --- /dev/null +++ b/app/src/main/java/com/github/gotify/messages/provider/ApplicationHolder.kt @@ -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 = 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 -> + onReceiveApps( + apps + ) + } + ) { e: ApiException -> onFailedApps(e) }) + } + + private fun onReceiveApps(apps: List) { + 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 + } +} diff --git a/app/src/main/java/com/github/gotify/messages/provider/MessageDeletion.java b/app/src/main/java/com/github/gotify/messages/provider/MessageDeletion.java deleted file mode 100644 index 471f77b..0000000 --- a/app/src/main/java/com/github/gotify/messages/provider/MessageDeletion.java +++ /dev/null @@ -1,27 +0,0 @@ -package com.github.gotify.messages.provider; - -import com.github.gotify.client.model.Message; - -public final class MessageDeletion { - private final Message message; - private final int allPosition; - private final int appPosition; - - public MessageDeletion(Message message, int allPosition, int appPosition) { - this.message = message; - this.allPosition = allPosition; - this.appPosition = appPosition; - } - - public int getAllPosition() { - return allPosition; - } - - public int getAppPosition() { - return appPosition; - } - - public Message getMessage() { - return message; - } -} diff --git a/app/src/main/java/com/github/gotify/messages/provider/MessageDeletion.kt b/app/src/main/java/com/github/gotify/messages/provider/MessageDeletion.kt new file mode 100644 index 0000000..f357b42 --- /dev/null +++ b/app/src/main/java/com/github/gotify/messages/provider/MessageDeletion.kt @@ -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 +) diff --git a/app/src/main/java/com/github/gotify/messages/provider/MessageFacade.java b/app/src/main/java/com/github/gotify/messages/provider/MessageFacade.java deleted file mode 100644 index 992bfba..0000000 --- a/app/src/main/java/com/github/gotify/messages/provider/MessageFacade.java +++ /dev/null @@ -1,84 +0,0 @@ -package com.github.gotify.messages.provider; - -import com.github.gotify.client.api.MessageApi; -import com.github.gotify.client.model.Message; -import com.github.gotify.client.model.PagedMessages; -import java.util.List; - -public class MessageFacade { - private final ApplicationHolder applicationHolder; - private final MessageRequester requester; - private final MessageStateHolder state; - private final MessageImageCombiner combiner; - - public MessageFacade(MessageApi api, ApplicationHolder applicationHolder) { - this.applicationHolder = applicationHolder; - this.requester = new MessageRequester(api); - this.combiner = new MessageImageCombiner(); - this.state = new MessageStateHolder(); - } - - public synchronized List get(long appId) { - return combiner.combine(state.state(appId).messages, applicationHolder.get()); - } - - public synchronized void addMessages(List messages) { - for (Message message : messages) { - state.newMessage(message); - } - } - - public synchronized List loadMore(long appId) { - MessageState state = this.state.state(appId); - if (state.hasNext || !state.loaded) { - PagedMessages pagedMessages = requester.loadMore(state); - if (pagedMessages != null) { - this.state.newMessages(appId, pagedMessages); - } - } - return get(appId); - } - - public synchronized void loadMoreIfNotPresent(long appId) { - MessageState state = this.state.state(appId); - if (!state.loaded) { - loadMore(appId); - } - } - - public synchronized void clear() { - this.state.clear(); - } - - public long getLastReceivedMessage() { - return state.getLastReceivedMessage(); - } - - public synchronized void deleteLocal(Message message) { - // If there is already a deletion pending, that one should be executed before scheduling the - // next deletion. - if (this.state.deletionPending()) commitDelete(); - this.state.deleteMessage(message); - } - - public synchronized void commitDelete() { - if (this.state.deletionPending()) { - MessageDeletion deletion = this.state.purgePendingDeletion(); - this.requester.asyncRemoveMessage(deletion.getMessage()); - } - } - - public synchronized MessageDeletion undoDeleteLocal() { - return this.state.undoPendingDeletion(); - } - - public synchronized boolean deleteAll(long appId) { - boolean success = this.requester.deleteAll(appId); - this.state.deleteAll(appId); - return success; - } - - public synchronized boolean canLoadMore(long appId) { - return state.state(appId).hasNext; - } -} diff --git a/app/src/main/java/com/github/gotify/messages/provider/MessageFacade.kt b/app/src/main/java/com/github/gotify/messages/provider/MessageFacade.kt new file mode 100644 index 0000000..f0b338c --- /dev/null +++ b/app/src/main/java/com/github/gotify/messages/provider/MessageFacade.kt @@ -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 { + return MessageImageCombiner.combine(state.state(appId).messages, applicationHolder.get()) + } + + @Synchronized + fun addMessages(messages: List) { + messages.forEach { + state.newMessage(it) + } + } + + @Synchronized + fun loadMore(appId: Long): List { + val state = state.state(appId) + if (state.hasNext || !state.loaded) { + val pagedMessages = requester.loadMore(state) + if (pagedMessages != null) { + this.state.newMessages(appId, pagedMessages) + } + } + return get(appId) + } + + @Synchronized + fun loadMoreIfNotPresent(appId: Long) { + val state = state.state(appId) + if (!state.loaded) { + loadMore(appId) + } + } + + @Synchronized + fun clear() { + state.clear() + } + + fun getLastReceivedMessage(): Long = state.lastReceivedMessage + + @Synchronized + fun deleteLocal(message: Message) { + // If there is already a deletion pending, that one should be executed before scheduling the + // next deletion. + if (state.deletionPending()) commitDelete() + state.deleteMessage(message) + } + + @Synchronized + fun commitDelete() { + if (state.deletionPending()) { + val deletion = state.purgePendingDeletion() + requester.asyncRemoveMessage(deletion!!.message) + } + } + + @Synchronized + fun undoDeleteLocal(): MessageDeletion? = state.undoPendingDeletion() + + @Synchronized + fun deleteAll(appId: Long): Boolean { + val success = requester.deleteAll(appId) + state.deleteAll(appId) + return success + } + + @Synchronized + fun canLoadMore(appId: Long): Boolean = state.state(appId).hasNext +} diff --git a/app/src/main/java/com/github/gotify/messages/provider/MessageImageCombiner.java b/app/src/main/java/com/github/gotify/messages/provider/MessageImageCombiner.java deleted file mode 100644 index a258b02..0000000 --- a/app/src/main/java/com/github/gotify/messages/provider/MessageImageCombiner.java +++ /dev/null @@ -1,36 +0,0 @@ -package com.github.gotify.messages.provider; - -import com.github.gotify.client.model.Application; -import com.github.gotify.client.model.Message; -import java.util.ArrayList; -import java.util.List; -import java.util.Map; -import java.util.concurrent.ConcurrentHashMap; - -public class MessageImageCombiner { - - List combine(List messages, List applications) { - Map appIdToImage = appIdToImage(applications); - - List result = new ArrayList<>(); - - for (Message message : messages) { - MessageWithImage messageWithImage = new MessageWithImage(); - - messageWithImage.message = message; - messageWithImage.image = appIdToImage.get(message.getAppid()); - - result.add(messageWithImage); - } - - return result; - } - - public static Map appIdToImage(List applications) { - Map map = new ConcurrentHashMap<>(); - for (Application app : applications) { - map.put(app.getId(), app.getImage()); - } - return map; - } -} diff --git a/app/src/main/java/com/github/gotify/messages/provider/MessageImageCombiner.kt b/app/src/main/java/com/github/gotify/messages/provider/MessageImageCombiner.kt new file mode 100644 index 0000000..90953e9 --- /dev/null +++ b/app/src/main/java/com/github/gotify/messages/provider/MessageImageCombiner.kt @@ -0,0 +1,26 @@ +package com.github.gotify.messages.provider + +import com.github.gotify.client.model.Application +import com.github.gotify.client.model.Message + +object MessageImageCombiner { + fun combine(messages: List, applications: List): List { + val appIdToImage = appIdToImage(applications) + val result = mutableListOf() + messages.forEach { + val messageWithImage = MessageWithImage() + messageWithImage.message = it + messageWithImage.image = appIdToImage[it.appid]!! + result.add(messageWithImage) + } + return result + } + + fun appIdToImage(applications: List): Map { + val map = mutableMapOf() + applications.forEach { + map[it.id] = it.image + } + return map + } +} diff --git a/app/src/main/java/com/github/gotify/messages/provider/MessageRequester.java b/app/src/main/java/com/github/gotify/messages/provider/MessageRequester.java deleted file mode 100644 index 707ffc8..0000000 --- a/app/src/main/java/com/github/gotify/messages/provider/MessageRequester.java +++ /dev/null @@ -1,52 +0,0 @@ -package com.github.gotify.messages.provider; - -import com.github.gotify.api.Api; -import com.github.gotify.api.ApiException; -import com.github.gotify.api.Callback; -import com.github.gotify.client.api.MessageApi; -import com.github.gotify.client.model.Message; -import com.github.gotify.client.model.PagedMessages; -import com.github.gotify.log.Log; - -class MessageRequester { - private static final Integer LIMIT = 100; - private MessageApi messageApi; - - MessageRequester(MessageApi messageApi) { - this.messageApi = messageApi; - } - - PagedMessages loadMore(MessageState state) { - try { - Log.i("Loading more messages for " + state.appId); - if (MessageState.ALL_MESSAGES == state.appId) { - return Api.execute(messageApi.getMessages(LIMIT, state.nextSince)); - } else { - return Api.execute(messageApi.getAppMessages(state.appId, LIMIT, state.nextSince)); - } - } catch (ApiException apiException) { - Log.e("failed requesting messages", apiException); - return null; - } - } - - void asyncRemoveMessage(Message message) { - Log.i("Removing message with id " + message.getId()); - messageApi.deleteMessage(message.getId()).enqueue(Callback.call()); - } - - boolean deleteAll(Long appId) { - try { - Log.i("Deleting all messages for " + appId); - if (MessageState.ALL_MESSAGES == appId) { - Api.execute(messageApi.deleteMessages()); - } else { - Api.execute(messageApi.deleteAppMessages(appId)); - } - return true; - } catch (ApiException e) { - Log.e("Could not delete messages", e); - return false; - } - } -} diff --git a/app/src/main/java/com/github/gotify/messages/provider/MessageRequester.kt b/app/src/main/java/com/github/gotify/messages/provider/MessageRequester.kt new file mode 100644 index 0000000..9f61f7b --- /dev/null +++ b/app/src/main/java/com/github/gotify/messages/provider/MessageRequester.kt @@ -0,0 +1,49 @@ +package com.github.gotify.messages.provider + +import com.github.gotify.api.Api +import com.github.gotify.api.ApiException +import com.github.gotify.api.Callback +import com.github.gotify.client.api.MessageApi +import com.github.gotify.client.model.Message +import com.github.gotify.client.model.PagedMessages +import com.github.gotify.log.Log + +internal class MessageRequester(private val messageApi: MessageApi) { + fun loadMore(state: MessageState): PagedMessages? { + return try { + Log.i("Loading more messages for " + state.appId) + if (MessageState.ALL_MESSAGES == state.appId) { + Api.execute(messageApi.getMessages(LIMIT, state.nextSince)) + } else { + Api.execute(messageApi.getAppMessages(state.appId, LIMIT, state.nextSince)) + } + } catch (apiException: ApiException) { + Log.e("failed requesting messages", apiException) + null + } + } + + fun asyncRemoveMessage(message: Message) { + Log.i("Removing message with id " + message.id) + messageApi.deleteMessage(message.id).enqueue(Callback.call()) + } + + fun deleteAll(appId: Long): Boolean { + return try { + Log.i("Deleting all messages for $appId") + if (MessageState.ALL_MESSAGES == appId) { + Api.execute(messageApi.deleteMessages()) + } else { + Api.execute(messageApi.deleteAppMessages(appId)) + } + true + } catch (e: ApiException) { + Log.e("Could not delete messages", e) + false + } + } + + companion object { + private const val LIMIT = 100 + } +} diff --git a/app/src/main/java/com/github/gotify/messages/provider/MessageState.java b/app/src/main/java/com/github/gotify/messages/provider/MessageState.java deleted file mode 100644 index d90da5f..0000000 --- a/app/src/main/java/com/github/gotify/messages/provider/MessageState.java +++ /dev/null @@ -1,15 +0,0 @@ -package com.github.gotify.messages.provider; - -import com.github.gotify.client.model.Message; -import java.util.ArrayList; -import java.util.List; - -public class MessageState { - public static final long ALL_MESSAGES = -1; - - long appId; - boolean loaded; - boolean hasNext; - long nextSince = 0; - List messages = new ArrayList<>(); -} diff --git a/app/src/main/java/com/github/gotify/messages/provider/MessageState.kt b/app/src/main/java/com/github/gotify/messages/provider/MessageState.kt new file mode 100644 index 0000000..1694080 --- /dev/null +++ b/app/src/main/java/com/github/gotify/messages/provider/MessageState.kt @@ -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() + + companion object { + const val ALL_MESSAGES = -1L + } +} diff --git a/app/src/main/java/com/github/gotify/messages/provider/MessageStateHolder.java b/app/src/main/java/com/github/gotify/messages/provider/MessageStateHolder.java deleted file mode 100644 index 4065d46..0000000 --- a/app/src/main/java/com/github/gotify/messages/provider/MessageStateHolder.java +++ /dev/null @@ -1,134 +0,0 @@ -package com.github.gotify.messages.provider; - -import com.github.gotify.client.model.Message; -import com.github.gotify.client.model.PagedMessages; -import java.util.HashMap; -import java.util.Map; - -class MessageStateHolder { - private long lastReceivedMessage = -1; - private Map states = new HashMap<>(); - - private MessageDeletion pendingDeletion = null; - - synchronized void clear() { - states = new HashMap<>(); - } - - synchronized void newMessages(Long appId, PagedMessages pagedMessages) { - MessageState state = state(appId); - - if (!state.loaded && pagedMessages.getMessages().size() > 0) { - lastReceivedMessage = - Math.max(pagedMessages.getMessages().get(0).getId(), lastReceivedMessage); - } - - state.loaded = true; - state.messages.addAll(pagedMessages.getMessages()); - state.hasNext = pagedMessages.getPaging().getNext() != null; - state.nextSince = pagedMessages.getPaging().getSince(); - state.appId = appId; - states.put(appId, state); - - // If there is a message with pending deletion, it should not reappear in the list in case - // it is added again. - if (deletionPending()) { - deleteMessage(pendingDeletion.getMessage()); - } - } - - synchronized void newMessage(Message message) { - // If there is a message with pending deletion, its indices are going to change. To keep - // them consistent the deletion is undone first and redone again after adding the new - // message. - MessageDeletion deletion = undoPendingDeletion(); - - addMessage(message, 0, 0); - lastReceivedMessage = message.getId(); - - if (deletion != null) deleteMessage(deletion.getMessage()); - } - - synchronized MessageState state(Long appId) { - MessageState state = states.get(appId); - if (state == null) { - return emptyState(appId); - } - return state; - } - - synchronized void deleteAll(Long appId) { - clear(); - MessageState state = state(appId); - state.loaded = true; - states.put(appId, state); - } - - private MessageState emptyState(Long appId) { - MessageState emptyState = new MessageState(); - emptyState.loaded = false; - emptyState.hasNext = false; - emptyState.nextSince = 0; - emptyState.appId = appId; - return emptyState; - } - - synchronized long getLastReceivedMessage() { - return lastReceivedMessage; - } - - synchronized void deleteMessage(Message message) { - MessageState allMessages = state(MessageState.ALL_MESSAGES); - MessageState appMessages = state(message.getAppid()); - - int pendingDeletedAllPosition = -1; - int pendingDeletedAppPosition = -1; - - if (allMessages.loaded) { - int allPosition = allMessages.messages.indexOf(message); - if (allPosition != -1) allMessages.messages.remove(allPosition); - pendingDeletedAllPosition = allPosition; - } - - if (appMessages.loaded) { - int appPosition = appMessages.messages.indexOf(message); - if (appPosition != -1) appMessages.messages.remove(appPosition); - pendingDeletedAppPosition = appPosition; - } - - pendingDeletion = - new MessageDeletion(message, pendingDeletedAllPosition, pendingDeletedAppPosition); - } - - synchronized MessageDeletion undoPendingDeletion() { - if (pendingDeletion != null) - addMessage( - pendingDeletion.getMessage(), - pendingDeletion.getAllPosition(), - pendingDeletion.getAppPosition()); - return purgePendingDeletion(); - } - - synchronized MessageDeletion purgePendingDeletion() { - MessageDeletion result = pendingDeletion; - pendingDeletion = null; - return result; - } - - synchronized boolean deletionPending() { - return pendingDeletion != null; - } - - private void addMessage(Message message, int allPosition, int appPosition) { - MessageState allMessages = state(MessageState.ALL_MESSAGES); - MessageState appMessages = state(message.getAppid()); - - if (allMessages.loaded && allPosition != -1) { - allMessages.messages.add(allPosition, message); - } - - if (appMessages.loaded && appPosition != -1) { - appMessages.messages.add(appPosition, message); - } - } -} diff --git a/app/src/main/java/com/github/gotify/messages/provider/MessageStateHolder.kt b/app/src/main/java/com/github/gotify/messages/provider/MessageStateHolder.kt new file mode 100644 index 0000000..d8966ba --- /dev/null +++ b/app/src/main/java/com/github/gotify/messages/provider/MessageStateHolder.kt @@ -0,0 +1,131 @@ +package com.github.gotify.messages.provider + +import com.github.gotify.client.model.Message +import com.github.gotify.client.model.PagedMessages +import kotlin.math.max + +internal class MessageStateHolder { + @get:Synchronized + var lastReceivedMessage = -1L + private set + private var states = mutableMapOf() + private var pendingDeletion: MessageDeletion? = null + + @Synchronized + fun clear() { + states = mutableMapOf() + } + + @Synchronized + fun newMessages(appId: Long, pagedMessages: PagedMessages) { + val state = state(appId) + + if (!state.loaded && pagedMessages.messages.size > 0) { + lastReceivedMessage = max(pagedMessages.messages[0].id, lastReceivedMessage) + } + + state.apply { + loaded = true + messages.addAll(pagedMessages.messages) + hasNext = pagedMessages.paging.next != null + nextSince = pagedMessages.paging.since + this.appId = appId + } + states[appId] = state + + // If there is a message with pending deletion, it should not reappear in the list in case + // it is added again. + if (deletionPending()) { + deleteMessage(pendingDeletion!!.message) + } + } + + @Synchronized + fun newMessage(message: Message) { + // If there is a message with pending deletion, its indices are going to change. To keep + // them consistent the deletion is undone first and redone again after adding the new + // message. + val deletion = undoPendingDeletion() + addMessage(message, 0, 0) + lastReceivedMessage = message.id + if (deletion != null) deleteMessage(deletion.message) + } + + @Synchronized + fun state(appId: Long): MessageState = states[appId] ?: emptyState(appId) + + @Synchronized + fun deleteAll(appId: Long) { + clear() + val state = state(appId) + state.loaded = true + states[appId] = state + } + + private fun emptyState(appId: Long): MessageState { + return MessageState().apply { + loaded = false + hasNext = false + nextSince = 0 + this.appId = appId + } + } + + @Synchronized + fun deleteMessage(message: Message) { + val allMessages = state(MessageState.ALL_MESSAGES) + val appMessages = state(message.appid) + var pendingDeletedAllPosition = -1 + var pendingDeletedAppPosition = -1 + + if (allMessages.loaded) { + val allPosition = allMessages.messages.indexOf(message) + if (allPosition != -1) allMessages.messages.removeAt(allPosition) + pendingDeletedAllPosition = allPosition + } + if (appMessages.loaded) { + val appPosition = appMessages.messages.indexOf(message) + if (appPosition != -1) appMessages.messages.removeAt(appPosition) + pendingDeletedAppPosition = appPosition + } + pendingDeletion = MessageDeletion( + message, + pendingDeletedAllPosition, + pendingDeletedAppPosition + ) + } + + @Synchronized + fun undoPendingDeletion(): MessageDeletion? { + if (pendingDeletion != null) { + addMessage( + pendingDeletion!!.message, + pendingDeletion!!.allPosition, + pendingDeletion!!.appPosition + ) + } + return purgePendingDeletion() + } + + @Synchronized + fun purgePendingDeletion(): MessageDeletion? { + val result = pendingDeletion + pendingDeletion = null + return result + } + + @Synchronized + fun deletionPending(): Boolean = pendingDeletion != null + + private fun addMessage(message: Message, allPosition: Int, appPosition: Int) { + val allMessages = state(MessageState.ALL_MESSAGES) + val appMessages = state(message.appid) + + if (allMessages.loaded && allPosition != -1) { + allMessages.messages.add(allPosition, message) + } + if (appMessages.loaded && appPosition != -1) { + appMessages.messages.add(appPosition, message) + } + } +} diff --git a/app/src/main/java/com/github/gotify/messages/provider/MessageWithImage.java b/app/src/main/java/com/github/gotify/messages/provider/MessageWithImage.java deleted file mode 100644 index 6a7ef47..0000000 --- a/app/src/main/java/com/github/gotify/messages/provider/MessageWithImage.java +++ /dev/null @@ -1,8 +0,0 @@ -package com.github.gotify.messages.provider; - -import com.github.gotify.client.model.Message; - -public class MessageWithImage { - public Message message; - public String image; -} diff --git a/app/src/main/java/com/github/gotify/messages/provider/MessageWithImage.kt b/app/src/main/java/com/github/gotify/messages/provider/MessageWithImage.kt new file mode 100644 index 0000000..9690e06 --- /dev/null +++ b/app/src/main/java/com/github/gotify/messages/provider/MessageWithImage.kt @@ -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 +}