From 3025407ddd5179ef4aaff1ec6ab9786d9a643d62 Mon Sep 17 00:00:00 2001 From: Gareth Jones Date: Sat, 15 Jul 2023 19:37:29 +1200 Subject: [PATCH] feat: add hot network questions widget (#144) * feat: create initial widget * feat: fetch an actual hot network question from the exchange network * feat: cache the list of hot network questions for 5 minutes * refactor: use scope functions * feat: improve widget UI * feat: fetch and show question icon * feat: open current hot network question in app * refactor: remove unused code and deduplicate a little * refactor: use `apply` with remote views * fix: attempt to ensure a different hot question is picked * refactor: use strings (with English and Spanish translations), and improve question title textview id * feat: use Coil to load question icons to take advantage of disk caching * fix: don't consider `null` or empty as being a cache hit for hot questions * fix: show a loading message and setup reload button in `onUpdate` * feat: improve widget refresh button * fix: restructure padding so that refresh button area fills the whole space * chore: remove resolved todos * fix: adjust widget label * refactor: use private constants for shared preference keys * refactor: use string resource * refactor: use a less generic name than `Network` * fix: add widget description * fix: adjust widget min width and height * ci: adjust build timeouts --------- Co-authored-by: Tyler Wong --- app/src/main/AndroidManifest.xml | 12 + .../NetworkHotQuestionsRepository.kt | 15 ++ .../stack/data/site/SiteInterceptor.kt | 1 + .../tylerbwong/stack/data/site/SiteStore.kt | 4 + .../me/tylerbwong/stack/ui/BaseActivity.kt | 5 + .../detail/QuestionDetailActivity.kt | 4 +- .../ui/widgets/HotNetworkQuestionsWidget.kt | 216 ++++++++++++++++++ .../res/drawable-hdpi/ic_refresh_light.png | Bin 0 -> 417 bytes .../layout/hot_network_questions_widget.xml | 44 ++++ app/src/main/res/values-es/strings.xml | 7 + app/src/main/res/values/strings.xml | 7 + .../xml/hot_network_questions_widget_info.xml | 7 + .../me/tylerbwong/stack/api/di/ApiModule.kt | 12 + .../stack/api/model/NetworkHotQuestion.kt | 26 +++ .../api/service/NetworkHotQuestionsService.kt | 9 + 15 files changed, 368 insertions(+), 1 deletion(-) create mode 100644 app/src/main/kotlin/me/tylerbwong/stack/data/repository/NetworkHotQuestionsRepository.kt create mode 100644 app/src/main/kotlin/me/tylerbwong/stack/ui/widgets/HotNetworkQuestionsWidget.kt create mode 100644 app/src/main/res/drawable-hdpi/ic_refresh_light.png create mode 100644 app/src/main/res/layout/hot_network_questions_widget.xml create mode 100644 app/src/main/res/xml/hot_network_questions_widget_info.xml create mode 100644 stackexchange-api/src/main/kotlin/me/tylerbwong/stack/api/model/NetworkHotQuestion.kt create mode 100644 stackexchange-api/src/main/kotlin/me/tylerbwong/stack/api/service/NetworkHotQuestionsService.kt diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 89ae3999..99990ed6 100755 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -233,5 +233,17 @@ android:value="androidx.startup" tools:node="remove" /> + + + + + + + diff --git a/app/src/main/kotlin/me/tylerbwong/stack/data/repository/NetworkHotQuestionsRepository.kt b/app/src/main/kotlin/me/tylerbwong/stack/data/repository/NetworkHotQuestionsRepository.kt new file mode 100644 index 00000000..823aba47 --- /dev/null +++ b/app/src/main/kotlin/me/tylerbwong/stack/data/repository/NetworkHotQuestionsRepository.kt @@ -0,0 +1,15 @@ +package me.tylerbwong.stack.data.repository + +import me.tylerbwong.stack.api.model.NetworkHotQuestion +import me.tylerbwong.stack.api.service.NetworkHotQuestionsService +import javax.inject.Inject +import javax.inject.Singleton + +@Singleton +class NetworkHotQuestionsRepository @Inject constructor( + private val networkHotQuestionsService: NetworkHotQuestionsService, +) { + suspend fun getHotNetworkQuestions(): List { + return networkHotQuestionsService.getHotNetworkQuestions() + } +} diff --git a/app/src/main/kotlin/me/tylerbwong/stack/data/site/SiteInterceptor.kt b/app/src/main/kotlin/me/tylerbwong/stack/data/site/SiteInterceptor.kt index 26b57401..9f8e5989 100644 --- a/app/src/main/kotlin/me/tylerbwong/stack/data/site/SiteInterceptor.kt +++ b/app/src/main/kotlin/me/tylerbwong/stack/data/site/SiteInterceptor.kt @@ -49,6 +49,7 @@ class SiteInterceptor @Inject constructor( "sites", "me/associated", "inbox", + "hot-questions-json", ) } } diff --git a/app/src/main/kotlin/me/tylerbwong/stack/data/site/SiteStore.kt b/app/src/main/kotlin/me/tylerbwong/stack/data/site/SiteStore.kt index 3016b590..67cf3301 100644 --- a/app/src/main/kotlin/me/tylerbwong/stack/data/site/SiteStore.kt +++ b/app/src/main/kotlin/me/tylerbwong/stack/data/site/SiteStore.kt @@ -41,6 +41,10 @@ class SiteStore @Inject constructor( mutableSiteLiveData.value = this.site } + fun clearDeepLinkedSites() { + deepLinkSites.clear() + } + companion object { internal const val SITE_PREFERENCES = "site_preferences" internal val defaultSite: String diff --git a/app/src/main/kotlin/me/tylerbwong/stack/ui/BaseActivity.kt b/app/src/main/kotlin/me/tylerbwong/stack/ui/BaseActivity.kt index 20e08faf..a3fa1cbf 100644 --- a/app/src/main/kotlin/me/tylerbwong/stack/ui/BaseActivity.kt +++ b/app/src/main/kotlin/me/tylerbwong/stack/ui/BaseActivity.kt @@ -72,10 +72,15 @@ abstract class BaseActivity( } private fun overrideDeepLinkSite() { + if (intent.getBooleanExtra(CLEAR_DEEP_LINKED_SITES, false)) { + siteStore.clearDeepLinkedSites() + } + intent.getStringExtra(DEEP_LINK_SITE)?.let { siteStore.pushCurrentDeepLinkSite(it) } } companion object { internal const val DEEP_LINK_SITE = "deep_link_site" + internal const val CLEAR_DEEP_LINKED_SITES = "clear_deep_linked_sites" } } diff --git a/app/src/main/kotlin/me/tylerbwong/stack/ui/questions/detail/QuestionDetailActivity.kt b/app/src/main/kotlin/me/tylerbwong/stack/ui/questions/detail/QuestionDetailActivity.kt index f1e0752e..a51f5ed6 100755 --- a/app/src/main/kotlin/me/tylerbwong/stack/ui/questions/detail/QuestionDetailActivity.kt +++ b/app/src/main/kotlin/me/tylerbwong/stack/ui/questions/detail/QuestionDetailActivity.kt @@ -200,13 +200,15 @@ class QuestionDetailActivity : BaseActivity( answerId: Int? = null, commentId: Int? = null, isInAnswerMode: Boolean = false, - deepLinkSite: String? = null + deepLinkSite: String? = null, + clearDeepLinkedSites: Boolean = false, ) = Intent(context, QuestionDetailActivity::class.java) .putExtra(QUESTION_ID, questionId) .putExtra(ANSWER_ID, answerId) .putExtra(COMMENT_ID, commentId) .putExtra(IS_IN_ANSWER_MODE, isInAnswerMode) .putExtra(DEEP_LINK_SITE, deepLinkSite) + .putExtra(CLEAR_DEEP_LINKED_SITES, clearDeepLinkedSites) fun startActivity(context: Context, id: Int) { context.startActivity(makeIntent(context, id)) diff --git a/app/src/main/kotlin/me/tylerbwong/stack/ui/widgets/HotNetworkQuestionsWidget.kt b/app/src/main/kotlin/me/tylerbwong/stack/ui/widgets/HotNetworkQuestionsWidget.kt new file mode 100644 index 00000000..d637723c --- /dev/null +++ b/app/src/main/kotlin/me/tylerbwong/stack/ui/widgets/HotNetworkQuestionsWidget.kt @@ -0,0 +1,216 @@ +package me.tylerbwong.stack.ui.widgets + +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.view.View +import android.widget.RemoteViews +import androidx.core.graphics.drawable.toBitmap +import coil.ImageLoader +import coil.request.ImageRequest +import com.squareup.moshi.Moshi +import com.squareup.moshi.Types +import dagger.hilt.android.AndroidEntryPoint +import kotlinx.coroutines.CoroutineDispatcher +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.DelicateCoroutinesApi +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.GlobalScope +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext +import me.tylerbwong.stack.R +import me.tylerbwong.stack.api.model.NetworkHotQuestion +import me.tylerbwong.stack.data.repository.NetworkHotQuestionsRepository +import me.tylerbwong.stack.ui.questions.detail.QuestionDetailActivity +import timber.log.Timber +import java.util.concurrent.TimeUnit +import javax.inject.Inject + +@AndroidEntryPoint +class HotNetworkQuestionsWidget @OptIn(DelicateCoroutinesApi::class) constructor( + private val externalScope: CoroutineScope = GlobalScope, + private val ioDispatcher: CoroutineDispatcher = Dispatchers.IO +) : AppWidgetProvider() { + @Inject + lateinit var networkHotQuestionsRepository: NetworkHotQuestionsRepository + + @Inject + lateinit var imageLoader: ImageLoader + + private fun refreshWidgets( + context: Context, + appWidgetManager: AppWidgetManager, + appWidgetIds: IntArray, + currentQuestionId: Int = -1 + ) { + for (appWidgetId in appWidgetIds) { + externalScope.launch { + val question = getRandomHotNetworkQuestion(context, currentQuestionId) + + val remoteViews = buildRemoteViews(context, question) + appWidgetManager.updateAppWidget(appWidgetId, remoteViews) + } + } + } + + private suspend fun getHotNetworkQuestions(context: Context): List { + val sharedPreferences = context.getSharedPreferences(CACHE_PREFERENCE_NAME, Context.MODE_PRIVATE) + val type = Types.newParameterizedType(MutableList::class.java, NetworkHotQuestion::class.java) + val jsonAdapter = Moshi.Builder().build().adapter>(type) + + sharedPreferences.getString(CACHE_QUESTIONS_KEY, null)?.let { + val expiresAfter = sharedPreferences.getLong(CACHE_EXPIRES_AFTER_KEY, -1) + + if (expiresAfter > System.currentTimeMillis()) { + Timber.d("hot network questions: cache hit") + + val questions = jsonAdapter.fromJson(it) ?: emptyList() + + if (questions.isNotEmpty()) { + return questions + } + } + } + + Timber.d("hot network questions: cache miss") + + return networkHotQuestionsRepository.getHotNetworkQuestions().also { + sharedPreferences.edit().apply { + putString(CACHE_QUESTIONS_KEY, jsonAdapter.toJson(it)) + putLong( + CACHE_EXPIRES_AFTER_KEY, + System.currentTimeMillis() + TimeUnit.MINUTES.toMillis(CACHE_EXPIRES_AFTER_MINUTES) + ) + + apply() + } + } + } + + private suspend fun getRandomHotNetworkQuestion(context: Context, currentQuestionId: Int): NetworkHotQuestion { + return withContext(ioDispatcher) { + val questions = getHotNetworkQuestions(context) + .also { Timber.d("hot network questions count is ${it.size}") } + + var question = questions.random() + + // the exchange typically provides 100 hot network questions, so explicitly checking that the question + // id is different two times should ensure the odds of the same hot question being picked are very low + // without requiring us to worry about handling edge cases that could cause infinite loops + if (questions.size > 1 && question.questionId == currentQuestionId) { + question = questions.random() + + if (question.questionId == currentQuestionId) { + question = questions.random() + } + } + + question + } + } + + private suspend fun buildRemoteViews(context: Context, question: NetworkHotQuestion): RemoteViews { + return RemoteViews(context.packageName, R.layout.hot_network_questions_widget).apply { + // Set the question title + setTextViewText(R.id.hotNetworkQuestionTitleTextView, question.title) + + ImageRequest.Builder(context) + .data(question.iconUrl) + .target( + onSuccess = { + setImageViewBitmap(R.id.hotQuestionIcon, it.toBitmap()) + setViewVisibility(R.id.hotQuestionIcon, View.VISIBLE) + }, + onError = { + setViewVisibility(R.id.hotQuestionIcon, View.INVISIBLE) + } + ) + .build() + .let { imageLoader.execute(it) } + + // Set click listeners for the question title and refresh button + setOnClickPendingIntent(R.id.hotNetworkQuestionTitleTextView, getOpenQuestionIntent(context, question)) + setOnClickPendingIntent(R.id.fetchNewHotQuestionButton, getFetchNewHotQuestionIntent(context, question)) + } + } + + private fun getOpenQuestionIntent(context: Context, question: NetworkHotQuestion): PendingIntent { + val intent = QuestionDetailActivity.makeIntent( + context = context, + questionId = question.questionId, + deepLinkSite = question.site, + clearDeepLinkedSites = true + ) + + return PendingIntent.getActivity( + context, + System.currentTimeMillis().toInt(), + intent.setAction("OPEN_QUESTION_${question.questionId}_ON_${question.site}"), + PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE + ) + } + + private fun getFetchNewHotQuestionIntent(context: Context, currentQuestion: NetworkHotQuestion?): PendingIntent { + val intent = Intent(context, HotNetworkQuestionsWidget::class.java) + + intent.action = ACTION_REFRESH + intent.putExtra(CURRENT_HOT_QUESTION_ID, currentQuestion?.questionId) + + return PendingIntent.getBroadcast( + context, + 0, + intent, + PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE + ) + } + + override fun onUpdate(context: Context, appWidgetManager: AppWidgetManager, appWidgetIds: IntArray) { + for (appWidgetId in appWidgetIds) { + val remoteViews = RemoteViews(context.packageName, R.layout.hot_network_questions_widget).apply { + setTextViewText( + R.id.hotNetworkQuestionTitleTextView, + context.getString(R.string.hot_network_questions_loading) + ) + + setOnClickPendingIntent(R.id.fetchNewHotQuestionButton, getFetchNewHotQuestionIntent(context, null)) + } + appWidgetManager.updateAppWidget(appWidgetId, remoteViews) + } + + refreshWidgets(context, appWidgetManager, appWidgetIds) + } + + override fun onReceive(context: Context, intent: Intent) { + super.onReceive(context, intent) + + when (intent.action) { + ACTION_REFRESH -> { + val appWidgetManager = AppWidgetManager.getInstance(context) + val appWidgetIds = appWidgetManager.getAppWidgetIds( + ComponentName(context, HotNetworkQuestionsWidget::class.java) + ) + + refreshWidgets( + context, + appWidgetManager, + appWidgetIds, + intent.getIntExtra(CURRENT_HOT_QUESTION_ID, -1) + ) + } + } + } + + companion object { + private const val ACTION_REFRESH = "me.tylerbwong.stack.widget.ACTION_REFRESH" + private const val CURRENT_HOT_QUESTION_ID = "current_hot_question_id" + + private const val CACHE_EXPIRES_AFTER_MINUTES = 5L + + private const val CACHE_PREFERENCE_NAME = "hot_network_questions_widget_cache" + private const val CACHE_QUESTIONS_KEY = "hot_network_questions" + private const val CACHE_EXPIRES_AFTER_KEY = "hot_network_questions_expires_after" + } +} diff --git a/app/src/main/res/drawable-hdpi/ic_refresh_light.png b/app/src/main/res/drawable-hdpi/ic_refresh_light.png new file mode 100644 index 0000000000000000000000000000000000000000..2a4d773d0d268723c05b82941746cf54ba9aca8b GIT binary patch literal 417 zcmeAS@N?(olHy`uVBq!ia0vp^1|ZDA3?vioaBc-s#sNMdt{|F3aB9|>(?D&4B|(0{ z46^-PYRsUYV-`fA#VZ-OgHz%*WcJK9f<@=|~uZRUyPM7-Am*8DtIe&W4Z@$~S6Ig{G z%ho&VOC&OMeCIlL;!w|mDGO#Q9-g)!d*L;5ntnl{BgYHVNgX!rKLk6$kKJNfYJb!q$TV@tMwF0gj~dhMfPz}AMDah&^pm*1GD zbG3piQD;YkjlJ>1eX+$%bz04BBCmr&3k?J!e#~Yph&^WUFQm4KP4drw{so=-E|+-= UR{a;;2@Es_Pgg&ebxsLQ0LZziZ~y=R literal 0 HcmV?d00001 diff --git a/app/src/main/res/layout/hot_network_questions_widget.xml b/app/src/main/res/layout/hot_network_questions_widget.xml new file mode 100644 index 00000000..d22c3046 --- /dev/null +++ b/app/src/main/res/layout/hot_network_questions_widget.xml @@ -0,0 +1,44 @@ + + + + + + + + + + + diff --git a/app/src/main/res/values-es/strings.xml b/app/src/main/res/values-es/strings.xml index 446008d2..47fea663 100644 --- a/app/src/main/res/values-es/strings.xml +++ b/app/src/main/res/values-es/strings.xml @@ -275,4 +275,11 @@ Ocultar este comentario globalmente. Si hay un problema con este comentario, márcalo primero. Ocultar usuario Oculte este usuario y todo el contenido asociado globalmente. Si hay un problema con una publicación específica, márquelo primero. + + + Ver preguntas aleatorias de redes populares + Cargando preguntas calientes de la red + No se pueden cargar las preguntas de la red caliente + Icono de pregunta de red caliente + Obtener nueva pregunta de red activa diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index e2295f8a..b055c7f5 100755 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -293,4 +293,11 @@ Hide this comment globally. If there is an issue with this comment, please flag it first. Hide user Hide this user and all associated content globally. If there is an issue with a specific post, please flag it first. + + + View random hot network questions + Loading hot network questions + Unable to load hot network questions + Hot network question icon + Fetch new hot network question diff --git a/app/src/main/res/xml/hot_network_questions_widget_info.xml b/app/src/main/res/xml/hot_network_questions_widget_info.xml new file mode 100644 index 00000000..88deb498 --- /dev/null +++ b/app/src/main/res/xml/hot_network_questions_widget_info.xml @@ -0,0 +1,7 @@ + + diff --git a/stackexchange-api/src/main/kotlin/me/tylerbwong/stack/api/di/ApiModule.kt b/stackexchange-api/src/main/kotlin/me/tylerbwong/stack/api/di/ApiModule.kt index 9c3ff388..a488b82a 100644 --- a/stackexchange-api/src/main/kotlin/me/tylerbwong/stack/api/di/ApiModule.kt +++ b/stackexchange-api/src/main/kotlin/me/tylerbwong/stack/api/di/ApiModule.kt @@ -13,6 +13,7 @@ import me.tylerbwong.stack.api.service.AuthService import me.tylerbwong.stack.api.service.CommentService import me.tylerbwong.stack.api.service.FlagService import me.tylerbwong.stack.api.service.InboxService +import me.tylerbwong.stack.api.service.NetworkHotQuestionsService import me.tylerbwong.stack.api.service.QuestionService import me.tylerbwong.stack.api.service.SearchService import me.tylerbwong.stack.api.service.SiteService @@ -86,6 +87,17 @@ class ApiModule { retrofit: Retrofit ): SearchService = retrofit.create(SearchService::class.java) + @Singleton + @Provides + fun provideNetworkHotQuestionsService( + retrofit: Retrofit + ): NetworkHotQuestionsService { + return retrofit.newBuilder() + .baseUrl("https://stackexchange.com") + .build() + .create(NetworkHotQuestionsService::class.java) + } + @Singleton @Provides fun provideQuestionService( diff --git a/stackexchange-api/src/main/kotlin/me/tylerbwong/stack/api/model/NetworkHotQuestion.kt b/stackexchange-api/src/main/kotlin/me/tylerbwong/stack/api/model/NetworkHotQuestion.kt new file mode 100644 index 00000000..4712ad4d --- /dev/null +++ b/stackexchange-api/src/main/kotlin/me/tylerbwong/stack/api/model/NetworkHotQuestion.kt @@ -0,0 +1,26 @@ +package me.tylerbwong.stack.api.model + +import com.squareup.moshi.Json +import com.squareup.moshi.JsonClass + +@JsonClass(generateAdapter = true) +data class NetworkHotQuestion( + @Json(name = "site") + val site: String, + @Json(name = "question_id") + val questionId: Int, + @Json(name = "title") + val title: String, + @Json(name = "display_score") + val displayScore: Double, + @Json(name = "icon_url") + val iconUrl: String, + @Json(name = "creation_date") + val creationDate: Long, + @Json(name = "answer_count") + val answerCount: Int, + @Json(name = "user_name") + val userName: String, + @Json(name = "tags") + val tags: List +) diff --git a/stackexchange-api/src/main/kotlin/me/tylerbwong/stack/api/service/NetworkHotQuestionsService.kt b/stackexchange-api/src/main/kotlin/me/tylerbwong/stack/api/service/NetworkHotQuestionsService.kt new file mode 100644 index 00000000..6f86bb39 --- /dev/null +++ b/stackexchange-api/src/main/kotlin/me/tylerbwong/stack/api/service/NetworkHotQuestionsService.kt @@ -0,0 +1,9 @@ +package me.tylerbwong.stack.api.service + +import me.tylerbwong.stack.api.model.NetworkHotQuestion +import retrofit2.http.GET + +interface NetworkHotQuestionsService { + @GET("hot-questions-json") + suspend fun getHotNetworkQuestions(): List +}