Skip to content

Commit

Permalink
Merge pull request #801 from Automattic/update/authenticate-watch-fro…
Browse files Browse the repository at this point in the history
…m-phone

Sending refresh token from phone to watch
  • Loading branch information
ashiagr authored Apr 26, 2023
2 parents 9cd32fd + 83328f3 commit 32171b7
Show file tree
Hide file tree
Showing 33 changed files with 779 additions and 56 deletions.
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 @@ -184,9 +184,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 @@ -835,6 +835,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

0 comments on commit 32171b7

Please sign in to comment.