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 00000000..2a4d773d Binary files /dev/null and b/app/src/main/res/drawable-hdpi/ic_refresh_light.png differ 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 +}