Nozioni di base sulle app

Lezioni dal percorso di WhatsApp su Wear OS

Altoparlanti

  • Martin Pelant

    Software Engineer, Meta

  • Subhag Bhatia

    Software Engineer, Meta

Informazioni

Informazioni sulla presentazione

Ascolta direttamente i tecnici di WhatsApp su come hanno eseguito l'upgrade della loro app per Wear OS su larga scala. Questa sessione illustra le decisioni relative all'architettura e alla UX alla base dell'integrazione di Material 3 e dell'adozione dei widget per Wear.

Scopri direttamente gli insight, i pattern e gli strumenti utilizzati dal team per aggiornare l'app e offrire un'esperienza utente coerente.

Riepilogo

  • La condivisione del codice dello smartphone Android con Wear OS riduce il lavoro duplicato, ma richiede l'inserimento delle dipendenze e l'override del manifest. I test devono confrontare i set di multibinding in modo che le funzionalità modularizzate non scompaiano silenziosamente.
  • La gestione del bridging delle notifiche di runtime impedisce la duplicazione degli avvisi, consentendo al contempo allo smartwatch di funzionare in modo indipendente. Se disattivi il bridging al momento dell'accesso e lo riattivi al momento della disconnessione, eviti che i dispositivi con mirroring dello smartphone vibrino due volte.
  • Le migrazioni a Material 3 su display rotondi richiedono la gestione delle modifiche comportamentali insieme allo stile visivo. Utilizzare TransformingLazyColumn correttamente significa applicare le trasformazioni a un solo livello ed evitare di memorizzare nella cache le posizioni con remember di Compose.
  • I limiti di scadenza delle app possono causare cali improvvisi degli utenti attivi se gli smartwatch non vengono aggiornati tempestivamente. I promemoria degli aggiornamenti pianificati indirizzano gli utenti a Google Play prima della scadenza della build di 90 giorni.

Gemini Notebook

Argomenti

  • wear-os
  • material-3
  • jetpack-compose
  • modularizzazione
  • notifiche
  • tiles-and-widgets

Trascrizione

Scarica la trascrizione

Introduzione

  • Ciao,

  • Mi chiamo Martin.

  • E io sono Subhag.

  • Siamo ingegneri di Meta e facciamo parte del team che ha sviluppato WhatsApp su Wear OS.

  • Di recente abbiamo modernizzato la nostra app.

  • Oggi condivideremo gli insegnamenti che abbiamo appreso dalla migrazione a un codebase condiviso e dalla creazione di nuove esperienze su questa base.

  • La particolarità di WhatsApp su Wear OS è che funziona senza lo smartphone.

  • Una volta collegato, funge da dispositivo WhatsApp autonomo, con crittografia end-to-end proprio come lo smartphone, con il proprio database e la propria connessione al server.

  • In questo modo, continua a funzionare anche tramite Wi-Fi o LTE quando lo smartphone è fuori portata.

  • Abbiamo lanciato WhatsApp su Wear OS nel 2023.

  • Era basato su un client compatto e portatile scritto in Rust.

  • Ci ha permesso di entrare in produzione rapidamente.

  • Tuttavia, si trattava di una seconda implementazione indipendente di WhatsApp, il che significa che ogni protocollo, crittografia e funzionalità doveva essere creato due volte.

  • Di conseguenza, l'app dell'orologio era sempre in ritardo.

  • La portabilità ha anche reso difficile la gestione dello sfondo.

  • Il client gestiva i propri thread, timer e socket, cose che Android vuole gestire per te.

  • Su Android, il background ha delle regole.

  • In modalità Sospensione, la rete viene persa.

  • Una volta memorizzato nella cache, il processo ha un tempo di esecuzione limitato o nullo.

  • Per superare questo limite, devi utilizzare un servizio, un job o un push.

  • Quindi la nostra app dell'orologio ha dovuto collegarli manualmente.

  • Ma il nostro codebase Android lo fa già.

  • Abbiamo quindi deciso di riutilizzare ed eseguire la migrazione alla nostra codebase Android, che è stata testata su miliardi di dispositivi e segue già le regole di Android.

