├── .gitignore ├── .idea ├── SmartIM │ ├── debug │ │ └── smartqq_2018-03-28.log │ └── info │ │ └── smartqq_2018-03-28.log ├── caches │ └── build_file_checksums.ser ├── codeStyles │ └── Project.xml ├── encodings.xml ├── gradle.xml ├── misc.xml ├── modules.xml ├── runConfigurations.xml └── vcs.xml ├── README.md ├── app ├── .gitignore ├── build.gradle ├── proguard-rules.pro └── src │ ├── androidTest │ └── java │ │ └── com │ │ └── kookong │ │ └── kkcling │ │ └── ExampleInstrumentedTest.java │ ├── main │ ├── AndroidManifest.xml │ ├── java │ │ └── com │ │ │ └── kookong │ │ │ └── kkcling │ │ │ ├── DLNAManager.java │ │ │ ├── DLNAPlayer.java │ │ │ ├── MainActivity.java │ │ │ └── server │ │ │ ├── ContentNode.java │ │ │ ├── ContentTree.java │ │ │ ├── HttpServer.java │ │ │ ├── MediaServer.java │ │ │ └── UpnpUtil.java │ └── res │ │ ├── drawable-v24 │ │ └── ic_launcher_foreground.xml │ │ ├── drawable │ │ └── ic_launcher_background.xml │ │ ├── layout │ │ └── activity_main.xml │ │ ├── mipmap-anydpi-v26 │ │ ├── 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 │ │ ├── colors.xml │ │ ├── strings.xml │ │ └── styles.xml │ └── test │ └── java │ └── com │ └── kookong │ └── kkcling │ └── ExampleUnitTest.java ├── build.gradle ├── gradle.properties ├── gradle └── wrapper │ ├── gradle-wrapper.jar │ └── gradle-wrapper.properties ├── gradlew ├── gradlew.bat └── settings.gradle /.gitignore: -------------------------------------------------------------------------------- 1 | *.iml 2 | .gradle 3 | /local.properties 4 | /.idea/workspace.xml 5 | /.idea/libraries 6 | .DS_Store 7 | /build 8 | /captures 9 | .externalNativeBuild 10 | -------------------------------------------------------------------------------- /.idea/SmartIM/debug/smartqq_2018-03-28.log: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/zhangwenxue/Cling4Android/e4794d872ecdf2d51307dcefc32f4d34ea1d04bd/.idea/SmartIM/debug/smartqq_2018-03-28.log -------------------------------------------------------------------------------- /.idea/SmartIM/info/smartqq_2018-03-28.log: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/zhangwenxue/Cling4Android/e4794d872ecdf2d51307dcefc32f4d34ea1d04bd/.idea/SmartIM/info/smartqq_2018-03-28.log -------------------------------------------------------------------------------- /.idea/caches/build_file_checksums.ser: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/zhangwenxue/Cling4Android/e4794d872ecdf2d51307dcefc32f4d34ea1d04bd/.idea/caches/build_file_checksums.ser -------------------------------------------------------------------------------- /.idea/codeStyles/Project.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 15 | 16 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | -------------------------------------------------------------------------------- /.idea/encodings.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | -------------------------------------------------------------------------------- /.idea/gradle.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 17 | 18 | -------------------------------------------------------------------------------- /.idea/misc.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 16 | 26 | 27 | 28 | 29 | 30 | 31 | 33 | 34 | 36 | 37 | 38 | 39 | 40 | 1.8 41 | 42 | 47 | 48 | 49 | 50 | 51 | 52 | -------------------------------------------------------------------------------- /.idea/modules.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | -------------------------------------------------------------------------------- /.idea/runConfigurations.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 11 | 12 | -------------------------------------------------------------------------------- /.idea/vcs.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Cling4Android 2 | 利用Cling DLNA框架实现搜索设备、投放本地视频/网络视频的功能 3 | -------------------------------------------------------------------------------- /app/.gitignore: -------------------------------------------------------------------------------- 1 | /build 2 | -------------------------------------------------------------------------------- /app/build.gradle: -------------------------------------------------------------------------------- 1 | apply plugin: 'com.android.application' 2 | 3 | android { 4 | compileSdkVersion 26 5 | defaultConfig { 6 | applicationId "com.kookong.kkcling" 7 | minSdkVersion 17 8 | targetSdkVersion 26 9 | versionCode 1 10 | versionName "1.0" 11 | testInstrumentationRunner "android.support.test.runner.AndroidJUnitRunner" 12 | } 13 | buildTypes { 14 | release { 15 | minifyEnabled false 16 | proguardFiles getDefaultProguardFile('proguard-android.txt'), 'proguard-rules.pro' 17 | } 18 | } 19 | packagingOptions { 20 | exclude 'META-INF/LICENSE.txt' 21 | exclude 'META-INF/beans.xml' 22 | } 23 | } 24 | 25 | dependencies { 26 | implementation fileTree(dir: 'libs', include: ['*.jar']) 27 | implementation 'com.android.support.constraint:constraint-layout:1.0.2' 28 | testImplementation 'junit:junit:4.12' 29 | // Cling library 30 | compile 'org.fourthline.cling:cling-core:2.1.1' 31 | compile 'org.fourthline.cling:cling-support:2.1.1' 32 | // Jetty library 33 | compile 'org.eclipse.jetty:jetty-server:8.1.12.v20130726' 34 | compile 'org.eclipse.jetty:jetty-servlet:8.1.12.v20130726' 35 | compile 'org.eclipse.jetty:jetty-client:8.1.12.v20130726' 36 | androidTestImplementation 'com.android.support.test:runner:0.5' 37 | androidTestImplementation 'com.android.support.test.espresso:espresso-core:2.2.2' 38 | } 39 | -------------------------------------------------------------------------------- /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/androidTest/java/com/kookong/kkcling/ExampleInstrumentedTest.java: -------------------------------------------------------------------------------- 1 | package com.kookong.kkcling; 2 | 3 | import android.content.Context; 4 | import android.support.test.InstrumentationRegistry; 5 | import android.support.test.runner.AndroidJUnit4; 6 | 7 | import org.junit.Test; 8 | import org.junit.runner.RunWith; 9 | 10 | import static org.junit.Assert.*; 11 | 12 | /** 13 | * Instrumented test, which will execute on an Android device. 14 | * 15 | * @see Testing documentation 16 | */ 17 | @RunWith(AndroidJUnit4.class) 18 | public class ExampleInstrumentedTest { 19 | @Test 20 | public void useAppContext() throws Exception { 21 | // Context of the app under test. 22 | Context appContext = InstrumentationRegistry.getTargetContext(); 23 | 24 | assertEquals("com.kookong.kkcling", appContext.getPackageName()); 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /app/src/main/AndroidManifest.xml: -------------------------------------------------------------------------------- 1 | 2 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 20 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | -------------------------------------------------------------------------------- /app/src/main/java/com/kookong/kkcling/DLNAManager.java: -------------------------------------------------------------------------------- 1 | package com.kookong.kkcling; 2 | 3 | import android.content.ComponentName; 4 | import android.content.Context; 5 | import android.content.Intent; 6 | import android.content.ServiceConnection; 7 | import android.os.Handler; 8 | import android.os.IBinder; 9 | import android.os.Looper; 10 | import android.os.Message; 11 | import android.util.Log; 12 | 13 | import com.kookong.kkcling.server.MediaServer; 14 | 15 | import org.fourthline.cling.android.AndroidUpnpService; 16 | import org.fourthline.cling.android.AndroidUpnpServiceImpl; 17 | import org.fourthline.cling.controlpoint.ControlPoint; 18 | import org.fourthline.cling.model.ValidationException; 19 | import org.fourthline.cling.model.meta.Device; 20 | import org.fourthline.cling.model.meta.RemoteDevice; 21 | import org.fourthline.cling.model.types.DeviceType; 22 | import org.fourthline.cling.model.types.UDADeviceType; 23 | import org.fourthline.cling.registry.DefaultRegistryListener; 24 | import org.fourthline.cling.registry.Registry; 25 | 26 | import java.net.SocketException; 27 | import java.util.ArrayList; 28 | import java.util.List; 29 | 30 | public class DLNAManager { 31 | private static final String TAG = "ClingManager"; 32 | 33 | private static final int MSG_DISCOVER_START = 0; 34 | private static final int MSG_DEVICE_ADDED = 1; 35 | private static final int MSG_DEVICE_REMOVED = 2; 36 | private static final int MSG_SEARCH = 3; 37 | private static final DeviceType DEVICE_TYPE = new UDADeviceType("MediaRenderer"); 38 | private MediaServer mediaServer; 39 | 40 | public interface DeviceRefreshListener { 41 | void onDeviceRefresh(); 42 | } 43 | 44 | private volatile static DLNAManager instance; 45 | private AndroidUpnpService upnpService; 46 | private DeviceRefreshListener mDeviceDiscoveryListener; 47 | private final List mDeviceList = new ArrayList<>(); 48 | private volatile boolean searchCmd = false; 49 | 50 | private DLNAManager(Context context) { 51 | if (context == null) { 52 | throw new NullPointerException("context must not be null!"); 53 | } 54 | try { 55 | mediaServer = new MediaServer(); 56 | } catch (ValidationException e) { 57 | e.printStackTrace(); 58 | } catch (SocketException e) { 59 | e.printStackTrace(); 60 | } 61 | context.getApplicationContext().bindService( 62 | new Intent(context.getApplicationContext(), AndroidUpnpServiceImpl.class), 63 | mServiceConnection, 64 | Context.BIND_AUTO_CREATE); 65 | } 66 | 67 | private final Handler mHandler = new Handler(Looper.getMainLooper()) { 68 | @Override 69 | public void handleMessage(Message msg) { 70 | if (mDeviceDiscoveryListener == null) { 71 | return; 72 | } 73 | switch (msg.what) { 74 | case MSG_DISCOVER_START: 75 | mDeviceList.clear(); 76 | mDeviceDiscoveryListener.onDeviceRefresh(); 77 | break; 78 | case MSG_DEVICE_REMOVED: 79 | Device removedDevice = (Device) msg.obj; 80 | int removedPos = mDeviceList.indexOf(removedDevice); 81 | if (removedPos >= 0) { 82 | mDeviceList.remove(removedPos); 83 | mDeviceDiscoveryListener.onDeviceRefresh(); 84 | } 85 | break; 86 | case MSG_DEVICE_ADDED: 87 | Device addedDevice = (Device) msg.obj; 88 | int addedPos = mDeviceList.indexOf(addedDevice); 89 | if (addedPos < 0) { 90 | mDeviceList.add(addedDevice); 91 | mDeviceDiscoveryListener.onDeviceRefresh(); 92 | } 93 | break; 94 | case MSG_SEARCH: 95 | search(); 96 | break; 97 | default: 98 | break; 99 | } 100 | } 101 | }; 102 | 103 | 104 | private final ServiceConnection mServiceConnection = new ServiceConnection() { 105 | 106 | public void onServiceConnected(ComponentName className, IBinder service) { 107 | upnpService = (AndroidUpnpService) service; 108 | upnpService.getRegistry().addDevice(mediaServer.getDevice()); 109 | upnpService.getRegistry().addListener(mRegistryListener); 110 | if (searchCmd) { 111 | search(); 112 | } 113 | } 114 | 115 | @Override 116 | public void onServiceDisconnected(ComponentName name) { 117 | upnpService.getRegistry().shutdown(); 118 | upnpService = null; 119 | } 120 | 121 | @Override 122 | public void onBindingDied(ComponentName name) { 123 | upnpService.getRegistry().shutdown(); 124 | upnpService = null; 125 | } 126 | }; 127 | private final DefaultRegistryListener mRegistryListener = new DefaultRegistryListener() { 128 | 129 | @Override 130 | public void remoteDeviceDiscoveryFailed(Registry registry, RemoteDevice device, Exception ex) { 131 | Log.e(TAG, "remoteDeviceDiscoveryFailed," + ex.getMessage()); 132 | mHandler.obtainMessage(MSG_DEVICE_REMOVED, device).sendToTarget(); 133 | } 134 | 135 | @Override 136 | public void deviceAdded(Registry registry, Device device) { 137 | if (device == null) { 138 | Log.e(TAG, "deviceAdded, device is null"); 139 | return; 140 | } 141 | if (device.getType().equals(DEVICE_TYPE)) { 142 | Log.d(TAG, "deviceAdded," + device.getDetails().getFriendlyName()); 143 | mHandler.obtainMessage(MSG_DEVICE_ADDED, device).sendToTarget(); 144 | } 145 | } 146 | 147 | @Override 148 | public void deviceRemoved(Registry registry, Device device) { 149 | if (device == null) { 150 | Log.e(TAG, "deviceRemoved(),device device is null"); 151 | return; 152 | } 153 | if (device.getType().equals(DEVICE_TYPE)) { 154 | Log.d(TAG, "deviceRemoved," + device.getDetails().getFriendlyName()); 155 | mHandler.obtainMessage(MSG_DEVICE_REMOVED, device).sendToTarget(); 156 | } 157 | } 158 | }; 159 | 160 | public void setOnDeviceRefreshListener(DeviceRefreshListener listener) { 161 | this.mDeviceDiscoveryListener = listener; 162 | } 163 | 164 | public static DLNAManager getInstance(Context context) { 165 | if (instance == null) { 166 | synchronized (DLNAManager.class) { 167 | if (instance == null) { 168 | instance = new DLNAManager(context); 169 | } 170 | } 171 | } 172 | return instance; 173 | } 174 | 175 | 176 | public void search() { 177 | if (upnpService == null) { 178 | searchCmd = true; 179 | return; 180 | } 181 | searchCmd = false; 182 | upnpService.getRegistry().removeAllRemoteDevices(); 183 | upnpService.getRegistry().addDevice(mediaServer.getDevice()); 184 | upnpService.getControlPoint().search(); 185 | } 186 | 187 | public List getDeviceList() { 188 | return mDeviceList; 189 | } 190 | 191 | public ControlPoint getControlPoint() { 192 | if (upnpService == null) { 193 | return null; 194 | } 195 | return upnpService.getControlPoint(); 196 | } 197 | 198 | public void stop(Context context) { 199 | if (context == null) { 200 | throw new NullPointerException("context must not be null !"); 201 | } 202 | context.getApplicationContext().unbindService(mServiceConnection); 203 | if (mediaServer != null) { 204 | mediaServer.stop(); 205 | } 206 | mDeviceDiscoveryListener = null; 207 | } 208 | 209 | } 210 | -------------------------------------------------------------------------------- /app/src/main/java/com/kookong/kkcling/DLNAPlayer.java: -------------------------------------------------------------------------------- 1 | package com.kookong.kkcling; 2 | 3 | import android.os.Handler; 4 | import android.os.Looper; 5 | import android.os.Message; 6 | import android.text.TextUtils; 7 | import android.util.Log; 8 | 9 | import com.kookong.kkcling.server.MediaServer; 10 | 11 | import org.fourthline.cling.controlpoint.ControlPoint; 12 | import org.fourthline.cling.model.action.ActionInvocation; 13 | import org.fourthline.cling.model.message.UpnpResponse; 14 | import org.fourthline.cling.model.meta.Device; 15 | import org.fourthline.cling.model.meta.Service; 16 | import org.fourthline.cling.model.types.UDAServiceId; 17 | import org.fourthline.cling.model.types.UDAServiceType; 18 | import org.fourthline.cling.support.avtransport.callback.GetCurrentTransportActions; 19 | import org.fourthline.cling.support.avtransport.callback.GetMediaInfo; 20 | import org.fourthline.cling.support.avtransport.callback.GetPositionInfo; 21 | import org.fourthline.cling.support.avtransport.callback.GetTransportInfo; 22 | import org.fourthline.cling.support.avtransport.callback.Pause; 23 | import org.fourthline.cling.support.avtransport.callback.Play; 24 | import org.fourthline.cling.support.avtransport.callback.Seek; 25 | import org.fourthline.cling.support.avtransport.callback.SetAVTransportURI; 26 | import org.fourthline.cling.support.avtransport.callback.Stop; 27 | import org.fourthline.cling.support.igd.callback.GetStatusInfo; 28 | import org.fourthline.cling.support.model.Connection; 29 | import org.fourthline.cling.support.model.DIDLObject; 30 | import org.fourthline.cling.support.model.MediaInfo; 31 | import org.fourthline.cling.support.model.PositionInfo; 32 | import org.fourthline.cling.support.model.ProtocolInfo; 33 | import org.fourthline.cling.support.model.Res; 34 | import org.fourthline.cling.support.model.TransportAction; 35 | import org.fourthline.cling.support.model.TransportInfo; 36 | import org.fourthline.cling.support.model.TransportState; 37 | import org.fourthline.cling.support.model.TransportStatus; 38 | import org.fourthline.cling.support.model.item.VideoItem; 39 | import org.fourthline.cling.support.renderingcontrol.callback.GetMute; 40 | import org.fourthline.cling.support.renderingcontrol.callback.GetVolume; 41 | import org.fourthline.cling.support.renderingcontrol.callback.SetMute; 42 | import org.fourthline.cling.support.renderingcontrol.callback.SetVolume; 43 | import org.seamless.util.MimeType; 44 | 45 | import java.text.SimpleDateFormat; 46 | import java.util.Date; 47 | import java.util.Formatter; 48 | import java.util.Locale; 49 | 50 | public class DLNAPlayer { 51 | private static final String TAG = "ClingPlayer"; 52 | private static final String DIDL_LITE_FOOTER = ""; 53 | private static final String DIDL_LITE_HEADER = "" + ""; 56 | private static final int MSG_INVALID_URL = 0x01; 57 | private static final int MSG_GET_CONNECTION_FAILED = 0x02; 58 | private static final int MSG_GET_CONNECTION_SUCCESS = 0x03; 59 | private static final int MSG_AV_TRANSPORT_NOT_FOUND = 0x04; 60 | 61 | private static final int MSG_SET_URL_SUCCESS = 0x05; 62 | private static final int MSG_ON_PLAY = 0x06; 63 | private static final int MSG_ON_PAUSE = 0x07; 64 | private static final int MSG_ON_STOP = 0x08; 65 | private static final int MSG_SEEK_COMPLETE = 0x09; 66 | private static final int MSG_ON_GET_MEDIAINFO = 0x10; 67 | private static final int MSG_ON_GET_TRANSPORT_STATE = 0x11; 68 | 69 | private static final int MSG_VOLUME_CHANGED = 0x12; 70 | private static final int MSG_MUTE_STATUS_CHANGED = 0x13; 71 | private static final int MSG_TIMELINE_CHANGED = 0x14; 72 | 73 | private static final int MSG_SET_URI_FAILED = -1 * MSG_SET_URL_SUCCESS; 74 | private static final int MSG_PLAY_FAILED = -1 * MSG_ON_PLAY; 75 | private static final int MSG_PAUSE_FAILED = -1 * MSG_ON_PAUSE; 76 | private static final int MSG_STOP_FAILED = -1 * MSG_ON_STOP; 77 | private static final int MSG_SEEK_FAILED = -1 * MSG_SEEK_COMPLETE; 78 | private static final int MSG_SET_VOLUME_FAILED = -1 * MSG_VOLUME_CHANGED; 79 | private static final int MSG_SET_MUTE_FAILED = -0x12; 80 | private static final int MSG_GET_VOLUME_FAILED = -0x13; 81 | private static final int MSG_GET_MUTE_FAILED = -0x14; 82 | 83 | public interface EventListener { 84 | void onPlay(); 85 | 86 | void onGetMediaInfo(MediaInfo mediaInfo); 87 | 88 | void onPlayerError(); 89 | 90 | void onTimelineChanged(PositionInfo positionInfo); 91 | 92 | void onSeekCompleted(); 93 | 94 | void onPaused(); 95 | 96 | void onMuteStatusChanged(boolean isMute); 97 | 98 | void onVolumeChanged(int volume); 99 | 100 | void onStop(); 101 | } 102 | 103 | private EventListener mListener; 104 | private ControlPoint mControlPoint; 105 | private Device mDevice; 106 | private final Handler mHandler = new Handler(Looper.myLooper()) { 107 | @Override 108 | public void handleMessage(Message msg) { 109 | switch (msg.what) { 110 | case MSG_INVALID_URL: 111 | break; 112 | case MSG_GET_CONNECTION_FAILED: 113 | break; 114 | case MSG_GET_CONNECTION_SUCCESS: 115 | break; 116 | case MSG_AV_TRANSPORT_NOT_FOUND: 117 | break; 118 | case MSG_SET_URL_SUCCESS: 119 | break; 120 | case MSG_ON_PLAY: 121 | if (mListener != null) { 122 | mListener.onPlay(); 123 | } 124 | break; 125 | case MSG_ON_PAUSE: 126 | if (mListener != null) { 127 | mListener.onPaused(); 128 | } 129 | break; 130 | case MSG_ON_STOP: 131 | if (mListener != null) { 132 | mListener.onStop(); 133 | } 134 | break; 135 | case MSG_SEEK_COMPLETE: 136 | if (mListener != null) { 137 | mListener.onSeekCompleted(); 138 | } 139 | break; 140 | case MSG_ON_GET_MEDIAINFO: 141 | MediaInfo mediaInfo = (MediaInfo) msg.obj; 142 | if (mListener != null) { 143 | mListener.onGetMediaInfo(mediaInfo); 144 | } 145 | break; 146 | case MSG_VOLUME_CHANGED: 147 | if (mListener != null) { 148 | mListener.onVolumeChanged(msg.arg1); 149 | } 150 | break; 151 | case MSG_MUTE_STATUS_CHANGED: 152 | if (mListener != null) { 153 | mListener.onMuteStatusChanged((Boolean) msg.obj); 154 | } 155 | break; 156 | case MSG_TIMELINE_CHANGED: 157 | if (mListener != null) { 158 | mListener.onTimelineChanged((PositionInfo) msg.obj); 159 | } 160 | break; 161 | case MSG_SET_URI_FAILED: 162 | break; 163 | case MSG_PLAY_FAILED: 164 | case MSG_PAUSE_FAILED: 165 | case MSG_STOP_FAILED: 166 | if (mListener != null) { 167 | mListener.onPlayerError(); 168 | } 169 | break; 170 | case MSG_ON_GET_TRANSPORT_STATE: 171 | TransportInfo transportInfo = (TransportInfo) msg.obj; 172 | if (transportInfo != null) { 173 | TransportStatus status = transportInfo.getCurrentTransportStatus(); 174 | if (mListener != null && (status == TransportStatus.OK || status == TransportStatus.CUSTOM)) { 175 | TransportState state = transportInfo.getCurrentTransportState(); 176 | switch (state) { 177 | case STOPPED: 178 | case NO_MEDIA_PRESENT: 179 | mListener.onStop(); 180 | break; 181 | case PAUSED_PLAYBACK: 182 | mListener.onPaused(); 183 | break; 184 | case PLAYING: 185 | mListener.onPlay(); 186 | break; 187 | default: 188 | break; 189 | } 190 | } else { 191 | mListener.onPlayerError(); 192 | } 193 | } 194 | break; 195 | case MSG_SEEK_FAILED: 196 | break; 197 | case MSG_SET_VOLUME_FAILED: 198 | break; 199 | case MSG_GET_MUTE_FAILED: 200 | break; 201 | default: 202 | break; 203 | } 204 | } 205 | }; 206 | 207 | public void addListener(EventListener listener) { 208 | this.mListener = listener; 209 | } 210 | 211 | public void removeListener() { 212 | this.mListener = null; 213 | } 214 | 215 | public void setUp(Device device, ControlPoint controlPoint) { 216 | this.mControlPoint = controlPoint; 217 | this.mDevice = device; 218 | } 219 | 220 | public void play(String name, String url) { 221 | setUriAndPlay(name, url); 222 | } 223 | 224 | public void resume() { 225 | playInner(); 226 | } 227 | 228 | public void pause() { 229 | check(); 230 | Service service = mDevice.findService(new UDAServiceType("AVTransport")); 231 | if (service == null) { 232 | Log.w(TAG, "pause failed, AVTransport service is null."); 233 | mHandler.obtainMessage(MSG_PAUSE_FAILED).sendToTarget(); 234 | return; 235 | } 236 | mControlPoint.execute(new Pause(service) { 237 | @Override 238 | public void success(ActionInvocation invocation) { 239 | super.success(invocation); 240 | Log.d(TAG, "pause success"); 241 | mHandler.obtainMessage(MSG_ON_PAUSE).sendToTarget(); 242 | } 243 | 244 | @Override 245 | public void failure(ActionInvocation invocation, UpnpResponse operation, String defaultMsg) { 246 | Log.e(TAG, "pause failed," + defaultMsg); 247 | mHandler.obtainMessage(MSG_PAUSE_FAILED).sendToTarget(); 248 | } 249 | }); 250 | } 251 | 252 | public void getTransportInfo() { 253 | check(); 254 | Service service = mDevice.findService(new UDAServiceType("AVTransport")); 255 | if (service == null) { 256 | Log.w(TAG, "getTransportInfo failed, AVTransport service is null."); 257 | return; 258 | } 259 | mControlPoint.execute(new GetTransportInfo(service) { 260 | @Override 261 | public void success(ActionInvocation invocation) { 262 | super.success(invocation); 263 | Log.d(TAG, "getTransportInfo success"); 264 | } 265 | 266 | @Override 267 | public void received(ActionInvocation invocation, TransportInfo transportInfo) { 268 | String msg = "getTransportInfo received. curState:" + transportInfo.getCurrentTransportState() + ",curStatus:" + transportInfo.getCurrentTransportStatus() 269 | + ",speed:" + transportInfo.getCurrentSpeed(); 270 | mHandler.obtainMessage(MSG_ON_GET_TRANSPORT_STATE, transportInfo).sendToTarget(); 271 | Log.d(TAG, msg); 272 | } 273 | 274 | @Override 275 | public void failure(ActionInvocation invocation, UpnpResponse operation, String defaultMsg) { 276 | Log.e(TAG, "getTransportInfo failed," + defaultMsg); 277 | } 278 | }); 279 | } 280 | 281 | public void getCurrentTransportActions() { 282 | check(); 283 | Service service = mDevice.findService(new UDAServiceType("AVTransport")); 284 | if (service == null) { 285 | Log.w(TAG, "getCurrentTransportActions failed, AVTransport service is null."); 286 | return; 287 | } 288 | mControlPoint.execute(new GetCurrentTransportActions(service) { 289 | @Override 290 | public void success(ActionInvocation invocation) { 291 | Log.d(TAG, "getCurrentTransportActions success"); 292 | } 293 | 294 | @Override 295 | public void received(ActionInvocation actionInvocation, TransportAction[] actions) { 296 | StringBuilder sb = new StringBuilder(); 297 | if (actions != null && actions.length != 0) { 298 | sb.append("["); 299 | for (TransportAction action : actions) { 300 | sb.append(action); 301 | sb.append(","); 302 | } 303 | sb.setLength(sb.length() - 1); 304 | sb.append("]"); 305 | } 306 | Log.d(TAG, "getCurrentTransportActions received:" + sb.toString()); 307 | } 308 | 309 | @Override 310 | public void failure(ActionInvocation invocation, UpnpResponse operation, String defaultMsg) { 311 | Log.e(TAG, "getCurrentTransportActions failed," + defaultMsg); 312 | } 313 | }); 314 | } 315 | 316 | public void seekTo(final int timeSeconds) { 317 | if (timeSeconds < 0) { 318 | Log.w(TAG, "seek failed,invalid param timeSeconds:" + timeSeconds); 319 | return; 320 | } 321 | check(); 322 | Service service = mDevice.findService(new UDAServiceType("AVTransport")); 323 | if (service == null) { 324 | Log.w(TAG, "seekTo failed, AVTransport service is null."); 325 | mHandler.obtainMessage(MSG_SEEK_FAILED).sendToTarget(); 326 | return; 327 | } 328 | final String time = secondsToString(timeSeconds); 329 | mControlPoint.execute(new Seek(service, time) { 330 | @Override 331 | public void success(ActionInvocation invocation) { 332 | Log.d(TAG, "seekTo success"); 333 | mHandler.obtainMessage(MSG_SEEK_COMPLETE).sendToTarget(); 334 | } 335 | 336 | @Override 337 | public void failure(ActionInvocation invocation, UpnpResponse operation, String defaultMsg) { 338 | Log.e(TAG, "seek to " + time + " failed ," + defaultMsg); 339 | mHandler.obtainMessage(MSG_SEEK_FAILED).sendToTarget(); 340 | } 341 | }); 342 | } 343 | 344 | public void setVolume(final int volume) { 345 | check(); 346 | Service service = mDevice.findService(new UDAServiceType("RenderingControl")); 347 | if (service == null) { 348 | Log.w(TAG, "setVolume failed, RenderingControl service is null."); 349 | mHandler.obtainMessage(MSG_SET_VOLUME_FAILED).sendToTarget(); 350 | return; 351 | } 352 | mControlPoint.execute(new SetVolume(service, volume) { 353 | @Override 354 | public void success(ActionInvocation invocation) { 355 | Log.d(TAG, "setVolume success"); 356 | mHandler.obtainMessage(MSG_VOLUME_CHANGED, volume, 0).sendToTarget(); 357 | } 358 | 359 | @Override 360 | public void failure(ActionInvocation invocation, UpnpResponse operation, String defaultMsg) { 361 | Log.e(TAG, "setVolume failed," + defaultMsg); 362 | mHandler.obtainMessage(MSG_SET_VOLUME_FAILED).sendToTarget(); 363 | } 364 | }); 365 | } 366 | 367 | public void setMute(final boolean mute) { 368 | check(); 369 | Service service = mDevice.findService(new UDAServiceType("RenderingControl")); 370 | if (service == null) { 371 | Log.w(TAG, "setMute failed, RenderingControl service is null."); 372 | mHandler.obtainMessage(MSG_SET_MUTE_FAILED).sendToTarget(); 373 | return; 374 | } 375 | mControlPoint.execute(new SetMute(service, mute) { 376 | @Override 377 | public void success(ActionInvocation invocation) { 378 | Log.d(TAG, "setMute success"); 379 | mHandler.obtainMessage(MSG_MUTE_STATUS_CHANGED, mute).sendToTarget(); 380 | } 381 | 382 | @Override 383 | public void failure(ActionInvocation invocation, UpnpResponse operation, String defaultMsg) { 384 | Log.e(TAG, "setMute failed," + defaultMsg); 385 | mHandler.obtainMessage(MSG_SET_MUTE_FAILED).sendToTarget(); 386 | } 387 | }); 388 | } 389 | 390 | 391 | public void stop() { 392 | check(); 393 | Service service = mDevice.findService(new UDAServiceType("AVTransport")); 394 | if (service == null) { 395 | Log.w(TAG, "stop failed, AVTransport service is null."); 396 | mHandler.obtainMessage(MSG_STOP_FAILED).sendToTarget(); 397 | return; 398 | } 399 | mControlPoint.execute(new Stop(service) { 400 | @Override 401 | public void success(ActionInvocation invocation) { 402 | Log.d(TAG, "stop success."); 403 | mHandler.obtainMessage(MSG_ON_STOP).sendToTarget(); 404 | } 405 | 406 | @Override 407 | public void failure(ActionInvocation invocation, UpnpResponse operation, String defaultMsg) { 408 | Log.e(TAG, "stop failed," + defaultMsg); 409 | mHandler.obtainMessage(MSG_STOP_FAILED).sendToTarget(); 410 | } 411 | }); 412 | } 413 | 414 | public void getConnectionStatus() { 415 | check(); 416 | Service service = mDevice.findService(new UDAServiceId("WANIPConnection")); 417 | if (service == null) { 418 | Log.w(TAG, "getConnectionStatus failed, WANIPConnection service is null."); 419 | mHandler.obtainMessage(MSG_GET_CONNECTION_FAILED).sendToTarget(); 420 | return; 421 | } 422 | mControlPoint.execute( 423 | new GetStatusInfo(service) { 424 | @Override 425 | protected void success(Connection.StatusInfo statusInfo) { 426 | Log.d(TAG, "getConnectionStatus success,status:" + statusInfo.getStatus() + ",uptimeSeconds:" + 427 | statusInfo.getUptimeSeconds() + ",lastError:" + statusInfo.getLastError()); 428 | mHandler.obtainMessage(MSG_GET_CONNECTION_SUCCESS, statusInfo).sendToTarget(); 429 | } 430 | 431 | @Override 432 | public void failure(ActionInvocation invocation, 433 | UpnpResponse operation, 434 | String defaultMsg) { 435 | Log.e(TAG, "getConnectionStatus failed," + defaultMsg); 436 | mHandler.obtainMessage(MSG_GET_CONNECTION_FAILED).sendToTarget(); 437 | } 438 | } 439 | ); 440 | } 441 | 442 | 443 | public void getCurrentPosition() { 444 | check(); 445 | Service service = mDevice.findService(new UDAServiceType("AVTransport")); 446 | if (service == null) { 447 | Log.w(TAG, "getCurrentPosition failed, AVTransport service is null."); 448 | return; 449 | } 450 | mControlPoint.execute(new GetPositionInfo(service) { 451 | @Override 452 | public void success(ActionInvocation invocation) { 453 | super.success(invocation); 454 | Log.d(TAG, "getCurrentPosition success"); 455 | 456 | } 457 | 458 | @Override 459 | public void received(ActionInvocation invocation, PositionInfo positionInfo) { 460 | Log.i(TAG, "getCurrentPosition received," + positionInfo); 461 | mHandler.obtainMessage(MSG_TIMELINE_CHANGED, positionInfo).sendToTarget(); 462 | } 463 | 464 | @Override 465 | public void failure(ActionInvocation invocation, UpnpResponse operation, String defaultMsg) { 466 | Log.e(TAG, "getCurrentPosition failed," + defaultMsg); 467 | } 468 | }); 469 | } 470 | 471 | public void getVolume() { 472 | check(); 473 | Service service = mDevice.findService(new UDAServiceType("RenderingControl")); 474 | if (service == null) { 475 | Log.w(TAG, "getVolume failed, RenderingControl service is null."); 476 | mHandler.obtainMessage(MSG_GET_VOLUME_FAILED).sendToTarget(); 477 | return; 478 | } 479 | mControlPoint.execute(new GetVolume(service) { 480 | @Override 481 | public void received(ActionInvocation actionInvocation, int currentVolume) { 482 | Log.e(TAG, "getVolume success," + currentVolume); 483 | mHandler.obtainMessage(MSG_VOLUME_CHANGED, currentVolume, 0).sendToTarget(); 484 | } 485 | 486 | @Override 487 | public void failure(ActionInvocation invocation, UpnpResponse operation, String defaultMsg) { 488 | Log.e(TAG, "getVolume failed," + defaultMsg); 489 | mHandler.obtainMessage(MSG_GET_VOLUME_FAILED).sendToTarget(); 490 | } 491 | }); 492 | } 493 | 494 | public void getMute() { 495 | check(); 496 | Service service = mDevice.findService(new UDAServiceType("RenderingControl")); 497 | if (service == null) { 498 | Log.w(TAG, "getMute failed, RenderingControl service is null."); 499 | mHandler.obtainMessage(MSG_GET_MUTE_FAILED).sendToTarget(); 500 | return; 501 | } 502 | mControlPoint.execute(new GetMute(service) { 503 | @Override 504 | public void received(ActionInvocation actionInvocation, boolean currentMute) { 505 | Log.d(TAG, "getMute received,currentMute:" + currentMute); 506 | mHandler.obtainMessage(MSG_MUTE_STATUS_CHANGED, currentMute).sendToTarget(); 507 | } 508 | 509 | @Override 510 | public void failure(ActionInvocation invocation, UpnpResponse operation, String defaultMsg) { 511 | Log.e(TAG, "getMute failed," + defaultMsg); 512 | mHandler.obtainMessage(MSG_GET_MUTE_FAILED).sendToTarget(); 513 | } 514 | }); 515 | } 516 | 517 | public void getMediaInfo() { 518 | check(); 519 | Service service = mDevice.findService(new UDAServiceType("AVTransport")); 520 | if (service == null) { 521 | Log.w(TAG, "getMediaInfo failed, AVTransport service is null."); 522 | return; 523 | } 524 | mControlPoint.execute(new GetMediaInfo(service) { 525 | @Override 526 | public void success(ActionInvocation invocation) { 527 | super.success(invocation); 528 | Log.i(TAG, "getMediaInfo success"); 529 | } 530 | 531 | @Override 532 | public void received(ActionInvocation invocation, MediaInfo mediaInfo) { 533 | Log.i(TAG, "getMediaInfo received," + mediaInfo.getMediaDuration() + "," + mediaInfo.getCurrentURI() + "," + mediaInfo.getCurrentURIMetaData()); 534 | mHandler.obtainMessage(MSG_ON_GET_MEDIAINFO, mediaInfo).sendToTarget(); 535 | } 536 | 537 | @Override 538 | public void failure(ActionInvocation invocation, UpnpResponse operation, String defaultMsg) { 539 | Log.e(TAG, "getMediaInfo failed," + defaultMsg); 540 | } 541 | }); 542 | } 543 | 544 | private void setUriAndPlay(String name, String uri) { 545 | check(); 546 | if (TextUtils.isEmpty(uri)) { 547 | Log.e(TAG, "播放地址为空!"); 548 | mHandler.obtainMessage(MSG_INVALID_URL).sendToTarget(); 549 | return; 550 | } 551 | if (!(uri.startsWith("http") || uri.startsWith("rtsp"))) { 552 | uri = "http://" + MediaServer.IP_ADDRESS + ":" + MediaServer.PORT + "/" + uri; 553 | } 554 | Service service = mDevice.findService(new UDAServiceType("AVTransport")); 555 | if (service == null) { 556 | Log.w(TAG, "setAVTransportURI failed, AVTransport service is null."); 557 | mHandler.obtainMessage(MSG_SET_URI_FAILED).sendToTarget(); 558 | return; 559 | } 560 | String mediaData = pushMediaToRender(uri, "id", name, "0"); 561 | mControlPoint.execute(new SetAVTransportURI(service, uri, mediaData) { 562 | @Override 563 | public void success(ActionInvocation invocation) { 564 | Log.d(TAG, "setAVTransportURI success."); 565 | mHandler.obtainMessage(MSG_SET_URL_SUCCESS).sendToTarget(); 566 | playInner(); 567 | } 568 | 569 | @Override 570 | public void failure(ActionInvocation invocation, UpnpResponse operation, String defaultMsg) { 571 | Log.d(TAG, "setAVTransportURI failed," + defaultMsg); 572 | mHandler.obtainMessage(MSG_SET_URI_FAILED).sendToTarget(); 573 | } 574 | }); 575 | } 576 | 577 | private void playInner() { 578 | check(); 579 | Service service = mDevice.findService(new UDAServiceType("AVTransport")); 580 | if (service == null) { 581 | Log.w(TAG, "play failed, AVTransport service is null."); 582 | mHandler.obtainMessage(MSG_PLAY_FAILED).sendToTarget(); 583 | return; 584 | } 585 | mControlPoint.execute(new Play(service) { 586 | @Override 587 | public void failure(ActionInvocation invocation, UpnpResponse operation, String defaultMsg) { 588 | Log.e(TAG, "play failed," + defaultMsg); 589 | mHandler.obtainMessage(MSG_PLAY_FAILED).sendToTarget(); 590 | } 591 | 592 | @Override 593 | public void success(ActionInvocation invocation) { 594 | Log.e(TAG, "play success"); 595 | mHandler.obtainMessage(MSG_ON_PLAY).sendToTarget(); 596 | getMediaInfo(); 597 | } 598 | }); 599 | } 600 | 601 | private void check() { 602 | if (mControlPoint == null) { 603 | throw new NullPointerException("mControlPoint must not be null,you should invoke" + 604 | " setControlPoint(ControlPoint) method first."); 605 | } 606 | if (mDevice == null) { 607 | throw new NullPointerException("MediaRender device must not be null."); 608 | } 609 | } 610 | 611 | 612 | /** 613 | * 把时间戳转换成 00:00:00 格式 614 | * 615 | * @param secs 时间(s) 616 | * @return 00:00:00 时间格式 617 | */ 618 | public static String secondsToString(int secs) { 619 | StringBuilder formatBuilder = new StringBuilder(); 620 | Formatter formatter = new Formatter(formatBuilder, Locale.getDefault()); 621 | int seconds = secs % 60; 622 | int minutes = (secs / 60) % 60; 623 | int hours = secs / 3600; 624 | formatBuilder.setLength(0); 625 | return formatter.format("%02d:%02d:%02d", hours, minutes, seconds).toString(); 626 | } 627 | 628 | /** 629 | * 把 00:00:00 格式转成时间戳 630 | * 631 | * @param formatTime 00:00:00 时间格式 632 | * @return 时间戳(毫秒) 633 | */ 634 | public static int stringToSeconds(String formatTime) { 635 | if (TextUtils.isEmpty(formatTime)) { 636 | return 0; 637 | } 638 | 639 | String[] tmp = formatTime.split(":"); 640 | if (tmp.length < 3) { 641 | return 0; 642 | } 643 | int second = Integer.valueOf(tmp[0]) * 3600 + Integer.valueOf(tmp[1]) * 60 + Integer.valueOf(tmp[2]); 644 | return second * 1000; 645 | } 646 | 647 | private String pushMediaToRender(String url, String id, String name, String duration) { 648 | long size = 0; 649 | long bitrate = 0; 650 | Res res = new Res(new MimeType(ProtocolInfo.WILDCARD, ProtocolInfo.WILDCARD), size, url); 651 | 652 | String creator = "unknow"; 653 | String resolution = "unknow"; 654 | VideoItem videoItem = new VideoItem(id, "0", name, creator, res); 655 | 656 | String metadata = createItemMetadata(videoItem); 657 | Log.e(TAG, "metadata: " + metadata); 658 | return metadata; 659 | } 660 | 661 | private String createItemMetadata(DIDLObject item) { 662 | StringBuilder metadata = new StringBuilder(); 663 | metadata.append(DIDL_LITE_HEADER); 664 | 665 | metadata.append(String.format("", item.getId(), item.getParentID(), item.isRestricted() ? "1" : "0")); 666 | 667 | metadata.append(String.format("%s", item.getTitle())); 668 | String creator = item.getCreator(); 669 | if (creator != null) { 670 | creator = creator.replaceAll("<", "_"); 671 | creator = creator.replaceAll(">", "_"); 672 | } 673 | metadata.append(String.format("%s", creator)); 674 | 675 | metadata.append(String.format("%s", item.getClazz().getValue())); 676 | 677 | SimpleDateFormat sdf = new SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss"); 678 | Date now = new Date(); 679 | String time = sdf.format(now); 680 | metadata.append(String.format("%s", time)); 681 | 682 | // metadata.append(String.format("%s", 683 | // item.get); 684 | 685 | // http://192.168.1.104:8088/Music/07.我醒著做夢.mp3 687 | 688 | Res res = item.getFirstResource(); 689 | if (res != null) { 690 | // protocol info 691 | String protocolInfo = ""; 692 | ProtocolInfo pi = res.getProtocolInfo(); 693 | if (pi != null) { 694 | protocolInfo = String.format("protocolInfo=\"%s:%s:%s:%s\"", pi.getProtocol(), pi.getNetwork(), pi.getContentFormatMimeType(), pi 695 | .getAdditionalInfo()); 696 | } 697 | Log.e(TAG, "protocolInfo: " + protocolInfo); 698 | 699 | // resolution, extra info, not adding yet 700 | String resolution = ""; 701 | if (res.getResolution() != null && res.getResolution().length() > 0) { 702 | resolution = String.format("resolution=\"%s\"", res.getResolution()); 703 | } 704 | 705 | // duration 706 | String duration = ""; 707 | if (res.getDuration() != null && res.getDuration().length() > 0) { 708 | duration = String.format("duration=\"%s\"", res.getDuration()); 709 | } 710 | 711 | // res begin 712 | // metadata.append(String.format("", protocolInfo)); // no resolution & duration yet 713 | metadata.append(String.format("", protocolInfo, resolution, duration)); 714 | 715 | // url 716 | String url = res.getValue(); 717 | metadata.append(url); 718 | 719 | // res end 720 | metadata.append(""); 721 | } 722 | metadata.append(""); 723 | 724 | metadata.append(DIDL_LITE_FOOTER); 725 | 726 | return metadata.toString(); 727 | } 728 | } 729 | -------------------------------------------------------------------------------- /app/src/main/java/com/kookong/kkcling/MainActivity.java: -------------------------------------------------------------------------------- 1 | package com.kookong.kkcling; 2 | 3 | import android.app.ListActivity; 4 | import android.content.Context; 5 | import android.os.Bundle; 6 | import android.util.Log; 7 | import android.view.LayoutInflater; 8 | import android.view.View; 9 | import android.view.ViewGroup; 10 | import android.widget.AdapterView; 11 | import android.widget.ArrayAdapter; 12 | import android.widget.BaseAdapter; 13 | import android.widget.TextView; 14 | 15 | import org.fourthline.cling.controlpoint.ControlPoint; 16 | import org.fourthline.cling.model.meta.Device; 17 | import org.fourthline.cling.support.model.MediaInfo; 18 | import org.fourthline.cling.support.model.PositionInfo; 19 | 20 | import java.util.List; 21 | 22 | public class MainActivity extends ListActivity { 23 | BaseAdapter mAdapter; 24 | private DLNAPlayer mPlayer; 25 | private Device mDevice; 26 | private ControlPoint mControlPoint; 27 | 28 | @Override 29 | protected void onCreate(Bundle savedInstanceState) { 30 | super.onCreate(savedInstanceState); 31 | setContentView(R.layout.activity_main); 32 | mAdapter = new CustomAdapter(this, android.R.layout.simple_list_item_single_choice, DLNAManager.getInstance(this).getDeviceList()); 33 | setListAdapter(mAdapter); 34 | mPlayer = new DLNAPlayer(); 35 | DLNAManager.getInstance(this).setOnDeviceRefreshListener(new DLNAManager.DeviceRefreshListener() { 36 | @Override 37 | public void onDeviceRefresh() { 38 | if (mControlPoint == null) { 39 | mControlPoint = DLNAManager.getInstance(MainActivity.this).getControlPoint(); 40 | } 41 | mAdapter.notifyDataSetChanged(); 42 | } 43 | }); 44 | getListView().setOnItemClickListener(new AdapterView.OnItemClickListener() { 45 | @Override 46 | public void onItemClick(AdapterView parent, View view, int position, long id) { 47 | mDevice = (Device) mAdapter.getItem(position); 48 | mPlayer.setUp(mDevice, mControlPoint); 49 | } 50 | }); 51 | mPlayer.addListener(new DLNAPlayer.EventListener() { 52 | @Override 53 | public void onPlay() { 54 | log("<-onPlay->"); 55 | } 56 | 57 | @Override 58 | public void onGetMediaInfo(MediaInfo mediaInfo) { 59 | log("onGetMediaInfo:" + mediaInfo.getMediaDuration() + "," + mediaInfo.getPlayMedium() + "," + mediaInfo.getRecordMedium() + "," + mediaInfo.getCurrentURI()); 60 | } 61 | 62 | @Override 63 | public void onPlayerError() { 64 | log("onPlayError"); 65 | } 66 | 67 | @Override 68 | public void onTimelineChanged(PositionInfo positionInfo) { 69 | log("onTimelineChanged:" + positionInfo.getTrackDuration() + "," + positionInfo.getAbsTime() + "," + positionInfo.getRelTime()); 70 | } 71 | 72 | @Override 73 | public void onSeekCompleted() { 74 | log("onSeekCompleted"); 75 | } 76 | 77 | @Override 78 | public void onPaused() { 79 | log("onPaused"); 80 | } 81 | 82 | @Override 83 | public void onMuteStatusChanged(boolean isMute) { 84 | log("onMuteStatusChanged:" + isMute); 85 | } 86 | 87 | @Override 88 | public void onVolumeChanged(int volume) { 89 | log("onVolumeChanged:" + volume); 90 | } 91 | 92 | @Override 93 | public void onStop() { 94 | log("onStop"); 95 | } 96 | }); 97 | } 98 | 99 | public void onClick(View view) { 100 | switch (view.getId()) { 101 | case R.id.search: 102 | DLNAManager.getInstance(this).search(); 103 | break; 104 | case R.id.play: 105 | String url = "/storage/emulated/0/test.mp4"; 106 | mPlayer.play("香港卫视", url); 107 | break; 108 | case R.id.pause: 109 | mPlayer.pause(); 110 | break; 111 | case R.id.stop: 112 | mPlayer.stop(); 113 | break; 114 | case R.id.get_connection: 115 | mPlayer.getConnectionStatus(); 116 | break; 117 | case R.id.get_media_info: 118 | mPlayer.getMediaInfo(); 119 | break; 120 | case R.id.get_cur_pos: 121 | mPlayer.getCurrentPosition(); 122 | break; 123 | case R.id.get_transport_info: 124 | mPlayer.getTransportInfo(); 125 | break; 126 | case R.id.get_transport_actions: 127 | mPlayer.getCurrentTransportActions(); 128 | break; 129 | case R.id.get_volume: 130 | mPlayer.getVolume(); 131 | break; 132 | case R.id.set_volume: 133 | mPlayer.setVolume(30); 134 | break; 135 | case R.id.get_mute: 136 | mPlayer.getMute(); 137 | break; 138 | case R.id.set_mute: 139 | mPlayer.setMute(true); 140 | break; 141 | default: 142 | break; 143 | } 144 | 145 | } 146 | 147 | class CustomAdapter extends ArrayAdapter { 148 | 149 | 150 | public CustomAdapter(Context context, int resource, List objects) { 151 | super(context, resource, objects); 152 | } 153 | 154 | @Override 155 | public View getView(int position, View convertView, ViewGroup parent) { 156 | ViewHolder holder; 157 | if (convertView == null) { 158 | holder = new ViewHolder(); 159 | convertView = LayoutInflater.from(getContext()).inflate(android.R.layout.simple_list_item_single_choice, parent, false); 160 | holder.textView = convertView.findViewById(android.R.id.text1); 161 | convertView.setTag(holder); 162 | } else { 163 | holder = (ViewHolder) convertView.getTag(); 164 | } 165 | Device device = getItem(position); 166 | holder.textView.setText(device.getDetails().getFriendlyName()); 167 | return convertView; 168 | } 169 | 170 | class ViewHolder { 171 | TextView textView; 172 | } 173 | } 174 | 175 | private void log(String msg) { 176 | Log.i("==MainActivity==", msg); 177 | } 178 | 179 | } 180 | -------------------------------------------------------------------------------- /app/src/main/java/com/kookong/kkcling/server/ContentNode.java: -------------------------------------------------------------------------------- 1 | package com.kookong.kkcling.server; 2 | 3 | import org.fourthline.cling.support.model.container.Container; 4 | import org.fourthline.cling.support.model.item.Item; 5 | 6 | public class ContentNode { 7 | private Container container; 8 | private Item item; 9 | private String id; 10 | private String fullPath; 11 | private boolean isItem; 12 | 13 | public ContentNode(String id, Container container) { 14 | this.id = id; 15 | this.container = container; 16 | this.fullPath = null; 17 | this.isItem = false; 18 | } 19 | 20 | public ContentNode(String id, Item item, String fullPath) { 21 | this.id = id; 22 | this.item = item; 23 | this.fullPath = fullPath; 24 | this.isItem = true; 25 | } 26 | 27 | public String getId() { 28 | return id; 29 | } 30 | 31 | public Container getContainer() { 32 | return container; 33 | } 34 | 35 | public Item getItem() { 36 | return item; 37 | } 38 | 39 | public String getFullPath() { 40 | if (isItem && fullPath != null) { 41 | return fullPath; 42 | } 43 | return null; 44 | } 45 | 46 | public boolean isItem() { 47 | return isItem; 48 | } 49 | } 50 | -------------------------------------------------------------------------------- /app/src/main/java/com/kookong/kkcling/server/ContentTree.java: -------------------------------------------------------------------------------- 1 | package com.kookong.kkcling.server; 2 | 3 | import org.fourthline.cling.support.model.WriteStatus; 4 | import org.fourthline.cling.support.model.container.Container; 5 | 6 | import java.util.HashMap; 7 | 8 | 9 | public class ContentTree { 10 | 11 | public final static String ROOT_ID = "0"; 12 | public final static String VIDEO_ID = "1"; 13 | public final static String AUDIO_ID = "2"; 14 | public final static String IMAGE_ID = "3"; 15 | public final static String IMAGE_FOLD_ID = "4"; 16 | public final static String VIDEO_PREFIX = "video-item-"; 17 | public final static String AUDIO_PREFIX = "audio-item-"; 18 | public final static String IMAGE_PREFIX = "image-item-"; 19 | 20 | private static HashMap contentMap = new HashMap(); 21 | 22 | private static ContentNode rootNode = createRootNode(); 23 | 24 | public ContentTree() {}; 25 | 26 | protected static ContentNode createRootNode() { 27 | // create root container 28 | Container root = new Container(); 29 | root.setId(ROOT_ID); 30 | root.setParentID("-1"); 31 | root.setTitle("GNaP MediaServer root directory"); 32 | root.setCreator("GNaP Media Server"); 33 | root.setRestricted(true); 34 | root.setSearchable(true); 35 | root.setWriteStatus(WriteStatus.NOT_WRITABLE); 36 | root.setChildCount(0); 37 | ContentNode rootNode = new ContentNode(ROOT_ID, root); 38 | contentMap.put(ROOT_ID, rootNode); 39 | return rootNode; 40 | } 41 | 42 | public static ContentNode getRootNode() { 43 | return rootNode; 44 | } 45 | 46 | public static ContentNode getNode(String id) { 47 | if( contentMap.containsKey(id)) { 48 | return contentMap.get(id); 49 | } 50 | return null; 51 | } 52 | 53 | public static boolean hasNode(String id) { 54 | return contentMap.containsKey(id); 55 | } 56 | 57 | public static void addNode(String ID, ContentNode Node) { 58 | contentMap.put(ID, Node); 59 | } 60 | } 61 | -------------------------------------------------------------------------------- /app/src/main/java/com/kookong/kkcling/server/HttpServer.java: -------------------------------------------------------------------------------- 1 | package com.kookong.kkcling.server; 2 | 3 | import java.io.BufferedReader; 4 | import java.io.ByteArrayInputStream; 5 | import java.io.ByteArrayOutputStream; 6 | import java.io.File; 7 | import java.io.FileInputStream; 8 | import java.io.FileOutputStream; 9 | import java.io.IOException; 10 | import java.io.InputStream; 11 | import java.io.InputStreamReader; 12 | import java.io.OutputStream; 13 | import java.io.PrintWriter; 14 | import java.net.ServerSocket; 15 | import java.net.Socket; 16 | import java.net.URLDecoder; 17 | import java.net.URLEncoder; 18 | import java.util.Date; 19 | import java.util.Enumeration; 20 | import java.util.Hashtable; 21 | import java.util.Locale; 22 | import java.util.Properties; 23 | import java.util.StringTokenizer; 24 | import java.util.TimeZone; 25 | import java.util.Vector; 26 | 27 | /** 28 | * A simple, tiny, nicely embeddable HTTP 1.0 server in Java 29 | * Modified from NanoHTTPD, you can find it here 30 | * http://elonen.iki.fi/code/nanohttpd/ 31 | */ 32 | public class HttpServer { 33 | // ================================================== 34 | // API parts 35 | // ================================================== 36 | 37 | /** 38 | * Override this to customize the server.

39 | *

40 | * (By default, this delegates to serveFile() and allows directory listing.) 41 | * 42 | * @param uri Percent-decoded URI without parameters, for example "/index.cgi" 43 | * @param method "GET", "POST" etc. 44 | * @param parms Parsed, percent decoded parameters from URI and, in case of POST, data. 45 | * @param header Header entries, percent decoded 46 | * @return HTTP response, see class Response for details 47 | */ 48 | public Response serve(String uri, String method, Properties header, Properties parms, Properties files) { 49 | System.out.println(method + " '" + uri + "' "); 50 | 51 | Enumeration e = header.propertyNames(); 52 | while (e.hasMoreElements()) { 53 | String value = (String) e.nextElement(); 54 | System.out.println(" HDR: '" + value + "' = '" + 55 | header.getProperty(value) + "'"); 56 | } 57 | e = parms.propertyNames(); 58 | while (e.hasMoreElements()) { 59 | String value = (String) e.nextElement(); 60 | System.out.println(" PRM: '" + value + "' = '" + 61 | parms.getProperty(value) + "'"); 62 | } 63 | e = files.propertyNames(); 64 | while (e.hasMoreElements()) { 65 | String value = (String) e.nextElement(); 66 | System.out.println(" UPLOADED: '" + value + "' = '" + 67 | files.getProperty(value) + "'"); 68 | } 69 | 70 | //Map uri to acture file in ContentTree 71 | 72 | String itemId = uri.replaceFirst("/", ""); 73 | itemId = URLDecoder.decode(itemId); 74 | String newUri = null; 75 | 76 | if (ContentTree.hasNode(itemId)) { 77 | ContentNode node = ContentTree.getNode(itemId); 78 | if (node.isItem()) { 79 | newUri = node.getFullPath(); 80 | } 81 | } 82 | 83 | if (newUri != null) uri = newUri; 84 | return serveFile(uri, header, myRootDir, false); 85 | } 86 | 87 | /** 88 | * HTTP response. 89 | * Return one of these from serve(). 90 | */ 91 | public class Response { 92 | /** 93 | * Default constructor: response = HTTP_OK, data = mime = 'null' 94 | */ 95 | public Response() { 96 | this.status = HTTP_OK; 97 | } 98 | 99 | /** 100 | * Basic constructor. 101 | */ 102 | public Response(String status, String mimeType, InputStream data) { 103 | this.status = status; 104 | this.mimeType = mimeType; 105 | this.data = data; 106 | } 107 | 108 | /** 109 | * Convenience method that makes an InputStream out of 110 | * given text. 111 | */ 112 | public Response(String status, String mimeType, String txt) { 113 | this.status = status; 114 | this.mimeType = mimeType; 115 | try { 116 | this.data = new ByteArrayInputStream(txt.getBytes("UTF-8")); 117 | } catch (java.io.UnsupportedEncodingException uee) { 118 | uee.printStackTrace(); 119 | } 120 | } 121 | 122 | /** 123 | * Adds given line to the header. 124 | */ 125 | public void addHeader(String name, String value) { 126 | header.put(name, value); 127 | } 128 | 129 | /** 130 | * HTTP status code after processing, e.g. "200 OK", HTTP_OK 131 | */ 132 | public String status; 133 | 134 | /** 135 | * MIME type of content, e.g. "text/html" 136 | */ 137 | public String mimeType; 138 | 139 | /** 140 | * Data of the response, may be null. 141 | */ 142 | public InputStream data; 143 | 144 | /** 145 | * Headers for the HTTP response. Use addHeader() 146 | * to add lines. 147 | */ 148 | public Properties header = new Properties(); 149 | } 150 | 151 | /** 152 | * Some HTTP response status codes 153 | */ 154 | public static final String 155 | HTTP_OK = "200 OK", 156 | HTTP_PARTIALCONTENT = "206 Partial Content", 157 | HTTP_RANGE_NOT_SATISFIABLE = "416 Requested Range Not Satisfiable", 158 | HTTP_REDIRECT = "301 Moved Permanently", 159 | HTTP_FORBIDDEN = "403 Forbidden", 160 | HTTP_NOTFOUND = "404 Not Found", 161 | HTTP_BADREQUEST = "400 Bad Request", 162 | HTTP_INTERNALERROR = "500 Internal Server Error", 163 | HTTP_NOTIMPLEMENTED = "501 Not Implemented"; 164 | 165 | /** 166 | * Common mime types for dynamic content 167 | */ 168 | public static final String 169 | MIME_PLAINTEXT = "text/plain", 170 | MIME_HTML = "text/html", 171 | MIME_DEFAULT_BINARY = "application/octet-stream", 172 | MIME_XML = "text/xml"; 173 | 174 | // ================================================== 175 | // Socket & server code 176 | // ================================================== 177 | 178 | /** 179 | * Starts a HTTP server to given port.

180 | * Throws an IOException if the socket is already in use 181 | */ 182 | public HttpServer(int port) throws IOException { 183 | myTcpPort = port; 184 | this.myRootDir = new File("/"); 185 | myServerSocket = new ServerSocket(myTcpPort); 186 | myThread = new Thread(new Runnable() { 187 | public void run() { 188 | try { 189 | while (true) 190 | new HTTPSession(myServerSocket.accept()); 191 | } catch (IOException e) { 192 | e.printStackTrace(); 193 | } 194 | } 195 | }); 196 | myThread.setDaemon(true); 197 | myThread.start(); 198 | } 199 | 200 | /** 201 | * Stops the server. 202 | */ 203 | public void stop() { 204 | try { 205 | myServerSocket.close(); 206 | myThread.join(); 207 | } catch (IOException e) { 208 | e.printStackTrace(); 209 | } catch (InterruptedException e) { 210 | e.printStackTrace(); 211 | } 212 | } 213 | 214 | /** 215 | * Handles one session, i.e. parses the HTTP request 216 | * and returns the response. 217 | */ 218 | private class HTTPSession implements Runnable { 219 | public HTTPSession(Socket s) { 220 | mySocket = s; 221 | Thread t = new Thread(this); 222 | t.setDaemon(true); 223 | t.start(); 224 | } 225 | 226 | public void run() { 227 | try { 228 | InputStream is = mySocket.getInputStream(); 229 | if (is == null) return; 230 | 231 | // Read the first 8192 bytes. 232 | // The full header should fit in here. 233 | // Apache's default header limit is 8KB. 234 | int bufferSize = 8192; 235 | byte[] buf = new byte[bufferSize]; 236 | int length = is.read(buf, 0, bufferSize); 237 | if (length <= 0) return; 238 | 239 | // Create a BufferedReader for parsing the header. 240 | ByteArrayInputStream byteArrayInputStream = new ByteArrayInputStream(buf, 0, length); 241 | BufferedReader hin = new BufferedReader(new InputStreamReader(byteArrayInputStream)); 242 | Properties pre = new Properties(); 243 | Properties properties = new Properties(); 244 | Properties header = new Properties(); 245 | Properties files = new Properties(); 246 | 247 | // Decode the header into parms and header java properties 248 | decodeHeader(hin, pre, properties, header); 249 | String method = pre.getProperty("method"); 250 | String uri = pre.getProperty("uri"); 251 | 252 | long size = 0x7FFFFFFFFFFFFFFFL; 253 | String contentLength = header.getProperty("content-length"); 254 | if (contentLength != null) { 255 | try { 256 | size = Integer.parseInt(contentLength); 257 | } catch (NumberFormatException ex) { 258 | ex.printStackTrace(); 259 | } 260 | } 261 | 262 | // We are looking for the byte separating header from body. 263 | // It must be the last byte of the first two sequential new lines. 264 | int split = 0; 265 | boolean found = false; 266 | while (split < length) { 267 | if (buf[split] == '\r' && buf[++split] == '\n' && buf[++split] == '\r' && buf[++split] == '\n') { 268 | found = true; 269 | break; 270 | } 271 | split++; 272 | } 273 | split++; 274 | 275 | // Write the part of body already read to ByteArrayOutputStream f 276 | ByteArrayOutputStream f = new ByteArrayOutputStream(); 277 | if (split < length) f.write(buf, split, length - split); 278 | 279 | // While Firefox sends on the first read all the data fitting 280 | // our buffer, Chrome and Opera sends only the headers even if 281 | // there is data for the body. So we do some magic here to find 282 | // out whether we have already consumed part of body, if we 283 | // have reached the end of the data to be sent or we should 284 | // expect the first byte of the body at the next read. 285 | if (split < length) 286 | size -= length - split + 1; 287 | else if (!found || size == 0x7FFFFFFFFFFFFFFFL) 288 | size = 0; 289 | 290 | // Now read all the body and write it to f 291 | buf = new byte[512]; 292 | while (length >= 0 && size > 0) { 293 | length = is.read(buf, 0, 512); 294 | size -= length; 295 | if (length > 0) 296 | f.write(buf, 0, length); 297 | } 298 | 299 | // Get the raw body as a byte [] 300 | byte[] rawBuff = f.toByteArray(); 301 | 302 | // Create a BufferedReader for easily reading it as string. 303 | ByteArrayInputStream bin = new ByteArrayInputStream(rawBuff); 304 | BufferedReader in = new BufferedReader(new InputStreamReader(bin)); 305 | 306 | // If the method is POST, there may be parameters 307 | // in data section, too, read it: 308 | if (method.equalsIgnoreCase("POST")) { 309 | String contentType = ""; 310 | String contentTypeHeader = header.getProperty("content-type"); 311 | StringTokenizer st = new StringTokenizer(contentTypeHeader, "; "); 312 | if (st.hasMoreTokens()) { 313 | contentType = st.nextToken(); 314 | } 315 | 316 | if (contentType.equalsIgnoreCase("multipart/form-data")) { 317 | // Handle multipart/form-data 318 | if (!st.hasMoreTokens()) 319 | sendError(HTTP_BADREQUEST, "BAD REQUEST: Content type is multipart/form-data but boundary missing. Usage: GET /example/file.html"); 320 | String boundaryExp = st.nextToken(); 321 | st = new StringTokenizer(boundaryExp, "="); 322 | if (st.countTokens() != 2) 323 | sendError(HTTP_BADREQUEST, "BAD REQUEST: Content type is multipart/form-data but boundary syntax error. Usage: GET /example/file.html"); 324 | st.nextToken(); 325 | String boundary = st.nextToken(); 326 | 327 | decodeMultipartData(boundary, rawBuff, in, properties, files); 328 | } else { 329 | // Handle application/x-www-form-urlencoded 330 | String postLine = ""; 331 | char buff[] = new char[512]; 332 | int read = in.read(buff); 333 | while (read >= 0 && !postLine.endsWith("\r\n")) { 334 | postLine += String.valueOf(buff, 0, read); 335 | read = in.read(buff); 336 | } 337 | postLine = postLine.trim(); 338 | decodeParams(postLine, properties); 339 | } 340 | } 341 | 342 | // Ok, now do the serve() 343 | Response r = serve(uri, method, header, properties, files); 344 | if (r == null) 345 | sendError(HTTP_INTERNALERROR, "SERVER INTERNAL ERROR: Serve() returned a null response."); 346 | else 347 | sendResponse(r.status, r.mimeType, r.header, r.data); 348 | 349 | in.close(); 350 | is.close(); 351 | } catch (IOException ioe) { 352 | try { 353 | sendError(HTTP_INTERNALERROR, "SERVER INTERNAL ERROR: IOException: " + ioe.getMessage()); 354 | } catch (Throwable t) { 355 | t.printStackTrace(); 356 | } 357 | } catch (InterruptedException ie) { 358 | // Thrown by sendError, ignore and exit the thread. 359 | ie.printStackTrace(); 360 | } 361 | } 362 | 363 | /** 364 | * Decodes the sent headers and loads the data into 365 | * java Properties' key - value pairs 366 | **/ 367 | private void decodeHeader(BufferedReader in, Properties pre, Properties parms, Properties header) 368 | throws InterruptedException { 369 | try { 370 | // Read the request line 371 | String inLine = in.readLine(); 372 | if (inLine == null) return; 373 | StringTokenizer st = new StringTokenizer(inLine); 374 | if (!st.hasMoreTokens()) 375 | sendError(HTTP_BADREQUEST, "BAD REQUEST: Syntax error. Usage: GET /example/file.html"); 376 | 377 | String method = st.nextToken(); 378 | pre.put("method", method); 379 | 380 | if (!st.hasMoreTokens()) 381 | sendError(HTTP_BADREQUEST, "BAD REQUEST: Missing URI. Usage: GET /example/file.html"); 382 | 383 | String uri = st.nextToken(); 384 | 385 | // Decode parameters from the URI 386 | int qmi = uri.indexOf('?'); 387 | if (qmi >= 0) { 388 | decodeParams(uri.substring(qmi + 1), parms); 389 | uri = decodePercent(uri.substring(0, qmi)); 390 | } else uri = decodePercent(uri); 391 | 392 | // If there's another token, it's protocol version, 393 | // followed by HTTP headers. Ignore version but parse headers. 394 | // NOTE: this now forces header names lowercase since they are 395 | // case insensitive and vary by client. 396 | if (st.hasMoreTokens()) { 397 | String line = in.readLine(); 398 | while (line != null && line.trim().length() > 0) { 399 | int p = line.indexOf(':'); 400 | if (p >= 0) 401 | header.put(line.substring(0, p).trim().toLowerCase(), line.substring(p + 1).trim()); 402 | line = in.readLine(); 403 | } 404 | } 405 | 406 | pre.put("uri", uri); 407 | } catch (IOException ioe) { 408 | sendError(HTTP_INTERNALERROR, "SERVER INTERNAL ERROR: IOException: " + ioe.getMessage()); 409 | } 410 | } 411 | 412 | /** 413 | * Decodes the Multipart Body data and put it 414 | * into java Properties' key - value pairs. 415 | **/ 416 | private void decodeMultipartData(String boundary, byte[] fbuf, BufferedReader in, Properties parms, Properties files) 417 | throws InterruptedException { 418 | try { 419 | int[] boundaryPositions = getBoundaryPositions(fbuf, boundary.getBytes()); 420 | int boundaryCount = 1; 421 | String line = in.readLine(); 422 | while (line != null) { 423 | if (!line.contains(boundary)) 424 | sendError(HTTP_BADREQUEST, "BAD REQUEST: Content type is multipart/form-data but next chunk does not start with boundary. Usage: GET /example/file.html"); 425 | boundaryCount++; 426 | Properties item = new Properties(); 427 | line = in.readLine(); 428 | while (line != null && line.trim().length() > 0) { 429 | int p = line.indexOf(':'); 430 | if (p != -1) 431 | item.put(line.substring(0, p).trim().toLowerCase(), line.substring(p + 1).trim()); 432 | line = in.readLine(); 433 | } 434 | if (line != null) { 435 | String contentDisposition = item.getProperty("content-disposition"); 436 | if (contentDisposition == null) { 437 | sendError(HTTP_BADREQUEST, "BAD REQUEST: Content type is multipart/form-data but no content-disposition info found. Usage: GET /example/file.html"); 438 | } 439 | StringTokenizer st = new StringTokenizer(contentDisposition, "; "); 440 | Properties disposition = new Properties(); 441 | while (st.hasMoreTokens()) { 442 | String token = st.nextToken(); 443 | int p = token.indexOf('='); 444 | if (p != -1) 445 | disposition.put(token.substring(0, p).trim().toLowerCase(), token.substring(p + 1).trim()); 446 | } 447 | String pname = disposition.getProperty("name"); 448 | pname = pname.substring(1, pname.length() - 1); 449 | 450 | String value = ""; 451 | if (item.getProperty("content-type") == null) { 452 | while (line != null && !line.contains(boundary)) { 453 | line = in.readLine(); 454 | if (line != null) { 455 | int d = line.indexOf(boundary); 456 | if (d == -1) 457 | value += line; 458 | else 459 | value += line.substring(0, d - 2); 460 | } 461 | } 462 | } else { 463 | if (boundaryCount > boundaryPositions.length) 464 | sendError(HTTP_INTERNALERROR, "Error processing request"); 465 | int offset = stripMultipartHeaders(fbuf, boundaryPositions[boundaryCount - 2]); 466 | String path = saveTmpFile(fbuf, offset, boundaryPositions[boundaryCount - 1] - offset - 4); 467 | files.put(pname, path); 468 | value = disposition.getProperty("filename"); 469 | value = value.substring(1, value.length() - 1); 470 | do { 471 | line = in.readLine(); 472 | } while (line != null && !line.contains(boundary)); 473 | } 474 | parms.put(pname, value); 475 | } 476 | } 477 | } catch (IOException ioe) { 478 | sendError(HTTP_INTERNALERROR, "SERVER INTERNAL ERROR: IOException: " + ioe.getMessage()); 479 | } 480 | } 481 | 482 | /** 483 | * Find the byte positions where multipart boundaries start. 484 | **/ 485 | public int[] getBoundaryPositions(byte[] b, byte[] boundary) { 486 | int matchCount = 0; 487 | int matchByte = -1; 488 | Vector matchBytes = new Vector(); 489 | for (int i = 0; i < b.length; i++) { 490 | if (b[i] == boundary[matchCount]) { 491 | if (matchCount == 0) 492 | matchByte = i; 493 | matchCount++; 494 | if (matchCount == boundary.length) { 495 | matchBytes.addElement(new Integer(matchByte)); 496 | matchCount = 0; 497 | matchByte = -1; 498 | } 499 | } else { 500 | i -= matchCount; 501 | matchCount = 0; 502 | matchByte = -1; 503 | } 504 | } 505 | int[] ret = new int[matchBytes.size()]; 506 | for (int i = 0; i < ret.length; i++) { 507 | ret[i] = ((Integer) matchBytes.elementAt(i)).intValue(); 508 | } 509 | return ret; 510 | } 511 | 512 | /** 513 | * Retrieves the content of a sent file and saves it 514 | * to a temporary file. 515 | * The full path to the saved file is returned. 516 | **/ 517 | private String saveTmpFile(byte[] b, int offset, int len) { 518 | String path = ""; 519 | if (len > 0) { 520 | String tmpdir = System.getProperty("java.io.tmpdir"); 521 | try { 522 | File temp = File.createTempFile("NanoHTTPD", "", new File(tmpdir)); 523 | OutputStream out = new FileOutputStream(temp); 524 | out.write(b, offset, len); 525 | out.close(); 526 | path = temp.getAbsolutePath(); 527 | } catch (Exception e) { // Catch exception if any 528 | System.err.println("Error: " + e.getMessage()); 529 | } 530 | } 531 | return path; 532 | } 533 | 534 | 535 | /** 536 | * It returns the offset separating multipart file headers 537 | * from the file's data. 538 | **/ 539 | private int stripMultipartHeaders(byte[] b, int offset) { 540 | int i = 0; 541 | for (i = offset; i < b.length; i++) { 542 | if (b[i] == '\r' && b[++i] == '\n' && b[++i] == '\r' && b[++i] == '\n') 543 | break; 544 | } 545 | return i + 1; 546 | } 547 | 548 | /** 549 | * Decodes the percent encoding scheme.
550 | * For example: "an+example%20string" -> "an example string" 551 | */ 552 | private String decodePercent(String str) throws InterruptedException { 553 | try { 554 | StringBuilder sb = new StringBuilder(); 555 | for (int i = 0; i < str.length(); i++) { 556 | char c = str.charAt(i); 557 | switch (c) { 558 | case '+': 559 | sb.append(' '); 560 | break; 561 | case '%': 562 | sb.append((char) Integer.parseInt(str.substring(i + 1, i + 3), 16)); 563 | i += 2; 564 | break; 565 | default: 566 | sb.append(c); 567 | break; 568 | } 569 | } 570 | return sb.toString(); 571 | } catch (Exception e) { 572 | sendError(HTTP_BADREQUEST, "BAD REQUEST: Bad percent-encoding."); 573 | return null; 574 | } 575 | } 576 | 577 | /** 578 | * Decodes parameters in percent-encoded URI-format 579 | * ( e.g. "name=Jack%20Daniels&pass=Single%20Malt" ) and 580 | * adds them to given Properties. NOTE: this doesn't support multiple 581 | * identical keys due to the simplicity of Properties -- if you need multiples, 582 | * you might want to replace the Properties with a Hashtable of Vectors or such. 583 | */ 584 | private void decodeParams(String params, Properties p) 585 | throws InterruptedException { 586 | if (params == null) 587 | return; 588 | 589 | StringTokenizer st = new StringTokenizer(params, "&"); 590 | while (st.hasMoreTokens()) { 591 | String e = st.nextToken(); 592 | int sep = e.indexOf('='); 593 | if (sep >= 0) 594 | p.put(decodePercent(e.substring(0, sep)).trim(), 595 | decodePercent(e.substring(sep + 1))); 596 | } 597 | } 598 | 599 | /** 600 | * Returns an error message as a HTTP response and 601 | * throws InterruptedException to stop further request processing. 602 | */ 603 | private void sendError(String status, String msg) throws InterruptedException { 604 | sendResponse(status, MIME_PLAINTEXT, null, new ByteArrayInputStream(msg.getBytes())); 605 | throw new InterruptedException(); 606 | } 607 | 608 | /** 609 | * Sends given response to the socket. 610 | */ 611 | private void sendResponse(String status, String mime, Properties header, InputStream data) { 612 | try { 613 | if (status == null) 614 | throw new Error("sendResponse(): Status can't be null."); 615 | 616 | OutputStream out = mySocket.getOutputStream(); 617 | PrintWriter pw = new PrintWriter(out); 618 | pw.print("HTTP/1.0 " + status + " \r\n"); 619 | 620 | if (mime != null) 621 | pw.print("Content-Type: " + mime + "\r\n"); 622 | 623 | if (header == null || header.getProperty("Date") == null) 624 | pw.print("Date: " + gmtFrmt.format(new Date()) + "\r\n"); 625 | 626 | if (header != null) { 627 | Enumeration e = header.keys(); 628 | while (e.hasMoreElements()) { 629 | String key = (String) e.nextElement(); 630 | String value = header.getProperty(key); 631 | pw.print(key + ": " + value + "\r\n"); 632 | } 633 | } 634 | 635 | pw.print("\r\n"); 636 | pw.flush(); 637 | 638 | if (data != null) { 639 | int pending = data.available(); // This is to support partial sends, see serveFile() 640 | byte[] buff = new byte[2048]; 641 | while (pending > 0) { 642 | int read = data.read(buff, 0, ((pending > 2048) ? 2048 : pending)); 643 | if (read <= 0) break; 644 | out.write(buff, 0, read); 645 | pending -= read; 646 | } 647 | } 648 | out.flush(); 649 | out.close(); 650 | if (data != null) 651 | data.close(); 652 | } catch (IOException ioe) { 653 | // Couldn't write? No can do. 654 | try { 655 | mySocket.close(); 656 | } catch (Throwable t) { 657 | t.printStackTrace(); 658 | } 659 | } 660 | } 661 | 662 | private Socket mySocket; 663 | } 664 | 665 | /** 666 | * URL-encodes everything between "/"-characters. 667 | * Encodes spaces as '%20' instead of '+'. 668 | */ 669 | private String encodeUri(String uri) { 670 | String newUri = ""; 671 | StringTokenizer st = new StringTokenizer(uri, "/ ", true); 672 | while (st.hasMoreTokens()) { 673 | String tok = st.nextToken(); 674 | if (tok.equals("/")) 675 | newUri += "/"; 676 | else if (tok.equals(" ")) 677 | newUri += "%20"; 678 | else { 679 | newUri += URLEncoder.encode(tok); 680 | // For Java 1.4 you'll want to use this instead: 681 | // try { newUri += URLEncoder.encode( tok, "UTF-8" ); } catch ( java.io.UnsupportedEncodingException uee ) {} 682 | } 683 | } 684 | return newUri; 685 | } 686 | 687 | private int myTcpPort; 688 | private final ServerSocket myServerSocket; 689 | private Thread myThread; 690 | private File myRootDir; 691 | 692 | // ================================================== 693 | // File server code 694 | // ================================================== 695 | 696 | /** 697 | * Serves file from homeDir and its' subdirectories (only). 698 | * Uses only URI, ignores all headers and HTTP parameters. 699 | */ 700 | public Response serveFile(String uri, Properties header, File homeDir, 701 | boolean allowDirectoryListing) { 702 | Response res = null; 703 | 704 | // Make sure we won't die of an exception later 705 | if (!homeDir.isDirectory()) 706 | res = new Response(HTTP_INTERNALERROR, MIME_PLAINTEXT, 707 | "INTERNAL ERRROR: serveFile(): given homeDir is not a directory."); 708 | 709 | if (res == null) { 710 | // Remove URL arguments 711 | uri = uri.trim().replace(File.separatorChar, '/'); 712 | if (uri.indexOf('?') >= 0) 713 | uri = uri.substring(0, uri.indexOf('?')); 714 | 715 | // Prohibit getting out of current directory 716 | if (uri.startsWith("..") || uri.endsWith("..") || uri.indexOf("../") >= 0) 717 | res = new Response(HTTP_FORBIDDEN, MIME_PLAINTEXT, 718 | "FORBIDDEN: Won't serve ../ for security reasons."); 719 | } 720 | 721 | File f = new File(homeDir, uri); 722 | if (res == null && !f.exists()) 723 | res = new Response(HTTP_NOTFOUND, MIME_PLAINTEXT, 724 | "Error 404, file not found."); 725 | 726 | // List the directory, if necessary 727 | if (res == null && f.isDirectory()) { 728 | // Browsers get confused without '/' after the 729 | // directory, send a redirect. 730 | if (!uri.endsWith("/")) { 731 | uri += "/"; 732 | res = new Response(HTTP_REDIRECT, MIME_HTML, 733 | "Redirected: " + 734 | uri + ""); 735 | res.addHeader("Location", uri); 736 | } 737 | 738 | if (res == null) { 739 | // First try index.html and index.htm 740 | if (new File(f, "index.html").exists()) 741 | f = new File(homeDir, uri + "/index.html"); 742 | else if (new File(f, "index.htm").exists()) 743 | f = new File(homeDir, uri + "/index.htm"); 744 | // No index file, list the directory if it is readable 745 | else if (allowDirectoryListing && f.canRead()) { 746 | String[] files = f.list(); 747 | String msg = "

Directory " + uri + "


"; 748 | 749 | if (uri.length() > 1) { 750 | String u = uri.substring(0, uri.length() - 1); 751 | int slash = u.lastIndexOf('/'); 752 | if (slash >= 0 && slash < u.length()) 753 | msg += "..
"; 754 | } 755 | 756 | if (files != null) { 757 | for (int i = 0; i < files.length; ++i) { 758 | File curFile = new File(f, files[i]); 759 | boolean dir = curFile.isDirectory(); 760 | if (dir) { 761 | msg += ""; 762 | files[i] += "/"; 763 | } 764 | 765 | msg += "" + 766 | files[i] + ""; 767 | 768 | // Show file size 769 | if (curFile.isFile()) { 770 | long len = curFile.length(); 771 | msg += "  ("; 772 | if (len < 1024) 773 | msg += len + " bytes"; 774 | else if (len < 1024 * 1024) 775 | msg += len / 1024 + "." + (len % 1024 / 10 % 100) + " KB"; 776 | else 777 | msg += len / (1024 * 1024) + "." + len % (1024 * 1024) / 10 % 100 + " MB"; 778 | 779 | msg += ")"; 780 | } 781 | msg += "
"; 782 | if (dir) msg += "
"; 783 | } 784 | } 785 | msg += ""; 786 | res = new Response(HTTP_OK, MIME_HTML, msg); 787 | } else { 788 | res = new Response(HTTP_FORBIDDEN, MIME_PLAINTEXT, 789 | "FORBIDDEN: No directory listing."); 790 | } 791 | } 792 | } 793 | 794 | try { 795 | if (res == null) { 796 | // Get MIME type from file name extension, if possible 797 | String mime = null; 798 | int dot = f.getCanonicalPath().lastIndexOf('.'); 799 | if (dot >= 0) 800 | mime = (String) theMimeTypes.get(f.getCanonicalPath().substring(dot + 1).toLowerCase()); 801 | if (mime == null) 802 | mime = MIME_DEFAULT_BINARY; 803 | 804 | // Support (simple) skipping: 805 | long startFrom = 0; 806 | long endAt = -1; 807 | String range = header.getProperty("range"); 808 | if (range != null) { 809 | if (range.startsWith("bytes=")) { 810 | range = range.substring("bytes=".length()); 811 | int minus = range.indexOf('-'); 812 | try { 813 | if (minus > 0) { 814 | startFrom = Long.parseLong(range.substring(0, minus)); 815 | endAt = Long.parseLong(range.substring(minus + 1)); 816 | } 817 | } catch (NumberFormatException nfe) { 818 | } 819 | } 820 | } 821 | 822 | // Change return code and add Content-Range header when skipping is requested 823 | long fileLen = f.length(); 824 | if (range != null && startFrom >= 0) { 825 | if (startFrom >= fileLen) { 826 | res = new Response(HTTP_RANGE_NOT_SATISFIABLE, MIME_PLAINTEXT, ""); 827 | res.addHeader("Content-Range", "bytes 0-0/" + fileLen); 828 | } else { 829 | if (endAt < 0) 830 | endAt = fileLen - 1; 831 | long newLen = endAt - startFrom + 1; 832 | if (newLen < 0) newLen = 0; 833 | 834 | final long dataLen = newLen; 835 | FileInputStream fis = new FileInputStream(f) { 836 | public int available() throws IOException { 837 | return (int) dataLen; 838 | } 839 | }; 840 | fis.skip(startFrom); 841 | 842 | res = new Response(HTTP_PARTIALCONTENT, mime, fis); 843 | res.addHeader("Content-Length", "" + dataLen); 844 | res.addHeader("Content-Range", "bytes " + startFrom + "-" + endAt + "/" + fileLen); 845 | } 846 | } else { 847 | res = new Response(HTTP_OK, mime, new FileInputStream(f)); 848 | res.addHeader("Content-Length", "" + fileLen); 849 | } 850 | } 851 | } catch (IOException ioe) { 852 | res = new Response(HTTP_FORBIDDEN, MIME_PLAINTEXT, "FORBIDDEN: Reading file failed."); 853 | } 854 | 855 | res.addHeader("Accept-Ranges", "bytes"); // Announce that the file server accepts partial content requestes 856 | return res; 857 | } 858 | 859 | /** 860 | * Hashtable mapping (String)FILENAME_EXTENSION -> (String)MIME_TYPE 861 | */ 862 | private static Hashtable theMimeTypes = new Hashtable(); 863 | 864 | static { 865 | StringTokenizer st = new StringTokenizer( 866 | "css text/css " + 867 | "js text/javascript " + 868 | "htm text/html " + 869 | "html text/html " + 870 | "txt text/plain " + 871 | "asc text/plain " + 872 | "gif image/gif " + 873 | "jpg image/jpeg " + 874 | "jpeg image/jpeg " + 875 | "png image/png " + 876 | "mp3 audio/mpeg " + 877 | "m3u audio/mpeg-url " + 878 | "pdf application/pdf " + 879 | "doc application/msword " + 880 | "ogg application/x-ogg " + 881 | "zip application/octet-stream " + 882 | "exe application/octet-stream " + 883 | "class application/octet-stream "); 884 | while (st.hasMoreTokens()) 885 | theMimeTypes.put(st.nextToken(), st.nextToken()); 886 | } 887 | 888 | /** 889 | * GMT date formatter 890 | */ 891 | private static java.text.SimpleDateFormat gmtFrmt; 892 | 893 | static { 894 | gmtFrmt = new java.text.SimpleDateFormat("E, d MMM yyyy HH:mm:ss 'GMT'", Locale.US); 895 | gmtFrmt.setTimeZone(TimeZone.getTimeZone("GMT")); 896 | } 897 | 898 | /** 899 | * The distribution licence 900 | */ 901 | private static final String LICENCE = 902 | "Copyright (C) 2001,2005-2011 by Jarno Elonen \n" + 903 | "and Copyright (C) 2010 by Konstantinos Togias \n" + 904 | "\n" + 905 | "Redistribution and use in source and binary forms, with or without\n" + 906 | "modification, are permitted provided that the following conditions\n" + 907 | "are met:\n" + 908 | "\n" + 909 | "Redistributions of source code must retain the above copyright notice,\n" + 910 | "this list of conditions and the following disclaimer. Redistributions in\n" + 911 | "binary form must reproduce the above copyright notice, this list of\n" + 912 | "conditions and the following disclaimer in the documentation and/or other\n" + 913 | "materials provided with the distribution. The name of the author may not\n" + 914 | "be used to endorse or promote products derived from this software without\n" + 915 | "specific prior written permission. \n" + 916 | " \n" + 917 | "THIS SOFTWARE IS PROVIDED BY THE AUTHOR ``AS IS'' AND ANY EXPRESS OR\n" + 918 | "IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES\n" + 919 | "OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED.\n" + 920 | "IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY DIRECT, INDIRECT,\n" + 921 | "INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT\n" + 922 | "NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE,\n" + 923 | "DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY\n" + 924 | "THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT\n" + 925 | "(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE\n" + 926 | "OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE."; 927 | } 928 | 929 | -------------------------------------------------------------------------------- /app/src/main/java/com/kookong/kkcling/server/MediaServer.java: -------------------------------------------------------------------------------- 1 | 2 | package com.kookong.kkcling.server; 3 | 4 | import android.util.Log; 5 | 6 | import org.fourthline.cling.model.ValidationException; 7 | import org.fourthline.cling.model.meta.DeviceDetails; 8 | import org.fourthline.cling.model.meta.DeviceIdentity; 9 | import org.fourthline.cling.model.meta.Icon; 10 | import org.fourthline.cling.model.meta.LocalDevice; 11 | import org.fourthline.cling.model.meta.LocalService; 12 | import org.fourthline.cling.model.meta.ManufacturerDetails; 13 | import org.fourthline.cling.model.meta.ModelDetails; 14 | import org.fourthline.cling.model.types.DeviceType; 15 | import org.fourthline.cling.model.types.UDADeviceType; 16 | import org.fourthline.cling.model.types.UDN; 17 | 18 | import java.io.IOException; 19 | import java.net.SocketException; 20 | 21 | public class MediaServer { 22 | private final static String TAG = "MediaServer"; 23 | 24 | public static final int PORT = 6660; 25 | private static final int VERSION = 1; 26 | private static final String DMS_DESC = "MSI MediaServer"; 27 | private static final String DMR_DESC = "MSI MediaRenderer"; 28 | private static final String deviceType = "MediaServer"; 29 | 30 | private LocalDevice mLocalDevice; 31 | private HttpServer mHttpServer; 32 | 33 | public volatile static String IP_ADDRESS; 34 | 35 | public MediaServer() throws ValidationException, SocketException { 36 | DeviceType type = new UDADeviceType(deviceType, VERSION); 37 | DeviceDetails details = new DeviceDetails("DMS (" + android.os.Build.MODEL + ")", new ManufacturerDetails( 38 | android.os.Build.MANUFACTURER), new ModelDetails(android.os.Build.MODEL, DMS_DESC, "v1")); 39 | String ip = UpnpUtil.getIP(); 40 | IP_ADDRESS = ip; 41 | UDN udn = UpnpUtil.uniqueSystemIdentifier("GNaP-MediaServer", "localhost", "http://" + ip + "/" + PORT); 42 | // service.setManager(new DefaultServiceManager(service, ContentDirectoryService.class)); 43 | 44 | mLocalDevice = new LocalDevice(new DeviceIdentity(udn), type, details, createDefaultDeviceIcon(), getLocalService()); 45 | 46 | Log.v(TAG, "MediaServer device created: "); 47 | Log.v(TAG, "friendly name: " + details.getFriendlyName()); 48 | Log.v(TAG, "manufacturer: " + details.getManufacturerDetails().getManufacturer()); 49 | Log.v(TAG, "model: " + details.getModelDetails().getModelName()); 50 | if (mHttpServer != null) { 51 | mHttpServer.stop(); 52 | mHttpServer = null; 53 | } 54 | // start http server 55 | try { 56 | mHttpServer = new HttpServer(PORT); 57 | } catch (IOException ioe) { 58 | Log.e(TAG, "Couldn't start server:\n" + ioe); 59 | System.exit(-1); 60 | } 61 | Log.e(TAG, "Started Http Server on port " + PORT); 62 | } 63 | 64 | public LocalDevice getDevice() { 65 | return mLocalDevice; 66 | } 67 | 68 | public void stop() { 69 | if (mHttpServer != null) { 70 | mHttpServer.stop(); 71 | } 72 | } 73 | 74 | private Icon createDefaultDeviceIcon() { 75 | return null; 76 | } 77 | 78 | private LocalService getLocalService() { 79 | return null; 80 | } 81 | 82 | } 83 | -------------------------------------------------------------------------------- /app/src/main/java/com/kookong/kkcling/server/UpnpUtil.java: -------------------------------------------------------------------------------- 1 | package com.kookong.kkcling.server; 2 | 3 | import android.util.Log; 4 | 5 | import org.fourthline.cling.model.types.UDN; 6 | 7 | import java.math.BigInteger; 8 | import java.net.InetAddress; 9 | import java.net.NetworkInterface; 10 | import java.net.SocketException; 11 | import java.security.MessageDigest; 12 | import java.util.Enumeration; 13 | import java.util.UUID; 14 | 15 | public class UpnpUtil { 16 | 17 | private static final String TAG = "UpnpUtil"; 18 | 19 | public static UDN uniqueSystemIdentifier(String salt, String hostName, String hostAddress) { 20 | StringBuilder systemSalt = new StringBuilder(); 21 | Log.d(TAG, "host:" + hostName + " ip:" + hostAddress); 22 | systemSalt.append(hostAddress).append( 23 | hostAddress); 24 | systemSalt.append(android.os.Build.MODEL); 25 | systemSalt.append(android.os.Build.MANUFACTURER); 26 | 27 | try { 28 | byte[] hash = MessageDigest.getInstance("MD5").digest(systemSalt.toString().getBytes()); 29 | return new UDN(new UUID(new BigInteger(-1, hash).longValue(), salt.hashCode())); 30 | } catch (Exception ex) { 31 | throw new RuntimeException(ex); 32 | } 33 | } 34 | 35 | public static String getIP() throws SocketException { 36 | String ipAddress = ""; 37 | for (Enumeration en = NetworkInterface.getNetworkInterfaces(); en 38 | .hasMoreElements(); ) { 39 | NetworkInterface networkInterface = en.nextElement(); 40 | if (networkInterface.getName().toLowerCase().equals("eth0") 41 | || networkInterface.getName().toLowerCase().equals("wlan0")) { 42 | for (Enumeration netAddress = networkInterface.getInetAddresses(); netAddress 43 | .hasMoreElements(); ) { 44 | InetAddress inetAddress = netAddress.nextElement(); 45 | if (!inetAddress.isLoopbackAddress()) { 46 | ipAddress = inetAddress.getHostAddress(); 47 | if (!ipAddress.contains("::")) {// ipV6的地址 48 | Log.e(TAG, ipAddress); 49 | return ipAddress; 50 | } 51 | } 52 | } 53 | } 54 | } 55 | return ipAddress; 56 | } 57 | } 58 | -------------------------------------------------------------------------------- /app/src/main/res/drawable-v24/ic_launcher_foreground.xml: -------------------------------------------------------------------------------- 1 | 7 | 12 | 13 | 19 | 22 | 25 | 26 | 27 | 28 | 34 | 35 | -------------------------------------------------------------------------------- /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/layout/activity_main.xml: -------------------------------------------------------------------------------- 1 | 2 | 8 | 9 | 10 | 16 | 17 | 21 | 22 | 26 | 27 |