Merge branch 'material' of https://github.com/DDRBoxman/MPD
This commit is contained in:
commit
658a7f1ca7
|
@ -1,5 +1,6 @@
|
||||||
plugins {
|
plugins {
|
||||||
id("com.android.application")
|
id("com.android.application")
|
||||||
|
id("org.jetbrains.kotlin.android")
|
||||||
}
|
}
|
||||||
|
|
||||||
android {
|
android {
|
||||||
|
@ -12,10 +13,18 @@ android {
|
||||||
targetSdk = 30
|
targetSdk = 30
|
||||||
versionCode = 1
|
versionCode = 1
|
||||||
versionName = "1.0"
|
versionName = "1.0"
|
||||||
|
vectorDrawables {
|
||||||
|
useSupportLibrary = true
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
buildFeatures {
|
buildFeatures {
|
||||||
aidl = true
|
aidl = true
|
||||||
|
compose = true
|
||||||
|
}
|
||||||
|
|
||||||
|
composeOptions {
|
||||||
|
kotlinCompilerExtensionVersion = "1.5.4"
|
||||||
}
|
}
|
||||||
|
|
||||||
buildTypes {
|
buildTypes {
|
||||||
|
@ -31,8 +40,34 @@ android {
|
||||||
sourceCompatibility = JavaVersion.VERSION_1_9
|
sourceCompatibility = JavaVersion.VERSION_1_9
|
||||||
targetCompatibility = JavaVersion.VERSION_1_9
|
targetCompatibility = JavaVersion.VERSION_1_9
|
||||||
}
|
}
|
||||||
|
kotlinOptions {
|
||||||
|
jvmTarget = "1.8"
|
||||||
|
}
|
||||||
|
packaging {
|
||||||
|
resources {
|
||||||
|
excludes += "/META-INF/{AL2.0,LGPL2.1}"
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
dependencies {
|
dependencies {
|
||||||
|
implementation("androidx.lifecycle:lifecycle-runtime-ktx:2.6.2")
|
||||||
|
implementation(platform("androidx.compose:compose-bom:2023.10.01"))
|
||||||
|
|
||||||
|
implementation("androidx.compose.material3:material3")
|
||||||
|
implementation("androidx.activity:activity-compose:1.8.2")
|
||||||
|
implementation("androidx.compose.material:material-icons-extended")
|
||||||
|
implementation("androidx.lifecycle:lifecycle-viewmodel-compose:2.6.2")
|
||||||
|
implementation("androidx.lifecycle:lifecycle-runtime-compose:2.6.2")
|
||||||
|
|
||||||
|
implementation("com.github.alorma:compose-settings-ui-m3:1.0.3")
|
||||||
|
implementation("com.github.alorma:compose-settings-storage-preferences:1.0.3")
|
||||||
|
implementation("com.google.accompanist:accompanist-permissions:0.33.2-alpha")
|
||||||
|
|
||||||
|
// Android Studio Preview support
|
||||||
|
implementation("androidx.compose.ui:ui-tooling-preview")
|
||||||
|
debugImplementation("androidx.compose.ui:ui-tooling")
|
||||||
|
debugImplementation("androidx.compose.ui:ui-test-manifest")
|
||||||
|
|
||||||
implementation("androidx.appcompat:appcompat:1.6.1")
|
implementation("androidx.appcompat:appcompat:1.6.1")
|
||||||
}
|
}
|
|
@ -24,16 +24,19 @@
|
||||||
android:icon="@mipmap/ic_launcher"
|
android:icon="@mipmap/ic_launcher"
|
||||||
android:label="@string/app_name"
|
android:label="@string/app_name"
|
||||||
android:requestLegacyExternalStorage="true"
|
android:requestLegacyExternalStorage="true"
|
||||||
android:roundIcon="@mipmap/ic_launcher_round">
|
android:roundIcon="@mipmap/ic_launcher_round"
|
||||||
|
android:theme="@style/Theme.MPD">
|
||||||
<activity
|
<activity
|
||||||
android:name=".Settings"
|
android:name=".ui.SettingsActivity"
|
||||||
android:exported="true">
|
android:exported="true">
|
||||||
<intent-filter>
|
<intent-filter>
|
||||||
<action android:name="android.intent.action.MAIN" />
|
<action android:name="android.intent.action.MAIN" />
|
||||||
|
|
||||||
<category android:name="android.intent.category.LAUNCHER" />
|
<category android:name="android.intent.category.LAUNCHER" />
|
||||||
</intent-filter>
|
</intent-filter>
|
||||||
<intent-filter>
|
<intent-filter>
|
||||||
<action android:name="android.intent.action.MAIN" />
|
<action android:name="android.intent.action.MAIN" />
|
||||||
|
|
||||||
<category android:name="android.intent.category.LEANBACK_LAUNCHER" />
|
<category android:name="android.intent.category.LEANBACK_LAUNCHER" />
|
||||||
</intent-filter>
|
</intent-filter>
|
||||||
</activity>
|
</activity>
|
||||||
|
|
|
@ -25,6 +25,8 @@ import android.widget.RemoteViews;
|
||||||
|
|
||||||
import androidx.core.app.ServiceCompat;
|
import androidx.core.app.ServiceCompat;
|
||||||
|
|
||||||
|
import org.musicpd.ui.SettingsActivity;
|
||||||
|
|
||||||
import java.lang.reflect.Constructor;
|
import java.lang.reflect.Constructor;
|
||||||
import java.lang.reflect.Method;
|
import java.lang.reflect.Method;
|
||||||
|
|
||||||
|
@ -197,7 +199,7 @@ public class Main extends Service implements Runnable {
|
||||||
}
|
}
|
||||||
}, filter);
|
}, filter);
|
||||||
|
|
||||||
final Intent mainIntent = new Intent(this, Settings.class);
|
final Intent mainIntent = new Intent(this, SettingsActivity.class);
|
||||||
mainIntent.setAction("android.intent.action.MAIN");
|
mainIntent.setAction("android.intent.action.MAIN");
|
||||||
mainIntent.addCategory("android.intent.category.LAUNCHER");
|
mainIntent.addCategory("android.intent.category.LAUNCHER");
|
||||||
final PendingIntent contentIntent = PendingIntent.getActivity(this, 0,
|
final PendingIntent contentIntent = PendingIntent.getActivity(this, 0,
|
||||||
|
|
|
@ -5,12 +5,15 @@ import android.net.ConnectivityManager;
|
||||||
import android.net.LinkAddress;
|
import android.net.LinkAddress;
|
||||||
import android.net.LinkProperties;
|
import android.net.LinkProperties;
|
||||||
|
|
||||||
|
import androidx.annotation.Nullable;
|
||||||
|
|
||||||
import java.net.Inet4Address;
|
import java.net.Inet4Address;
|
||||||
import java.net.InetAddress;
|
import java.net.InetAddress;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
|
|
||||||
public class NetworkUtil {
|
public class NetworkUtil {
|
||||||
|
|
||||||
|
@Nullable
|
||||||
public static String getDeviceIPV4Address(Context context) {
|
public static String getDeviceIPV4Address(Context context) {
|
||||||
ConnectivityManager connectivityManager = (ConnectivityManager) context.getSystemService(Context.CONNECTIVITY_SERVICE);
|
ConnectivityManager connectivityManager = (ConnectivityManager) context.getSystemService(Context.CONNECTIVITY_SERVICE);
|
||||||
LinkProperties linkProperties = connectivityManager.getLinkProperties(connectivityManager.getActiveNetwork());
|
LinkProperties linkProperties = connectivityManager.getLinkProperties(connectivityManager.getActiveNetwork());
|
||||||
|
|
|
@ -0,0 +1,34 @@
|
||||||
|
package org.musicpd;
|
||||||
|
|
||||||
|
import static android.content.Context.MODE_PRIVATE;
|
||||||
|
|
||||||
|
import android.content.Context;
|
||||||
|
import android.content.SharedPreferences;
|
||||||
|
|
||||||
|
public class Preferences {
|
||||||
|
private static final String TAG = "Settings";
|
||||||
|
|
||||||
|
public static final String KEY_RUN_ON_BOOT ="run_on_boot";
|
||||||
|
public static final String KEY_WAKELOCK ="wakelock";
|
||||||
|
public static final String KEY_PAUSE_ON_HEADPHONES_DISCONNECT ="pause_on_headphones_disconnect";
|
||||||
|
|
||||||
|
public static SharedPreferences get(Context context) {
|
||||||
|
return context.getSharedPreferences(TAG, MODE_PRIVATE);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static void putBoolean(Context context, String key, boolean value) {
|
||||||
|
final SharedPreferences prefs = get(context);
|
||||||
|
|
||||||
|
if (prefs == null)
|
||||||
|
return;
|
||||||
|
final SharedPreferences.Editor editor = prefs.edit();
|
||||||
|
editor.putBoolean(key, value);
|
||||||
|
editor.apply();
|
||||||
|
}
|
||||||
|
|
||||||
|
public static boolean getBoolean(Context context, String key, boolean defValue) {
|
||||||
|
final SharedPreferences prefs = get(context);
|
||||||
|
|
||||||
|
return prefs != null ? prefs.getBoolean(key, defValue) : defValue;
|
||||||
|
}
|
||||||
|
}
|
|
@ -20,13 +20,13 @@ public class Receiver extends BroadcastReceiver {
|
||||||
@Override
|
@Override
|
||||||
public void onReceive(Context context, Intent intent) {
|
public void onReceive(Context context, Intent intent) {
|
||||||
Log.d("Receiver", "onReceive: " + intent);
|
Log.d("Receiver", "onReceive: " + intent);
|
||||||
if (BOOT_ACTIONS.contains(intent.getAction())) {
|
if (intent.getAction() == "android.intent.action.BOOT_COMPLETED") {
|
||||||
if (Settings.Preferences.getBoolean(context,
|
if (Settings.Preferences.getBoolean(context,
|
||||||
Settings.Preferences.KEY_RUN_ON_BOOT,
|
Settings.Preferences.KEY_RUN_ON_BOOT,
|
||||||
false)) {
|
false)) {
|
||||||
final boolean wakelock =
|
final boolean wakelock =
|
||||||
Settings.Preferences.getBoolean(context,
|
Preferences.getBoolean(context,
|
||||||
Settings.Preferences.KEY_WAKELOCK, false);
|
Preferences.KEY_WAKELOCK, false);
|
||||||
Main.start(context, wakelock);
|
Main.start(context, wakelock);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,296 +0,0 @@
|
||||||
// SPDX-License-Identifier: GPL-2.0-or-later
|
|
||||||
// Copyright The Music Player Daemon Project
|
|
||||||
|
|
||||||
package org.musicpd;
|
|
||||||
|
|
||||||
import java.util.LinkedList;
|
|
||||||
|
|
||||||
import android.Manifest;
|
|
||||||
import android.app.Activity;
|
|
||||||
import android.content.Context;
|
|
||||||
import android.content.SharedPreferences;
|
|
||||||
import android.content.SharedPreferences.Editor;
|
|
||||||
import android.content.pm.PackageManager;
|
|
||||||
import android.os.Bundle;
|
|
||||||
import android.os.Handler;
|
|
||||||
import android.os.Message;
|
|
||||||
import android.util.Log;
|
|
||||||
import android.widget.ArrayAdapter;
|
|
||||||
import android.widget.CheckBox;
|
|
||||||
import android.widget.CompoundButton;
|
|
||||||
import android.widget.CompoundButton.OnCheckedChangeListener;
|
|
||||||
import android.widget.ListView;
|
|
||||||
import android.widget.TextView;
|
|
||||||
import android.widget.ToggleButton;
|
|
||||||
|
|
||||||
public class Settings extends Activity {
|
|
||||||
private static final String TAG = "Settings";
|
|
||||||
private Main.Client mClient;
|
|
||||||
private TextView mTextStatus;
|
|
||||||
private ToggleButton mRunButton;
|
|
||||||
private boolean mFirstRun;
|
|
||||||
private LinkedList<String> mLogListArray = new LinkedList<String>();
|
|
||||||
private ListView mLogListView;
|
|
||||||
private ArrayAdapter<String> mLogListAdapter;
|
|
||||||
|
|
||||||
private static final int MAX_LOGS = 500;
|
|
||||||
|
|
||||||
private static final int MSG_ERROR = 0;
|
|
||||||
private static final int MSG_STOPPED = 1;
|
|
||||||
private static final int MSG_STARTED = 2;
|
|
||||||
private static final int MSG_LOG = 3;
|
|
||||||
|
|
||||||
public static class Preferences {
|
|
||||||
public static final String KEY_RUN_ON_BOOT ="run_on_boot";
|
|
||||||
public static final String KEY_WAKELOCK ="wakelock";
|
|
||||||
public static final String KEY_PAUSE_ON_HEADPHONES_DISCONNECT ="pause_on_headphones_disconnect";
|
|
||||||
|
|
||||||
public static SharedPreferences get(Context context) {
|
|
||||||
return context.getSharedPreferences(TAG, MODE_PRIVATE);
|
|
||||||
}
|
|
||||||
|
|
||||||
public static void putBoolean(Context context, String key, boolean value) {
|
|
||||||
final SharedPreferences prefs = get(context);
|
|
||||||
|
|
||||||
if (prefs == null)
|
|
||||||
return;
|
|
||||||
final Editor editor = prefs.edit();
|
|
||||||
editor.putBoolean(key, value);
|
|
||||||
editor.apply();
|
|
||||||
}
|
|
||||||
|
|
||||||
public static boolean getBoolean(Context context, String key, boolean defValue) {
|
|
||||||
final SharedPreferences prefs = get(context);
|
|
||||||
|
|
||||||
return prefs != null ? prefs.getBoolean(key, defValue) : defValue;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private Handler mHandler = new Handler(new Handler.Callback() {
|
|
||||||
@Override
|
|
||||||
public boolean handleMessage(Message msg) {
|
|
||||||
switch (msg.what) {
|
|
||||||
case MSG_ERROR:
|
|
||||||
Log.d(TAG, "onError");
|
|
||||||
|
|
||||||
mClient.release();
|
|
||||||
connectClient();
|
|
||||||
|
|
||||||
mRunButton.setEnabled(false);
|
|
||||||
mRunButton.setChecked(false);
|
|
||||||
|
|
||||||
mTextStatus.setText((String)msg.obj);
|
|
||||||
mFirstRun = true;
|
|
||||||
break;
|
|
||||||
case MSG_STOPPED:
|
|
||||||
Log.d(TAG, "onStopped");
|
|
||||||
mRunButton.setEnabled(true);
|
|
||||||
if (!mFirstRun && Preferences.getBoolean(Settings.this, Preferences.KEY_RUN_ON_BOOT, false))
|
|
||||||
mRunButton.setChecked(true);
|
|
||||||
else
|
|
||||||
mRunButton.setChecked(false);
|
|
||||||
mFirstRun = true;
|
|
||||||
mTextStatus.setText("");
|
|
||||||
break;
|
|
||||||
case MSG_STARTED:
|
|
||||||
Log.d(TAG, "onStarted");
|
|
||||||
mRunButton.setChecked(true);
|
|
||||||
mFirstRun = true;
|
|
||||||
mTextStatus.setText("MPD service started");
|
|
||||||
break;
|
|
||||||
case MSG_LOG:
|
|
||||||
if (mLogListArray.size() > MAX_LOGS)
|
|
||||||
mLogListArray.remove(0);
|
|
||||||
String priority;
|
|
||||||
switch (msg.arg1) {
|
|
||||||
case Log.DEBUG:
|
|
||||||
priority = "D";
|
|
||||||
break;
|
|
||||||
case Log.ERROR:
|
|
||||||
priority = "E";
|
|
||||||
break;
|
|
||||||
case Log.INFO:
|
|
||||||
priority = "I";
|
|
||||||
break;
|
|
||||||
case Log.VERBOSE:
|
|
||||||
priority = "V";
|
|
||||||
break;
|
|
||||||
case Log.WARN:
|
|
||||||
priority = "W";
|
|
||||||
break;
|
|
||||||
default:
|
|
||||||
priority = "";
|
|
||||||
}
|
|
||||||
mLogListArray.add(priority + "/ " + (String)msg.obj);
|
|
||||||
mLogListAdapter.notifyDataSetChanged();
|
|
||||||
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
private final OnCheckedChangeListener mOnRunChangeListener = new OnCheckedChangeListener() {
|
|
||||||
@Override
|
|
||||||
public void onCheckedChanged(CompoundButton buttonView, boolean isChecked) {
|
|
||||||
if (mClient != null) {
|
|
||||||
if (isChecked) {
|
|
||||||
mClient.start();
|
|
||||||
if (Preferences.getBoolean(Settings.this,
|
|
||||||
Preferences.KEY_WAKELOCK, false))
|
|
||||||
mClient.setWakelockEnabled(true);
|
|
||||||
if (Preferences.getBoolean(Settings.this,
|
|
||||||
Preferences.KEY_PAUSE_ON_HEADPHONES_DISCONNECT, false))
|
|
||||||
mClient.setPauseOnHeadphonesDisconnect(true);
|
|
||||||
} else {
|
|
||||||
mClient.stop();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
private final OnCheckedChangeListener mOnRunOnBootChangeListener = new OnCheckedChangeListener() {
|
|
||||||
@Override
|
|
||||||
public void onCheckedChanged(CompoundButton buttonView, boolean isChecked) {
|
|
||||||
Preferences.putBoolean(Settings.this, Preferences.KEY_RUN_ON_BOOT, isChecked);
|
|
||||||
if (isChecked && mClient != null && !mRunButton.isChecked())
|
|
||||||
mRunButton.setChecked(true);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
private final OnCheckedChangeListener mOnWakelockChangeListener = new OnCheckedChangeListener() {
|
|
||||||
@Override
|
|
||||||
public void onCheckedChanged(CompoundButton buttonView, boolean isChecked) {
|
|
||||||
Preferences.putBoolean(Settings.this, Preferences.KEY_WAKELOCK, isChecked);
|
|
||||||
if (mClient != null && mClient.isRunning())
|
|
||||||
mClient.setWakelockEnabled(isChecked);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
private final OnCheckedChangeListener mOnPauseOnHeadphonesDisconnectChangeListener = new OnCheckedChangeListener() {
|
|
||||||
@Override
|
|
||||||
public void onCheckedChanged(CompoundButton buttonView, boolean isChecked) {
|
|
||||||
Preferences.putBoolean(Settings.this, Preferences.KEY_PAUSE_ON_HEADPHONES_DISCONNECT, isChecked);
|
|
||||||
if (mClient != null && mClient.isRunning())
|
|
||||||
mClient.setPauseOnHeadphonesDisconnect(isChecked);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
@Override
|
|
||||||
protected void onCreate(Bundle savedInstanceState) {
|
|
||||||
/* TODO: this sure is the wrong place to request
|
|
||||||
permissions - it will cause MPD to quit
|
|
||||||
immediately; we should request permissions when we
|
|
||||||
need them, but implementing that is complicated, so
|
|
||||||
for now, we do it here to give users a quick
|
|
||||||
solution for the problem */
|
|
||||||
requestAllPermissions();
|
|
||||||
|
|
||||||
setContentView(R.layout.settings);
|
|
||||||
mRunButton = (ToggleButton) findViewById(R.id.run);
|
|
||||||
mRunButton.setOnCheckedChangeListener(mOnRunChangeListener);
|
|
||||||
|
|
||||||
mTextStatus = (TextView) findViewById(R.id.status);
|
|
||||||
|
|
||||||
mLogListAdapter = new ArrayAdapter<String>(this, R.layout.log_item, mLogListArray);
|
|
||||||
|
|
||||||
mLogListView = (ListView) findViewById(R.id.log_list);
|
|
||||||
mLogListView.setAdapter(mLogListAdapter);
|
|
||||||
mLogListView.setTranscriptMode(ListView.TRANSCRIPT_MODE_NORMAL);
|
|
||||||
|
|
||||||
CheckBox checkbox = (CheckBox) findViewById(R.id.run_on_boot);
|
|
||||||
checkbox.setOnCheckedChangeListener(mOnRunOnBootChangeListener);
|
|
||||||
if (Preferences.getBoolean(this, Preferences.KEY_RUN_ON_BOOT, false))
|
|
||||||
checkbox.setChecked(true);
|
|
||||||
|
|
||||||
checkbox = (CheckBox) findViewById(R.id.wakelock);
|
|
||||||
checkbox.setOnCheckedChangeListener(mOnWakelockChangeListener);
|
|
||||||
if (Preferences.getBoolean(this, Preferences.KEY_WAKELOCK, false))
|
|
||||||
checkbox.setChecked(true);
|
|
||||||
|
|
||||||
checkbox = (CheckBox) findViewById(R.id.pause_on_headphones_disconnect);
|
|
||||||
checkbox.setOnCheckedChangeListener(mOnPauseOnHeadphonesDisconnectChangeListener);
|
|
||||||
if (Preferences.getBoolean(this, Preferences.KEY_PAUSE_ON_HEADPHONES_DISCONNECT, false))
|
|
||||||
checkbox.setChecked(true);
|
|
||||||
|
|
||||||
TextView networkAddressTextView = (TextView) findViewById(R.id.networkAddress);
|
|
||||||
String deviceIPV4Address = NetworkUtil.getDeviceIPV4Address(this);
|
|
||||||
networkAddressTextView.setText(deviceIPV4Address);
|
|
||||||
|
|
||||||
super.onCreate(savedInstanceState);
|
|
||||||
}
|
|
||||||
|
|
||||||
private void checkRequestPermission(String permission) {
|
|
||||||
if (checkSelfPermission(permission) == PackageManager.PERMISSION_GRANTED)
|
|
||||||
return;
|
|
||||||
|
|
||||||
try {
|
|
||||||
this.requestPermissions(new String[]{permission}, 0);
|
|
||||||
} catch (Exception e) {
|
|
||||||
Log.e(TAG, "requestPermissions(" + permission + ") failed",
|
|
||||||
e);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private void requestAllPermissions() {
|
|
||||||
if (android.os.Build.VERSION.SDK_INT < 23)
|
|
||||||
/* we don't need to request permissions on
|
|
||||||
this old Android version */
|
|
||||||
return;
|
|
||||||
|
|
||||||
/* starting with Android 6.0, we need to explicitly
|
|
||||||
request all permissions before using them;
|
|
||||||
mentioning them in the manifest is not enough */
|
|
||||||
|
|
||||||
checkRequestPermission(Manifest.permission.READ_EXTERNAL_STORAGE);
|
|
||||||
}
|
|
||||||
|
|
||||||
private void connectClient() {
|
|
||||||
mClient = new Main.Client(this, new Main.Client.Callback() {
|
|
||||||
|
|
||||||
private void removeMessages() {
|
|
||||||
/* don't remove log messages */
|
|
||||||
mHandler.removeMessages(MSG_STOPPED);
|
|
||||||
mHandler.removeMessages(MSG_STARTED);
|
|
||||||
mHandler.removeMessages(MSG_ERROR);
|
|
||||||
}
|
|
||||||
|
|
||||||
@Override
|
|
||||||
public void onStopped() {
|
|
||||||
removeMessages();
|
|
||||||
mHandler.sendEmptyMessage(MSG_STOPPED);
|
|
||||||
}
|
|
||||||
|
|
||||||
@Override
|
|
||||||
public void onStarted() {
|
|
||||||
removeMessages();
|
|
||||||
mHandler.sendEmptyMessage(MSG_STARTED);
|
|
||||||
}
|
|
||||||
|
|
||||||
@Override
|
|
||||||
public void onError(String error) {
|
|
||||||
removeMessages();
|
|
||||||
mHandler.sendMessage(Message.obtain(mHandler, MSG_ERROR, error));
|
|
||||||
}
|
|
||||||
|
|
||||||
@Override
|
|
||||||
public void onLog(int priority, String msg) {
|
|
||||||
mHandler.sendMessage(Message.obtain(mHandler, MSG_LOG, priority, 0, msg));
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
@Override
|
|
||||||
protected void onStart() {
|
|
||||||
mFirstRun = false;
|
|
||||||
connectClient();
|
|
||||||
super.onStart();
|
|
||||||
}
|
|
||||||
|
|
||||||
@Override
|
|
||||||
protected void onStop() {
|
|
||||||
mClient.release();
|
|
||||||
mClient = null;
|
|
||||||
super.onStop();
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -0,0 +1,39 @@
|
||||||
|
package org.musicpd.ui
|
||||||
|
|
||||||
|
import android.app.Application
|
||||||
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
|
import androidx.compose.foundation.layout.Row
|
||||||
|
import androidx.compose.foundation.layout.Spacer
|
||||||
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.foundation.layout.size
|
||||||
|
import androidx.compose.material.icons.Icons
|
||||||
|
import androidx.compose.material.icons.filled.Wifi
|
||||||
|
import androidx.compose.material3.Icon
|
||||||
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.platform.LocalContext
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import org.musicpd.NetworkUtil
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun NetworkAddress() {
|
||||||
|
val address = NetworkUtil.getDeviceIPV4Address(LocalContext.current)
|
||||||
|
val padding = 4.dp
|
||||||
|
Row(
|
||||||
|
Modifier
|
||||||
|
.padding(padding)
|
||||||
|
.fillMaxWidth(),
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
horizontalArrangement = Arrangement.Center
|
||||||
|
) {
|
||||||
|
Icon(
|
||||||
|
imageVector = Icons.Default.Wifi,
|
||||||
|
contentDescription = "Wifi")
|
||||||
|
Spacer(Modifier.size(padding))
|
||||||
|
Text(text = address ?: "")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
@ -0,0 +1,52 @@
|
||||||
|
package org.musicpd.ui
|
||||||
|
|
||||||
|
import androidx.compose.material.icons.Icons
|
||||||
|
import androidx.compose.material.icons.filled.BatteryAlert
|
||||||
|
import androidx.compose.material.icons.filled.Headphones
|
||||||
|
import androidx.compose.material.icons.filled.PowerSettingsNew
|
||||||
|
import androidx.compose.material3.Icon
|
||||||
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.ui.res.stringResource
|
||||||
|
import com.alorma.compose.settings.storage.preferences.rememberPreferenceBooleanSettingState
|
||||||
|
import com.alorma.compose.settings.ui.SettingsSwitch
|
||||||
|
import org.musicpd.Preferences
|
||||||
|
import org.musicpd.R
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun SettingsOptions(
|
||||||
|
onBootChanged: (Boolean) -> Unit,
|
||||||
|
onWakeLockChanged: (Boolean) -> Unit,
|
||||||
|
onHeadphonesChanged: (Boolean) -> Unit
|
||||||
|
) {
|
||||||
|
val bootState = rememberPreferenceBooleanSettingState(
|
||||||
|
key = Preferences.KEY_RUN_ON_BOOT,
|
||||||
|
defaultValue = false
|
||||||
|
)
|
||||||
|
val wakelockState =
|
||||||
|
rememberPreferenceBooleanSettingState(key = Preferences.KEY_WAKELOCK, defaultValue = false)
|
||||||
|
val headphoneState = rememberPreferenceBooleanSettingState(
|
||||||
|
key = Preferences.KEY_PAUSE_ON_HEADPHONES_DISCONNECT,
|
||||||
|
defaultValue = false
|
||||||
|
)
|
||||||
|
|
||||||
|
SettingsSwitch(
|
||||||
|
icon = { Icon(imageVector = Icons.Default.PowerSettingsNew, contentDescription = "Power") },
|
||||||
|
title = { Text(text = stringResource(R.string.checkbox_run_on_boot)) },
|
||||||
|
onCheckedChange = onBootChanged,
|
||||||
|
state = bootState
|
||||||
|
)
|
||||||
|
SettingsSwitch(
|
||||||
|
icon = { Icon(imageVector = Icons.Default.BatteryAlert, contentDescription = "Battery") },
|
||||||
|
title = { Text(text = stringResource(R.string.checkbox_wakelock)) },
|
||||||
|
onCheckedChange = onWakeLockChanged,
|
||||||
|
state = wakelockState
|
||||||
|
)
|
||||||
|
SettingsSwitch(
|
||||||
|
icon = { Icon(imageVector = Icons.Default.Headphones, contentDescription = "Headphones") },
|
||||||
|
title = { Text(text = stringResource(R.string.checkbox_pause_on_headphones_disconnect)) },
|
||||||
|
onCheckedChange = onHeadphonesChanged,
|
||||||
|
state = headphoneState
|
||||||
|
)
|
||||||
|
|
||||||
|
}
|
|
@ -0,0 +1,212 @@
|
||||||
|
package org.musicpd.ui
|
||||||
|
|
||||||
|
import android.os.Bundle
|
||||||
|
import androidx.activity.ComponentActivity
|
||||||
|
import androidx.activity.compose.setContent
|
||||||
|
import androidx.activity.viewModels
|
||||||
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
|
import androidx.compose.foundation.layout.Column
|
||||||
|
import androidx.compose.foundation.layout.Row
|
||||||
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.foundation.lazy.LazyColumn
|
||||||
|
import androidx.compose.foundation.lazy.items
|
||||||
|
import androidx.compose.foundation.lazy.rememberLazyListState
|
||||||
|
import androidx.compose.material.icons.Icons
|
||||||
|
import androidx.compose.material.icons.filled.Circle
|
||||||
|
import androidx.compose.material3.Button
|
||||||
|
import androidx.compose.material3.Icon
|
||||||
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.material3.OutlinedButton
|
||||||
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.State
|
||||||
|
import androidx.compose.runtime.collectAsState
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.graphics.Color
|
||||||
|
import androidx.compose.ui.platform.LocalContext
|
||||||
|
import androidx.compose.ui.res.stringResource
|
||||||
|
import androidx.compose.ui.text.font.FontFamily
|
||||||
|
import androidx.compose.ui.tooling.preview.Preview
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||||
|
import androidx.lifecycle.viewmodel.compose.viewModel
|
||||||
|
import com.google.accompanist.permissions.ExperimentalPermissionsApi
|
||||||
|
import com.google.accompanist.permissions.isGranted
|
||||||
|
import com.google.accompanist.permissions.rememberPermissionState
|
||||||
|
import com.google.accompanist.permissions.shouldShowRationale
|
||||||
|
import kotlinx.coroutines.CoroutineScope
|
||||||
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
import org.musicpd.Main
|
||||||
|
import org.musicpd.R
|
||||||
|
|
||||||
|
class SettingsActivity : ComponentActivity() {
|
||||||
|
|
||||||
|
private val settingsViewModel: SettingsViewModel by viewModels()
|
||||||
|
|
||||||
|
override fun onCreate(savedInstanceState: Bundle?) {
|
||||||
|
super.onCreate(savedInstanceState)
|
||||||
|
|
||||||
|
setContent {
|
||||||
|
MaterialTheme {
|
||||||
|
SettingsContainer(settingsViewModel)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun connectClient() {
|
||||||
|
val client = Main.Client(this, object : Main.Client.Callback {
|
||||||
|
override fun onStopped() {
|
||||||
|
settingsViewModel.updateStatus("", false)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onStarted() {
|
||||||
|
settingsViewModel.updateStatus("MPD Service Started", true)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onError(error: String) {
|
||||||
|
settingsViewModel.removeClient()
|
||||||
|
settingsViewModel.updateStatus(error, false)
|
||||||
|
connectClient()
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onLog(priority: Int, msg: String) {
|
||||||
|
settingsViewModel.addLogItem(priority, msg)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
settingsViewModel.setClient(client)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onStart() {
|
||||||
|
//mFirstRun = false
|
||||||
|
connectClient()
|
||||||
|
super.onStart()
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onStop() {
|
||||||
|
settingsViewModel.removeClient()
|
||||||
|
super.onStop()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@OptIn(ExperimentalPermissionsApi::class)
|
||||||
|
@Composable
|
||||||
|
fun SettingsContainer(settingsViewModel: SettingsViewModel = viewModel()) {
|
||||||
|
val context = LocalContext.current
|
||||||
|
|
||||||
|
val storagePermissionState = rememberPermissionState(
|
||||||
|
android.Manifest.permission.READ_EXTERNAL_STORAGE
|
||||||
|
)
|
||||||
|
|
||||||
|
if (storagePermissionState.status.shouldShowRationale) {
|
||||||
|
Column(Modifier
|
||||||
|
.padding(4.dp)
|
||||||
|
.fillMaxWidth(),
|
||||||
|
horizontalAlignment = Alignment.CenterHorizontally,
|
||||||
|
verticalArrangement = Arrangement.Center
|
||||||
|
) {
|
||||||
|
Text(stringResource(id = R.string.external_files_permission_request))
|
||||||
|
Button(onClick = { }) {
|
||||||
|
Text("Request permission")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
Column {
|
||||||
|
NetworkAddress()
|
||||||
|
ServerStatus(settingsViewModel)
|
||||||
|
if (!storagePermissionState.status.isGranted) {
|
||||||
|
OutlinedButton(onClick = { storagePermissionState.launchPermissionRequest() }, Modifier
|
||||||
|
.padding(4.dp)
|
||||||
|
.fillMaxWidth()) {
|
||||||
|
Text("Request external storage permission", color = MaterialTheme.colorScheme.secondary)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
SettingsOptions(
|
||||||
|
onBootChanged = { newValue ->
|
||||||
|
if (newValue) {
|
||||||
|
settingsViewModel.startMPD(context)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
onWakeLockChanged = { newValue ->
|
||||||
|
settingsViewModel.setWakelockEnabled(newValue)
|
||||||
|
},
|
||||||
|
onHeadphonesChanged = { newValue ->
|
||||||
|
settingsViewModel.setPauseOnHeadphonesDisconnect(newValue)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
LogView(settingsViewModel.logItemFLow.collectAsStateWithLifecycle())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun ServerStatus(settingsViewModel: SettingsViewModel) {
|
||||||
|
val context = LocalContext.current
|
||||||
|
|
||||||
|
val statusUiState by settingsViewModel.statusUIState.collectAsState()
|
||||||
|
|
||||||
|
Column {
|
||||||
|
Row(
|
||||||
|
Modifier
|
||||||
|
.padding(4.dp)
|
||||||
|
.fillMaxWidth(),
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
horizontalArrangement = Arrangement.SpaceEvenly
|
||||||
|
) {
|
||||||
|
Row {
|
||||||
|
Icon(
|
||||||
|
imageVector = Icons.Default.Circle,
|
||||||
|
contentDescription = "",
|
||||||
|
tint = if (statusUiState.running) Color(0xFFB8F397) else Color(0xFFFFDAD6)
|
||||||
|
)
|
||||||
|
Text(text = if (statusUiState.running) "Running" else "Stopped")
|
||||||
|
}
|
||||||
|
Button(onClick = {
|
||||||
|
if (statusUiState.running)
|
||||||
|
settingsViewModel.stopMPD()
|
||||||
|
else
|
||||||
|
settingsViewModel.startMPD(context)
|
||||||
|
}) {
|
||||||
|
Text(text = if (statusUiState.running) "Stop MPD" else "Start MPD")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Row(
|
||||||
|
Modifier
|
||||||
|
.padding(4.dp)
|
||||||
|
.fillMaxWidth(),
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
horizontalArrangement = Arrangement.SpaceEvenly
|
||||||
|
) {
|
||||||
|
Text(text = statusUiState.statusMessage)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun LogView(messages: State<List<String>>) {
|
||||||
|
val state = rememberLazyListState()
|
||||||
|
|
||||||
|
LazyColumn(
|
||||||
|
Modifier.padding(4.dp),
|
||||||
|
state
|
||||||
|
) {
|
||||||
|
items(messages.value) { message ->
|
||||||
|
Text(text = message, fontFamily = FontFamily.Monospace)
|
||||||
|
}
|
||||||
|
CoroutineScope(Dispatchers.Main).launch {
|
||||||
|
state.scrollToItem(messages.value.count(), 0)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Preview(showBackground = true)
|
||||||
|
@Composable
|
||||||
|
fun SettingsPreview() {
|
||||||
|
MaterialTheme {
|
||||||
|
SettingsContainer()
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,84 @@
|
||||||
|
package org.musicpd.ui
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
import android.util.Log
|
||||||
|
import androidx.lifecycle.ViewModel
|
||||||
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
|
import kotlinx.coroutines.flow.asStateFlow
|
||||||
|
import org.musicpd.Main
|
||||||
|
import org.musicpd.Preferences
|
||||||
|
|
||||||
|
private const val MAX_LOGS = 500
|
||||||
|
|
||||||
|
class SettingsViewModel : ViewModel() {
|
||||||
|
|
||||||
|
private var mClient: Main.Client? = null
|
||||||
|
|
||||||
|
private val _logItemFLow = MutableStateFlow(listOf<String>())
|
||||||
|
val logItemFLow: StateFlow<List<String>> = _logItemFLow
|
||||||
|
|
||||||
|
data class StatusUiState(
|
||||||
|
val statusMessage: String = "",
|
||||||
|
val running: Boolean = false
|
||||||
|
)
|
||||||
|
|
||||||
|
private val _statusUIState = MutableStateFlow(StatusUiState())
|
||||||
|
val statusUIState: StateFlow<StatusUiState> = _statusUIState.asStateFlow()
|
||||||
|
|
||||||
|
fun addLogItem(priority: Int, message: String) {
|
||||||
|
if (_logItemFLow.value.size > MAX_LOGS) {
|
||||||
|
_logItemFLow.value = _logItemFLow.value.drop(1)
|
||||||
|
}
|
||||||
|
|
||||||
|
val priorityString: String = when (priority) {
|
||||||
|
Log.DEBUG -> "D"
|
||||||
|
Log.ERROR -> "E"
|
||||||
|
Log.INFO -> "I"
|
||||||
|
Log.VERBOSE -> "V"
|
||||||
|
Log.WARN -> "W"
|
||||||
|
else -> ""
|
||||||
|
}
|
||||||
|
|
||||||
|
_logItemFLow.value = _logItemFLow.value + ("$priorityString/$message")
|
||||||
|
}
|
||||||
|
|
||||||
|
fun updateStatus(message: String, running: Boolean) {
|
||||||
|
_statusUIState.value = StatusUiState(message, running)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun setClient(client: Main.Client) {
|
||||||
|
mClient = client
|
||||||
|
}
|
||||||
|
|
||||||
|
fun removeClient() {
|
||||||
|
mClient?.release()
|
||||||
|
mClient = null
|
||||||
|
}
|
||||||
|
|
||||||
|
fun startMPD(context: Context) {
|
||||||
|
mClient?.start()
|
||||||
|
if (Preferences.getBoolean(
|
||||||
|
context,
|
||||||
|
Preferences.KEY_WAKELOCK, false
|
||||||
|
)
|
||||||
|
) mClient?.setWakelockEnabled(true)
|
||||||
|
if (Preferences.getBoolean(
|
||||||
|
context,
|
||||||
|
Preferences.KEY_PAUSE_ON_HEADPHONES_DISCONNECT, false
|
||||||
|
)
|
||||||
|
) mClient?.setPauseOnHeadphonesDisconnect(true)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun stopMPD() {
|
||||||
|
mClient?.stop()
|
||||||
|
}
|
||||||
|
|
||||||
|
fun setWakelockEnabled(enabled: Boolean) {
|
||||||
|
mClient?.setWakelockEnabled(enabled)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun setPauseOnHeadphonesDisconnect(enabled: Boolean) {
|
||||||
|
mClient?.setPauseOnHeadphonesDisconnect(enabled)
|
||||||
|
}
|
||||||
|
}
|
|
@ -1,5 +0,0 @@
|
||||||
<?xml version="1.0" encoding="utf-8"?>
|
|
||||||
<TextView xmlns:android="http://schemas.android.com/apk/res/android"
|
|
||||||
android:layout_width="match_parent"
|
|
||||||
android:layout_height="wrap_content"
|
|
||||||
android:typeface="monospace" />
|
|
|
@ -1,65 +0,0 @@
|
||||||
<?xml version="1.0" encoding="utf-8"?>
|
|
||||||
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
|
|
||||||
android:layout_width="match_parent"
|
|
||||||
android:layout_height="wrap_content"
|
|
||||||
android:orientation="vertical">
|
|
||||||
|
|
||||||
<LinearLayout
|
|
||||||
android:layout_width="match_parent"
|
|
||||||
android:layout_height="wrap_content"
|
|
||||||
android:gravity="center_vertical">
|
|
||||||
|
|
||||||
<ImageView
|
|
||||||
android:layout_width="wrap_content"
|
|
||||||
android:layout_height="wrap_content"
|
|
||||||
android:scaleType="center"
|
|
||||||
android:src="@drawable/baseline_wifi_24"
|
|
||||||
android:layout_margin="4dp"/>
|
|
||||||
|
|
||||||
<androidx.appcompat.widget.AppCompatTextView
|
|
||||||
android:id="@+id/networkAddress"
|
|
||||||
android:layout_width="match_parent"
|
|
||||||
android:layout_height="wrap_content"
|
|
||||||
android:layout_marginRight="24sp"
|
|
||||||
android:gravity="center"
|
|
||||||
android:padding="4dp"
|
|
||||||
android:textSize="24sp" />
|
|
||||||
</LinearLayout>
|
|
||||||
|
|
||||||
<ToggleButton
|
|
||||||
android:id="@+id/run"
|
|
||||||
android:layout_width="match_parent"
|
|
||||||
android:layout_height="wrap_content"
|
|
||||||
android:textOff="@string/toggle_button_run_off"
|
|
||||||
android:textOn="@string/toggle_button_run_on" />
|
|
||||||
|
|
||||||
<CheckBox
|
|
||||||
android:id="@+id/run_on_boot"
|
|
||||||
android:layout_width="match_parent"
|
|
||||||
android:layout_height="wrap_content"
|
|
||||||
android:text="@string/checkbox_run_on_boot" />
|
|
||||||
|
|
||||||
<CheckBox
|
|
||||||
android:id="@+id/wakelock"
|
|
||||||
android:layout_width="match_parent"
|
|
||||||
android:layout_height="wrap_content"
|
|
||||||
android:text="@string/checkbox_wakelock" />
|
|
||||||
|
|
||||||
<CheckBox
|
|
||||||
android:id="@+id/pause_on_headphones_disconnect"
|
|
||||||
android:layout_width="match_parent"
|
|
||||||
android:layout_height="wrap_content"
|
|
||||||
android:text="@string/checkbox_pause_on_headphones_disconnect" />
|
|
||||||
|
|
||||||
<TextView
|
|
||||||
android:id="@+id/status"
|
|
||||||
android:layout_width="wrap_content"
|
|
||||||
android:layout_height="wrap_content" />
|
|
||||||
|
|
||||||
<ListView
|
|
||||||
android:id="@+id/log_list"
|
|
||||||
android:layout_width="match_parent"
|
|
||||||
android:layout_height="wrap_content"
|
|
||||||
android:paddingTop="10dip" />
|
|
||||||
|
|
||||||
</LinearLayout>
|
|
|
@ -9,4 +9,5 @@
|
||||||
<string name="checkbox_run_on_boot">Run MPD automatically on boot</string>
|
<string name="checkbox_run_on_boot">Run MPD automatically on boot</string>
|
||||||
<string name="checkbox_wakelock">Prevent suspend when MPD is running (Wakelock)</string>
|
<string name="checkbox_wakelock">Prevent suspend when MPD is running (Wakelock)</string>
|
||||||
<string name="checkbox_pause_on_headphones_disconnect">Pause MPD when headphones disconnect</string>
|
<string name="checkbox_pause_on_headphones_disconnect">Pause MPD when headphones disconnect</string>
|
||||||
|
<string name="external_files_permission_request">MPD requires access to external files to play local music. Please grant the permission.</string>
|
||||||
</resources>
|
</resources>
|
||||||
|
|
|
@ -0,0 +1,4 @@
|
||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<resources>
|
||||||
|
<style name="Theme.MPD" parent="android:Theme.Material.Light.NoActionBar" />
|
||||||
|
</resources>
|
|
@ -1,4 +1,5 @@
|
||||||
// Top-level build file where you can add configuration options common to all sub-projects/modules.
|
// Top-level build file where you can add configuration options common to all sub-projects/modules.
|
||||||
plugins {
|
plugins {
|
||||||
id("com.android.application") version "8.1.2" apply false
|
id("com.android.application") version "8.1.2" apply false
|
||||||
|
id("org.jetbrains.kotlin.android") version "1.9.20" apply false
|
||||||
}
|
}
|
Loading…
Reference in New Issue