Passaggio a un codebase condiviso

  • In questo modo, non è necessario creare due volte le nuove funzionalità.

  • L'obiettivo era semplice: mantenere la logica di business e rimuovere l'interfaccia utente.

  • Ciò che rimane è una libreria headless che utilizza i flussi Kotlin come API.

  • La sfida, tuttavia, è che non puoi districare un'app di queste dimensioni in un'unica soluzione.

  • Alcuni moduli combinano la UI con l'infrastruttura.

  • Altre dipendono da funzionalità non necessarie nell'app per Wear OS.

  • Quindi abbiamo lavorato su due binari: abbiamo continuato a modularizzare e abbiamo rimosso o sovrascritto ciò che non potevamo ancora rimuovere.

  • Iniziamo con la modularizzazione.

  • Per escludere il codice dalla build, una semplice istruzione "se si tratta di un'app Telefono" non funzionerà perché non rimuove il codice.

  • Per questo motivo, facciamo molto affidamento sulla DI, in particolare su quella facoltativa e sul multibinding.

  • Utilizziamo la DI interna, ma è molto simile a quella offerta da Dagger.

  • Quindi gli esempi sono in Dagger.

  • Con l'associazione facoltativa, in Dagger BindsOptionalOf, puoi dichiarare una dipendenza come facoltativa, ad esempio chiamando il backend.

    @Module
    interface CallingModule {
        @BindsOptionalOf
        fun callingBackend(): CallingBackend
    }
    
    class Calls @Inject constructor(
        val backend: Optional<CallingBackend>,
    ) {
        fun start(): CallResult =
            backend.getOrNull()?.start()
                ?: CallResult.NotAllowed
    }
  • Se la build dello smartwatch include un modulo che fornisce l'implementazione, il backend inserito sarà presente e la chiamata funzionerà.

  • Se ometti il modulo, il valore sarà null e l'API potrà restituire NotAllowed.

  • Il multibinding funziona allo stesso modo, ma per gli insiemi.

    @Module
    interface CoreModule {
        @Multibinds
        fun handlers(): Set<MessageHandler>
    }
    
    @Module
    interface ReactionsModule {
        @Binds @IntoSet
        fun reactions(impl: ReactionHandler):
     MessageHandler
    }
    
    class MessageDispatcher @Inject constructor(
          private val handlers: Set<MessageHandler>,
    )
  • Puoi dichiarare un set con un multibinding, ad esempio MessageHandler, e poi ogni modulo della funzionalità può aggiungere il proprio gestore utilizzando il multibinding nel set.

  • Se non vuoi supportare i pagamenti, ad esempio puoi escludere l'intero modulo dei pagamenti dalla build e l'insieme inserito non includerà quel gestore.

  • In questo modo, la questione se la funzionalità è supportata o meno riguarda il grafico di build.

  • Tuttavia, questo disaccoppiamento ha un duplice effetto.

  • Con i riferimenti diretti, l'eliminazione di un modulo interromperebbe la build.

  • Con l'associazione facoltativa o multipla, non esiste alcun riferimento al modulo, quindi la build viene superata e la funzionalità scompare silenziosamente o viene applicata l'impostazione predefinita.

  • Ed è esattamente quello che abbiamo fatto.

  • Un refactoring ha spostato una funzionalità nel proprio modulo e la nostra app di build e la nostra app dell'orologio non l'hanno inclusa.

  • Di conseguenza, i messaggi vocali mostravano una durata di zero secondi.

  • Come fare per evitare che ciò accada?

  • Beh, ti serve un test per rimettere il controllo.

  • Nel nostro caso, confrontiamo i set di multi-binding tra l'app Telefono e l'app dell'orologio per le interfacce importanti.

  • In questo modo ci assicuriamo di avere sempre l'implementazione corretta.

  • Il secondo percorso riguarda ciò che non abbiamo ancora potuto rimuovere.

  • Il riutilizzo dei moduli ti farà risparmiare lavoro, ma tutto ciò che dichiarano viene unito nel manifest.

  • L'attività di avvio dello smartphone aggiungerebbe un'icona dell'app secondaria oppure i provider di contenuti non necessari verrebbero avviati con la tua app.

  • L'unione dei manifest offre un utile attributo tools:node="remove" che consente di rimuovere i componenti.

    <!-- remove unused Main activity node -->
    <activity android:name="com.whatsapp.Main"
              tools:node="remove" />
    
    <!-- redirect any calls to a new Activity -->
    <activity-alias
         android:name="com.whatsapp.Main"
         android:targetActivity=".MyNewActivity" />
    
    <provider
    android:name="…MigrationContentProvider"
            tools:node="remove" />
  • Una volta che non sono più presenti, lo shrinker può rimuovere anche il codice.

  • Se qualcosa deve ancora raggiungere l'attività, puoi utilizzare activity-alias per reindirizzare alla nuova classe.

  • Tuttavia, tieni presente che questi attributi si applicano solo ai manifest con priorità inferiore, quindi l'ordine di unione decide se un elemento viene rimosso o meno.

  • Le risorse funzionano allo stesso modo.

  • Reuse ti offre stringhe di telefono senza costi.

  • Tuttavia, alcuni promettono cose che uno smartwatch non può fare.

  • Facciamo un esempio alla fine dell'avviso sulla crittografia che si trova all'inizio di ogni chat.

  • Sullo smartphone, termina con un link "Scopri di più".

  • Tuttavia, non c'è nulla dietro il link sullo smartwatch.

  • Quindi, dichiariamo nuovamente la stessa chiave nel modulo con priorità più alta sullo smartwatch e sostituiamo la stringa con "Usa lo smartphone per saperne di più".

    <!-- phone -->
    <string name="…_disclosure">… Learn more</string>
    
    <!-- wearable override, same key -->
    <string name="…_disclosure">…Use your phone to learn more.</string>
  • Poi, il gestore delle risorse sceglie la nostra.

  • Inoltre, devi assicurarti di non eseguire l'override della stringa per errore.

  • Per questo motivo, abbiamo creato una regola di build che garantisce e non va a buon fine se l'override non è incluso nella lista consentita.

