מחליף פלט

מתג פלט הוא תכונה ב-Cast SDK שמאפשרת העברה חלקה בין הפעלה מקומית להפעלה מרחוק של תוכן החל מ-Android 13. המטרה היא לעזור לאפליקציות שולח בקלות ובמהירות לשלוט במקומות שבהם התוכן מופעל. מתג מעבר למכשיר משתמש בספרייה MediaRouter כדי להעביר את התוכן שמופעל בין הרמקול של הטלפון, מכשירי Bluetooth מותאמים ומכשירים מרוחקים שתומכים ב-Cast. אפשר לחלק את התרחישים לדוגמה לפי התרחישים הבאים:

הורידו את הדוגמה הבאה והיעזרו בה כדי ללמוד כיצד להטמיע את Output Switcher באפליקציית האודיו שלכם. ראו את קובץ README.md הכלול לקבלת הוראות כיצד להריץ את הדגימה.

הורדת דוגמה

צריך להפעיל את מתג הפלט כדי לתמוך בחיבור מקומי מרחוק ובמיקום מקומי לפי השלבים שמפורטים במדריך הזה. לא נדרשים שלבים נוספים כדי לתמוך בהעברה בין הרמקולים המקומיים של המכשיר לבין מכשירי ה-Bluetooth המותאמים.

אפליקציות אודיו הן אפליקציות שתומכות ב-Google Cast for Audio בהגדרות של אפליקציית המקבל ב-Google Cast SDK Console

ממשק המשתמש של מתג הנגישות

מתג הפלט מציג את המכשירים המקומיים והמכשירים המרוחקים הזמינים, וכן את מצבי המכשיר הנוכחיים, כולל רמת החיבור הנוכחית של המכשיר, אם נבחר שהוא מתחבר. אם יש מכשירים נוספים בנוסף למכשיר הנוכחי, לחיצה על מכשיר אחר מאפשרת להעביר את הפעלת המדיה למכשיר שנבחר.

בעיות ידועות

  • סשנים של מדיה שנוצרו להפעלה מקומית ייסגרו וייווצרו מחדש במהלך המעבר להתראה של Cast SDK.

נקודות כניסה

התראה בנושא מדיה

אם אפליקציה מפרסמת התראת מדיה עם MediaSession להפעלה מקומית (הפעלה באופן מקומי), בפינה השמאלית העליונה של ההתראה על מדיה יוצג צ'יפ התראה עם שם המכשיר (למשל: הרמקול של הטלפון) שבו התוכן מופעל כרגע. הקשה על צ'יפ ההתראות פותחת את ממשק המשתמש של מערכת הדו-שיח 'מתג פלט'.

הגדרות עוצמת הקול

ניתן גם להפעיל את ממשק המשתמש של מערכת הדו-שיח 'מתג פלט' על ידי לחיצה על לחצני עוצמת הקול הפיזיים במכשיר, הקשה על סמל ההגדרות בתחתית המסך והקשה על הטקסט 'Play <App Name> on <Cast Device>' (הפעלת <שם האפליקציה> במכשיר <cast).

סיכום השלבים

דרישות מוקדמות

  1. מעבירים את האפליקציה הקיימת ל-Android ל-AndroidX.
  2. צריך לעדכן את build.gradle של האפליקציה כדי שייעשה שימוש בגרסה המינימלית הנדרשת של Android Sender SDK ל-Output Switcher:
    dependencies {
      ...
      implementation 'com.google.android.gms:play-services-cast-framework:21.2.0'
      ...
    }
  3. האפליקציה תומכת בהתראות מדיה.
  4. מכשיר עם Android 13.

הגדרת התראות מדיה

כדי להשתמש במתג הפלט, אפליקציות אודיו ווידאו נדרשות ליצור התראת מדיה כדי להציג את סטטוס ההפעלה ואת אמצעי הבקרה של המדיה להפעלה מקומית. לשם כך צריך ליצור MediaSession, להגדיר את MediaStyle עם האסימון של MediaSession ולהגדיר את פקדי המדיה בהתראה.

אם אתם לא משתמשים כרגע ב-MediaStyle וב-MediaSession, קטע הקוד שלמטה מראה איך להגדיר אותם, ויש מדריכים זמינים להגדרת קריאות חוזרות (callback) של הפעלת מדיה באפליקציות אודיו ווידאו:

