Skip to content

Commit 3bd22e6

Browse files
authored
Rabbit Holes (feature branch) (#5114)
* Rabbit Holes (feature branch) * Add missing stuff. * Lint. * Show survey after saving suggested reading list. * Use last two history entries for suggested reading list. * Rejigger arrangement of buttons. * Whoops. * Deduplicate. * Tweak strings. * Wire up most of analytics. * Design feedback for reading lists. * Further design feedback. * Exclude Main Page from potential sources of suggestions. * Also exclude Main Page from search suggestions. * Fix dark mode issue. * Deduplicate recent searches vs suggestion. * Touch up analytics. * Prevent showing survey twice. * Send events only when/where experiment is active. * Simplify a bit. * A bit more feedback.
1 parent b789647 commit 3bd22e6

34 files changed

+843
-171
lines changed

app/src/main/java/org/wikipedia/Constants.kt

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -107,6 +107,8 @@ object Constants {
107107
USER_CONTRIB_ACTIVITY("userContribActivity"),
108108
EDIT_ADD_IMAGE("editAddImage"),
109109
SUGGESTED_EDITS_RECENT_EDITS("suggestedEditsRecentEdits"),
110+
RABBIT_HOLE_SEARCH("rabbitHoleSearch"),
111+
RABBIT_HOLE_READING_LIST("rabbitHoleReadingList")
110112
}
111113

112114
enum class ImageEditType(name: String) {
Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,46 @@
1+
package org.wikipedia.analytics.eventplatform
2+
3+
import kotlinx.serialization.SerialName
4+
import kotlinx.serialization.Serializable
5+
import org.wikipedia.WikipediaApp
6+
import org.wikipedia.analytics.metricsplatform.RabbitHolesAnalyticsHelper
7+
import org.wikipedia.json.JsonUtil
8+
9+
object RabbitHolesEvent {
10+
fun submit(
11+
action: String,
12+
activeInterface: String,
13+
source: String? = null,
14+
feedbackSelect: String? = null,
15+
feedbackText: String? = null,
16+
wikiId: String = WikipediaApp.instance.appOrSystemLanguageCode
17+
) {
18+
if (!RabbitHolesAnalyticsHelper.rabbitHolesEnabled) {
19+
return
20+
}
21+
22+
EventPlatformClient.submit(
23+
AppInteractionEvent(
24+
action,
25+
activeInterface,
26+
JsonUtil.encodeToString(ActionData(
27+
groupAssigned = RabbitHolesAnalyticsHelper.abcTest.getGroupName(),
28+
source = source,
29+
feedbackText = feedbackText,
30+
feedbackSelect = feedbackSelect
31+
)).orEmpty(),
32+
WikipediaApp.instance.languageState.appLanguageCode,
33+
wikiId,
34+
"app_rabbit_holes"
35+
)
36+
)
37+
}
38+
39+
@Serializable
40+
class ActionData(
41+
@SerialName("group_assigned") val groupAssigned: String? = null,
42+
@SerialName("source") val source: String? = null,
43+
@SerialName("feedback_select") val feedbackSelect: String? = null,
44+
@SerialName("feedback_text") val feedbackText: String? = null,
45+
)
46+
}
Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
package org.wikipedia.analytics.metricsplatform
2+
3+
import org.wikipedia.analytics.ABTest
4+
5+
class RabbitHolesABCTest : ABTest("rabbitHoles", GROUP_SIZE_3) {
6+
fun getGroupName(): String {
7+
return when (group) {
8+
GROUP_2 -> "search"
9+
GROUP_3 -> "reading_list"
10+
else -> "control"
11+
}
12+
}
13+
}
Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
package org.wikipedia.analytics.metricsplatform
2+
3+
import org.wikipedia.util.GeoUtil
4+
import org.wikipedia.util.ReleaseUtil
5+
import java.time.LocalDate
6+
7+
object RabbitHolesAnalyticsHelper {
8+
val abcTest = RabbitHolesABCTest()
9+
10+
private val enabledCountries = listOf(
11+
// sub-saharan africa
12+
"AO", "BJ", "BW", "IO", "BF", "BI", "CV", "CM", "CF", "TD", "KM", "CG", "IC", "CD", "DJ", "GQ", "ER",
13+
"SZ", "ET", "GA", "GM", "GH", "GN", "GW", "KE", "LS", "LR", "MG", "MW", "ML", "MR", "MU", "YT", "MZ",
14+
"NA", "NE", "NG", "RE", "RW", "SH", "ST", "SN", "SC", "SL", "SO", "ZA", "SS", "TG", "UG", "TZ", "ZM",
15+
"ZW",
16+
// south asia
17+
"IN", "PK", "BD", "LK", "MV", "NP", "BT", "AF"
18+
)
19+
20+
val rabbitHolesEnabled get() = ReleaseUtil.isPreBetaRelease ||
21+
(enabledCountries.contains(GeoUtil.geoIPCountry.orEmpty()) &&
22+
LocalDate.now() <= LocalDate.of(2024, 12, 31))
23+
}

app/src/main/java/org/wikipedia/dataclient/mwapi/MwQueryPage.kt

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -25,7 +25,7 @@ class MwQueryPage {
2525
@SerialName("pageprops") val pageProps: PageProps? = null
2626
@SerialName("entityterms") val entityTerms: EntityTerms? = null
2727

28-
private val ns = 0
28+
val ns = 0
2929
val coordinates: List<Coordinates>? = null
3030
private val thumbnail: Thumbnail? = null
3131
val varianttitles: Map<String, String>? = null

app/src/main/java/org/wikipedia/history/HistoryEntry.kt

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -100,5 +100,7 @@ class HistoryEntry(
100100
const val SOURCE_SINGLE_WEBVIEW = 40
101101
const val SOURCE_SUGGESTED_EDITS_RECENT_EDITS = 41
102102
const val SOURCE_FEED_PLACES = 42
103+
const val SOURCE_RABBIT_HOLE_SEARCH = 43
104+
const val SOURCE_RABBIT_HOLE_READING_LIST = 44
103105
}
104106
}

app/src/main/java/org/wikipedia/history/db/HistoryEntryDao.kt

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,9 @@ interface HistoryEntryDao {
1414
@Query("SELECT * FROM HistoryEntry WHERE authority = :authority AND lang = :lang AND apiTitle = :apiTitle LIMIT 1")
1515
suspend fun findEntryBy(authority: String, lang: String, apiTitle: String): HistoryEntry?
1616

17+
@Query("SELECT * FROM HistoryEntry WHERE lang = :lang ORDER BY timestamp DESC LIMIT :count")
18+
suspend fun getLastHistoryEntries(lang: String, count: Int): List<HistoryEntry>
19+
1720
@Query("DELETE FROM HistoryEntry")
1821
suspend fun deleteAll()
1922

app/src/main/java/org/wikipedia/page/PageActivity.kt

Lines changed: 128 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -29,22 +29,30 @@ import androidx.lifecycle.repeatOnLifecycle
2929
import androidx.preference.PreferenceManager
3030
import com.google.android.material.dialog.MaterialAlertDialogBuilder
3131
import com.google.android.material.snackbar.Snackbar
32+
import kotlinx.coroutines.CoroutineExceptionHandler
33+
import kotlinx.coroutines.delay
3234
import kotlinx.coroutines.flow.collectLatest
3335
import kotlinx.coroutines.launch
36+
import kotlinx.serialization.json.encodeToJsonElement
3437
import org.wikipedia.Constants
3538
import org.wikipedia.Constants.InvokeSource
3639
import org.wikipedia.R
3740
import org.wikipedia.WikipediaApp
3841
import org.wikipedia.activity.BaseActivity
3942
import org.wikipedia.activity.SingleWebViewActivity
43+
import org.wikipedia.analytics.ABTest
4044
import org.wikipedia.analytics.eventplatform.ArticleLinkPreviewInteractionEvent
4145
import org.wikipedia.analytics.eventplatform.BreadCrumbLogEvent
4246
import org.wikipedia.analytics.eventplatform.DonorExperienceEvent
47+
import org.wikipedia.analytics.eventplatform.RabbitHolesEvent
4348
import org.wikipedia.analytics.metricsplatform.ArticleLinkPreviewInteraction
49+
import org.wikipedia.analytics.metricsplatform.RabbitHolesAnalyticsHelper
4450
import org.wikipedia.auth.AccountUtil
4551
import org.wikipedia.commons.FilePageActivity
4652
import org.wikipedia.concurrency.FlowEventBus
53+
import org.wikipedia.database.AppDatabase
4754
import org.wikipedia.databinding.ActivityPageBinding
55+
import org.wikipedia.dataclient.ServiceFactory
4856
import org.wikipedia.dataclient.WikiSite
4957
import org.wikipedia.dataclient.donate.CampaignCollection
5058
import org.wikipedia.dataclient.mwapi.MwQueryPage
@@ -58,13 +66,15 @@ import org.wikipedia.events.ChangeTextSizeEvent
5866
import org.wikipedia.extensions.parcelableExtra
5967
import org.wikipedia.gallery.GalleryActivity
6068
import org.wikipedia.history.HistoryEntry
69+
import org.wikipedia.json.JsonUtil
6170
import org.wikipedia.language.LangLinksActivity
6271
import org.wikipedia.navtab.NavTab
6372
import org.wikipedia.notifications.AnonymousNotificationHelper
6473
import org.wikipedia.notifications.NotificationActivity
6574
import org.wikipedia.page.linkpreview.LinkPreviewDialog
6675
import org.wikipedia.page.tabs.TabActivity
6776
import org.wikipedia.readinglist.ReadingListActivity
77+
import org.wikipedia.readinglist.ReadingListsShareHelper
6878
import org.wikipedia.search.SearchActivity
6979
import org.wikipedia.settings.Prefs
7080
import org.wikipedia.staticdata.MainPageNameData
@@ -84,9 +94,11 @@ import org.wikipedia.util.UriUtil
8494
import org.wikipedia.util.log.L
8595
import org.wikipedia.views.FrameLayoutNavMenuTriggerer
8696
import org.wikipedia.views.ObservableWebView
97+
import org.wikipedia.views.SurveyDialog
8798
import org.wikipedia.views.ViewUtil
8899
import org.wikipedia.watchlist.WatchlistExpiry
89100
import java.util.Locale
101+
import java.util.concurrent.TimeUnit
90102

91103
class PageActivity : BaseActivity(), PageFragment.Callback, LinkPreviewDialog.LoadPageCallback, FrameLayoutNavMenuTriggerer.Callback {
92104

@@ -104,6 +116,7 @@ class PageActivity : BaseActivity(), PageFragment.Callback, LinkPreviewDialog.Lo
104116
private val isCabOpen get() = currentActionModes.isNotEmpty()
105117
private var exclusiveTooltipRunnable: Runnable? = null
106118
private var isTooltipShowing = false
119+
private var suggestedSearchTerm: String? = null
107120

108121
private val requestEditSectionLauncher = registerForActivityResult(ActivityResultContracts.StartActivityForResult()) {
109122
if (it.resultCode == EditHandler.RESULT_REFRESH_PAGE) {
@@ -179,6 +192,13 @@ class PageActivity : BaseActivity(), PageFragment.Callback, LinkPreviewDialog.Lo
179192
}
180193
}
181194

195+
private val requestSuggestedReadingListLauncher = registerForActivityResult(ActivityResultContracts.StartActivityForResult()) {
196+
if (it.resultCode == RESULT_CANCELED) {
197+
RabbitHolesEvent.submit("impression", "reading_list_warn")
198+
FeedbackUtil.showMessage(this, R.string.suggested_reading_list_back_cancel)
199+
}
200+
}
201+
182202
public override fun onCreate(savedInstanceState: Bundle?) {
183203
super.onCreate(savedInstanceState)
184204
PreferenceManager.setDefaultValues(this, R.xml.preferences, false)
@@ -214,7 +234,8 @@ class PageActivity : BaseActivity(), PageFragment.Callback, LinkPreviewDialog.Lo
214234
binding.pageToolbarButtonSearch.setOnClickListener {
215235
pageFragment.articleInteractionEvent?.logSearchWikipediaClick()
216236
pageFragment.metricsPlatformArticleEventToolbarInteraction.logSearchWikipediaClick()
217-
startActivity(SearchActivity.newIntent(this@PageActivity, InvokeSource.TOOLBAR, null))
237+
startActivity(SearchActivity.newIntent(this@PageActivity, InvokeSource.TOOLBAR, null,
238+
suggestedSearchQuery = suggestedSearchTerm))
218239
}
219240
binding.pageToolbarButtonTabs.updateTabCount(false)
220241
binding.pageToolbarButtonTabs.setOnClickListener {
@@ -397,6 +418,8 @@ class PageActivity : BaseActivity(), PageFragment.Callback, LinkPreviewDialog.Lo
397418
override fun onPageLoadComplete() {
398419
removeTransitionAnimState()
399420
maybeShowThemeTooltip()
421+
422+
maybeStartRabbitHole()
400423
}
401424

402425
override fun onPageDismissBottomSheet() {
@@ -576,6 +599,9 @@ class PageActivity : BaseActivity(), PageFragment.Callback, LinkPreviewDialog.Lo
576599
* foreground tab.
577600
*/
578601
private fun loadPage(pageTitle: PageTitle?, entry: HistoryEntry?, position: TabPosition) {
602+
603+
binding.pageToolbarButtonSearch.setText(R.string.search_hint)
604+
579605
if (isDestroyed || pageTitle == null || entry == null) {
580606
return
581607
}
@@ -794,6 +820,107 @@ class PageActivity : BaseActivity(), PageFragment.Callback, LinkPreviewDialog.Lo
794820
}
795821
}
796822

823+
private fun maybeStartRabbitHole() {
824+
if (!RabbitHolesAnalyticsHelper.rabbitHolesEnabled || RabbitHolesAnalyticsHelper.abcTest.group == ABTest.GROUP_1) {
825+
return
826+
}
827+
lifecycleScope.launch(CoroutineExceptionHandler { _, t ->
828+
L.e(t)
829+
}) {
830+
pageFragment.title?.let { title ->
831+
if (RabbitHolesAnalyticsHelper.abcTest.group == ABTest.GROUP_2) {
832+
if (title.displayText != MainPageNameData.valueFor(title.wikiSite.languageCode)) {
833+
val response = ServiceFactory.get(title.wikiSite).searchMoreLike("morelike:${title.prefixedText}", 3, 3)
834+
response.query?.pages?.firstOrNull()?.let { page ->
835+
applySuggestedSearchTerm(page.displayTitle(title.wikiSite.languageCode))
836+
}
837+
}
838+
} else if (RabbitHolesAnalyticsHelper.abcTest.group == ABTest.GROUP_3 && !Prefs.suggestedReadingListDialogShown) {
839+
val historyEntries = AppDatabase.instance.historyEntryDao().getLastHistoryEntries(title.wikiSite.languageCode, 2)
840+
.filter { it.displayTitle != MainPageNameData.valueFor(title.wikiSite.languageCode) }
841+
if (historyEntries.size < 2) {
842+
return@launch
843+
}
844+
845+
val pages = mutableListOf<MwQueryPage>()
846+
historyEntries.forEach { entry ->
847+
val response = ServiceFactory.get(title.wikiSite).searchMoreLike("morelike:${entry.apiTitle}", 10, 10)
848+
response.query?.pages?.filter { it.title != historyEntries[0].apiTitle && it.title != historyEntries[1].apiTitle }?.take(5)?.let { pages.addAll(it) }
849+
}
850+
851+
if (pages.isNotEmpty()) {
852+
applySuggestedReadingList(historyEntries[0], historyEntries[1], pages)
853+
854+
Prefs.suggestedReadingListDialogShown = true
855+
RabbitHolesEvent.submit("impression", "reading_list_prompt")
856+
857+
MaterialAlertDialogBuilder(this@PageActivity)
858+
.setTitle(R.string.suggested_reading_list_dialog_title)
859+
.setMessage(R.string.suggested_reading_list_dialog_body)
860+
.setPositiveButton(R.string.suggested_reading_list_dialog_positive) { _, _ ->
861+
RabbitHolesEvent.submit("enter_click", "reading_list_prompt")
862+
requestSuggestedReadingListLauncher.launch(ReadingListActivity.newIntent(this@PageActivity, true, suggestedList = true))
863+
}
864+
.setNegativeButton(R.string.suggested_reading_list_dialog_negative) { _, _ ->
865+
RabbitHolesEvent.submit("ignore_click", "reading_list_prompt")
866+
FeedbackUtil.showMessage(this@PageActivity, R.string.suggested_reading_list_later_snackbar)
867+
}
868+
.show()
869+
}
870+
}
871+
}
872+
}
873+
maybeShowRabbitHolesSurvey()
874+
}
875+
876+
private fun applySuggestedSearchTerm(term: String) {
877+
suggestedSearchTerm = term
878+
binding.pageToolbarButtonSearch.text = term
879+
}
880+
881+
private fun applySuggestedReadingList(basedOnTitle1: HistoryEntry, basedOnTitle2: HistoryEntry, pages: List<MwQueryPage>) {
882+
val listItems = pages.map {
883+
JsonUtil.json.encodeToJsonElement(
884+
ReadingListsShareHelper.ExportedReadingListPage(
885+
basedOnTitle1.title.wikiSite.languageCode,
886+
it.displayTitle(basedOnTitle1.title.wikiSite.languageCode),
887+
it.ns,
888+
it.description,
889+
it.thumbUrl()
890+
)
891+
)
892+
}
893+
val readingList = ReadingListsShareHelper.ExportedReadingList(
894+
list = mapOf(basedOnTitle1.title.wikiSite.languageCode to listItems),
895+
name = getString(R.string.suggested_reading_list_title),
896+
description = getString(R.string.suggested_reading_list_description_multi,
897+
StringUtil.fromHtml(basedOnTitle1.title.displayText).toString(),
898+
StringUtil.fromHtml(basedOnTitle2.title.displayText).toString())
899+
)
900+
Prefs.importReadingListsDialogShown = false
901+
Prefs.suggestedReadingListsData = JsonUtil.encodeToString(readingList)
902+
}
903+
904+
private fun maybeShowRabbitHolesSurvey() {
905+
lifecycleScope.launch(CoroutineExceptionHandler { _, t ->
906+
L.e(t)
907+
}) {
908+
delay(TimeUnit.SECONDS.toMillis(if (ReleaseUtil.isDevRelease) 1L else 10L))
909+
pageFragment.historyEntry?.let {
910+
if (!Prefs.suggestedContentSurveyShown && it.source == HistoryEntry.SOURCE_RABBIT_HOLE_SEARCH) {
911+
Prefs.suggestedContentSurveyShown = true
912+
SurveyDialog.showFeedbackOptionsDialog(
913+
this@PageActivity,
914+
titleId = R.string.rabbit_holes_survey_dialog_title,
915+
messageId = R.string.rabbit_holes_survey_dialog_body,
916+
snackbarMessageId = R.string.survey_dialog_submitted_snackbar,
917+
invokeSource = InvokeSource.RABBIT_HOLE_SEARCH
918+
)
919+
}
920+
}
921+
}
922+
}
923+
797924
companion object {
798925
private const val LANGUAGE_CODE_BUNDLE_KEY = "language"
799926
private const val EXCEPTION_MESSAGE_WEBVIEW = "webview"

app/src/main/java/org/wikipedia/readinglist/ReadingListActivity.kt

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -35,23 +35,29 @@ class ReadingListActivity : SingleFragmentActivity<ReadingListFragment>() {
3535
super.onBackPressed()
3636
if (intent.getBooleanExtra(EXTRA_READING_LIST_PREVIEW, false)) {
3737
ReadingListsAnalyticsHelper.logReceiveCancel(this, fragment.readingList)
38+
} else if (intent.getBooleanExtra(EXTRA_READING_LIST_SUGGESTED, false)) {
39+
setResult(RESULT_CANCELED)
3840
}
3941
}
4042

4143
companion object {
4244
private const val EXTRA_READING_LIST_TITLE = "readingListTitle"
4345
const val EXTRA_READING_LIST_ID = "readingListId"
4446
const val EXTRA_READING_LIST_PREVIEW = "previewReadingList"
47+
const val EXTRA_READING_LIST_SUGGESTED = "suggestedReadingList"
48+
const val EXTRA_READING_LIST_SUGGESTED_SAVE = "suggestedReadingListSave"
4549

4650
fun newIntent(context: Context, list: ReadingList): Intent {
4751
return Intent(context, ReadingListActivity::class.java)
4852
.putExtra(EXTRA_READING_LIST_TITLE, list.title)
4953
.putExtra(EXTRA_READING_LIST_ID, list.id)
5054
}
5155

52-
fun newIntent(context: Context, preview: Boolean): Intent {
56+
fun newIntent(context: Context, preview: Boolean, suggestedList: Boolean = false, suggestedListSave: Boolean = false): Intent {
5357
return Intent(context, ReadingListActivity::class.java)
5458
.putExtra(EXTRA_READING_LIST_PREVIEW, preview)
59+
.putExtra(EXTRA_READING_LIST_SUGGESTED, suggestedList)
60+
.putExtra(EXTRA_READING_LIST_SUGGESTED_SAVE, suggestedListSave)
5561
}
5662
}
5763
}

0 commit comments

Comments
 (0)