Gestione del bridging delle notifiche

  • Ora parliamo di notifiche.

  • Per impostazione predefinita, Wear OS esegue il bridging o il mirroring delle notifiche dello smartphone.

  • È fantastico, ma funziona solo se lo smartphone è connesso.

  • Inoltre, vogliamo che WhatsApp funzioni in modo indipendente, quindi la nostra app pubblica le proprie notifiche.

  • Tuttavia, se il bridging rimane attivo, ogni messaggio vibra due volte.

  • La disattivazione del bridging corregge i duplicati.

  • Esiste un flag statico del file manifest, ma si applica dall'installazione prima che l'utente si connetta.

  • Quindi, se installi l'app e non accedi, perdi improvvisamente tutte le notifiche, il che non è il massimo, vero?

  • Quindi lo impostiamo in fase di runtime utilizzando un BridgingManager.

    isRegistered.collect {
      when (it) {
             // watch posts its own
        true  -> bridging.setEnabled(false)
     // phone bridges again
        false -> bridging.setEnabled(true)
      }
    }
    
    fun setEnabled(bridgingEnabled){
       bridgingManager.setConfig(
    BridgingConfig.Builder(context,
    bridgingEnabled)
          .build())
    }
  • Quando accedi, il bridging viene disattivato.

  • Quando esci, la riattiviamo.

  • Tuttavia, tieni presente che non esiste un'API per leggere i valori e abbiamo osservato che lo smartwatch li perde.

  • Quindi, lo riscriviamo ogni volta che lo smartwatch viene rimosso dal caricabatterie o se l'app viene aggiornata.

  • L'ultimo argomento di cui volevo parlare è come uscire.

  • Quando scolleghi uno smartwatch dal tuo account, gli utenti si aspettano che i messaggi vengano eliminati.

  • Quindi eliminiamo tutto: messaggi, chiavi, impostazioni.

  • Ma come farlo in sicurezza?

  • Se elimini i file mentre i thread in background continuano a scriverci, ricevi eccezioni SQLite o, peggio ancora, scritture su database appena ricreati.

  • Inoltre, non è possibile cancellare lo stato di un processo senza interromperlo.

  • Android offre l'API clearApplicationUserData, che cancella i dati e arresta l'app, ma reimposta anche le autorizzazioni.

  • Inoltre, su alcuni OEM, il bridging delle notifiche è legato alle autorizzazioni alle notifiche, quindi se lo utilizzi, perderesti improvvisamente anche il mirroring dello smartphone.

  • Quindi interrompiamo il processo noi stessi.

    clearActivityBackStack()
    
    // enumerate our own, kill each
    killOtherProcesses()
    
    // no threads alive — now safe
    deleteDataDirectories()
    
    // show logged out confirmation
    firePostCleanupIntent()
  • Creiamo un piccolo servizio in un processo secondario, che non ha DI, né WorkManager, quindi non avvia il resto dell'app.

  • Dopo la disconnessione, questo servizio interrompe tutti gli altri processi nella nostra app e solo allora elimina tutti i dati quando non ci sono conflitti.

  • Poiché non utilizziamo clearApplicationUserData, le autorizzazioni rimangono e possiamo attivare il bridging delle notifiche, in modo che le notifiche dello smartphone vengano ripristinate.

  • Questo è tutto per la prima parte. Ora lascio la parola a Subhag, che parlerà dell'aggiornamento della UI.