קוטלין
// Create a media session. NotificationCompat.MediaStyle
// PlayerService is your own Service or Activity responsible for media playback.
val mediaSession = MediaSessionCompat(this, "PlayerService")

// Create a MediaStyle object and supply your media session token to it.
val mediaStyle = Notification.MediaStyle().setMediaSession(mediaSession.sessionToken)

// Create a Notification which is styled by your MediaStyle object.
// This connects your media session to the media controls.
// Don't forget to include a small icon.
val notification = Notification.Builder(this@PlayerService, CHANNEL_ID)
    .setStyle(mediaStyle)
    .setSmallIcon(R.drawable.ic_app_logo)
    .build()

// Specify any actions which your users can perform, such as pausing and skipping to the next track.
val pauseAction: Notification.Action = Notification.Action.Builder(
        pauseIcon, "Pause", pauseIntent
    ).build()
notification.addAction(pauseAction)
Java
if (android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.O) {
    // Create a media session. NotificationCompat.MediaStyle
    // PlayerService is your own Service or Activity responsible for media playback.
    MediaSession mediaSession = new MediaSession(this, "PlayerService");

    // Create a MediaStyle object and supply your media session token to it.
    Notification.MediaStyle mediaStyle = new Notification.MediaStyle().setMediaSession(mediaSession.getSessionToken());

    // Specify any actions which your users can perform, such as pausing and skipping to the next track.
    Notification.Action pauseAction = Notification.Action.Builder(pauseIcon, "Pause", pauseIntent).build();

    // Create a Notification which is styled by your MediaStyle object.
    // This connects your media session to the media controls.
    // Don't forget to include a small icon.
    String CHANNEL_ID = "CHANNEL_ID";
    Notification notification = new Notification.Builder(this, CHANNEL_ID)
        .setStyle(mediaStyle)
        .setSmallIcon(R.drawable.ic_app_logo)
        .addAction(pauseAction)
        .build();
}

בנוסף, כדי לאכלס את ההתראה במידע על המדיה שלכם, צריך להוסיף את המטא-נתונים ומצב ההפעלה של המדיה ל-MediaSession.

כדי להוסיף מטא-נתונים ל-MediaSession, משתמשים ב-setMetaData() ומציינים את כל הקבועים הרלוונטיים של MediaMetadata במדיה ב-MediaMetadataCompat.Builder().

קוטלין
mediaSession.setMetadata(MediaMetadataCompat.Builder()
    // Title
    .putString(MediaMetadata.METADATA_KEY_TITLE, currentTrack.title)

    // Artist
    // Could also be the channel name or TV series.
    .putString(MediaMetadata.METADATA_KEY_ARTIST, currentTrack.artist)

    // Album art
    // Could also be a screenshot or hero image for video content
    // The URI scheme needs to be "content", "file", or "android.resource".
    .putString(
        MediaMetadata.METADATA_KEY_ALBUM_ART_URI, currentTrack.albumArtUri)
    )

    // Duration
    // If duration isn't set, such as for live broadcasts, then the progress
    // indicator won't be shown on the seekbar.
    .putLong(MediaMetadata.METADATA_KEY_DURATION, currentTrack.duration)

    .build()
)
Java
if (android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.O) {
    mediaSession.setMetadata(
        new MediaMetadataCompat.Builder()
        // Title
        .putString(MediaMetadata.METADATA_KEY_TITLE, currentTrack.title)

        // Artist
        // Could also be the channel name or TV series.
        .putString(MediaMetadata.METADATA_KEY_ARTIST, currentTrack.artist)

        // Album art
        // Could also be a screenshot or hero image for video content
        // The URI scheme needs to be "content", "file", or "android.resource".
        .putString(MediaMetadata.METADATA_KEY_ALBUM_ART_URI, currentTrack.albumArtUri)

        // Duration
        // If duration isn't set, such as for live broadcasts, then the progress
        // indicator won't be shown on the seekbar.
        .putLong(MediaMetadata.METADATA_KEY_DURATION, currentTrack.duration)

        .build()
    );
}

כדי להוסיף את מצב ההפעלה ל-MediaSession, משתמשים ב-setPlaybackState() ומספקים את כל הקביעות הרלוונטיות של PlaybackStateCompat למדיה ב-PlaybackStateCompat.Builder().

