a.0.1.0 move to creates src
This commit is contained in:
1 parent
86d3a3b47f
commit
f22d954105
942 files changed
+275635
-225
No files matched your search
@@ -0,0 +1,48 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
|
||||
<uses-permission android:name="android.permission.INTERNET" />
|
||||
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
|
||||
<uses-permission android:name="android.permission.ACCESS_WIFI_STATE" />
|
||||
<uses-permission android:name="android.permission.CHANGE_WIFI_MULTICAST_STATE" />
|
||||
<uses-permission android:name="android.permission.WAKE_LOCK" />
|
||||
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
|
||||
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_DATA_SYNC" />
|
||||
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
|
||||
|
||||
<application
|
||||
android:allowBackup="false"
|
||||
android:label="@string/app_name"
|
||||
android:supportsRtl="true"
|
||||
android:usesCleartextTraffic="true"
|
||||
android:theme="@style/Theme.RamShare">
|
||||
|
||||
<activity
|
||||
android:name=".MainActivity"
|
||||
android:exported="true"
|
||||
android:configChanges="orientation|screenSize|keyboardHidden"
|
||||
android:windowSoftInputMode="adjustResize">
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.MAIN" />
|
||||
<category android:name="android.intent.category.LAUNCHER" />
|
||||
</intent-filter>
|
||||
</activity>
|
||||
|
||||
<service
|
||||
android:name=".RamshareService"
|
||||
android:enabled="true"
|
||||
android:exported="false"
|
||||
android:foregroundServiceType="dataSync" />
|
||||
|
||||
<provider
|
||||
android:name="androidx.core.content.FileProvider"
|
||||
android:authorities="${applicationId}.fileprovider"
|
||||
android:exported="false"
|
||||
android:grantUriPermissions="true">
|
||||
<meta-data
|
||||
android:name="android.support.FILE_PROVIDER_PATHS"
|
||||
android:resource="@xml/file_paths" />
|
||||
</provider>
|
||||
|
||||
</application>
|
||||
</manifest>
|
||||
@@ -0,0 +1,290 @@
|
||||
package com.ramshare.app
|
||||
|
||||
import android.content.ContentResolver
|
||||
import android.net.Uri
|
||||
import android.provider.OpenableColumns
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.withContext
|
||||
import org.json.JSONArray
|
||||
import org.json.JSONObject
|
||||
import java.io.BufferedReader
|
||||
import java.io.InputStreamReader
|
||||
import java.net.HttpURLConnection
|
||||
import java.net.URL
|
||||
import java.net.URLEncoder
|
||||
|
||||
data class PeerDto(
|
||||
val id: String,
|
||||
val name: String,
|
||||
val ips: List<String>,
|
||||
val port: Int,
|
||||
val fingerprint: String,
|
||||
val pinCode: String,
|
||||
val verified: Boolean,
|
||||
val trustsMe: Boolean,
|
||||
val online: Boolean,
|
||||
val lastSeen: Long,
|
||||
val lastPing: Long,
|
||||
val failCount: Int
|
||||
)
|
||||
|
||||
data class TextDto(
|
||||
val sender: String,
|
||||
val text: String,
|
||||
val timestamp: Long
|
||||
)
|
||||
|
||||
data class MediaDto(
|
||||
val sender: String,
|
||||
val filename: String,
|
||||
val savedPath: String,
|
||||
val mime: String,
|
||||
val size: Long,
|
||||
val timestamp: Long,
|
||||
val previewable: Boolean,
|
||||
val risky: Boolean
|
||||
)
|
||||
|
||||
data class TransferDto(
|
||||
val id: String,
|
||||
val filename: String,
|
||||
val peerName: String,
|
||||
val direction: String,
|
||||
val transferredBytes: Long,
|
||||
val totalBytes: Long,
|
||||
val percent: Int,
|
||||
val done: Boolean,
|
||||
val error: String?
|
||||
)
|
||||
|
||||
data class ScanDto(
|
||||
val active: Boolean,
|
||||
val range: String,
|
||||
val scanned: Int,
|
||||
val total: Int,
|
||||
val found: Int
|
||||
)
|
||||
|
||||
data class NodeStateDto(
|
||||
val deviceName: String = "Загрузка...",
|
||||
val myIp: String = "127.0.0.1",
|
||||
val p2pPort: Int = 8443,
|
||||
val myFingerprint: String = "",
|
||||
val myPin: String = "--------",
|
||||
val mediaDir: String = "",
|
||||
val pingIntervalSec: Int = 15,
|
||||
val lastPing: Long = 0L,
|
||||
val maxPingFails: Int = 5,
|
||||
val now: Long = 0L,
|
||||
val peers: List<PeerDto> = emptyList(),
|
||||
val texts: List<TextDto> = emptyList(),
|
||||
val media: List<MediaDto> = emptyList(),
|
||||
val transfers: List<TransferDto> = emptyList(),
|
||||
val scan: ScanDto = ScanDto(false, "", 0, 0, 0)
|
||||
)
|
||||
|
||||
object ApiClient {
|
||||
private const val BASE_HOST = "127.0.0.1:${RamshareService.UI_PORT}"
|
||||
private const val BASE_URL = "http://$BASE_HOST"
|
||||
|
||||
private fun openConn(path: String, method: String, timeoutMs: Int = 5000): HttpURLConnection {
|
||||
val conn = (URL("$BASE_URL$path").openConnection() as HttpURLConnection)
|
||||
conn.requestMethod = method
|
||||
conn.connectTimeout = timeoutMs
|
||||
conn.readTimeout = timeoutMs
|
||||
conn.setRequestProperty("Host", BASE_HOST)
|
||||
conn.setRequestProperty("Origin", BASE_URL)
|
||||
return conn
|
||||
}
|
||||
|
||||
private fun readError(conn: HttpURLConnection): String {
|
||||
val stream = conn.errorStream ?: return "Ошибка ${conn.responseCode}"
|
||||
return BufferedReader(InputStreamReader(stream, Charsets.UTF_8)).use { it.readText() }
|
||||
.ifBlank { "Ошибка ${conn.responseCode}" }
|
||||
}
|
||||
|
||||
suspend fun fetchState(): NodeStateDto = withContext(Dispatchers.IO) {
|
||||
val conn = openConn("/ui/state", "GET", 3000)
|
||||
try {
|
||||
if (conn.responseCode !in 200..299) throw Exception(readError(conn))
|
||||
val text = BufferedReader(InputStreamReader(conn.inputStream, Charsets.UTF_8)).use { it.readText() }
|
||||
val j = JSONObject(text)
|
||||
|
||||
val peersArr = j.optJSONArray("peers") ?: JSONArray()
|
||||
val peers = (0 until peersArr.length()).map { i ->
|
||||
val p = peersArr.getJSONObject(i)
|
||||
val ipsArr = p.optJSONArray("ips") ?: JSONArray()
|
||||
val ips = (0 until ipsArr.length()).map { k -> ipsArr.getString(k) }
|
||||
PeerDto(
|
||||
id = p.optString("id"),
|
||||
name = p.optString("name", "Unknown"),
|
||||
ips = ips,
|
||||
port = p.optInt("port", 8443),
|
||||
fingerprint = p.optString("fingerprint"),
|
||||
pinCode = p.optString("pin_code"),
|
||||
verified = p.optBoolean("verified"),
|
||||
trustsMe = p.optBoolean("trusts_me"),
|
||||
online = p.optBoolean("online"),
|
||||
lastSeen = p.optLong("last_seen", 0L),
|
||||
lastPing = p.optLong("last_ping", 0L),
|
||||
failCount = p.optInt("fail_count", 0)
|
||||
)
|
||||
}
|
||||
|
||||
val textsArr = j.optJSONArray("texts") ?: JSONArray()
|
||||
val texts = (0 until textsArr.length()).map { i ->
|
||||
val t = textsArr.getJSONObject(i)
|
||||
TextDto(
|
||||
sender = t.optString("sender"),
|
||||
text = t.optString("text"),
|
||||
timestamp = t.optLong("timestamp")
|
||||
)
|
||||
}
|
||||
|
||||
val mediaArr = j.optJSONArray("media") ?: JSONArray()
|
||||
val media = (0 until mediaArr.length()).map { i ->
|
||||
val m = mediaArr.getJSONObject(i)
|
||||
MediaDto(
|
||||
sender = m.optString("sender"),
|
||||
filename = m.optString("filename"),
|
||||
savedPath = m.optString("saved_path"),
|
||||
mime = m.optString("mime"),
|
||||
size = m.optLong("size"),
|
||||
timestamp = m.optLong("timestamp"),
|
||||
previewable = m.optBoolean("previewable"),
|
||||
risky = m.optBoolean("risky")
|
||||
)
|
||||
}
|
||||
|
||||
val trArr = j.optJSONArray("transfers") ?: JSONArray()
|
||||
val transfers = (0 until trArr.length()).map { i ->
|
||||
val tr = trArr.getJSONObject(i)
|
||||
val err = if (tr.isNull("error")) null else tr.optString("error")
|
||||
TransferDto(
|
||||
id = tr.optString("id"),
|
||||
filename = tr.optString("filename"),
|
||||
peerName = tr.optString("peer_name"),
|
||||
direction = tr.optString("direction"),
|
||||
transferredBytes = tr.optLong("transferred_bytes"),
|
||||
totalBytes = tr.optLong("total_bytes"),
|
||||
percent = tr.optInt("percent"),
|
||||
done = tr.optBoolean("done"),
|
||||
error = err
|
||||
)
|
||||
}
|
||||
|
||||
val sc = j.optJSONObject("scan") ?: JSONObject()
|
||||
val scan = ScanDto(
|
||||
active = sc.optBoolean("active"),
|
||||
range = sc.optString("range"),
|
||||
scanned = sc.optInt("scanned"),
|
||||
total = sc.optInt("total"),
|
||||
found = sc.optInt("found")
|
||||
)
|
||||
|
||||
NodeStateDto(
|
||||
deviceName = j.optString("device_name"),
|
||||
myIp = j.optString("my_ip"),
|
||||
p2pPort = j.optInt("p2p_port", 8443),
|
||||
myFingerprint = j.optString("my_fingerprint"),
|
||||
myPin = j.optString("my_pin"),
|
||||
mediaDir = j.optString("media_dir"),
|
||||
pingIntervalSec = j.optInt("ping_interval_sec", 15),
|
||||
lastPing = j.optLong("last_ping", 0L),
|
||||
maxPingFails = j.optInt("max_ping_fails", 5),
|
||||
now = j.optLong("now", System.currentTimeMillis() / 1000L),
|
||||
peers = peers,
|
||||
texts = texts,
|
||||
media = media,
|
||||
transfers = transfers,
|
||||
scan = scan
|
||||
)
|
||||
} finally {
|
||||
conn.disconnect()
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun postJson(path: String, body: JSONObject, timeoutMs: Int = 15000): String =
|
||||
withContext(Dispatchers.IO) {
|
||||
val conn = openConn(path, "POST", timeoutMs)
|
||||
try {
|
||||
val bytes = body.toString().toByteArray(Charsets.UTF_8)
|
||||
conn.doOutput = true
|
||||
conn.setRequestProperty("Content-Type", "application/json")
|
||||
conn.setFixedLengthStreamingMode(bytes.size)
|
||||
conn.outputStream.use { it.write(bytes) }
|
||||
if (conn.responseCode !in 200..299) throw Exception(readError(conn))
|
||||
BufferedReader(InputStreamReader(conn.inputStream, Charsets.UTF_8)).use { it.readText() }
|
||||
} finally {
|
||||
conn.disconnect()
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun postEmpty(path: String, timeoutMs: Int = 15000) = withContext(Dispatchers.IO) {
|
||||
val conn = openConn(path, "POST", timeoutMs)
|
||||
try {
|
||||
conn.doOutput = true
|
||||
conn.setFixedLengthStreamingMode(0)
|
||||
conn.outputStream.close()
|
||||
if (conn.responseCode !in 200..299) throw Exception(readError(conn))
|
||||
} finally {
|
||||
conn.disconnect()
|
||||
}
|
||||
}
|
||||
|
||||
/** Потоковая отправка файла из ContentResolver напрямую в сокет с O(1) потреблением ОЗУ. */
|
||||
suspend fun sendFileStream(resolver: ContentResolver, peerId: String, uri: Uri): String =
|
||||
withContext(Dispatchers.IO) {
|
||||
var displayName = "file.bin"
|
||||
var fileSize = -1L
|
||||
|
||||
resolver.query(uri, arrayOf(OpenableColumns.DISPLAY_NAME, OpenableColumns.SIZE), null, null, null)
|
||||
?.use { cursor ->
|
||||
if (cursor.moveToFirst()) {
|
||||
val nameIdx = cursor.getColumnIndex(OpenableColumns.DISPLAY_NAME)
|
||||
val sizeIdx = cursor.getColumnIndex(OpenableColumns.SIZE)
|
||||
if (nameIdx >= 0 && !cursor.isNull(nameIdx)) {
|
||||
displayName = cursor.getString(nameIdx)
|
||||
}
|
||||
if (sizeIdx >= 0 && !cursor.isNull(sizeIdx)) {
|
||||
fileSize = cursor.getLong(sizeIdx)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (fileSize <= 0L) {
|
||||
resolver.openFileDescriptor(uri, "r")?.use { pfd ->
|
||||
fileSize = pfd.statSize
|
||||
}
|
||||
}
|
||||
if (fileSize <= 0L) throw Exception("Не удалось определить размер файла (или файл пуст)")
|
||||
|
||||
val encodedName = URLEncoder.encode(displayName, "UTF-8").replace("+", "%20")
|
||||
val conn = openConn("/ui/send-media", "POST", 0)
|
||||
try {
|
||||
conn.doOutput = true
|
||||
conn.setRequestProperty("X-Target-Id", peerId)
|
||||
conn.setRequestProperty("X-Filename", encodedName)
|
||||
conn.setRequestProperty("Content-Type", "application/octet-stream")
|
||||
conn.setFixedLengthStreamingMode(fileSize)
|
||||
|
||||
val input = resolver.openInputStream(uri)
|
||||
?: throw Exception("Не удалось открыть выбранный файл")
|
||||
input.use { ins ->
|
||||
conn.outputStream.use { outs ->
|
||||
val buf = ByteArray(64 * 1024)
|
||||
while (true) {
|
||||
val r = ins.read(buf)
|
||||
if (r <= 0) break
|
||||
outs.write(buf, 0, r)
|
||||
}
|
||||
outs.flush()
|
||||
}
|
||||
}
|
||||
if (conn.responseCode !in 200..299) throw Exception(readError(conn))
|
||||
displayName
|
||||
} finally {
|
||||
conn.disconnect()
|
||||
}
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,16 @@
|
||||
package com.ramshare.app
|
||||
|
||||
object RamshareNative {
|
||||
init {
|
||||
System.loadLibrary("ramshare_ui")
|
||||
}
|
||||
|
||||
external fun startServer(
|
||||
baseDir: String,
|
||||
deviceName: String,
|
||||
p2pPort: Int,
|
||||
uiPort: Int
|
||||
): Boolean
|
||||
|
||||
external fun stopServer()
|
||||
}
|
||||
@@ -0,0 +1,173 @@
|
||||
package com.ramshare.app
|
||||
|
||||
import android.app.Notification
|
||||
import android.app.NotificationChannel
|
||||
import android.app.NotificationManager
|
||||
import android.app.PendingIntent
|
||||
import android.app.Service
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.content.pm.ServiceInfo
|
||||
import android.net.wifi.WifiManager
|
||||
import android.os.Build
|
||||
import android.os.IBinder
|
||||
import android.os.PowerManager
|
||||
|
||||
class RamshareService : Service() {
|
||||
|
||||
companion object {
|
||||
const val ACTION_START = "com.ramshare.app.START"
|
||||
const val ACTION_STOP = "com.ramshare.app.STOP"
|
||||
const val CHANNEL_ID = "ramshare_node_channel"
|
||||
const val NOTIF_ID = 1001
|
||||
const val P2P_PORT = 8443
|
||||
const val UI_PORT = 8080
|
||||
}
|
||||
|
||||
private var multicastLock: WifiManager.MulticastLock? = null
|
||||
private var wifiLock: WifiManager.WifiLock? = null
|
||||
private var wakeLock: PowerManager.WakeLock? = null
|
||||
|
||||
override fun onCreate() {
|
||||
super.onCreate()
|
||||
createNotificationChannel()
|
||||
}
|
||||
|
||||
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
|
||||
if (intent?.action == ACTION_STOP) {
|
||||
stopNodeAndSelf()
|
||||
return START_NOT_STICKY
|
||||
}
|
||||
|
||||
val notification = buildNotification()
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
|
||||
startForeground(
|
||||
NOTIF_ID,
|
||||
notification,
|
||||
ServiceInfo.FOREGROUND_SERVICE_TYPE_DATA_SYNC
|
||||
)
|
||||
} else {
|
||||
startForeground(NOTIF_ID, notification)
|
||||
}
|
||||
|
||||
acquireLocks()
|
||||
|
||||
val model = (Build.MANUFACTURER + "-" + Build.MODEL)
|
||||
.replace(Regex("[^a-zA-Z0-9_\\- ]"), "")
|
||||
.trim()
|
||||
.ifEmpty { "Android-Node" }
|
||||
|
||||
Thread {
|
||||
RamshareNative.startServer(
|
||||
baseDir = filesDir.absolutePath,
|
||||
deviceName = model,
|
||||
p2pPort = P2P_PORT,
|
||||
uiPort = UI_PORT
|
||||
)
|
||||
}.start()
|
||||
|
||||
return START_STICKY
|
||||
}
|
||||
|
||||
private fun acquireLocks() {
|
||||
val wifi = applicationContext.getSystemService(Context.WIFI_SERVICE) as? WifiManager
|
||||
if (multicastLock == null) {
|
||||
multicastLock = wifi?.createMulticastLock("ramshare:multicast")?.apply {
|
||||
setReferenceCounted(false)
|
||||
acquire()
|
||||
}
|
||||
}
|
||||
if (wifiLock == null) {
|
||||
val mode = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
|
||||
WifiManager.WIFI_MODE_FULL_LOW_LATENCY
|
||||
} else {
|
||||
@Suppress("DEPRECATION")
|
||||
WifiManager.WIFI_MODE_FULL_HIGH_PERF
|
||||
}
|
||||
wifiLock = wifi?.createWifiLock(mode, "ramshare:wifilock")?.apply {
|
||||
setReferenceCounted(false)
|
||||
acquire()
|
||||
}
|
||||
}
|
||||
if (wakeLock == null) {
|
||||
val pm = getSystemService(Context.POWER_SERVICE) as? PowerManager
|
||||
wakeLock = pm?.newWakeLock(PowerManager.PARTIAL_WAKE_LOCK, "ramshare:wakelock")?.apply {
|
||||
setReferenceCounted(false)
|
||||
acquire()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun releaseLocks() {
|
||||
runCatching { if (multicastLock?.isHeld == true) multicastLock?.release() }
|
||||
runCatching { if (wifiLock?.isHeld == true) wifiLock?.release() }
|
||||
runCatching { if (wakeLock?.isHeld == true) wakeLock?.release() }
|
||||
multicastLock = null
|
||||
wifiLock = null
|
||||
wakeLock = null
|
||||
}
|
||||
|
||||
private fun stopNodeAndSelf() {
|
||||
RamshareNative.stopServer()
|
||||
releaseLocks()
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) {
|
||||
stopForeground(STOP_FOREGROUND_REMOVE)
|
||||
} else {
|
||||
@Suppress("DEPRECATION")
|
||||
stopForeground(true)
|
||||
}
|
||||
stopSelf()
|
||||
}
|
||||
|
||||
override fun onDestroy() {
|
||||
RamshareNative.stopServer()
|
||||
releaseLocks()
|
||||
super.onDestroy()
|
||||
}
|
||||
|
||||
override fun onBind(intent: Intent?): IBinder? = null
|
||||
|
||||
private fun createNotificationChannel() {
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
|
||||
val ch = NotificationChannel(
|
||||
CHANNEL_ID,
|
||||
"Узел LAN Share",
|
||||
NotificationManager.IMPORTANCE_LOW
|
||||
).apply {
|
||||
description = "Фоновая работа P2P-узла и приём файлов в локальной сети"
|
||||
}
|
||||
val nm = getSystemService(NotificationManager::class.java)
|
||||
nm?.createNotificationChannel(ch)
|
||||
}
|
||||
}
|
||||
|
||||
private fun buildNotification(): Notification {
|
||||
val openIntent = PendingIntent.getActivity(
|
||||
this,
|
||||
0,
|
||||
Intent(this, MainActivity::class.java),
|
||||
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE
|
||||
)
|
||||
val stopIntent = PendingIntent.getService(
|
||||
this,
|
||||
1,
|
||||
Intent(this, RamshareService::class.java).apply { action = ACTION_STOP },
|
||||
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE
|
||||
)
|
||||
|
||||
return Notification.Builder(this, CHANNEL_ID)
|
||||
.setContentTitle("LAN Share активен")
|
||||
.setContentText("P2P mTLS порт $P2P_PORT • Ожидание передач")
|
||||
.setSmallIcon(android.R.drawable.stat_sys_upload_done)
|
||||
.setContentIntent(openIntent)
|
||||
.setOngoing(true)
|
||||
.addAction(
|
||||
Notification.Action.Builder(
|
||||
null,
|
||||
"Остановить",
|
||||
stopIntent
|
||||
).build()
|
||||
)
|
||||
.build()
|
||||
}
|
||||
}
|
||||
Binary file not shown.
@@ -0,0 +1,3 @@
|
||||
<resources>
|
||||
<string name="app_name">LAN Share</string>
|
||||
</resources>
|
||||
@@ -0,0 +1,6 @@
|
||||
<resources>
|
||||
<style name="Theme.RamShare" parent="android:Theme.Material.NoActionBar">
|
||||
<item name="android:statusBarColor">#0F172A</item>
|
||||
<item name="android:navigationBarColor">#0F172A</item>
|
||||
</style>
|
||||
</resources>
|
||||
@@ -0,0 +1,4 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<paths>
|
||||
<files-path name="received_media" path="received_media/" />
|
||||
</paths>
|
||||
Reference in new issue
Block a user