[video_player_android] Add RTSP support (#7081)

Add RTSP support to `DataSourceType.network` videos on Android platform.

I'm using this patch on my projects and it works well, but I need some feedback if the approach used is correct. If so, I will continue writing the tests.

This PR implements the Android part of this feature request: https://github.com/flutter/flutter/issues/18061 .

I added a RTSP tab on the example app:

https://github.com/flutter/packages/assets/7874200/9f0addb1-f6bb-4ec6-b8ad-e889f7d8b154
diff --git a/packages/video_player/video_player_android/AUTHORS b/packages/video_player/video_player_android/AUTHORS
index fc16c35..07a1e9f 100644
--- a/packages/video_player/video_player_android/AUTHORS
+++ b/packages/video_player/video_player_android/AUTHORS
@@ -65,3 +65,4 @@
 Alex Li <google@alexv525.com>
 Rahul Raj <64.rahulraj@gmail.com>
 Márton Matuz <matuzmarci@gmail.com>
+André Sousa <andrelvsousa@gmail.com>
diff --git a/packages/video_player/video_player_android/CHANGELOG.md b/packages/video_player/video_player_android/CHANGELOG.md
index 911555a..e6815fc 100644
--- a/packages/video_player/video_player_android/CHANGELOG.md
+++ b/packages/video_player/video_player_android/CHANGELOG.md
@@ -1,3 +1,7 @@
+## 2.6.0
+
+* Adds RTSP support.
+
 ## 2.5.4
 
 * Updates Media3-ExoPlayer to 1.4.0.
diff --git a/packages/video_player/video_player_android/android/build.gradle b/packages/video_player/video_player_android/android/build.gradle
index d9ec79e..af50083 100644
--- a/packages/video_player/video_player_android/android/build.gradle
+++ b/packages/video_player/video_player_android/android/build.gradle
@@ -52,6 +52,7 @@
         implementation "androidx.media3:media3-exoplayer:${exoplayer_version}"
         implementation "androidx.media3:media3-exoplayer-hls:${exoplayer_version}"
         implementation "androidx.media3:media3-exoplayer-dash:${exoplayer_version}"
+        implementation "androidx.media3:media3-exoplayer-rtsp:${exoplayer_version}"
         implementation "androidx.media3:media3-exoplayer-smoothstreaming:${exoplayer_version}"
         testImplementation 'junit:junit:4.13.2'
         testImplementation 'androidx.test:core:1.3.0'
diff --git a/packages/video_player/video_player_android/android/src/main/java/io/flutter/plugins/videoplayer/RemoteVideoAsset.java b/packages/video_player/video_player_android/android/src/main/java/io/flutter/plugins/videoplayer/HttpVideoAsset.java
similarity index 95%
rename from packages/video_player/video_player_android/android/src/main/java/io/flutter/plugins/videoplayer/RemoteVideoAsset.java
rename to packages/video_player/video_player_android/android/src/main/java/io/flutter/plugins/videoplayer/HttpVideoAsset.java
index 75c3c42..f29efc3 100644
--- a/packages/video_player/video_player_android/android/src/main/java/io/flutter/plugins/videoplayer/RemoteVideoAsset.java
+++ b/packages/video_player/video_player_android/android/src/main/java/io/flutter/plugins/videoplayer/HttpVideoAsset.java
@@ -19,14 +19,14 @@
 import androidx.media3.exoplayer.source.MediaSource;
 import java.util.Map;
 
-final class RemoteVideoAsset extends VideoAsset {
+final class HttpVideoAsset extends VideoAsset {
   private static final String DEFAULT_USER_AGENT = "ExoPlayer";
   private static final String HEADER_USER_AGENT = "User-Agent";
 
   @NonNull private final StreamingFormat streamingFormat;
   @NonNull private final Map<String, String> httpHeaders;
 
-  RemoteVideoAsset(
+  HttpVideoAsset(
       @Nullable String assetUrl,
       @NonNull StreamingFormat streamingFormat,
       @NonNull Map<String, String> httpHeaders) {
@@ -79,8 +79,8 @@
       userAgent = httpHeaders.get(HEADER_USER_AGENT);
     }
     unstableUpdateDataSourceFactory(initialFactory, httpHeaders, userAgent);
-    DataSource.Factory dataSoruceFactory = new DefaultDataSource.Factory(context, initialFactory);
-    return new DefaultMediaSourceFactory(context).setDataSourceFactory(dataSoruceFactory);
+    DataSource.Factory dataSourceFactory = new DefaultDataSource.Factory(context, initialFactory);
+    return new DefaultMediaSourceFactory(context).setDataSourceFactory(dataSourceFactory);
   }
 
   // TODO: Migrate to stable API, see https://github.com/flutter/flutter/issues/147039.
diff --git a/packages/video_player/video_player_android/android/src/main/java/io/flutter/plugins/videoplayer/RtspVideoAsset.java b/packages/video_player/video_player_android/android/src/main/java/io/flutter/plugins/videoplayer/RtspVideoAsset.java
new file mode 100644
index 0000000..1eb87c8
--- /dev/null
+++ b/packages/video_player/video_player_android/android/src/main/java/io/flutter/plugins/videoplayer/RtspVideoAsset.java
@@ -0,0 +1,32 @@
+// Copyright 2013 The Flutter Authors. All rights reserved.
+// Use of this source code is governed by a BSD-style license that can be
+// found in the LICENSE file.
+
+package io.flutter.plugins.videoplayer;
+
+import android.content.Context;
+import androidx.annotation.NonNull;
+import androidx.annotation.OptIn;
+import androidx.media3.common.MediaItem;
+import androidx.media3.common.util.UnstableApi;
+import androidx.media3.exoplayer.rtsp.RtspMediaSource;
+import androidx.media3.exoplayer.source.MediaSource;
+
+final class RtspVideoAsset extends VideoAsset {
+  RtspVideoAsset(@NonNull String assetUrl) {
+    super(assetUrl);
+  }
+
+  @NonNull
+  @Override
+  MediaItem getMediaItem() {
+    return new MediaItem.Builder().setUri(assetUrl).build();
+  }
+
+  // TODO: Migrate to stable API, see https://github.com/flutter/flutter/issues/147039.
+  @OptIn(markerClass = UnstableApi.class)
+  @Override
+  MediaSource.Factory getMediaSourceFactory(Context context) {
+    return new RtspMediaSource.Factory();
+  }
+}
diff --git a/packages/video_player/video_player_android/android/src/main/java/io/flutter/plugins/videoplayer/VideoAsset.java b/packages/video_player/video_player_android/android/src/main/java/io/flutter/plugins/videoplayer/VideoAsset.java
index 2b83437..3fab758 100644
--- a/packages/video_player/video_player_android/android/src/main/java/io/flutter/plugins/videoplayer/VideoAsset.java
+++ b/packages/video_player/video_player_android/android/src/main/java/io/flutter/plugins/videoplayer/VideoAsset.java
@@ -41,7 +41,21 @@
       @Nullable String remoteUrl,
       @NonNull StreamingFormat streamingFormat,
       @NonNull Map<String, String> httpHeaders) {
-    return new RemoteVideoAsset(remoteUrl, streamingFormat, new HashMap<>(httpHeaders));
+    return new HttpVideoAsset(remoteUrl, streamingFormat, new HashMap<>(httpHeaders));
+  }
+
+  /**
+   * Returns an asset from a RTSP URL.
+   *
+   * @param rtspUrl remote asset, beginning with {@code rtsp://}.
+   * @return the asset.
+   */
+  @NonNull
+  static VideoAsset fromRtspUrl(@NonNull String rtspUrl) {
+    if (!rtspUrl.startsWith("rtsp://")) {
+      throw new IllegalArgumentException("rtspUrl must start with 'rtsp://'");
+    }
+    return new RtspVideoAsset(rtspUrl);
   }
 
   @Nullable protected final String assetUrl;
diff --git a/packages/video_player/video_player_android/android/src/main/java/io/flutter/plugins/videoplayer/VideoPlayerPlugin.java b/packages/video_player/video_player_android/android/src/main/java/io/flutter/plugins/videoplayer/VideoPlayerPlugin.java
index 0e57068..af13c53 100644
--- a/packages/video_player/video_player_android/android/src/main/java/io/flutter/plugins/videoplayer/VideoPlayerPlugin.java
+++ b/packages/video_player/video_player_android/android/src/main/java/io/flutter/plugins/videoplayer/VideoPlayerPlugin.java
@@ -110,6 +110,8 @@
         assetLookupKey = flutterState.keyForAsset.get(arg.getAsset());
       }
       videoAsset = VideoAsset.fromAssetUrl("asset:///" + assetLookupKey);
+    } else if (arg.getUri().startsWith("rtsp://")) {
+      videoAsset = VideoAsset.fromRtspUrl(arg.getUri());
     } else {
       Map<String, String> httpHeaders = arg.getHttpHeaders();
       VideoAsset.StreamingFormat streamingFormat = VideoAsset.StreamingFormat.UNKNOWN;
diff --git a/packages/video_player/video_player_android/android/src/test/java/io/flutter/plugins/videoplayer/VideoAssetTest.java b/packages/video_player/video_player_android/android/src/test/java/io/flutter/plugins/videoplayer/VideoAssetTest.java
index 743bf57..6e95d05 100644
--- a/packages/video_player/video_player_android/android/src/test/java/io/flutter/plugins/videoplayer/VideoAssetTest.java
+++ b/packages/video_player/video_player_android/android/src/test/java/io/flutter/plugins/videoplayer/VideoAssetTest.java
@@ -69,8 +69,8 @@
 
     DefaultHttpDataSource.Factory mockFactory = mockHttpFactory();
 
-    // Cast to RemoteVideoAsset to call a testing-only method to intercept calls.
-    ((RemoteVideoAsset) asset)
+    // Cast to HttpVideoAsset to call a testing-only method to intercept calls.
+    ((HttpVideoAsset) asset)
         .getMediaSourceFactory(ApplicationProvider.getApplicationContext(), mockFactory);
 
     verify(mockFactory).setUserAgent("ExoPlayer");
@@ -89,8 +89,8 @@
 
     DefaultHttpDataSource.Factory mockFactory = mockHttpFactory();
 
-    // Cast to RemoteVideoAsset to call a testing-only method to intercept calls.
-    ((RemoteVideoAsset) asset)
+    // Cast to HttpVideoAsset to call a testing-only method to intercept calls.
+    ((HttpVideoAsset) asset)
         .getMediaSourceFactory(ApplicationProvider.getApplicationContext(), mockFactory);
 
     verify(mockFactory).setUserAgent("FantasticalVideoBot");
@@ -127,12 +127,28 @@
 
     DefaultHttpDataSource.Factory mockFactory = mockHttpFactory();
 
-    // Cast to RemoteVideoAsset to call a testing-only method to intercept calls.
-    ((RemoteVideoAsset) asset)
+    // Cast to HttpVideoAsset to call a testing-only method to intercept calls.
+    ((HttpVideoAsset) asset)
         .getMediaSourceFactory(ApplicationProvider.getApplicationContext(), mockFactory);
 
     verify(mockFactory).setUserAgent("ExoPlayer");
     verify(mockFactory).setAllowCrossProtocolRedirects(true);
     verify(mockFactory).setDefaultRequestProperties(headers);
   }
