├── .gitignore ├── BUILD.md ├── LICENSE ├── README.md ├── app ├── .gitignore ├── build.gradle ├── proguard-rules.pro └── src │ └── main │ ├── AndroidManifest.xml │ ├── java │ └── com │ │ └── rom1v │ │ └── sndcpy │ │ ├── MainActivity.java │ │ └── RecordService.java │ └── res │ ├── drawable │ ├── ic_album_black_24dp.xml │ ├── ic_close_24dp.xml │ ├── ic_launcher_background.xml │ └── ic_launcher_foreground.xml │ ├── mipmap-anydpi │ ├── ic_launcher.xml │ └── ic_launcher_round.xml │ ├── mipmap-hdpi │ ├── ic_launcher.png │ └── ic_launcher_round.png │ ├── mipmap-mdpi │ ├── ic_launcher.png │ └── ic_launcher_round.png │ ├── mipmap-xhdpi │ ├── ic_launcher.png │ └── ic_launcher_round.png │ ├── mipmap-xxhdpi │ ├── ic_launcher.png │ └── ic_launcher_round.png │ ├── mipmap-xxxhdpi │ ├── ic_launcher.png │ └── ic_launcher_round.png │ └── values │ ├── strings.xml │ └── styles.xml ├── build.gradle ├── gradle.properties ├── gradle └── wrapper │ ├── gradle-wrapper.jar │ └── gradle-wrapper.properties ├── gradlew ├── gradlew.bat ├── release.sh ├── run ├── settings.gradle ├── sndcpy └── sndcpy.bat /.gitignore: -------------------------------------------------------------------------------- 1 | *.iml 2 | .gradle 3 | /local.properties 4 | /.idea/ 5 | .DS_Store 6 | /build 7 | /dist 8 | /captures 9 | .externalNativeBuild 10 | .cxx 11 | -------------------------------------------------------------------------------- /BUILD.md: -------------------------------------------------------------------------------- 1 | # Build 2 | 3 | ## Debug 4 | 5 | This project is an Android application with some shell scripts to execute on the 6 | computer. Therefore, just use `gradle` as usual: 7 | 8 | ``` 9 | ./gradlew assembleDebug 10 | ``` 11 | 12 | (or build from _Android Studio_) 13 | 14 | To run it: 15 | 16 | ```bash 17 | ./run 18 | ./run # if several devices are connected 19 | ``` 20 | 21 | _Since building is very fast, `./run` also executes `./gradlew assembleDebug` to 22 | always run an up-to-date version._ 23 | 24 | 25 | ## Release 26 | 27 | To build and install a release, you need to generate a signed APK. 28 | 29 | For that purpose, first generate a _keystore_: 30 | 31 | ```bash 32 | # generate sndcpy.keystore file 33 | keytool -genkey -v -keystore sndcpy.keystore -alias sndcpy \ 34 | -keyalg RSA -keysize 2048 -validity 30000 35 | ``` 36 | 37 | Then, add these lines (and adapt) in `~/.gradle/gradle.properties`: 38 | 39 | ```bash 40 | SNDCPY_STORE_FILE=/path/to/your/sndcpy.keystore 41 | SNDCPY_STORE_PASSWORD=the_keystore_password 42 | SNDCPY_KEY_ALIAS=sndcpy 43 | SNDCPY_KEY_PASSWORD=the_key_password 44 | ``` 45 | 46 | Then, execute `./release.sh`. It will generate a release in `dist/`. 47 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2020 Romain Vimont 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # sndcpy (v1.1) 2 | 3 | This tool forwards audio from an Android 10 device to the computer. It does not 4 | require any _root_ access. It works on _GNU/Linux_, _Windows_ and _macOS_. 5 | 6 | The purpose is to enable [audio forwarding][issue14] while mirroring with 7 | [scrcpy]. However, it can be used independently. 8 | 9 | **Update: [scrcpy 2.0] now supports audio forwarding natively.** 10 | 11 | [issue14]: https://github.com/Genymobile/scrcpy/issues/14 12 | [scrcpy]: https://github.com/Genymobile/scrcpy 13 | [scrcpy 2.0]: https://blog.rom1v.com/2023/03/scrcpy-2-0-with-audio/ 14 | 15 | ## Note 16 | 17 | This application is a proof-of-concept. But as explained below, many apps are 18 | restricted and could not forward the audio. 19 | 20 | ## Requirements 21 | 22 | - The Android device requires at least Android 10. 23 | - [VLC] must be installed on the computer. 24 | 25 | [vlc]: https://www.videolan.org/ 26 | 27 | 28 | ## Get the app 29 | 30 | Download the latest release: 31 | 32 | - [`sndcpy-v1.1.zip`][release] 33 | SHA-256: `b045320ddddbc5a237d6d95213c664f03b2f46ef8bbfcef91c2d95644ed42bbc` 34 | - [`sndcpy-with-adb-windows-v1.1.zip`][release-adb] 35 | SHA-256: `0b93c846f574df3f38f53eeee44b1a35b863a49465da8f5cd5b1b69a29305901` 36 | 37 | _On Windows, for simplicity, take the second archive, which also contains 38 | `adb`._ 39 | 40 | [release]: https://github.com/rom1v/sndcpy/releases/download/v1.1/sndcpy-v1.1.zip 41 | [release-adb]: https://github.com/rom1v/sndcpy/releases/download/v1.1/sndcpy-with-adb-windows-v1.1.zip 42 | 43 | Alternatively, you could [build the app][BUILD]. 44 | 45 | [BUILD]: BUILD.md 46 | 47 | ## Run the app 48 | 49 | Plug an Android 10 device with USB debugging enabled, and execute: 50 | 51 | ```bash 52 | ./sndcpy 53 | ``` 54 | 55 | If several devices are connected (listed by `adb devices`): 56 | 57 | ```bash 58 | ./sndcpy # replace by the device serial 59 | ``` 60 | 61 | _(omit `./` on Windows)_ 62 | 63 | It will install the app on the device and start forwarding audio. 64 | 65 | Press `Ctrl`+`c` in the terminal to stop (except on Windows, just disconnect the 66 | device or stop capture from the device notifications). 67 | 68 | VLC may print this error message once: 69 | 70 | ``` 71 | main stream error: connection error: Connection refused 72 | ``` 73 | 74 | It is "expected", just ignore it. 75 | 76 | The sound continues to be played on the device. The volume can be adjusted 77 | independently on the device and on the computer. 78 | 79 | ## Uninstall 80 | 81 | To uninstall the app from the device: 82 | 83 | ```bash 84 | adb uninstall com.rom1v.sndcpy 85 | ``` 86 | 87 | ## Apps restrictions 88 | 89 | `sndcpy` may only forward audio from apps which do not prevent audio 90 | capture. The rules are detailed in [§capture policy][rules]: 91 | 92 | > - By default, apps that target versions up to and including to Android 9.0 do 93 | > not permit playback capture. To enable it, include 94 | > `android:allowAudioPlaybackCapture="true"` in the app's `manifest.xml` file. 95 | > - By default, apps that target Android 10 (API level 29) or higher allow their 96 | > audio to be captured. To disable playback capture, include 97 | > `android:allowAudioPlaybackCapture="false"` in the app's `manifest.xml` 98 | > file. 99 | 100 | So some apps might need to be updated to support audio capture. 101 | 102 | [rules]: https://developer.android.com/guide/topics/media/playback-capture#capture_policy 103 | 104 | ## Audio delay 105 | 106 | This is just a proof-of-concept, so it's far from perfect. 107 | 108 | For example, jitter may cause VLC to automatically increase its buffering, 109 | causing an unacceptable delay: 110 | 111 | ``` 112 | main input error: ES_OUT_SET_(GROUP_)PCR is called too late (pts_delay increased to 377 ms) 113 | ``` 114 | 115 | In that case, just restart it. 116 | 117 | ## Environment variables 118 | 119 | The scripts [`sndcpy`](sndcpy) and [`sndcpy.bat`](sndcpy.bat) use some default 120 | values which can be overridden by environment variables. 121 | 122 | - `ADB`: the full path to the `adb` executable 123 | - `VLC`: the full path to the `vlc` executable 124 | - `SNDCPY_APK`: the full path to `sndcpy.apk` 125 | - `SNDCPY_PORT`: the local port to forward to communicate with the device 126 | 127 | 128 | ## Blog post 129 | 130 | - [Audio forwarding on Android 10][blogpost] 131 | 132 | [blogpost]: https://blog.rom1v.com/2020/06/audio-forwarding-on-android-10/ 133 | -------------------------------------------------------------------------------- /app/.gitignore: -------------------------------------------------------------------------------- 1 | /build 2 | -------------------------------------------------------------------------------- /app/build.gradle: -------------------------------------------------------------------------------- 1 | apply plugin: 'com.android.application' 2 | 3 | android { 4 | compileSdkVersion 31 5 | 6 | defaultConfig { 7 | applicationId "com.rom1v.sndcpy" 8 | minSdkVersion 29 9 | targetSdkVersion 31 10 | versionCode 2 11 | versionName "1.1" 12 | 13 | testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner" 14 | } 15 | 16 | buildTypes { 17 | release { 18 | minifyEnabled false 19 | proguardFiles getDefaultProguardFile('proguard-android-optimize.txt'), 'proguard-rules.pro' 20 | } 21 | } 22 | } 23 | 24 | if (project.hasProperty("SNDCPY_STORE_FILE")) { 25 | android.signingConfigs { 26 | release { 27 | // to be defined in ~/.gradle/gradle.properties 28 | storeFile file(RELEASE_STORE_FILE) 29 | storePassword RELEASE_STORE_PASSWORD 30 | keyAlias RELEASE_KEY_ALIAS 31 | keyPassword RELEASE_KEY_PASSWORD 32 | } 33 | } 34 | android.buildTypes.release.signingConfig = android.signingConfigs.release 35 | } 36 | -------------------------------------------------------------------------------- /app/proguard-rules.pro: -------------------------------------------------------------------------------- 1 | # Add project specific ProGuard rules here. 2 | # You can control the set of applied configuration files using the 3 | # proguardFiles setting in build.gradle. 4 | # 5 | # For more details, see 6 | # http://developer.android.com/guide/developing/tools/proguard.html 7 | 8 | # If your project uses WebView with JS, uncomment the following 9 | # and specify the fully qualified class name to the JavaScript interface 10 | # class: 11 | #-keepclassmembers class fqcn.of.javascript.interface.for.webview { 12 | # public *; 13 | #} 14 | 15 | # Uncomment this to preserve the line number information for 16 | # debugging stack traces. 17 | #-keepattributes SourceFile,LineNumberTable 18 | 19 | # If you keep the line number information, uncomment this to 20 | # hide the original source file name. 21 | #-renamesourcefileattribute SourceFile 22 | -------------------------------------------------------------------------------- /app/src/main/AndroidManifest.xml: -------------------------------------------------------------------------------- 1 | 3 | 4 | 5 | 6 | 7 | 12 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 29 | 30 | 31 | -------------------------------------------------------------------------------- /app/src/main/java/com/rom1v/sndcpy/MainActivity.java: -------------------------------------------------------------------------------- 1 | package com.rom1v.sndcpy; 2 | 3 | import android.Manifest; 4 | import android.app.Activity; 5 | import android.content.Intent; 6 | import android.content.pm.PackageManager; 7 | import android.media.projection.MediaProjectionManager; 8 | import android.os.Bundle; 9 | 10 | public class MainActivity extends Activity { 11 | 12 | private static final int REQUEST_CODE_PERMISSION_AUDIO = 1; 13 | private static final int REQUEST_CODE_START_CAPTURE = 2; 14 | 15 | @Override 16 | protected void onCreate(Bundle savedInstanceState) { 17 | super.onCreate(savedInstanceState); 18 | 19 | if (checkSelfPermission(Manifest.permission.RECORD_AUDIO) != PackageManager.PERMISSION_GRANTED) { 20 | String[] permissions = {Manifest.permission.RECORD_AUDIO}; 21 | requestPermissions(permissions, REQUEST_CODE_PERMISSION_AUDIO); 22 | } 23 | 24 | MediaProjectionManager mediaProjectionManager = (MediaProjectionManager) getSystemService(MEDIA_PROJECTION_SERVICE); 25 | Intent intent = mediaProjectionManager.createScreenCaptureIntent(); 26 | startActivityForResult(intent, REQUEST_CODE_START_CAPTURE); 27 | } 28 | 29 | @Override 30 | public void onActivityResult(int requestCode, int resultCode, Intent data) { 31 | if (requestCode == REQUEST_CODE_START_CAPTURE && resultCode == Activity.RESULT_OK) { 32 | RecordService.start(this, data); 33 | } 34 | finish(); 35 | } 36 | } 37 | -------------------------------------------------------------------------------- /app/src/main/java/com/rom1v/sndcpy/RecordService.java: -------------------------------------------------------------------------------- 1 | package com.rom1v.sndcpy; 2 | 3 | import android.app.Activity; 4 | import android.app.Notification; 5 | import android.app.NotificationChannel; 6 | import android.app.NotificationManager; 7 | import android.app.PendingIntent; 8 | import android.app.Service; 9 | import android.content.Context; 10 | import android.content.Intent; 11 | import android.content.pm.ServiceInfo; 12 | import android.graphics.drawable.Icon; 13 | import android.media.AudioAttributes; 14 | import android.media.AudioFormat; 15 | import android.media.AudioPlaybackCaptureConfiguration; 16 | import android.media.AudioRecord; 17 | import android.media.projection.MediaProjection; 18 | import android.media.projection.MediaProjectionManager; 19 | import android.net.LocalServerSocket; 20 | import android.net.LocalSocket; 21 | import android.os.Handler; 22 | import android.os.IBinder; 23 | import android.os.Message; 24 | import android.util.Log; 25 | 26 | import java.io.IOException; 27 | 28 | public class RecordService extends Service { 29 | 30 | private static final String TAG = "sndcpy"; 31 | private static final String CHANNEL_ID = "sndcpy"; 32 | private static final int NOTIFICATION_ID = 1; 33 | 34 | private static final String ACTION_RECORD = "com.rom1v.sndcpy.RECORD"; 35 | private static final String ACTION_STOP = "com.rom1v.sndcpy.STOP"; 36 | private static final String EXTRA_MEDIA_PROJECTION_DATA = "mediaProjectionData"; 37 | 38 | private static final int MSG_CONNECTION_ESTABLISHED = 1; 39 | 40 | private static final String SOCKET_NAME = "sndcpy"; 41 | 42 | 43 | private static final int SAMPLE_RATE = 48000; 44 | private static final int CHANNELS = 2; 45 | 46 | private final Handler handler = new ConnectionHandler(this); 47 | private MediaProjectionManager mediaProjectionManager; 48 | private MediaProjection mediaProjection; 49 | private Thread recorderThread; 50 | 51 | public static void start(Context context, Intent data) { 52 | Intent intent = new Intent(context, RecordService.class); 53 | intent.setAction(ACTION_RECORD); 54 | intent.putExtra(EXTRA_MEDIA_PROJECTION_DATA, data); 55 | context.startForegroundService(intent); 56 | } 57 | 58 | @Override 59 | public void onCreate() { 60 | super.onCreate(); 61 | 62 | Notification notification = createNotification(false); 63 | 64 | NotificationChannel channel = new NotificationChannel(CHANNEL_ID, getString(R.string.app_name), NotificationManager.IMPORTANCE_NONE); 65 | getNotificationManager().createNotificationChannel(channel); 66 | 67 | startForeground(NOTIFICATION_ID, notification, ServiceInfo.FOREGROUND_SERVICE_TYPE_MEDIA_PROJECTION); 68 | } 69 | 70 | @Override 71 | public int onStartCommand(Intent intent, int flags, int startId) { 72 | String action = intent.getAction(); 73 | if (ACTION_STOP.equals(action)) { 74 | stopSelf(); 75 | return START_NOT_STICKY; 76 | } 77 | 78 | if (isRunning()) { 79 | return START_NOT_STICKY; 80 | } 81 | 82 | Intent data = intent.getParcelableExtra(EXTRA_MEDIA_PROJECTION_DATA); 83 | mediaProjectionManager = (MediaProjectionManager) getSystemService(MEDIA_PROJECTION_SERVICE); 84 | mediaProjection = mediaProjectionManager.getMediaProjection(Activity.RESULT_OK, data); 85 | if (mediaProjection != null) { 86 | startRecording(); 87 | } else { 88 | Log.w(TAG, "Failed to capture audio"); 89 | stopSelf(); 90 | } 91 | return START_NOT_STICKY; 92 | } 93 | 94 | @Override 95 | public IBinder onBind(Intent intent) { 96 | return null; 97 | } 98 | 99 | private Notification createNotification(boolean established) { 100 | Notification.Builder notificationBuilder = new Notification.Builder(this, CHANNEL_ID); 101 | notificationBuilder.setContentTitle(getString(R.string.app_name)); 102 | int textRes = established ? R.string.notification_forwarding : R.string.notification_waiting; 103 | notificationBuilder.setContentText(getText(textRes)); 104 | notificationBuilder.setSmallIcon(R.drawable.ic_album_black_24dp); 105 | notificationBuilder.addAction(createStopAction()); 106 | return notificationBuilder.build(); 107 | } 108 | 109 | 110 | private Intent createStopIntent() { 111 | Intent intent = new Intent(this, RecordService.class); 112 | intent.setAction(ACTION_STOP); 113 | return intent; 114 | } 115 | 116 | private Notification.Action createStopAction() { 117 | Intent stopIntent = createStopIntent(); 118 | PendingIntent stopPendingIntent = PendingIntent.getService(this, 0, stopIntent, PendingIntent.FLAG_ONE_SHOT | PendingIntent.FLAG_IMMUTABLE); 119 | Icon stopIcon = Icon.createWithResource(this, R.drawable.ic_close_24dp); 120 | String stopString = getString(R.string.action_stop); 121 | Notification.Action.Builder actionBuilder = new Notification.Action.Builder(stopIcon, stopString, stopPendingIntent); 122 | return actionBuilder.build(); 123 | } 124 | 125 | private static LocalSocket connect() throws IOException { 126 | LocalServerSocket localServerSocket = new LocalServerSocket(SOCKET_NAME); 127 | try { 128 | return localServerSocket.accept(); 129 | } finally { 130 | localServerSocket.close(); 131 | } 132 | } 133 | 134 | private static AudioPlaybackCaptureConfiguration createAudioPlaybackCaptureConfig(MediaProjection mediaProjection) { 135 | AudioPlaybackCaptureConfiguration.Builder confBuilder = new AudioPlaybackCaptureConfiguration.Builder(mediaProjection); 136 | confBuilder.addMatchingUsage(AudioAttributes.USAGE_MEDIA); 137 | confBuilder.addMatchingUsage(AudioAttributes.USAGE_GAME); 138 | confBuilder.addMatchingUsage(AudioAttributes.USAGE_UNKNOWN); 139 | return confBuilder.build(); 140 | } 141 | 142 | private static AudioFormat createAudioFormat() { 143 | AudioFormat.Builder builder = new AudioFormat.Builder(); 144 | builder.setEncoding(AudioFormat.ENCODING_PCM_16BIT); 145 | builder.setSampleRate(SAMPLE_RATE); 146 | builder.setChannelMask(CHANNELS == 2 ? AudioFormat.CHANNEL_IN_STEREO : AudioFormat.CHANNEL_IN_MONO); 147 | return builder.build(); 148 | } 149 | 150 | private static AudioRecord createAudioRecord(MediaProjection mediaProjection) { 151 | AudioRecord.Builder builder = new AudioRecord.Builder(); 152 | builder.setAudioFormat(createAudioFormat()); 153 | builder.setBufferSizeInBytes(1024 * 1024); 154 | builder.setAudioPlaybackCaptureConfig(createAudioPlaybackCaptureConfig(mediaProjection)); 155 | return builder.build(); 156 | } 157 | 158 | private void startRecording() { 159 | final AudioRecord recorder = createAudioRecord(mediaProjection); 160 | 161 | recorderThread = new Thread(new Runnable() { 162 | @Override 163 | public void run() { 164 | try (LocalSocket socket = connect()) { 165 | handler.sendEmptyMessage(MSG_CONNECTION_ESTABLISHED); 166 | 167 | recorder.startRecording(); 168 | int BUFFER_MS = 15; // do not buffer more than BUFFER_MS milliseconds 169 | byte[] buf = new byte[SAMPLE_RATE * CHANNELS * BUFFER_MS / 1000]; 170 | while (true) { 171 | int r = recorder.read(buf, 0, buf.length); 172 | socket.getOutputStream().write(buf, 0, r); 173 | } 174 | } catch (IOException e) { 175 | // ignore 176 | } finally { 177 | recorder.stop(); 178 | stopSelf(); 179 | } 180 | } 181 | }); 182 | recorderThread.start(); 183 | } 184 | 185 | private boolean isRunning() { 186 | return recorderThread != null; 187 | } 188 | 189 | @Override 190 | public void onDestroy() { 191 | super.onDestroy(); 192 | stopForeground(true); 193 | if (recorderThread != null) { 194 | recorderThread.interrupt(); 195 | recorderThread = null; 196 | } 197 | } 198 | 199 | private NotificationManager getNotificationManager() { 200 | return (NotificationManager) getSystemService(Context.NOTIFICATION_SERVICE); 201 | } 202 | 203 | private static final class ConnectionHandler extends Handler { 204 | 205 | private RecordService service; 206 | 207 | ConnectionHandler(RecordService service) { 208 | this.service = service; 209 | } 210 | 211 | @Override 212 | public void handleMessage(Message message) { 213 | if (!service.isRunning()) { 214 | // if the VPN is not running anymore, ignore obsolete events 215 | return; 216 | } 217 | 218 | if (message.what == MSG_CONNECTION_ESTABLISHED) { 219 | Notification notification = service.createNotification(true); 220 | service.getNotificationManager().notify(NOTIFICATION_ID, notification); 221 | } 222 | } 223 | } 224 | } -------------------------------------------------------------------------------- /app/src/main/res/drawable/ic_album_black_24dp.xml: -------------------------------------------------------------------------------- 1 | 6 | 9 | 10 | -------------------------------------------------------------------------------- /app/src/main/res/drawable/ic_close_24dp.xml: -------------------------------------------------------------------------------- 1 | 6 | 9 | 10 | -------------------------------------------------------------------------------- /app/src/main/res/drawable/ic_launcher_background.xml: -------------------------------------------------------------------------------- 1 | 2 | 7 | 10 | 15 | 20 | 25 | 30 | 35 | 40 | 45 | 50 | 55 | 60 | 65 | 70 | 75 | 80 | 85 | 90 | 95 | 100 | 105 | 110 | 115 | 120 | 125 | 130 | 135 | 140 | 145 | 150 | 155 | 160 | 165 | 170 | 171 | -------------------------------------------------------------------------------- /app/src/main/res/drawable/ic_launcher_foreground.xml: -------------------------------------------------------------------------------- 1 | 7 | 8 | 9 | 15 | 18 | 21 | 22 | 23 | 24 | 30 | -------------------------------------------------------------------------------- /app/src/main/res/mipmap-anydpi/ic_launcher.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | -------------------------------------------------------------------------------- /app/src/main/res/mipmap-anydpi/ic_launcher_round.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | -------------------------------------------------------------------------------- /app/src/main/res/mipmap-hdpi/ic_launcher.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/rom1v/sndcpy/92f5f47ca49187e30bfb0473771f3a913b4812ab/app/src/main/res/mipmap-hdpi/ic_launcher.png -------------------------------------------------------------------------------- /app/src/main/res/mipmap-hdpi/ic_launcher_round.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/rom1v/sndcpy/92f5f47ca49187e30bfb0473771f3a913b4812ab/app/src/main/res/mipmap-hdpi/ic_launcher_round.png -------------------------------------------------------------------------------- /app/src/main/res/mipmap-mdpi/ic_launcher.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/rom1v/sndcpy/92f5f47ca49187e30bfb0473771f3a913b4812ab/app/src/main/res/mipmap-mdpi/ic_launcher.png -------------------------------------------------------------------------------- /app/src/main/res/mipmap-mdpi/ic_launcher_round.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/rom1v/sndcpy/92f5f47ca49187e30bfb0473771f3a913b4812ab/app/src/main/res/mipmap-mdpi/ic_launcher_round.png -------------------------------------------------------------------------------- /app/src/main/res/mipmap-xhdpi/ic_launcher.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/rom1v/sndcpy/92f5f47ca49187e30bfb0473771f3a913b4812ab/app/src/main/res/mipmap-xhdpi/ic_launcher.png -------------------------------------------------------------------------------- /app/src/main/res/mipmap-xhdpi/ic_launcher_round.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/rom1v/sndcpy/92f5f47ca49187e30bfb0473771f3a913b4812ab/app/src/main/res/mipmap-xhdpi/ic_launcher_round.png -------------------------------------------------------------------------------- /app/src/main/res/mipmap-xxhdpi/ic_launcher.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/rom1v/sndcpy/92f5f47ca49187e30bfb0473771f3a913b4812ab/app/src/main/res/mipmap-xxhdpi/ic_launcher.png -------------------------------------------------------------------------------- /app/src/main/res/mipmap-xxhdpi/ic_launcher_round.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/rom1v/sndcpy/92f5f47ca49187e30bfb0473771f3a913b4812ab/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.png -------------------------------------------------------------------------------- /app/src/main/res/mipmap-xxxhdpi/ic_launcher.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/rom1v/sndcpy/92f5f47ca49187e30bfb0473771f3a913b4812ab/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png -------------------------------------------------------------------------------- /app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/rom1v/sndcpy/92f5f47ca49187e30bfb0473771f3a913b4812ab/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.png -------------------------------------------------------------------------------- /app/src/main/res/values/strings.xml: -------------------------------------------------------------------------------- 1 | 2 | sndcpy 3 | Waiting for connection… 4 | Audio forwarding enabled 5 | Stop 6 | 7 | -------------------------------------------------------------------------------- /app/src/main/res/values/styles.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 11 | 12 | 13 | -------------------------------------------------------------------------------- /build.gradle: -------------------------------------------------------------------------------- 1 | // Top-level build file where you can add configuration options common to all sub-projects/modules. 2 | 3 | buildscript { 4 | repositories { 5 | google() 6 | jcenter() 7 | } 8 | dependencies { 9 | classpath 'com.android.tools.build:gradle:7.0.2' 10 | // NOTE: Do not place your application dependencies here; they belong 11 | // in the individual module build.gradle files 12 | } 13 | } 14 | 15 | allprojects { 16 | repositories { 17 | google() 18 | jcenter() 19 | 20 | } 21 | } 22 | 23 | task clean(type: Delete) { 24 | delete rootProject.buildDir 25 | } 26 | -------------------------------------------------------------------------------- /gradle.properties: -------------------------------------------------------------------------------- 1 | # Project-wide Gradle settings. 2 | # IDE (e.g. Android Studio) users: 3 | # Gradle settings configured through the IDE *will override* 4 | # any settings specified in this file. 5 | # For more details on how to configure your build environment visit 6 | # http://www.gradle.org/docs/current/userguide/build_environment.html 7 | # Specifies the JVM arguments used for the daemon process. 8 | # The setting is particularly useful for tweaking memory settings. 9 | org.gradle.jvmargs=-Xmx1536m 10 | # When configured, Gradle will run in incubating parallel mode. 11 | # This option should only be used with decoupled projects. More details, visit 12 | # http://www.gradle.org/docs/current/userguide/multi_project_builds.html#sec:decoupled_projects 13 | # org.gradle.parallel=true 14 | # AndroidX package structure to make it clearer which packages are bundled with the 15 | # Android operating system, and which are packaged with your app's APK 16 | # https://developer.android.com/topic/libraries/support-library/androidx-rn 17 | android.useAndroidX=true 18 | # Automatically convert third-party libraries to use AndroidX 19 | android.enableJetifier=true 20 | -------------------------------------------------------------------------------- /gradle/wrapper/gradle-wrapper.jar: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/rom1v/sndcpy/92f5f47ca49187e30bfb0473771f3a913b4812ab/gradle/wrapper/gradle-wrapper.jar -------------------------------------------------------------------------------- /gradle/wrapper/gradle-wrapper.properties: -------------------------------------------------------------------------------- 1 | #Mon Jun 08 22:54:59 CEST 2020 2 | distributionBase=GRADLE_USER_HOME 3 | distributionPath=wrapper/dists 4 | zipStoreBase=GRADLE_USER_HOME 5 | zipStorePath=wrapper/dists 6 | distributionUrl=https\://services.gradle.org/distributions/gradle-7.0.2-all.zip 7 | -------------------------------------------------------------------------------- /gradlew: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env sh 2 | 3 | ############################################################################## 4 | ## 5 | ## Gradle start up script for UN*X 6 | ## 7 | ############################################################################## 8 | 9 | # Attempt to set APP_HOME 10 | # Resolve links: $0 may be a link 11 | PRG="$0" 12 | # Need this for relative symlinks. 13 | while [ -h "$PRG" ] ; do 14 | ls=`ls -ld "$PRG"` 15 | link=`expr "$ls" : '.*-> \(.*\)$'` 16 | if expr "$link" : '/.*' > /dev/null; then 17 | PRG="$link" 18 | else 19 | PRG=`dirname "$PRG"`"/$link" 20 | fi 21 | done 22 | SAVED="`pwd`" 23 | cd "`dirname \"$PRG\"`/" >/dev/null 24 | APP_HOME="`pwd -P`" 25 | cd "$SAVED" >/dev/null 26 | 27 | APP_NAME="Gradle" 28 | APP_BASE_NAME=`basename "$0"` 29 | 30 | # Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. 31 | DEFAULT_JVM_OPTS="" 32 | 33 | # Use the maximum available, or set MAX_FD != -1 to use that value. 34 | MAX_FD="maximum" 35 | 36 | warn () { 37 | echo "$*" 38 | } 39 | 40 | die () { 41 | echo 42 | echo "$*" 43 | echo 44 | exit 1 45 | } 46 | 47 | # OS specific support (must be 'true' or 'false'). 48 | cygwin=false 49 | msys=false 50 | darwin=false 51 | nonstop=false 52 | case "`uname`" in 53 | CYGWIN* ) 54 | cygwin=true 55 | ;; 56 | Darwin* ) 57 | darwin=true 58 | ;; 59 | MINGW* ) 60 | msys=true 61 | ;; 62 | NONSTOP* ) 63 | nonstop=true 64 | ;; 65 | esac 66 | 67 | CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar 68 | 69 | # Determine the Java command to use to start the JVM. 70 | if [ -n "$JAVA_HOME" ] ; then 71 | if [ -x "$JAVA_HOME/jre/sh/java" ] ; then 72 | # IBM's JDK on AIX uses strange locations for the executables 73 | JAVACMD="$JAVA_HOME/jre/sh/java" 74 | else 75 | JAVACMD="$JAVA_HOME/bin/java" 76 | fi 77 | if [ ! -x "$JAVACMD" ] ; then 78 | die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME 79 | 80 | Please set the JAVA_HOME variable in your environment to match the 81 | location of your Java installation." 82 | fi 83 | else 84 | JAVACMD="java" 85 | which java >/dev/null 2>&1 || die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 86 | 87 | Please set the JAVA_HOME variable in your environment to match the 88 | location of your Java installation." 89 | fi 90 | 91 | # Increase the maximum file descriptors if we can. 92 | if [ "$cygwin" = "false" -a "$darwin" = "false" -a "$nonstop" = "false" ] ; then 93 | MAX_FD_LIMIT=`ulimit -H -n` 94 | if [ $? -eq 0 ] ; then 95 | if [ "$MAX_FD" = "maximum" -o "$MAX_FD" = "max" ] ; then 96 | MAX_FD="$MAX_FD_LIMIT" 97 | fi 98 | ulimit -n $MAX_FD 99 | if [ $? -ne 0 ] ; then 100 | warn "Could not set maximum file descriptor limit: $MAX_FD" 101 | fi 102 | else 103 | warn "Could not query maximum file descriptor limit: $MAX_FD_LIMIT" 104 | fi 105 | fi 106 | 107 | # For Darwin, add options to specify how the application appears in the dock 108 | if $darwin; then 109 | GRADLE_OPTS="$GRADLE_OPTS \"-Xdock:name=$APP_NAME\" \"-Xdock:icon=$APP_HOME/media/gradle.icns\"" 110 | fi 111 | 112 | # For Cygwin, switch paths to Windows format before running java 113 | if $cygwin ; then 114 | APP_HOME=`cygpath --path --mixed "$APP_HOME"` 115 | CLASSPATH=`cygpath --path --mixed "$CLASSPATH"` 116 | JAVACMD=`cygpath --unix "$JAVACMD"` 117 | 118 | # We build the pattern for arguments to be converted via cygpath 119 | ROOTDIRSRAW=`find -L / -maxdepth 1 -mindepth 1 -type d 2>/dev/null` 120 | SEP="" 121 | for dir in $ROOTDIRSRAW ; do 122 | ROOTDIRS="$ROOTDIRS$SEP$dir" 123 | SEP="|" 124 | done 125 | OURCYGPATTERN="(^($ROOTDIRS))" 126 | # Add a user-defined pattern to the cygpath arguments 127 | if [ "$GRADLE_CYGPATTERN" != "" ] ; then 128 | OURCYGPATTERN="$OURCYGPATTERN|($GRADLE_CYGPATTERN)" 129 | fi 130 | # Now convert the arguments - kludge to limit ourselves to /bin/sh 131 | i=0 132 | for arg in "$@" ; do 133 | CHECK=`echo "$arg"|egrep -c "$OURCYGPATTERN" -` 134 | CHECK2=`echo "$arg"|egrep -c "^-"` ### Determine if an option 135 | 136 | if [ $CHECK -ne 0 ] && [ $CHECK2 -eq 0 ] ; then ### Added a condition 137 | eval `echo args$i`=`cygpath --path --ignore --mixed "$arg"` 138 | else 139 | eval `echo args$i`="\"$arg\"" 140 | fi 141 | i=$((i+1)) 142 | done 143 | case $i in 144 | (0) set -- ;; 145 | (1) set -- "$args0" ;; 146 | (2) set -- "$args0" "$args1" ;; 147 | (3) set -- "$args0" "$args1" "$args2" ;; 148 | (4) set -- "$args0" "$args1" "$args2" "$args3" ;; 149 | (5) set -- "$args0" "$args1" "$args2" "$args3" "$args4" ;; 150 | (6) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" ;; 151 | (7) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" ;; 152 | (8) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" "$args7" ;; 153 | (9) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" "$args7" "$args8" ;; 154 | esac 155 | fi 156 | 157 | # Escape application args 158 | save () { 159 | for i do printf %s\\n "$i" | sed "s/'/'\\\\''/g;1s/^/'/;\$s/\$/' \\\\/" ; done 160 | echo " " 161 | } 162 | APP_ARGS=$(save "$@") 163 | 164 | # Collect all arguments for the java command, following the shell quoting and substitution rules 165 | eval set -- $DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS "\"-Dorg.gradle.appname=$APP_BASE_NAME\"" -classpath "\"$CLASSPATH\"" org.gradle.wrapper.GradleWrapperMain "$APP_ARGS" 166 | 167 | # by default we should be in the correct project dir, but when run from Finder on Mac, the cwd is wrong 168 | if [ "$(uname)" = "Darwin" ] && [ "$HOME" = "$PWD" ]; then 169 | cd "$(dirname "$0")" 170 | fi 171 | 172 | exec "$JAVACMD" "$@" 173 | -------------------------------------------------------------------------------- /gradlew.bat: -------------------------------------------------------------------------------- 1 | @if "%DEBUG%" == "" @echo off 2 | @rem ########################################################################## 3 | @rem 4 | @rem Gradle startup script for Windows 5 | @rem 6 | @rem ########################################################################## 7 | 8 | @rem Set local scope for the variables with windows NT shell 9 | if "%OS%"=="Windows_NT" setlocal 10 | 11 | set DIRNAME=%~dp0 12 | if "%DIRNAME%" == "" set DIRNAME=. 13 | set APP_BASE_NAME=%~n0 14 | set APP_HOME=%DIRNAME% 15 | 16 | @rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. 17 | set DEFAULT_JVM_OPTS= 18 | 19 | @rem Find java.exe 20 | if defined JAVA_HOME goto findJavaFromJavaHome 21 | 22 | set JAVA_EXE=java.exe 23 | %JAVA_EXE% -version >NUL 2>&1 24 | if "%ERRORLEVEL%" == "0" goto init 25 | 26 | echo. 27 | echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 28 | echo. 29 | echo Please set the JAVA_HOME variable in your environment to match the 30 | echo location of your Java installation. 31 | 32 | goto fail 33 | 34 | :findJavaFromJavaHome 35 | set JAVA_HOME=%JAVA_HOME:"=% 36 | set JAVA_EXE=%JAVA_HOME%/bin/java.exe 37 | 38 | if exist "%JAVA_EXE%" goto init 39 | 40 | echo. 41 | echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 42 | echo. 43 | echo Please set the JAVA_HOME variable in your environment to match the 44 | echo location of your Java installation. 45 | 46 | goto fail 47 | 48 | :init 49 | @rem Get command-line arguments, handling Windows variants 50 | 51 | if not "%OS%" == "Windows_NT" goto win9xME_args 52 | 53 | :win9xME_args 54 | @rem Slurp the command line arguments. 55 | set CMD_LINE_ARGS= 56 | set _SKIP=2 57 | 58 | :win9xME_args_slurp 59 | if "x%~1" == "x" goto execute 60 | 61 | set CMD_LINE_ARGS=%* 62 | 63 | :execute 64 | @rem Setup the command line 65 | 66 | set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar 67 | 68 | @rem Execute Gradle 69 | "%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %CMD_LINE_ARGS% 70 | 71 | :end 72 | @rem End local scope for the variables with windows NT shell 73 | if "%ERRORLEVEL%"=="0" goto mainEnd 74 | 75 | :fail 76 | rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of 77 | rem the _cmd.exe /c_ return code! 78 | if not "" == "%GRADLE_EXIT_CONSOLE%" exit 1 79 | exit /b 1 80 | 81 | :mainEnd 82 | if "%OS%"=="Windows_NT" endlocal 83 | 84 | :omega 85 | -------------------------------------------------------------------------------- /release.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | VERSION=$(git describe --tags --always) 3 | rm -rf dist 4 | ./gradlew assembleRelease 5 | mkdir dist 6 | cd dist 7 | cp ../app/build/outputs/apk/release/app-release.apk sndcpy.apk 8 | cp ../sndcpy . 9 | cp ../sndcpy.bat . 10 | zip sndcpy-"$VERSION".zip sndcpy.apk sndcpy sndcpy.bat 11 | -------------------------------------------------------------------------------- /run: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | set -e 3 | ./gradlew assembleDebug 4 | SNDCPY_APK=app/build/outputs/apk/debug/app-debug.apk ./sndcpy "$@" 5 | -------------------------------------------------------------------------------- /settings.gradle: -------------------------------------------------------------------------------- 1 | rootProject.name='sndcpy' 2 | include ':app' 3 | -------------------------------------------------------------------------------- /sndcpy: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | set -e 3 | ADB=${ADB:-adb} 4 | VLC=${VLC:-vlc} 5 | SNDCPY_APK=${SNDCPY_APK:-sndcpy.apk} 6 | SNDCPY_PORT=${SNDCPY_PORT:-28200} 7 | 8 | serial= 9 | if [[ $# -ge 1 ]] 10 | then 11 | serial="-s $1" 12 | echo "Waiting for device $1..." 13 | else 14 | echo 'Waiting for device...' 15 | fi 16 | 17 | "$ADB" $serial wait-for-device 18 | "$ADB" $serial install -t -r -g "$SNDCPY_APK" || 19 | { 20 | echo 'Uninstalling existing version first...' 21 | "$ADB" $serial uninstall com.rom1v.sndcpy 22 | "$ADB" $serial install -t -g "$SNDCPY_APK" 23 | } 24 | 25 | "$ADB" $serial shell appops set com.rom1v.sndcpy PROJECT_MEDIA allow 26 | "$ADB" $serial forward tcp:$SNDCPY_PORT localabstract:sndcpy 27 | "$ADB" $serial shell am start com.rom1v.sndcpy/.MainActivity 28 | 29 | sleep 2 30 | 31 | echo Playing audio... 32 | "$VLC" -Idummy --demux rawaud --network-caching=0 --play-and-exit tcp://localhost:"$SNDCPY_PORT" 33 | -------------------------------------------------------------------------------- /sndcpy.bat: -------------------------------------------------------------------------------- 1 | @echo off 2 | if not defined ADB set ADB=adb 3 | if not defined VLC set VLC="C:\Program Files\VideoLAN\VLC\vlc.exe" 4 | if not defined SNDCPY_APK set SNDCPY_APK=sndcpy.apk 5 | if not defined SNDCPY_PORT set SNDCPY_PORT=28200 6 | 7 | if not "%1"=="" ( 8 | set serial=-s %1 9 | echo Waiting for device %1... 10 | ) else ( 11 | echo Waiting for device... 12 | ) 13 | 14 | %ADB% %serial% wait-for-device || goto :error 15 | %ADB% %serial% install -t -r -g %SNDCPY_APK% || ( 16 | echo Uninstalling existing version first... 17 | %ADB% %serial% uninstall com.rom1v.sndcpy || goto :error 18 | %ADB% %serial% install -t -g %SNDCPY_APK% || goto :error 19 | ) 20 | %ADB% %serial% shell appops set com.rom1v.sndcpy PROJECT_MEDIA allow 21 | %ADB% %serial% forward tcp:%SNDCPY_PORT% localabstract:sndcpy || goto :error 22 | %ADB% %serial% shell am start com.rom1v.sndcpy/.MainActivity || goto :error 23 | 24 | timeout 2 25 | 26 | echo Playing audio... 27 | %VLC% -Idummy --demux rawaud --network-caching=0 --play-and-exit tcp://localhost:%SNDCPY_PORT% 28 | goto :EOF 29 | 30 | :error 31 | echo Failed with error #%errorlevel%. 32 | pause 33 | exit /b %errorlevel% 34 | --------------------------------------------------------------------------------