Skip to content
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

Sending refresh token from phone to watch #801

Merged
merged 24 commits into from
Apr 26, 2023
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
24 commits
Select commit Hold shift + click to select a range
6283fa1
Initial POC sending refresh token from phone to watch
mchowning Feb 25, 2023
033afa9
Use Horologist for authentication
luizgrp Mar 6, 2023
9aa8dc1
Update horologist to 0.3.9
mchowning Mar 16, 2023
087521c
Spotless fixes
mchowning Mar 16, 2023
f2fc35c
Turn warnings as errors back on
mchowning Mar 16, 2023
bf2ad8a
Remove unnecessary dependency declaration
mchowning Mar 16, 2023
7465ea0
Clarify/simplify kotlin stdlib version constraint
mchowning Mar 17, 2023
ca51cb6
Remove compileSdkPreview declarations
mchowning Mar 17, 2023
23319e0
Updating our lifecycle dependencies to 2.6.0
mchowning Mar 17, 2023
b2a243e
Update core-ktx to 1.9.0
mchowning Mar 17, 2023
14e8817
Merge branch 'update/wear-with-lifecycle-dep-updates' into update/aut…
mchowning Mar 29, 2023
abe8917
Remove fixme comment
mchowning Mar 29, 2023
54d36be
Require horologist opt-in in account module
mchowning Apr 5, 2023
abc3da5
Improve logging
mchowning Apr 5, 2023
094ff27
Minor formatting fix
mchowning Apr 5, 2023
17806af
Merge remote-tracking branch 'origin/main' into update/authenticate-w…
mchowning Apr 14, 2023
4566f5d
Got login from phone working again after mergin origin/main
mchowning Apr 14, 2023
ecf1063
Show notification when user is logged into watch from phone
mchowning Apr 15, 2023
946c71b
Sync podcasts after watch login from phone
mchowning Apr 15, 2023
35e7953
Show sign in notification until refresh is done
mchowning Apr 15, 2023
d486b0a
Add email to logging in notification
mchowning Apr 22, 2023
95c5287
Make sure login notification doesn't dismiss too quickly
mchowning Apr 22, 2023
b49526a
Show gravatar avatar on logging in notification
mchowning Apr 23, 2023
83328f3
Crossfade gravatar placeholder
mchowning Apr 23, 2023
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
2 changes: 1 addition & 1 deletion app/src/main/AndroidManifest.xml
Original file line number Diff line number Diff line change
Expand Up @@ -262,7 +262,7 @@
android:name="android.app.shortcuts"
android:resource="@xml/shortcuts"/>
</activity>

<activity-alias
android:name=".ui.MainActivity_0"
android:label="@string/app_name"
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,7 @@ import au.com.shiftyjelly.pocketcasts.account.PromoCodeUpgradedFragment
import au.com.shiftyjelly.pocketcasts.account.onboarding.OnboardingActivity
import au.com.shiftyjelly.pocketcasts.account.onboarding.OnboardingActivityContract
import au.com.shiftyjelly.pocketcasts.account.onboarding.OnboardingActivityContract.OnboardingFinish
import au.com.shiftyjelly.pocketcasts.account.watchsync.WatchSync
import au.com.shiftyjelly.pocketcasts.analytics.AnalyticsEvent
import au.com.shiftyjelly.pocketcasts.analytics.AnalyticsSource
import au.com.shiftyjelly.pocketcasts.analytics.AnalyticsTrackerWrapper
Expand Down Expand Up @@ -182,6 +183,7 @@ class MainActivity :
@Inject lateinit var analyticsTracker: AnalyticsTrackerWrapper
@Inject lateinit var episodeAnalytics: EpisodeAnalytics
@Inject lateinit var syncManager: SyncManager
@Inject lateinit var watchSync: WatchSync

private lateinit var bottomNavHideManager: BottomNavHideManager
private lateinit var observeUpNext: LiveData<UpNextQueue.State>
Expand Down Expand Up @@ -718,6 +720,8 @@ class MainActivity :

settings.setTrialFinishedSeen(true)
}

lifecycleScope.launch { watchSync.sendAuthToDataLayer() }
}

val lastSeenVersionCode = settings.getWhatsNewVersionCode()
Expand Down
25 changes: 25 additions & 0 deletions app/src/main/res/values/wear.xml
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Copyright (C) 2021 The Android Open Source Project

Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at

http://www.apache.org/licenses/LICENSE-2.0

Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
-->