+
+  @Test
+  public void rtspVideoRequiresRtspUrl() {
+    assertThrows(
+        IllegalArgumentException.class, () -> VideoAsset.fromRtspUrl("https://not.rtsp/video.mp4"));
+  }
+
+  @Test
+  public void rtspVideoCreatesMediaItem() {
+    VideoAsset asset = VideoAsset.fromRtspUrl("rtsp://test:pass@flutter.dev/stream");
+    MediaItem mediaItem = asset.getMediaItem();
+
+    assert mediaItem.localConfiguration != null;
+    assertEquals(
+        mediaItem.localConfiguration.uri, Uri.parse("rtsp://test:pass@flutter.dev/stream"));
+  }
 }
diff --git a/packages/video_player/video_player_android/example/lib/main.dart b/packages/video_player/video_player_android/example/lib/main.dart
index df28b39..79f4963 100644
--- a/packages/video_player/video_player_android/example/lib/main.dart
+++ b/packages/video_player/video_player_android/example/lib/main.dart
@@ -20,7 +20,7 @@
   @override
   Widget build(BuildContext context) {
     return DefaultTabController(
-      length: 2,
+      length: 3,
       child: Scaffold(
         key: const ValueKey<String>('home_page'),
         appBar: AppBar(
@@ -28,10 +28,8 @@
           bottom: const TabBar(
             isScrollable: true,
             tabs: <Widget>[
-              Tab(
-                icon: Icon(Icons.cloud),
-                text: 'Remote',
-              ),
+              Tab(icon: Icon(Icons.cloud), text: 'Remote'),
+              Tab(icon: Icon(Icons.videocam), text: 'RTSP'),
               Tab(icon: Icon(Icons.insert_drive_file), text: 'Asset'),
             ],
           ),
@@ -39,6 +37,7 @@
         body: TabBarView(
           children: <Widget>[
             _BumbleBeeRemoteVideo(),
+            _RtspRemoteVideo(),
             _ButterFlyAssetVideo(),
           ],
         ),
@@ -63,8 +62,7 @@
     _controller.addListener(() {
       setState(() {});
     });
-    _controller.initialize().then((_) => setState(() {}));
-    _controller.play();
+    _controller.initialize().then((_) => _controller.play());
   }
 
   @override
@@ -156,6 +154,90 @@
   }
 }
 
+class _RtspRemoteVideo extends StatefulWidget {
+  @override
+  _RtspRemoteVideoState createState() => _RtspRemoteVideoState();
+}
+
+class _RtspRemoteVideoState extends State<_RtspRemoteVideo> {
+  MiniController? _controller;
+
+  @override
+  void dispose() {
+    _controller?.dispose();
+    super.dispose();
+  }
+
+  Future<void> changeUrl(String url) async {
+    if (_controller != null) {
+      await _controller!.dispose();
+    }
+
+    setState(() {
+      _controller = MiniController.network(url);
+    });
+
+    _controller!.addListener(() {
+      setState(() {});
+    });
+
+    return _controller!.initialize();
+  }
+
+  String? _validateRtspUrl(String? value) {
+    if (value == null || !value.startsWith('rtsp://')) {
+      return 'Enter a valid RTSP URL';
+    }
+    return null;
+  }
+
+  @override
+  Widget build(BuildContext context) {
+    return SingleChildScrollView(
+      child: Column(
+        children: <Widget>[
+          Container(padding: const EdgeInsets.only(top: 20.0)),
+          const Text('With RTSP streaming'),
+          Padding(
+            padding: const EdgeInsets.all(20.0),
+            child: TextFormField(
+              autovalidateMode: AutovalidateMode.onUserInteraction,
+              decoration: const InputDecoration(label: Text('RTSP URL')),
+              validator: _validateRtspUrl,
+              textInputAction: TextInputAction.done,
+              onFieldSubmitted: (String value) {
+                if (_validateRtspUrl(value) == null) {
+                  changeUrl(value);
+                } else {
+                  setState(() {
+                    _controller?.dispose();
+                    _controller = null;
+                  });
+                }
+              },
+            ),
+          ),
+          if (_controller != null)
+            Container(
+              padding: const EdgeInsets.all(20),
+              child: AspectRatio(
+                aspectRatio: _controller!.value.aspectRatio,
+                child: Stack(
+                  alignment: Alignment.bottomCenter,
+                  children: <Widget>[
+                    VideoPlayer(_controller!),
+                    _ControlsOverlay(controller: _controller!),
+                    VideoProgressIndicator(_controller!),
+                  ],
+                ),
+              ),
+            ),
+        ],
+      ),
+    );
+  }
+}
+
 class _ControlsOverlay extends StatelessWidget {
   const _ControlsOverlay({required this.controller});
 
diff --git a/packages/video_player/video_player_android/pubspec.yaml b/packages/video_player/video_player_android/pubspec.yaml
index fee6349..ed629fa 100644
--- a/packages/video_player/video_player_android/pubspec.yaml
+++ b/packages/video_player/video_player_android/pubspec.yaml
@@ -2,7 +2,7 @@
 description: Android implementation of the video_player plugin.
 repository: https://github.com/flutter/packages/tree/main/packages/video_player/video_player_android
 issue_tracker: https://github.com/flutter/flutter/issues?q=is%3Aissue+is%3Aopen+label%3A%22p%3A+video_player%22
-version: 2.5.4
+version: 2.6.0
 
 environment:
   sdk: ^3.4.0