Initial commit
This commit is contained in:
+35
@@ -0,0 +1,35 @@
|
|||||||
|
# Gradle
|
||||||
|
.gradle/
|
||||||
|
build/
|
||||||
|
**/build/
|
||||||
|
.kotlin/
|
||||||
|
|
||||||
|
# Android Studio / IntelliJ
|
||||||
|
.idea/
|
||||||
|
*.iml
|
||||||
|
*.iws
|
||||||
|
*.ipr
|
||||||
|
.navigation/
|
||||||
|
captures/
|
||||||
|
.externalNativeBuild/
|
||||||
|
.cxx/
|
||||||
|
|
||||||
|
# Local config (SDK path, etc.)
|
||||||
|
local.properties
|
||||||
|
|
||||||
|
# Build artifacts
|
||||||
|
*.apk
|
||||||
|
*.aab
|
||||||
|
*.ap_
|
||||||
|
*.dex
|
||||||
|
output-metadata.json
|
||||||
|
|
||||||
|
# Signing keys
|
||||||
|
*.jks
|
||||||
|
*.keystore
|
||||||
|
keystore.properties
|
||||||
|
|
||||||
|
# OS / misc
|
||||||
|
.DS_Store
|
||||||
|
Thumbs.db
|
||||||
|
*.log
|
||||||
@@ -0,0 +1,125 @@
|
|||||||
|
# wIRC
|
||||||
|
|
||||||
|
A native Android IRC client: mIRC colors, timestamps, OP powers (kick/ban/op),
|
||||||
|
Ctrl+K/B/I formatting shortcuts, saved servers with on-connect identify
|
||||||
|
commands, and every channel *and* private message opening as its own
|
||||||
|
independently-styled window (1px black border, adjustable background
|
||||||
|
transparency, black/white text toggle).
|
||||||
|
|
||||||
|
## What's built so far (MVP)
|
||||||
|
|
||||||
|
- Raw socket IRC connection (`irc/IrcConnection.kt`) with auto-reconnect/backoff,
|
||||||
|
optional TLS, auto-join on connect.
|
||||||
|
- Full mIRC control-code parser (`irc/MircColorParser.kt`) — colors, bold,
|
||||||
|
underline, reverse, reset — rendered as a Compose `AnnotatedString`.
|
||||||
|
- Protocol line parser (`irc/IrcLineParser.kt`) for PRIVMSG/JOIN/PART/KICK/MODE/
|
||||||
|
TOPIC/NOTICE/QUIT/NAMES.
|
||||||
|
- `ConnectionManager` — app-wide singleton so every open window reads from the
|
||||||
|
same shared state (messages, user list) regardless of which window is active.
|
||||||
|
- `IrcConnectionService` — foreground service that keeps the socket(s) alive
|
||||||
|
when the app is backgrounded (Android kills background network connections
|
||||||
|
aggressively without this).
|
||||||
|
- `ChannelScreen` — the actual window UI: scrollback with mIRC colors + per-line
|
||||||
|
timestamps, input bar, long-press a nick for a Kick/Ban/Op/De-op menu, and a
|
||||||
|
settings panel (gear icon) with the transparency slider and text color toggle.
|
||||||
|
- `ChannelWindowActivity` — one instance per channel. Uses
|
||||||
|
`documentLaunchMode="always"` + `resizeableActivity="true"` in the manifest
|
||||||
|
and `FLAG_ACTIVITY_NEW_DOCUMENT` on launch, which is the standard way an app
|
||||||
|
can offer real independent, draggable/resizable OS-level windows on devices
|
||||||
|
that support freeform multi-window (large tablets, Samsung DeX, etc).
|
||||||
|
- `MainActivity` / `MainScreen` — the main window. Lists saved servers as
|
||||||
|
cards (Connect / Edit / Delete), and per-connected-server a box to join +
|
||||||
|
open a channel window. "+ Add Server" opens `ServerEditDialog` with host,
|
||||||
|
port, TLS, nick, auto-join channels, and a multi-line "on-connect / identify
|
||||||
|
commands" field — e.g. `PRIVMSG NickServ :IDENTIFY hunter2` — sent
|
||||||
|
automatically right after registration on every connect, before auto-join.
|
||||||
|
- `ServerRepository` — persists the saved server list (including on-connect
|
||||||
|
commands) to `servers.json` in app-private storage, so it survives app
|
||||||
|
restarts. This is the "file that saves that information" you asked for.
|
||||||
|
- Private messages: `ConnectionManager` now keys PMs by the *other person's*
|
||||||
|
nick (not our own), and emits a `newQueryBuffers` event the first time a PM
|
||||||
|
from someone new arrives. `IrcConnectionService` listens for that and
|
||||||
|
auto-launches a `ChannelWindowActivity` for it — so a PM pops its own window
|
||||||
|
the same way tapping a channel does, no different from every other window
|
||||||
|
in the app.
|
||||||
|
- Ctrl+K / Ctrl+B / Ctrl+I: hardware-keyboard shortcuts wired into the input
|
||||||
|
field in `ChannelScreen.kt`. Ctrl+B/I insert the raw bold (`\u0002`) /
|
||||||
|
italic (`\u001D`) toggle control chars at the cursor. Ctrl+K opens a 16-swatch
|
||||||
|
mIRC color picker dialog (foreground, then optional background) and inserts
|
||||||
|
the real `\u0003FG,BG` wire code — same bytes mIRC/HexChat send.
|
||||||
|
|
||||||
|
## Known limitations / what's still rough
|
||||||
|
|
||||||
|
- **"Minimize" isn't an app feature** — on devices with freeform multi-window
|
||||||
|
(large tablets, Samsung DeX, etc.) the OS itself draws minimize/maximize/
|
||||||
|
close chrome around any `resizeableActivity="true"` window. Both
|
||||||
|
`MainActivity` and `ChannelWindowActivity` are marked resizable so they get
|
||||||
|
that treatment automatically; there's no button to build on the app side.
|
||||||
|
- **No SASL handshake yet** — `ServerConfig` has `saslUser`/`saslPass` fields
|
||||||
|
but `IrcConnection` only sends `CAP REQ :sasl` and stops there. The
|
||||||
|
"on-connect commands" field is the current way to identify (it just sends a
|
||||||
|
plain `PRIVMSG NickServ :IDENTIFY ...` after registration, which works fine
|
||||||
|
on Rizon/most networks even without real SASL).
|
||||||
|
- **Multi-window is best-effort**, same as before — genuinely dependent on
|
||||||
|
device/OEM support for freeform windowing. This is a platform limitation,
|
||||||
|
not something the app can force.
|
||||||
|
- **No notifications for mentions when a window isn't focused yet.**
|
||||||
|
- **Channel list per server isn't persisted** — only the server configs
|
||||||
|
(including identify commands) are saved; which channels you had open resets
|
||||||
|
each app restart. Worth adding to `ServerRepository` next if it bugs you.
|
||||||
|
|
||||||
|
## Building — Claude Code / PowerShell, no Android Studio
|
||||||
|
|
||||||
|
This project lives at `C:\wallpapers\wIRC`. Since you're picking this up
|
||||||
|
with Claude Code the same way as Cart & Dagger: it needs the Android SDK
|
||||||
|
command-line tools + Gradle, same setup you've already got at
|
||||||
|
`C:\Users\admin\AppData\Local\Android\Sdk`.
|
||||||
|
|
||||||
|
From PowerShell, in `C:\wallpapers\wIRC`:
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
cd C:\wallpapers\wIRC
|
||||||
|
|
||||||
|
# One-time: point Gradle at your existing SDK install
|
||||||
|
$env:ANDROID_HOME = "C:\Users\admin\AppData\Local\Android\Sdk"
|
||||||
|
"sdk.dir=C:\\Users\\admin\\AppData\\Local\\Android\\Sdk" | Out-File -Encoding ascii local.properties
|
||||||
|
|
||||||
|
# Generate the Gradle wrapper jar (not shipped in this zip — it's a binary)
|
||||||
|
# Requires a local Gradle install once, just to bootstrap the wrapper:
|
||||||
|
gradle wrapper --gradle-version 8.9
|
||||||
|
|
||||||
|
# Build
|
||||||
|
.\gradlew.bat assembleDebug
|
||||||
|
```
|
||||||
|
|
||||||
|
The debug APK lands at `C:\wallpapers\wIRC\app\build\outputs\apk\debug\app-debug.apk`.
|
||||||
|
From there, install it the same way you've been doing for Cart & Dagger —
|
||||||
|
`adb install`, or transfer it over your own hosting and install directly on
|
||||||
|
the tablet/phone.
|
||||||
|
|
||||||
|
If Claude Code hits a missing `gradle` command for that one-time wrapper
|
||||||
|
bootstrap, the SDK's `cmdline-tools` don't include Gradle itself — grab it
|
||||||
|
separately (`choco install gradle` if you use Chocolatey, or download the
|
||||||
|
binary distribution and add it to PATH) just for that first `gradle wrapper`
|
||||||
|
call; every build after that uses `.\gradlew.bat` and doesn't need a system
|
||||||
|
Gradle install anymore.
|
||||||
|
|
||||||
|
## Transferring to tablet/phone
|
||||||
|
|
||||||
|
Once you've got an APK (built on the laptop in Android Studio, or later
|
||||||
|
directly on-device via Claude Code in Termux/proot once that's sorted out):
|
||||||
|
|
||||||
|
- Simplest: `adb install app-debug.apk` if the device is plugged in / on the
|
||||||
|
same network with ADB over Wi-Fi enabled.
|
||||||
|
- Or just copy the APK over via your own file transfer (your hosting, a
|
||||||
|
shared folder, whatever you'd normally use) and install it directly on
|
||||||
|
the device — you'll need "install unknown apps" allowed for whichever app
|
||||||
|
you use to open the APK file.
|
||||||
|
|
||||||
|
## Suggested next steps
|
||||||
|
|
||||||
|
1. Wire up persistence for server/channel lists (Room or DataStore) so
|
||||||
|
`MainActivity` remembers your setup between launches.
|
||||||
|
2. Finish SASL PLAIN auth in `IrcConnection`.
|
||||||
|
3. Add a notification path for mentions when a channel window isn't focused.
|
||||||
|
4. Decide on nick auto-complete / tab-complete in the input bar.
|
||||||
@@ -0,0 +1,62 @@
|
|||||||
|
plugins {
|
||||||
|
id("com.android.application")
|
||||||
|
id("org.jetbrains.kotlin.android")
|
||||||
|
}
|
||||||
|
|
||||||
|
android {
|
||||||
|
namespace = "com.tpuahsiew.ircwindows"
|
||||||
|
compileSdk = 35
|
||||||
|
|
||||||
|
defaultConfig {
|
||||||
|
applicationId = "com.tpuahsiew.ircwindows"
|
||||||
|
minSdk = 26
|
||||||
|
targetSdk = 35
|
||||||
|
versionCode = 1
|
||||||
|
versionName = "0.1"
|
||||||
|
}
|
||||||
|
|
||||||
|
buildTypes {
|
||||||
|
release {
|
||||||
|
isMinifyEnabled = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
compileOptions {
|
||||||
|
sourceCompatibility = JavaVersion.VERSION_17
|
||||||
|
targetCompatibility = JavaVersion.VERSION_17
|
||||||
|
}
|
||||||
|
|
||||||
|
kotlinOptions {
|
||||||
|
jvmTarget = "17"
|
||||||
|
}
|
||||||
|
|
||||||
|
buildFeatures {
|
||||||
|
compose = true
|
||||||
|
}
|
||||||
|
|
||||||
|
composeOptions {
|
||||||
|
kotlinCompilerExtensionVersion = "1.5.14"
|
||||||
|
}
|
||||||
|
|
||||||
|
packaging {
|
||||||
|
resources {
|
||||||
|
excludes += "/META-INF/{AL2.0,LGPL2.1}"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
dependencies {
|
||||||
|
implementation("androidx.core:core-ktx:1.13.1")
|
||||||
|
implementation("androidx.lifecycle:lifecycle-runtime-ktx:2.8.4")
|
||||||
|
implementation("androidx.activity:activity-compose:1.9.1")
|
||||||
|
implementation(platform("androidx.compose:compose-bom:2024.09.00"))
|
||||||
|
implementation("androidx.compose.ui:ui")
|
||||||
|
implementation("androidx.compose.ui:ui-graphics")
|
||||||
|
implementation("androidx.compose.ui:ui-tooling-preview")
|
||||||
|
implementation("androidx.compose.material3:material3")
|
||||||
|
implementation("androidx.datastore:datastore-preferences:1.1.1")
|
||||||
|
implementation("androidx.lifecycle:lifecycle-service:2.8.4")
|
||||||
|
implementation("androidx.security:security-crypto:1.1.0-alpha06")
|
||||||
|
implementation("io.coil-kt:coil-compose:2.6.0")
|
||||||
|
debugImplementation("androidx.compose.ui:ui-tooling")
|
||||||
|
}
|
||||||
@@ -0,0 +1,49 @@
|
|||||||
|
<?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.FOREGROUND_SERVICE" />
|
||||||
|
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_DATA_SYNC" />
|
||||||
|
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
|
||||||
|
|
||||||
|
<application
|
||||||
|
android:name=".IrcWindowsApp"
|
||||||
|
android:allowBackup="false"
|
||||||
|
android:icon="@drawable/ic_launcher"
|
||||||
|
android:label="@string/app_name"
|
||||||
|
android:theme="@style/Theme.IrcWindows.Transparent"
|
||||||
|
android:supportsRtl="true">
|
||||||
|
|
||||||
|
<!-- Main window: server / channel list, add-connection screen -->
|
||||||
|
<activity
|
||||||
|
android:name=".MainActivity"
|
||||||
|
android:exported="true"
|
||||||
|
android:resizeableActivity="true"
|
||||||
|
android:theme="@style/Theme.IrcWindows.Transparent">
|
||||||
|
<intent-filter>
|
||||||
|
<action android:name="android.intent.action.MAIN" />
|
||||||
|
<category android:name="android.intent.category.LAUNCHER" />
|
||||||
|
</intent-filter>
|
||||||
|
</activity>
|
||||||
|
|
||||||
|
<!-- One of these gets launched per channel. documentLaunchMode="always" +
|
||||||
|
resizeableActivity lets Android treat each launch as its own task, which is
|
||||||
|
what allows multiple independent, draggable/resizable windows on devices
|
||||||
|
that support freeform / multi-window (large tablets, DeX, etc). -->
|
||||||
|
<activity
|
||||||
|
android:name=".ChannelWindowActivity"
|
||||||
|
android:exported="false"
|
||||||
|
android:resizeableActivity="true"
|
||||||
|
android:documentLaunchMode="always"
|
||||||
|
android:launchMode="standard"
|
||||||
|
android:theme="@style/Theme.IrcWindows.Transparent"
|
||||||
|
android:windowSoftInputMode="adjustResize"
|
||||||
|
android:configChanges="screenSize|smallestScreenSize|screenLayout|orientation" />
|
||||||
|
|
||||||
|
<service
|
||||||
|
android:name=".irc.IrcConnectionService"
|
||||||
|
android:exported="false"
|
||||||
|
android:foregroundServiceType="dataSync" />
|
||||||
|
|
||||||
|
</application>
|
||||||
|
</manifest>
|
||||||
@@ -0,0 +1,141 @@
|
|||||||
|
package com.tpuahsiew.ircwindows
|
||||||
|
|
||||||
|
import android.app.ActivityManager
|
||||||
|
import android.content.Intent
|
||||||
|
import android.os.Bundle
|
||||||
|
import androidx.activity.ComponentActivity
|
||||||
|
import androidx.activity.compose.setContent
|
||||||
|
import androidx.compose.runtime.collectAsState
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.lifecycle.lifecycleScope
|
||||||
|
import com.tpuahsiew.ircwindows.data.BufferKey
|
||||||
|
import com.tpuahsiew.ircwindows.data.WindowDisplaySettings
|
||||||
|
import com.tpuahsiew.ircwindows.data.WindowPrefsRepository
|
||||||
|
import com.tpuahsiew.ircwindows.data.isChannelName
|
||||||
|
import com.tpuahsiew.ircwindows.data.storageId
|
||||||
|
import com.tpuahsiew.ircwindows.irc.ConnectionManager
|
||||||
|
import com.tpuahsiew.ircwindows.irc.IrcConnectionService
|
||||||
|
import com.tpuahsiew.ircwindows.ui.ChannelScreen
|
||||||
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Each channel a user opens gets its own instance of this Activity (see manifest:
|
||||||
|
* documentLaunchMode="always", resizeableActivity="true"). On devices with freeform /
|
||||||
|
* multi-window support this lets multiple channels sit on screen as separate,
|
||||||
|
* independently draggable/resizable windows rather than tabs inside one window.
|
||||||
|
*/
|
||||||
|
class ChannelWindowActivity : ComponentActivity() {
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
const val EXTRA_SERVER_ID = "server_id"
|
||||||
|
const val EXTRA_CHANNEL_NAME = "channel_name"
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onCreate(savedInstanceState: Bundle?) {
|
||||||
|
super.onCreate(savedInstanceState)
|
||||||
|
|
||||||
|
val serverId = intent.getStringExtra(EXTRA_SERVER_ID) ?: run { finish(); return }
|
||||||
|
val channelName = intent.getStringExtra(EXTRA_CHANNEL_NAME) ?: run { finish(); return }
|
||||||
|
val bufferKey = BufferKey(serverId, channelName)
|
||||||
|
val windowTitle = if (isChannelName(channelName)) channelName else "PM: $channelName"
|
||||||
|
setTaskDescription(ActivityManager.TaskDescription(windowTitle))
|
||||||
|
|
||||||
|
val prefsRepo = WindowPrefsRepository(applicationContext)
|
||||||
|
val displaySettingsFlow: StateFlow<WindowDisplaySettings> =
|
||||||
|
MutableStateFlow(WindowDisplaySettings())
|
||||||
|
lifecycleScope.launch {
|
||||||
|
prefsRepo.settingsFor(bufferKey.storageId()).collect { settings ->
|
||||||
|
(displaySettingsFlow as MutableStateFlow).value = settings
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
setContent {
|
||||||
|
val buffers by ConnectionManager.buffers.collectAsState()
|
||||||
|
val displaySettings by displaySettingsFlow.collectAsState()
|
||||||
|
val ownNicks by ConnectionManager.ownNicksFlow.collectAsState()
|
||||||
|
val buffer = buffers[bufferKey]
|
||||||
|
|
||||||
|
ChannelScreen(
|
||||||
|
channelName = windowTitle,
|
||||||
|
buffer = buffer,
|
||||||
|
displaySettings = displaySettings,
|
||||||
|
ownNick = ownNicks[serverId],
|
||||||
|
onSend = { text ->
|
||||||
|
if (text.startsWith("/")) {
|
||||||
|
val withoutSlash = text.substring(1)
|
||||||
|
val spaceIdx = withoutSlash.indexOf(' ')
|
||||||
|
val command = (if (spaceIdx >= 0) withoutSlash.substring(0, spaceIdx) else withoutSlash).uppercase()
|
||||||
|
val rawArgs = if (spaceIdx >= 0) withoutSlash.substring(spaceIdx + 1) else ""
|
||||||
|
val args = rawArgs.trim()
|
||||||
|
|
||||||
|
when (command) {
|
||||||
|
"ME" -> ConnectionManager.sendChannelAction(serverId, channelName, args)
|
||||||
|
"NICK" -> ConnectionManager.sendRaw(serverId, "NICK $args")
|
||||||
|
"PART", "EXIT", "CLOSE" -> {
|
||||||
|
val target = args.ifBlank { channelName }
|
||||||
|
ConnectionManager.connectionFor(serverId)?.partChannel(target, "leaving")
|
||||||
|
if (args.isBlank() || target == channelName) finish()
|
||||||
|
}
|
||||||
|
"QUIT" -> {
|
||||||
|
if (rawArgs.isBlank()) {
|
||||||
|
ConnectionManager.disconnectAll()
|
||||||
|
stopService(Intent(this, IrcConnectionService::class.java))
|
||||||
|
kotlin.system.exitProcess(0)
|
||||||
|
} else {
|
||||||
|
ConnectionManager.sendChannelMessage(serverId, channelName, rawArgs)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
"JOIN" -> {
|
||||||
|
ConnectionManager.sendRaw(serverId, "JOIN $args")
|
||||||
|
val joinIntent = Intent(this, ChannelWindowActivity::class.java).apply {
|
||||||
|
putExtra(EXTRA_SERVER_ID, serverId)
|
||||||
|
putExtra(EXTRA_CHANNEL_NAME, args)
|
||||||
|
flags = Intent.FLAG_ACTIVITY_NEW_DOCUMENT or Intent.FLAG_ACTIVITY_MULTIPLE_TASK
|
||||||
|
}
|
||||||
|
startActivity(joinIntent)
|
||||||
|
}
|
||||||
|
"MSG" -> {
|
||||||
|
val msgSpaceIdx = args.indexOf(' ')
|
||||||
|
if (msgSpaceIdx >= 0) {
|
||||||
|
val target = args.substring(0, msgSpaceIdx)
|
||||||
|
val message = args.substring(msgSpaceIdx + 1)
|
||||||
|
ConnectionManager.sendChannelMessage(serverId, target, message)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
else -> ConnectionManager.sendRaw(serverId, "$command $args".trim())
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
ConnectionManager.sendChannelMessage(serverId, channelName, text)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
onKick = { nick -> ConnectionManager.connectionFor(serverId)?.kick(channelName, nick) },
|
||||||
|
onBan = { nick -> ConnectionManager.connectionFor(serverId)?.ban(channelName, "$nick!*@*") },
|
||||||
|
onOp = { nick -> ConnectionManager.connectionFor(serverId)?.setMode(channelName, "+o", nick) },
|
||||||
|
onDeop = { nick -> ConnectionManager.connectionFor(serverId)?.setMode(channelName, "-o", nick) },
|
||||||
|
onAlphaChange = { alpha ->
|
||||||
|
lifecycleScope.launch { prefsRepo.setAlpha(bufferKey.storageId(), alpha) }
|
||||||
|
},
|
||||||
|
onTextColorChange = { index ->
|
||||||
|
lifecycleScope.launch { prefsRepo.setTextColorMircIndex(bufferKey.storageId(), index) }
|
||||||
|
},
|
||||||
|
onFontSizeChange = { size ->
|
||||||
|
lifecycleScope.launch { prefsRepo.setFontSize(bufferKey.storageId(), size) }
|
||||||
|
},
|
||||||
|
onShowUserListChange = { show ->
|
||||||
|
lifecycleScope.launch { prefsRepo.setShowUserList(bufferKey.storageId(), show) }
|
||||||
|
},
|
||||||
|
onNickColorsToggle = { enabled ->
|
||||||
|
lifecycleScope.launch { prefsRepo.setNickColorsEnabled(bufferKey.storageId(), enabled) }
|
||||||
|
},
|
||||||
|
onMentionHighlightColorChange = { colorArgb ->
|
||||||
|
lifecycleScope.launch { prefsRepo.setMentionHighlightColor(bufferKey.storageId(), colorArgb) }
|
||||||
|
},
|
||||||
|
onShowImagePreviewsChange = { show ->
|
||||||
|
lifecycleScope.launch { prefsRepo.setShowImagePreviews(bufferKey.storageId(), show) }
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
package com.tpuahsiew.ircwindows
|
||||||
|
|
||||||
|
import android.app.Application
|
||||||
|
import android.content.Intent
|
||||||
|
import android.os.Build
|
||||||
|
import com.tpuahsiew.ircwindows.irc.ConnectionManager
|
||||||
|
import com.tpuahsiew.ircwindows.irc.IrcConnectionService
|
||||||
|
|
||||||
|
class IrcWindowsApp : Application() {
|
||||||
|
override fun onCreate() {
|
||||||
|
super.onCreate()
|
||||||
|
ConnectionManager.init(this)
|
||||||
|
val intent = Intent(this, IrcConnectionService::class.java)
|
||||||
|
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
|
||||||
|
startForegroundService(intent)
|
||||||
|
} else {
|
||||||
|
startService(intent)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,346 @@
|
|||||||
|
package com.tpuahsiew.ircwindows
|
||||||
|
|
||||||
|
import android.app.ActivityManager
|
||||||
|
import android.content.Intent
|
||||||
|
import android.os.Bundle
|
||||||
|
import androidx.activity.ComponentActivity
|
||||||
|
import androidx.activity.compose.setContent
|
||||||
|
import androidx.compose.foundation.layout.*
|
||||||
|
import androidx.compose.foundation.lazy.LazyColumn
|
||||||
|
import androidx.compose.foundation.lazy.items
|
||||||
|
import androidx.compose.foundation.text.KeyboardOptions
|
||||||
|
import androidx.compose.material3.*
|
||||||
|
import androidx.compose.runtime.*
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.focus.FocusDirection
|
||||||
|
import androidx.compose.ui.graphics.Color
|
||||||
|
import androidx.compose.ui.platform.LocalFocusManager
|
||||||
|
import androidx.compose.ui.input.key.Key
|
||||||
|
import androidx.compose.ui.input.key.KeyEventType
|
||||||
|
import androidx.compose.ui.input.key.isShiftPressed
|
||||||
|
import androidx.compose.ui.input.key.key
|
||||||
|
import androidx.compose.ui.input.key.onPreviewKeyEvent
|
||||||
|
import androidx.compose.ui.input.key.type
|
||||||
|
import androidx.compose.ui.text.input.ImeAction
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import com.tpuahsiew.ircwindows.data.ServerConfig
|
||||||
|
import com.tpuahsiew.ircwindows.data.ServerRepository
|
||||||
|
import com.tpuahsiew.ircwindows.irc.ConnectionManager
|
||||||
|
import kotlinx.coroutines.delay
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
|
||||||
|
// Material 3's darkColorScheme() defaults to a purple accent; this keeps everything
|
||||||
|
// (OutlinedTextField borders, Button/TextButton labels, etc.) neutral black-and-white
|
||||||
|
// to match the rest of the app's deliberately plain look.
|
||||||
|
private val neutralDarkScheme = darkColorScheme(
|
||||||
|
primary = Color.White,
|
||||||
|
onPrimary = Color.Black,
|
||||||
|
secondary = Color.White,
|
||||||
|
onSecondary = Color.Black,
|
||||||
|
background = Color.Black,
|
||||||
|
onBackground = Color.White,
|
||||||
|
surface = Color.Black,
|
||||||
|
onSurface = Color.White,
|
||||||
|
outline = Color.White
|
||||||
|
)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Main window. Resizable like every other window in the app (see manifest), so on
|
||||||
|
* devices with freeform multi-window support it gets the OS's own window chrome -
|
||||||
|
* including minimize - for free; there's no app-level "minimize" API to build.
|
||||||
|
*/
|
||||||
|
class MainActivity : ComponentActivity() {
|
||||||
|
override fun onCreate(savedInstanceState: Bundle?) {
|
||||||
|
super.onCreate(savedInstanceState)
|
||||||
|
setTaskDescription(ActivityManager.TaskDescription("wIRC - Main"))
|
||||||
|
val repo = ServerRepository(applicationContext)
|
||||||
|
setContent {
|
||||||
|
MaterialTheme(colorScheme = neutralDarkScheme) {
|
||||||
|
Surface {
|
||||||
|
MainScreen(
|
||||||
|
repo = repo,
|
||||||
|
onOpenChannel = { serverId, channelName ->
|
||||||
|
val intent = Intent(this, ChannelWindowActivity::class.java).apply {
|
||||||
|
putExtra(ChannelWindowActivity.EXTRA_SERVER_ID, serverId)
|
||||||
|
putExtra(ChannelWindowActivity.EXTRA_CHANNEL_NAME, channelName)
|
||||||
|
flags = Intent.FLAG_ACTIVITY_NEW_DOCUMENT or Intent.FLAG_ACTIVITY_MULTIPLE_TASK
|
||||||
|
}
|
||||||
|
startActivity(intent)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun MainScreen(repo: ServerRepository, onOpenChannel: (serverId: String, channelName: String) -> Unit) {
|
||||||
|
var servers by remember { mutableStateOf(repo.loadAll()) }
|
||||||
|
var showAddDialog by remember { mutableStateOf(false) }
|
||||||
|
var editingServer by remember { mutableStateOf<ServerConfig?>(null) }
|
||||||
|
var connectedIds by remember { mutableStateOf(setOf<String>()) }
|
||||||
|
var channelInputs by remember { mutableStateOf(mapOf<String, String>()) }
|
||||||
|
var openedChannels by remember { mutableStateOf(mapOf<String, List<String>>()) }
|
||||||
|
val coroutineScope = rememberCoroutineScope()
|
||||||
|
|
||||||
|
Column(modifier = Modifier.fillMaxSize().padding(16.dp)) {
|
||||||
|
Row(
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
horizontalArrangement = Arrangement.SpaceBetween,
|
||||||
|
verticalAlignment = Alignment.CenterVertically
|
||||||
|
) {
|
||||||
|
Text("wIRC", style = MaterialTheme.typography.headlineSmall)
|
||||||
|
Button(onClick = { editingServer = null; showAddDialog = true }) {
|
||||||
|
Text("+ Add Server")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Spacer(Modifier.height(16.dp))
|
||||||
|
|
||||||
|
if (servers.isEmpty()) {
|
||||||
|
Text("No saved servers yet. Tap \"+ Add Server\" to set one up.")
|
||||||
|
}
|
||||||
|
|
||||||
|
LazyColumn(modifier = Modifier.weight(1f)) {
|
||||||
|
items(servers) { server ->
|
||||||
|
ServerCard(
|
||||||
|
server = server,
|
||||||
|
isConnected = server.id in connectedIds,
|
||||||
|
channelInput = channelInputs[server.id] ?: "#",
|
||||||
|
openedChannels = openedChannels[server.id] ?: emptyList(),
|
||||||
|
onChannelInputChange = { text -> channelInputs = channelInputs + (server.id to text) },
|
||||||
|
onConnect = {
|
||||||
|
ConnectionManager.connect(server)
|
||||||
|
connectedIds = connectedIds + server.id
|
||||||
|
if (server.autoJoinChannels.isNotEmpty()) {
|
||||||
|
// Auto-join sends JOIN correctly on connect (see IrcConnection),
|
||||||
|
// but nothing else opens a window for it like the manual
|
||||||
|
// "Join + Open" flow does - so give the server a moment to
|
||||||
|
// actually join us before opening windows for them.
|
||||||
|
coroutineScope.launch {
|
||||||
|
delay(500)
|
||||||
|
server.autoJoinChannels.forEach { ch ->
|
||||||
|
val current = openedChannels[server.id] ?: emptyList()
|
||||||
|
if (ch !in current) openedChannels = openedChannels + (server.id to (current + ch))
|
||||||
|
onOpenChannel(server.id, ch)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
onEdit = { editingServer = server; showAddDialog = true },
|
||||||
|
onDelete = {
|
||||||
|
repo.remove(server.id)
|
||||||
|
servers = repo.loadAll()
|
||||||
|
},
|
||||||
|
onJoinAndOpen = { ch ->
|
||||||
|
if (ch.isNotBlank()) {
|
||||||
|
ConnectionManager.connectionFor(server.id)?.joinChannel(ch)
|
||||||
|
val current = openedChannels[server.id] ?: emptyList()
|
||||||
|
if (ch !in current) openedChannels = openedChannels + (server.id to (current + ch))
|
||||||
|
onOpenChannel(server.id, ch)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
onReopenWindow = { ch -> onOpenChannel(server.id, ch) }
|
||||||
|
)
|
||||||
|
Spacer(Modifier.height(12.dp))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (showAddDialog) {
|
||||||
|
ServerEditDialog(
|
||||||
|
existing = editingServer,
|
||||||
|
onDismiss = { showAddDialog = false },
|
||||||
|
onSave = { config ->
|
||||||
|
repo.upsert(config)
|
||||||
|
servers = repo.loadAll()
|
||||||
|
showAddDialog = false
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun ServerCard(
|
||||||
|
server: ServerConfig,
|
||||||
|
isConnected: Boolean,
|
||||||
|
channelInput: String,
|
||||||
|
openedChannels: List<String>,
|
||||||
|
onChannelInputChange: (String) -> Unit,
|
||||||
|
onConnect: () -> Unit,
|
||||||
|
onEdit: () -> Unit,
|
||||||
|
onDelete: () -> Unit,
|
||||||
|
onJoinAndOpen: (String) -> Unit,
|
||||||
|
onReopenWindow: (String) -> Unit
|
||||||
|
) {
|
||||||
|
OutlinedCard(modifier = Modifier.fillMaxWidth()) {
|
||||||
|
Column(modifier = Modifier.padding(12.dp)) {
|
||||||
|
Row(
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
horizontalArrangement = Arrangement.SpaceBetween,
|
||||||
|
verticalAlignment = Alignment.CenterVertically
|
||||||
|
) {
|
||||||
|
Column {
|
||||||
|
Text(server.label, style = MaterialTheme.typography.titleMedium)
|
||||||
|
Text(
|
||||||
|
"${server.host}:${server.port}${if (server.useTls) " (TLS)" else ""} \u00b7 nick: ${server.nick}",
|
||||||
|
style = MaterialTheme.typography.bodySmall
|
||||||
|
)
|
||||||
|
if (server.onConnectCommands.isNotEmpty()) {
|
||||||
|
Text(
|
||||||
|
"${server.onConnectCommands.size} on-connect command(s) saved",
|
||||||
|
style = MaterialTheme.typography.bodySmall
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Row {
|
||||||
|
TextButton(onClick = onEdit) { Text("Edit") }
|
||||||
|
TextButton(onClick = onDelete) { Text("Delete") }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Spacer(Modifier.height(8.dp))
|
||||||
|
|
||||||
|
if (!isConnected) {
|
||||||
|
Button(onClick = onConnect) { Text("Connect") }
|
||||||
|
} else {
|
||||||
|
Text("Connected", style = MaterialTheme.typography.bodySmall)
|
||||||
|
Spacer(Modifier.height(4.dp))
|
||||||
|
Row {
|
||||||
|
OutlinedTextField(
|
||||||
|
value = channelInput,
|
||||||
|
onValueChange = onChannelInputChange,
|
||||||
|
label = { Text("Channel") },
|
||||||
|
modifier = Modifier.weight(1f)
|
||||||
|
)
|
||||||
|
Spacer(Modifier.width(8.dp))
|
||||||
|
Button(onClick = { onJoinAndOpen(channelInput.trim()) }) {
|
||||||
|
Text("Join + Open")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (openedChannels.isNotEmpty()) {
|
||||||
|
Spacer(Modifier.height(4.dp))
|
||||||
|
Text("Windows:", style = MaterialTheme.typography.labelSmall)
|
||||||
|
openedChannels.forEach { ch ->
|
||||||
|
TextButton(onClick = { onReopenWindow(ch) }) { Text(ch) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun ServerEditDialog(
|
||||||
|
existing: ServerConfig?,
|
||||||
|
onDismiss: () -> Unit,
|
||||||
|
onSave: (ServerConfig) -> Unit
|
||||||
|
) {
|
||||||
|
var label by remember { mutableStateOf(existing?.label ?: "") }
|
||||||
|
var host by remember { mutableStateOf(existing?.host ?: "") }
|
||||||
|
var port by remember { mutableStateOf((existing?.port ?: 6667).toString()) }
|
||||||
|
var useTls by remember { mutableStateOf(existing?.useTls ?: false) }
|
||||||
|
var nick by remember { mutableStateOf(existing?.nick ?: "") }
|
||||||
|
var autoJoin by remember { mutableStateOf(existing?.autoJoinChannels?.joinToString(", ") ?: "") }
|
||||||
|
// One raw IRC command per line, e.g.:
|
||||||
|
// PRIVMSG NickServ :IDENTIFY hunter2
|
||||||
|
var onConnectCommands by remember {
|
||||||
|
mutableStateOf(existing?.onConnectCommands?.joinToString("\n") ?: "")
|
||||||
|
}
|
||||||
|
val focusManager = LocalFocusManager.current
|
||||||
|
|
||||||
|
AlertDialog(
|
||||||
|
onDismissRequest = onDismiss,
|
||||||
|
title = { Text(if (existing == null) "Add Server" else "Edit Server") },
|
||||||
|
text = {
|
||||||
|
Column(
|
||||||
|
modifier = Modifier.onPreviewKeyEvent { event ->
|
||||||
|
if (event.type == KeyEventType.KeyDown && event.key == Key.Tab) {
|
||||||
|
focusManager.moveFocus(if (event.isShiftPressed) FocusDirection.Up else FocusDirection.Down)
|
||||||
|
true
|
||||||
|
} else false
|
||||||
|
}
|
||||||
|
) {
|
||||||
|
OutlinedTextField(
|
||||||
|
value = label,
|
||||||
|
onValueChange = { label = it },
|
||||||
|
label = { Text("Display name") },
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
keyboardOptions = KeyboardOptions(imeAction = ImeAction.Next)
|
||||||
|
)
|
||||||
|
Spacer(Modifier.height(6.dp))
|
||||||
|
OutlinedTextField(
|
||||||
|
value = host,
|
||||||
|
onValueChange = { host = it },
|
||||||
|
label = { Text("Host") },
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
keyboardOptions = KeyboardOptions(imeAction = ImeAction.Next)
|
||||||
|
)
|
||||||
|
Spacer(Modifier.height(6.dp))
|
||||||
|
OutlinedTextField(
|
||||||
|
value = port,
|
||||||
|
onValueChange = { port = it },
|
||||||
|
label = { Text("Port") },
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
keyboardOptions = KeyboardOptions(imeAction = ImeAction.Next)
|
||||||
|
)
|
||||||
|
Spacer(Modifier.height(6.dp))
|
||||||
|
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||||
|
Checkbox(checked = useTls, onCheckedChange = { useTls = it })
|
||||||
|
Text("Use TLS")
|
||||||
|
}
|
||||||
|
OutlinedTextField(
|
||||||
|
value = nick,
|
||||||
|
onValueChange = { nick = it },
|
||||||
|
label = { Text("Nick") },
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
keyboardOptions = KeyboardOptions(imeAction = ImeAction.Next)
|
||||||
|
)
|
||||||
|
Spacer(Modifier.height(6.dp))
|
||||||
|
OutlinedTextField(
|
||||||
|
value = autoJoin,
|
||||||
|
onValueChange = { autoJoin = it },
|
||||||
|
label = { Text("Auto-join channels (comma separated)") },
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
keyboardOptions = KeyboardOptions(imeAction = ImeAction.Next)
|
||||||
|
)
|
||||||
|
Spacer(Modifier.height(6.dp))
|
||||||
|
OutlinedTextField(
|
||||||
|
value = onConnectCommands,
|
||||||
|
onValueChange = { onConnectCommands = it },
|
||||||
|
label = { Text("On-connect / identify commands, one per line") },
|
||||||
|
placeholder = { Text("PRIVMSG NickServ :IDENTIFY yourpassword") },
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
minLines = 3,
|
||||||
|
keyboardOptions = KeyboardOptions(imeAction = ImeAction.Done)
|
||||||
|
)
|
||||||
|
Text(
|
||||||
|
"Saved (encrypted) and sent automatically every time you connect, " +
|
||||||
|
"right after registering and before auto-join.",
|
||||||
|
style = MaterialTheme.typography.labelSmall
|
||||||
|
)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
confirmButton = {
|
||||||
|
TextButton(onClick = {
|
||||||
|
val id = existing?.id ?: host.replace(Regex("[^a-zA-Z0-9]"), "_")
|
||||||
|
onSave(
|
||||||
|
ServerConfig(
|
||||||
|
id = id,
|
||||||
|
label = label.ifBlank { host },
|
||||||
|
host = host,
|
||||||
|
port = port.toIntOrNull() ?: 6667,
|
||||||
|
useTls = useTls,
|
||||||
|
nick = nick.ifBlank { "guest${(1000..9999).random()}" },
|
||||||
|
autoJoinChannels = autoJoin.split(",").map { it.trim() }.filter { it.isNotBlank() },
|
||||||
|
onConnectCommands = onConnectCommands.split("\n").map { it.trim() }.filter { it.isNotBlank() }
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}, enabled = host.isNotBlank()) { Text("Save") }
|
||||||
|
},
|
||||||
|
dismissButton = {
|
||||||
|
TextButton(onClick = onDismiss) { Text("Cancel") }
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,58 @@
|
|||||||
|
package com.tpuahsiew.ircwindows.data
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
import org.json.JSONArray
|
||||||
|
import org.json.JSONObject
|
||||||
|
import java.io.File
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Persists each buffer's scrollback to a JSON file under filesDir/history/, so messages
|
||||||
|
* survive an app restart. Capped at 500 messages per buffer, matching the in-memory cap
|
||||||
|
* ConnectionManager already applies.
|
||||||
|
*/
|
||||||
|
class MessageHistoryRepository(private val context: Context) {
|
||||||
|
|
||||||
|
private fun fileFor(key: BufferKey): File {
|
||||||
|
val dir = File(context.filesDir, "history")
|
||||||
|
if (!dir.exists()) dir.mkdirs()
|
||||||
|
val safeName = key.name.replace(Regex("[^a-zA-Z0-9]"), "_")
|
||||||
|
return File(dir, "${key.serverId}__${safeName}.json")
|
||||||
|
}
|
||||||
|
|
||||||
|
fun loadMessages(key: BufferKey): List<IrcMessage> {
|
||||||
|
val file = fileFor(key)
|
||||||
|
if (!file.exists()) return emptyList()
|
||||||
|
return try {
|
||||||
|
val arr = JSONArray(file.readText())
|
||||||
|
(0 until arr.length()).map { i -> fromJson(arr.getJSONObject(i)) }
|
||||||
|
} catch (e: Exception) {
|
||||||
|
emptyList()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun saveMessages(key: BufferKey, messages: List<IrcMessage>) {
|
||||||
|
val arr = JSONArray()
|
||||||
|
messages.takeLast(500).forEach { arr.put(toJson(it)) }
|
||||||
|
fileFor(key).writeText(arr.toString())
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun toJson(m: IrcMessage): JSONObject = JSONObject().apply {
|
||||||
|
put("timestampMillis", m.timestampMillis)
|
||||||
|
put("kind", m.kind.name)
|
||||||
|
put("sender", m.sender ?: JSONObject.NULL)
|
||||||
|
put("rawText", m.rawText)
|
||||||
|
put("target", m.target ?: JSONObject.NULL)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun fromJson(o: JSONObject): IrcMessage = IrcMessage(
|
||||||
|
timestampMillis = o.optLong("timestampMillis", 0L),
|
||||||
|
kind = try {
|
||||||
|
MessageKind.valueOf(o.optString("kind", "SYSTEM"))
|
||||||
|
} catch (e: Exception) {
|
||||||
|
MessageKind.SYSTEM
|
||||||
|
},
|
||||||
|
sender = o.optString("sender", null).takeIf { it != "null" },
|
||||||
|
rawText = o.optString("rawText", ""),
|
||||||
|
target = o.optString("target", null).takeIf { it != "null" }
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,48 @@
|
|||||||
|
package com.tpuahsiew.ircwindows.data
|
||||||
|
|
||||||
|
/** A configured IRC server/network connection. */
|
||||||
|
data class ServerConfig(
|
||||||
|
val id: String, // stable key, e.g. "rizon"
|
||||||
|
val label: String, // display name, e.g. "Rizon"
|
||||||
|
val host: String,
|
||||||
|
val port: Int = 6667,
|
||||||
|
val useTls: Boolean = false,
|
||||||
|
val nick: String,
|
||||||
|
val autoJoinChannels: List<String> = emptyList(),
|
||||||
|
val saslUser: String? = null,
|
||||||
|
val saslPass: String? = null,
|
||||||
|
// Raw IRC commands sent right after registration (001), before auto-join channels.
|
||||||
|
// e.g. "PRIVMSG NickServ :IDENTIFY hunter2" or "NS IDENTIFY hunter2".
|
||||||
|
// This is the "on join perform" list — saved to disk and replayed every connect.
|
||||||
|
val onConnectCommands: List<String> = emptyList()
|
||||||
|
)
|
||||||
|
|
||||||
|
/** True for channel-style targets ('#', '&', '+', '!' prefixes per the IRC spec). */
|
||||||
|
fun isChannelName(name: String): Boolean =
|
||||||
|
name.isNotEmpty() && name[0] in "#&+!"
|
||||||
|
|
||||||
|
enum class MessageKind { CHAT, ACTION, JOIN, PART, QUIT, KICK, MODE, NOTICE, NICK, TOPIC, SYSTEM }
|
||||||
|
|
||||||
|
data class IrcMessage(
|
||||||
|
val timestampMillis: Long,
|
||||||
|
val kind: MessageKind,
|
||||||
|
val sender: String?, // nick, or null for system/server lines
|
||||||
|
val rawText: String, // still contains mIRC control codes; parsed at render time
|
||||||
|
val target: String? = null // e.g. for KICK, the nick that got kicked
|
||||||
|
)
|
||||||
|
|
||||||
|
/** Buffer key: server id + channel/query name (channel names include '#'). */
|
||||||
|
data class BufferKey(val serverId: String, val name: String)
|
||||||
|
|
||||||
|
data class ChannelUser(
|
||||||
|
val nick: String,
|
||||||
|
val isOp: Boolean = false,
|
||||||
|
val isVoice: Boolean = false
|
||||||
|
)
|
||||||
|
|
||||||
|
data class ChannelBuffer(
|
||||||
|
val key: BufferKey,
|
||||||
|
val messages: List<IrcMessage> = emptyList(),
|
||||||
|
val users: List<ChannelUser> = emptyList(),
|
||||||
|
val topic: String = ""
|
||||||
|
)
|
||||||
@@ -0,0 +1,95 @@
|
|||||||
|
package com.tpuahsiew.ircwindows.data
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
import android.content.SharedPreferences
|
||||||
|
import androidx.security.crypto.EncryptedSharedPreferences
|
||||||
|
import androidx.security.crypto.MasterKey
|
||||||
|
import org.json.JSONArray
|
||||||
|
import org.json.JSONObject
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Saves the server list — including each server's "on join perform" commands
|
||||||
|
* (NickServ/ChanServ identify, etc.) — as a single JSON blob inside an
|
||||||
|
* EncryptedSharedPreferences store backed by an Android Keystore master key,
|
||||||
|
* so identify passwords never sit in plaintext on disk. Loaded once at app
|
||||||
|
* start and replayed automatically on every connect, so you only ever type
|
||||||
|
* your identify commands in once.
|
||||||
|
*/
|
||||||
|
class ServerRepository(context: Context) {
|
||||||
|
|
||||||
|
private val prefs: SharedPreferences = run {
|
||||||
|
val masterKey = MasterKey.Builder(context)
|
||||||
|
.setKeyScheme(MasterKey.KeyScheme.AES256_GCM)
|
||||||
|
.build()
|
||||||
|
EncryptedSharedPreferences.create(
|
||||||
|
context,
|
||||||
|
"servers_encrypted",
|
||||||
|
masterKey,
|
||||||
|
EncryptedSharedPreferences.PrefKeyEncryptionScheme.AES256_SIV,
|
||||||
|
EncryptedSharedPreferences.PrefValueEncryptionScheme.AES256_GCM
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun loadAll(): List<ServerConfig> {
|
||||||
|
val json = prefs.getString(SERVERS_KEY, null) ?: return emptyList()
|
||||||
|
return try {
|
||||||
|
val arr = JSONArray(json)
|
||||||
|
(0 until arr.length()).map { i -> fromJson(arr.getJSONObject(i)) }
|
||||||
|
} catch (e: Exception) {
|
||||||
|
emptyList()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun saveAll(servers: List<ServerConfig>) {
|
||||||
|
val arr = JSONArray()
|
||||||
|
servers.forEach { arr.put(toJson(it)) }
|
||||||
|
prefs.edit().putString(SERVERS_KEY, arr.toString()).apply()
|
||||||
|
}
|
||||||
|
|
||||||
|
fun upsert(server: ServerConfig) {
|
||||||
|
val current = loadAll().toMutableList()
|
||||||
|
val idx = current.indexOfFirst { it.id == server.id }
|
||||||
|
if (idx >= 0) current[idx] = server else current.add(server)
|
||||||
|
saveAll(current)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun remove(serverId: String) {
|
||||||
|
saveAll(loadAll().filterNot { it.id == serverId })
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun toJson(s: ServerConfig): JSONObject = JSONObject().apply {
|
||||||
|
put("id", s.id)
|
||||||
|
put("label", s.label)
|
||||||
|
put("host", s.host)
|
||||||
|
put("port", s.port)
|
||||||
|
put("useTls", s.useTls)
|
||||||
|
put("nick", s.nick)
|
||||||
|
put("autoJoinChannels", JSONArray(s.autoJoinChannels))
|
||||||
|
put("saslUser", s.saslUser ?: JSONObject.NULL)
|
||||||
|
put("saslPass", s.saslPass ?: JSONObject.NULL)
|
||||||
|
put("onConnectCommands", JSONArray(s.onConnectCommands))
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun fromJson(o: JSONObject): ServerConfig {
|
||||||
|
fun strArray(key: String): List<String> {
|
||||||
|
val arr = o.optJSONArray(key) ?: return emptyList()
|
||||||
|
return (0 until arr.length()).map { arr.getString(it) }
|
||||||
|
}
|
||||||
|
return ServerConfig(
|
||||||
|
id = o.getString("id"),
|
||||||
|
label = o.getString("label"),
|
||||||
|
host = o.getString("host"),
|
||||||
|
port = o.optInt("port", 6667),
|
||||||
|
useTls = o.optBoolean("useTls", false),
|
||||||
|
nick = o.getString("nick"),
|
||||||
|
autoJoinChannels = strArray("autoJoinChannels"),
|
||||||
|
saslUser = o.optString("saslUser", null).takeIf { it != "null" },
|
||||||
|
saslPass = o.optString("saslPass", null).takeIf { it != "null" },
|
||||||
|
onConnectCommands = strArray("onConnectCommands")
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
private const val SERVERS_KEY = "servers_json"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,78 @@
|
|||||||
|
package com.tpuahsiew.ircwindows.data
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
import androidx.datastore.preferences.core.*
|
||||||
|
import androidx.datastore.preferences.preferencesDataStore
|
||||||
|
import kotlinx.coroutines.flow.Flow
|
||||||
|
import kotlinx.coroutines.flow.map
|
||||||
|
|
||||||
|
val Context.windowPrefsStore by preferencesDataStore(name = "window_prefs")
|
||||||
|
|
||||||
|
data class WindowDisplaySettings(
|
||||||
|
val backgroundAlpha: Float = 0.85f, // 0f = fully transparent, 1f = fully opaque
|
||||||
|
val textColorMircIndex: Int = 0, // index into the standard mIRC palette; 0 = white
|
||||||
|
val fontSize: Float = 14f,
|
||||||
|
val showUserList: Boolean = true,
|
||||||
|
val mentionHighlightColorArgb: Int = 0x40FFA500, // translucent orange
|
||||||
|
val nickColorsEnabled: Boolean = true,
|
||||||
|
val showImagePreviews: Boolean = false // opt-in: auto-loading images reveals your IP to the host
|
||||||
|
)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Settings are keyed per-buffer (server+channel) so each floating window can be
|
||||||
|
* configured independently, matching how the windows themselves are independent.
|
||||||
|
*/
|
||||||
|
class WindowPrefsRepository(private val context: Context) {
|
||||||
|
|
||||||
|
private fun alphaKey(bufferKeyId: String) = floatPreferencesKey("alpha_$bufferKeyId")
|
||||||
|
private fun textColorMircIndexKey(bufferKeyId: String) = intPreferencesKey("textcolormircindex_$bufferKeyId")
|
||||||
|
private fun fontSizeKey(bufferKeyId: String) = floatPreferencesKey("fontsize_$bufferKeyId")
|
||||||
|
private fun showUserListKey(bufferKeyId: String) = booleanPreferencesKey("showuserlist_$bufferKeyId")
|
||||||
|
private fun mentionColorKey(bufferKeyId: String) = intPreferencesKey("mentioncolor_$bufferKeyId")
|
||||||
|
private fun nickColorsEnabledKey(bufferKeyId: String) = booleanPreferencesKey("nickcolors_$bufferKeyId")
|
||||||
|
private fun showImagePreviewsKey(bufferKeyId: String) = booleanPreferencesKey("showimagepreviews_$bufferKeyId")
|
||||||
|
|
||||||
|
fun settingsFor(bufferKeyId: String): Flow<WindowDisplaySettings> {
|
||||||
|
return context.windowPrefsStore.data.map { prefs ->
|
||||||
|
WindowDisplaySettings(
|
||||||
|
backgroundAlpha = prefs[alphaKey(bufferKeyId)] ?: 0.85f,
|
||||||
|
textColorMircIndex = prefs[textColorMircIndexKey(bufferKeyId)] ?: 0,
|
||||||
|
fontSize = prefs[fontSizeKey(bufferKeyId)] ?: 14f,
|
||||||
|
showUserList = prefs[showUserListKey(bufferKeyId)] ?: true,
|
||||||
|
mentionHighlightColorArgb = prefs[mentionColorKey(bufferKeyId)] ?: 0x40FFA500,
|
||||||
|
nickColorsEnabled = prefs[nickColorsEnabledKey(bufferKeyId)] ?: true,
|
||||||
|
showImagePreviews = prefs[showImagePreviewsKey(bufferKeyId)] ?: false
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun setAlpha(bufferKeyId: String, alpha: Float) {
|
||||||
|
context.windowPrefsStore.edit { it[alphaKey(bufferKeyId)] = alpha }
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun setTextColorMircIndex(bufferKeyId: String, index: Int) {
|
||||||
|
context.windowPrefsStore.edit { it[textColorMircIndexKey(bufferKeyId)] = index }
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun setFontSize(bufferKeyId: String, fontSize: Float) {
|
||||||
|
context.windowPrefsStore.edit { it[fontSizeKey(bufferKeyId)] = fontSize }
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun setShowUserList(bufferKeyId: String, show: Boolean) {
|
||||||
|
context.windowPrefsStore.edit { it[showUserListKey(bufferKeyId)] = show }
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun setMentionHighlightColor(bufferKeyId: String, colorArgb: Int) {
|
||||||
|
context.windowPrefsStore.edit { it[mentionColorKey(bufferKeyId)] = colorArgb }
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun setNickColorsEnabled(bufferKeyId: String, enabled: Boolean) {
|
||||||
|
context.windowPrefsStore.edit { it[nickColorsEnabledKey(bufferKeyId)] = enabled }
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun setShowImagePreviews(bufferKeyId: String, show: Boolean) {
|
||||||
|
context.windowPrefsStore.edit { it[showImagePreviewsKey(bufferKeyId)] = show }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun BufferKey.storageId(): String = "${serverId}__${name}"
|
||||||
@@ -0,0 +1,250 @@
|
|||||||
|
package com.tpuahsiew.ircwindows.irc
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
import com.tpuahsiew.ircwindows.data.*
|
||||||
|
import com.tpuahsiew.ircwindows.data.isChannelName
|
||||||
|
import kotlinx.coroutines.CoroutineScope
|
||||||
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.SupervisorJob
|
||||||
|
import kotlinx.coroutines.flow.MutableSharedFlow
|
||||||
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
|
import kotlinx.coroutines.flow.SharedFlow
|
||||||
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
|
import kotlinx.coroutines.flow.update
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
import java.util.concurrent.ConcurrentHashMap
|
||||||
|
|
||||||
|
/**
|
||||||
|
* App-wide singleton. Owns every IrcConnection and every ChannelBuffer, so that all
|
||||||
|
* windows (Activities) read from the same source of truth via StateFlow.
|
||||||
|
*/
|
||||||
|
object ConnectionManager {
|
||||||
|
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Default)
|
||||||
|
private val connections = ConcurrentHashMap<String, IrcConnection>()
|
||||||
|
private val ownNicks = ConcurrentHashMap<String, String>() // serverId -> our current nick
|
||||||
|
private var historyRepo: MessageHistoryRepository? = null
|
||||||
|
|
||||||
|
private val _buffers = MutableStateFlow<Map<BufferKey, ChannelBuffer>>(emptyMap())
|
||||||
|
val buffers: StateFlow<Map<BufferKey, ChannelBuffer>> = _buffers
|
||||||
|
|
||||||
|
private val _statuses = MutableStateFlow<Map<String, String>>(emptyMap())
|
||||||
|
val statuses: StateFlow<Map<String, String>> = _statuses
|
||||||
|
|
||||||
|
private val _ownNicks = MutableStateFlow<Map<String, String>>(emptyMap())
|
||||||
|
val ownNicksFlow: StateFlow<Map<String, String>> = _ownNicks
|
||||||
|
|
||||||
|
fun init(context: Context) {
|
||||||
|
historyRepo = MessageHistoryRepository(context.applicationContext)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun setOwnNick(serverId: String, nick: String) {
|
||||||
|
ownNicks[serverId] = nick
|
||||||
|
_ownNicks.update { it + (serverId to nick) }
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fires once for every brand-new private-message buffer (a query with someone
|
||||||
|
// who wasn't already an open window). IrcConnectionService listens to this to
|
||||||
|
// auto-launch a window for it, same as tapping a channel manually.
|
||||||
|
private val _newQueryBuffers = MutableSharedFlow<BufferKey>(extraBufferCapacity = 16)
|
||||||
|
val newQueryBuffers: SharedFlow<BufferKey> = _newQueryBuffers
|
||||||
|
|
||||||
|
fun connect(config: ServerConfig) {
|
||||||
|
if (connections.containsKey(config.id)) return
|
||||||
|
setOwnNick(config.id, config.nick)
|
||||||
|
val conn = IrcConnection(
|
||||||
|
config = config,
|
||||||
|
scope = scope,
|
||||||
|
onLine = { line -> handleLine(config.id, line) },
|
||||||
|
onStatus = { msg -> _statuses.update { it + (config.id to msg) } }
|
||||||
|
)
|
||||||
|
connections[config.id] = conn
|
||||||
|
conn.connect()
|
||||||
|
}
|
||||||
|
|
||||||
|
fun disconnectAll() {
|
||||||
|
connections.values.forEach { it.disconnect() }
|
||||||
|
connections.clear()
|
||||||
|
}
|
||||||
|
|
||||||
|
fun connectionFor(serverId: String): IrcConnection? = connections[serverId]
|
||||||
|
|
||||||
|
// IRC servers don't echo PRIVMSG back to its sender, so a sent message has to be
|
||||||
|
// added to our own buffer locally - otherwise it would never show up in the window
|
||||||
|
// that sent it.
|
||||||
|
fun sendChannelMessage(serverId: String, target: String, text: String) {
|
||||||
|
connectionFor(serverId)?.sendMessage(target, text)
|
||||||
|
val myNick = ownNicks[serverId] ?: return
|
||||||
|
addMessage(
|
||||||
|
BufferKey(serverId, target),
|
||||||
|
IrcMessage(System.currentTimeMillis(), MessageKind.CHAT, myNick, text)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Same local-echo problem as sendChannelMessage, but for /me: the wire form is a
|
||||||
|
// CTCP ACTION wrapper, while the buffer should just hold the plain action text
|
||||||
|
// (ChannelScreen renders MessageKind.ACTION with its own "* nick ..." prefix).
|
||||||
|
fun sendChannelAction(serverId: String, target: String, action: String) {
|
||||||
|
connectionFor(serverId)?.sendMessage(target, "ACTION $action")
|
||||||
|
val myNick = ownNicks[serverId] ?: return
|
||||||
|
addMessage(
|
||||||
|
BufferKey(serverId, target),
|
||||||
|
IrcMessage(System.currentTimeMillis(), MessageKind.ACTION, myNick, action)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Raw passthrough for slash commands that don't have dedicated handling.
|
||||||
|
fun sendRaw(serverId: String, raw: String) {
|
||||||
|
connectionFor(serverId)?.send(raw)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun addMessage(key: BufferKey, msg: IrcMessage) {
|
||||||
|
val isNewBuffer = !_buffers.value.containsKey(key)
|
||||||
|
// Do the file I/O (seeding a brand-new buffer from disk, then persisting the
|
||||||
|
// updated list) off the caller's thread - addMessage gets called both from the
|
||||||
|
// IO-dispatched read loop and directly from UI-thread local-echo sends.
|
||||||
|
scope.launch(Dispatchers.IO) {
|
||||||
|
val seeded = if (isNewBuffer) (historyRepo?.loadMessages(key) ?: emptyList()) else emptyList()
|
||||||
|
_buffers.update { map ->
|
||||||
|
val existing = map[key] ?: ChannelBuffer(key, messages = seeded)
|
||||||
|
val trimmed = (existing.messages + msg).takeLast(500)
|
||||||
|
map + (key to existing.copy(messages = trimmed))
|
||||||
|
}
|
||||||
|
val toSave = _buffers.value[key]?.messages ?: emptyList()
|
||||||
|
historyRepo?.saveMessages(key, toSave)
|
||||||
|
}
|
||||||
|
if (isNewBuffer && !isChannelName(key.name)) {
|
||||||
|
scope.launch { _newQueryBuffers.emit(key) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun updateUsers(key: BufferKey, transform: (List<ChannelUser>) -> List<ChannelUser>) {
|
||||||
|
_buffers.update { map ->
|
||||||
|
val existing = map[key] ?: ChannelBuffer(key)
|
||||||
|
map + (key to existing.copy(users = transform(existing.users)))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun handleLine(serverId: String, line: ParsedLine) {
|
||||||
|
val now = System.currentTimeMillis()
|
||||||
|
val nick = nickFromPrefix(line.prefix) ?: line.prefix
|
||||||
|
|
||||||
|
when (line.command) {
|
||||||
|
"PRIVMSG" -> {
|
||||||
|
val target = line.params.getOrNull(0) ?: return
|
||||||
|
val text = line.params.getOrNull(1) ?: return
|
||||||
|
val isAction = text.startsWith("\u0001ACTION") && text.endsWith("\u0001")
|
||||||
|
val body = if (isAction) text.removePrefix("\u0001ACTION ").removeSuffix("\u0001") else text
|
||||||
|
// Channel messages are keyed by the channel name. Private messages arrive
|
||||||
|
// with target == our own nick, so key those by the *sender* instead -
|
||||||
|
// that's the conversation identity a query window should use.
|
||||||
|
val bufferName = if (isChannelName(target)) target else (nick ?: target)
|
||||||
|
addMessage(
|
||||||
|
BufferKey(serverId, bufferName),
|
||||||
|
IrcMessage(now, if (isAction) MessageKind.ACTION else MessageKind.CHAT, nick, body)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
"JOIN" -> {
|
||||||
|
val channel = line.params.getOrNull(0) ?: return
|
||||||
|
addMessage(BufferKey(serverId, channel), IrcMessage(now, MessageKind.JOIN, nick, "$nick joined $channel"))
|
||||||
|
if (nick != null) updateUsers(BufferKey(serverId, channel)) { it + ChannelUser(nick) }
|
||||||
|
}
|
||||||
|
"PART" -> {
|
||||||
|
val channel = line.params.getOrNull(0) ?: return
|
||||||
|
addMessage(BufferKey(serverId, channel), IrcMessage(now, MessageKind.PART, nick, "$nick left $channel"))
|
||||||
|
if (nick != null) updateUsers(BufferKey(serverId, channel)) { users -> users.filterNot { it.nick == nick } }
|
||||||
|
}
|
||||||
|
"KICK" -> {
|
||||||
|
val channel = line.params.getOrNull(0) ?: return
|
||||||
|
val kicked = line.params.getOrNull(1) ?: return
|
||||||
|
val reason = line.params.getOrNull(2) ?: ""
|
||||||
|
addMessage(
|
||||||
|
BufferKey(serverId, channel),
|
||||||
|
IrcMessage(now, MessageKind.KICK, nick, "$kicked was kicked by $nick ($reason)", target = kicked)
|
||||||
|
)
|
||||||
|
updateUsers(BufferKey(serverId, channel)) { users -> users.filterNot { it.nick == kicked } }
|
||||||
|
}
|
||||||
|
"MODE" -> {
|
||||||
|
val channel = line.params.getOrNull(0) ?: return
|
||||||
|
if (!channel.startsWith("#")) return
|
||||||
|
val modeStr = line.params.getOrNull(1) ?: ""
|
||||||
|
val modeTarget = line.params.getOrNull(2)
|
||||||
|
addMessage(
|
||||||
|
BufferKey(serverId, channel),
|
||||||
|
IrcMessage(now, MessageKind.MODE, nick, "$nick sets mode $modeStr ${modeTarget ?: ""}".trim())
|
||||||
|
)
|
||||||
|
if (modeTarget != null && (modeStr == "+o" || modeStr == "-o" || modeStr == "+v" || modeStr == "-v")) {
|
||||||
|
updateUsers(BufferKey(serverId, channel)) { users ->
|
||||||
|
users.map { u ->
|
||||||
|
if (u.nick != modeTarget) u
|
||||||
|
else when (modeStr) {
|
||||||
|
"+o" -> u.copy(isOp = true)
|
||||||
|
"-o" -> u.copy(isOp = false)
|
||||||
|
"+v" -> u.copy(isVoice = true)
|
||||||
|
"-v" -> u.copy(isVoice = false)
|
||||||
|
else -> u
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
"TOPIC" -> {
|
||||||
|
val channel = line.params.getOrNull(0) ?: return
|
||||||
|
val topic = line.params.getOrNull(1) ?: ""
|
||||||
|
_buffers.update { map ->
|
||||||
|
val key = BufferKey(serverId, channel)
|
||||||
|
val existing = map[key] ?: ChannelBuffer(key)
|
||||||
|
map + (key to existing.copy(topic = topic))
|
||||||
|
}
|
||||||
|
addMessage(BufferKey(serverId, channel), IrcMessage(now, MessageKind.TOPIC, nick, "$nick changed topic: $topic"))
|
||||||
|
}
|
||||||
|
"353" -> { // NAMES reply
|
||||||
|
val channel = line.params.getOrNull(2) ?: return
|
||||||
|
val names = line.params.getOrNull(3)?.split(" ")?.filter { it.isNotBlank() } ?: return
|
||||||
|
val parsedUsers = names.map { raw ->
|
||||||
|
when {
|
||||||
|
raw.startsWith("@") -> ChannelUser(raw.removePrefix("@"), isOp = true)
|
||||||
|
raw.startsWith("+") -> ChannelUser(raw.removePrefix("+"), isVoice = true)
|
||||||
|
else -> ChannelUser(raw)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
updateUsers(BufferKey(serverId, channel)) { existing -> (existing + parsedUsers).distinctBy { it.nick } }
|
||||||
|
}
|
||||||
|
"NOTICE" -> {
|
||||||
|
val target = line.params.getOrNull(0) ?: return
|
||||||
|
val text = line.params.getOrNull(1) ?: return
|
||||||
|
addMessage(BufferKey(serverId, target), IrcMessage(now, MessageKind.NOTICE, nick, text))
|
||||||
|
}
|
||||||
|
"NICK" -> {
|
||||||
|
val oldNick = nick ?: return
|
||||||
|
val newNick = line.params.getOrNull(0) ?: return
|
||||||
|
val affectedKeys = _buffers.value.filterKeys { it.serverId == serverId }
|
||||||
|
.filterValues { buf -> buf.users.any { it.nick == oldNick } }
|
||||||
|
.keys
|
||||||
|
_buffers.update { map ->
|
||||||
|
map.mapValues { (key, buf) ->
|
||||||
|
if (key.serverId == serverId && buf.users.any { it.nick == oldNick }) {
|
||||||
|
buf.copy(users = buf.users.map { u -> if (u.nick == oldNick) u.copy(nick = newNick) else u })
|
||||||
|
} else buf
|
||||||
|
}
|
||||||
|
}
|
||||||
|
affectedKeys.forEach { key ->
|
||||||
|
addMessage(key, IrcMessage(now, MessageKind.SYSTEM, null, "$oldNick is now known as $newNick"))
|
||||||
|
}
|
||||||
|
if (ownNicks[serverId] == oldNick) {
|
||||||
|
setOwnNick(serverId, newNick)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
"QUIT" -> {
|
||||||
|
val reason = line.params.getOrNull(0) ?: ""
|
||||||
|
// Remove nick from every channel buffer on this server it appears in.
|
||||||
|
_buffers.update { map ->
|
||||||
|
map.mapValues { (key, buf) ->
|
||||||
|
if (key.serverId == serverId && nick != null && buf.users.any { it.nick == nick }) {
|
||||||
|
buf.copy(users = buf.users.filterNot { it.nick == nick })
|
||||||
|
} else buf
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,159 @@
|
|||||||
|
package com.tpuahsiew.ircwindows.irc
|
||||||
|
|
||||||
|
import com.tpuahsiew.ircwindows.data.ServerConfig
|
||||||
|
import kotlinx.coroutines.*
|
||||||
|
import java.io.BufferedReader
|
||||||
|
import java.io.InputStreamReader
|
||||||
|
import java.io.OutputStreamWriter
|
||||||
|
import java.io.PrintWriter
|
||||||
|
import java.net.Socket
|
||||||
|
import javax.net.ssl.SSLSocket
|
||||||
|
import javax.net.ssl.SSLSocketFactory
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Owns one socket to one IRC server. Emits raw parsed lines via onLine.
|
||||||
|
* Handles registration (NICK/USER), and basic auto-reconnect with backoff.
|
||||||
|
*/
|
||||||
|
class IrcConnection(
|
||||||
|
val config: ServerConfig,
|
||||||
|
private val scope: CoroutineScope,
|
||||||
|
private val onLine: (ParsedLine) -> Unit,
|
||||||
|
private val onStatus: (String) -> Unit
|
||||||
|
) {
|
||||||
|
private var socket: Socket? = null
|
||||||
|
private var writer: PrintWriter? = null
|
||||||
|
private var readJob: Job? = null
|
||||||
|
private var wantConnected = false
|
||||||
|
private var backoffMillis = 2000L
|
||||||
|
|
||||||
|
// Every channel we're currently in, whether from config.autoJoinChannels or joined
|
||||||
|
// manually mid-session - so a reconnect rejoins everything, not just the original
|
||||||
|
// auto-join list.
|
||||||
|
private val joinedChannels = java.util.concurrent.CopyOnWriteArraySet<String>()
|
||||||
|
|
||||||
|
fun connect() {
|
||||||
|
wantConnected = true
|
||||||
|
readJob?.cancel()
|
||||||
|
readJob = scope.launch(Dispatchers.IO) {
|
||||||
|
while (wantConnected && isActive) {
|
||||||
|
try {
|
||||||
|
onStatus("Connecting to ${config.host}:${config.port}...")
|
||||||
|
val sock: Socket = if (config.useTls) {
|
||||||
|
val sslSocket = SSLSocketFactory.getDefault().createSocket(config.host, config.port) as SSLSocket
|
||||||
|
val sslParams = sslSocket.sslParameters
|
||||||
|
sslParams.endpointIdentificationAlgorithm = "HTTPS"
|
||||||
|
sslSocket.sslParameters = sslParams
|
||||||
|
sslSocket.startHandshake()
|
||||||
|
sslSocket
|
||||||
|
} else {
|
||||||
|
Socket(config.host, config.port)
|
||||||
|
}
|
||||||
|
socket = sock
|
||||||
|
val out = PrintWriter(OutputStreamWriter(sock.getOutputStream(), Charsets.UTF_8), true)
|
||||||
|
writer = out
|
||||||
|
|
||||||
|
if (!config.saslUser.isNullOrBlank()) {
|
||||||
|
out.println("CAP REQ :sasl")
|
||||||
|
}
|
||||||
|
out.println("NICK ${config.nick}")
|
||||||
|
out.println("USER ${config.nick} 0 * :${config.nick}")
|
||||||
|
|
||||||
|
backoffMillis = 2000L
|
||||||
|
onStatus("Connected, registering...")
|
||||||
|
|
||||||
|
val reader = BufferedReader(InputStreamReader(sock.getInputStream(), Charsets.UTF_8))
|
||||||
|
while (isActive) {
|
||||||
|
val line = reader.readLine() ?: break
|
||||||
|
handleRawLine(line)
|
||||||
|
}
|
||||||
|
} catch (e: Exception) {
|
||||||
|
onStatus("Connection error: ${e.message}")
|
||||||
|
} finally {
|
||||||
|
closeSocketQuietly()
|
||||||
|
}
|
||||||
|
|
||||||
|
if (wantConnected) {
|
||||||
|
onStatus("Reconnecting in ${backoffMillis / 1000}s...")
|
||||||
|
delay(backoffMillis)
|
||||||
|
backoffMillis = (backoffMillis * 2).coerceAtMost(60_000L)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun handleRawLine(line: String) {
|
||||||
|
if (line.startsWith("PING")) {
|
||||||
|
val token = line.substringAfter("PING ")
|
||||||
|
send("PONG $token")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
val parsed = parseIrcLine(line) ?: return
|
||||||
|
|
||||||
|
// CTCP requests arrive as a PRIVMSG wrapped in \x01...\x01. VERSION gets
|
||||||
|
// handled right here so it never shows up in a channel/PM window - real
|
||||||
|
// clients answer these silently too.
|
||||||
|
if (parsed.command == "PRIVMSG") {
|
||||||
|
val text = parsed.params.getOrNull(1)
|
||||||
|
if (text != null && text.startsWith("\u0001") && text.endsWith("\u0001") && text.length > 1) {
|
||||||
|
val ctcpBody = text.removePrefix("\u0001").removeSuffix("\u0001")
|
||||||
|
val ctcpCommand = ctcpBody.substringBefore(' ').uppercase()
|
||||||
|
if (ctcpCommand == "VERSION") {
|
||||||
|
val replyTo = nickFromPrefix(parsed.prefix)
|
||||||
|
if (replyTo != null) {
|
||||||
|
send("NOTICE $replyTo :\u0001VERSION It's an irc client, fucko!\u0001")
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
// Other CTCP types (ACTION, PING, etc.) fall through to onLine as normal -
|
||||||
|
// ACTION in particular still needs to reach ConnectionManager to render as /me.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (parsed.command == "001") {
|
||||||
|
// Registered. Run "on join perform" commands first (NickServ/ChanServ
|
||||||
|
// identify, etc.) so we're identified before joining anything that
|
||||||
|
// might require it, then rejoin every channel we were actually in -
|
||||||
|
// the originally configured auto-join list plus anything joined
|
||||||
|
// manually mid-session, so a reconnect doesn't forget those.
|
||||||
|
config.onConnectCommands.forEach { cmd -> if (cmd.isNotBlank()) send(cmd) }
|
||||||
|
(config.autoJoinChannels + joinedChannels).distinct().forEach { ch -> joinChannel(ch) }
|
||||||
|
}
|
||||||
|
onLine(parsed)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun send(raw: String) {
|
||||||
|
scope.launch(Dispatchers.IO) { writer?.println(raw) }
|
||||||
|
}
|
||||||
|
|
||||||
|
fun sendMessage(target: String, text: String) {
|
||||||
|
send("PRIVMSG $target :$text")
|
||||||
|
}
|
||||||
|
|
||||||
|
fun joinChannel(channel: String) {
|
||||||
|
joinedChannels.add(channel)
|
||||||
|
send("JOIN $channel")
|
||||||
|
}
|
||||||
|
|
||||||
|
fun partChannel(channel: String, reason: String = "") {
|
||||||
|
joinedChannels.remove(channel)
|
||||||
|
send("PART $channel :$reason")
|
||||||
|
}
|
||||||
|
fun kick(channel: String, nick: String, reason: String = "") = send("KICK $channel $nick :$reason")
|
||||||
|
fun setMode(channel: String, modeChange: String, nick: String) = send("MODE $channel $modeChange $nick")
|
||||||
|
fun ban(channel: String, mask: String) = send("MODE $channel +b $mask")
|
||||||
|
|
||||||
|
fun disconnect() {
|
||||||
|
wantConnected = false
|
||||||
|
scope.launch(Dispatchers.IO) {
|
||||||
|
try { writer?.println("QUIT :leaving") } catch (_: Exception) {}
|
||||||
|
readJob?.cancel()
|
||||||
|
closeSocketQuietly()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun closeSocketQuietly() {
|
||||||
|
try { socket?.close() } catch (_: Exception) {}
|
||||||
|
socket = null
|
||||||
|
writer = null
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,77 @@
|
|||||||
|
package com.tpuahsiew.ircwindows.irc
|
||||||
|
|
||||||
|
import android.app.*
|
||||||
|
import android.content.Intent
|
||||||
|
import android.os.Build
|
||||||
|
import android.os.IBinder
|
||||||
|
import androidx.core.app.NotificationCompat
|
||||||
|
import androidx.lifecycle.LifecycleService
|
||||||
|
import androidx.lifecycle.lifecycleScope
|
||||||
|
import com.tpuahsiew.ircwindows.ChannelWindowActivity
|
||||||
|
import com.tpuahsiew.ircwindows.MainActivity
|
||||||
|
import com.tpuahsiew.ircwindows.R
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Foreground service whose only job is to keep the process (and therefore every
|
||||||
|
* IrcConnection socket held by ConnectionManager) alive while the app is backgrounded.
|
||||||
|
* Android aggressively kills backgrounded network connections without this.
|
||||||
|
*/
|
||||||
|
class IrcConnectionService : LifecycleService() {
|
||||||
|
|
||||||
|
override fun onCreate() {
|
||||||
|
super.onCreate()
|
||||||
|
createNotificationChannel()
|
||||||
|
startForeground(NOTIFICATION_ID, buildNotification())
|
||||||
|
|
||||||
|
// Auto-open a window for every brand-new private message, the same way
|
||||||
|
// tapping a channel does. Uses NEW_TASK since we're launching from a
|
||||||
|
// Service context, not an Activity.
|
||||||
|
lifecycleScope.launch {
|
||||||
|
ConnectionManager.newQueryBuffers.collect { key ->
|
||||||
|
val intent = Intent(this@IrcConnectionService, ChannelWindowActivity::class.java).apply {
|
||||||
|
putExtra(ChannelWindowActivity.EXTRA_SERVER_ID, key.serverId)
|
||||||
|
putExtra(ChannelWindowActivity.EXTRA_CHANNEL_NAME, key.name)
|
||||||
|
flags = Intent.FLAG_ACTIVITY_NEW_TASK or
|
||||||
|
Intent.FLAG_ACTIVITY_NEW_DOCUMENT or
|
||||||
|
Intent.FLAG_ACTIVITY_MULTIPLE_TASK
|
||||||
|
}
|
||||||
|
startActivity(intent)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
|
||||||
|
super.onStartCommand(intent, flags, startId)
|
||||||
|
return START_STICKY
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun buildNotification(): Notification {
|
||||||
|
val pendingIntent = PendingIntent.getActivity(
|
||||||
|
this, 0,
|
||||||
|
Intent(this, MainActivity::class.java),
|
||||||
|
PendingIntent.FLAG_IMMUTABLE
|
||||||
|
)
|
||||||
|
return NotificationCompat.Builder(this, CHANNEL_ID)
|
||||||
|
.setContentTitle("wIRC")
|
||||||
|
.setContentText("Connected")
|
||||||
|
.setSmallIcon(R.drawable.ic_notification)
|
||||||
|
.setContentIntent(pendingIntent)
|
||||||
|
.setOngoing(true)
|
||||||
|
.build()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun createNotificationChannel() {
|
||||||
|
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
|
||||||
|
val channel = NotificationChannel(
|
||||||
|
CHANNEL_ID, "IRC connection", NotificationManager.IMPORTANCE_LOW
|
||||||
|
)
|
||||||
|
getSystemService(NotificationManager::class.java).createNotificationChannel(channel)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
const val CHANNEL_ID = "irc_connection"
|
||||||
|
const val NOTIFICATION_ID = 1001
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,47 @@
|
|||||||
|
package com.tpuahsiew.ircwindows.irc
|
||||||
|
|
||||||
|
/** A minimally-parsed raw IRC protocol line. */
|
||||||
|
data class ParsedLine(
|
||||||
|
val prefix: String?, // e.g. "nick!user@host" or a server name
|
||||||
|
val command: String, // e.g. "PRIVMSG", "JOIN", "376", etc.
|
||||||
|
val params: List<String> // trailing param (after ':') is params.last() if present
|
||||||
|
)
|
||||||
|
|
||||||
|
fun parseIrcLine(line: String): ParsedLine? {
|
||||||
|
if (line.isBlank()) return null
|
||||||
|
var rest = line
|
||||||
|
var prefix: String? = null
|
||||||
|
|
||||||
|
if (rest.startsWith(":")) {
|
||||||
|
val spaceIdx = rest.indexOf(' ')
|
||||||
|
if (spaceIdx == -1) return null
|
||||||
|
prefix = rest.substring(1, spaceIdx)
|
||||||
|
rest = rest.substring(spaceIdx + 1)
|
||||||
|
}
|
||||||
|
|
||||||
|
val trailingSplit = rest.indexOf(" :")
|
||||||
|
val paramsStr: String
|
||||||
|
val trailing: String?
|
||||||
|
if (trailingSplit != -1) {
|
||||||
|
paramsStr = rest.substring(0, trailingSplit)
|
||||||
|
trailing = rest.substring(trailingSplit + 2)
|
||||||
|
} else {
|
||||||
|
paramsStr = rest
|
||||||
|
trailing = null
|
||||||
|
}
|
||||||
|
|
||||||
|
val parts = paramsStr.split(" ").filter { it.isNotEmpty() }
|
||||||
|
if (parts.isEmpty()) return null
|
||||||
|
val command = parts[0].uppercase()
|
||||||
|
val params = parts.drop(1).toMutableList()
|
||||||
|
if (trailing != null) params.add(trailing)
|
||||||
|
|
||||||
|
return ParsedLine(prefix, command, params)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Extracts just the nick portion from an IRC prefix like "nick!user@host". */
|
||||||
|
fun nickFromPrefix(prefix: String?): String? {
|
||||||
|
if (prefix == null) return null
|
||||||
|
val bangIdx = prefix.indexOf('!')
|
||||||
|
return if (bangIdx != -1) prefix.substring(0, bangIdx) else prefix
|
||||||
|
}
|
||||||
@@ -0,0 +1,165 @@
|
|||||||
|
package com.tpuahsiew.ircwindows.irc
|
||||||
|
|
||||||
|
import androidx.compose.ui.graphics.Color
|
||||||
|
import androidx.compose.ui.text.AnnotatedString
|
||||||
|
import androidx.compose.ui.text.SpanStyle
|
||||||
|
import androidx.compose.ui.text.withStyle
|
||||||
|
import androidx.compose.ui.text.font.FontStyle
|
||||||
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
|
import androidx.compose.ui.text.style.TextDecoration
|
||||||
|
|
||||||
|
// Standard 16-color mIRC palette (index 0-15).
|
||||||
|
val mircPalette = listOf(
|
||||||
|
Color(0xFFFFFFFF), // 0 white
|
||||||
|
Color(0xFF000000), // 1 black
|
||||||
|
Color(0xFF00007F), // 2 blue (navy)
|
||||||
|
Color(0xFF009300), // 3 green
|
||||||
|
Color(0xFFFF0000), // 4 red
|
||||||
|
Color(0xFF7F0000), // 5 brown (maroon)
|
||||||
|
Color(0xFF9C009C), // 6 purple
|
||||||
|
Color(0xFFFC7F00), // 7 orange
|
||||||
|
Color(0xFFFFFF00), // 8 yellow
|
||||||
|
Color(0xFF00FC00), // 9 light green
|
||||||
|
Color(0xFF00939C), // 10 cyan
|
||||||
|
Color(0xFF00FFFF), // 11 light cyan
|
||||||
|
Color(0xFF0000FC), // 12 light blue
|
||||||
|
Color(0xFFFF00FF), // 13 pink
|
||||||
|
Color(0xFF7F7F7F), // 14 grey
|
||||||
|
Color(0xFFD2D2D2) // 15 light grey
|
||||||
|
)
|
||||||
|
|
||||||
|
private const val BOLD = '\u0002'
|
||||||
|
private const val COLOR = '\u0003'
|
||||||
|
private const val RESET = '\u000F'
|
||||||
|
private const val REVERSE = '\u0016'
|
||||||
|
private const val UNDERLINE = '\u001F'
|
||||||
|
|
||||||
|
private const val ITALIC = ''
|
||||||
|
|
||||||
|
private val urlRegex = Regex("""https?://\S+""")
|
||||||
|
private val urlColor = Color(0xFF4EA1FF)
|
||||||
|
|
||||||
|
private val imageUrlRegex = Regex("""https?://[^\s]+""")
|
||||||
|
private val imageExtensions = setOf("jpg", "jpeg", "png", "gif", "webp", "bmp")
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Extracts URLs from raw message text that look like they point at an image,
|
||||||
|
* based on the file extension in the path (ignoring query string/fragment).
|
||||||
|
*/
|
||||||
|
fun extractImageUrls(text: String): List<String> {
|
||||||
|
return imageUrlRegex.findAll(text).map { it.value }.filter { url ->
|
||||||
|
val cleanUrl = url.substringBefore('?').substringBefore('#')
|
||||||
|
val ext = cleanUrl.substringAfterLast('.', "").lowercase()
|
||||||
|
ext in imageExtensions
|
||||||
|
}.toList()
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Parses a raw IRC line (which may contain mIRC control codes) into an AnnotatedString
|
||||||
|
* ready to render in Compose. baseColor is applied where no explicit foreground is set,
|
||||||
|
* so it follows the user's chosen black/white text setting.
|
||||||
|
*/
|
||||||
|
fun parseMircToAnnotatedString(raw: String, baseColor: Color): AnnotatedString {
|
||||||
|
val builder = AnnotatedString.Builder()
|
||||||
|
|
||||||
|
var bold = false
|
||||||
|
var underline = false
|
||||||
|
var italic = false
|
||||||
|
var reverse = false
|
||||||
|
var fg: Color? = null
|
||||||
|
var bg: Color? = null
|
||||||
|
|
||||||
|
fun currentStyle(): SpanStyle {
|
||||||
|
val effectiveFg = if (reverse) (bg ?: baseColor) else (fg ?: baseColor)
|
||||||
|
val effectiveBg = if (reverse) (fg ?: baseColor) else bg
|
||||||
|
return SpanStyle(
|
||||||
|
color = effectiveFg,
|
||||||
|
background = effectiveBg ?: Color.Unspecified,
|
||||||
|
fontWeight = if (bold) FontWeight.Bold else FontWeight.Normal,
|
||||||
|
fontStyle = if (italic) FontStyle.Italic else FontStyle.Normal,
|
||||||
|
textDecoration = if (underline) TextDecoration.Underline else TextDecoration.None
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
var i = 0
|
||||||
|
var runStart = 0
|
||||||
|
|
||||||
|
fun flush(endExclusive: Int) {
|
||||||
|
if (endExclusive > runStart) {
|
||||||
|
builder.withStyle(currentStyle()) {
|
||||||
|
append(raw.substring(runStart, endExclusive))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun readColorDigits(startIdx: Int): Pair<Int?, Int> {
|
||||||
|
// Reads up to 2 digits starting at startIdx, returns (colorIndexOrNull, nextIndex)
|
||||||
|
var idx = startIdx
|
||||||
|
var numStr = ""
|
||||||
|
while (idx < raw.length && raw[idx].isDigit() && numStr.length < 2) {
|
||||||
|
numStr += raw[idx]
|
||||||
|
idx++
|
||||||
|
}
|
||||||
|
if (numStr.isEmpty()) return Pair(null, startIdx)
|
||||||
|
val n = numStr.toInt().coerceIn(0, 15)
|
||||||
|
return Pair(n, idx)
|
||||||
|
}
|
||||||
|
|
||||||
|
while (i < raw.length) {
|
||||||
|
when (raw[i]) {
|
||||||
|
BOLD -> {
|
||||||
|
flush(i); bold = !bold; i++; runStart = i
|
||||||
|
}
|
||||||
|
UNDERLINE -> {
|
||||||
|
flush(i); underline = !underline; i++; runStart = i
|
||||||
|
}
|
||||||
|
ITALIC -> {
|
||||||
|
flush(i); italic = !italic; i++; runStart = i
|
||||||
|
}
|
||||||
|
REVERSE -> {
|
||||||
|
flush(i); reverse = !reverse; i++; runStart = i
|
||||||
|
}
|
||||||
|
RESET -> {
|
||||||
|
flush(i)
|
||||||
|
bold = false; underline = false; italic = false; reverse = false; fg = null; bg = null
|
||||||
|
i++; runStart = i
|
||||||
|
}
|
||||||
|
COLOR -> {
|
||||||
|
flush(i)
|
||||||
|
i++
|
||||||
|
val (fgIdx, nextI) = readColorDigits(i)
|
||||||
|
i = nextI
|
||||||
|
if (fgIdx == null) {
|
||||||
|
fg = null; bg = null
|
||||||
|
} else {
|
||||||
|
fg = mircPalette[fgIdx]
|
||||||
|
if (i < raw.length && raw[i] == ',') {
|
||||||
|
val (bgIdx, nextI2) = readColorDigits(i + 1)
|
||||||
|
if (bgIdx != null) {
|
||||||
|
bg = mircPalette[bgIdx]
|
||||||
|
i = nextI2
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
runStart = i
|
||||||
|
}
|
||||||
|
else -> i++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
flush(raw.length)
|
||||||
|
|
||||||
|
val parsed = builder.toAnnotatedString()
|
||||||
|
val matches = urlRegex.findAll(parsed.text).toList()
|
||||||
|
if (matches.isEmpty()) return parsed
|
||||||
|
|
||||||
|
// Layer link styling/annotations on top of the existing mIRC-colored string rather
|
||||||
|
// than replacing it, so control codes elsewhere in the line are unaffected.
|
||||||
|
val linked = AnnotatedString.Builder(parsed)
|
||||||
|
matches.forEach { match ->
|
||||||
|
val start = match.range.first
|
||||||
|
val end = match.range.last + 1
|
||||||
|
linked.addStyle(SpanStyle(color = urlColor, textDecoration = TextDecoration.Underline), start, end)
|
||||||
|
linked.addStringAnnotation(tag = "URL", annotation = match.value, start = start, end = end)
|
||||||
|
}
|
||||||
|
return linked.toAnnotatedString()
|
||||||
|
}
|
||||||
@@ -0,0 +1,707 @@
|
|||||||
|
package com.tpuahsiew.ircwindows.ui
|
||||||
|
|
||||||
|
import android.content.Intent
|
||||||
|
import android.net.Uri
|
||||||
|
import androidx.compose.foundation.background
|
||||||
|
import androidx.compose.foundation.border
|
||||||
|
import androidx.compose.foundation.clickable
|
||||||
|
import androidx.compose.foundation.combinedClickable
|
||||||
|
import androidx.compose.foundation.interaction.MutableInteractionSource
|
||||||
|
import androidx.compose.foundation.layout.*
|
||||||
|
import androidx.compose.foundation.lazy.LazyColumn
|
||||||
|
import androidx.compose.foundation.lazy.items
|
||||||
|
import androidx.compose.foundation.lazy.rememberLazyListState
|
||||||
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
|
import androidx.compose.foundation.text.BasicTextField
|
||||||
|
import androidx.compose.foundation.text.ClickableText
|
||||||
|
import androidx.compose.foundation.text.KeyboardActions
|
||||||
|
import androidx.compose.foundation.text.KeyboardOptions
|
||||||
|
import androidx.compose.material.icons.Icons
|
||||||
|
import androidx.compose.material.icons.filled.Person
|
||||||
|
import androidx.compose.material.icons.filled.Settings
|
||||||
|
import androidx.compose.material3.*
|
||||||
|
import androidx.compose.runtime.*
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.draw.clip
|
||||||
|
import androidx.compose.ui.focus.FocusRequester
|
||||||
|
import androidx.compose.ui.focus.focusRequester
|
||||||
|
import androidx.compose.ui.graphics.Color
|
||||||
|
import androidx.compose.ui.graphics.SolidColor
|
||||||
|
import androidx.compose.ui.graphics.toArgb
|
||||||
|
import androidx.compose.ui.input.key.*
|
||||||
|
import androidx.compose.ui.layout.ContentScale
|
||||||
|
import androidx.compose.ui.platform.LocalContext
|
||||||
|
import androidx.compose.ui.platform.LocalDensity
|
||||||
|
import androidx.compose.ui.platform.LocalSoftwareKeyboardController
|
||||||
|
import androidx.compose.ui.text.font.FontFamily
|
||||||
|
import androidx.compose.ui.text.input.ImeAction
|
||||||
|
import androidx.compose.ui.text.input.TextFieldValue
|
||||||
|
import androidx.compose.ui.text.rememberTextMeasurer
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import androidx.compose.ui.unit.sp
|
||||||
|
import coil.compose.AsyncImage
|
||||||
|
import com.tpuahsiew.ircwindows.data.ChannelBuffer
|
||||||
|
import com.tpuahsiew.ircwindows.data.ChannelUser
|
||||||
|
import com.tpuahsiew.ircwindows.data.MessageKind
|
||||||
|
import com.tpuahsiew.ircwindows.data.WindowDisplaySettings
|
||||||
|
import com.tpuahsiew.ircwindows.irc.extractImageUrls
|
||||||
|
import com.tpuahsiew.ircwindows.irc.mircPalette
|
||||||
|
import com.tpuahsiew.ircwindows.irc.parseMircToAnnotatedString
|
||||||
|
import java.text.SimpleDateFormat
|
||||||
|
import java.util.*
|
||||||
|
import kotlin.math.floor
|
||||||
|
|
||||||
|
// Tracks an in-progress nick tab-completion so a repeated Tab press (with the
|
||||||
|
// input still exactly as this completion left it) cycles to the next match
|
||||||
|
// instead of redoing the same one.
|
||||||
|
private data class TabCompletionState(
|
||||||
|
val wordStart: Int,
|
||||||
|
val matches: List<String>,
|
||||||
|
val index: Int,
|
||||||
|
val textAfter: String,
|
||||||
|
val cursorAfter: Int
|
||||||
|
)
|
||||||
|
|
||||||
|
@OptIn(ExperimentalMaterial3Api::class, androidx.compose.foundation.ExperimentalFoundationApi::class)
|
||||||
|
@Composable
|
||||||
|
fun ChannelScreen(
|
||||||
|
channelName: String,
|
||||||
|
buffer: ChannelBuffer?,
|
||||||
|
displaySettings: WindowDisplaySettings,
|
||||||
|
ownNick: String?,
|
||||||
|
onSend: (String) -> Unit,
|
||||||
|
onKick: (String) -> Unit,
|
||||||
|
onBan: (String) -> Unit,
|
||||||
|
onOp: (String) -> Unit,
|
||||||
|
onDeop: (String) -> Unit,
|
||||||
|
onAlphaChange: (Float) -> Unit,
|
||||||
|
onTextColorChange: (Int) -> Unit,
|
||||||
|
onFontSizeChange: (Float) -> Unit,
|
||||||
|
onShowUserListChange: (Boolean) -> Unit,
|
||||||
|
onNickColorsToggle: (Boolean) -> Unit,
|
||||||
|
onMentionHighlightColorChange: (Int) -> Unit,
|
||||||
|
onShowImagePreviewsChange: (Boolean) -> Unit
|
||||||
|
) {
|
||||||
|
var input by remember { mutableStateOf(TextFieldValue("")) }
|
||||||
|
var showSettings by remember { mutableStateOf(false) }
|
||||||
|
var contextMenuUser by remember { mutableStateOf<ChannelUser?>(null) }
|
||||||
|
var showColorPicker by remember { mutableStateOf(false) }
|
||||||
|
var tabState by remember { mutableStateOf<TabCompletionState?>(null) }
|
||||||
|
val history = remember { mutableStateListOf<String>() }
|
||||||
|
var historyIndex by remember { mutableStateOf(-1) }
|
||||||
|
var draftBeforeHistory by remember { mutableStateOf("") }
|
||||||
|
val inputFocusRequester = remember { FocusRequester() }
|
||||||
|
val keyboardController = LocalSoftwareKeyboardController.current
|
||||||
|
val context = LocalContext.current
|
||||||
|
|
||||||
|
// Inserts a raw mIRC/IRCv3 formatting control char at the current cursor
|
||||||
|
// position (or wraps the selection, if any), same behavior as toggling
|
||||||
|
// bold/italic in a real mIRC client - it's a toggle marker, not a wrapper tag.
|
||||||
|
fun insertControlChar(char: Char) {
|
||||||
|
val text = input.text
|
||||||
|
val sel = input.selection
|
||||||
|
val newText = text.substring(0, sel.start) + char + text.substring(sel.end)
|
||||||
|
val newCursor = sel.start + 1
|
||||||
|
input = TextFieldValue(newText, androidx.compose.ui.text.TextRange(newCursor))
|
||||||
|
}
|
||||||
|
|
||||||
|
fun insertColorCode(fg: Int, bg: Int?) {
|
||||||
|
val code = "\u0003" + fg.toString().padStart(2, '0') + (bg?.let { "," + it.toString().padStart(2, '0') } ?: "")
|
||||||
|
val text = input.text
|
||||||
|
val sel = input.selection
|
||||||
|
val newText = text.substring(0, sel.start) + code + text.substring(sel.end)
|
||||||
|
val newCursor = sel.start + code.length
|
||||||
|
input = TextFieldValue(newText, androidx.compose.ui.text.TextRange(newCursor))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Tab-completes the nick fragment before the cursor against buffer.users.
|
||||||
|
// A repeated Tab press against the exact text/cursor state this function
|
||||||
|
// last produced cycles to the next match; anything else (fresh typing,
|
||||||
|
// moved cursor) starts a new completion from scratch.
|
||||||
|
fun tryTabComplete(): Boolean {
|
||||||
|
val text = input.text
|
||||||
|
val cursor = input.selection.start
|
||||||
|
val state = tabState
|
||||||
|
|
||||||
|
if (state != null && text == state.textAfter && cursor == state.cursorAfter) {
|
||||||
|
val nextIndex = (state.index + 1) % state.matches.size
|
||||||
|
val nick = state.matches[nextIndex]
|
||||||
|
val completion = "$nick "
|
||||||
|
val newText = text.substring(0, state.wordStart) + completion + text.substring(state.cursorAfter)
|
||||||
|
val newCursor = state.wordStart + completion.length
|
||||||
|
input = TextFieldValue(newText, androidx.compose.ui.text.TextRange(newCursor))
|
||||||
|
tabState = state.copy(index = nextIndex, textAfter = newText, cursorAfter = newCursor)
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
var wordStart = cursor
|
||||||
|
while (wordStart > 0 && !text[wordStart - 1].isWhitespace()) wordStart--
|
||||||
|
val partial = text.substring(wordStart, cursor)
|
||||||
|
val matches = (buffer?.users ?: emptyList()).map { it.nick }
|
||||||
|
.filter { it.startsWith(partial, ignoreCase = true) }
|
||||||
|
|
||||||
|
if (matches.isEmpty()) {
|
||||||
|
tabState = null
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
val nick = matches[0]
|
||||||
|
val completion = "$nick "
|
||||||
|
val newText = text.substring(0, wordStart) + completion + text.substring(cursor)
|
||||||
|
val newCursor = wordStart + completion.length
|
||||||
|
input = TextFieldValue(newText, androidx.compose.ui.text.TextRange(newCursor))
|
||||||
|
tabState = TabCompletionState(wordStart, matches, 0, newText, newCursor)
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
// Pushes a sent line onto the up/down-arrow recall history (skipping an exact repeat
|
||||||
|
// of the last entry) and resets navigation back to "not browsing history".
|
||||||
|
fun recordHistory(text: String) {
|
||||||
|
if (history.isEmpty() || history.last() != text) {
|
||||||
|
history.add(text)
|
||||||
|
}
|
||||||
|
historyIndex = -1
|
||||||
|
}
|
||||||
|
|
||||||
|
fun sendCurrentInput() {
|
||||||
|
if (input.text.isNotBlank()) {
|
||||||
|
recordHistory(input.text)
|
||||||
|
onSend(input.text)
|
||||||
|
input = TextFieldValue("")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun isMention(text: String): Boolean {
|
||||||
|
val nick = ownNick
|
||||||
|
if (nick.isNullOrBlank()) return false
|
||||||
|
return Regex("(?i)\\b${Regex.escape(nick)}\\b").containsMatchIn(text)
|
||||||
|
}
|
||||||
|
|
||||||
|
val textColor = mircPalette[displaySettings.textColorMircIndex]
|
||||||
|
val bgColor = Color.Black.copy(alpha = displaySettings.backgroundAlpha)
|
||||||
|
val listState = rememberLazyListState()
|
||||||
|
val timeFormat = remember { SimpleDateFormat("HH:mm", Locale.getDefault()) }
|
||||||
|
|
||||||
|
LaunchedEffect(buffer?.messages?.size) {
|
||||||
|
if ((buffer?.messages?.size ?: 0) > 0) {
|
||||||
|
listState.animateScrollToItem((buffer!!.messages.size - 1).coerceAtLeast(0))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Box(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxSize()
|
||||||
|
.clickable(
|
||||||
|
indication = null,
|
||||||
|
interactionSource = remember { MutableInteractionSource() }
|
||||||
|
) {
|
||||||
|
inputFocusRequester.requestFocus()
|
||||||
|
keyboardController?.show()
|
||||||
|
}
|
||||||
|
.background(bgColor)
|
||||||
|
) {
|
||||||
|
Column(modifier = Modifier.fillMaxSize()) {
|
||||||
|
// Title bar: channel name + settings gear. Deliberately plain, no theming.
|
||||||
|
Row(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.padding(horizontal = 6.dp, vertical = 2.dp),
|
||||||
|
horizontalArrangement = Arrangement.SpaceBetween,
|
||||||
|
verticalAlignment = Alignment.CenterVertically
|
||||||
|
) {
|
||||||
|
Text(channelName, color = textColor, fontFamily = FontFamily.Monospace, style = MaterialTheme.typography.labelSmall)
|
||||||
|
Row {
|
||||||
|
IconButton(onClick = { onShowUserListChange(!displaySettings.showUserList) }, modifier = Modifier.size(28.dp)) {
|
||||||
|
Icon(Icons.Default.Person, contentDescription = "Toggle user list", tint = textColor, modifier = Modifier.size(16.dp))
|
||||||
|
}
|
||||||
|
IconButton(onClick = { showSettings = !showSettings }, modifier = Modifier.size(28.dp)) {
|
||||||
|
Icon(Icons.Default.Settings, contentDescription = "Window settings", tint = textColor, modifier = Modifier.size(16.dp))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (showSettings) {
|
||||||
|
WindowSettingsPanel(
|
||||||
|
displaySettings = displaySettings,
|
||||||
|
textColor = textColor,
|
||||||
|
onAlphaChange = onAlphaChange,
|
||||||
|
onTextColorChange = onTextColorChange,
|
||||||
|
onFontSizeChange = onFontSizeChange,
|
||||||
|
onNickColorsToggle = onNickColorsToggle,
|
||||||
|
onMentionHighlightColorChange = onMentionHighlightColorChange,
|
||||||
|
onShowImagePreviewsChange = onShowImagePreviewsChange
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Scrollback + user list, side by side, filling the available height
|
||||||
|
Row(modifier = Modifier.weight(1f).fillMaxWidth()) {
|
||||||
|
// Scrollback height is snapped down to a whole multiple of one line's height so a
|
||||||
|
// resize never leaves a partially-rendered line visible - the leftover fractional
|
||||||
|
// space is absorbed by the Spacer below instead, leaving the rest of the layout
|
||||||
|
// (nicklist, input bar) unaffected.
|
||||||
|
BoxWithConstraints(
|
||||||
|
modifier = Modifier
|
||||||
|
.weight(1f)
|
||||||
|
.fillMaxHeight()
|
||||||
|
) {
|
||||||
|
val density = LocalDensity.current
|
||||||
|
val textMeasurer = rememberTextMeasurer()
|
||||||
|
val lineHeightPx = remember(displaySettings.fontSize) {
|
||||||
|
textMeasurer.measure(
|
||||||
|
text = "Mg",
|
||||||
|
style = androidx.compose.ui.text.TextStyle(
|
||||||
|
fontFamily = FontFamily.Monospace,
|
||||||
|
fontSize = displaySettings.fontSize.sp
|
||||||
|
)
|
||||||
|
).size.height
|
||||||
|
}
|
||||||
|
val lineHeightDp = with(density) { lineHeightPx.toDp() }
|
||||||
|
val snappedHeight = if (lineHeightDp > 0.dp) {
|
||||||
|
lineHeightDp * floor(maxHeight / lineHeightDp).toInt()
|
||||||
|
} else {
|
||||||
|
maxHeight
|
||||||
|
}
|
||||||
|
|
||||||
|
Column(modifier = Modifier.fillMaxSize()) {
|
||||||
|
LazyColumn(
|
||||||
|
state = listState,
|
||||||
|
modifier = Modifier
|
||||||
|
.height(snappedHeight)
|
||||||
|
.padding(horizontal = 6.dp)
|
||||||
|
) {
|
||||||
|
items(buffer?.messages ?: emptyList()) { msg ->
|
||||||
|
val ts = timeFormat.format(Date(msg.timestampMillis))
|
||||||
|
val bodyAnnotated = parseMircToAnnotatedString(msg.rawText, textColor)
|
||||||
|
val mentioned = isMention(msg.rawText)
|
||||||
|
val nickColor = if (displaySettings.nickColorsEnabled) colorForNick(msg.sender ?: "") else textColor
|
||||||
|
val mutedColor = textColor.copy(alpha = 0.7f)
|
||||||
|
Column(modifier = Modifier.fillMaxWidth()) {
|
||||||
|
Row(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.let { if (mentioned) it.background(Color(displaySettings.mentionHighlightColorArgb)) else it }
|
||||||
|
) {
|
||||||
|
when (msg.kind) {
|
||||||
|
MessageKind.CHAT -> {
|
||||||
|
Text(
|
||||||
|
text = "[$ts] ",
|
||||||
|
color = mutedColor,
|
||||||
|
fontFamily = FontFamily.Monospace,
|
||||||
|
fontSize = displaySettings.fontSize.sp
|
||||||
|
)
|
||||||
|
Text(
|
||||||
|
text = "<${msg.sender}>",
|
||||||
|
color = nickColor,
|
||||||
|
fontFamily = FontFamily.Monospace,
|
||||||
|
fontSize = displaySettings.fontSize.sp
|
||||||
|
)
|
||||||
|
Text(
|
||||||
|
text = " ",
|
||||||
|
color = mutedColor,
|
||||||
|
fontFamily = FontFamily.Monospace,
|
||||||
|
fontSize = displaySettings.fontSize.sp
|
||||||
|
)
|
||||||
|
}
|
||||||
|
MessageKind.ACTION -> {
|
||||||
|
Text(
|
||||||
|
text = "[$ts] * ",
|
||||||
|
color = mutedColor,
|
||||||
|
fontFamily = FontFamily.Monospace,
|
||||||
|
fontSize = displaySettings.fontSize.sp
|
||||||
|
)
|
||||||
|
Text(
|
||||||
|
text = "${msg.sender} ",
|
||||||
|
color = nickColor,
|
||||||
|
fontFamily = FontFamily.Monospace,
|
||||||
|
fontSize = displaySettings.fontSize.sp
|
||||||
|
)
|
||||||
|
}
|
||||||
|
else -> {
|
||||||
|
Text(
|
||||||
|
text = "[$ts] * ",
|
||||||
|
color = mutedColor,
|
||||||
|
fontFamily = FontFamily.Monospace,
|
||||||
|
fontSize = displaySettings.fontSize.sp
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
ClickableText(
|
||||||
|
text = bodyAnnotated,
|
||||||
|
style = androidx.compose.ui.text.TextStyle(
|
||||||
|
fontFamily = FontFamily.Monospace,
|
||||||
|
fontSize = displaySettings.fontSize.sp
|
||||||
|
),
|
||||||
|
onClick = { offset ->
|
||||||
|
bodyAnnotated.getStringAnnotations(tag = "URL", start = offset, end = offset)
|
||||||
|
.firstOrNull()?.let { annotation ->
|
||||||
|
context.startActivity(Intent(Intent.ACTION_VIEW, Uri.parse(annotation.item)))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
if (displaySettings.showImagePreviews) {
|
||||||
|
extractImageUrls(msg.rawText).forEach { url ->
|
||||||
|
AsyncImage(
|
||||||
|
model = url,
|
||||||
|
contentDescription = null,
|
||||||
|
modifier = Modifier
|
||||||
|
.padding(start = 8.dp, top = 2.dp, bottom = 4.dp)
|
||||||
|
.heightIn(max = 200.dp)
|
||||||
|
.clip(RoundedCornerShape(4.dp))
|
||||||
|
.clickable {
|
||||||
|
context.startActivity(Intent(Intent.ACTION_VIEW, Uri.parse(url)))
|
||||||
|
},
|
||||||
|
contentScale = ContentScale.Fit
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Spacer(modifier = Modifier.weight(1f))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// User list (tap and hold for OP actions)
|
||||||
|
if (displaySettings.showUserList && !buffer?.users.isNullOrEmpty()) {
|
||||||
|
LazyColumn(
|
||||||
|
modifier = Modifier
|
||||||
|
.width(140.dp)
|
||||||
|
.fillMaxHeight()
|
||||||
|
.padding(4.dp)
|
||||||
|
) {
|
||||||
|
items(buffer!!.users) { user ->
|
||||||
|
Text(
|
||||||
|
text = (if (user.isOp) "@" else if (user.isVoice) "+" else "") + user.nick,
|
||||||
|
color = textColor,
|
||||||
|
fontFamily = FontFamily.Monospace,
|
||||||
|
fontSize = displaySettings.fontSize.sp,
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.padding(vertical = 2.dp)
|
||||||
|
.combinedClickable(
|
||||||
|
onClick = {
|
||||||
|
val prefill = "/msg ${user.nick} "
|
||||||
|
input = TextFieldValue(prefill, androidx.compose.ui.text.TextRange(prefill.length))
|
||||||
|
inputFocusRequester.requestFocus()
|
||||||
|
},
|
||||||
|
onLongClick = { contextMenuUser = user }
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Input bar
|
||||||
|
Row(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.padding(4.dp),
|
||||||
|
verticalAlignment = Alignment.CenterVertically
|
||||||
|
) {
|
||||||
|
Box(
|
||||||
|
modifier = Modifier
|
||||||
|
.weight(1f)
|
||||||
|
.heightIn(min = 32.dp, max = 40.dp)
|
||||||
|
.padding(vertical = 2.dp),
|
||||||
|
contentAlignment = Alignment.CenterStart
|
||||||
|
) {
|
||||||
|
BasicTextField(
|
||||||
|
value = input,
|
||||||
|
onValueChange = { input = it },
|
||||||
|
singleLine = true,
|
||||||
|
textStyle = androidx.compose.ui.text.TextStyle(
|
||||||
|
fontFamily = FontFamily.Monospace,
|
||||||
|
color = textColor,
|
||||||
|
fontSize = displaySettings.fontSize.sp
|
||||||
|
),
|
||||||
|
cursorBrush = SolidColor(textColor),
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.focusRequester(inputFocusRequester)
|
||||||
|
.onPreviewKeyEvent { event ->
|
||||||
|
// Hardware-keyboard shortcuts: Ctrl+K color picker,
|
||||||
|
// Ctrl+B bold, Ctrl+I italic. Tablet-with-keyboard friendly.
|
||||||
|
if (event.type == KeyEventType.KeyDown && event.isCtrlPressed) {
|
||||||
|
when (event.key) {
|
||||||
|
Key.K -> { showColorPicker = true; true }
|
||||||
|
Key.B -> { insertControlChar('\u0002'); true }
|
||||||
|
Key.I -> { insertControlChar('\u001D'); true }
|
||||||
|
else -> false
|
||||||
|
}
|
||||||
|
} else if (event.type == KeyEventType.KeyDown && event.key == Key.Enter && !event.isCtrlPressed) {
|
||||||
|
// Hardware Enter sends, same as the software keyboard's
|
||||||
|
// ImeAction.Send below. Ctrl+Enter is left alone in case
|
||||||
|
// it's wanted for something else later.
|
||||||
|
sendCurrentInput()
|
||||||
|
true
|
||||||
|
} else if (event.type == KeyEventType.KeyDown && event.key == Key.Tab && !event.isCtrlPressed) {
|
||||||
|
tryTabComplete()
|
||||||
|
} else if (event.type == KeyEventType.KeyDown && event.key == Key.DirectionUp) {
|
||||||
|
if (history.isEmpty()) {
|
||||||
|
false
|
||||||
|
} else {
|
||||||
|
if (historyIndex == -1) draftBeforeHistory = input.text
|
||||||
|
historyIndex = (historyIndex + 1).coerceAtMost(history.size - 1)
|
||||||
|
val entry = history[history.size - 1 - historyIndex]
|
||||||
|
input = TextFieldValue(entry, androidx.compose.ui.text.TextRange(entry.length))
|
||||||
|
true
|
||||||
|
}
|
||||||
|
} else if (event.type == KeyEventType.KeyDown && event.key == Key.DirectionDown) {
|
||||||
|
if (historyIndex == -1) {
|
||||||
|
false
|
||||||
|
} else {
|
||||||
|
historyIndex--
|
||||||
|
if (historyIndex < 0) {
|
||||||
|
input = TextFieldValue(draftBeforeHistory, androidx.compose.ui.text.TextRange(draftBeforeHistory.length))
|
||||||
|
} else {
|
||||||
|
val entry = history[history.size - 1 - historyIndex]
|
||||||
|
input = TextFieldValue(entry, androidx.compose.ui.text.TextRange(entry.length))
|
||||||
|
}
|
||||||
|
true
|
||||||
|
}
|
||||||
|
} else false
|
||||||
|
},
|
||||||
|
keyboardOptions = KeyboardOptions(imeAction = ImeAction.Send),
|
||||||
|
keyboardActions = KeyboardActions(onSend = { sendCurrentInput() })
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
contextMenuUser?.let { user ->
|
||||||
|
OpActionMenu(
|
||||||
|
user = user,
|
||||||
|
onDismiss = { contextMenuUser = null },
|
||||||
|
onKick = { onKick(user.nick); contextMenuUser = null },
|
||||||
|
onBan = { onBan(user.nick); contextMenuUser = null },
|
||||||
|
onOp = { onOp(user.nick); contextMenuUser = null },
|
||||||
|
onDeop = { onDeop(user.nick); contextMenuUser = null }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (showColorPicker) {
|
||||||
|
MircColorPickerDialog(
|
||||||
|
onDismiss = { showColorPicker = false },
|
||||||
|
onPick = { fg, bg ->
|
||||||
|
insertColorCode(fg, bg)
|
||||||
|
showColorPicker = false
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Standard 16-color mIRC palette swatches, same indices the wire protocol uses
|
||||||
|
// (\u0003FG,BG), so picking a swatch here inserts the exact code mIRC/HexChat/etc send.
|
||||||
|
private val mircSwatches = mircPalette.mapIndexed { index, color -> index to color }
|
||||||
|
|
||||||
|
// Deterministic per-nick color so the same nick always renders the same color within a
|
||||||
|
// session, without needing to track color assignments anywhere.
|
||||||
|
private val nickColorPalette = listOf(
|
||||||
|
Color(0xFFFF6B6B), Color(0xFF4ECDC4), Color(0xFFFFD93D), Color(0xFF95E1D3),
|
||||||
|
Color(0xFFF38181), Color(0xFFAA96DA), Color(0xFFFCBAD3), Color(0xFFA8D8EA),
|
||||||
|
Color(0xFFFFAAA5), Color(0xFF88D8B0)
|
||||||
|
)
|
||||||
|
|
||||||
|
private fun colorForNick(nick: String): Color =
|
||||||
|
nickColorPalette[(nick.hashCode().and(0x7FFFFFFF)) % nickColorPalette.size]
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun MircColorPickerDialog(onDismiss: () -> Unit, onPick: (fg: Int, bg: Int?) -> Unit) {
|
||||||
|
var selectedFg by remember { mutableStateOf<Int?>(null) }
|
||||||
|
var selectedBg by remember { mutableStateOf<Int?>(null) }
|
||||||
|
var pickingBg by remember { mutableStateOf(false) }
|
||||||
|
|
||||||
|
AlertDialog(
|
||||||
|
onDismissRequest = onDismiss,
|
||||||
|
title = { Text(if (pickingBg) "Background (optional)" else "Text color") },
|
||||||
|
text = {
|
||||||
|
Column {
|
||||||
|
FlowSwatchGrid(
|
||||||
|
selected = if (pickingBg) selectedBg else selectedFg,
|
||||||
|
onSelect = { idx -> if (pickingBg) selectedBg = idx else selectedFg = idx }
|
||||||
|
)
|
||||||
|
Spacer(Modifier.height(8.dp))
|
||||||
|
if (!pickingBg) {
|
||||||
|
TextButton(onClick = { pickingBg = true }, enabled = selectedFg != null) {
|
||||||
|
Text("Next: pick background (optional) \u2192")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
confirmButton = {
|
||||||
|
TextButton(
|
||||||
|
onClick = { if (selectedFg != null) onPick(selectedFg!!, selectedBg) },
|
||||||
|
enabled = selectedFg != null
|
||||||
|
) { Text("Insert") }
|
||||||
|
},
|
||||||
|
dismissButton = {
|
||||||
|
TextButton(onClick = onDismiss) { Text("Cancel") }
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun FlowSwatchGrid(selected: Int?, onSelect: (Int) -> Unit) {
|
||||||
|
Column {
|
||||||
|
mircSwatches.chunked(4).forEach { row ->
|
||||||
|
Row {
|
||||||
|
row.forEach { (idx, color) ->
|
||||||
|
Box(
|
||||||
|
modifier = Modifier
|
||||||
|
.padding(2.dp)
|
||||||
|
.size(36.dp)
|
||||||
|
.background(color)
|
||||||
|
.border(if (selected == idx) 3.dp else 1.dp, Color.Black)
|
||||||
|
.clickableNoRipple { onSelect(idx) }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@OptIn(androidx.compose.foundation.ExperimentalFoundationApi::class)
|
||||||
|
@Composable
|
||||||
|
private fun Modifier.clickableNoRipple(onClick: () -> Unit): Modifier = this.then(
|
||||||
|
Modifier.combinedClickable(onClick = onClick, onLongClick = {})
|
||||||
|
)
|
||||||
|
|
||||||
|
// Reuses the same mIRC swatch grid as Ctrl+K, but picks a single color and converts it
|
||||||
|
// to a translucent (~25% alpha) ARGB int suitable for a message-row background tint.
|
||||||
|
@Composable
|
||||||
|
private fun MentionColorPickerDialog(onDismiss: () -> Unit, onPick: (Int) -> Unit) {
|
||||||
|
AlertDialog(
|
||||||
|
onDismissRequest = onDismiss,
|
||||||
|
title = { Text("Mention highlight color") },
|
||||||
|
text = {
|
||||||
|
FlowSwatchGrid(
|
||||||
|
selected = null,
|
||||||
|
onSelect = { idx ->
|
||||||
|
val rgb = mircSwatches.first { it.first == idx }.second.toArgb()
|
||||||
|
val translucentArgb = (0x40 shl 24) or (rgb and 0x00FFFFFF)
|
||||||
|
onPick(translucentArgb)
|
||||||
|
onDismiss()
|
||||||
|
}
|
||||||
|
)
|
||||||
|
},
|
||||||
|
confirmButton = {
|
||||||
|
TextButton(onClick = onDismiss) { Text("Close") }
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun WindowSettingsPanel(
|
||||||
|
displaySettings: WindowDisplaySettings,
|
||||||
|
textColor: Color,
|
||||||
|
onAlphaChange: (Float) -> Unit,
|
||||||
|
onTextColorChange: (Int) -> Unit,
|
||||||
|
onFontSizeChange: (Float) -> Unit,
|
||||||
|
onNickColorsToggle: (Boolean) -> Unit,
|
||||||
|
onMentionHighlightColorChange: (Int) -> Unit,
|
||||||
|
onShowImagePreviewsChange: (Boolean) -> Unit
|
||||||
|
) {
|
||||||
|
var showMentionColorPicker by remember { mutableStateOf(false) }
|
||||||
|
val sliderColors = SliderDefaults.colors(
|
||||||
|
thumbColor = Color.White,
|
||||||
|
activeTrackColor = Color.White,
|
||||||
|
inactiveTrackColor = Color.White.copy(alpha = 0.3f)
|
||||||
|
)
|
||||||
|
Column(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.border(1.dp, Color.Black)
|
||||||
|
.padding(8.dp)
|
||||||
|
) {
|
||||||
|
Text("Background transparency", color = textColor, style = MaterialTheme.typography.bodySmall)
|
||||||
|
Slider(
|
||||||
|
value = displaySettings.backgroundAlpha,
|
||||||
|
onValueChange = onAlphaChange,
|
||||||
|
valueRange = 0f..1f,
|
||||||
|
colors = sliderColors
|
||||||
|
)
|
||||||
|
Spacer(Modifier.height(4.dp))
|
||||||
|
Text("Font size", color = textColor, style = MaterialTheme.typography.bodySmall)
|
||||||
|
Slider(
|
||||||
|
value = displaySettings.fontSize,
|
||||||
|
onValueChange = onFontSizeChange,
|
||||||
|
valueRange = 10f..24f,
|
||||||
|
colors = sliderColors
|
||||||
|
)
|
||||||
|
Spacer(Modifier.height(4.dp))
|
||||||
|
Text("Text color:", color = textColor, style = MaterialTheme.typography.bodySmall)
|
||||||
|
FlowSwatchGrid(
|
||||||
|
selected = displaySettings.textColorMircIndex,
|
||||||
|
onSelect = onTextColorChange
|
||||||
|
)
|
||||||
|
Spacer(Modifier.height(4.dp))
|
||||||
|
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||||
|
Text("Nick colors", color = textColor, style = MaterialTheme.typography.bodySmall)
|
||||||
|
Spacer(Modifier.width(8.dp))
|
||||||
|
Switch(
|
||||||
|
checked = displaySettings.nickColorsEnabled,
|
||||||
|
onCheckedChange = onNickColorsToggle
|
||||||
|
)
|
||||||
|
}
|
||||||
|
Spacer(Modifier.height(4.dp))
|
||||||
|
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||||
|
Text("Mention highlight color:", color = textColor, style = MaterialTheme.typography.bodySmall)
|
||||||
|
Spacer(Modifier.width(8.dp))
|
||||||
|
Box(
|
||||||
|
modifier = Modifier
|
||||||
|
.size(24.dp)
|
||||||
|
.background(Color(displaySettings.mentionHighlightColorArgb))
|
||||||
|
.border(1.dp, Color.White)
|
||||||
|
.clickableNoRipple { showMentionColorPicker = true }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
Spacer(Modifier.height(4.dp))
|
||||||
|
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||||
|
Text("Show image previews", color = textColor, style = MaterialTheme.typography.bodySmall)
|
||||||
|
Spacer(Modifier.width(8.dp))
|
||||||
|
Switch(
|
||||||
|
checked = displaySettings.showImagePreviews,
|
||||||
|
onCheckedChange = onShowImagePreviewsChange
|
||||||
|
)
|
||||||
|
}
|
||||||
|
if (showMentionColorPicker) {
|
||||||
|
MentionColorPickerDialog(
|
||||||
|
onDismiss = { showMentionColorPicker = false },
|
||||||
|
onPick = onMentionHighlightColorChange
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun OpActionMenu(
|
||||||
|
user: ChannelUser,
|
||||||
|
onDismiss: () -> Unit,
|
||||||
|
onKick: () -> Unit,
|
||||||
|
onBan: () -> Unit,
|
||||||
|
onOp: () -> Unit,
|
||||||
|
onDeop: () -> Unit
|
||||||
|
) {
|
||||||
|
AlertDialog(
|
||||||
|
onDismissRequest = onDismiss,
|
||||||
|
title = { Text(user.nick) },
|
||||||
|
text = {
|
||||||
|
Column {
|
||||||
|
TextButton(onClick = onKick) { Text("Kick") }
|
||||||
|
TextButton(onClick = onBan) { Text("Ban") }
|
||||||
|
if (user.isOp) {
|
||||||
|
TextButton(onClick = onDeop) { Text("De-op") }
|
||||||
|
} else {
|
||||||
|
TextButton(onClick = onOp) { Text("Op") }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
confirmButton = {
|
||||||
|
TextButton(onClick = onDismiss) { Text("Close") }
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
|
android:width="108dp"
|
||||||
|
android:height="108dp"
|
||||||
|
android:viewportWidth="108"
|
||||||
|
android:viewportHeight="108">
|
||||||
|
<path android:fillColor="#000000" android:pathData="M0,0h108v108h-108z" />
|
||||||
|
<path
|
||||||
|
android:fillColor="#FFFFFF"
|
||||||
|
android:pathData="M20,30 L88,30 L88,70 L45,70 L28,86 L28,70 L20,70 Z" />
|
||||||
|
</vector>
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
|
android:width="24dp"
|
||||||
|
android:height="24dp"
|
||||||
|
android:viewportWidth="24"
|
||||||
|
android:viewportHeight="24">
|
||||||
|
<path
|
||||||
|
android:fillColor="#FFFFFF"
|
||||||
|
android:pathData="M4,4 L20,4 L20,16 L10,16 L6,20 L6,16 L4,16 Z" />
|
||||||
|
</vector>
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
<resources>
|
||||||
|
<color name="black">#FF000000</color>
|
||||||
|
<color name="white">#FFFFFFFF</color>
|
||||||
|
</resources>
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
<resources>
|
||||||
|
<string name="app_name">wIRC</string>
|
||||||
|
</resources>
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
<resources>
|
||||||
|
<!-- Base app theme, plain Material3 dark, no bloggin.space branding -->
|
||||||
|
<style name="Theme.IrcWindows" parent="android:Theme.Material.NoActionBar">
|
||||||
|
</style>
|
||||||
|
|
||||||
|
<!-- Transparent variant used by every Activity so the Compose layer fully controls
|
||||||
|
its own background alpha, border, and chrome instead of the system decorations. -->
|
||||||
|
<style name="Theme.IrcWindows.Transparent" parent="Theme.IrcWindows">
|
||||||
|
<item name="android:windowIsTranslucent">true</item>
|
||||||
|
<item name="android:windowBackground">@android:color/transparent</item>
|
||||||
|
<item name="android:windowNoTitle">true</item>
|
||||||
|
<item name="android:windowContentOverlay">@null</item>
|
||||||
|
<item name="android:windowAnimationStyle">@android:style/Animation</item>
|
||||||
|
<item name="android:colorBackgroundCacheHint">@null</item>
|
||||||
|
<item name="android:windowIsFloating">false</item>
|
||||||
|
</style>
|
||||||
|
</resources>
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
plugins {
|
||||||
|
id("com.android.application") version "8.6.0" apply false
|
||||||
|
id("org.jetbrains.kotlin.android") version "1.9.24" apply false
|
||||||
|
}
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
org.gradle.jvmargs=-Xmx2048m
|
||||||
|
android.useAndroidX=true
|
||||||
|
kotlin.code.style=official
|
||||||
Vendored
BIN
Binary file not shown.
+9
@@ -0,0 +1,9 @@
|
|||||||
|
distributionBase=GRADLE_USER_HOME
|
||||||
|
distributionPath=wrapper/dists
|
||||||
|
distributionUrl=https\://services.gradle.org/distributions/gradle-8.9-bin.zip
|
||||||
|
networkTimeout=10000
|
||||||
|
retries=0
|
||||||
|
retryBackOffMs=500
|
||||||
|
validateDistributionUrl=true
|
||||||
|
zipStoreBase=GRADLE_USER_HOME
|
||||||
|
zipStorePath=wrapper/dists
|
||||||
@@ -0,0 +1,248 @@
|
|||||||
|
#!/bin/sh
|
||||||
|
|
||||||
|
#
|
||||||
|
# Copyright © 2015 the original authors.
|
||||||
|
#
|
||||||
|
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
# you may not use this file except in compliance with the License.
|
||||||
|
# You may obtain a copy of the License at
|
||||||
|
#
|
||||||
|
# https://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
#
|
||||||
|
# Unless required by applicable law or agreed to in writing, software
|
||||||
|
# distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
# See the License for the specific language governing permissions and
|
||||||
|
# limitations under the License.
|
||||||
|
#
|
||||||
|
# SPDX-License-Identifier: Apache-2.0
|
||||||
|
#
|
||||||
|
|
||||||
|
##############################################################################
|
||||||
|
#
|
||||||
|
# gradlew start up script for POSIX generated by Gradle.
|
||||||
|
#
|
||||||
|
# Important for running:
|
||||||
|
#
|
||||||
|
# (1) You need a POSIX-compliant shell to run this script. If your /bin/sh is
|
||||||
|
# noncompliant, but you have some other compliant shell such as ksh or
|
||||||
|
# bash, then to run this script, type that shell name before the whole
|
||||||
|
# command line, like:
|
||||||
|
#
|
||||||
|
# ksh gradlew
|
||||||
|
#
|
||||||
|
# Busybox and similar reduced shells will NOT work, because this script
|
||||||
|
# requires all of these POSIX shell features:
|
||||||
|
# * functions;
|
||||||
|
# * expansions «$var», «${var}», «${var:-default}», «${var+SET}»,
|
||||||
|
# «${var#prefix}», «${var%suffix}», and «$( cmd )»;
|
||||||
|
# * compound commands having a testable exit status, especially «case»;
|
||||||
|
# * various built-in commands including «command», «set», and «ulimit».
|
||||||
|
#
|
||||||
|
# Important for patching:
|
||||||
|
#
|
||||||
|
# (2) This script targets any POSIX shell, so it avoids extensions provided
|
||||||
|
# by Bash, Ksh, etc; in particular arrays are avoided.
|
||||||
|
#
|
||||||
|
# The "traditional" practice of packing multiple parameters into a
|
||||||
|
# space-separated string is a well documented source of bugs and security
|
||||||
|
# problems, so this is (mostly) avoided, by progressively accumulating
|
||||||
|
# options in "$@", and eventually passing that to Java.
|
||||||
|
#
|
||||||
|
# Where the inherited environment variables (DEFAULT_JVM_OPTS, JAVA_OPTS,
|
||||||
|
# and GRADLE_OPTS) rely on word-splitting, this is performed explicitly;
|
||||||
|
# see the in-line comments for details.
|
||||||
|
#
|
||||||
|
# There are tweaks for specific operating systems such as AIX, CygWin,
|
||||||
|
# Darwin, MinGW, and NonStop.
|
||||||
|
#
|
||||||
|
# (3) This script is generated from the Groovy template
|
||||||
|
# https://github.com/gradle/gradle/blob/3d91ce3b8caaf77ad09f381f43615b715b53f72c/platforms/jvm/plugins-application/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt
|
||||||
|
# within the Gradle project.
|
||||||
|
#
|
||||||
|
# You can find Gradle at https://github.com/gradle/gradle/.
|
||||||
|
#
|
||||||
|
##############################################################################
|
||||||
|
|
||||||
|
# Attempt to set APP_HOME
|
||||||
|
|
||||||
|
# Resolve links: $0 may be a link
|
||||||
|
app_path=$0
|
||||||
|
|
||||||
|
# Need this for daisy-chained symlinks.
|
||||||
|
while
|
||||||
|
APP_HOME=${app_path%"${app_path##*/}"} # leaves a trailing /; empty if no leading path
|
||||||
|
[ -h "$app_path" ]
|
||||||
|
do
|
||||||
|
ls=$( ls -ld "$app_path" )
|
||||||
|
link=${ls#*' -> '}
|
||||||
|
case $link in #(
|
||||||
|
/*) app_path=$link ;; #(
|
||||||
|
*) app_path=$APP_HOME$link ;;
|
||||||
|
esac
|
||||||
|
done
|
||||||
|
|
||||||
|
# This is normally unused
|
||||||
|
# shellcheck disable=SC2034
|
||||||
|
APP_BASE_NAME=${0##*/}
|
||||||
|
# Discard cd standard output in case $CDPATH is set (https://github.com/gradle/gradle/issues/25036)
|
||||||
|
APP_HOME=$( cd -P "${APP_HOME:-./}" > /dev/null && printf '%s\n' "$PWD" ) || exit
|
||||||
|
|
||||||
|
# Use the maximum available, or set MAX_FD != -1 to use that value.
|
||||||
|
MAX_FD=maximum
|
||||||
|
|
||||||
|
warn () {
|
||||||
|
echo "$*"
|
||||||
|
} >&2
|
||||||
|
|
||||||
|
die () {
|
||||||
|
echo
|
||||||
|
echo "$*"
|
||||||
|
echo
|
||||||
|
exit 1
|
||||||
|
} >&2
|
||||||
|
|
||||||
|
# OS specific support (must be 'true' or 'false').
|
||||||
|
cygwin=false
|
||||||
|
msys=false
|
||||||
|
darwin=false
|
||||||
|
nonstop=false
|
||||||
|
case "$( uname )" in #(
|
||||||
|
CYGWIN* ) cygwin=true ;; #(
|
||||||
|
Darwin* ) darwin=true ;; #(
|
||||||
|
MSYS* | MINGW* ) msys=true ;; #(
|
||||||
|
NONSTOP* ) nonstop=true ;;
|
||||||
|
esac
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
# Determine the Java command to use to start the JVM.
|
||||||
|
if [ -n "$JAVA_HOME" ] ; then
|
||||||
|
if [ -x "$JAVA_HOME/jre/sh/java" ] ; then
|
||||||
|
# IBM's JDK on AIX uses strange locations for the executables
|
||||||
|
JAVACMD=$JAVA_HOME/jre/sh/java
|
||||||
|
else
|
||||||
|
JAVACMD=$JAVA_HOME/bin/java
|
||||||
|
fi
|
||||||
|
if [ ! -x "$JAVACMD" ] ; then
|
||||||
|
die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME
|
||||||
|
|
||||||
|
Please set the JAVA_HOME variable in your environment to match the
|
||||||
|
location of your Java installation."
|
||||||
|
fi
|
||||||
|
else
|
||||||
|
JAVACMD=java
|
||||||
|
if ! command -v java >/dev/null 2>&1
|
||||||
|
then
|
||||||
|
die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.
|
||||||
|
|
||||||
|
Please set the JAVA_HOME variable in your environment to match the
|
||||||
|
location of your Java installation."
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Increase the maximum file descriptors if we can.
|
||||||
|
if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then
|
||||||
|
case $MAX_FD in #(
|
||||||
|
max*)
|
||||||
|
# In POSIX sh, ulimit -H is undefined. That's why the result is checked to see if it worked.
|
||||||
|
# shellcheck disable=SC2039,SC3045
|
||||||
|
MAX_FD=$( ulimit -H -n ) ||
|
||||||
|
warn "Could not query maximum file descriptor limit"
|
||||||
|
esac
|
||||||
|
case $MAX_FD in #(
|
||||||
|
'' | soft) :;; #(
|
||||||
|
*)
|
||||||
|
# In POSIX sh, ulimit -n is undefined. That's why the result is checked to see if it worked.
|
||||||
|
# shellcheck disable=SC2039,SC3045
|
||||||
|
ulimit -n "$MAX_FD" ||
|
||||||
|
warn "Could not set maximum file descriptor limit to $MAX_FD"
|
||||||
|
esac
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Collect all arguments for the java command, stacking in reverse order:
|
||||||
|
# * args from the command line
|
||||||
|
# * the main class name
|
||||||
|
# * -classpath
|
||||||
|
# * -D...appname settings
|
||||||
|
# * --module-path (only if needed)
|
||||||
|
# * DEFAULT_JVM_OPTS, JAVA_OPTS, and GRADLE_OPTS environment variables.
|
||||||
|
|
||||||
|
# For Cygwin or MSYS, switch paths to Windows format before running java
|
||||||
|
if "$cygwin" || "$msys" ; then
|
||||||
|
APP_HOME=$( cygpath --path --mixed "$APP_HOME" )
|
||||||
|
|
||||||
|
JAVACMD=$( cygpath --unix "$JAVACMD" )
|
||||||
|
|
||||||
|
# Now convert the arguments - kludge to limit ourselves to /bin/sh
|
||||||
|
for arg do
|
||||||
|
if
|
||||||
|
case $arg in #(
|
||||||
|
-*) false ;; # don't mess with options #(
|
||||||
|
/?*) t=${arg#/} t=/${t%%/*} # looks like a POSIX filepath
|
||||||
|
[ -e "$t" ] ;; #(
|
||||||
|
*) false ;;
|
||||||
|
esac
|
||||||
|
then
|
||||||
|
arg=$( cygpath --path --ignore --mixed "$arg" )
|
||||||
|
fi
|
||||||
|
# Roll the args list around exactly as many times as the number of
|
||||||
|
# args, so each arg winds up back in the position where it started, but
|
||||||
|
# possibly modified.
|
||||||
|
#
|
||||||
|
# NB: a `for` loop captures its iteration list before it begins, so
|
||||||
|
# changing the positional parameters here affects neither the number of
|
||||||
|
# iterations, nor the values presented in `arg`.
|
||||||
|
shift # remove old arg
|
||||||
|
set -- "$@" "$arg" # push replacement arg
|
||||||
|
done
|
||||||
|
fi
|
||||||
|
|
||||||
|
|
||||||
|
# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
|
||||||
|
DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"'
|
||||||
|
|
||||||
|
# Collect all arguments for the java command:
|
||||||
|
# * DEFAULT_JVM_OPTS, JAVA_OPTS, and optsEnvironmentVar are not allowed to contain shell fragments,
|
||||||
|
# and any embedded shellness will be escaped.
|
||||||
|
# * For example: A user cannot expect ${Hostname} to be expanded, as it is an environment variable and will be
|
||||||
|
# treated as '${Hostname}' itself on the command line.
|
||||||
|
|
||||||
|
set -- \
|
||||||
|
"-Dorg.gradle.appname=$APP_BASE_NAME" \
|
||||||
|
-jar "$APP_HOME/gradle/wrapper/gradle-wrapper.jar" \
|
||||||
|
"$@"
|
||||||
|
|
||||||
|
# Stop when "xargs" is not available.
|
||||||
|
if ! command -v xargs >/dev/null 2>&1
|
||||||
|
then
|
||||||
|
die "xargs is not available"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Use "xargs" to parse quoted args.
|
||||||
|
#
|
||||||
|
# With -n1 it outputs one arg per line, with the quotes and backslashes removed.
|
||||||
|
#
|
||||||
|
# In Bash we could simply go:
|
||||||
|
#
|
||||||
|
# readarray ARGS < <( xargs -n1 <<<"$var" ) &&
|
||||||
|
# set -- "${ARGS[@]}" "$@"
|
||||||
|
#
|
||||||
|
# but POSIX shell has neither arrays nor command substitution, so instead we
|
||||||
|
# post-process each arg (as a line of input to sed) to backslash-escape any
|
||||||
|
# character that might be a shell metacharacter, then use eval to reverse
|
||||||
|
# that process (while maintaining the separation between arguments), and wrap
|
||||||
|
# the whole thing up as a single "set" statement.
|
||||||
|
#
|
||||||
|
# This will of course break if any of these variables contains a newline or
|
||||||
|
# an unmatched quote.
|
||||||
|
#
|
||||||
|
|
||||||
|
eval "set -- $(
|
||||||
|
printf '%s\n' "$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS" |
|
||||||
|
xargs -n1 |
|
||||||
|
sed ' s~[^-[:alnum:]+,./:=@_]~\\&~g; ' |
|
||||||
|
tr '\n' ' '
|
||||||
|
)" '"$@"'
|
||||||
|
|
||||||
|
exec "$JAVACMD" "$@"
|
||||||
Vendored
+82
@@ -0,0 +1,82 @@
|
|||||||
|
@rem
|
||||||
|
@rem Copyright 2015 the original author or authors.
|
||||||
|
@rem
|
||||||
|
@rem Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
@rem you may not use this file except in compliance with the License.
|
||||||
|
@rem You may obtain a copy of the License at
|
||||||
|
@rem
|
||||||
|
@rem https://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
@rem
|
||||||
|
@rem Unless required by applicable law or agreed to in writing, software
|
||||||
|
@rem distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
@rem See the License for the specific language governing permissions and
|
||||||
|
@rem limitations under the License.
|
||||||
|
@rem
|
||||||
|
@rem SPDX-License-Identifier: Apache-2.0
|
||||||
|
@rem
|
||||||
|
|
||||||
|
@if "%DEBUG%"=="" @echo off
|
||||||
|
@rem ##########################################################################
|
||||||
|
@rem
|
||||||
|
@rem gradlew startup script for Windows
|
||||||
|
@rem
|
||||||
|
@rem ##########################################################################
|
||||||
|
|
||||||
|
@rem Set local scope for the variables, and ensure extensions are enabled
|
||||||
|
setlocal EnableExtensions
|
||||||
|
|
||||||
|
set DIRNAME=%~dp0
|
||||||
|
if "%DIRNAME%"=="" set DIRNAME=.
|
||||||
|
@rem This is normally unused
|
||||||
|
set APP_BASE_NAME=%~n0
|
||||||
|
set APP_HOME=%DIRNAME%
|
||||||
|
|
||||||
|
@rem Resolve any "." and ".." in APP_HOME to make it shorter.
|
||||||
|
for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi
|
||||||
|
|
||||||
|
@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
|
||||||
|
set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m"
|
||||||
|
|
||||||
|
@rem Find java.exe
|
||||||
|
if defined JAVA_HOME goto findJavaFromJavaHome
|
||||||
|
|
||||||
|
set JAVA_EXE=java.exe
|
||||||
|
%JAVA_EXE% -version >NUL 2>&1
|
||||||
|
if %ERRORLEVEL% equ 0 goto execute
|
||||||
|
|
||||||
|
echo. 1>&2
|
||||||
|
echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 1>&2
|
||||||
|
echo. 1>&2
|
||||||
|
echo Please set the JAVA_HOME variable in your environment to match the 1>&2
|
||||||
|
echo location of your Java installation. 1>&2
|
||||||
|
|
||||||
|
"%COMSPEC%" /c exit 1
|
||||||
|
|
||||||
|
:findJavaFromJavaHome
|
||||||
|
set JAVA_HOME=%JAVA_HOME:"=%
|
||||||
|
set JAVA_EXE=%JAVA_HOME%/bin/java.exe
|
||||||
|
|
||||||
|
if exist "%JAVA_EXE%" goto execute
|
||||||
|
|
||||||
|
echo. 1>&2
|
||||||
|
echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 1>&2
|
||||||
|
echo. 1>&2
|
||||||
|
echo Please set the JAVA_HOME variable in your environment to match the 1>&2
|
||||||
|
echo location of your Java installation. 1>&2
|
||||||
|
|
||||||
|
"%COMSPEC%" /c exit 1
|
||||||
|
|
||||||
|
:execute
|
||||||
|
@rem Setup the command line
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
@rem Execute gradlew
|
||||||
|
@rem endlocal doesn't take effect until after the line is parsed and variables are expanded
|
||||||
|
@rem which allows us to clear the local environment before executing the java command
|
||||||
|
endlocal & "%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -jar "%APP_HOME%\gradle\wrapper\gradle-wrapper.jar" %* & call :exitWithErrorLevel
|
||||||
|
|
||||||
|
:exitWithErrorLevel
|
||||||
|
@rem Use "%COMSPEC%" /c exit to allow operators to work properly in scripts
|
||||||
|
"%COMSPEC%" /c exit %ERRORLEVEL%
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
pluginManagement {
|
||||||
|
repositories {
|
||||||
|
google()
|
||||||
|
mavenCentral()
|
||||||
|
gradlePluginPortal()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
dependencyResolutionManagement {
|
||||||
|
repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS)
|
||||||
|
repositories {
|
||||||
|
google()
|
||||||
|
mavenCentral()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
rootProject.name = "wIRC"
|
||||||
|
include(":app")
|
||||||
Binary file not shown.
|
After Width: | Height: | Size: 1.6 KiB |
Reference in New Issue
Block a user