diff --git a/data/src/main/java/manager/WidgetManagerImpl.kt b/data/src/main/java/manager/WidgetManagerImpl.kt index d8b0dae5..0dab948d 100644 --- a/data/src/main/java/manager/WidgetManagerImpl.kt +++ b/data/src/main/java/manager/WidgetManagerImpl.kt @@ -19,6 +19,8 @@ package manager import android.content.Context +import android.content.Intent +import com.klinker.android.send_message.BroadcastUtils import me.leolin.shortcutbadger.ShortcutBadger import javax.inject.Inject @@ -26,6 +28,8 @@ class WidgetManagerImpl @Inject constructor(private val context: Context) : Widg override fun updateUnreadCount(count: Int) { ShortcutBadger.applyCount(context, count) + + BroadcastUtils.sendExplicitBroadcast(context, Intent(), WidgetManager.ACTION_NOTIFY_DATASET_CHANGED) } } \ No newline at end of file diff --git a/data/src/main/java/repository/MessageRepositoryImpl.kt b/data/src/main/java/repository/MessageRepositoryImpl.kt index 75318aa9..bc935912 100644 --- a/data/src/main/java/repository/MessageRepositoryImpl.kt +++ b/data/src/main/java/repository/MessageRepositoryImpl.kt @@ -93,6 +93,27 @@ class MessageRepositoryImpl @Inject constructor( }) } + override fun getConversationsForWidget(): List> { + val realm = Realm.getDefaultInstance() + + val conversations = realm.copyFromRealm(realm.where(Conversation::class.java) + .equalTo("archived", false) + .equalTo("blocked", false) + .findAll()) + + val messages = realm.copyFromRealm(realm.where(Message::class.java) + .sort("date", Sort.DESCENDING) + .distinctValues("threadId") + .findAll()) + + val conversationMap = conversations.associateBy { conversation -> conversation.id } + + return messages.mapNotNull { message -> + val conversation = conversationMap[message.threadId] + if (conversation == null) null else Pair(conversation, message) + } + } + override fun getBlockedConversations(): Flowable> { return Realm.getDefaultInstance() .where(Conversation::class.java) diff --git a/domain/src/main/java/manager/WidgetManager.kt b/domain/src/main/java/manager/WidgetManager.kt index f30fe599..b777aa49 100644 --- a/domain/src/main/java/manager/WidgetManager.kt +++ b/domain/src/main/java/manager/WidgetManager.kt @@ -20,6 +20,10 @@ package manager interface WidgetManager { + companion object { + const val ACTION_NOTIFY_DATASET_CHANGED = "com.moez.QKSMS.intent.action.ACTION_NOTIFY_DATASET_CHANGED" + } + fun updateUnreadCount(count: Int) } \ No newline at end of file diff --git a/domain/src/main/java/repository/MessageRepository.kt b/domain/src/main/java/repository/MessageRepository.kt index 764a57dd..0b41cd58 100644 --- a/domain/src/main/java/repository/MessageRepository.kt +++ b/domain/src/main/java/repository/MessageRepository.kt @@ -30,6 +30,8 @@ interface MessageRepository { fun getConversations(archived: Boolean = false): Flowable>> + fun getConversationsForWidget(): List> + fun getBlockedConversations(): Flowable> fun getConversationAsync(threadId: Long): Conversation diff --git a/presentation/src/main/AndroidManifest.xml b/presentation/src/main/AndroidManifest.xml index 49890599..3990eae7 100644 --- a/presentation/src/main/AndroidManifest.xml +++ b/presentation/src/main/AndroidManifest.xml @@ -112,6 +112,18 @@ + + + + + + + + + + + + * + * This file is part of QKSMS. + * + * QKSMS is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * QKSMS is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with QKSMS. If not, see . + */ +package feature.widget + +import android.app.PendingIntent +import android.appwidget.AppWidgetManager +import android.content.Context +import android.content.Intent +import android.graphics.Typeface +import android.telephony.PhoneNumberUtils +import android.text.Spannable +import android.text.SpannableString +import android.text.style.StyleSpan +import android.view.View +import android.widget.RemoteViews +import android.widget.RemoteViewsService +import com.moez.QKSMS.R +import common.util.Colors +import common.util.DateFormatter +import common.util.GlideApp +import common.util.extensions.dpToPx +import feature.main.MainActivity +import injection.appComponent +import model.Contact +import model.Conversation +import model.Message +import model.PhoneNumber +import repository.MessageRepository +import javax.inject.Inject + +class WidgetAdapter(intent: Intent) : RemoteViewsService.RemoteViewsFactory { + + companion object { + private const val MAX_CONVERSATIONS_COUNT = 25 + } + + @Inject lateinit var context: Context + @Inject lateinit var colors: Colors + @Inject lateinit var dateFormatter: DateFormatter + @Inject lateinit var messageRepo: MessageRepository + + private val appWidgetId = intent.getIntExtra(AppWidgetManager.EXTRA_APPWIDGET_ID, AppWidgetManager.INVALID_APPWIDGET_ID) + private val smallWidget = intent.getBooleanExtra("small_widget", false) + private var conversations: List> = listOf() + private val appWidgetManager by lazy { AppWidgetManager.getInstance(context) } + + // Cache colors, load lazily. This entire class is recreated when the widget is recreated, + // so if the theme changes then these values will be correct in the new instance + private val theme by lazy { colors.theme.blockingFirst() } + private val background by lazy { colors.background.blockingFirst() } + private val textPrimary by lazy { colors.textPrimary.blockingFirst() } + private val textSecondary by lazy { colors.textSecondary.blockingFirst() } + private val textTertiary by lazy { colors.textTertiary.blockingFirst() } + private val textPrimaryOnTheme by lazy { colors.textPrimaryOnTheme.blockingFirst() } + + override fun onCreate() { + appComponent.inject(this) + } + + override fun onDataSetChanged() { + conversations = messageRepo.getConversationsForWidget() + + val remoteViews = RemoteViews(context.packageName, R.layout.widget) + appWidgetManager.partiallyUpdateAppWidget(appWidgetId, remoteViews) + } + + /** + * Returns the number of rows to show. If there are more conversations than the max count, + * we'll return the max count + 1, where the last row just shows "View more conversations" + */ + override fun getCount(): Int { + val count = Math.min(conversations.size, MAX_CONVERSATIONS_COUNT) + val shouldShowViewMore = count < conversations.size + return count + if (shouldShowViewMore) 1 else 0 + } + + override fun getViewAt(position: Int): RemoteViews { + return when { + position >= MAX_CONVERSATIONS_COUNT -> getOverflowView() + else -> getConversationView(position) + } + } + + private fun getConversationView(position: Int): RemoteViews { + val conversation = conversations[position].first + val message = conversations[position].second + + val remoteViews = RemoteViews(context.packageName, R.layout.widget_list_item) + + // Avatar + remoteViews.setViewVisibility(R.id.avatar, if (smallWidget) View.GONE else View.VISIBLE) + remoteViews.setInt(R.id.avatar, "setBackgroundColor", theme) + remoteViews.setTextColor(R.id.initial, textPrimaryOnTheme) + remoteViews.setInt(R.id.icon, "setColorFilter", textPrimaryOnTheme) + remoteViews.setInt(R.id.avatarMask, "setColorFilter", background) + + val contact = conversation.recipients.map { recipient -> + recipient.contact ?: Contact().apply { numbers.add(PhoneNumber().apply { address = recipient.address }) } + }.firstOrNull() + + + // Use the icon if there's no name, otherwise show an initial + if (contact?.name.orEmpty().isNotEmpty()) { + remoteViews.setTextViewText(R.id.initial, contact?.name?.substring(0, 1)) + remoteViews.setViewVisibility(R.id.icon, View.GONE) + } else { + remoteViews.setTextViewText(R.id.initial, null) + remoteViews.setViewVisibility(R.id.icon, View.VISIBLE) + } + + remoteViews.setImageViewBitmap(R.id.photo, null) + contact?.numbers?.firstOrNull()?.address?.let { address -> + val futureGet = GlideApp.with(context) + .asBitmap() + .load(PhoneNumberUtils.stripSeparators(address)) + .submit(48.dpToPx(context), 48.dpToPx(context)) + + try { + remoteViews.setImageViewBitmap(R.id.photo, futureGet.get()) + } catch (e: Exception) { + } + } + + // Name + remoteViews.setTextColor(R.id.name, textPrimary) + remoteViews.setTextViewText(R.id.name, boldText(conversation.getTitle(), !message.read)) + + // Date + remoteViews.setTextColor(R.id.date, if (message.read) textTertiary else textPrimary) + remoteViews.setTextViewText(R.id.date, boldText(dateFormatter.getConversationTimestamp(message.date), !message.read)) + + // Snippet + remoteViews.setTextColor(R.id.snippet, if (message.read) textTertiary else textPrimary) + remoteViews.setTextViewText(R.id.snippet, boldText(message.getSummary(), !message.read)) + + // Launch conversation on click + val clickIntent = Intent().putExtra("threadId", conversation.id) + remoteViews.setOnClickFillInIntent(R.id.conversation, clickIntent) + + return remoteViews + } + + private fun getOverflowView(): RemoteViews { + val view = RemoteViews(context.packageName, R.layout.widget_loading) + val intent = Intent(context, MainActivity::class.java) + val pendingIntent = PendingIntent.getActivity(context, 0, intent, PendingIntent.FLAG_UPDATE_CURRENT) + view.setTextColor(R.id.loadingText, textSecondary) + view.setTextViewText(R.id.loadingText, context.getString(R.string.widget_more)) + view.setOnClickPendingIntent(R.id.loading, pendingIntent) + return view + } + + private fun boldText(text: String, shouldBold: Boolean): CharSequence { + return if (shouldBold) { + SpannableString(text).apply { + setSpan(StyleSpan(Typeface.BOLD), 0, length, Spannable.SPAN_INCLUSIVE_INCLUSIVE) + } + } else { + text + } + } + + override fun getLoadingView(): RemoteViews { + val view = RemoteViews(context.packageName, R.layout.widget_loading) + view.setTextViewText(R.id.loadingText, context.getText(R.string.widget_loading)) + return view + } + + override fun getViewTypeCount(): Int { + return 2 + } + + override fun getItemId(position: Int): Long { + return position.toLong() + } + + override fun hasStableIds(): Boolean { + return false + } + + override fun onDestroy() { + } + +} \ No newline at end of file diff --git a/presentation/src/main/java/feature/widget/WidgetProvider.kt b/presentation/src/main/java/feature/widget/WidgetProvider.kt new file mode 100644 index 00000000..9499b2c1 --- /dev/null +++ b/presentation/src/main/java/feature/widget/WidgetProvider.kt @@ -0,0 +1,140 @@ +/* + * Copyright (C) 2017 Moez Bhatti + * + * This file is part of QKSMS. + * + * QKSMS is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * QKSMS is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with QKSMS. If not, see . + */ + +package feature.widget + +import android.app.PendingIntent +import android.appwidget.AppWidgetManager +import android.appwidget.AppWidgetProvider +import android.content.ComponentName +import android.content.Context +import android.content.Intent +import android.net.Uri +import android.os.Bundle +import android.widget.RemoteViews +import com.moez.QKSMS.R +import common.util.Colors +import feature.compose.ComposeActivity +import feature.main.MainActivity +import injection.appComponent +import manager.WidgetManager +import timber.log.Timber +import javax.inject.Inject + +class WidgetProvider : AppWidgetProvider() { + + @Inject lateinit var colors: Colors + + init { + appComponent.inject(this) + } + + override fun onReceive(context: Context, intent: Intent) { + when (intent.action) { + WidgetManager.ACTION_NOTIFY_DATASET_CHANGED -> updateData(context) + else -> super.onReceive(context, intent) + } + } + + /** + * Update all widgets in the list + */ + override fun onUpdate(context: Context, appWidgetManager: AppWidgetManager, appWidgetIds: IntArray) { + super.onUpdate(context, appWidgetManager, appWidgetIds) + + for (appWidgetId in appWidgetIds) { + updateWidget(context, appWidgetId, isSmallWidget(appWidgetManager, appWidgetId)) + } + } + + /** + * Notify all the widgets that they should update their adapter data + */ + private fun updateData(context: Context) { + val appWidgetManager = AppWidgetManager.getInstance(context) + val appWidgetIds = appWidgetManager.getAppWidgetIds(ComponentName(context, WidgetProvider::class.java)) + + // We need to update all Mms appwidgets on the home screen. + appWidgetManager.notifyAppWidgetViewDataChanged(appWidgetIds, R.id.conversations) + } + + /** + * Update widget when widget size changes + */ + override fun onAppWidgetOptionsChanged(context: Context, appWidgetManager: AppWidgetManager, appWidgetId: Int, newOptions: Bundle) { + updateWidget(context, appWidgetId, isSmallWidget(appWidgetManager, appWidgetId)) + super.onAppWidgetOptionsChanged(context, appWidgetManager, appWidgetId, newOptions) + } + + /** + * Returns true when widget has less than 4 columns, else false + */ + private fun isSmallWidget(appWidgetManager: AppWidgetManager, appWidgetId: Int): Boolean { + val options = appWidgetManager.getAppWidgetOptions(appWidgetId) + val size = options.getInt(AppWidgetManager.OPTION_APPWIDGET_MIN_WIDTH) + + var n = 2 + while (70 * n - 30 < size) { + ++n + } + + val columns = n - 1 + + return columns < 4 + } + + /** + * Update the widget appWidgetId + */ + private fun updateWidget(context: Context, appWidgetId: Int, smallWidget: Boolean) { + Timber.v("updateWidget appWidgetId: $appWidgetId") + val remoteViews = RemoteViews(context.packageName, R.layout.widget) + + // Apply colors from theme + remoteViews.setInt(R.id.background, "setColorFilter", colors.background.blockingFirst()) + remoteViews.setInt(R.id.toolbar, "setColorFilter", colors.toolbarColor.blockingFirst()) + remoteViews.setTextColor(R.id.title, colors.textPrimary.blockingFirst()) + remoteViews.setInt(R.id.compose, "setColorFilter", colors.theme.blockingFirst()) + + // Set adapter for conversations + val intent = Intent(context, WidgetService::class.java) + .putExtra(AppWidgetManager.EXTRA_APPWIDGET_ID, appWidgetId) + .putExtra("small_widget", smallWidget) + intent.data = Uri.parse(intent.toUri(Intent.URI_INTENT_SCHEME)) + remoteViews.setRemoteAdapter(R.id.conversations, intent) + + // Main intent + val mainIntent = Intent(context, MainActivity::class.java) + val mainPI = PendingIntent.getActivity(context, 0, mainIntent, PendingIntent.FLAG_UPDATE_CURRENT) + remoteViews.setOnClickPendingIntent(R.id.title, mainPI) + + // Compose intent + val composeIntent = Intent(context, ComposeActivity::class.java) + val composePI = PendingIntent.getActivity(context, 0, composeIntent, PendingIntent.FLAG_UPDATE_CURRENT) + remoteViews.setOnClickPendingIntent(R.id.compose, composePI) + + // Conversation intent + val startActivityIntent = Intent(context, ComposeActivity::class.java) + val startActivityPendingIntent = PendingIntent.getActivity(context, 0, startActivityIntent, PendingIntent.FLAG_UPDATE_CURRENT) + remoteViews.setPendingIntentTemplate(R.id.conversations, startActivityPendingIntent) + + AppWidgetManager.getInstance(context).updateAppWidget(appWidgetId, remoteViews) + } + +} diff --git a/presentation/src/main/java/feature/widget/WidgetService.kt b/presentation/src/main/java/feature/widget/WidgetService.kt new file mode 100644 index 00000000..63a53e92 --- /dev/null +++ b/presentation/src/main/java/feature/widget/WidgetService.kt @@ -0,0 +1,31 @@ +/* + * Copyright (C) 2017 Moez Bhatti + * + * This file is part of QKSMS. + * + * QKSMS is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * QKSMS is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with QKSMS. If not, see . + */ + +package feature.widget + +import android.content.Intent +import android.widget.RemoteViewsService + +class WidgetService : RemoteViewsService() { + + override fun onGetViewFactory(intent: Intent): RemoteViewsService.RemoteViewsFactory { + return WidgetAdapter(intent) + } + +} diff --git a/presentation/src/main/java/injection/AppComponent.kt b/presentation/src/main/java/injection/AppComponent.kt index 689fb274..3a338b9c 100644 --- a/presentation/src/main/java/injection/AppComponent.kt +++ b/presentation/src/main/java/injection/AppComponent.kt @@ -20,7 +20,6 @@ package injection import common.QKApplication import common.util.ContactImageLoader -import dagger.Component import common.widget.AvatarView import common.widget.MmsPreviewView import common.widget.PagerTitleView @@ -29,6 +28,7 @@ import common.widget.QkEditText import common.widget.QkSwitch import common.widget.QkTextView import common.widget.Separator +import dagger.Component import feature.blocked.BlockedActivity import feature.blocked.BlockedViewModel import feature.compose.ComposeActivity @@ -50,6 +50,8 @@ import feature.setup.SetupActivity import feature.setup.SetupViewModel import feature.themepicker.ThemePickerActivity import feature.themepicker.ThemePickerViewModel +import feature.widget.WidgetAdapter +import feature.widget.WidgetProvider import receiver.DefaultSmsChangedReceiver import receiver.MarkReadReceiver import receiver.MarkSeenReceiver @@ -93,6 +95,9 @@ interface AppComponent { fun inject(receiver: RemoteMessagingReceiver) fun inject(receiver: SmsProviderChangedReceiver) fun inject(receiver: SmsReceiver) + fun inject(receiver: WidgetProvider) + + fun inject(service: WidgetAdapter) fun inject(view: AvatarView) fun inject(view: DetailedChipView) diff --git a/presentation/src/main/res/drawable-xxxhdpi/circle_mask.png b/presentation/src/main/res/drawable-xxxhdpi/circle_mask.png new file mode 100644 index 00000000..3b2b17e5 Binary files /dev/null and b/presentation/src/main/res/drawable-xxxhdpi/circle_mask.png differ diff --git a/presentation/src/main/res/drawable/rounded_rectangle_top_4dp.xml b/presentation/src/main/res/drawable/rounded_rectangle_top_4dp.xml new file mode 100755 index 00000000..a9cf8dd1 --- /dev/null +++ b/presentation/src/main/res/drawable/rounded_rectangle_top_4dp.xml @@ -0,0 +1,29 @@ + + + + + + + + + + \ No newline at end of file diff --git a/presentation/src/main/res/drawable/widget_preview.png b/presentation/src/main/res/drawable/widget_preview.png new file mode 100644 index 00000000..bb91a8e6 Binary files /dev/null and b/presentation/src/main/res/drawable/widget_preview.png differ diff --git a/presentation/src/main/res/layout/widget.xml b/presentation/src/main/res/layout/widget.xml new file mode 100644 index 00000000..1c005293 --- /dev/null +++ b/presentation/src/main/res/layout/widget.xml @@ -0,0 +1,90 @@ + + + + + + + + + + + + + + + + + + + + diff --git a/presentation/src/main/res/layout/widget_list_item.xml b/presentation/src/main/res/layout/widget_list_item.xml new file mode 100755 index 00000000..5b6b71fd --- /dev/null +++ b/presentation/src/main/res/layout/widget_list_item.xml @@ -0,0 +1,111 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/presentation/src/main/res/layout/widget_loading.xml b/presentation/src/main/res/layout/widget_loading.xml new file mode 100755 index 00000000..80dcd879 --- /dev/null +++ b/presentation/src/main/res/layout/widget_loading.xml @@ -0,0 +1,35 @@ + + + + + + + \ No newline at end of file diff --git a/presentation/src/main/res/values/strings.xml b/presentation/src/main/res/values/strings.xml index 1c73c4e1..88a180d7 100644 --- a/presentation/src/main/res/values/strings.xml +++ b/presentation/src/main/res/values/strings.xml @@ -137,6 +137,9 @@ Theme + Loading… + View more conversations + Material RGB diff --git a/presentation/src/main/res/xml/widget_info.xml b/presentation/src/main/res/xml/widget_info.xml new file mode 100644 index 00000000..5d7604c5 --- /dev/null +++ b/presentation/src/main/res/xml/widget_info.xml @@ -0,0 +1,27 @@ + + + \ No newline at end of file