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 android.widget.ViewFlipper; import androidx.annotation.NonNull; import androidx.appcompat.app.ActionBarDrawerToggle; import androidx.appcompat.app.AlertDialog; import androidx.appcompat.app.AppCompatActivity; import androidx.appcompat.view.ContextThemeWrapper; import androidx.appcompat.widget.Toolbar; import androidx.core.content.ContextCompat; import androidx.core.graphics.drawable.DrawableCompat; import androidx.core.view.GravityCompat; import androidx.drawerlayout.widget.DrawerLayout; 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 butterknife.BindView; import butterknife.ButterKnife; import butterknife.OnClick; 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.ClientFactory; import com.github.gotify.client.ApiClient; 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.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.picasso.PicassoHandler; import com.github.gotify.service.WebSocketService; import com.github.gotify.settings.SettingsActivity; 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.ArrayList; import java.util.Arrays; import java.util.List; import static java.util.Collections.emptyList; public class MessagesActivity extends AppCompatActivity implements NavigationView.OnNavigationItemSelectedListener { private 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 int APPLICATION_ORDER = 1; @BindView(R.id.toolbar) Toolbar toolbar; @BindView(R.id.drawer_layout) DrawerLayout drawer; @BindView(R.id.nav_view) NavigationView navigationView; @BindView(R.id.messages_view) RecyclerView messagesView; @BindView(R.id.swipe_refresh) SwipeRefreshLayout swipeRefreshLayout; @BindView(R.id.flipper) ViewFlipper flipper; private MessageFacade messages; private ApiClient client; private Settings settings; protected ApplicationHolder appsHolder; private int appId = MessageState.ALL_MESSAGES; private boolean isLoadMore = false; private Integer selectAppIdOnDrawerClose = null; private PicassoHandler picassoHandler; // we need to keep the target references otherwise they get gc'ed before they can be called. @SuppressWarnings("MismatchedQueryAndUpdateOfCollection") private final List<Target> targetReferences = new ArrayList<>(); @Override protected void onCreate(Bundle savedInstanceState) { super.onCreate(savedInstanceState); setContentView(R.layout.activity_messages); ButterKnife.bind(this); Log.i("Entering " + getClass().getSimpleName()); settings = new Settings(this); picassoHandler = new PicassoHandler(this, settings); client = ClientFactory.clientToken(settings.url(), settings.sslSettings(), settings.token()); appsHolder = new ApplicationHolder(this, client); appsHolder.onUpdate(() -> onUpdateApps(appsHolder.get())); appsHolder.request(); initDrawer(); messages = new MessageFacade(client.createService(MessageApi.class), appsHolder); LinearLayoutManager layoutManager = new LinearLayoutManager(this); DividerItemDecoration dividerItemDecoration = new DividerItemDecoration( messagesView.getContext(), layoutManager.getOrientation()); ListMessageAdapter adapter = new ListMessageAdapter( this, settings, picassoHandler.get(), emptyList(), this::scheduleDeletion); messagesView.addItemDecoration(dividerItemDecoration); messagesView.setHasFixedSize(true); messagesView.setLayoutManager(layoutManager); messagesView.addOnScrollListener(new MessageListOnScrollListener()); messagesView.setAdapter(adapter); ItemTouchHelper itemTouchHelper = new ItemTouchHelper(new SwipeToDeleteCallback(adapter)); itemTouchHelper.attachToRecyclerView(messagesView); swipeRefreshLayout.setOnRefreshListener(this::onRefresh); drawer.addDrawerListener( new DrawerLayout.SimpleDrawerListener() { @Override public void onDrawerClosed(View drawerView) { if (selectAppIdOnDrawerClose != null) { appId = selectAppIdOnDrawerClose; new SelectApplicationAndUpdateMessages(true) .execute(selectAppIdOnDrawerClose); selectAppIdOnDrawerClose = null; } } }); swipeRefreshLayout.setEnabled(false); messagesView .getViewTreeObserver() .addOnScrollChangedListener( () -> { View topChild = messagesView.getChildAt(0); if (topChild != null) { swipeRefreshLayout.setEnabled(topChild.getTop() == 0); } else { swipeRefreshLayout.setEnabled(true); } }); new SelectApplicationAndUpdateMessages(true).execute(appId); } public void onRefreshAll(View view) { try { picassoHandler.evict(); } catch (IOException e) { Log.e("Problem evicting Picasso cache", e); } startActivity(new Intent(this, InitializationActivity.class)); finish(); } private void onRefresh() { messages.clear(); new LoadMore().execute(appId); } @OnClick(R.id.learn_gotify) public void openDocumentation() { Intent browserIntent = new Intent(Intent.ACTION_VIEW, Uri.parse("https://gotify.net/docs/pushmsg")); startActivity(browserIntent); } public void commitDelete() { new CommitDeleteMessage().execute(); } protected void onUpdateApps(List<Application> applications) { Menu menu = navigationView.getMenu(); menu.removeGroup(R.id.apps); targetReferences.clear(); updateMessagesAndStopLoading(messages.get(appId)); for (Application app : applications) { MenuItem item = menu.add(R.id.apps, app.getId(), APPLICATION_ORDER, app.getName()); item.setCheckable(true); Target t = Utils.toDrawable(getResources(), item::setIcon); targetReferences.add(t); picassoHandler .get() .load(Utils.resolveAbsoluteUrl(settings.url() + "/", app.getImage())) .error(R.drawable.ic_alarm) .placeholder(R.drawable.ic_placeholder) .resize(100, 100) .into(t); } } private void initDrawer() { setSupportActionBar(toolbar); navigationView.setItemIconTintList(null); ActionBarDrawerToggle toggle = new ActionBarDrawerToggle( this, drawer, toolbar, R.string.navigation_drawer_open, R.string.navigation_drawer_close); drawer.addDrawerListener(toggle); toggle.syncState(); navigationView.setNavigationItemSelectedListener(this); View headerView = navigationView.getHeaderView(0); 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 (drawer.isDrawerOpen(GravityCompat.START)) { drawer.closeDrawer(GravityCompat.START); } else { super.onBackPressed(); } } @SuppressWarnings("StatementWithEmptyBody") @Override public boolean onNavigationItemSelected(MenuItem item) { // Handle navigation view item clicks here. int id = item.getItemId(); if (item.getGroupId() == R.id.apps) { selectAppIdOnDrawerClose = id; startLoading(); toolbar.setSubtitle(item.getTitle()); } else if (id == R.id.nav_all_messages) { selectAppIdOnDrawerClose = MessageState.ALL_MESSAGES; startLoading(); 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)); } drawer.closeDrawer(GravityCompat.START); return true; } public void doLogout(DialogInterface dialog, int which) { setContentView(R.layout.splash); new DeleteClientAndNavigateToLogin().execute(); } private void startLoading() { swipeRefreshLayout.setRefreshing(true); messagesView.setVisibility(View.GONE); } private void stopLoading() { swipeRefreshLayout.setRefreshing(false); messagesView.setVisibility(View.VISIBLE); } @Override protected void onResume() { Context context = getApplicationContext(); NotificationManager nManager = ((NotificationManager) context.getSystemService(Context.NOTIFICATION_SERVICE)); nManager.cancelAll(); IntentFilter filter = new IntentFilter(); filter.addAction(WebSocketService.NEW_MESSAGE_BROADCAST); registerReceiver(receiver, filter); new UpdateMissedMessages().execute(messages.getLastReceivedMessage()); navigationView .getMenu() .findItem(appId == MessageState.ALL_MESSAGES ? R.id.nav_all_messages : appId) .setChecked(true); super.onResume(); } @Override protected void onPause() { unregisterReceiver(receiver); super.onPause(); } @Override protected void onDestroy() { super.onDestroy(); picassoHandler.get().shutdown(); } private void scheduleDeletion(int position, Message message, boolean listAnimation) { ListMessageAdapter adapter = (ListMessageAdapter) messagesView.getAdapter(); messages.deleteLocal(message); adapter.setItems(messages.get(appId)); if (listAnimation) adapter.notifyItemRemoved(position); else adapter.notifyDataSetChanged(); showDeletionSnackbar(); } private void undoDelete() { MessageDeletion deletion = messages.undoDeleteLocal(); if (deletion != null) { ListMessageAdapter adapter = (ListMessageAdapter) messagesView.getAdapter(); adapter.setItems(messages.get(appId)); int insertPosition = appId == MessageState.ALL_MESSAGES ? deletion.getAllPosition() : deletion.getAppPosition(); adapter.notifyItemInserted(insertPosition); } } private void showDeletionSnackbar() { View view = swipeRefreshLayout; Snackbar snackbar = Snackbar.make(view, R.string.snackbar_deleted, Snackbar.LENGTH_LONG); snackbar.setAction(R.string.snackbar_undo, v -> undoDelete()); snackbar.addCallback(new SnackbarCallback()); snackbar.show(); } private class SnackbarCallback extends BaseTransientBottomBar.BaseCallback<Snackbar> { @Override public void onDismissed(Snackbar transientBottomBar, int event) { super.onDismissed(transientBottomBar, event); if (event != DISMISS_EVENT_ACTION && event != DISMISS_EVENT_CONSECUTIVE) { // Execute deletion when the snackbar disappeared without pressing the undo button // DISMISS_EVENT_CONSECUTIVE should be excluded as well, because it would cause the // deletion to be sent to the server twice, since the deletion is sent to the server // in MessageFacade if a message is deleted while another message was already // waiting for deletion. MessagesActivity.this.commitDelete(); } } } private class SwipeToDeleteCallback extends ItemTouchHelper.SimpleCallback { private 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(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 && messages.canLoadMore(appId)) { if (!isLoadMore) { isLoadMore = true; new LoadMore().execute(appId); } } } } } private class UpdateMissedMessages extends AsyncTask<Integer, Void, Boolean> { @Override protected Boolean doInBackground(Integer... ids) { Integer id = first(ids); if (id == -1) { return false; } List<Message> newMessages = new MissedMessageUtil(client.createService(MessageApi.class)) .missingMessages(id); messages.addMessages(newMessages); return !newMessages.isEmpty(); } @Override protected void onPostExecute(Boolean update) { if (update) { new SelectApplicationAndUpdateMessages(true).execute(appId); } } } @Override public boolean onCreateOptionsMenu(Menu menu) { getMenuInflater().inflate(R.menu.messages_action, menu); return super.onCreateOptionsMenu(menu); } @Override public boolean onOptionsItemSelected(MenuItem item) { if (item.getItemId() == R.id.action_delete_all) { new DeleteMessages().execute(appId); } return super.onContextItemSelected(item); } private class LoadMore extends AsyncTask<Integer, Void, List<MessageWithImage>> { @Override protected List<MessageWithImage> doInBackground(Integer... appId) { return messages.loadMore(first(appId)); } @Override protected void onPostExecute(List<MessageWithImage> messageWithImages) { updateMessagesAndStopLoading(messageWithImages); } } private class SelectApplicationAndUpdateMessages extends AsyncTask<Integer, Void, Integer> { private SelectApplicationAndUpdateMessages(boolean withLoadingSpinner) { if (withLoadingSpinner) { startLoading(); } } @Override protected Integer doInBackground(Integer... appIds) { Integer appId = first(appIds); messages.loadMoreIfNotPresent(appId); return appId; } @Override protected void onPostExecute(Integer appId) { updateMessagesAndStopLoading(messages.get(appId)); } } private class NewSingleMessage extends AsyncTask<Message, Void, Void> { @Override protected Void doInBackground(Message... newMessages) { messages.addMessages(Arrays.asList(newMessages)); return null; } @Override protected void onPostExecute(Void data) { new SelectApplicationAndUpdateMessages(false).execute(appId); } } private class CommitDeleteMessage extends AsyncTask<Void, Void, Void> { @Override protected Void doInBackground(Void... messages) { MessagesActivity.this.messages.commitDelete(); return null; } @Override protected void onPostExecute(Void data) { new SelectApplicationAndUpdateMessages(false).execute(appId); } } private class DeleteMessages extends AsyncTask<Integer, Void, Boolean> { DeleteMessages() { startLoading(); } @Override protected Boolean doInBackground(Integer... appId) { return messages.deleteAll(first(appId)); } @Override protected void onPostExecute(Boolean success) { if (!success) { Utils.showSnackBar(MessagesActivity.this, "Delete failed :("); } new SelectApplicationAndUpdateMessages(false).execute(appId); } } private class DeleteClientAndNavigateToLogin extends AsyncTask<Void, Void, Void> { @Override protected Void doInBackground(Void... ignore) { ClientApi api = ClientFactory.clientToken( settings.url(), settings.sslSettings(), settings.token()) .createService(ClientApi.class); stopService(new Intent(MessagesActivity.this, WebSocketService.class)); try { List<Client> clients = Api.execute(api.getClients()); Client currentClient = null; for (Client client : clients) { if (client.getToken().equals(settings.token())) { currentClient = client; break; } } if (currentClient != null) { Log.i("Delete client with id " + currentClient.getId()); Api.execute(api.deleteClient(currentClient.getId())); } else { Log.e("Could not delete client, client does not exist."); } } catch (ApiException e) { Log.e("Could not delete client", e); } return null; } @Override protected void onPostExecute(Void aVoid) { settings.clear(); startActivity(new Intent(MessagesActivity.this, LoginActivity.class)); finish(); super.onPostExecute(aVoid); } } private void updateMessagesAndStopLoading(List<MessageWithImage> messageWithImages) { isLoadMore = false; stopLoading(); if (messageWithImages.isEmpty()) { flipper.setDisplayedChild(1); } else { flipper.setDisplayedChild(0); } ListMessageAdapter adapter = (ListMessageAdapter) messagesView.getAdapter(); adapter.setItems(messageWithImages); adapter.notifyDataSetChanged(); } private <T> T first(T[] data) { if (data.length != 1) { throw new IllegalArgumentException("must be one element"); } return data[0]; } }