<resources xmlns:tools="http://schemas.android.com/tools">
<string-array
name="android_wear_capabilities"
translatable="false"
tools:ignore="UnusedResources">
<!-- declaring the provided capabilities -->
<item>horologist_phone</item>
</string-array>
</resources>
1 change: 1 addition & 0 deletions base.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -205,6 +205,7 @@ dependencies {
implementation androidLibs.composeUiToolingPreview
implementation androidLibs.composeViewModel
implementation androidLibs.composeUiUtil
implementation androidLibs.wearPlayServices

implementation libs.kotlinCoroutines
implementation libs.kotlinCoroutinesAndroid
Expand Down
6 changes: 5 additions & 1 deletion dependencies.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -185,9 +185,13 @@ project.ext {

wearComposeMaterial: "androidx.wear.compose:compose-material:$versionComposeWear",
wearComposeFoundation: "androidx.wear.compose:compose-foundation:$versionComposeWear",
wearComposeNavigation: "androidx.wear.compose:compose-navigation:$versionComposeWear",
wearComposeNavigation: "androidx.wear.compose:compose-navigation:$versionComposeWear",
wearPlayServices: "com.google.android.gms:play-services-wearable:18.0.0",

horologistAuthData: "com.google.android.horologist:horologist-auth-data:$versionHorologist",
horologistAuthDataPhone: "com.google.android.horologist:horologist-auth-data-phone:$versionHorologist",
horologistComposeLayout: "com.google.android.horologist:horologist-compose-layout:$versionHorologist",
horologistDatalayer: "com.google.android.horologist:horologist-datalayer:$versionHorologist",
horologistMedia: "com.google.android.horologist:horologist-media:$versionHorologist",
horologistMediaUi: "com.google.android.horologist:horologist-media-ui:$versionHorologist",
horologistMediaData: "com.google.android.horologist:horologist-media-data:$versionHorologist",
Expand Down
4 changes: 4 additions & 0 deletions modules/features/account/build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -30,4 +30,8 @@ dependencies {
implementation project(':modules:services:ui')
implementation project(':modules:services:utils')
implementation project(':modules:services:views')

// android libs
implementation androidLibs.horologistAuthDataPhone
implementation androidLibs.horologistDatalayer
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
package au.com.shiftyjelly.pocketcasts.account.di

import android.content.Context
import com.google.android.horologist.annotations.ExperimentalHorologistApi
import com.google.android.horologist.data.WearDataLayerRegistry
import dagger.Module
import dagger.Provides
import dagger.hilt.InstallIn
import dagger.hilt.android.qualifiers.ApplicationContext
import dagger.hilt.components.SingletonComponent
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
import javax.inject.Singleton

@Module
@InstallIn(SingletonComponent::class)
object AuthModule {

@Singleton
@Provides
@ForApplicationScope
fun coroutineScope(): CoroutineScope =
CoroutineScope(SupervisorJob() + Dispatchers.Default)

@OptIn(ExperimentalHorologistApi::class)
@Singleton
@Provides
fun providesWearDataLayerRegistry(
@ApplicationContext context: Context,
@ForApplicationScope coroutineScope: CoroutineScope
): WearDataLayerRegistry {
return WearDataLayerRegistry.fromContext(
application = context,
coroutineScope = coroutineScope
)
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
package au.com.shiftyjelly.pocketcasts.account.di

import au.com.shiftyjelly.pocketcasts.account.watchsync.WatchSyncAuthData
import au.com.shiftyjelly.pocketcasts.account.watchsync.WatchSyncAuthDataSerializer
import com.google.android.horologist.annotations.ExperimentalHorologistApi
import com.google.android.horologist.auth.data.phone.tokenshare.TokenBundleRepository
import com.google.android.horologist.auth.data.phone.tokenshare.impl.TokenBundleRepositoryImpl
import com.google.android.horologist.data.WearDataLayerRegistry
import dagger.Module
import dagger.Provides
import dagger.hilt.InstallIn
import dagger.hilt.components.SingletonComponent
import kotlinx.coroutines.CoroutineScope
import javax.inject.Qualifier

@Module
@InstallIn(SingletonComponent::class)
object AuthPhoneModule {

@ExperimentalHorologistApi
@Provides
fun providesTokenBundleRepository(
wearDataLayerRegistry: WearDataLayerRegistry,
@ForApplicationScope coroutineScope: CoroutineScope,
): TokenBundleRepository<WatchSyncAuthData?> {
return TokenBundleRepositoryImpl(
registry = wearDataLayerRegistry,
coroutineScope = coroutineScope,
serializer = WatchSyncAuthDataSerializer
)
}
}

@Qualifier
@Retention(AnnotationRetention.BINARY)
annotation class ForApplicationScope
Original file line number Diff line number Diff line change
@@ -0,0 +1,81 @@
package au.com.shiftyjelly.pocketcasts.account.watchsync

import android.annotation.SuppressLint
import au.com.shiftyjelly.pocketcasts.repositories.sync.LoginResult
import au.com.shiftyjelly.pocketcasts.repositories.sync.SignInSource
import au.com.shiftyjelly.pocketcasts.repositories.sync.SyncManager
import au.com.shiftyjelly.pocketcasts.utils.log.LogBuffer
import com.google.android.horologist.annotations.ExperimentalHorologistApi
import com.google.android.horologist.auth.data.phone.tokenshare.TokenBundleRepository
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import timber.log.Timber
import javax.inject.Inject
import javax.inject.Singleton

@Singleton
@SuppressLint("VisibleForTests") // https://issuetracker.google.com/issues/239451111
class WatchSync @OptIn(ExperimentalHorologistApi::class)
@Inject constructor(
private val syncManager: SyncManager,
private val tokenBundleRepository: TokenBundleRepository<WatchSyncAuthData?>,
) {
/**
* This should be called by the phone app to update the refresh token available to
* the watch app in the data layer.
*/
@OptIn(ExperimentalHorologistApi::class)
suspend fun sendAuthToDataLayer() {
withContext(Dispatchers.Default) {
try {
Timber.i("Updating WatchSyncAuthData in data layer")

val watchSyncAuthData = syncManager.getRefreshToken()?.let { refreshToken ->
syncManager.getLoginIdentity()?.let { loginIdentity ->
WatchSyncAuthData(
refreshToken = refreshToken,
loginIdentity = loginIdentity
)
}
}

if (watchSyncAuthData == null) {
Timber.i("Removing WatchSyncAuthData from data layer")
}

tokenBundleRepository.update(watchSyncAuthData)
} catch (cancellationException: CancellationException) {
// Don't catch CancellationException since this represents the normal cancellation of a coroutine
throw cancellationException
} catch (exception: Exception) {
LogBuffer.e(
LogBuffer.TAG_BACKGROUND_TASKS,
"Saving refresh token to data layer failed: $exception"
)
}
}
}

suspend fun processAuthDataChange(data: WatchSyncAuthData?, onResult: (LoginResult) -> Unit) {
if (data != null) {

Timber.i("Received WatchSyncAuthData change from phone")

if (!syncManager.isLoggedIn()) {
val result = syncManager.loginWithToken(
token = data.refreshToken,
loginIdentity = data.loginIdentity,
signInSource = SignInSource.WatchPhoneSync
)
onResult(result)
} else {
Timber.i("Received WatchSyncAuthData from phone, but user is already logged in")
}
} else {
// The user either was never logged in on their phone or just logged out.
// Either way, leave the user's login state on the watch unchanged.
Timber.i("Received null WatchSyncAuthData change")
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
package au.com.shiftyjelly.pocketcasts.account.watchsync

import androidx.datastore.core.Serializer
import au.com.shiftyjelly.pocketcasts.preferences.RefreshToken
import au.com.shiftyjelly.pocketcasts.repositories.sync.LoginIdentity
import com.squareup.moshi.Json
import com.squareup.moshi.JsonClass
import com.squareup.moshi.Moshi
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import java.io.InputStream
import java.io.InputStreamReader
import java.io.OutputStream

@JsonClass(generateAdapter = true)
data class WatchSyncAuthData(
@field:Json(name = "refreshToken") val refreshToken: RefreshToken,
@field:Json(name = "loginIdentity") val loginIdentity: LoginIdentity,
)

object WatchSyncAuthDataSerializer : Serializer<WatchSyncAuthData?> {

private val adapter = WatchSyncAuthDataJsonAdapter(
Moshi.Builder()
.add(RefreshToken::class.java, RefreshToken.Adapter)
.add(LoginIdentity.Adapter)
.build()
)

override val defaultValue: WatchSyncAuthData? = null

override suspend fun readFrom(input: InputStream): WatchSyncAuthData? {
val string = InputStreamReader(input).readText()
return adapter.fromJson(string)
}

override suspend fun writeTo(t: WatchSyncAuthData?, output: OutputStream) {
withContext(Dispatchers.IO) {
if (t != null) {
val jsonString = adapter.toJson(t)
output.write(jsonString.toByteArray())
}
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -14,9 +14,9 @@ import au.com.shiftyjelly.pocketcasts.models.type.SubscriptionFrequency
import au.com.shiftyjelly.pocketcasts.models.type.SubscriptionPlatform
import au.com.shiftyjelly.pocketcasts.models.type.SubscriptionType
import au.com.shiftyjelly.pocketcasts.ui.extensions.getThemeColor
import au.com.shiftyjelly.pocketcasts.utils.Gravatar
import au.com.shiftyjelly.pocketcasts.utils.TimeConstants
import au.com.shiftyjelly.pocketcasts.utils.days
import au.com.shiftyjelly.pocketcasts.utils.extensions.md5Hex
import au.com.shiftyjelly.pocketcasts.utils.extensions.toLocalizedFormatLongStyle
import java.util.Date
import au.com.shiftyjelly.pocketcasts.localization.R as LR
Expand Down Expand Up @@ -54,9 +54,7 @@ open class UserView @JvmOverloads constructor(
is SignInState.SignedIn -> {
val strPocketCastsPlus = context.getString(LR.string.pocket_casts_plus).uppercase()
val strSignedInAs = context.getString(LR.string.profile_signed_in_as).uppercase()
/* https://en.gravatar.com/site/implement/images/
d=404: display no image if there is not one associated with the requested email hash */
val gravatarUrl = "https://www.gravatar.com/avatar/${signInState.email.md5Hex()}?d=404"
val gravatarUrl = Gravatar.getUrl(signInState.email)

lblUsername.text = signInState.email
lblSignInStatus.text = if (signInState.isSignedInAsPlus) strPocketCastsPlus else strSignedInAs
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
package au.com.shiftyjelly.pocketcasts.compose.images

import androidx.compose.animation.Crossfade
import androidx.compose.animation.core.tween
import androidx.compose.foundation.Image
import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.alpha
import androidx.compose.ui.platform.LocalContext
import au.com.shiftyjelly.pocketcasts.utils.Gravatar
import coil.compose.rememberAsyncImagePainter
import coil.request.ImageRequest

@Composable
fun GravatarProfileImage(
email: String,
modifier: Modifier = Modifier,
contentDescription: String?,
placeholder: @Composable (() -> Unit) = {},
) {

val gravatarUrl = remember(email) {
Gravatar.getUrl(email)
}

val gravatarPainter = rememberAsyncImagePainter(
model = ImageRequest.Builder(LocalContext.current)
.data(gravatarUrl)
.crossfade(true)
.build(),
)

Crossfade(
gravatarPainter.state.painter == null,
animationSpec = tween(500),
) { showPlaceholder ->
Image(
painter = gravatarPainter,
contentDescription = contentDescription,
modifier = modifier
.alpha(if (showPlaceholder) 0f else 1f)
)

// If the gravatar image has not loaded or fails to load (because there is no gravatar image associated
// with this account), show the placeholder. We are settings the placeholder this way instead of
// setting a placeholder on an AsyncImagePainter because this approach continues showing the placeholder
// when there is not a gravatar image associated with the account.
if (showPlaceholder) {
placeholder()
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -833,6 +833,7 @@
<string name="profile_sign_out" translatable="false">@string/sign_out</string>
<string name="profile_sign_out_confirm">Are you sure you want to sign out of syncing?</string>
<string name="profile_signed_in_as">Signed in as</string>
<string name="profile_logging_in">Logging in…</string>
<string name="profile_sonos">Sonos</string>
<string name="profile_sonos_connect">Connect</string>
<string name="profile_sonos_connect_account">Connecting to Sonos will allow the Sonos app to access your episode information.\n\nYour email address, password and other sensitive items are never shared.</string>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -39,7 +39,7 @@ value class AccessToken(val value: String) {
@JvmInline
value class RefreshToken(val value: String) {
object Adapter : JsonAdapter<RefreshToken>() {
override fun fromJson(reader: JsonReader) = RefreshToken((reader.nextString()))
override fun fromJson(reader: JsonReader) = RefreshToken(reader.nextString())

override fun toJson(writer: JsonWriter, refreshToken: RefreshToken?) {
writer.value(refreshToken?.value)
Expand Down
Loading