├── .gitignore ├── .idea ├── codeStyles │ ├── Project.xml │ └── codeStyleConfig.xml ├── compiler.xml ├── encodings.xml ├── gradle.xml ├── jarRepositories.xml ├── misc.xml └── vcs.xml ├── LICENSE ├── README.md ├── app ├── .gitignore ├── build.gradle ├── proguard-rules.pro └── src │ ├── androidTest │ └── java │ │ └── studio │ │ └── attect │ │ └── websocketservice │ │ └── example │ │ └── ExampleInstrumentedTest.kt │ ├── main │ ├── AndroidManifest.xml │ ├── java │ │ └── studio │ │ │ └── attect │ │ │ └── websocketservice │ │ │ └── example │ │ │ ├── AppCompatTextView.kt │ │ │ ├── BubbleData.kt │ │ │ ├── ByteArray.kt │ │ │ ├── MainActivity.kt │ │ │ ├── MyApplication.kt │ │ │ └── String.kt │ └── res │ │ ├── drawable-hdpi │ │ └── ic_notification_icon.png │ │ ├── drawable-mdpi │ │ └── ic_notification_icon.png │ │ ├── drawable-v24 │ │ └── ic_launcher_foreground.xml │ │ ├── drawable-xhdpi │ │ └── ic_notification_icon.png │ │ ├── drawable-xxhdpi │ │ └── ic_notification_icon.png │ │ ├── drawable-xxxhdpi │ │ └── ic_notification_icon.png │ │ ├── drawable │ │ └── ic_launcher_background.xml │ │ ├── layout │ │ ├── activity_main.xml │ │ ├── bubble_client.xml │ │ ├── bubble_server.xml │ │ └── bubble_system.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 │ │ ├── dimens.xml │ │ ├── strings.xml │ │ └── styles.xml │ └── test │ └── java │ └── studio │ └── attect │ └── websocketservice │ └── example │ └── ExampleUnitTest.kt ├── build.gradle ├── gradle.properties ├── gradle └── wrapper │ ├── gradle-wrapper.jar │ └── gradle-wrapper.properties ├── gradlew ├── gradlew.bat ├── settings.gradle ├── staticviewmodelstore ├── build.gradle └── staticviewmodelstore-release.aar └── websocketservice ├── .gitignore ├── build.gradle ├── proguard-rules.pro └── src ├── androidTest └── java │ └── studio │ └── attect │ └── websocketservice │ └── ExampleInstrumentedTest.java ├── main ├── AndroidManifest.xml ├── java │ └── studio │ │ └── attect │ │ └── websocketservice │ │ ├── BytesDataObserver.kt │ │ ├── RFC6455CloseState.kt │ │ ├── StringDataObserver.kt │ │ ├── WebSocketHandshakeHeader.kt │ │ ├── WebSocketService.kt │ │ ├── WebSocketServiceViewModel.kt │ │ └── WebSocketStatus.kt └── res │ └── values │ └── strings.xml └── test └── java └── studio └── attect └── websocketservice └── ExampleUnitTest.java /.gitignore: -------------------------------------------------------------------------------- 1 | *.iml 2 | .gradle 3 | /local.properties 4 | /.idea/caches 5 | /.idea/libraries 6 | /.idea/modules.xml 7 | /.idea/workspace.xml 8 | /.idea/navEditor.xml 9 | /.idea/assetWizardSettings.xml 10 | .DS_Store 11 | /build 12 | /captures 13 | .externalNativeBuild 14 | -------------------------------------------------------------------------------- /.idea/codeStyles/Project.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 6 | 7 | 8 | 9 |
10 | 11 | 12 | 13 | xmlns:android 14 | 15 | ^$ 16 | 17 | 18 | 19 |
20 |
21 | 22 | 23 | 24 | xmlns:.* 25 | 26 | ^$ 27 | 28 | 29 | BY_NAME 30 | 31 |
32 |
33 | 34 | 35 | 36 | .*:id 37 | 38 | http://schemas.android.com/apk/res/android 39 | 40 | 41 | 42 |
43 |
44 | 45 | 46 | 47 | .*:name 48 | 49 | http://schemas.android.com/apk/res/android 50 | 51 | 52 | 53 |
54 |
55 | 56 | 57 | 58 | name 59 | 60 | ^$ 61 | 62 | 63 | 64 |
65 |
66 | 67 | 68 | 69 | style 70 | 71 | ^$ 72 | 73 | 74 | 75 |
76 |
77 | 78 | 79 | 80 | .* 81 | 82 | ^$ 83 | 84 | 85 | BY_NAME 86 | 87 |
88 |
89 | 90 | 91 | 92 | .* 93 | 94 | http://schemas.android.com/apk/res/android 95 | 96 | 97 | ANDROID_ATTRIBUTE_ORDER 98 | 99 |
100 |
101 | 102 | 103 | 104 | .* 105 | 106 | .* 107 | 108 | 109 | BY_NAME 110 | 111 |
112 |
113 |
114 |
115 | 116 | 118 |
119 |
-------------------------------------------------------------------------------- /.idea/codeStyles/codeStyleConfig.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 5 | -------------------------------------------------------------------------------- /.idea/compiler.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | -------------------------------------------------------------------------------- /.idea/encodings.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | -------------------------------------------------------------------------------- /.idea/gradle.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 22 | 23 | -------------------------------------------------------------------------------- /.idea/jarRepositories.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 9 | 10 | 14 | 15 | 19 | 20 | 24 | 25 | 29 | 30 | -------------------------------------------------------------------------------- /.idea/misc.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 14 | 15 | 16 | 17 | 18 | 19 | 21 | -------------------------------------------------------------------------------- /.idea/vcs.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2019 Attect 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 | # WebSocketService 2 | 3 | Android WebSocket Service实现 4 | 5 | 如果你困扰怎么在Android上使用WebSocket,此项目是你很好的研究对象 6 | 7 | Demo可用作WebSocket测试工具 8 | 9 | 基于okhttp 4.2.2 10 | 11 | 这是一个LocalService,并不支持运行在独立进程中 12 | 13 | ## 愚蠢警告 14 | 15 | 项目创建3年后回看代码,功能没问题,但一些设计思想和实现相当愚蠢,仅供实现参考! 16 | 17 | ## 用法 18 | 19 | 导入此项目自己阅读app模块的代码 20 | 21 | ## 功能 22 | 23 | 1. 这个Demo可以直接作为WebSocket测试工具使用 24 | 1. 字符串数据内容即时收发 25 | 1. 二进制数据内容即时收发 26 | 1. 轻松的获取连接状态 27 | 1. 可选的后台持续运行功能 28 | 1. 自动重连 29 | 1. 自定义协议升级握手header 30 | 1. 自定义心跳时间 31 | 32 | ## 数据传递 33 | Service与其它安卓组件进行数据交换不是一件很方便的事情 34 | 35 | 此项目使用 https://github.com/Attect/StaticViewModelStore 来实现在同一进程的App内,任意Activity/Fragment/Service及其它LifecycleOwner中进行数据的收发和控制这个Service 36 | 37 | ## 演示 38 | https://www.bilibili.com/video/av51729765/ 39 | -------------------------------------------------------------------------------- /app/.gitignore: -------------------------------------------------------------------------------- 1 | /build 2 | -------------------------------------------------------------------------------- /app/build.gradle: -------------------------------------------------------------------------------- 1 | apply plugin: 'com.android.application' 2 | 3 | apply plugin: 'kotlin-android' 4 | 5 | apply plugin: 'kotlin-android-extensions' 6 | 7 | android { 8 | compileSdkVersion 31 9 | defaultConfig { 10 | applicationId 'studio.attect.websocketservice.example' 11 | minSdkVersion 19 12 | targetSdkVersion 31 13 | versionCode 1 14 | versionName "1.0" 15 | testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner" 16 | } 17 | buildTypes { 18 | release { 19 | minifyEnabled false 20 | proguardFiles getDefaultProguardFile('proguard-android-optimize.txt'), 'proguard-rules.pro' 21 | } 22 | } 23 | compileOptions { 24 | setSourceCompatibility(JavaVersion.VERSION_1_8) 25 | setTargetCompatibility(JavaVersion.VERSION_1_8) 26 | } 27 | } 28 | 29 | dependencies { 30 | implementation fileTree(dir: 'libs', include: ['*.jar']) 31 | implementation"org.jetbrains.kotlin:kotlin-stdlib-jdk8:$kotlin_version" 32 | implementation "androidx.appcompat:appcompat:${appcompat_version}" 33 | implementation "androidx.core:core-ktx:${core_ktx_version}" 34 | implementation "androidx.constraintlayout:constraintlayout:${constraintlayout_version}" 35 | testImplementation "junit:junit:${junit_version}" 36 | androidTestImplementation "androidx.test:runner:${runner_version}" 37 | androidTestImplementation "androidx.test.espresso:espresso-core:${espresso_core_version}" 38 | implementation project(path: ':websocketservice') 39 | implementation "androidx.recyclerview:recyclerview:${recyclerview_version}" 40 | implementation "androidx.cardview:cardview:${cardview_version}" 41 | implementation project(path: ':staticviewmodelstore') 42 | implementation "com.squareup.okhttp3:okhttp:${okhttp_version}" 43 | } 44 | -------------------------------------------------------------------------------- /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/studio/attect/websocketservice/example/ExampleInstrumentedTest.kt: -------------------------------------------------------------------------------- 1 | package studio.attect.websocketservice.example 2 | 3 | import androidx.test.InstrumentationRegistry 4 | import androidx.test.runner.AndroidJUnit4 5 | 6 | import org.junit.Test 7 | import org.junit.runner.RunWith 8 | 9 | import org.junit.Assert.* 10 | 11 | /** 12 | * Instrumented test, which will execute on an Android device. 13 | * 14 | * See [testing documentation](http://d.android.com/tools/testing). 15 | */ 16 | @RunWith(AndroidJUnit4::class) 17 | class ExampleInstrumentedTest { 18 | @Test 19 | fun useAppContext() { 20 | // Context of the app under test. 21 | val appContext = InstrumentationRegistry.getTargetContext() 22 | assertEquals("studio.attect.websocketservice", appContext.packageName) 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /app/src/main/AndroidManifest.xml: -------------------------------------------------------------------------------- 1 | 2 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 25 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | 38 | 39 | -------------------------------------------------------------------------------- /app/src/main/java/studio/attect/websocketservice/example/AppCompatTextView.kt: -------------------------------------------------------------------------------- 1 | package studio.attect.websocketservice.example 2 | 3 | import androidx.appcompat.widget.AppCompatTextView 4 | import androidx.core.content.ContextCompat 5 | 6 | fun AppCompatTextView.enableClick(){ 7 | setTextColor(ContextCompat.getColor(context, R.color.colorPrimary)) 8 | isFocusable = true 9 | isClickable = true 10 | } 11 | 12 | fun AppCompatTextView.disableClick(){ 13 | setTextColor(ContextCompat.getColor(context, R.color.colorDisable)) 14 | isFocusable = false 15 | isClickable = false 16 | } -------------------------------------------------------------------------------- /app/src/main/java/studio/attect/websocketservice/example/BubbleData.kt: -------------------------------------------------------------------------------- 1 | package studio.attect.websocketservice.example 2 | 3 | data class BubbleData (val sender:Int,val strContent:String?,val byteContent:ByteArray?,val hex:Boolean,val time:Long) -------------------------------------------------------------------------------- /app/src/main/java/studio/attect/websocketservice/example/ByteArray.kt: -------------------------------------------------------------------------------- 1 | package studio.attect.websocketservice.example 2 | 3 | /** 4 | * 将byte数组转为可读字符串 5 | */ 6 | fun ByteArray.toHexString() = joinToString("") { "%02x ".format(it).toUpperCase() } -------------------------------------------------------------------------------- /app/src/main/java/studio/attect/websocketservice/example/MainActivity.kt: -------------------------------------------------------------------------------- 1 | package studio.attect.websocketservice.example 2 | 3 | import android.app.Notification 4 | import android.app.NotificationChannel 5 | import android.app.NotificationManager 6 | import android.app.PendingIntent 7 | import android.content.Context 8 | import android.content.Intent 9 | import android.content.Intent.ACTION_MAIN 10 | import android.content.Intent.CATEGORY_LAUNCHER 11 | import android.graphics.BitmapFactory 12 | import android.os.Build 13 | import android.os.Bundle 14 | import android.util.Log 15 | import android.view.View 16 | import android.view.ViewGroup 17 | import androidx.appcompat.widget.AppCompatTextView 18 | import androidx.core.app.NotificationCompat 19 | import androidx.recyclerview.widget.LinearLayoutManager 20 | import androidx.recyclerview.widget.RecyclerView 21 | import kotlinx.android.synthetic.main.activity_main.* 22 | import okio.ByteString 23 | import okio.ByteString.Companion.toByteString 24 | import studio.attect.staticviewmodelstore.StaticViewModelLifecycleActivity 25 | import studio.attect.websocketservice.* 26 | import java.text.SimpleDateFormat 27 | import java.util.* 28 | 29 | /** 30 | * @author attect 31 | * @date 2019-05-04 32 | */ 33 | class MainActivity : StaticViewModelLifecycleActivity() { 34 | 35 | private lateinit var layoutManager: LinearLayoutManager 36 | private lateinit var recyclerViewAdapter: MyRecyclerViewAdapter 37 | 38 | private lateinit var webSocketViewModel: WebSocketServiceViewModel 39 | 40 | 41 | override fun onCreate(savedInstanceState: Bundle?) { 42 | super.onCreate(savedInstanceState) 43 | setContentView(R.layout.activity_main) 44 | 45 | WebSocketService.getViewModel(this)?.let { 46 | webSocketViewModel = it 47 | } 48 | 49 | 50 | layoutManager = LinearLayoutManager(this, RecyclerView.VERTICAL, false) 51 | recyclerViewAdapter = MyRecyclerViewAdapter() 52 | 53 | recyclerView.layoutManager = layoutManager 54 | recyclerView.adapter = recyclerViewAdapter 55 | 56 | 57 | actionButton.setOnClickListener { view -> 58 | (view as AppCompatTextView).let { textView -> 59 | if (webSocketViewModel.status.value != WebSocketStatus.CONNECTED) { 60 | addressEditText.text?.toString()?.let { url -> 61 | textView.disableClick() 62 | addressEditText.isEnabled = false 63 | notificationCheckBox.isEnabled = false 64 | val handshakeHeader = WebSocketHandshakeHeader("client", "WebSocketService") 65 | if (notificationCheckBox.isChecked) { 66 | WebSocketService.startService( 67 | this, 68 | url, 69 | NOTIFICATION_ID, 70 | createForeRunningNotification("WebSocket", "connect to $url"), 71 | arrayListOf(handshakeHeader), 72 | 5000 73 | ) 74 | } else { 75 | WebSocketService.startService(this, url, arrayListOf(handshakeHeader),5000) 76 | } 77 | } 78 | } else { 79 | webSocketViewModel.stopByUser.value = true 80 | } 81 | 82 | } 83 | } 84 | 85 | sendButton.setOnClickListener { 86 | editText.text?.toString()?.let { 87 | if (hexCheckBox.isChecked) { 88 | val byteArray = it.hexStringToByteArray() 89 | val byteString = byteArray.toByteString(0, byteArray.size) 90 | webSocketViewModel.sendBytesData.value = byteString 91 | recyclerViewAdapter.addContent( 92 | BubbleData( 93 | SENDER_CLIENT, 94 | null, 95 | byteArray, 96 | true, 97 | System.currentTimeMillis() 98 | ) 99 | ) 100 | } else { 101 | webSocketViewModel.sendStringData.value = it 102 | recyclerViewAdapter.addContent( 103 | BubbleData( 104 | SENDER_CLIENT, 105 | it, 106 | null, 107 | false, 108 | System.currentTimeMillis() 109 | ) 110 | ) 111 | } 112 | } 113 | } 114 | sendButton.disableClick() 115 | 116 | webSocketViewModel.status.observe(this, androidx.lifecycle.Observer { webSocketStatus -> 117 | webSocketStatus?.let { 118 | when (it) { 119 | WebSocketStatus.DISCONNECTED -> { 120 | actionButton.enableClick() 121 | actionButton.setText(R.string.connect) 122 | sendButton.disableClick() 123 | addressEditText.isEnabled = true 124 | notificationCheckBox.isEnabled = true 125 | recyclerViewAdapter.addContent( 126 | BubbleData( 127 | SENDER_SYSTEM, 128 | "连接已断开", 129 | null, 130 | false, 131 | System.currentTimeMillis() 132 | ) 133 | ) 134 | } 135 | WebSocketStatus.CONNECTING -> { 136 | actionButton.setText(R.string.connecting) 137 | recyclerViewAdapter.addContent( 138 | BubbleData( 139 | SENDER_SYSTEM, 140 | "连接中", 141 | null, 142 | false, 143 | System.currentTimeMillis() 144 | ) 145 | ) 146 | } 147 | WebSocketStatus.CONNECTED -> { 148 | actionButton.setText(R.string.disconnect) 149 | sendButton.enableClick() 150 | recyclerViewAdapter.addContent( 151 | BubbleData( 152 | SENDER_SYSTEM, 153 | "已连接", 154 | null, 155 | false, 156 | System.currentTimeMillis() 157 | ) 158 | ) 159 | actionButton.enableClick() 160 | } 161 | WebSocketStatus.CLOSING -> { 162 | actionButton.setText(R.string.ws_closing) 163 | recyclerViewAdapter.addContent( 164 | BubbleData( 165 | SENDER_SYSTEM, 166 | "正在断开连接", 167 | null, 168 | false, 169 | System.currentTimeMillis() 170 | ) 171 | ) 172 | } 173 | WebSocketStatus.RECONNECTING -> { 174 | actionButton.disableClick() 175 | actionButton.setText(R.string.retrying) 176 | recyclerViewAdapter.addContent( 177 | BubbleData( 178 | SENDER_SYSTEM, 179 | "重新连接中", 180 | null, 181 | false, 182 | System.currentTimeMillis() 183 | ) 184 | ) 185 | } 186 | } 187 | } 188 | }) 189 | 190 | webSocketViewModel.receiveStringData.observe( 191 | this, 192 | object : StringDataObserver(webSocketViewModel) { 193 | override fun onReceive(t: String) { 194 | Log.d("Test", "first string observe receive:$t") 195 | recyclerViewAdapter.addContent( 196 | BubbleData( 197 | SENDER_SERVER, 198 | t, 199 | null, 200 | false, 201 | System.currentTimeMillis() 202 | ) 203 | ) 204 | } 205 | }) 206 | webSocketViewModel.receiveStringData.observe( 207 | this, 208 | object : StringDataObserver(webSocketViewModel) { 209 | override fun onReceive(t: String) { 210 | Log.d("Test", "second string observe receive:$t") 211 | } 212 | }) 213 | 214 | webSocketViewModel.receiveBytesData.observe(this, 215 | object : BytesDataObserver(webSocketViewModel) { 216 | override fun onReceive(b: ByteString) { 217 | recyclerViewAdapter.addContent( 218 | BubbleData( 219 | SENDER_SERVER, 220 | null, 221 | b.toByteArray(), 222 | true, 223 | System.currentTimeMillis() 224 | ) 225 | ) 226 | } 227 | }) 228 | } 229 | 230 | /** 231 | * 创建通知前,需要向系统中创建一个通知频道 232 | * 仅在安卓O(8.0/26)及其更高版本才必须 233 | * @param channelId 频道ID,自定义字符串 234 | * @param channelName 频道名称,用户可以在系统设置中看到相关名称 235 | * @param channelDescription 频道描述,用户可以在系统设置中看到相关描述,用于向用户解释为何需要一个这样的通知 236 | * @return channelName 237 | */ 238 | private fun createNotificationChannel( 239 | channelId: String = NOTIFICATION_CHANNEL_ID, 240 | channelName: String = getString(R.string.description_keep_websocket_active_in_background), 241 | channelDescription: String = getString(R.string.description_use_notification_keep_websocket) 242 | ): String { 243 | return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { 244 | val channel = NotificationChannel( 245 | channelId, 246 | channelName, 247 | NotificationManager.IMPORTANCE_LOW 248 | ) 249 | channel.description = channelDescription 250 | //向系统注册频道,你不能在之后再进行对其进行重要性修改 251 | val notificationManager = getSystemService(NotificationManager::class.java) 252 | notificationManager.createNotificationChannel(channel) 253 | 254 | channelId 255 | } else { 256 | "" 257 | } 258 | } 259 | 260 | /** 261 | * 获得系统通知服务管理者 262 | * 需要通过它来与系统通知功能进行操作 263 | * @return NotificationManager 264 | */ 265 | private fun getNotificationManager(): NotificationManager { 266 | return getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager 267 | } 268 | 269 | /** 270 | * 为WebSocket服务创建一个前台通知 271 | * @return Notification 通知本身 272 | */ 273 | private fun createForeRunningNotification(title: String, content: String): Notification { 274 | val channel = createNotificationChannel() 275 | 276 | val backToMainActivityIntent = Intent(this, MainActivity::class.java).apply { 277 | action = ACTION_MAIN 278 | addCategory(CATEGORY_LAUNCHER) 279 | } 280 | 281 | val backToMainActivityPendingIntent = 282 | PendingIntent.getActivity(this, 0, backToMainActivityIntent, 0) 283 | 284 | val notification = NotificationCompat.Builder(this, channel).apply { 285 | setLargeIcon(BitmapFactory.decodeResource(resources, R.mipmap.ic_launcher)) 286 | setSmallIcon(R.drawable.ic_notification_icon) 287 | setContentTitle(title) 288 | setContentText(content) 289 | setContentIntent(backToMainActivityPendingIntent) 290 | priority = NotificationCompat.PRIORITY_MIN //让通知不在状态栏显示图标,处于通知列表的最下方,避免打扰用户使用 291 | }.build() 292 | 293 | 294 | //直接向系统通知服务管理者请求发送通知 295 | getNotificationManager().notify(NOTIFICATION_ID, notification) 296 | 297 | return notification 298 | } 299 | 300 | 301 | inner class MyRecyclerViewAdapter : RecyclerView.Adapter() { 302 | 303 | 304 | /** 305 | * 列表实时内容 306 | */ 307 | private val contents: ArrayList = arrayListOf() 308 | 309 | /** 310 | * 列表大小,避免实时求 311 | */ 312 | private var size = 0 313 | 314 | fun addContent(data: BubbleData) { 315 | contents.add(data) 316 | size++ 317 | notifyItemInserted(size - 1) 318 | recyclerView.smoothScrollToPosition(size - 1) //自动滚动到底部 319 | } 320 | 321 | override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): BubbleViewHolder { 322 | return when (viewType) { 323 | SENDER_SERVER -> BubbleViewHolder( 324 | layoutInflater.inflate( 325 | R.layout.bubble_server, 326 | parent, 327 | false 328 | ) 329 | ) 330 | SENDER_CLIENT -> BubbleViewHolder( 331 | layoutInflater.inflate( 332 | R.layout.bubble_client, 333 | parent, 334 | false 335 | ) 336 | ) 337 | else -> BubbleViewHolder( 338 | layoutInflater.inflate( 339 | R.layout.bubble_system, 340 | parent, 341 | false 342 | ) 343 | ) 344 | } 345 | } 346 | 347 | override fun getItemCount(): Int = size 348 | 349 | override fun onBindViewHolder(holder: BubbleViewHolder, position: Int) { 350 | holder.applyData(contents[position]) 351 | } 352 | 353 | override fun getItemViewType(position: Int): Int { 354 | return when { 355 | contents[position].sender == SENDER_SERVER -> SENDER_SERVER 356 | contents[position].sender == SENDER_CLIENT -> SENDER_CLIENT 357 | else -> SENDER_SYSTEM 358 | } 359 | } 360 | 361 | } 362 | 363 | inner class BubbleViewHolder(itemView: View) : RecyclerView.ViewHolder(itemView) { 364 | private val time: AppCompatTextView? = itemView.findViewById(R.id.time) 365 | private val content: AppCompatTextView? = itemView.findViewById(R.id.content) 366 | private val type: AppCompatTextView? = itemView.findViewById(R.id.type) 367 | 368 | fun applyData(data: BubbleData) { 369 | time?.text = SimpleDateFormat("HH:mm:ss", Locale.getDefault()).format(Date(data.time)) 370 | if (data.hex) { 371 | data.byteContent?.let { 372 | content?.text = it.toHexString() 373 | type?.text = String.format(getString(R.string.type_hex_size), it.size) 374 | } 375 | 376 | } else { 377 | data.strContent?.let { 378 | content?.text = it 379 | type?.text = String.format(getString(R.string.type_string_size), it.length) 380 | } 381 | 382 | } 383 | } 384 | } 385 | 386 | companion object { 387 | /** 388 | * 通知频道的id 389 | * 字符串类型,自己随意定义 390 | */ 391 | const val NOTIFICATION_CHANNEL_ID = "websocket" 392 | 393 | /** 394 | * 通知的id 395 | * 此ID用于给系统分辨是否为同一个通知(同一个即为更新) 396 | */ 397 | const val NOTIFICATION_ID = 1 398 | 399 | /** 400 | * 标识:系统消息 401 | */ 402 | const val SENDER_SYSTEM = 0 403 | 404 | /** 405 | * 标识:发送方:服务端(远端) 406 | */ 407 | const val SENDER_SERVER = 1 408 | 409 | /** 410 | * 标识:发送方:客户端(本机) 411 | */ 412 | const val SENDER_CLIENT = 2 413 | } 414 | 415 | 416 | } 417 | -------------------------------------------------------------------------------- /app/src/main/java/studio/attect/websocketservice/example/MyApplication.kt: -------------------------------------------------------------------------------- 1 | package studio.attect.websocketservice.example 2 | 3 | import android.app.Application 4 | import studio.attect.staticviewmodelstore.StaticViewModelStore 5 | 6 | class MyApplication : Application() { 7 | 8 | override fun onCreate() { 9 | super.onCreate() 10 | StaticViewModelStore.application = this 11 | } 12 | } -------------------------------------------------------------------------------- /app/src/main/java/studio/attect/websocketservice/example/String.kt: -------------------------------------------------------------------------------- 1 | package studio.attect.websocketservice.example 2 | 3 | private const val HEX_CHARS = "0123456789ABCDEF" 4 | 5 | /** 6 | * 将可读十六进制字符串转换为byte数组 7 | * 空格将会被消除 8 | */ 9 | fun String.hexStringToByteArray() : ByteArray { 10 | val str = this.replace(" ","") 11 | if(str.length % 2 > 0){ 12 | return (str+"F").hexStringToByteArray() 13 | } 14 | 15 | val result = ByteArray(str.length / 2) 16 | 17 | for (i in 0 until str.length step 2) { 18 | val firstIndex = HEX_CHARS.indexOf(str[i].toUpperCase()); 19 | val secondIndex = HEX_CHARS.indexOf(str[i + 1].toUpperCase()); 20 | 21 | val octet = firstIndex.shl(4).or(secondIndex) 22 | result[i.shr(1)] = octet.toByte() 23 | } 24 | 25 | return result 26 | } -------------------------------------------------------------------------------- /app/src/main/res/drawable-hdpi/ic_notification_icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Attect/WebSocketService/4344e1034e2a46c620432fcdc52c43a8a1323e7f/app/src/main/res/drawable-hdpi/ic_notification_icon.png -------------------------------------------------------------------------------- /app/src/main/res/drawable-mdpi/ic_notification_icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Attect/WebSocketService/4344e1034e2a46c620432fcdc52c43a8a1323e7f/app/src/main/res/drawable-mdpi/ic_notification_icon.png -------------------------------------------------------------------------------- /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-xhdpi/ic_notification_icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Attect/WebSocketService/4344e1034e2a46c620432fcdc52c43a8a1323e7f/app/src/main/res/drawable-xhdpi/ic_notification_icon.png -------------------------------------------------------------------------------- /app/src/main/res/drawable-xxhdpi/ic_notification_icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Attect/WebSocketService/4344e1034e2a46c620432fcdc52c43a8a1323e7f/app/src/main/res/drawable-xxhdpi/ic_notification_icon.png -------------------------------------------------------------------------------- /app/src/main/res/drawable-xxxhdpi/ic_notification_icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Attect/WebSocketService/4344e1034e2a46c620432fcdc52c43a8a1323e7f/app/src/main/res/drawable-xxxhdpi/ic_notification_icon.png -------------------------------------------------------------------------------- /app/src/main/res/drawable/ic_launcher_background.xml: -------------------------------------------------------------------------------- 1 | 2 | 8 | 10 | 12 | 14 | 16 | 18 | 20 | 22 | 24 | 26 | 28 | 30 | 32 | 34 | 36 | 38 | 40 | 42 | 44 | 46 | 48 | 50 | 52 | 54 | 56 | 58 | 60 | 62 | 64 | 66 | 68 | 70 | 72 | 74 | 75 | -------------------------------------------------------------------------------- /app/src/main/res/layout/activity_main.xml: -------------------------------------------------------------------------------- 1 | 2 | 8 | 9 | 19 | 20 | 32 | 33 | 47 | 48 | 60 | 61 | 67 | 68 | 79 | 80 | 81 | 82 | 83 | 84 | 101 | 102 | 103 | 104 | 114 | 115 | 125 | 126 | 136 | 137 | 151 | 152 | 164 | 165 | 170 | 171 | 179 | 180 | 181 | 182 | 183 | 184 | 201 | 202 | 203 | 204 | 205 | -------------------------------------------------------------------------------- /app/src/main/res/layout/bubble_client.xml: -------------------------------------------------------------------------------- 1 | 2 | 8 | 9 | 26 | 27 | 32 | 33 | 43 | 44 | 54 | 55 | 69 | 70 | 80 | 81 | 82 | 83 | 84 | 85 | -------------------------------------------------------------------------------- /app/src/main/res/layout/bubble_server.xml: -------------------------------------------------------------------------------- 1 | 2 | 8 | 9 | 26 | 27 | 32 | 33 | 43 | 44 | 54 | 55 | 69 | 70 | 80 | 81 | 82 | 83 | 84 | 85 | -------------------------------------------------------------------------------- /app/src/main/res/layout/bubble_system.xml: -------------------------------------------------------------------------------- 1 | 2 | 8 | 9 | 21 | 22 | 34 | 35 | 36 | -------------------------------------------------------------------------------- /app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | -------------------------------------------------------------------------------- /app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | -------------------------------------------------------------------------------- /app/src/main/res/mipmap-hdpi/ic_launcher.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Attect/WebSocketService/4344e1034e2a46c620432fcdc52c43a8a1323e7f/app/src/main/res/mipmap-hdpi/ic_launcher.png -------------------------------------------------------------------------------- /app/src/main/res/mipmap-hdpi/ic_launcher_round.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Attect/WebSocketService/4344e1034e2a46c620432fcdc52c43a8a1323e7f/app/src/main/res/mipmap-hdpi/ic_launcher_round.png -------------------------------------------------------------------------------- /app/src/main/res/mipmap-mdpi/ic_launcher.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Attect/WebSocketService/4344e1034e2a46c620432fcdc52c43a8a1323e7f/app/src/main/res/mipmap-mdpi/ic_launcher.png -------------------------------------------------------------------------------- /app/src/main/res/mipmap-mdpi/ic_launcher_round.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Attect/WebSocketService/4344e1034e2a46c620432fcdc52c43a8a1323e7f/app/src/main/res/mipmap-mdpi/ic_launcher_round.png -------------------------------------------------------------------------------- /app/src/main/res/mipmap-xhdpi/ic_launcher.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Attect/WebSocketService/4344e1034e2a46c620432fcdc52c43a8a1323e7f/app/src/main/res/mipmap-xhdpi/ic_launcher.png -------------------------------------------------------------------------------- /app/src/main/res/mipmap-xhdpi/ic_launcher_round.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Attect/WebSocketService/4344e1034e2a46c620432fcdc52c43a8a1323e7f/app/src/main/res/mipmap-xhdpi/ic_launcher_round.png -------------------------------------------------------------------------------- /app/src/main/res/mipmap-xxhdpi/ic_launcher.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Attect/WebSocketService/4344e1034e2a46c620432fcdc52c43a8a1323e7f/app/src/main/res/mipmap-xxhdpi/ic_launcher.png -------------------------------------------------------------------------------- /app/src/main/res/mipmap-xxhdpi/ic_launcher_round.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Attect/WebSocketService/4344e1034e2a46c620432fcdc52c43a8a1323e7f/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.png -------------------------------------------------------------------------------- /app/src/main/res/mipmap-xxxhdpi/ic_launcher.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Attect/WebSocketService/4344e1034e2a46c620432fcdc52c43a8a1323e7f/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png -------------------------------------------------------------------------------- /app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Attect/WebSocketService/4344e1034e2a46c620432fcdc52c43a8a1323e7f/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.png -------------------------------------------------------------------------------- /app/src/main/res/values/colors.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | #2196F3 4 | #1E88E5 5 | #2196F3 6 | 7 | #AFAFAF 8 | #A0A0A0 9 | 10 | #FFF 11 | 12 | -------------------------------------------------------------------------------- /app/src/main/res/values/dimens.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 10sp 4 | -------------------------------------------------------------------------------- /app/src/main/res/values/strings.xml: -------------------------------------------------------------------------------- 1 | 2 | WebSocketService 3 | 十六进制 4 | 发送 5 | Server 6 | Client 7 | HEX:%d 8 | String:%d 9 | 输入发送内容 10 | 连接 11 | 断开 12 | 输入服务器地址,ws://或wss://开头 13 | 驻留通知 14 | 15 | wss://echo.websocket.org 16 | 维持WebSocket后台活动通知 17 | 为了防止程序被“暂停”,App需要一个通知来保持前台应用状态,否则当App后台后,WebSocket将在几分钟内被冻结 18 | 连接中 19 | 已连接 20 | 正在断开 21 | 重试中 22 | 23 | -------------------------------------------------------------------------------- /app/src/main/res/values/styles.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 10 | 11 | 12 | -------------------------------------------------------------------------------- /app/src/test/java/studio/attect/websocketservice/example/ExampleUnitTest.kt: -------------------------------------------------------------------------------- 1 | package studio.attect.websocketservice.example 2 | 3 | import org.junit.Test 4 | 5 | import org.junit.Assert.* 6 | 7 | /** 8 | * Example local unit test, which will execute on the development machine (host). 9 | * 10 | * See [testing documentation](http://d.android.com/tools/testing). 11 | */ 12 | class ExampleUnitTest { 13 | @Test 14 | fun addition_isCorrect() { 15 | assertEquals(4, 2 + 2) 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /build.gradle: -------------------------------------------------------------------------------- 1 | // Top-level build file where you can add configuration options common to all sub-projects/modules. 2 | 3 | buildscript { 4 | ext { 5 | appcompat_version = '1.3.0' 6 | constraintlayout_version = '2.0.4' 7 | core_ktx_version = '1.7.0' 8 | espresso_core_version = '3.4.0' 9 | junit_version = '4.13.2' 10 | runner_version = '1.4.0' 11 | recyclerview_version = '1.2.1' 12 | cardview_version = '1.0.0' 13 | kotlin_version = '1.6.10' 14 | 15 | lifecycle_viewmodel_ktx_version = '2.4.1' 16 | lifecycle_extensions_version = '2.2.0' 17 | lifecycle_livedata_version = '2.4.1' 18 | lifecycle_runtime_version = '2.4.1' 19 | lifecycle_common_java8_version = '2.4.1' 20 | okhttp_version = '4.2.2' 21 | fragment_ktx_version = '1.3.5' 22 | // conscrypt_openjdk_uber_version = '2.1.0' 23 | } 24 | repositories { 25 | google() 26 | jcenter() 27 | 28 | } 29 | dependencies { 30 | classpath 'com.android.tools.build:gradle:7.1.2' 31 | classpath "org.jetbrains.kotlin:kotlin-gradle-plugin:$kotlin_version" 32 | // NOTE: Do not place your application dependencies here; they belong 33 | // in the individual module build.gradle files 34 | } 35 | } 36 | 37 | allprojects { 38 | repositories { 39 | google() 40 | jcenter() 41 | 42 | } 43 | } 44 | 45 | task clean(type: Delete) { 46 | delete rootProject.buildDir 47 | } 48 | -------------------------------------------------------------------------------- /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 | # Kotlin code style for this project: "official" or "obsolete": 21 | kotlin.code.style=official 22 | -------------------------------------------------------------------------------- /gradle/wrapper/gradle-wrapper.jar: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Attect/WebSocketService/4344e1034e2a46c620432fcdc52c43a8a1323e7f/gradle/wrapper/gradle-wrapper.jar -------------------------------------------------------------------------------- /gradle/wrapper/gradle-wrapper.properties: -------------------------------------------------------------------------------- 1 | #Wed Aug 21 11:14:41 CST 2019 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.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 | -------------------------------------------------------------------------------- /settings.gradle: -------------------------------------------------------------------------------- 1 | include ':app', ':websocketservice', ':staticviewmodelstore' 2 | -------------------------------------------------------------------------------- /staticviewmodelstore/build.gradle: -------------------------------------------------------------------------------- 1 | configurations.maybeCreate("default") 2 | artifacts.add("default", file('staticviewmodelstore-release.aar')) 3 | -------------------------------------------------------------------------------- /staticviewmodelstore/staticviewmodelstore-release.aar: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Attect/WebSocketService/4344e1034e2a46c620432fcdc52c43a8a1323e7f/staticviewmodelstore/staticviewmodelstore-release.aar -------------------------------------------------------------------------------- /websocketservice/.gitignore: -------------------------------------------------------------------------------- 1 | /build 2 | -------------------------------------------------------------------------------- /websocketservice/build.gradle: -------------------------------------------------------------------------------- 1 | apply plugin: 'com.android.library' 2 | apply plugin: 'kotlin-android' 3 | apply plugin: 'kotlin-android-extensions' 4 | 5 | android { 6 | compileSdkVersion 31 7 | 8 | 9 | defaultConfig { 10 | minSdkVersion 14 11 | targetSdkVersion 31 12 | 13 | testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner" 14 | 15 | } 16 | 17 | buildTypes { 18 | release { 19 | minifyEnabled false 20 | proguardFiles getDefaultProguardFile('proguard-android-optimize.txt'), 'proguard-rules.pro' 21 | } 22 | } 23 | compileOptions { 24 | setSourceCompatibility(JavaVersion.VERSION_1_8) 25 | setTargetCompatibility(JavaVersion.VERSION_1_8) 26 | } 27 | 28 | } 29 | 30 | dependencies { 31 | implementation fileTree(dir: 'libs', include: ['*.jar']) 32 | 33 | implementation "androidx.appcompat:appcompat:${appcompat_version}" 34 | testImplementation "junit:junit:${junit_version}" 35 | androidTestImplementation "androidx.test:runner:${runner_version}" 36 | androidTestImplementation "androidx.test.espresso:espresso-core:${espresso_core_version}" 37 | implementation "androidx.core:core-ktx:${core_ktx_version}" 38 | implementation "org.jetbrains.kotlin:kotlin-stdlib-jdk8:$kotlin_version" 39 | implementation project(path: ':staticviewmodelstore') 40 | implementation "androidx.appcompat:appcompat:${appcompat_version}" 41 | implementation"org.jetbrains.kotlin:kotlin-stdlib-jdk8:$kotlin_version" 42 | implementation "androidx.lifecycle:lifecycle-viewmodel-ktx:${lifecycle_viewmodel_ktx_version}" 43 | implementation "androidx.fragment:fragment-ktx:${fragment_ktx_version}" 44 | // ViewModel and LiveData 45 | implementation "androidx.lifecycle:lifecycle-extensions:${lifecycle_extensions_version}" 46 | 47 | // alternatively - just LiveData 48 | implementation "androidx.lifecycle:lifecycle-livedata:${lifecycle_livedata_version}" 49 | 50 | // alternatively - Lifecycles only (no ViewModel or LiveData). 51 | 52 | // Support library depends on this lightweight import 53 | implementation "androidx.lifecycle:lifecycle-runtime:${lifecycle_runtime_version}" 54 | 55 | // alternately - if using Java8, use the following instead of compiler 56 | implementation "androidx.lifecycle:lifecycle-common-java8:${lifecycle_common_java8_version}" 57 | implementation "com.squareup.okhttp3:okhttp:${okhttp_version}" 58 | // implementation "org.conscrypt:conscrypt-openjdk-uber:${conscrypt_openjdk_uber_version}" 59 | } 60 | repositories { 61 | mavenCentral() 62 | } 63 | -------------------------------------------------------------------------------- /websocketservice/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 | -------------------------------------------------------------------------------- /websocketservice/src/androidTest/java/studio/attect/websocketservice/ExampleInstrumentedTest.java: -------------------------------------------------------------------------------- 1 | package studio.attect.websocketservice; 2 | 3 | import android.content.Context; 4 | import androidx.test.InstrumentationRegistry; 5 | import androidx.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() { 21 | // Context of the app under test. 22 | Context appContext = InstrumentationRegistry.getTargetContext(); 23 | 24 | assertEquals("studio.attect.websocketservice.test", appContext.getPackageName()); 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /websocketservice/src/main/AndroidManifest.xml: -------------------------------------------------------------------------------- 1 | 3 | -------------------------------------------------------------------------------- /websocketservice/src/main/java/studio/attect/websocketservice/BytesDataObserver.kt: -------------------------------------------------------------------------------- 1 | package studio.attect.websocketservice 2 | 3 | import androidx.lifecycle.Observer 4 | import okio.ByteString 5 | 6 | /** 7 | * 二进制数据接收观察器 8 | * 会自动触发WebSocketServer让其提交下一个数据 9 | * 10 | * @author attect 11 | * @date 2019-05-08 12 | */ 13 | abstract class BytesDataObserver(private val webSocketServiceViewModel: WebSocketServiceViewModel) : 14 | Observer { 15 | 16 | final override fun onChanged(b: ByteString?) { 17 | webSocketServiceViewModel.requireNextBytesData() 18 | b?.let { 19 | onReceive(it) 20 | } 21 | } 22 | 23 | abstract fun onReceive(b: ByteString) 24 | } -------------------------------------------------------------------------------- /websocketservice/src/main/java/studio/attect/websocketservice/RFC6455CloseState.kt: -------------------------------------------------------------------------------- 1 | package studio.attect.websocketservice 2 | 3 | /** 4 | * rfc6455连接关闭状态码 5 | * https://tools.ietf.org/html/rfc6455#section-7.4 6 | * 7 | * 部分状态码也可在这看到,名称与值相同 8 | * @see okhttp3.internal.ws.WebSocketProtocol 9 | * 10 | * @author attect 11 | * @date 2019-05-06 12 | */ 13 | enum class RFC6455CloseState constructor(i: Int) { 14 | /** 15 | * 默认值 16 | */ 17 | NO_ERROR(0), 18 | 19 | /** 20 | * 主动关闭状态码:正常关闭 21 | */ 22 | CLOSE_STATE_NORMAL(1000), 23 | 24 | /** 25 | * 主动关闭状态码:正在离开 26 | */ 27 | CLOSE_STATE_GOING_AWAY(1001), 28 | 29 | /** 30 | * 主动关闭状态码:协议错误 31 | */ 32 | CLOSE_STATE_PROTOCOL_ERROR(1002), 33 | 34 | /** 35 | * 主动关闭状态码:不可接受的数据类型 36 | */ 37 | CLOSE_STATE_DATA_TYPE_ERROR(1003), 38 | 39 | /** 40 | * 主动关闭状态码:无法处理的消息类型 41 | */ 42 | CLOSE_STATE_DATA_TYPE_CANT_HANDLE(1007), 43 | 44 | /** 45 | * 主动关闭状态码:服务器违反约定规则 46 | */ 47 | CLOSE_STATE_SERVER_VIOLATE_RULE(1008), 48 | 49 | /** 50 | * 主动关闭状态码:收到的消息过大 51 | */ 52 | CLOSE_STATE_MESSAGE_TO_LARGE(1009), 53 | 54 | /** 55 | * 主动关闭状态码:没有从服务器得到期望的内容 56 | */ 57 | CLOSE_STATE_SERVER_NOT_RESPONSE_EXPECT_RESULT(1010), 58 | 59 | /** 60 | * 主动关闭状态码:服务器正在关闭 61 | */ 62 | CLOSE_STATE_SERVER_IS_CLOSING(1011), 63 | 64 | /** 65 | * 主动关闭状态码:无法完成TLS握手 66 | * 可能是证书错误 67 | */ 68 | CLOSE_STATE_SAFE_HANDSHAKE(1015); 69 | 70 | var value: Int = 0 71 | internal set 72 | 73 | init { 74 | value = i 75 | } 76 | } 77 | -------------------------------------------------------------------------------- /websocketservice/src/main/java/studio/attect/websocketservice/StringDataObserver.kt: -------------------------------------------------------------------------------- 1 | package studio.attect.websocketservice 2 | 3 | import androidx.lifecycle.Observer 4 | 5 | /** 6 | * 字符串数据接收观察器 7 | * 会自动触发WebSocketServer让其提交下一个数据 8 | * 9 | * @author attect 10 | * @date 2019-05-08 11 | */ 12 | abstract class StringDataObserver(private val webSocketServiceViewModel: WebSocketServiceViewModel) : 13 | Observer { 14 | 15 | final override fun onChanged(t: String?) { 16 | webSocketServiceViewModel.requireNextStringData() 17 | t?.let { 18 | onReceive(it) 19 | } 20 | } 21 | 22 | abstract fun onReceive(t: String) 23 | 24 | } -------------------------------------------------------------------------------- /websocketservice/src/main/java/studio/attect/websocketservice/WebSocketHandshakeHeader.kt: -------------------------------------------------------------------------------- 1 | package studio.attect.websocketservice 2 | 3 | import android.os.Parcel 4 | import android.os.Parcelable 5 | 6 | data class WebSocketHandshakeHeader(val key: String, val value: String):Parcelable { 7 | constructor(parcel: Parcel) : this( 8 | parcel.readString().toString(), 9 | parcel.readString().toString() 10 | ) 11 | 12 | override fun writeToParcel(parcel: Parcel, flags: Int) { 13 | parcel.writeString(key) 14 | parcel.writeString(value) 15 | } 16 | 17 | override fun describeContents(): Int { 18 | return 0 19 | } 20 | 21 | companion object CREATOR : Parcelable.Creator { 22 | override fun createFromParcel(parcel: Parcel): WebSocketHandshakeHeader { 23 | return WebSocketHandshakeHeader(parcel) 24 | } 25 | 26 | override fun newArray(size: Int): Array { 27 | return arrayOfNulls(size) 28 | } 29 | } 30 | } -------------------------------------------------------------------------------- /websocketservice/src/main/java/studio/attect/websocketservice/WebSocketService.kt: -------------------------------------------------------------------------------- 1 | package studio.attect.websocketservice 2 | 3 | 4 | import android.app.Notification 5 | import android.app.Service 6 | import android.content.Context 7 | import android.content.Intent 8 | import android.os.IBinder 9 | import android.util.Log 10 | import androidx.lifecycle.Observer 11 | import okhttp3.* 12 | import okio.ByteString 13 | import studio.attect.staticviewmodelstore.StaticViewModelLifecycleService 14 | import studio.attect.staticviewmodelstore.StaticViewModelStore 15 | import java.util.* 16 | import java.util.concurrent.TimeUnit 17 | 18 | 19 | /** 20 | * WebSocketService 21 | * 是一个LocalService 22 | * 只支持启动一次,支持绑定前台通知 23 | * 通过ViewModel进行发送/接收数据传递 24 | * 25 | * @author attect 26 | * @date 2019-05-04 27 | */ 28 | open class WebSocketService : StaticViewModelLifecycleService() { 29 | lateinit var serviceViewModel: WebSocketServiceViewModel 30 | private set 31 | 32 | private var webSocket: WebSocket? = null 33 | 34 | private var webSocketListener = MyWebSocketListener() 35 | 36 | private var notificationId: Int = Int.MIN_VALUE 37 | 38 | private val handshakeHeaders: ArrayList = arrayListOf() 39 | 40 | private var pingInterval: Long = 0L 41 | 42 | private val stringDataQueue = LinkedList() 43 | 44 | private val bytesDataQueue = LinkedList() 45 | 46 | override fun onCreate() { 47 | super.onCreate() 48 | if (instanceCreateLock) return 49 | instanceCreateLock = true 50 | getViewModel(this)?.let { 51 | serviceViewModel = it 52 | } 53 | serviceViewModel.sendStringData.observe(this, Observer { content -> 54 | content?.let { 55 | webSocket?.send(it) 56 | } 57 | }) 58 | serviceViewModel.sendBytesData.observe(this, Observer { content -> 59 | content?.let { 60 | webSocket?.send(it) 61 | } 62 | }) 63 | serviceViewModel.stopByUser.observe(this, Observer { 64 | if (it) { 65 | Log.d(TAG, "stop signal from user") 66 | disconnectFromServer() 67 | } 68 | }) 69 | serviceViewModel.receiveStringData.observe(this, Observer { content -> 70 | if (content == null && stringDataQueue.size > 0) { 71 | serviceViewModel.receiveStringData.value = stringDataQueue.poll() 72 | } 73 | }) 74 | serviceViewModel.receiveBytesData.observe(this, Observer { content -> 75 | if (content == null && bytesDataQueue.size > 0) { 76 | serviceViewModel.receiveBytesData.value = bytesDataQueue.poll() 77 | } 78 | }) 79 | } 80 | 81 | 82 | override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int { 83 | if (intent == null) return START_NOT_STICKY 84 | val defaultResult = super.onStartCommand(intent, Service.START_FLAG_REDELIVERY, startId) 85 | //这个方法全局只允许被跑一次 86 | if (instanceCommandLock) return defaultResult 87 | instanceCommandLock = true 88 | 89 | //获得服务器地址 90 | if (intent.hasExtra(CONFIG_SERVER)) { 91 | if (intent.hasExtra(CONFIG_HANDSHAKE_HEADERS)) { 92 | handshakeHeaders.clear() 93 | intent.getParcelableArrayListExtra( 94 | CONFIG_HANDSHAKE_HEADERS 95 | )?.let { 96 | handshakeHeaders.addAll(it) 97 | } 98 | 99 | } 100 | if (intent.hasExtra(CONFIG_PING_INTERVAL)) pingInterval = intent.getLongExtra( 101 | CONFIG_PING_INTERVAL, 0L 102 | ) 103 | serverUrl = intent.getStringExtra(CONFIG_SERVER) 104 | connectToServer() 105 | } else { 106 | return defaultResult //没有设定服务器地址 107 | } 108 | //如果需要创建前台服务 109 | if (intent.hasExtra(CONFIG_CREATE_NOTIFICATION_ID) && intent.hasExtra( 110 | CONFIG_CREATE_NOTIFICATION 111 | ) 112 | ) { 113 | notificationId = intent.getIntExtra(CONFIG_CREATE_NOTIFICATION_ID, Int.MIN_VALUE) 114 | notificationId.let { 115 | if (it > Int.MIN_VALUE) { 116 | startForeground( 117 | it, intent.getParcelableExtra( 118 | CONFIG_CREATE_NOTIFICATION 119 | ) 120 | ) 121 | } 122 | } 123 | } 124 | 125 | 126 | return defaultResult 127 | } 128 | 129 | override fun onBind(intent: Intent): IBinder? { 130 | super.onBind(intent) 131 | return null 132 | } 133 | 134 | override fun onDestroy() { 135 | super.onDestroy() 136 | instanceCreateLock = false 137 | instanceCommandLock = false 138 | 139 | //清除viewModel中的发送数据 140 | serviceViewModel.sendStringData.value = null 141 | serviceViewModel.sendBytesData.value = null 142 | serviceViewModel.status.value = null 143 | } 144 | 145 | /** 146 | * 连接到服务器 147 | * 同时重置用户手动停止状态 148 | */ 149 | private fun connectToServer() { 150 | if (serviceViewModel.stopByUser.value == true) { 151 | serviceViewModel.stopByUser.postValue(false) 152 | } 153 | 154 | serverUrl?.let { 155 | serviceViewModel.status.postValue(WebSocketStatus.CONNECTING) 156 | val okHttpBuilder = OkHttpClient.Builder() 157 | okHttpBuilder.pingInterval(pingInterval, TimeUnit.MILLISECONDS) 158 | okHttpBuilder.retryOnConnectionFailure(true) 159 | okHttpBuilder.connectTimeout(5000, TimeUnit.MILLISECONDS) 160 | val requestBuilder = Request.Builder().url(it) 161 | handshakeHeaders.forEach { 162 | requestBuilder.addHeader(it.key, it.value) 163 | } 164 | webSocket = 165 | okHttpBuilder.build().newWebSocket(requestBuilder.build(), webSocketListener) 166 | return 167 | } 168 | throw IllegalStateException("Can't connect to WebSocket server because serverUrl is null") 169 | } 170 | 171 | private fun reconnectToServer(webSocket: WebSocket) { 172 | webSocket.cancel() 173 | //自动重连 174 | if (serviceViewModel.stopByUser.value != true) { 175 | serviceViewModel.status.postValue(WebSocketStatus.RECONNECTING) 176 | Log.w(TAG, "disconnect by environment, auto reconnect after 5000ms") 177 | Thread { 178 | try { 179 | Thread.sleep(5000) 180 | if (serviceViewModel.stopByUser.value != true) connectToServer() 181 | } catch (e: InterruptedException) { 182 | e.printStackTrace() 183 | } 184 | }.start() 185 | } 186 | } 187 | 188 | private fun disconnectFromServer() { 189 | if (serviceViewModel.status.value != WebSocketStatus.CONNECTED) return 190 | webSocket?.close(RFC6455CloseState.CLOSE_STATE_NORMAL.value, "disconnect by user") 191 | } 192 | 193 | inner class MyWebSocketListener : WebSocketListener() { 194 | override fun onOpen(webSocket: WebSocket, response: Response) { 195 | super.onOpen(webSocket, response) 196 | serviceViewModel.status.postValue(WebSocketStatus.CONNECTED) 197 | Log.d(TAG, "WebSocket connected") 198 | if (serviceViewModel.stopByUser.value == true) disconnectFromServer() //针对从CONNECTING状态切换过来 199 | } 200 | 201 | override fun onFailure(webSocket: WebSocket, t: Throwable, response: Response?) { 202 | super.onFailure(webSocket, t, response) 203 | serviceViewModel.status.postValue(WebSocketStatus.DISCONNECTED) 204 | Log.d(TAG, "WebSocket onFailure") 205 | t.printStackTrace() 206 | reconnectToServer(webSocket) 207 | } 208 | 209 | override fun onClosing(webSocket: WebSocket, code: Int, reason: String) { 210 | super.onClosing(webSocket, code, reason) 211 | serviceViewModel.status.postValue(WebSocketStatus.CLOSING) 212 | Log.e(TAG, "WebSocket is closing, because [$code]$reason ") 213 | if (serviceViewModel.stopByUser.value == true) { 214 | stopSelf() 215 | } 216 | } 217 | 218 | override fun onMessage(webSocket: WebSocket, text: String) { 219 | super.onMessage(webSocket, text) 220 | Log.d(TAG, "WebSocket onStringMessage:$text") 221 | stringDataQueue.offer(text) 222 | serviceViewModel.receiveStringData.postValue(null) //触发队列,必须直接post null 223 | } 224 | 225 | override fun onMessage(webSocket: WebSocket, bytes: ByteString) { 226 | super.onMessage(webSocket, bytes) 227 | Log.d(TAG, "WebSocket onHexMessage:${bytes.size}") 228 | bytesDataQueue.offer(bytes) 229 | serviceViewModel.receiveBytesData.postValue(null) //触发队列,必须直接post null 230 | } 231 | 232 | override fun onClosed(webSocket: WebSocket, code: Int, reason: String) { 233 | super.onClosed(webSocket, code, reason) 234 | serviceViewModel.status.postValue(WebSocketStatus.DISCONNECTED) 235 | Log.e(TAG, "WebSocket is closed, because [$code]$reason ") 236 | if (serviceViewModel.stopByUser.value == true) { 237 | webSocket.cancel() 238 | stopSelf() 239 | } 240 | } 241 | } 242 | 243 | companion object { 244 | private const val TAG = "WSS" 245 | 246 | /** 247 | * 在StaticViewModelStore中对应注册的key 248 | */ 249 | private const val WEB_SOCKET_STATIC_VIEW_STORE_KEY = "[ws]" 250 | 251 | /** 252 | * 服务器地址的key 253 | */ 254 | const val CONFIG_SERVER = "serverAddress" 255 | 256 | /** 257 | * 握手请求头参数 258 | */ 259 | const val CONFIG_HANDSHAKE_HEADERS = "handshakeHeaders" 260 | 261 | /** 262 | * 伴随一个通知启动 263 | * 通知id的key 264 | */ 265 | const val CONFIG_CREATE_NOTIFICATION_ID = "withNotification_ID" 266 | 267 | /** 268 | * 伴随一个通知启动 269 | * notification的key 270 | */ 271 | const val CONFIG_CREATE_NOTIFICATION = "withNotification" 272 | 273 | /** 274 | * 心跳频率 275 | * 单位毫秒 276 | */ 277 | const val CONFIG_PING_INTERVAL = "pingInterval" 278 | 279 | /** 280 | * onCreate方法锁 281 | * true时表示已经有一个逻辑实例在跑了 282 | */ 283 | private var instanceCreateLock = false 284 | 285 | /** 286 | * onStartCommand方法锁 287 | * 创建Service的方法始终会导致startCommand方法的调用,因此需要一个针对内部逻辑的锁 288 | * true时表示已经有一个逻辑实例在跑了 289 | */ 290 | private var instanceCommandLock = false 291 | 292 | /** 293 | * 服务器地址 294 | */ 295 | private var serverUrl: String? = null 296 | 297 | 298 | /** 299 | * 启动服务 300 | * @param context App上下文 301 | * @param serverUrl 服务器地址 302 | * @param handshakeHeaders 握手HTTP自定义头部数据 303 | * @param pingInterval 心跳ping间隔,0禁用,单位毫秒 304 | */ 305 | @JvmOverloads 306 | fun startService( 307 | context: Context, 308 | serverUrl: String, 309 | handshakeHeaders: ArrayList = arrayListOf(), 310 | pingInterval: Long = 0L 311 | ) { 312 | context.startService(Intent(context, WebSocketService::class.java).apply { 313 | putExtra(CONFIG_SERVER, serverUrl) 314 | putParcelableArrayListExtra(CONFIG_HANDSHAKE_HEADERS, handshakeHeaders) 315 | putExtra(CONFIG_PING_INTERVAL, pingInterval) 316 | }) 317 | } 318 | 319 | /** 320 | * 启动服务,同时绑定到一个前台通知 321 | * @param context App上下文 322 | * @param serverUrl 服务器地址 323 | * @param notificationId 已经创建的通知的id 324 | * @param notification 已经创建的通知本体 325 | * @param handshakeHeaders 握手HTTP自定义头部数据 326 | * @param pingInterval 心跳ping间隔,0禁用,单位毫秒 327 | */ 328 | @JvmOverloads 329 | fun startService( 330 | context: Context, 331 | serverUrl: String, 332 | notificationId: Int, 333 | notification: Notification, 334 | handshakeHeaders: ArrayList = arrayListOf(), 335 | pingInterval: Long = 0L 336 | ) { 337 | context.startService(Intent(context, WebSocketService::class.java).apply { 338 | putExtra(CONFIG_SERVER, serverUrl) 339 | putExtra(CONFIG_CREATE_NOTIFICATION_ID, notificationId) 340 | putExtra(CONFIG_CREATE_NOTIFICATION, notification) 341 | putParcelableArrayListExtra(CONFIG_HANDSHAKE_HEADERS, handshakeHeaders) 342 | putExtra(CONFIG_PING_INTERVAL, pingInterval) 343 | }) 344 | } 345 | 346 | @JvmStatic 347 | fun getViewModel(caller: StaticViewModelStore.StaticViewModelStoreCaller): WebSocketServiceViewModel? { 348 | return caller.getStaticViewModel( 349 | WEB_SOCKET_STATIC_VIEW_STORE_KEY, 350 | WebSocketServiceViewModel::class.java 351 | ) 352 | } 353 | 354 | } 355 | } -------------------------------------------------------------------------------- /websocketservice/src/main/java/studio/attect/websocketservice/WebSocketServiceViewModel.kt: -------------------------------------------------------------------------------- 1 | package studio.attect.websocketservice 2 | 3 | import androidx.lifecycle.MutableLiveData 4 | import androidx.lifecycle.ViewModel 5 | import okio.ByteString 6 | 7 | /** 8 | * WebSocketService与其它接口进行数据交换的ViewModel 9 | * 10 | * @author attect 11 | * @date 2019-05-04 12 | */ 13 | class WebSocketServiceViewModel :ViewModel(){ 14 | /** 15 | * 接收到的字符串数据 16 | */ 17 | val receiveStringData = MutableLiveData() 18 | 19 | /** 20 | * 接收到的二进制数据 21 | */ 22 | val receiveBytesData = MutableLiveData() 23 | 24 | /** 25 | * 要发送的字符串数据 26 | */ 27 | val sendStringData = MutableLiveData() 28 | 29 | /** 30 | * 要发送的二进制数据 31 | */ 32 | val sendBytesData = MutableLiveData() 33 | 34 | 35 | /** 36 | * 给服务断开连接的信号 37 | * 注意不要将其内容设为null 38 | */ 39 | val stopByUser = MutableLiveData() 40 | 41 | /** 42 | * 服务状态 43 | * 模糊的 44 | * 取值为null时,表示service没有启动 45 | */ 46 | val status = MutableLiveData() 47 | 48 | /** 49 | * 外部使用时请求获取下一个文本数据 50 | * @see StringDataObserver.onChanged 51 | */ 52 | internal fun requireNextStringData(){ 53 | receiveStringData.value?.let { 54 | receiveStringData.postValue(null) 55 | } 56 | } 57 | 58 | /** 59 | * 外部使用时请求获取下一个二进制数据 60 | * @see BytesDataObserver.onChanged 61 | */ 62 | internal fun requireNextBytesData(){ 63 | receiveBytesData.value?.let { 64 | receiveBytesData.postValue(null) 65 | } 66 | } 67 | 68 | } -------------------------------------------------------------------------------- /websocketservice/src/main/java/studio/attect/websocketservice/WebSocketStatus.kt: -------------------------------------------------------------------------------- 1 | package studio.attect.websocketservice 2 | 3 | /** 4 | * WebSocketService的状态 5 | * 模糊的 6 | * 包含实际连接的状态,和一些逻辑状态 7 | * 8 | * 注意它们的切换可能是跳跃的 9 | * 10 | * @author attect 11 | * @date 2019-05-04 12 | */ 13 | enum class WebSocketStatus { 14 | 15 | /** 16 | * 已断开连接 17 | * 刚启动或网络错误以及重连尝试间的状态 18 | */ 19 | DISCONNECTED, 20 | 21 | /** 22 | * 正在连接 23 | */ 24 | CONNECTING, 25 | 26 | /** 27 | * 正在等待重新连接 28 | */ 29 | RECONNECTING, 30 | 31 | /** 32 | * 已经连接,通信正常 33 | */ 34 | CONNECTED, 35 | 36 | /** 37 | * 正在关闭连接 38 | */ 39 | CLOSING 40 | } -------------------------------------------------------------------------------- /websocketservice/src/main/res/values/strings.xml: -------------------------------------------------------------------------------- 1 | 2 | websocketservice 3 | 4 | -------------------------------------------------------------------------------- /websocketservice/src/test/java/studio/attect/websocketservice/ExampleUnitTest.java: -------------------------------------------------------------------------------- 1 | package studio.attect.websocketservice; 2 | 3 | import org.junit.Test; 4 | 5 | import static org.junit.Assert.*; 6 | 7 | /** 8 | * Example local unit test, which will execute on the development machine (host). 9 | * 10 | * @see Testing documentation 11 | */ 12 | public class ExampleUnitTest { 13 | @Test 14 | public void addition_isCorrect() { 15 | assertEquals(4, 2 + 2); 16 | } 17 | } --------------------------------------------------------------------------------