Modernizzazione della UI di Wear OS

  • Grazie, Martin.

  • Con l'infrastruttura condivisa, il nostro obiettivo era far sì che WhatsApp fosse davvero a suo agio sul tuo polso.

  • La prima parte di questo percorso è stata il passaggio a Material 3, ma prima chiediamoci il perché.

  • Su un display rotondo in cui ogni pixel è importante, Material 3 ci ha fornito i componenti giusti e le basi di progettazione per modernizzare WhatsApp per il polso.

  • Se noti gli elementi visivi, in apparenza potrebbe sembrare solo un aggiornamento della UI.

  • Stavamo passando da Material 2 a 3 e non è stato possibile farlo da un giorno all'altro.

  • Il codice precedente e quello nuovo dovevano coesistere dietro un interruttore.

  • Di conseguenza, le schermate che corrispondevano visivamente a Figma mostravano ancora alcuni comportamenti non corretti.

  • Per fare alcuni esempi, lo scorrimento della corona, lo scorrimento verso destra per tornare indietro, alcuni dei gesti più basilari non funzionavano.

  • In altre parole, la migrazione di un sistema di progettazione non è solo una migrazione visiva, ma anche una migrazione del comportamento.

  • A proposito di comportamenti, se noti le righe vicino ai bordi, sono più strette e inserite nella curva.

  • Questo è TransformingLazyColumn.

  • Le righe si trasformano in modo che la lunetta circolare non le tagli.

  • Uno schermo dinamico può complicare le cose.

  • Arrivano nuovi messaggi, le righe si spostano e le bolle assumono ogni forma.

  • Vediamo cosa significava fare le cose per bene.

  • Se noti, a sinistra, i fumetti si stanno riducendo in modo troppo aggressivo.

  • Ogni messaggio è internamente una bolla racchiusa in una scheda.

  • Abbiamo indicato alla scheda principale di applicare una trasformazione, ma la stessa trasformazione è stata trasmessa anche al fumetto secondario.

  • Di conseguenza, il fumetto secondario riceve il doppio della trasformazione prevista.

  • La correzione è stata quella di applicare una trasformazione a un livello, ovvero alla scheda principale.

  • E questo è anche un principio chiave.

  • Abbiamo garantito una trasformazione per un elemento.

  • Ecco un altro problema: bolle piccole e sbiadite proprio al centro dello schermo, dove dovrebbero essere di dimensioni normali.

  • Le dimensioni sono una funzione della loro posizione sullo schermo.

  • Nella nostra versione iniziale, abbiamo salvato l'adattamento delle dimensioni utilizzando la funzione remember di Compose, ma in questo modo è stata salvata anche la posizione precedente.

  • Di conseguenza, quando arriva un nuovo messaggio e i fumetti precedenti vengono spostati verso l'alto, la loro trasformazione si basa ancora sulla loro posizione precedente.

  • E questo genera il bug.

  • Per risolvere il problema, abbiamo interrotto il salvataggio delle dimensioni utilizzando la chiamata remember e abbiamo eliminato la chiamata.

  • E anche questo è un insegnamento.

  • Quando una riga si sposta, la sua trasformazione deve spostarsi con essa.

  • Qui puoi vedere entrambe le correzioni nel codice.

    // FIX #1: ONE OWNER
    CompositionLocalProvider(
      LocalSurfaceTransformation provides null
    ) {
      M3Card(transformation = t) {
        SharedChatEntry(...)
      }
    }
    
    // FIX #2: CURRENT ITEM SCOPE
    val t = SurfaceTransformation(spec)
    // BUG #1: DOUBLE MORPH
    M3Card(transformation = t) {
      CompositionLocalProvider(
        LocalSurfaceTransformation provides t
      ) { SharedChatEntry(...) }
    }
    
    // BUG #2: STALE ITEM SCOPE
    val t = remember(spec) {
      SurfaceTransformation(spec)
    }
  • In alto a sinistra, puoi vedere che la trasformazione viene applicata a due livelli, mentre in alto a destra viene applicata a un solo livello, ovvero la scheda M3.

  • In basso a sinistra, utilizziamo la chiamata remember per memorizzare nella cache la trasformazione, ma memorizza nella cache anche la posizione.

  • In basso a destra, calcoliamo la trasformazione considerando ogni volta l'ultima posizione della bolla.

  • Come puoi vedere, il risultato è un'esperienza coerente su tutti gli schermi, in cui i contenuti principali rimangono vicino al centro e le righe si spostano naturalmente vicino al bordo.