קוטלין
mediaSession.setPlaybackState(
    PlaybackStateCompat.Builder()
        .setState(
            PlaybackStateCompat.STATE_PLAYING,

            // Playback position
            // Used to update the elapsed time and the progress bar.
            mediaPlayer.currentPosition.toLong(),

            // Playback speed
            // Determines the rate at which the elapsed time changes.
            playbackSpeed
        )

        // isSeekable
        // Adding the SEEK_TO action indicates that seeking is supported
        // and makes the seekbar position marker draggable. If this is not
        // supplied seek will be disabled but progress will still be shown.
        .setActions(PlaybackStateCompat.ACTION_SEEK_TO)
        .build()
)
Java
if (android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.O) {
    mediaSession.setPlaybackState(
        new PlaybackStateCompat.Builder()
            .setState(
                 PlaybackStateCompat.STATE_PLAYING,

                // Playback position
                // Used to update the elapsed time and the progress bar.
                mediaPlayer.currentPosition.toLong(),

                // Playback speed
                // Determines the rate at which the elapsed time changes.
                playbackSpeed
            )

        // isSeekable
        // Adding the SEEK_TO action indicates that seeking is supported
        // and makes the seekbar position marker draggable. If this is not
        // supplied seek will be disabled but progress will still be shown.
        .setActions(PlaybackStateCompat.ACTION_SEEK_TO)
        .build()
    );
}

התנהגות ההתראות באפליקציית וידאו

אפליקציות וידאו או אפליקציות אודיו שלא תומכות בהפעלה מקומית ברקע צריכות לפעול באופן ספציפי להתראות על מדיה, כדי למנוע בעיות בשליחת פקודות מדיה במצבים שבהם אין תמיכה בהפעלה מקומית:

  • כשמפרסמים את ההתראה על מדיה כשמפעילים מדיה באופן מקומי כשהאפליקציה פועלת בחזית.
  • משהים את ההפעלה המקומית וסוגרים את ההתראה כשהאפליקציה ברקע.
  • כשהאפליקציה חוזרת לחזית, ההפעלה המקומית אמורה להימשך וההודעה אמורה להתפרסם מחדש.

הפעל את מתג הפלט בקובץ AndroidManifest.xml

כדי להפעיל את מתג הפלט, צריך להוסיף את MediaTransferReceiver ל-AndroidManifest.xml של האפליקציה. אחרת, התכונה לא תופעל וגם סימון התכונה מרחוק לחנות מקומית לא יהיה חוקי.

<application>
    ...
    <receiver
         android:name="androidx.mediarouter.media.MediaTransferReceiver"
         android:exported="true">
    </receiver>
    ...
</application>

MediaTransferReceiver הוא מקלט שידור שמאפשר להעביר מדיה בין מכשירים עם ממשק משתמש של המערכת. למידע נוסף, קראו את חומר העזר בנושא MediaTransferReceiver.

מקומי מרחוק

כשהמשתמש מעביר את ההפעלה מהפעלה מקומית מרחוק, ה-SDK של Cast יתחיל את סשן ההעברה באופן אוטומטי. עם זאת, צריך לטפל במעבר בין אפליקציות מקומיות מרחוק, למשל, להפסיק את ההפעלה המקומית ולטעון את המדיה במכשיר Cast. על האפליקציות להאזין להעברה (cast) SessionManagerListener, באמצעות onSessionStarted() ועל onSessionEnded() קריאות חוזרות, ולטפל בפעולה בעת קבלת ההעברה SessionManager קריאות חוזרות. אפליקציות צריכות לוודא שהקריאות החוזרות האלה עדיין פעילות כשתיבת הדו-שיח 'מתג פלט' נפתחת והאפליקציה לא נמצאת בחזית.

עדכון SessionManagerListener לצורך העברה (cast) ברקע

הגרסה הקודמת של Cast כבר תומכת ב'העברה מקומית מרחוק' כשהאפליקציה פועלת בחזית. חוויית העברה טיפוסית מתחילה כשמשתמשים לוחצים על סמל ההעברה באפליקציה ובוחרים מכשיר להזרמת מדיה. במקרה כזה, האפליקציה צריכה להירשם ל-SessionManagerListener, ב-onCreate() או onStart() ולבטל את הרישום של ה-listener ב-onStop() או onDestroy() לפעילות של האפליקציה.

