summaryrefslogtreecommitdiff
path: root/android/app/src/main/java/dev/equestria
diff options
context:
space:
mode:
Diffstat (limited to 'android/app/src/main/java/dev/equestria')
-rw-r--r--android/app/src/main/java/dev/equestria/mist/JavaScriptExtensions.kt184
-rw-r--r--android/app/src/main/java/dev/equestria/mist/MainActivity.kt203
-rw-r--r--android/app/src/main/java/dev/equestria/mist/MediaPlaybackService.kt59
-rw-r--r--android/app/src/main/java/dev/equestria/mist/ui/theme/Color.kt11
-rw-r--r--android/app/src/main/java/dev/equestria/mist/ui/theme/Theme.kt80
-rw-r--r--android/app/src/main/java/dev/equestria/mist/ui/theme/Type.kt34
6 files changed, 571 insertions, 0 deletions
diff --git a/android/app/src/main/java/dev/equestria/mist/JavaScriptExtensions.kt b/android/app/src/main/java/dev/equestria/mist/JavaScriptExtensions.kt
new file mode 100644
index 0000000..8cb1c90
--- /dev/null
+++ b/android/app/src/main/java/dev/equestria/mist/JavaScriptExtensions.kt
@@ -0,0 +1,184 @@
+package dev.equestria.mist
+
+import android.Manifest
+import android.annotation.SuppressLint
+import android.content.Context
+import android.content.Intent
+import android.content.pm.PackageManager
+import android.graphics.BitmapFactory
+import android.support.v4.media.MediaMetadataCompat
+import android.support.v4.media.session.MediaSessionCompat
+import android.support.v4.media.session.PlaybackStateCompat
+import android.util.Log
+import android.view.View
+import android.view.Window
+import android.webkit.JavascriptInterface
+import androidx.activity.ComponentActivity
+import androidx.core.app.ActivityCompat
+import androidx.core.app.NotificationCompat
+import androidx.core.app.NotificationManagerCompat
+import androidx.core.view.WindowCompat
+import androidx.media.app.NotificationCompat.MediaStyle
+import java.io.IOException
+import java.net.URL
+import android.graphics.Bitmap
+import android.webkit.WebView
+
+
+fun convertPixelsToDp(context: Context, pixels: Float): Float {
+ val screenPixelDensity = context.resources.displayMetrics.density
+ val dpValue = pixels / screenPixelDensity
+ return dpValue
+}
+
+@SuppressLint("InternalInsetResource", "DiscouragedApi")
+fun getStatusBarHeight(activity: ComponentActivity): Float {
+ val resourceId = activity.resources.getIdentifier("status_bar_height", "dimen", "android")
+ return if (resourceId > 0) {
+ val statusBarHeight = activity.resources.getDimensionPixelSize(resourceId)
+ convertPixelsToDp(activity.applicationContext, statusBarHeight.toFloat())
+ } else {
+ 0f
+ }
+}
+
+@SuppressLint("InternalInsetResource", "DiscouragedApi")
+fun getNavigationBarHeight(activity: ComponentActivity): Float {
+ val resourceId = activity.resources.getIdentifier("navigation_bar_height", "dimen", "android")
+ return if (resourceId > 0) {
+ val statusBarHeight = activity.resources.getDimensionPixelSize(resourceId)
+ convertPixelsToDp(activity.applicationContext, statusBarHeight.toFloat())
+ } else {
+ 0f
+ }
+}
+
+class JavaScriptExtensions(originalActivity: MainActivity, private val window: Window, private val view: WebView, private val intent: Intent) {
+ private val activity: MainActivity = originalActivity
+ private var initialStatusBar: Boolean = WindowCompat.getInsetsController(window, view).isAppearanceLightStatusBars
+ private val session: MediaSessionCompat = MediaSessionCompat(activity.applicationContext, "Player")
+ private val notificationBuilder: NotificationCompat.Builder = NotificationCompat.Builder(activity.applicationContext, "main")
+ .setSmallIcon(R.drawable.ic_stat_player)
+ .setVisibility(NotificationCompat.VISIBILITY_PUBLIC)
+ .setContentTitle("Mist")
+ private var mAlbumArt: Bitmap? = null
+
+ @JavascriptInterface
+ fun removeNotification() {
+ with (NotificationManagerCompat.from(activity.applicationContext)) {
+ cancel(1)
+ }
+ }
+
+ @JavascriptInterface
+ fun removeService() {
+ activity.stopService(intent)
+ }
+
+ @JavascriptInterface
+ fun updateNotificationAlbumArt(albumArt: String) {
+ activity.startForegroundService(intent)
+
+ try {
+ val url = URL(albumArt)
+ val image = BitmapFactory.decodeStream(url.openConnection().getInputStream())
+ mAlbumArt = image
+ } catch (e: IOException) {
+ Log.e("NotificationAlbumArt", "Failed to fetch album art", e)
+ }
+ }
+
+ @JavascriptInterface
+ fun setNotificationData(title: String, artist: String, album: String, position: Long, duration: Long, playing: Boolean, buffering: Boolean, shuffle: Boolean, repeat: Boolean) {
+ val playbackStateBuilder = PlaybackStateCompat.Builder()
+ val style = MediaStyle()
+
+ val state = if (buffering) {
+ PlaybackStateCompat.STATE_BUFFERING
+ } else if (playing) {
+ PlaybackStateCompat.STATE_PLAYING
+ } else {
+ PlaybackStateCompat.STATE_PAUSED
+ }
+ val playbackSpeed = 1f
+ playbackStateBuilder.setState(state, position, playbackSpeed)
+
+ playbackStateBuilder.setActions(PlaybackStateCompat.ACTION_PLAY_PAUSE
+ or PlaybackStateCompat.ACTION_SKIP_TO_PREVIOUS
+ or PlaybackStateCompat.ACTION_SKIP_TO_NEXT
+ or PlaybackStateCompat.ACTION_SEEK_TO
+ or PlaybackStateCompat.ACTION_STOP
+ )
+
+ val builder = MediaMetadataCompat.Builder()
+
+ builder.putString(MediaMetadataCompat.METADATA_KEY_TITLE, title)
+ builder.putString(MediaMetadataCompat.METADATA_KEY_ARTIST, artist)
+ builder.putString(MediaMetadataCompat.METADATA_KEY_ALBUM, album)
+ builder.putBitmap(MediaMetadataCompat.METADATA_KEY_ALBUM_ART, mAlbumArt)
+ builder.putBitmap(MediaMetadataCompat.METADATA_KEY_ART, mAlbumArt)
+ builder.putLong(MediaMetadataCompat.METADATA_KEY_DURATION, duration)
+
+ val callback = object: MediaSessionCompat.Callback() {
+ override fun onPlay() {
+ view.post { view.evaluateJavascript("window.playPause();", null) }
+ }
+
+ override fun onPause() {
+ view.post { view.evaluateJavascript("window.playPause();", null) }
+ }
+
+ override fun onStop() {
+ view.post { view.evaluateJavascript("window.stop();", null) }
+ }
+
+ override fun onSkipToPrevious() {
+ view.post { view.evaluateJavascript("window.previous();", null) }
+ }
+
+ override fun onSkipToNext() {
+ view.post { view.evaluateJavascript("window.next();", null) }
+ }
+
+ override fun onSeekTo(pos: Long) {
+ view.post { view.evaluateJavascript("window.document.getElementById(\"player\").contentDocument.getElementById(\"player-audio\").currentTime = $pos / 1000;", null) }
+ }
+ }
+
+ session.setCallback(callback)
+ session.setMetadata(builder.build())
+ session.setPlaybackState(playbackStateBuilder.build())
+ style.setMediaSession(session.sessionToken)
+ notificationBuilder.setStyle(style)
+
+ val notification = notificationBuilder.build()
+
+ with (NotificationManagerCompat.from(activity.applicationContext)) {
+ if (ActivityCompat.checkSelfPermission(
+ activity.applicationContext,
+ Manifest.permission.POST_NOTIFICATIONS
+ ) != PackageManager.PERMISSION_GRANTED
+ ) {
+ return
+ }
+ notify(1, notification)
+ }
+ }
+
+ @JavascriptInterface
+ fun getNavigationBarHeight(): Float {
+ return getNavigationBarHeight(activity)
+ }
+
+ @JavascriptInterface
+ fun getStatusBarHeight(): Float {
+ return getStatusBarHeight(activity)
+ }
+
+ @JavascriptInterface
+ fun setStatusBarTheme(dark: Boolean) {
+ if (!initialStatusBar) return
+ WindowCompat.getInsetsController(window, view).isAppearanceLightStatusBars = dark
+ }
+}
+
diff --git a/android/app/src/main/java/dev/equestria/mist/MainActivity.kt b/android/app/src/main/java/dev/equestria/mist/MainActivity.kt
new file mode 100644
index 0000000..939feaa
--- /dev/null
+++ b/android/app/src/main/java/dev/equestria/mist/MainActivity.kt
@@ -0,0 +1,203 @@
+package dev.equestria.mist
+
+import android.Manifest
+import android.annotation.SuppressLint
+import android.app.NotificationChannel
+import android.app.NotificationManager
+import android.content.Context
+import android.content.Intent
+import android.content.pm.PackageManager
+import android.media.MediaMetadata
+import android.os.Build
+import android.os.Bundle
+import android.support.v4.media.MediaMetadataCompat
+import android.support.v4.media.session.MediaSessionCompat
+import android.support.v4.media.session.PlaybackStateCompat
+import android.util.Log
+import android.view.ViewGroup
+import android.webkit.WebSettings
+import android.webkit.WebView
+import android.webkit.WebViewClient
+import androidx.activity.ComponentActivity
+import androidx.activity.compose.setContent
+import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.Spacer
+import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.foundation.layout.height
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.layout.safeDrawingPadding
+import androidx.compose.foundation.layout.wrapContentHeight
+import androidx.compose.foundation.layout.wrapContentWidth
+import androidx.compose.material3.AlertDialog
+import androidx.compose.material3.AlertDialogDefaults
+import androidx.compose.material3.ExperimentalMaterial3Api
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.Scaffold
+import androidx.compose.material3.Surface
+import androidx.compose.material3.Text
+import androidx.compose.material3.TextButton
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.DisposableEffect
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.rememberUpdatedState
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.platform.LocalDensity
+import androidx.compose.ui.platform.LocalView
+import androidx.compose.ui.tooling.preview.Preview
+import androidx.compose.ui.unit.dp
+import androidx.compose.ui.viewinterop.AndroidView
+import androidx.core.app.ActivityCompat
+import androidx.core.view.WindowCompat
+import androidx.core.view.WindowInsetsCompat
+import androidx.core.view.WindowInsetsControllerCompat
+import androidx.media.app.NotificationCompat as MediaNotificationCompat
+import androidx.core.app.NotificationCompat
+import androidx.core.app.NotificationManagerCompat
+import androidx.core.content.ContextCompat
+import com.android.volley.Request
+import com.android.volley.toolbox.JsonObjectRequest
+import com.android.volley.toolbox.Volley
+import com.google.android.material.dialog.MaterialAlertDialogBuilder
+import dev.equestria.mist.ui.theme.MistTheme
+
+class MainActivity : ComponentActivity() {
+ private lateinit var intent: Intent
+
+ @OptIn(ExperimentalMaterial3Api::class)
+ fun showError() {
+ setContent {
+ AlertDialog(
+ onDismissRequest = {
+ finish()
+ }
+ ) {
+ Surface(
+ modifier = Modifier
+ .wrapContentWidth()
+ .wrapContentHeight(),
+ shape = MaterialTheme.shapes.large,
+ tonalElevation = AlertDialogDefaults.TonalElevation
+ ) {
+ Column(modifier = Modifier.padding(16.dp)) {
+ Text(
+ text = "Unable to connect to Mist at this moment. Please try again later.",
+ )
+ Spacer(modifier = Modifier.height(24.dp))
+ TextButton(
+ onClick = {
+ finish()
+ },
+ modifier = Modifier.align(Alignment.End)
+ ) {
+ Text("Quit")
+ }
+ }
+ }
+ }
+ }
+ }
+
+ override fun onCreate(savedInstanceState: Bundle?) {
+ intent = Intent(this, MediaPlaybackService::class.java)
+
+ super.onCreate(savedInstanceState)
+ WindowCompat.setDecorFitsSystemWindows(window, false)
+
+ if (ActivityCompat.checkSelfPermission(
+ applicationContext,
+ Manifest.permission.POST_NOTIFICATIONS
+ ) != PackageManager.PERMISSION_GRANTED
+ ) {
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
+ ActivityCompat.requestPermissions(
+ this, arrayOf(Manifest.permission.POST_NOTIFICATIONS), 1
+ )
+ }
+ }
+
+ val channel = NotificationChannel("main", "Playback", NotificationManager.IMPORTANCE_DEFAULT).apply {}
+ val notificationManager: NotificationManager =
+ getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
+ notificationManager.createNotificationChannel(channel)
+
+ setContent {
+ Box() {
+ MistTheme {}
+ }
+ }
+
+ val volleyQueue = Volley.newRequestQueue(baseContext)
+
+ val jsonObjectRequest = JsonObjectRequest(
+ Request.Method.GET, "https://mist.equestria.horse/connectivitycheck.txt", null,
+
+ { response ->
+ Log.i("HTTPRequest", response.toString())
+
+ if (response.getString("status") == "OK") {
+ setContent {
+ Box() {
+ MistTheme {
+ WebViewContainer(this@MainActivity, intent)
+ }
+ }
+ }
+
+ WebView.setWebContentsDebuggingEnabled(true)
+ } else {
+ setContent {
+ Box() {
+ MistTheme {}
+ }
+ }
+ showError()
+ }
+ },
+
+ { error ->
+ setContent {
+ Box() {
+ MistTheme {}
+ }
+ }
+ showError()
+ Log.e("HTTPRequest", "Request error: ${error.localizedMessage}")
+ })
+
+ volleyQueue.add(jsonObjectRequest)
+
+ Log.d("StatusBarHeight", getStatusBarHeight(this).toString())
+ Log.d("NavigationBarHeight", getNavigationBarHeight(this).toString())
+ }
+}
+
+@SuppressLint("SetJavaScriptEnabled")
+@Composable
+fun WebViewContainer(activity: MainActivity, intent: Intent) {
+ val mUrl = "https://mist.equestria.horse/app/"
+
+ AndroidView(factory = {
+ WebView(it).apply {
+ layoutParams = ViewGroup.LayoutParams(
+ ViewGroup.LayoutParams.MATCH_PARENT,
+ ViewGroup.LayoutParams.MATCH_PARENT
+ )
+
+ clearCache(true)
+ clearHistory()
+ webViewClient = WebViewClient()
+
+ settings.domStorageEnabled = true
+ settings.javaScriptEnabled = true
+ settings.safeBrowsingEnabled = false
+ settings.mediaPlaybackRequiresUserGesture = false
+ settings.userAgentString += " MistAndroid/" + BuildConfig.VERSION_NAME
+ settings.cacheMode = WebSettings.LOAD_NO_CACHE
+
+ addJavascriptInterface(JavaScriptExtensions(activity, activity.window, this, intent), "MistAndroid")
+ loadUrl(mUrl)
+ }
+ })
+} \ No newline at end of file
diff --git a/android/app/src/main/java/dev/equestria/mist/MediaPlaybackService.kt b/android/app/src/main/java/dev/equestria/mist/MediaPlaybackService.kt
new file mode 100644
index 0000000..15472b1
--- /dev/null
+++ b/android/app/src/main/java/dev/equestria/mist/MediaPlaybackService.kt
@@ -0,0 +1,59 @@
+package dev.equestria.mist
+
+import android.Manifest
+import android.app.ForegroundServiceStartNotAllowedException
+import android.app.Notification
+import android.app.Service
+import android.content.Intent
+import android.content.pm.PackageManager
+import android.os.Build
+import android.os.IBinder
+import android.support.v4.media.MediaMetadataCompat
+import android.support.v4.media.session.MediaSessionCompat
+import android.support.v4.media.session.PlaybackStateCompat
+import androidx.core.app.ActivityCompat
+import androidx.core.app.NotificationCompat
+import androidx.core.app.NotificationManagerCompat
+import androidx.core.app.ServiceCompat
+
+class MediaPlaybackService: Service() {
+ private lateinit var mNotification: Notification
+
+ fun setNotification(notification: Notification) {
+ mNotification = notification
+ }
+
+ override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
+ val session = MediaSessionCompat(applicationContext, "Player")
+ val playbackStateBuilder = PlaybackStateCompat.Builder()
+ val style = androidx.media.app.NotificationCompat.MediaStyle()
+
+ val notificationBuilder = NotificationCompat.Builder(applicationContext, "main")
+ .setSmallIcon(R.drawable.ic_stat_player)
+ .setVisibility(NotificationCompat.VISIBILITY_PUBLIC)
+ .setContentTitle("Mist")
+
+ playbackStateBuilder.setState(PlaybackStateCompat.STATE_STOPPED, 0L, 1f)
+
+ session.setPlaybackState(playbackStateBuilder.build())
+ style.setMediaSession(session.sessionToken)
+ notificationBuilder.setStyle(style)
+
+ val notification = notificationBuilder.build()
+ startForeground(1, notification)
+ return START_STICKY
+ }
+
+ override fun onBind(p0: Intent?): IBinder? {
+ return null
+ }
+
+ override fun onTaskRemoved(rootIntent: Intent?) {
+ super.onTaskRemoved(rootIntent)
+ with (NotificationManagerCompat.from(applicationContext)) {
+ cancel(1)
+ }
+ android.os.Process.killProcess(android.os.Process.myPid())
+ stopSelf()
+ }
+} \ No newline at end of file
diff --git a/android/app/src/main/java/dev/equestria/mist/ui/theme/Color.kt b/android/app/src/main/java/dev/equestria/mist/ui/theme/Color.kt
new file mode 100644
index 0000000..4ef2c56
--- /dev/null
+++ b/android/app/src/main/java/dev/equestria/mist/ui/theme/Color.kt
@@ -0,0 +1,11 @@
+package dev.equestria.mist.ui.theme
+
+import androidx.compose.ui.graphics.Color
+
+val Purple80 = Color(0xFFD0BCFF)
+val PurpleGrey80 = Color(0xFFCCC2DC)
+val Pink80 = Color(0xFFEFB8C8)
+
+val Purple40 = Color(0xFF6650a4)
+val PurpleGrey40 = Color(0xFF625b71)
+val Pink40 = Color(0xFF7D5260) \ No newline at end of file
diff --git a/android/app/src/main/java/dev/equestria/mist/ui/theme/Theme.kt b/android/app/src/main/java/dev/equestria/mist/ui/theme/Theme.kt
new file mode 100644
index 0000000..051d910
--- /dev/null
+++ b/android/app/src/main/java/dev/equestria/mist/ui/theme/Theme.kt
@@ -0,0 +1,80 @@
+package dev.equestria.mist.ui.theme
+
+import android.app.Activity
+import android.graphics.Color
+import android.os.Build
+import android.view.Window
+import android.view.WindowInsetsController
+import androidx.compose.foundation.isSystemInDarkTheme
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.darkColorScheme
+import androidx.compose.material3.dynamicDarkColorScheme
+import androidx.compose.material3.dynamicLightColorScheme
+import androidx.compose.material3.lightColorScheme
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.SideEffect
+import androidx.compose.ui.graphics.toArgb
+import androidx.compose.ui.platform.LocalContext
+import androidx.compose.ui.platform.LocalView
+import androidx.core.view.WindowCompat
+
+private val DarkColorScheme = darkColorScheme(
+ primary = Purple80,
+ secondary = PurpleGrey80,
+ tertiary = Pink80
+)
+
+private val LightColorScheme = lightColorScheme(
+ primary = Purple40,
+ secondary = PurpleGrey40,
+ tertiary = Pink40
+
+ /* Other default colors to override
+ background = Color(0xFFFFFBFE),
+ surface = Color(0xFFFFFBFE),
+ onPrimary = Color.White,
+ onSecondary = Color.White,
+ onTertiary = Color.White,
+ onBackground = Color(0xFF1C1B1F),
+ onSurface = Color(0xFF1C1B1F),
+ */
+)
+
+@Composable
+fun MistTheme(
+ darkTheme: Boolean = isSystemInDarkTheme(),
+ dynamicColor: Boolean = true,
+ content: @Composable () -> Unit
+) {
+ val colorScheme = when {
+ dynamicColor && Build.VERSION.SDK_INT >= Build.VERSION_CODES.S -> {
+ val context = LocalContext.current
+ if (darkTheme) dynamicDarkColorScheme(context) else dynamicLightColorScheme(context)
+ }
+
+ darkTheme -> DarkColorScheme
+ else -> LightColorScheme
+ }
+ val view = LocalView.current
+
+ val navigationBarItemColor = when {
+ darkTheme -> Window.DECOR_CAPTION_SHADE_LIGHT
+ else -> Window.DECOR_CAPTION_SHADE_DARK
+ }
+
+ if (!view.isInEditMode) {
+ SideEffect {
+ val window = (view.context as Activity).window
+ window.statusBarColor = Color.TRANSPARENT
+ window.navigationBarColor = Color.TRANSPARENT
+ window.setDecorCaptionShade(navigationBarItemColor)
+ WindowCompat.getInsetsController(window, view).isAppearanceLightStatusBars = !darkTheme
+ }
+ }
+
+ MaterialTheme(
+ colorScheme = colorScheme,
+ typography = Typography,
+ content = content
+ )
+} \ No newline at end of file
diff --git a/android/app/src/main/java/dev/equestria/mist/ui/theme/Type.kt b/android/app/src/main/java/dev/equestria/mist/ui/theme/Type.kt
new file mode 100644
index 0000000..290c7d2
--- /dev/null
+++ b/android/app/src/main/java/dev/equestria/mist/ui/theme/Type.kt
@@ -0,0 +1,34 @@
+package dev.equestria.mist.ui.theme
+
+import androidx.compose.material3.Typography
+import androidx.compose.ui.text.TextStyle
+import androidx.compose.ui.text.font.FontFamily
+import androidx.compose.ui.text.font.FontWeight
+import androidx.compose.ui.unit.sp
+
+// Set of Material typography styles to start with
+val Typography = Typography(
+ bodyLarge = TextStyle(
+ fontFamily = FontFamily.Default,
+ fontWeight = FontWeight.Normal,
+ fontSize = 16.sp,
+ lineHeight = 24.sp,
+ letterSpacing = 0.5.sp
+ )
+ /* Other default text styles to override
+ titleLarge = TextStyle(
+ fontFamily = FontFamily.Default,
+ fontWeight = FontWeight.Normal,
+ fontSize = 22.sp,
+ lineHeight = 28.sp,
+ letterSpacing = 0.sp
+ ),
+ labelSmall = TextStyle(
+ fontFamily = FontFamily.Default,
+ fontWeight = FontWeight.Medium,
+ fontSize = 11.sp,
+ lineHeight = 16.sp,
+ letterSpacing = 0.5.sp
+ )
+ */
+) \ No newline at end of file