Gestione degli avvisi di notifica sul polso

  • Ora parliamo delle notifiche.

  • Ora immagina che il tuo polso vibri, controlli lo smartwatch e vedi un messaggio.

  • Ma poco dopo, il polso vibra di nuovo e di nuovo.

  • Ricevi più notifiche e, ogni volta che controlli, vedi la stessa cosa.

  • Beh, è davvero fastidioso.

  • Per risolvere il problema, ci siamo posti due domande.

  • Quale evento deve attivare la notifica per l'utente?

  • E quale evento dovrebbe aggiornare le notifiche in modalità silenziosa?

  • Questo bug si verificava perché, dietro le quinte, gli aggiornamenti delle foto del profilo, i download di contenuti multimediali e le ricevute, che dovrebbero aggiornare la notifica in modo silenzioso, avvisavano invece l'utente.

  • Ora, quando arriva un nuovo messaggio, il polso vibra esattamente una volta, mentre gli eventi che non richiedono un avviso aggiornano la notifica in modo silenzioso.

  • E anche questa è una lezione.

  • Quando le notifiche sono uno dei percorsi dell'utente principali, dobbiamo assicurarci che ogni avviso meriti davvero l'attenzione dell'utente.

  • A proposito di notifiche.

  • In precedenza, le notifiche di WhatsApp su Wear OS consentivano di eseguire due azioni.

  • Puoi leggere il messaggio o aprire l'app.

  • Se volevi rispondere direttamente a una notifica, prima non potevi farlo.

  • Abbiamo quindi colmato questa lacuna utilizzando l'azione di notifica di Android con l'API RemoteInput.

  • Ora l'utente può digitare, pronunciare o toccare una risposta rapida.

  • Ma ricorda che l'invio non è la fine.

  • Se un messaggio viene inviato correttamente, contrassegniamo la chat come letta e cancelliamo la notifica.

  • Se un messaggio non viene recapitato, offriamo la possibilità di riprovare con un solo tocco.

  • Un altro aspetto fondamentale che abbiamo imparato durante lo sviluppo è che un'azione di notifica deve essere trattata come un flusso di lavoro end-to-end completo.

Migrazione dai riquadri ai widget

  • Poi, Schede e widget.

  • Un riquadro, come puoi vedere, è una schermata intera, che puoi raggiungere scorrendo dal quadrante.

  • I widget, invece, sono più schede di dimensioni ridotte, che possono essere inserite in un'unica schermata scorrevole.

  • Sia i riquadri sia i widget di WhatsApp si basano sugli stessi dati sottostanti.

  • E anche questo è un colpo di scena.

  • Potresti pensare che questa UI venga disegnata dalla nostra app, ma in realtà non è così.

  • Descriviamo solo la UI e i dati, mentre il sistema li disegna.

  • Inoltre, toccando una delle icone non è possibile eseguire il codice dell'app.

  • Puoi solo chiedere di aprire una schermata.

  • E con questo si conclude la nostra lezione.

  • Se il sistema sta disegnando la tua UI, questa fa parte delle metriche del prodotto e dei test.

  • Frank e Yuri approfondiranno l'argomento nel loro intervento sui widget.