בעקבות חוויית המשתמש החדשה של העברה (cast) באמצעות מתג מעבר, אפליקציות יכולות להתחיל להעביר (cast) כשהן ברקע. האפשרות הזו שימושית במיוחד לאפליקציות אודיו ששולחות התראות כשהן פועלות ברקע. אפליקציות יכולות לרשום את המאזינים של SessionManager ב-onCreate() של השירות ולבטל את הרישום ב-onDestroy() של השירות. באופן כזה, האפליקציות תמיד אמורות לקבל את הקריאות החוזרות (callback) בין השידורים המקומיים מרחוק (למשל onSessionStarted) כשהאפליקציה פועלת ברקע.

אם באפליקציה נעשה שימוש ב-MediaBrowserService, מומלץ לרשום בו את ה-SessionManagerListener.

קוטלין
class MyService : Service() {
    private var castContext: CastContext? = null
    protected fun onCreate() {
        castContext = CastContext.getSharedInstance(this)
        castContext
            .getSessionManager()
            .addSessionManagerListener(sessionManagerListener, CastSession::class.java)
    }

    protected fun onDestroy() {
        if (castContext != null) {
            castContext
                .getSessionManager()
                .removeSessionManagerListener(sessionManagerListener, CastSession::class.java)
        }
    }
}
Java
public class MyService extends Service {
  private CastContext castContext;

  @Override
  protected void onCreate() {
     castContext = CastContext.getSharedInstance(this);
     castContext
        .getSessionManager()
        .addSessionManagerListener(sessionManagerListener, CastSession.class);
  }

  @Override
  protected void onDestroy() {
    if (castContext != null) {
       castContext
          .getSessionManager()
          .removeSessionManagerListener(sessionManagerListener, CastSession.class);
    }
  }
}

במסגרת העדכון הזה, העברה מקומית מרחוק פועלת באותו אופן כמו העברה (cast) רגילה כשהאפליקציה פועלת ברקע, ולא נדרשת עבודה נוספת כדי לעבור ממכשירי Bluetooth למכשירי CAST.

שינוי מיקום מרחוק

מתג הפלט מאפשר לעבור מהפעלה מרחוק לרמקול של הטלפון או למכשיר ה-Bluetooth המקומי. אפשר להפעיל את זה על ידי הגדרת הדגל setRemoteToLocalEnabled לערך true ב-CastOptions.

במקרים שבהם המכשיר השולח הנוכחי מצטרף לסשן קיים עם מספר שולחים, והאפליקציה צריכה לבדוק אם מותר להעביר את המדיה הנוכחית באופן מקומי, אפליקציות צריכות להשתמש בקריאה חוזרת onTransferred של SessionTransferCallback על מנת לבדוק את SessionState.

הגדרת הדגל setRemoteToLocalEnabled

השדה CastOptions מספק setRemoteToLocalEnabled כדי להציג או להסתיר את הרמקול של הטלפון ואת מכשירי ה-Bluetooth המקומיים כיעדים להעברה, בתיבת הדו-שיח של 'מתג פלט' כשיש סשן העברה פעיל.

קוטלין
class CastOptionsProvider : OptionsProvider {
    fun getCastOptions(context: Context?): CastOptions {
        ...
        return Builder()
            ...
            .setRemoteToLocalEnabled(true)
            .build()
    }
}
Java
public class CastOptionsProvider implements OptionsProvider {
    @Override
    public CastOptions getCastOptions(Context context) {
        ...
        return new CastOptions.Builder()
            ...
            .setRemoteToLocalEnabled(true)
            .build()
  }
}

המשך ההפעלה באופן מקומי

אפליקציות שתומכות ב'שיתוף מרחוק' צריכות לרשום את SessionTransferCallback כדי לקבל התראה כשהאירוע מתרחש, ולבדוק אם אפשר לאפשר העברה של מדיה ולהמשיך בהפעלה באופן מקומי.

CastContext#addSessionTransferCallback(SessionTransferCallback) מאפשר לאפליקציה לרשום את ה-SessionTransferCallback שלה ולהאזין ל-onTransferred ול-onTransferFailed קריאות חוזרות (callback) כששולח מועבר להפעלה מקומית.

