Skip to content

HackWeek EoY Demo #4135

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Draft
wants to merge 4 commits into
base: main
Choose a base branch
from
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ import au.com.shiftyjelly.pocketcasts.profile.cloud.CloudFilesFragment
import au.com.shiftyjelly.pocketcasts.referrals.ReferralsGuestPassFragment
import au.com.shiftyjelly.pocketcasts.referrals.ReferralsGuestPassFragment.ReferralsPageType
import au.com.shiftyjelly.pocketcasts.referrals.ReferralsViewModel
import au.com.shiftyjelly.pocketcasts.settings.HackWeekEoYFragment
import au.com.shiftyjelly.pocketcasts.settings.HelpFragment
import au.com.shiftyjelly.pocketcasts.settings.SettingsFragment
import au.com.shiftyjelly.pocketcasts.settings.onboarding.OnboardingFlow
Expand Down Expand Up @@ -160,6 +161,7 @@ class ProfileFragment : BaseFragment(), TopScrollable {
ProfileSection.Bookmarks -> BookmarksContainerFragment.newInstance(sourceView = SourceView.PROFILE)
ProfileSection.ListeningHistory -> ProfileEpisodeListFragment.newInstance(ProfileEpisodeListFragment.Mode.History)
ProfileSection.Help -> HelpFragment()
ProfileSection.Hack -> HackWeekEoYFragment()
}
fragmentHostListener.addFragment(fragment)
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -111,6 +111,10 @@ internal enum class ProfileSection(
iconId = IR.drawable.ic_help,
labelId = LR.string.settings_title_help,
),
Hack(
iconId = IR.drawable.ic_arrow_right_small,
labelId = LR.string.settings_title_hack,
),
}

@Preview
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -167,8 +167,9 @@ class ProfileViewModel @Inject constructor(
ProfileSection.Bookmarks -> AnalyticsEvent.PROFILE_BOOKMARKS_SHOWN
ProfileSection.ListeningHistory -> AnalyticsEvent.LISTENING_HISTORY_SHOWN
ProfileSection.Help -> AnalyticsEvent.SETTINGS_HELP_SHOWN
ProfileSection.Hack -> null // don't track it for now
}
tracker.track(event)
event?.let(tracker::track)
}