Prevenire il calo degli utenti attivi con i promemoria di aggiornamento

  • E questo mi porta all'ultima slide, che ci invita a fare un passo indietro e a chiederci: alla fine della fiera, vogliamo che sempre più utenti utilizzino le nostre app.

  • Ora immagina di essere uno sviluppatore, di accedere alle tue dashboard un giorno e di vedere un calo significativo degli utenti attivi.

  • Beh, prima di tutto, sarebbe molto preoccupante.

  • e poi iniziare a eseguirne il debug.

  • Potresti pensare che si sia verificato un arresto anomalo o che sia in corso una release non riuscita.

  • Ma per noi non è stato così.

  • Si scopre che ogni versione dell'app WhatsApp ha una scadenza integrata di 90 giorni.

  • Immagina che gli utenti siano bloccati su una build obsoleta, mentre le build più recenti impiegano tempo per raggiungere i loro smartwatch.

  • Di conseguenza, gli utenti non abbandonano l'app, ma l'app smette di funzionare.

  • Per questo motivo, abbiamo reso l'aggiornamento dell'app parte del prodotto.

  • Come puoi vedere sullo schermo, 15 giorni prima della scadenza dell'app, viene visualizzato questo messaggio.

  • Viene visualizzato di nuovo quando mancano 7, 5, 3, 2 e 1 giorno.

  • L'utente può scegliere di ignorarlo o di accedere al Play Store per aggiornare l'app.

  • In questo modo, l'utente riceve più promemoria che lo informano che la scadenza dell'app si sta avvicinando e ha più opportunità di aggiornarla.

  • E questo è il nostro ultimo insegnamento.

  • Il nostro lavoro di sviluppatori non si conclude quando rilasciamo un aggiornamento al 100%.

  • È completo solo quando gli utenti ricevono effettivamente i nostri aggiornamenti.

  • Siamo arrivati alla fine.

  • Se vuoi continuare la conversazione, non esitare a passare a salutarci durante l'orario di consulenza.

  • Ora Frank e Yuri parleranno dei widget di Wear.

  • Grazie.

Codice

Snippet di codice

Snippet di Kotlin alle 02:27 in "Passaggio a un codebase condiviso"
@Module
interface CallingModule {
    @BindsOptionalOf
    fun callingBackend(): CallingBackend
}

class Calls @Inject constructor(
    val backend: Optional<CallingBackend>,
) {
    fun start(): CallResult =
        backend.getOrNull()?.start()
            ?: CallResult.NotAllowed
}
Snippet di Kotlin al minuto 02:47 in "Moving to a shared codebase"
@Module
interface CoreModule {
    @Multibinds
    fun handlers(): Set<MessageHandler>
}

@Module
interface ReactionsModule {
    @Binds @IntoSet
    fun reactions(impl: ReactionHandler):
 MessageHandler
}

class MessageDispatcher @Inject constructor(
      private val handlers: Set<MessageHandler>,
)
Snippet XML al minuto 04:16 in "Passaggio a un codebase condiviso"
<!-- remove unused Main activity node -->
<activity android:name="com.whatsapp.Main"
          tools:node="remove" />

<!-- redirect any calls to a new Activity -->
<activity-alias
     android:name="com.whatsapp.Main"
     android:targetActivity=".MyNewActivity" />

<provider
android:name="…MigrationContentProvider"
        tools:node="remove" />
Snippet XML al minuto 05:03 in "Passaggio a un codebase condiviso"
<!-- phone -->
<string name="…_disclosure">… Learn more</string>

<!-- wearable override, same key -->
<string name="…_disclosure">…Use your phone to learn more.</string>
Snippet Kotlin alle 05:59 in "Gestione del bridging delle notifiche"
isRegistered.collect {
  when (it) {
         // watch posts its own
    true  -> bridging.setEnabled(false)
 // phone bridges again
    false -> bridging.setEnabled(true)
  }
}

fun setEnabled(bridgingEnabled){
   bridgingManager.setConfig(
BridgingConfig.Builder(context,
bridgingEnabled)
      .build())
}
Snippet di Kotlin alle 07:06 in "Gestione del bridging delle notifiche"
clearActivityBackStack()

// enumerate our own, kill each
killOtherProcesses()

// no threads alive — now safe
deleteDataDirectories()

// show logged out confirmation
firePostCleanupIntent()
Snippet di Kotlin al minuto 10:30 in "Modernizing the Wear OS UI"
// FIX #1: ONE OWNER
CompositionLocalProvider(
  LocalSurfaceTransformation provides null
) {
  M3Card(transformation = t) {
    SharedChatEntry(...)
  }
}

// FIX #2: CURRENT ITEM SCOPE
val t = SurfaceTransformation(spec)
Snippet di Kotlin al minuto 10:30 in "Modernizing the Wear OS UI"
// BUG #1: DOUBLE MORPH
M3Card(transformation = t) {
  CompositionLocalProvider(
    LocalSurfaceTransformation provides t
  ) { SharedChatEntry(...) }
}

// BUG #2: STALE ITEM SCOPE
val t = remember(spec) {
  SurfaceTransformation(spec)
}