אחרי ביטול הרישום של SessionTransferCallback באפליקציה, היא תפסיק לקבל SessionTransferCallback.

השדה SessionTransferCallback הוא הרחבה של הקריאות הקיימות ב-SessionManagerListener, והוא מופעל אחרי ההפעלה של onSessionEnded. לכן, הסדר של קריאות חוזרות (callback) בין מיקום מרוחק הוא:

  1. onTransferring
  2. onSessionEnding
  3. onSessionEnded
  4. onTransferred

אפשר לפתוח את מתג הפלט באמצעות צ'יפ התראות המדיה כשהאפליקציה פועלת ברקע ומבצעת העברה (cast), לכן הטיפול באפליקציות צריך להיות שונה בהתאם לתמיכה בהפעלה ברקע. במקרה של העברה שנכשלה, onTransferFailed יופעל בכל פעם שהשגיאה מתרחשת.

אפליקציות שתומכות בהפעלה ברקע

באפליקציות שתומכות בהפעלה ברקע (בדרך כלל אפליקציות אודיו), מומלץ להשתמש ב-Service (למשל MediaBrowserService). השירותים צריכים להאזין לקריאה החוזרת (callback) של onTransferred ולחדש את ההפעלה באופן מקומי גם כשהאפליקציה פועלת בחזית או ברקע.

קוטלין
class MyService : Service() {
    private var castContext: CastContext? = null
    private var sessionTransferCallback: SessionTransferCallback? = null
    protected fun onCreate() {
        castContext = CastContext.getSharedInstance(this)
        castContext.getSessionManager()
                   .addSessionManagerListener(sessionManagerListener, CastSession::class.java)
        sessionTransferCallback = MySessionTransferCallback()
        castContext.addSessionTransferCallback(sessionTransferCallback)
    }

    protected fun onDestroy() {
        if (castContext != null) {
            castContext.getSessionManager()
                       .removeSessionManagerListener(sessionManagerListener, CastSession::class.java)
            if (sessionTransferCallback != null) {
                castContext.removeSessionTransferCallback(sessionTransferCallback)
            }
        }
    }

    class MySessionTransferCallback : SessionTransferCallback() {
        fun onTransferring(@SessionTransferCallback.TransferType transferType: Int) {
            // Perform necessary steps prior to onTransferred
        }

        fun onTransferred(@SessionTransferCallback.TransferType transferType: Int,
                          sessionState: SessionState?) {
            if (transferType == SessionTransferCallback.TRANSFER_TYPE_FROM_REMOTE_TO_LOCAL) {
                // Remote stream is transferred to the local device.
                // Retrieve information from the SessionState to continue playback on the local player.
            }
        }

        fun onTransferFailed(@SessionTransferCallback.TransferType transferType: Int,
                             @SessionTransferCallback.TransferFailedReason transferFailedReason: Int) {
            // Handle transfer failure.
        }
    }
}
Java
public class MyService extends Service {
    private CastContext castContext;
    private SessionTransferCallback sessionTransferCallback;

    @Override
    protected void onCreate() {
        castContext = CastContext.getSharedInstance(this);
        castContext.getSessionManager()
                   .addSessionManagerListener(sessionManagerListener, CastSession.class);
        sessionTransferCallback = new MySessionTransferCallback();
        castContext.addSessionTransferCallback(sessionTransferCallback);
    }

    @Override
    protected void onDestroy() {
        if (castContext != null) {
            castContext.getSessionManager()
                       .removeSessionManagerListener(sessionManagerListener, CastSession.class);
            if (sessionTransferCallback != null) {
                castContext.removeSessionTransferCallback(sessionTransferCallback);
            }
        }
    }

    public static class MySessionTransferCallback extends SessionTransferCallback {
        public MySessionTransferCallback() {}

        @Override
        public void onTransferring(@SessionTransferCallback.TransferType int transferType) {
            // Perform necessary steps prior to onTransferred
        }

        @Override
        public void onTransferred(@SessionTransferCallback.TransferType int transferType,
                                  SessionState sessionState) {
            if (transferType==SessionTransferCallback.TRANSFER_TYPE_FROM_REMOTE_TO_LOCAL) {
                // Remote stream is transferred to the local device.
                // Retrieve information from the SessionState to continue playback on the local player.
            }
        }