internal fun refreshProfile() {
Expand Down
1 change: 1 addition & 0 deletions modules/features/settings/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -77,6 +77,7 @@ dependencies {

implementation(projects.modules.services.images)
implementation(projects.modules.services.localization)
implementation(projects.modules.services.sharing)

debugImplementation(libs.compose.ui.tooling)

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,125 @@
package au.com.shiftyjelly.pocketcasts.settings

import android.content.Context
import android.os.Bundle
import android.view.LayoutInflater
import android.view.ViewGroup
import android.webkit.WebView
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.padding
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.unit.dp
import androidx.fragment.compose.content
import au.com.shiftyjelly.pocketcasts.compose.AppThemeWithBackground
import au.com.shiftyjelly.pocketcasts.preferences.Settings
import au.com.shiftyjelly.pocketcasts.repositories.user.UserManager
import au.com.shiftyjelly.pocketcasts.settings.onboarding.OnboardingFlow
import au.com.shiftyjelly.pocketcasts.settings.onboarding.OnboardingLauncher
import au.com.shiftyjelly.pocketcasts.settings.onboarding.OnboardingUpgradeSource
import au.com.shiftyjelly.pocketcasts.settings.util.WebViewScreenshotCapture
import au.com.shiftyjelly.pocketcasts.sharing.SharingClient
import au.com.shiftyjelly.pocketcasts.sharing.SharingRequest
import au.com.shiftyjelly.pocketcasts.ui.extensions.setupKeyboardModePan
import au.com.shiftyjelly.pocketcasts.ui.extensions.setupKeyboardModeResize
import au.com.shiftyjelly.pocketcasts.utils.extensions.pxToDp
import au.com.shiftyjelly.pocketcasts.views.fragments.BaseFragment
import au.com.shiftyjelly.pocketcasts.views.helper.HasBackstack
import dagger.hilt.android.AndroidEntryPoint
import javax.inject.Inject
import kotlinx.coroutines.launch

@AndroidEntryPoint
class HackWeekEoYFragment : BaseFragment(), HasBackstack {

private var webView: WebView? = null

@Inject lateinit var userManager: UserManager

@Inject lateinit var sharingClient: SharingClient

@Inject lateinit var settings: Settings

@Inject lateinit var screenshotCapture: WebViewScreenshotCapture

override fun onCreateView(
inflater: LayoutInflater,
container: ViewGroup?,
savedInstanceState: Bundle?,
) = content {
val coroutineScope = rememberCoroutineScope()

val bottomPadding by settings.bottomInset.collectAsState(0)
val miniPlayerPadding = bottomPadding.pxToDp(LocalContext.current).dp

val basePadding = 16.dp
val totalPadding = basePadding + miniPlayerPadding

AppThemeWithBackground(theme.activeTheme) {
HackWeekEoYPage(
onGoBack = {
@Suppress("DEPRECATION")
activity?.onBackPressed()
},
isSubscribedCallback = {
userManager.getSignInState().map { it.isSignedIn }.blockingFirst(false)
},
onUpsell = {
OnboardingLauncher.openOnboardingFlow(
activity = requireActivity(),
onboardingFlow = OnboardingFlow.Upsell(OnboardingUpgradeSource.END_OF_YEAR),
)
},
onShareScreenshot = {
val activity = activity
val webView = webView

if (activity != null && webView != null) {
coroutineScope.launch {
val file = screenshotCapture.captureScreenshot(webView, activity)
file?.let {
sharingClient.share(SharingRequest.hackEoYScreenshot(it).build())
}
}
}
},
onWebViewCreated = { webView = it },
onWebViewDisposed = { webView = null },
modifier = Modifier.fillMaxSize()
.padding(bottom = totalPadding),
)
}
}

override fun onAttach(context: Context) {
super.onAttach(context)
setupKeyboardModeResize()
}

override fun onDetach() {
super.onDetach()
setupKeyboardModePan()
}

override fun onDestroyView() {
super.onDestroyView()
webView = null
}

override fun getBackstackCount(): Int {
return if (webView?.canGoBack() == true) 1 else 0
}

override fun onBackPressed(): Boolean {
return webView?.let {
val canGoBack = it.canGoBack()
if (canGoBack) {
it.goBack()
}
canGoBack
} ?: false
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,135 @@
package au.com.shiftyjelly.pocketcasts.settings

import android.annotation.SuppressLint
import android.webkit.HttpAuthHandler
import android.webkit.JavascriptInterface
import android.webkit.WebView
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.material.CircularProgressIndicator
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.zIndex
import com.kevinnzou.web.AccompanistWebViewClient
import com.kevinnzou.web.WebView
import com.kevinnzou.web.rememberWebViewState
import kotlinx.coroutines.launch

@Composable
internal fun HackWeekEoYPage(
onGoBack: () -> Unit,
isSubscribedCallback: () -> Boolean,
onUpsell: () -> Unit,
onShareScreenshot: () -> Unit,
modifier: Modifier = Modifier,
onWebViewCreated: (WebView) -> Unit = {},
onWebViewDisposed: (WebView) -> Unit = {},
) {
var isWebViewLoading by remember { mutableStateOf(true) }
val webViewState = rememberWebViewState(url = "https://pocketcasts.net/eoy/")
val scope = rememberCoroutineScope()

Box(
modifier = modifier,
contentAlignment = Alignment.Center,
) {
WebView(
state = webViewState,
modifier = Modifier
.fillMaxSize()
.zIndex(1f),
client = remember {
WebViewClient(
onPageLoaded = { isWebViewLoading = false },
)
},
onCreated = { webView ->
webView.settings.apply {
@SuppressLint("SetJavaScriptEnabled")
javaScriptEnabled = true
domStorageEnabled = true
textZoom = 100
}
webView.addJavascriptInterface(
EoYJavascriptInterface {
scope.launch {
when (it) {
EoYWebMessage.Loaded -> webView.evaluateJavascript(
"window.setUserData({subscriber: ${isSubscribedCallback()}});",
null,
)

EoYWebMessage.Close -> onGoBack()
EoYWebMessage.Upsell -> onUpsell()
is EoYWebMessage.ShareStory -> onShareScreenshot()
}
}
},
"Android",
)
onWebViewCreated(webView)
},
onDispose = onWebViewDisposed,
)
AnimatedVisibility(
visible = isWebViewLoading,
enter = fadeIn(),
exit = fadeOut(),
) {
CircularProgressIndicator()
}
}
}

sealed class EoYWebMessage {
data object Loaded : EoYWebMessage()
data object Close : EoYWebMessage()
data class ShareStory(val payload: String?) : EoYWebMessage()
data object Upsell : EoYWebMessage()
}

private class EoYJavascriptInterface(
val onMessageReceived: (EoYWebMessage) -> Unit,
) {
@JavascriptInterface
fun loaded(data: Any?) {
onMessageReceived(EoYWebMessage.Loaded)
}

@JavascriptInterface
fun closeStories(data: String?) {
onMessageReceived(EoYWebMessage.Close)
}

@JavascriptInterface
fun shareStory(data: String?) {
onMessageReceived(EoYWebMessage.ShareStory(data))
}

@JavascriptInterface
fun plusFlow(data: String?) {
onMessageReceived(EoYWebMessage.Upsell)
}
}

private class WebViewClient(
val onPageLoaded: () -> Unit,
) : AccompanistWebViewClient() {
override fun onReceivedHttpAuthRequest(view: WebView?, handler: HttpAuthHandler?, host: String?, realm: String?) {
handler?.proceed("www-latest", "wjn3tbqwtf4CGMfgk1")
}

override fun onPageFinished(view: WebView, url: String?) {
super.onPageFinished(view, url)
onPageLoaded()
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,91 @@
package au.com.shiftyjelly.pocketcasts.settings.util

import android.app.Activity
import android.content.Context
import android.graphics.Bitmap
import android.graphics.Canvas
import android.graphics.Rect
import android.os.Build
import android.os.Looper
import android.view.PixelCopy
import android.view.Window
import android.webkit.WebView
import androidx.annotation.RequiresApi
import androidx.core.graphics.createBitmap
import java.io.File
import javax.inject.Inject
import kotlin.coroutines.resume
import kotlin.coroutines.resumeWithException
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.suspendCancellableCoroutine
import kotlinx.coroutines.withContext
import timber.log.Timber

class WebViewScreenshotCapture @Inject constructor() {
suspend fun captureScreenshot(webView: WebView, activity: Activity) = captureToFile(webView) { activity.window }

private suspend fun captureToFile(webView: WebView, windowProvider: () -> Window) = runCatching {
val bitmap = if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) {
drawToCanvas(webView)
} else {
usePixelCopy(webView, windowProvider())
}
writeToFile(webView.context, bitmap)
}.onSuccess {
Timber.d("Captured bitmap using PixelCopy API")
}.onFailure {
Timber.d("Failed to capture bitmap using PixelCopy API: $it")
}.getOrNull()

@RequiresApi(Build.VERSION_CODES.O)
private suspend fun usePixelCopy(webView: WebView, window: Window) = suspendCancellableCoroutine {
val location = IntArray(2)
webView.getLocationInWindow(location)
val x = location[0]
val y = location[1]

val width = webView.width
val height = webView.height

if (width == 0 || height == 0) {
it.resumeWithException(IllegalStateException("Failed to determine webview size"))
}

val bitmap = createBitmap(width, height)

try {
PixelCopy.request(
window,
Rect(x, y, x + width, y + height),
bitmap,
{ result ->
if (result == PixelCopy.SUCCESS) {
it.resume(bitmap)
} else {
it.resumeWithException(IllegalStateException("Failed to capture bitmap using PixelCopy API"))
}
},
android.os.Handler(Looper.getMainLooper()),
)
} catch (e: IllegalArgumentException) {
it.resumeWithException(e)
}
}

private fun drawToCanvas(webView: WebView): Bitmap {
val width = webView.width
val height = webView.height
val bitmap = createBitmap(width, height)
val canvas = Canvas(bitmap)
webView.draw(canvas)
return bitmap
}

private suspend fun writeToFile(context: Context, bitmap: Bitmap) = withContext(Dispatchers.IO) {
val file = File(context.cacheDir, "pocket-casts-eoy.png")
file.outputStream().use { stream ->
bitmap.compress(Bitmap.CompressFormat.PNG, 100, stream)
}
file
}
}
Loading