Skip to content

Commit

Permalink
feat: add hot network questions widget (#144)
Browse files Browse the repository at this point in the history
* 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 <tbwong3@gmail.com>
  • Loading branch information
G-Rath and tylerbwong authored Jul 15, 2023
1 parent 3956022 commit 3025407
Show file tree
Hide file tree
Showing 15 changed files with 368 additions and 1 deletion.
12 changes: 12 additions & 0 deletions app/src/main/AndroidManifest.xml
Original file line number Diff line number Diff line change
Expand Up @@ -233,5 +233,17 @@
android:value="androidx.startup"
tools:node="remove" />
</provider>

<receiver
android:exported="false"
android:name=".ui.widgets.HotNetworkQuestionsWidget"
android:label="Hot Questions">
<intent-filter>
<action android:name="android.appwidget.action.APPWIDGET_UPDATE" />
</intent-filter>
<meta-data
android:name="android.appwidget.provider"
android:resource="@xml/hot_network_questions_widget_info" />
</receiver>
</application>
</manifest>
Original file line number Diff line number Diff line change
@@ -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<NetworkHotQuestion> {
return networkHotQuestionsService.getHotNetworkQuestions()
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,7 @@ class SiteInterceptor @Inject constructor(
"sites",
"me/associated",
"inbox",
"hot-questions-json",
)
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
5 changes: 5 additions & 0 deletions app/src/main/kotlin/me/tylerbwong/stack/ui/BaseActivity.kt
Original file line number Diff line number Diff line change
Expand Up @@ -72,10 +72,15 @@ abstract class BaseActivity<T : ViewBinding>(
}

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"
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -200,13 +200,15 @@ class QuestionDetailActivity : BaseActivity<ActivityQuestionDetailBinding>(
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))
Expand Down
Original file line number Diff line number Diff line change
@@ -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<NetworkHotQuestion> {
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<List<NetworkHotQuestion>>(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"
}
}
Binary file added app/src/main/res/drawable-hdpi/ic_refresh_light.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
44 changes: 44 additions & 0 deletions app/src/main/res/layout/hot_network_questions_widget.xml
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:background="#FFFFFF"
android:gravity="center_vertical"
android:orientation="horizontal"
android:paddingStart="6dp"
android:paddingEnd="0dp">

<ImageView
android:id="@+id/hotQuestionIcon"
android:layout_width="36dp"
android:layout_height="36dp"
android:contentDescription="@string/hot_network_questions_question_icon"
android:visibility="invisible" />

<TextView
android:id="@+id/hotNetworkQuestionTitleTextView"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginStart="6dp"
android:layout_weight="1"
android:clickable="true"
android:focusable="true"
android:maxLines="3"
android:text="@string/hot_network_questions_unable_to_load"
android:textAppearance="@android:style/TextAppearance.Small"
android:textColor="@android:color/black" />

<FrameLayout
android:id="@+id/fetchNewHotQuestionButton"
android:layout_width="wrap_content"
android:layout_height="fill_parent"
android:background="?android:selectableItemBackground"
android:paddingHorizontal="6dp">

<ImageView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_gravity="center_vertical"
android:contentDescription="@string/hot_network_questions_fetch_new"
android:src="@drawable/ic_refresh_light" />
</FrameLayout>
</LinearLayout>
7 changes: 7 additions & 0 deletions app/src/main/res/values-es/strings.xml
Original file line number Diff line number Diff line change
Expand Up @@ -275,4 +275,11 @@
<string name="hide_comment_message">Ocultar este comentario globalmente. Si hay un problema con este comentario, márcalo primero.</string>
<string name="hide_user">Ocultar usuario</string>
<string name="hide_user_message">Oculte este usuario y todo el contenido asociado globalmente. Si hay un problema con una publicación específica, márquelo primero.</string>

<!-- Hot Network Questions Widget -->
<string name="hot_network_questions_description">Ver preguntas aleatorias de redes populares</string>
<string name="hot_network_questions_loading">Cargando preguntas calientes de la red</string>
<string name="hot_network_questions_unable_to_load">No se pueden cargar las preguntas de la red caliente</string>
<string name="hot_network_questions_question_icon">Icono de pregunta de red caliente</string>
<string name="hot_network_questions_fetch_new">Obtener nueva pregunta de red activa</string>
</resources>
7 changes: 7 additions & 0 deletions app/src/main/res/values/strings.xml
Original file line number Diff line number Diff line change
Expand Up @@ -293,4 +293,11 @@
<string name="hide_comment_message">Hide this comment globally. If there is an issue with this comment, please flag it first.</string>
<string name="hide_user">Hide user</string>
<string name="hide_user_message">Hide this user and all associated content globally. If there is an issue with a specific post, please flag it first.</string>

<!-- Hot Network Questions Widget -->
<string name="hot_network_questions_description">View random hot network questions</string>
<string name="hot_network_questions_loading">Loading hot network questions</string>
<string name="hot_network_questions_unable_to_load">Unable to load hot network questions</string>
<string name="hot_network_questions_question_icon">Hot network question icon</string>
<string name="hot_network_questions_fetch_new">Fetch new hot network question</string>
</resources>
7 changes: 7 additions & 0 deletions app/src/main/res/xml/hot_network_questions_widget_info.xml
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
<appwidget-provider xmlns:android="http://schemas.android.com/apk/res/android"
android:description="@string/hot_network_questions_description"
android:initialLayout="@layout/hot_network_questions_widget"
android:minWidth="250dp"
android:minHeight="40dp"
android:updatePeriodMillis="3600000">
</appwidget-provider>
Loading

0 comments on commit 3025407

Please sign in to comment.