        @Override
        public void onTransferFailed(@SessionTransferCallback.TransferType int transferType,
                                     @SessionTransferCallback.TransferFailedReason int transferFailedReason) {
            // Handle transfer failure.
        }
    }
}

אפליקציות שלא תומכות בהפעלה ברקע

באפליקציות שלא תומכות בהפעלה ברקע (בדרך כלל אפליקציות וידאו), מומלץ להאזין לקריאה החוזרת (callback) של onTransferred ולהמשיך את ההפעלה באופן מקומי אם האפליקציה פועלת בחזית.

אם האפליקציה נמצאת ברקע, היא צריכה להשהות את ההפעלה ועליה לאחסן את המידע הנדרש מ-SessionState (למשל, המטא-נתונים של המדיה ומיקום ההפעלה). כשהאפליקציה מוצגת ברקע, ההפעלה המקומית צריכה להמשיך עם המידע שנשמר.

קוטלין
class MyActivity : AppCompatActivity() {
    private var castContext: CastContext? = null
    private var sessionTransferCallback: SessionTransferCallback? = null
    protected fun onCreate() {
        castContext = CastContext.getSharedInstance(this)
        castContext.getSessionManager()
                   .addSessionManagerListener(sessionManagerListener, CastSession::class.java)
        sessionTransferCallback = MySessionTransferCallback()
        castContext.addSessionTransferCallback(sessionTransferCallback)
    }

    protected fun onDestroy() {
        if (castContext != null) {
            castContext.getSessionManager()
                       .removeSessionManagerListener(sessionManagerListener, CastSession::class.java)
            if (sessionTransferCallback != null) {
                castContext.removeSessionTransferCallback(sessionTransferCallback)
            }
        }
    }

    class MySessionTransferCallback : SessionTransferCallback() {
        fun onTransferring(@SessionTransferCallback.TransferType transferType: Int) {
            // Perform necessary steps prior to onTransferred
        }

        fun onTransferred(@SessionTransferCallback.TransferType transferType: Int,
                          sessionState: SessionState?) {
            if (transferType == SessionTransferCallback.TRANSFER_TYPE_FROM_REMOTE_TO_LOCAL) {
                // Remote stream is transferred to the local device.

                // Retrieve information from the SessionState to continue playback on the local player.
            }
        }

        fun onTransferFailed(@SessionTransferCallback.TransferType transferType: Int,
                             @SessionTransferCallback.TransferFailedReason transferFailedReason: Int) {
            // Handle transfer failure.
        }
    }
}
Java
public class MyActivity extends AppCompatActivity {
  private CastContext castContext;
  private SessionTransferCallback sessionTransferCallback;

  @Override
  protected void onCreate() {
     castContext = CastContext.getSharedInstance(this);
     castContext
        .getSessionManager()
        .addSessionManagerListener(sessionManagerListener, CastSession.class);
     sessionTransferCallback = new MySessionTransferCallback();
     castContext.addSessionTransferCallback(sessionTransferCallback);
  }

  @Override
  protected void onDestroy() {
    if (castContext != null) {
       castContext
          .getSessionManager()
          .removeSessionManagerListener(sessionManagerListener, CastSession.class);
      if (sessionTransferCallback != null) {
         castContext.removeSessionTransferCallback(sessionTransferCallback);
      }
    }
  }

  public static class MySessionTransferCallback extends SessionTransferCallback {
    public MySessionTransferCallback() {}

    @Override
    public void onTransferring(@SessionTransferCallback.TransferType int transferType) {
        // Perform necessary steps prior to onTransferred
    }

    @Override
    public void onTransferred(@SessionTransferCallback.TransferType int transferType,
                               SessionState sessionState) {
      if (transferType==SessionTransferCallback.TRANSFER_TYPE_FROM_REMOTE_TO_LOCAL) {
        // Remote stream is transferred to the local device.

        // Retrieve information from the SessionState to continue playback on the local player.
      }
    }

    @Override
    public void onTransferFailed(@SessionTransferCallback.TransferType int transferType,
                                 @SessionTransferCallback.TransferFailedReason int transferFailedReason) {
      // Handle transfer failure.
    }
  }
}