-
-
Notifications
You must be signed in to change notification settings - Fork 36
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
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 <tbwong3@gmail.com>
- Loading branch information
1 parent
3956022
commit 3025407
Showing
15 changed files
with
368 additions
and
1 deletion.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
15 changes: 15 additions & 0 deletions
15
app/src/main/kotlin/me/tylerbwong/stack/data/repository/NetworkHotQuestionsRepository.kt
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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() | ||
} | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
|
@@ -49,6 +49,7 @@ class SiteInterceptor @Inject constructor( | |
"sites", | ||
"me/associated", | ||
"inbox", | ||
"hot-questions-json", | ||
) | ||
} | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
216 changes: 216 additions & 0 deletions
216
app/src/main/kotlin/me/tylerbwong/stack/ui/widgets/HotNetworkQuestionsWidget.kt
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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" | ||
} | ||
} |
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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> |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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> |
Oops, something went wrong.