package com.github.gotify.messages import android.app.NotificationManager import android.content.BroadcastReceiver import android.content.Context import android.content.Intent import android.content.IntentFilter import android.graphics.Canvas import android.graphics.drawable.ColorDrawable import android.graphics.drawable.Drawable import android.net.Uri import android.os.Bundle import android.view.Menu import android.view.MenuItem import android.view.View import android.widget.ImageButton import android.widget.TextView import androidx.appcompat.app.ActionBarDrawerToggle import androidx.appcompat.app.AppCompatActivity import androidx.core.content.ContextCompat import androidx.core.graphics.drawable.DrawableCompat import androidx.core.view.GravityCompat import androidx.drawerlayout.widget.DrawerLayout.SimpleDrawerListener import androidx.lifecycle.ViewModelProvider import androidx.recyclerview.widget.DividerItemDecoration import androidx.recyclerview.widget.ItemTouchHelper import androidx.recyclerview.widget.LinearLayoutManager import androidx.recyclerview.widget.RecyclerView import com.github.gotify.BuildConfig import com.github.gotify.MissedMessageUtil import com.github.gotify.R import com.github.gotify.Utils import com.github.gotify.Utils.launchCoroutine import com.github.gotify.api.Api import com.github.gotify.api.ApiException import com.github.gotify.api.Callback import com.github.gotify.api.ClientFactory import com.github.gotify.client.api.ApplicationApi import com.github.gotify.client.api.ClientApi import com.github.gotify.client.api.MessageApi import com.github.gotify.client.model.Application import com.github.gotify.client.model.Client import com.github.gotify.client.model.Message import com.github.gotify.databinding.ActivityMessagesBinding import com.github.gotify.init.InitializationActivity import com.github.gotify.log.Log import com.github.gotify.log.LogsActivity import com.github.gotify.login.LoginActivity import com.github.gotify.messages.provider.MessageState import com.github.gotify.messages.provider.MessageWithImage import com.github.gotify.service.WebSocketService import com.github.gotify.settings.SettingsActivity import com.github.gotify.sharing.ShareActivity import com.google.android.material.dialog.MaterialAlertDialogBuilder import com.google.android.material.navigation.NavigationView import com.google.android.material.snackbar.BaseTransientBottomBar.BaseCallback import com.google.android.material.snackbar.Snackbar import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.withContext import java.io.IOException internal class MessagesActivity : AppCompatActivity(), NavigationView.OnNavigationItemSelectedListener { private lateinit var binding: ActivityMessagesBinding private lateinit var viewModel: MessagesModel private var isLoadMore = false private var updateAppOnDrawerClose: Long? = null private lateinit var listMessageAdapter: ListMessageAdapter private val receiver: BroadcastReceiver = object : BroadcastReceiver() { override fun onReceive(context: Context, intent: Intent) { val messageJson = intent.getStringExtra("message") val message = Utils.JSON.fromJson( messageJson, Message::class.java ) launchCoroutine { addSingleMessage(message) } } } override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) binding = ActivityMessagesBinding.inflate(layoutInflater) setContentView(binding.root) viewModel = ViewModelProvider(this, MessagesModelFactory(this))[MessagesModel::class.java] Log.i("Entering " + javaClass.simpleName) initDrawer() val layoutManager = LinearLayoutManager(this) val messagesView: RecyclerView = binding.messagesView val dividerItemDecoration = DividerItemDecoration( messagesView.context, layoutManager.orientation ) listMessageAdapter = ListMessageAdapter( this, viewModel.settings, viewModel.picassoHandler.get(), emptyList() ) { position, message, listAnimation -> scheduleDeletion( position, message, listAnimation ) } messagesView.addItemDecoration(dividerItemDecoration) messagesView.setHasFixedSize(true) messagesView.layoutManager = layoutManager messagesView.addOnScrollListener(MessageListOnScrollListener()) messagesView.adapter = listMessageAdapter val appsHolder = viewModel.appsHolder appsHolder.onUpdate { onUpdateApps(appsHolder.get()) } if (appsHolder.wasRequested()) onUpdateApps(appsHolder.get()) else appsHolder.request() val itemTouchHelper = ItemTouchHelper(SwipeToDeleteCallback(listMessageAdapter)) itemTouchHelper.attachToRecyclerView(messagesView) val swipeRefreshLayout = binding.swipeRefresh swipeRefreshLayout.setOnRefreshListener { onRefresh() } binding.drawerLayout.addDrawerListener( object : SimpleDrawerListener() { override fun onDrawerClosed(drawerView: View) { updateAppOnDrawerClose?.let { selectApp -> updateAppOnDrawerClose = null viewModel.appId = selectApp launchCoroutine { updateMessagesForApplication(true, selectApp) } invalidateOptionsMenu() } } } ) swipeRefreshLayout.isEnabled = false messagesView .viewTreeObserver .addOnScrollChangedListener { val topChild = messagesView.getChildAt(0) if (topChild != null) { swipeRefreshLayout.isEnabled = topChild.top == 0 } else { swipeRefreshLayout.isEnabled = true } } launchCoroutine { updateMessagesForApplication(true, viewModel.appId) } } override fun onPostCreate(savedInstanceState: Bundle?) { super.onPostCreate(savedInstanceState) binding.learnGotify.setOnClickListener { openDocumentation() } } private fun refreshAll() { try { viewModel.picassoHandler.evict() } catch (e: IOException) { Log.e("Problem evicting Picasso cache", e) } startActivity(Intent(this, InitializationActivity::class.java)) finish() } private fun onRefresh() { viewModel.messages.clear() launchCoroutine { loadMore(viewModel.appId) } } private fun openDocumentation() { val browserIntent = Intent(Intent.ACTION_VIEW, Uri.parse("https://gotify.net/docs/pushmsg")) startActivity(browserIntent) } private fun onUpdateApps(applications: List) { val menu: Menu = binding.navView.menu menu.removeGroup(R.id.apps) viewModel.targetReferences.clear() updateMessagesAndStopLoading(viewModel.messages[viewModel.appId]) var selectedItem = menu.findItem(R.id.nav_all_messages) applications.indices.forEach { index -> val app = applications[index] val item = menu.add(R.id.apps, index, APPLICATION_ORDER, app.name) item.isCheckable = true if (app.id == viewModel.appId) selectedItem = item val t = Utils.toDrawable(resources) { icon -> item.icon = icon } viewModel.targetReferences.add(t) viewModel.picassoHandler .get() .load(Utils.resolveAbsoluteUrl(viewModel.settings.url + "/", app.image)) .error(R.drawable.ic_alarm) .placeholder(R.drawable.ic_placeholder) .resize(100, 100) .into(t) } selectAppInMenu(selectedItem) } private fun initDrawer() { setSupportActionBar(binding.appBarDrawer.toolbar) binding.navView.itemIconTintList = null val toggle = ActionBarDrawerToggle( this, binding.drawerLayout, binding.appBarDrawer.toolbar, R.string.navigation_drawer_open, R.string.navigation_drawer_close ) binding.drawerLayout.addDrawerListener(toggle) toggle.syncState() binding.navView.setNavigationItemSelectedListener(this) val headerView = binding.navView.getHeaderView(0) val settings = viewModel.settings val user = headerView.findViewById(R.id.header_user) user.text = settings.user?.name val connection = headerView.findViewById(R.id.header_connection) connection.text = getString(R.string.connection, settings.user?.name, settings.url) val version = headerView.findViewById(R.id.header_version) version.text = getString(R.string.versions, BuildConfig.VERSION_NAME, settings.serverVersion) val refreshAll = headerView.findViewById(R.id.refresh_all) refreshAll.setOnClickListener { refreshAll() } } override fun onBackPressed() { if (binding.drawerLayout.isDrawerOpen(GravityCompat.START)) { binding.drawerLayout.closeDrawer(GravityCompat.START) } else { super.onBackPressed() } } override fun onNavigationItemSelected(item: MenuItem): Boolean { // Handle navigation view item clicks here. val id = item.itemId if (item.groupId == R.id.apps) { val app = viewModel.appsHolder.get()[id] updateAppOnDrawerClose = app.id startLoading() binding.appBarDrawer.toolbar.subtitle = item.title } else if (id == R.id.nav_all_messages) { updateAppOnDrawerClose = MessageState.ALL_MESSAGES startLoading() binding.appBarDrawer.toolbar.subtitle = "" } else if (id == R.id.logout) { MaterialAlertDialogBuilder(this) .setTitle(R.string.logout) .setMessage(getString(R.string.logout_confirm)) .setPositiveButton(R.string.yes) { _, _ -> doLogout() } .setNegativeButton(R.string.cancel, null) .show() } else if (id == R.id.nav_logs) { startActivity(Intent(this, LogsActivity::class.java)) } else if (id == R.id.settings) { startActivity(Intent(this, SettingsActivity::class.java)) } else if (id == R.id.push_message) { val intent = Intent(this@MessagesActivity, ShareActivity::class.java) startActivity(intent) } binding.drawerLayout.closeDrawer(GravityCompat.START) return true } private fun doLogout() { setContentView(R.layout.splash) launchCoroutine { deleteClientAndNavigateToLogin() } } private fun startLoading() { binding.swipeRefresh.isRefreshing = true binding.messagesView.visibility = View.GONE } private fun stopLoading() { binding.swipeRefresh.isRefreshing = false binding.messagesView.visibility = View.VISIBLE } override fun onResume() { val context = applicationContext val nManager = context.getSystemService(NOTIFICATION_SERVICE) as NotificationManager nManager.cancelAll() val filter = IntentFilter() filter.addAction(WebSocketService.NEW_MESSAGE_BROADCAST) registerReceiver(receiver, filter) launchCoroutine { updateMissedMessages(viewModel.messages.getLastReceivedMessage()) } var selectedIndex = R.id.nav_all_messages val appId = viewModel.appId if (appId != MessageState.ALL_MESSAGES) { val apps = viewModel.appsHolder.get() apps.indices.forEach { index -> if (apps[index].id == appId) { selectedIndex = index } } } listMessageAdapter.notifyDataSetChanged() selectAppInMenu(binding.navView.menu.findItem(selectedIndex)) super.onResume() } override fun onPause() { unregisterReceiver(receiver) super.onPause() } private fun selectAppInMenu(appItem: MenuItem?) { if (appItem != null) { appItem.isChecked = true if (appItem.itemId != R.id.nav_all_messages) { binding.appBarDrawer.toolbar.subtitle = appItem.title } } } private fun scheduleDeletion( position: Int, message: Message, listAnimation: Boolean ) { val adapter = binding.messagesView.adapter as ListMessageAdapter val messages = viewModel.messages messages.deleteLocal(message) adapter.items = messages[viewModel.appId] if (listAnimation) { adapter.notifyItemRemoved(position) } else { adapter.notifyDataSetChanged() } showDeletionSnackbar() } private fun undoDelete() { val messages = viewModel.messages val deletion = messages.undoDeleteLocal() if (deletion != null) { val adapter = binding.messagesView.adapter as ListMessageAdapter val appId = viewModel.appId adapter.items = messages[appId] val insertPosition = if (appId == MessageState.ALL_MESSAGES) { deletion.allPosition } else { deletion.appPosition } adapter.notifyItemInserted(insertPosition) } } private fun showDeletionSnackbar() { val view: View = binding.swipeRefresh val snackbar = Snackbar.make(view, R.string.snackbar_deleted, Snackbar.LENGTH_LONG) snackbar.setAction(R.string.snackbar_undo) { undoDelete() } snackbar.addCallback(SnackbarCallback()) snackbar.show() } private inner class SnackbarCallback : BaseCallback() { override fun onDismissed(transientBottomBar: Snackbar?, event: Int) { super.onDismissed(transientBottomBar, event) if (event != DISMISS_EVENT_ACTION && event != DISMISS_EVENT_CONSECUTIVE) { // Execute deletion when the snackbar disappeared without pressing the undo button // DISMISS_EVENT_CONSECUTIVE should be excluded as well, because it would cause the // deletion to be sent to the server twice, since the deletion is sent to the server // in MessageFacade if a message is deleted while another message was already // waiting for deletion. launchCoroutine { commitDeleteMessage() } } } } private inner class SwipeToDeleteCallback( private val adapter: ListMessageAdapter ) : ItemTouchHelper.SimpleCallback(0, ItemTouchHelper.LEFT or ItemTouchHelper.RIGHT) { private var icon: Drawable? private val background: ColorDrawable init { val backgroundColorId = ContextCompat.getColor(this@MessagesActivity, R.color.swipeBackground) val iconColorId = ContextCompat.getColor(this@MessagesActivity, R.color.swipeIcon) val drawable = ContextCompat.getDrawable(this@MessagesActivity, R.drawable.ic_delete) icon = null if (drawable != null) { icon = DrawableCompat.wrap(drawable.mutate()) DrawableCompat.setTint(icon!!, iconColorId) } background = ColorDrawable(backgroundColorId) } override fun onMove( recyclerView: RecyclerView, viewHolder: RecyclerView.ViewHolder, target: RecyclerView.ViewHolder ) = false override fun onSwiped(viewHolder: RecyclerView.ViewHolder, direction: Int) { val position = viewHolder.adapterPosition val message = adapter.items[position] scheduleDeletion(position, message.message, true) } override fun onChildDraw( c: Canvas, recyclerView: RecyclerView, viewHolder: RecyclerView.ViewHolder, dX: Float, dY: Float, actionState: Int, isCurrentlyActive: Boolean ) { icon?.let { val itemView = viewHolder.itemView val iconHeight = itemView.height / 3 val scale = iconHeight / it.intrinsicHeight.toDouble() val iconWidth = (it.intrinsicWidth * scale).toInt() val iconMarginLeftRight = 50 val iconMarginTopBottom = (itemView.height - iconHeight) / 2 val iconTop = itemView.top + iconMarginTopBottom val iconBottom = itemView.bottom - iconMarginTopBottom if (dX > 0) { // Swiping to the right val iconLeft = itemView.left + iconMarginLeftRight val iconRight = itemView.left + iconMarginLeftRight + iconWidth it.setBounds(iconLeft, iconTop, iconRight, iconBottom) background.setBounds( itemView.left, itemView.top, itemView.left + dX.toInt(), itemView.bottom ) } else if (dX < 0) { // Swiping to the left val iconLeft = itemView.right - iconMarginLeftRight - iconWidth val iconRight = itemView.right - iconMarginLeftRight it.setBounds(iconLeft, iconTop, iconRight, iconBottom) background.setBounds( itemView.right + dX.toInt(), itemView.top, itemView.right, itemView.bottom ) } else { // View is unswiped it.setBounds(0, 0, 0, 0) background.setBounds(0, 0, 0, 0) } background.draw(c) it.draw(c) } super.onChildDraw(c, recyclerView, viewHolder, dX, dY, actionState, isCurrentlyActive) } } private inner class MessageListOnScrollListener : RecyclerView.OnScrollListener() { override fun onScrollStateChanged(view: RecyclerView, scrollState: Int) {} override fun onScrolled(view: RecyclerView, dx: Int, dy: Int) { val linearLayoutManager = view.layoutManager as LinearLayoutManager? if (linearLayoutManager != null) { val lastVisibleItem = linearLayoutManager.findLastVisibleItemPosition() val totalItemCount = view.adapter!!.itemCount if (lastVisibleItem > totalItemCount - 15 && totalItemCount != 0 && viewModel.messages.canLoadMore(viewModel.appId) ) { if (!isLoadMore) { isLoadMore = true launchCoroutine { loadMore(viewModel.appId) } } } } } } private suspend fun updateMissedMessages(id: Long) { if (id == -1L) return val newMessages = MissedMessageUtil(viewModel.client.createService(MessageApi::class.java)) .missingMessages(id).filterNotNull() viewModel.messages.addMessages(newMessages) if (newMessages.isNotEmpty()) { updateMessagesForApplication(true, viewModel.appId) } } override fun onCreateOptionsMenu(menu: Menu): Boolean { menuInflater.inflate(R.menu.messages_action, menu) menu.findItem(R.id.action_delete_app).isVisible = viewModel.appId != MessageState.ALL_MESSAGES return super.onCreateOptionsMenu(menu) } override fun onOptionsItemSelected(item: MenuItem): Boolean { if (item.itemId == R.id.action_delete_all) { launchCoroutine { deleteMessages(viewModel.appId) } } if (item.itemId == R.id.action_delete_app) { MaterialAlertDialogBuilder(this) .setTitle(R.string.delete_app) .setMessage(R.string.ack) .setPositiveButton(R.string.yes) { _, _ -> deleteApp(viewModel.appId) } .setNegativeButton(R.string.no, null) .show() } return super.onContextItemSelected(item) } private fun deleteApp(appId: Long) { val settings = viewModel.settings val client = ClientFactory.clientToken(settings.url, settings.sslSettings(), settings.token) client.createService(ApplicationApi::class.java) .deleteApp(appId) .enqueue( Callback.callInUI( this, onSuccess = { refreshAll() }, onError = { Utils.showSnackBar(this, getString(R.string.error_delete_app)) } ) ) } private suspend fun loadMore(appId: Long) { val messagesWithImages = viewModel.messages.loadMore(appId) withContext(Dispatchers.Main) { updateMessagesAndStopLoading(messagesWithImages) } } private suspend fun updateMessagesForApplication(withLoadingSpinner: Boolean, appId: Long) { if (withLoadingSpinner) { withContext(Dispatchers.Main) { startLoading() } } viewModel.messages.loadMoreIfNotPresent(appId) withContext(Dispatchers.Main) { updateMessagesAndStopLoading(viewModel.messages[appId]) } } private suspend fun addSingleMessage(message: Message) { viewModel.messages.addMessages(listOf(message)) updateMessagesForApplication(false, viewModel.appId) } private suspend fun commitDeleteMessage() { viewModel.messages.commitDelete() updateMessagesForApplication(false, viewModel.appId) } private suspend fun deleteMessages(appId: Long) { withContext(Dispatchers.Main) { startLoading() } val success = viewModel.messages.deleteAll(appId) if (success) { updateMessagesForApplication(false, viewModel.appId) } else { withContext(Dispatchers.Main) { Utils.showSnackBar(this@MessagesActivity, "Delete failed :(") } } } private fun deleteClientAndNavigateToLogin() { val settings = viewModel.settings val api = ClientFactory.clientToken(settings.url, settings.sslSettings(), settings.token) .createService(ClientApi::class.java) stopService(Intent(this@MessagesActivity, WebSocketService::class.java)) try { val clients = Api.execute(api.clients) var currentClient: Client? = null for (client in clients) { if (client.token == settings.token) { currentClient = client break } } if (currentClient != null) { Log.i("Delete client with id " + currentClient.id) Api.execute(api.deleteClient(currentClient.id)) } else { Log.e("Could not delete client, client does not exist.") } } catch (e: ApiException) { Log.e("Could not delete client", e) } viewModel.settings.clear() startActivity(Intent(this@MessagesActivity, LoginActivity::class.java)) finish() } private fun updateMessagesAndStopLoading(messageWithImages: List) { isLoadMore = false stopLoading() if (messageWithImages.isEmpty()) { binding.flipper.displayedChild = 1 } else { binding.flipper.displayedChild = 0 } val adapter = binding.messagesView.adapter as ListMessageAdapter adapter.items = messageWithImages adapter.notifyDataSetChanged() } companion object { private const val APPLICATION_ORDER = 1 } }