Porównaj commity

...

46 Commity

Autor SHA1 Wiadomość Data
andrekir ff37bd7a98 fix adv settings buttons layout 2022-06-06 18:16:40 -03:00
andrekir 0e18994d30 add shutdown & reboot admin commands
(cherry picked from commit 47793a2086)
2022-06-06 18:12:18 -03:00
Andre Kirchhoff 0479f87231
1.2.67 2022-06-05 22:05:24 -03:00
andrekir d6acf2829d fix BLE address & use repository 2022-06-05 21:59:43 -03:00
Andre Kirchhoff 8004e11dfe
1.2.66 2022-06-01 22:27:33 -03:00
andrekir 0c9dc72c4c updating proto submodule to 1.2-legacy 2022-06-01 19:15:08 -03:00
Andre Kirchhoff 8a214669ba
update .gitmodules proto to 1.2-legacy branch 2022-06-01 19:11:44 -03:00
Andre Kirchhoff 113a4ffcdc
1.2.65 2022-06-01 10:19:13 -03:00
Andre Kirchhoff cfdeb974b1
include nano firmware 2022-06-01 10:18:15 -03:00
andrekir 501b05ca59 limit channel names to 11 characters 2022-05-23 16:32:54 -03:00
andrekir 60f7f98748 fix DeviceListEntry bug when BLE disabled 2022-05-23 16:10:50 -03:00
andrekir 5f131da50d add mlkit barcode scanner 2022-05-17 18:03:31 -03:00
andrekir f20368833e fix snackbar causing ScrollView crash 2022-05-16 18:39:26 -03:00
andrekir 51535f4e4f 1.2.64 2022-05-06 17:29:58 -03:00
andrekir d5642d0a03 use standard IM text input convention
(cherry picked from commit 99dfc8014a)
2022-05-06 17:26:57 -03:00
andrekir 967f777374 bluetooth scan & connect UI rework
(cherry picked from commit ef9114ddc1)
2022-05-03 18:17:38 -03:00
andrekir 104b044572 1.2.62 2022-04-30 18:19:10 -03:00
andrekir 265c6a7bf7 improve users fragment 2022-04-30 18:16:12 -03:00
andrekir b84dd3577f update UI when started with BLE disabled
(cherry picked from commit 0294da844b)
2022-04-30 16:54:48 -03:00
andrekir 9682c41f2b disassociate devices when not bonded
(cherry picked from commit b6410dd162)
2022-04-30 16:54:42 -03:00
andrekir ce90db8258 add BLE associations to devices list 2022-04-30 16:54:32 -03:00
andrekir 9506cfb9c4 move hasCompanionDeviceApi out of BluetoothInterface 2022-04-30 16:49:19 -03:00
andrekir 708c502eb9 update deprecated intent method 2022-04-28 11:55:07 -03:00
Andre Kirchhoff d4d39b282a update links to android docs 2022-04-24 21:45:56 -03:00
andrekir b873f2928d update not_connected string
(cherry picked from commit b1ad9c1d0e)
2022-04-07 23:55:10 -03:00
andrekir de2b87557b only consider device sleep when isPowerSaving enabled
(cherry picked from commit bcc46f36e6)
2022-04-07 23:54:54 -03:00
andrekir 8b13961b83 use keyboard capitalization settings
(cherry picked from commit 1bc843897d)
2022-04-07 23:54:21 -03:00
Andre Kirchhoff 276f6dc758
1.2.61 2022-04-03 17:12:04 -03:00
andrekir 496cd230dd add contacts 2022-04-03 12:00:05 -03:00
andrekir 22c370d231 insert sent messages in Packet 2022-03-29 11:05:44 -03:00
Andre Kirchhoff 1f3d817e3b
Merge pull request #394 from meshtastic/1.2-release
1.2.60
2022-03-19 17:22:18 -03:00
andrekir bf8f4f1660 1.2.60 2022-03-19 17:12:24 -03:00
andrekir 6258780106 update mapbox tokens 2022-03-15 01:33:30 -03:00
andrekir 67794f0433 improve firmware update 2022-03-11 00:03:46 -03:00
Andre Kirchhoff 4092fc5c7f
Merge pull request #388 from meshtastic/1.2-release
1.2.59
2022-03-02 17:50:24 -03:00
andrekir 91b2767634 1.2.59 2022-03-02 17:28:18 -03:00
andrekir 52f7a862b3 update gradle and libs 2022-03-02 17:27:32 -03:00
andrekir ede48be4f3 fix "Multiple substitutions specified in non-positional format of string resource string/connected_count" 2022-03-02 14:25:35 -03:00
andrekir 598ec54cf3 anonymize sendPosition 2022-03-02 14:23:41 -03:00
Mike Cumings 49188adc36 Issue #369 - Expand bluetooth repository use cases
Changes:
- Adds support for obtaining bonded devices
- Adds support for obtaining BLE scanner
- Consolidates state into a single, immutable data class instance
- Simplified and renamed broadcast receiver
- Renamed view model permissionsUpdated fun to identify the intended use

(cherry picked from commit 9592fd68de)
2022-03-02 14:10:01 -03:00
Mike Cumings c0fe9213f1 Issue #369 - Use repository pattern for bluetooth state
(cherry picked from commit b3878a4240)
2022-03-02 14:10:01 -03:00
Mike Cumings 1294eee8e3 CSV export improvements to make it more reliable
(cherry picked from commit 16d2b2e5f3)
2022-03-02 14:10:01 -03:00
Andre Kirchhoff 5659725b96
Merge pull request #387 from meshtastic/menu-messages
add action mode menu to messages (delete & select all)
2022-03-02 12:12:20 -03:00
andrekir 6ad2b1814f deleteMessage index by packet.id 2022-03-02 11:39:30 -03:00
andrekir 01f8154189 add action mode menu to messages (delete & select all) 2022-03-02 11:39:07 -03:00
Andre Kirchhoff 7395cc5583
tie 1.2-legacy releases to 1.2-legacy firmware 2022-02-28 16:34:55 -03:00
62 zmienionych plików z 1686 dodań i 738 usunięć

Wyświetl plik

@ -19,11 +19,12 @@ jobs:
- name: Load secrets
run: |
rm ./app/src/main/res/values/mapbox-token.xml
echo -e "<resources>\n <string name=\"mapbox_access_token\">$MAPBOXTOKEN</string>\n</resources>" > ./app/src/main/res/values/mapbox-token.xml
echo -e "<resources>\n <string name=\"mapbox_access_token\">$MAPBOX_ACCESS_TOKEN</string>\n</resources>" > ./app/src/main/res/values/mapbox-token.xml
mkdir -p ~/.gradle
echo "MAPBOX_DOWNLOADS_TOKEN=$MAPBOXTOKEN" >>~/.gradle/gradle.properties
echo "MAPBOX_DOWNLOADS_TOKEN=$MAPBOX_DOWNLOADS_TOKEN" >>~/.gradle/gradle.properties
env:
MAPBOXTOKEN: ${{ secrets.MAPBOXTOKEN }}
MAPBOX_ACCESS_TOKEN: ${{ secrets.MAPBOX_ACCESS_TOKEN }}
MAPBOX_DOWNLOADS_TOKEN: ${{ secrets.MAPBOX_DOWNLOADS_TOKEN }}
- name: Mock files for CI
run: |

Wyświetl plik

@ -22,14 +22,15 @@ jobs:
rm ./app/google-services.json
echo $GSERVICES > ./app/google-services.json
rm ./app/src/main/res/values/mapbox-token.xml
echo -e "<resources>\n <string name=\"mapbox_access_token\">$MAPBOXTOKEN</string>\n</resources>" > ./app/src/main/res/values/mapbox-token.xml
echo -e "<resources>\n <string name=\"mapbox_access_token\">$MAPBOX_ACCESS_TOKEN</string>\n</resources>" > ./app/src/main/res/values/mapbox-token.xml
mkdir -p ~/.gradle
echo "MAPBOX_DOWNLOADS_TOKEN=$MAPBOXTOKEN" >> ~/.gradle/gradle.properties
echo "MAPBOX_DOWNLOADS_TOKEN=$MAPBOX_DOWNLOADS_TOKEN" >> ~/.gradle/gradle.properties
echo $KEYSTORE | base64 -di > ./app/$KEYSTORE_FILENAME
echo "$KEYSTORE_PROPERTIES" > ./keystore.properties
env:
GSERVICES: ${{ secrets.GSERVICES }}
MAPBOXTOKEN: ${{ secrets.MAPBOXTOKEN }}
MAPBOX_ACCESS_TOKEN: ${{ secrets.MAPBOX_ACCESS_TOKEN }}
MAPBOX_DOWNLOADS_TOKEN: ${{ secrets.MAPBOX_DOWNLOADS_TOKEN }}
KEYSTORE: ${{ secrets.KEYSTORE }}
KEYSTORE_FILENAME: ${{ secrets.KEYSTORE_FILENAME }}
KEYSTORE_PROPERTIES: ${{ secrets.KEYSTORE_PROPERTIES }}
@ -47,6 +48,7 @@ jobs:
with:
repository: meshtastic/Meshtastic-device
releases-only: true
prefix: 'v1.2.'
token: ${{ secrets.GITHUB_TOKEN }}
- name: Create version strings
@ -72,7 +74,7 @@ jobs:
run: |
rm -rf ./app/src/main/assets/firmware
mkdir -p ./app/src/main/assets/firmware
unzip -qq ./firmware.zip 'spiffs-*.bin' 'firmware-heltec*.bin' 'firmware-tbeam*.bin' 'firmware-tlora*.bin' -d ./app/src/main/assets/firmware
unzip -qq ./firmware.zip 'spiffs-*.bin' 'firmware-heltec*.bin' 'firmware-tbeam*.bin' 'firmware-tlora*.bin' 'firmware-nano*.bin' -d ./app/src/main/assets/firmware
rm ./firmware.zip
- name: Validate Gradle wrapper

1
.gitmodules vendored
Wyświetl plik

@ -1,6 +1,7 @@
[submodule "app/src/main/proto"]
path = app/src/main/proto
url = https://github.com/meshtastic/Meshtastic-protobufs.git
branch = 1.2-legacy
[submodule "geeksville-androidlib"]
path = geeksville-androidlib
url = https://github.com/meshtastic/geeksville-androidlib.git

Wyświetl plik

@ -43,8 +43,8 @@ android {
applicationId "com.geeksville.mesh"
minSdkVersion 21 // The oldest emulator image I have tried is 22 (though 21 probably works)
targetSdkVersion 30 // 30 can't work until an explicit location permissions dialog is added
versionCode 20258 // format is Mmmss (where M is 1+the numeric major number
versionName "1.2.58"
versionCode 20267 // format is Mmmss (where M is 1+the numeric major number
versionName "1.2.67"
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
// per https://developer.android.com/studio/write/vector-asset-studio
@ -122,7 +122,7 @@ protobuf {
dependencies {
def room_version = '2.4.1'
def room_version = '2.4.2'
implementation fileTree(dir: 'libs', include: ['*.jar'])
implementation 'androidx.appcompat:appcompat:1.4.1'
@ -134,7 +134,7 @@ dependencies {
implementation 'com.google.android.material:material:1.5.0'
implementation 'androidx.viewpager2:viewpager2:1.0.0'
implementation 'androidx.lifecycle:lifecycle-extensions:2.2.0'
implementation 'androidx.lifecycle:lifecycle-livedata-ktx:2.4.0'
implementation 'androidx.lifecycle:lifecycle-livedata-ktx:2.4.1'
implementation "androidx.room:room-runtime:$room_version"
implementation "com.google.dagger:hilt-android:$hilt_version"
kapt "androidx.room:room-compiler:$room_version"
@ -175,9 +175,10 @@ dependencies {
// location services
implementation 'com.google.android.gms:play-services-location:19.0.1'
// For Google Sign-In (owner name accesss)
implementation 'com.google.android.gms:play-services-auth:20.1.0'
// ML Kit barcode scanning
implementation 'com.google.android.gms:play-services-code-scanner:16.0.0-beta1'
// Add the Firebase SDK for Crashlytics.
implementation 'com.google.firebase:firebase-crashlytics:18.2.6'
@ -207,4 +208,4 @@ dependencies {
kapt {
correctErrorTypes true
}
}

Wyświetl plik

@ -97,6 +97,9 @@
<meta-data
android:name="firebase_analytics_collection_enabled"
android:value="false" />
<meta-data
android:name="com.google.mlkit.vision.DEPENDENCIES"
android:value="barcode_ui"/>
<!-- we need bind job service for oreo -->
<service

Wyświetl plik

@ -66,8 +66,7 @@ interface IMeshService {
*/
void send(inout DataPacket packet);
void delete(int position);
void deleteMessage(int packetId);
void deleteAllMessages();
@ -96,6 +95,12 @@ interface IMeshService {
/// It sets a ChannelSet protobuf
void setChannels(in byte []payload);
/// Send Shutdown admin packet to nodeNum
void requestShutdown(in String nodeId);
/// Send Reboot admin packet to nodeNum
void requestReboot(in String nodeId);
/**
Is the packet radio currently connected to the phone? Returns a ConnectionState string.
*/

Wyświetl plik

@ -3,6 +3,9 @@ package com.geeksville.mesh
import android.app.Application
import android.content.Context
import android.content.SharedPreferences
import androidx.lifecycle.Lifecycle
import androidx.lifecycle.LifecycleOwner
import androidx.lifecycle.ProcessLifecycleOwner
import dagger.Module
import dagger.Provides
import dagger.hilt.InstallIn
@ -15,4 +18,14 @@ object ApplicationModule {
fun provideSharedPreferences(application: Application): SharedPreferences {
return application.getSharedPreferences("ui-prefs", Context.MODE_PRIVATE)
}
@Provides
fun provideProcessLifecycleOwner(): LifecycleOwner {
return ProcessLifecycleOwner.get()
}
@Provides
fun provideProcessLifecycle(processLifecycleOwner: LifecycleOwner): Lifecycle {
return processLifecycleOwner.lifecycle
}
}

Wyświetl plik

@ -0,0 +1,15 @@
package com.geeksville.mesh
import kotlinx.coroutines.Dispatchers
import javax.inject.Inject
/**
* Wrapper around `Dispatchers` to allow for easier testing when using dispatchers
* in injected classes.
*/
class CoroutineDispatchers @Inject constructor() {
val main = Dispatchers.Main
val mainImmediate = Dispatchers.Main.immediate
val default = Dispatchers.Default
val io = Dispatchers.IO
}

Wyświetl plik

@ -29,7 +29,9 @@ data class DataPacket(
var time: Long = System.currentTimeMillis(), // msecs since 1970
var id: Int = 0, // 0 means unassigned
var status: MessageStatus? = MessageStatus.UNKNOWN,
var hopLimit: Int = 0
var hopLimit: Int = 0,
var channel: Int = 0, // channel index
var delayed: Int = 0 // S&F MeshProtos.MeshPacket.Delayed.(...)_VALUE
) : Parcelable {
/**
@ -64,6 +66,8 @@ data class DataPacket(
parcel.readLong(),
parcel.readInt(),
parcel.readParcelable(MessageStatus::class.java.classLoader),
parcel.readInt(),
parcel.readInt(),
parcel.readInt()
)
@ -75,6 +79,7 @@ data class DataPacket(
if (from != other.from) return false
if (to != other.to) return false
if (channel != other.channel) return false
if (time != other.time) return false
if (id != other.id) return false
if (dataType != other.dataType) return false
@ -94,6 +99,8 @@ data class DataPacket(
result = 31 * result + bytes!!.contentHashCode()
result = 31 * result + status.hashCode()
result = 31 * result + hopLimit
result = 31 * result + channel
result = 31 * result + delayed
return result
}
@ -106,6 +113,8 @@ data class DataPacket(
parcel.writeInt(id)
parcel.writeParcelable(status, flags)
parcel.writeInt(hopLimit)
parcel.writeInt(channel)
parcel.writeInt(delayed)
}
override fun describeContents(): Int {
@ -122,6 +131,8 @@ data class DataPacket(
id = parcel.readInt()
status = parcel.readParcelable(MessageStatus::class.java.classLoader)
hopLimit = parcel.readInt()
channel = parcel.readInt()
delayed = parcel.readInt()
}
companion object CREATOR : Parcelable.Creator<DataPacket> {
@ -145,7 +156,7 @@ data class DataPacket(
override fun newArray(size: Int): Array<DataPacket?> {
return arrayOfNulls(size)
}
val utf8 = Charset.forName("UTF-8")
val utf8: Charset = Charset.forName("UTF-8")
}

Wyświetl plik

@ -4,7 +4,6 @@ import android.Manifest
import android.annotation.SuppressLint
import android.app.Activity
import android.bluetooth.BluetoothAdapter
import android.bluetooth.BluetoothManager
import android.content.*
import android.content.pm.PackageInfo
import android.content.pm.PackageManager
@ -14,6 +13,7 @@ import android.net.Uri
import android.os.Build
import android.os.Bundle
import android.os.Handler
import android.os.Looper
import android.os.RemoteException
import android.text.method.LinkMovementMethod
import android.view.Menu
@ -40,6 +40,7 @@ import com.geeksville.android.ServiceClient
import com.geeksville.concurrent.handledLaunch
import com.geeksville.mesh.android.*
import com.geeksville.mesh.databinding.ActivityMainBinding
import com.geeksville.mesh.model.BluetoothViewModel
import com.geeksville.mesh.model.ChannelSet
import com.geeksville.mesh.model.DeviceVersion
import com.geeksville.mesh.model.UIViewModel
@ -125,7 +126,7 @@ class MainActivity : AppCompatActivity(), Logging,
const val REQUEST_ENABLE_BT = 10
const val DID_REQUEST_PERM = 11
const val RC_SIGN_IN = 12 // google signin completed
const val SELECT_DEVICE_REQUEST_CODE = 13
// const val SELECT_DEVICE_REQUEST_CODE = 13
const val CREATE_CSV_FILE = 14
}
@ -134,11 +135,7 @@ class MainActivity : AppCompatActivity(), Logging,
// Used to schedule a coroutine in the GUI thread
private val mainScope = CoroutineScope(Dispatchers.Main + Job())
private val bluetoothAdapter: BluetoothAdapter? by lazy(LazyThreadSafetyMode.NONE) {
val bluetoothManager = getSystemService(Context.BLUETOOTH_SERVICE) as BluetoothManager
bluetoothManager.adapter
}
private val bluetoothViewModel: BluetoothViewModel by viewModels()
val model: UIViewModel by viewModels()
data class TabInfo(val text: String, val icon: Int, val content: Fragment)
@ -148,7 +145,7 @@ class MainActivity : AppCompatActivity(), Logging,
TabInfo(
"Messages",
R.drawable.ic_twotone_message_24,
MessagesFragment()
ContactsFragment()
),
TabInfo(
"Users",
@ -187,28 +184,6 @@ class MainActivity : AppCompatActivity(), Logging,
}
}
private val btStateReceiver = BluetoothStateReceiver {
updateBluetoothEnabled()
}
/**
* Don't tell our app we have bluetooth until we have bluetooth _and_ location access
*/
private fun updateBluetoothEnabled() {
var enabled = false // assume failure
if (hasConnectPermission()) {
/// ask the adapter if we have access
bluetoothAdapter?.apply {
enabled = isEnabled
}
} else
errormsg("Still missing needed bluetooth permissions")
debug("Detected our bluetooth access=$enabled")
model.bluetoothEnabled.value = enabled
}
/** Get the minimum permissions our app needs to run correctly
*/
private fun getMinimumPermissions(): List<String> {
@ -381,7 +356,7 @@ class MainActivity : AppCompatActivity(), Logging,
}
}
updateBluetoothEnabled()
bluetoothViewModel.permissionsUpdated()
}
@ -445,12 +420,6 @@ class MainActivity : AppCompatActivity(), Logging,
/// Set theme
setUITheme(prefs)
/// Set initial bluetooth state
updateBluetoothEnabled()
/// We now want to be informed of bluetooth state
registerReceiver(btStateReceiver, btStateReceiver.intentFilter)
/* not yet working
// Configure sign-in to request the user's ID, email address, and basic
// profile. ID and basic profile are included in DEFAULT_SIGN_IN.
@ -545,8 +514,7 @@ class MainActivity : AppCompatActivity(), Logging,
requestedChannelUrl = appLinkData
// if the device is connected already, process it now
if (model.isConnected.value == MeshService.ConnectionState.CONNECTED)
perhapsChangeChannel()
perhapsChangeChannel()
// We now wait for the device to connect, once connected, we ask the user if they want to switch to the new channel
}
@ -569,7 +537,6 @@ class MainActivity : AppCompatActivity(), Logging,
}
override fun onDestroy() {
unregisterReceiver(btStateReceiver)
unregisterMeshReceiver()
mainScope.cancel("Activity going away")
super.onDestroy()
@ -765,16 +732,16 @@ class MainActivity : AppCompatActivity(), Logging,
}
}
fun perhapsChangeChannel(url: Uri? = requestedChannelUrl) {
// If the is opening a channel URL, handle it now
if (url != null) {
private fun perhapsChangeChannel(url: Uri? = requestedChannelUrl) {
// if the device is connected already, process it now
if (url != null && model.isConnected.value == MeshService.ConnectionState.CONNECTED) {
requestedChannelUrl = null
try {
val channels = ChannelSet(url)
val primary = channels.primaryChannel
if (primary == null)
showSnackbar(R.string.channel_invalid)
else {
requestedChannelUrl = null
MaterialAlertDialogBuilder(this)
.setTitle(R.string.new_channel_rcvd)
@ -1003,17 +970,26 @@ class MainActivity : AppCompatActivity(), Logging,
override fun onStart() {
super.onStart()
// Ask to start bluetooth if no USB devices are visible
val hasUSB = SerialInterface.findDrivers(this).isNotEmpty()
if (!isInTestLab && !hasUSB) {
if (hasConnectPermission()) {
bluetoothAdapter?.let {
if (!it.isEnabled) {
bluetoothViewModel.enabled.observe(this) { enabled ->
if (!enabled) {
// Ask to start bluetooth if no USB devices are visible
val hasUSB = SerialInterface.findDrivers(this).isNotEmpty()
if (!isInTestLab && !hasUSB) {
if (hasConnectPermission()) {
val enableBtIntent = Intent(BluetoothAdapter.ACTION_REQUEST_ENABLE)
startActivityForResult(enableBtIntent, REQUEST_ENABLE_BT)
}
} else requestPermission()
}
} else requestPermission()
}
}
// Call perhapsChangeChannel() whenever [changeChannelUrl] updates with a non-null value
model.requestChannelUrl.observe(this) { url ->
url?.let {
requestedChannelUrl = url
model.clearRequestChannelUrl()
perhapsChangeChannel()
}
}
try {
@ -1043,7 +1019,7 @@ class MainActivity : AppCompatActivity(), Logging,
}
val handler: Handler by lazy {
Handler(mainLooper)
Handler(Looper.getMainLooper())
}
override fun onPrepareOptionsMenu(menu: Menu): Boolean {

Wyświetl plik

@ -1,24 +1,43 @@
package com.geeksville.mesh.android
import android.Manifest
import android.annotation.SuppressLint
import android.app.NotificationManager
import android.bluetooth.BluetoothManager
import android.companion.CompanionDeviceManager
import android.content.Context
import android.content.pm.PackageManager
import android.hardware.usb.UsbManager
import android.os.Build
import androidx.core.content.ContextCompat
import com.geeksville.mesh.service.BluetoothInterface
import com.geeksville.android.GeeksvilleApplication
import com.geeksville.mesh.MainActivity
/**
* @return null on platforms without a BlueTooth driver (i.e. the emulator)
*/
val Context.bluetoothManager: BluetoothManager? get() = getSystemService(Context.BLUETOOTH_SERVICE) as? BluetoothManager?
val Context.deviceManager: CompanionDeviceManager?
@SuppressLint("InlinedApi")
get() {
val activity: MainActivity? = GeeksvilleApplication.currentActivity as MainActivity?
return if (hasCompanionDeviceApi()) activity?.getSystemService(Context.COMPANION_DEVICE_SERVICE) as? CompanionDeviceManager?
else null
}
val Context.usbManager: UsbManager get() = requireNotNull(getSystemService(Context.USB_SERVICE) as? UsbManager?) { "USB_SERVICE is not available"}
val Context.notificationManager: NotificationManager get() = requireNotNull(getSystemService(Context.NOTIFICATION_SERVICE) as? NotificationManager?)
/**
* @return true if CompanionDeviceManager API is present
*/
fun Context.hasCompanionDeviceApi(): Boolean =
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O)
packageManager.hasSystemFeature(PackageManager.FEATURE_COMPANION_DEVICE_SETUP)
else false
/**
* return a list of the permissions we don't have
*/
@ -62,7 +81,7 @@ fun Context.getScanPermissions(): List<String> {
perms.add(Manifest.permission.BLUETOOTH_ADMIN)
}
*/
if (!BluetoothInterface.hasCompanionDeviceApi(this)) {
if (!hasCompanionDeviceApi()) {
perms.add(Manifest.permission.ACCESS_FINE_LOCATION)
perms.add(Manifest.permission.BLUETOOTH_ADMIN)
}

Wyświetl plik

@ -16,8 +16,8 @@ class PacketRepository @Inject constructor(private val packetDaoLazy: dagger.Laz
packetDao.getAllPacket(MAX_ITEMS)
}
suspend fun getAllPacketsInReceiveOrder(): Flow<List<Packet>> = withContext(Dispatchers.IO) {
packetDao.getAllPacketsInReceiveOrder(MAX_ITEMS)
suspend fun getAllPacketsInReceiveOrder(maxItems: Int = MAX_ITEMS): Flow<List<Packet>> = withContext(Dispatchers.IO) {
packetDao.getAllPacketsInReceiveOrder(maxItems)
}
suspend fun insert(packet: Packet) = withContext(Dispatchers.IO) {

Wyświetl plik

@ -16,7 +16,7 @@ data class Packet(@PrimaryKey val uuid: String,
@ColumnInfo(name = "message") val raw_message: String
) {
val proto: MeshProtos.MeshPacket?
val meshPacket: MeshProtos.MeshPacket?
get() {
if (message_type == "packet") {
val builder = MeshProtos.MeshPacket.newBuilder()
@ -28,13 +28,27 @@ data class Packet(@PrimaryKey val uuid: String,
}
return null
}
val nodeInfo: MeshProtos.NodeInfo?
get() {
if (message_type == "NodeInfo") {
val builder = MeshProtos.NodeInfo.newBuilder()
try {
TextFormat.getParser().merge(raw_message, builder)
return builder.build()
} catch (e: IOException) {
}
}
return null
}
val position: MeshProtos.Position?
get() {
return proto?.run {
return meshPacket?.run {
if (hasDecoded() && decoded.portnumValue == Portnums.PortNum.POSITION_APP_VALUE) {
return MeshProtos.Position.parseFrom(decoded.payload)
}
return null
}
} ?: nodeInfo?.position
}
}

Wyświetl plik

@ -0,0 +1,24 @@
package com.geeksville.mesh.model
import androidx.lifecycle.ViewModel
import androidx.lifecycle.asLiveData
import com.geeksville.mesh.repository.bluetooth.BluetoothRepository
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.flow.map
import javax.inject.Inject
/**
* Thin view model which adapts the view layer to the `BluetoothRepository`.
*/
@HiltViewModel
class BluetoothViewModel @Inject constructor(
private val bluetoothRepository: BluetoothRepository,
) : ViewModel() {
/**
* Called when permissions have been updated. This causes an explicit refresh of the
* bluetooth state.
*/
fun permissionsUpdated() = bluetoothRepository.refreshState()
val enabled = bluetoothRepository.state.map { it.enabled }.asLiveData()
}

Wyświetl plik

@ -30,10 +30,35 @@ class MessagesState(private val ui: UIViewModel) : Logging {
}
private var contactsList = emptyMap<String?, DataPacket>().toMutableMap()
val contacts = object : MutableLiveData<MutableMap<String?, DataPacket>>() {
}
private fun emptyDataPacket(to: String? = DataPacket.ID_BROADCAST): DataPacket {
return DataPacket(to, null, 1, DataPacket.ID_LOCAL, 0L)
}
// Map each contactId to last DataPacket message sent or received
// Broadcast: it.to == DataPacket.ID_BROADCAST; Direct Messages: it.to != DataPacket.ID_BROADCAST
private fun buildContacts() {
contactsList = messagesList.associateBy {
if (it.from == DataPacket.ID_LOCAL || it.to == DataPacket.ID_BROADCAST)
it.to else it.from
}.toMutableMap()
val all = DataPacket.ID_BROADCAST // always show contacts, even when empty
if (contactsList[all] == null)
contactsList[all] = emptyDataPacket()
contacts.value = contactsList
}
fun setMessages(m: List<DataPacket>) {
messagesList.clear()
messagesList.addAll(m)
messages.value = messagesList
buildContacts()
}
/// add a message our GUI list of past msgs
@ -44,6 +69,7 @@ class MessagesState(private val ui: UIViewModel) : Logging {
messagesList.add(m)
messages.value = messagesList
buildContacts()
}
fun removeMessage(m: DataPacket) {
@ -51,6 +77,7 @@ class MessagesState(private val ui: UIViewModel) : Logging {
messagesList.remove(m)
messages.value = messagesList
buildContacts()
}
private fun removeAllMessages() {
@ -58,6 +85,7 @@ class MessagesState(private val ui: UIViewModel) : Logging {
messagesList.clear()
messages.value = messagesList
buildContacts()
}
fun updateStatus(id: Int, status: MessageStatus) {
@ -95,12 +123,12 @@ class MessagesState(private val ui: UIViewModel) : Logging {
addMessage(p)
}
fun deleteMessage(packet: DataPacket, position: Int) {
fun deleteMessage(packet: DataPacket) {
val service = ui.meshService
if (service != null) {
try {
service.delete(position)
service.deleteMessage(packet.id)
} catch (ex: RemoteException) {
packet.errorMessage = "Error: ${ex.message}"
}
@ -116,7 +144,7 @@ class MessagesState(private val ui: UIViewModel) : Logging {
try {
service.deleteAllMessages()
} catch (ex: RemoteException) {
errormsg("Error: ${ex.message}")
}
removeAllMessages()
}

Wyświetl plik

@ -7,6 +7,7 @@ import android.net.Uri
import android.os.RemoteException
import android.view.Menu
import androidx.core.content.edit
import androidx.lifecycle.LiveData
import androidx.lifecycle.MutableLiveData
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
@ -20,7 +21,6 @@ import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.collect
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
@ -74,10 +74,6 @@ class UIViewModel @Inject constructor(
debug("ViewModel created")
}
fun insertPacket(packet: Packet) = viewModelScope.launch(Dispatchers.IO) {
repository.insert(packet)
}
fun deleteAllPacket() = viewModelScope.launch(Dispatchers.IO) {
repository.deleteAll()
}
@ -107,6 +103,20 @@ class UIViewModel @Inject constructor(
val channels = object : MutableLiveData<ChannelSet?>(null) {
}
private val _requestChannelUrl = MutableLiveData<Uri?>(null)
val requestChannelUrl: LiveData<Uri?> get() = _requestChannelUrl
fun setRequestChannelUrl(channelUrl: Uri) {
_requestChannelUrl.value = channelUrl
}
/**
* Called immediately after activity observes requestChannelUrl
*/
fun clearRequestChannelUrl() {
_requestChannelUrl.value = null
}
var positionBroadcastSecs: Int?
get() {
radioConfig.value?.preferences?.let {
@ -229,10 +239,6 @@ class UIViewModel @Inject constructor(
val ownerName = object : MutableLiveData<String>("MrIDE Test") {
}
val bluetoothEnabled = object : MutableLiveData<Boolean>(false) {
}
val provideLocation = object : MutableLiveData<Boolean>(preferences.getBoolean(MyPreferences.provideLocationKey, false)) {
override fun setValue(value: Boolean) {
super.setValue(value)
@ -243,9 +249,6 @@ class UIViewModel @Inject constructor(
}
}
/// If the app was launched because we received a new channel intent, the Url will be here
var requestedChannelUrl: Uri? = null
// clean up all this nasty owner state management FIXME
fun setOwner(s: String? = null) {
@ -271,6 +274,14 @@ class UIViewModel @Inject constructor(
}
}
fun requestShutdown() {
meshService?.requestShutdown(DataPacket.ID_LOCAL)
}
fun requestReboot() {
meshService?.requestReboot(DataPacket.ID_LOCAL)
}
/**
* Write the persisted packet data out to a CSV file in the specified location.
*/
@ -283,66 +294,83 @@ class UIViewModel @Inject constructor(
// Capture the current node value while we're still on main thread
val nodes = nodeDB.nodes.value ?: emptyMap()
val positionToPos: (MeshProtos.Position?) -> Position? = { meshPosition ->
meshPosition?.let { Position(it) }.takeIf {
it?.isValid() == true
}
}
writeToUri(file_uri) { writer ->
// Create a map of nodes keyed by their ID
val nodesById = nodes.values.associateBy { it.num }
val nodesById = nodes.values.associateBy { it.num }.toMutableMap()
val nodePositions = mutableMapOf<Int, MeshProtos.Position?>()
writer.appendLine("date,time,from,sender name,sender lat,sender long,rx lat,rx long,rx elevation,rx snr,distance,hop limit,payload")
// Packets are ordered by time, we keep most recent position of
// our device in localNodePosition.
var localNodePosition: MeshProtos.Position? = null
val dateFormat = SimpleDateFormat("yyyy-MM-dd,HH:mm:ss", Locale.getDefault())
repository.getAllPacketsInReceiveOrder().first().forEach { packet ->
packet.proto?.let { proto ->
repository.getAllPacketsInReceiveOrder(Int.MAX_VALUE).first().forEach { packet ->
// If we get a NodeInfo packet, use it to update our position data (if valid)
packet.nodeInfo?.let { nodeInfo ->
positionToPos.invoke(nodeInfo.position)?.let { _ ->
nodePositions[nodeInfo.num] = nodeInfo.position
}
}
packet.meshPacket?.let { proto ->
// If the packet contains position data then use it to update, if valid
packet.position?.let { position ->
if (proto.from == myNodeNum) {
localNodePosition = position
} else {
val rxDateTime = dateFormat.format(packet.received_date)
val rxFrom = proto.from.toUInt()
val senderName = nodesById[proto.from]?.user?.longName ?: ""
// sender lat & long
val senderPos = packet.position
?.let { p -> Position(p) }
?.takeIf { p -> p.isValid() }
val senderLat = senderPos?.latitude ?: ""
val senderLong = senderPos?.longitude ?: ""
// rx lat, long, and elevation
val rxPos = localNodePosition
?.let { p -> Position(p) }
?.takeIf { p -> p.isValid() }
val rxLat = rxPos?.latitude ?: ""
val rxLong = rxPos?.longitude ?: ""
val rxAlt = rxPos?.altitude ?: ""
val rxSnr = "%f".format(proto.rxSnr)
// Calculate the distance if both positions are valid
val dist = if (senderPos == null || rxPos == null) {
""
} else {
positionToMeter(
localNodePosition!!,
position
).roundToInt().toString()
}
val hopLimit = proto.hopLimit
val payload = when {
proto.decoded.portnumValue != Portnums.PortNum.TEXT_MESSAGE_APP_VALUE -> "<${proto.decoded.portnum}>"
proto.hasDecoded() -> "\"" + proto.decoded.payload.toStringUtf8()
.replace("\"", "\\\"") + "\""
proto.hasEncrypted() -> "${proto.encrypted.size()} encrypted bytes"
else -> ""
}
// date,time,from,sender name,sender lat,sender long,rx lat,rx long,rx elevation,rx snr,distance,hop limit,payload
writer.appendLine("$rxDateTime,$rxFrom,$senderName,$senderLat,$senderLong,$rxLat,$rxLong,$rxAlt,$rxSnr,$dist,$hopLimit,$payload")
positionToPos.invoke(position)?.let { _ ->
nodePositions[proto.from] = position
}
}
// Filter out of our results any packet that doesn't report SNR. This
// is primarily ADMIN_APP.
if (proto.rxSnr > 0.0f) {
val rxDateTime = dateFormat.format(packet.received_date)
val rxFrom = proto.from.toUInt()
val senderName = nodesById[proto.from]?.user?.longName ?: ""
// sender lat & long
val senderPosition = nodePositions[proto.from]
val senderPos = positionToPos.invoke(senderPosition)
val senderLat = senderPos?.latitude ?: ""
val senderLong = senderPos?.longitude ?: ""
// rx lat, long, and elevation
val rxPosition = nodePositions[myNodeNum]
val rxPos = positionToPos.invoke(rxPosition)
val rxLat = rxPos?.latitude ?: ""
val rxLong = rxPos?.longitude ?: ""
val rxAlt = rxPos?.altitude ?: ""
val rxSnr = "%f".format(proto.rxSnr)
// Calculate the distance if both positions are valid
val dist = if (senderPos == null || rxPos == null) {
""
} else {
positionToMeter(
rxPosition!!, // Use rxPosition but only if rxPos was valid
senderPosition!! // Use senderPosition but only if senderPos was valid
).roundToInt().toString()
}
val hopLimit = proto.hopLimit
val payload = when {
proto.decoded.portnumValue != Portnums.PortNum.TEXT_MESSAGE_APP_VALUE -> "<${proto.decoded.portnum}>"
proto.hasDecoded() -> "\"" + proto.decoded.payload.toStringUtf8()
.replace("\"", "\\\"") + "\""
proto.hasEncrypted() -> "${proto.encrypted.size()} encrypted bytes"
else -> ""
}
// date,time,from,sender name,sender lat,sender long,rx lat,rx long,rx elevation,rx snr,distance,hop limit,payload
writer.appendLine("$rxDateTime,$rxFrom,$senderName,$senderLat,$senderLong,$rxLat,$rxLong,$rxAlt,$rxSnr,$dist,$hopLimit,$payload")
}
}
}
}

Wyświetl plik

@ -1,4 +1,4 @@
package com.geeksville.mesh.service
package com.geeksville.mesh.repository.bluetooth
import android.bluetooth.BluetoothAdapter
import android.content.BroadcastReceiver
@ -6,29 +6,26 @@ import android.content.Context
import android.content.Intent
import android.content.IntentFilter
import com.geeksville.util.exceptionReporter
import javax.inject.Inject
/**
* A helper class to call onChanged when bluetooth is enabled or disabled
*/
class BluetoothStateReceiver(
private val onChanged: (Boolean) -> Unit
class BluetoothBroadcastReceiver @Inject constructor(
private val bluetoothRepository: BluetoothRepository
) : BroadcastReceiver() {
val intentFilter get() = IntentFilter(BluetoothAdapter.ACTION_STATE_CHANGED) // Can be used for registering
internal val intentFilter get() = IntentFilter(BluetoothAdapter.ACTION_STATE_CHANGED) // Can be used for registering
override fun onReceive(context: Context, intent: Intent) = exceptionReporter {
if (intent.action == BluetoothAdapter.ACTION_STATE_CHANGED) {
when (intent.bluetoothAdapterState) {
// Simulate a disconnection if the user disables bluetooth entirely
BluetoothAdapter.STATE_OFF -> onChanged(false)
BluetoothAdapter.STATE_ON -> onChanged(true)
BluetoothAdapter.STATE_OFF -> bluetoothRepository.refreshState()
BluetoothAdapter.STATE_ON -> bluetoothRepository.refreshState()
}
}
}
private val Intent.bluetoothAdapterState: Int
get() = getIntExtra(
BluetoothAdapter.EXTRA_STATE,
-1
)
get() = getIntExtra(BluetoothAdapter.EXTRA_STATE,-1)
}

Wyświetl plik

@ -0,0 +1,107 @@
package com.geeksville.mesh.repository.bluetooth
import android.annotation.SuppressLint
import android.app.Application
import android.bluetooth.BluetoothAdapter
import android.bluetooth.BluetoothDevice
import android.bluetooth.le.BluetoothLeScanner
import androidx.lifecycle.Lifecycle
import androidx.lifecycle.coroutineScope
import com.geeksville.android.Logging
import com.geeksville.mesh.CoroutineDispatchers
import com.geeksville.mesh.android.hasConnectPermission
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.*
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import javax.inject.Inject
import javax.inject.Singleton
/**
* Repository responsible for maintaining and updating the state of Bluetooth availability.
*/
@Singleton
class BluetoothRepository @Inject constructor(
private val application: Application,
private val bluetoothAdapterLazy: dagger.Lazy<BluetoothAdapter?>,
private val bluetoothBroadcastReceiverLazy: dagger.Lazy<BluetoothBroadcastReceiver>,
private val dispatchers: CoroutineDispatchers,
private val processLifecycle: Lifecycle,
) : Logging {
private val _state = MutableStateFlow(BluetoothState(
// Assume we have permission until we get our initial state update to prevent premature
// notifications to the user.
hasPermissions = true
))
val state: StateFlow<BluetoothState> = _state.asStateFlow()
init {
processLifecycle.coroutineScope.launch(dispatchers.default) {
updateBluetoothState()
bluetoothBroadcastReceiverLazy.get().let { receiver ->
application.registerReceiver(receiver, receiver.intentFilter)
}
}
}
fun refreshState() {
processLifecycle.coroutineScope.launch(dispatchers.default) {
updateBluetoothState()
}
}
/** @return true for a valid Bluetooth address, false otherwise */
fun isValid(bleAddress: String): Boolean {
return BluetoothAdapter.checkBluetoothAddress(bleAddress)
}
fun getRemoteDevice(address: String): BluetoothDevice? {
return bluetoothAdapterLazy.get()?.takeIf { isValid(address) }?.getRemoteDevice(address)
}
fun getBluetoothLeScanner(): BluetoothLeScanner? {
return bluetoothAdapterLazy.get()?.bluetoothLeScanner
}
@SuppressLint("MissingPermission")
internal suspend fun updateBluetoothState() {
val newState: BluetoothState = bluetoothAdapterLazy.get()?.takeIf {
application.hasConnectPermission().also { hasPerms ->
if (!hasPerms) errormsg("Still missing needed bluetooth permissions")
}
}?.let { adapter ->
/// ask the adapter if we have access
BluetoothState(
hasPermissions = true,
enabled = adapter.isEnabled,
bondedDevices = createBondedDevicesFlow(adapter),
)
} ?: BluetoothState()
_state.emit(newState)
debug("Detected our bluetooth access=$newState")
}
/**
* Creates a cold Flow used to obtain the set of bonded devices.
*/
@SuppressLint("MissingPermission") // Already checked prior to calling
private suspend fun createBondedDevicesFlow(adapter: BluetoothAdapter): Flow<Set<BluetoothDevice>>? {
return if (adapter.isEnabled) {
flow<Set<BluetoothDevice>> {
withContext(dispatchers.default) {
while (true) {
emit(adapter.bondedDevices)
delay(REFRESH_DELAY_MS)
}
}
}.flowOn(dispatchers.default)
} else {
null
}
}
companion object {
const val REFRESH_DELAY_MS = 1000L
}
}

Wyświetl plik

@ -0,0 +1,26 @@
package com.geeksville.mesh.repository.bluetooth
import android.app.Application
import android.bluetooth.BluetoothAdapter
import android.bluetooth.BluetoothManager
import android.content.Context
import dagger.Module
import dagger.Provides
import dagger.hilt.InstallIn
import dagger.hilt.components.SingletonComponent
@Module
@InstallIn(SingletonComponent::class)
interface BluetoothRepositoryModule {
companion object {
@Provides
fun provideBluetoothManager(application: Application): BluetoothManager? {
return application.getSystemService(Context.BLUETOOTH_SERVICE) as BluetoothManager?
}
@Provides
fun provideBluetoothAdapter(service: BluetoothManager?): BluetoothAdapter? {
return service?.adapter
}
}
}

Wyświetl plik

@ -0,0 +1,16 @@
package com.geeksville.mesh.repository.bluetooth
import android.bluetooth.BluetoothDevice
import kotlinx.coroutines.flow.Flow
/**
* A snapshot in time of the state of the bluetooth subsystem.
*/
data class BluetoothState(
/** Whether we have adequate permissions to query bluetooth state */
val hasPermissions: Boolean = false,
/** If we have adequate permissions and bluetooth is enabled */
val enabled: Boolean = false,
/** If enabled, a cold flow of the currently bonded devices */
val bondedDevices: Flow<Set<BluetoothDevice>>? = null
)

Wyświetl plik

@ -5,10 +5,7 @@ import android.bluetooth.BluetoothAdapter
import android.bluetooth.BluetoothGattCharacteristic
import android.bluetooth.BluetoothGattService
import android.bluetooth.BluetoothManager
import android.companion.CompanionDeviceManager
import android.content.Context
import android.content.pm.PackageManager
import android.os.Build
import com.geeksville.android.Logging
import com.geeksville.concurrent.handledLaunch
import com.geeksville.util.anonymize
@ -112,12 +109,6 @@ class BluetoothInterface(val service: RadioInterfaceService, val address: String
/** Return true if this address is still acceptable. For BLE that means, still bonded */
@SuppressLint("NewApi", "MissingPermission")
override fun addressValid(context: Context, rest: String): Boolean {
/* val allPaired = if (hasCompanionDeviceApi(context)) {
val deviceManager: CompanionDeviceManager by lazy {
context.getSystemService(Context.COMPANION_DEVICE_SERVICE) as CompanionDeviceManager
}
deviceManager.associations.map { it }.toSet()
} else { */
val allPaired = getBluetoothAdapter(context)?.bondedDevices.orEmpty()
.map { it.address }.toSet()
return if (!allPaired.contains(rest)) {
@ -127,63 +118,6 @@ class BluetoothInterface(val service: RadioInterfaceService, val address: String
true
}
/// Return the device we are configured to use, or null for none
/*
@SuppressLint("NewApi")
fun getBondedDeviceAddress(context: Context): String? =
if (hasCompanionDeviceApi(context)) {
// Use new companion API
val deviceManager = context.getSystemService(CompanionDeviceManager::class.java)
val associations = deviceManager.associations
val result = associations.firstOrNull()
debug("reading bonded devices: $result")
result
} else {
// Use classic API and a preferences string
val allPaired =
getBluetoothAdapter(context)?.bondedDevices.orEmpty().map { it.address }.toSet()
// If the user has unpaired our device, treat things as if we don't have one
val address = InterfaceService.getPrefs(context).getString(DEVADDR_KEY, null)
if (address != null && !allPaired.contains(address)) {
warn("Ignoring stale bond to ${address.anonymize}")
null
} else
address
}
*/
/// Can we use the modern BLE scan API?
fun hasCompanionDeviceApi(context: Context): Boolean =
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
val res =
context.packageManager.hasSystemFeature(PackageManager.FEATURE_COMPANION_DEVICE_SETUP)
debug("CompanionDevice API available=$res")
res
} else {
warn("CompanionDevice API not available, falling back to classic scan")
false
}
/** FIXME - when adding companion device support back in, use this code to set companion device from setBondedDevice
* if (BluetoothInterface.hasCompanionDeviceApi(this)) {
// We only keep an association to one device at a time...
if (addr != null) {
val deviceManager = getSystemService(CompanionDeviceManager::class.java)
deviceManager.associations.forEach { old ->
if (addr != old) {
BluetoothInterface.debug("Forgetting old BLE association $old")
deviceManager.disassociate(old)
}
}
}
*/
/**
* this is created in onCreate()
* We do an ugly hack of keeping it in the singleton so we can share it for the rare software update case

Wyświetl plik

@ -696,7 +696,9 @@ class MeshService : Service(), Logging {
id = packet.id,
dataType = data.portnumValue,
bytes = bytes,
hopLimit = hopLimit
hopLimit = hopLimit,
channel = packet.channel,
delayed = packet.delayedValue
)
}
}
@ -719,7 +721,7 @@ class MeshService : Service(), Logging {
// we only care about old text messages, we just store those...
if (dataPacket.dataType == Portnums.PortNum.TEXT_MESSAGE_APP_VALUE) {
// discard old messages if needed then add the new one
while (recentDataPackets.size > 50)
while (recentDataPackets.size > 100)
recentDataPackets.removeAt(0)
// FIXME - possible kotlin bug in 1.3.72 - it seems that if we start with the (globally shared) emptyList,
@ -919,6 +921,16 @@ class MeshService : Service(), Logging {
p.time = System.currentTimeMillis() // update time to the actual time we started sending
// debug("Sending to radio: ${packet.toPIIString()}")
sendToRadio(packet)
if (packet.hasDecoded()) {
val packetToSave = Packet(
UUID.randomUUID().toString(),
"packet",
System.currentTimeMillis(),
packet.toString()
)
insertPacket(packetToSave)
}
}
private fun processQueuedPackets() {
@ -1191,8 +1203,10 @@ class MeshService : Service(), Logging {
when (intent.action) {
RadioInterfaceService.RADIO_CONNECTED_ACTION -> {
try {
// sleep now disabled by default on ESP32, permanent is true unless isPowerSaving enabled
val lsEnabled = radioConfig?.preferences?.isPowerSaving ?: false
val connected = intent.getBooleanExtra(EXTRA_CONNECTED, false)
val permanent = intent.getBooleanExtra(EXTRA_PERMANENT, false)
val permanent = intent.getBooleanExtra(EXTRA_PERMANENT, false) || !lsEnabled
onConnectionChanged(
when {
connected -> ConnectionState.CONNECTED
@ -1309,13 +1323,14 @@ class MeshService : Service(), Logging {
if (asStr != null)
hwModelStr = asStr
}
setFirmwareUpdateFilename(hwModelStr)
val mi = with(myInfo) {
MyNodeInfo(
myNodeNum,
hasGps,
hwModelStr,
firmwareVersion,
firmwareUpdateFilename != null,
firmwareUpdateFilename?.appLoad != null && firmwareUpdateFilename?.spiffs != null,
isBluetoothInterface && SoftwareUpdateService.shouldUpdate(
this@MeshService,
DeviceVersion(firmwareVersion)
@ -1328,9 +1343,7 @@ class MeshService : Service(), Logging {
airUtilTx
)
}
newMyNodeInfo = mi
setFirmwareUpdateFilename(mi)
}
}
@ -1532,6 +1545,18 @@ class MeshService : Service(), Logging {
})
}
private fun requestShutdown(nodeId: String) {
sendToRadio(newMeshPacketTo(toNodeNum(nodeId)).buildAdminPacket {
shutdownSeconds = 5
})
}
private fun requestReboot(nodeId: String) {
sendToRadio(newMeshPacketTo(toNodeNum(nodeId)).buildAdminPacket {
rebootSeconds = 5
})
}
/**
* Start the modern (REV2) API configuration flow
*/
@ -1560,7 +1585,7 @@ class MeshService : Service(), Logging {
try {
val mi = myNodeInfo
if (mi != null) {
debug("Sending our position/time to=$destNum lat=$lat, lon=$lon, alt=$alt")
debug("Sending our position/time to=$destNum lat=${lat.anonymize}, lon=${lon.anonymize}, alt=$alt")
val position = MeshProtos.Position.newBuilder().also {
it.longitudeI = Position.degI(lon)
@ -1670,12 +1695,12 @@ class MeshService : Service(), Logging {
/***
* Return the filename we will install on the device
*/
private fun setFirmwareUpdateFilename(info: MyNodeInfo) {
private fun setFirmwareUpdateFilename(model: String?) {
firmwareUpdateFilename = try {
if (info.firmwareVersion != null && info.model != null)
if (model != null)
SoftwareUpdateService.getUpdateFilename(
this,
info.model
model
)
else
null
@ -1782,10 +1807,12 @@ class MeshService : Service(), Logging {
this@MeshService.setOwner(myId, longName, shortName)
}
override fun delete(position: Int) {
if (position >= 0) {
recentDataPackets.removeAt(position)
}
override fun deleteMessage(packetId: Int) {
val packet = recentDataPackets.find {it.id == packetId}
if (packet != null) {
recentDataPackets.remove(packet)
debug("Deleting message id=${packet.id}")
} else debug("Nothing to delete, message id=${packetId} not found")
}
override fun deleteAllMessages() {
@ -1888,6 +1915,13 @@ class MeshService : Service(), Logging {
stopLocationRequests()
}
override fun requestShutdown(nodeId: String) = toRemoteExceptions {
this@MeshService.requestShutdown(nodeId)
}
override fun requestReboot(nodeId: String) = toRemoteExceptions {
this@MeshService.requestReboot(nodeId)
}
}
}

Wyświetl plik

@ -2,24 +2,27 @@ package com.geeksville.mesh.service
import android.annotation.SuppressLint
import android.app.Service
import android.companion.CompanionDeviceManager
import android.content.Context
import android.content.Intent
import android.content.SharedPreferences
import android.os.IBinder
import androidx.core.content.edit
import androidx.lifecycle.LifecycleOwner
import androidx.lifecycle.ServiceLifecycleDispatcher
import androidx.lifecycle.coroutineScope
import com.geeksville.android.BinaryLogFile
import com.geeksville.android.GeeksvilleApplication
import com.geeksville.android.Logging
import com.geeksville.concurrent.handledLaunch
import com.geeksville.mesh.IRadioInterfaceService
import com.geeksville.mesh.repository.bluetooth.BluetoothRepository
import com.geeksville.util.anonymize
import com.geeksville.util.ignoreException
import com.geeksville.util.toRemoteExceptions
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.cancel
import dagger.hilt.android.AndroidEntryPoint
import kotlinx.coroutines.*
import kotlinx.coroutines.flow.collect
import javax.inject.Inject
open class RadioNotConnectedException(message: String = "Not connected to radio") :
@ -35,8 +38,18 @@ open class RadioNotConnectedException(message: String = "Not connected to radio"
* Note - this class intentionally dumb. It doesn't understand protobuf framing etc...
* It is designed to be simple so it can be stubbed out with a simulated version as needed.
*/
@AndroidEntryPoint
class RadioInterfaceService : Service(), Logging {
// The following is due to the fact that AIDL prevents us from extending from `LifecycleService`:
private val lifecycleOwner: LifecycleOwner = LifecycleOwner { lifecycleDispatcher.lifecycle }
private val lifecycleDispatcher: ServiceLifecycleDispatcher by lazy {
ServiceLifecycleDispatcher(lifecycleOwner)
}
@Inject
lateinit var bluetoothRepository: BluetoothRepository
companion object : Logging {
/**
* The RECEIVED_FROMRADIO
@ -104,7 +117,7 @@ class RadioInterfaceService : Service(), Logging {
@SuppressLint("NewApi")
fun getBondedDeviceAddress(context: Context): String? {
// If the user has unpaired our device, treat things as if we don't have one
var address = getDeviceAddress(context)
val address = getDeviceAddress(context)
/// Interfaces can filter addresses to indicate that address is no longer acceptable
if (address != null) {
@ -142,16 +155,6 @@ class RadioInterfaceService : Service(), Logging {
/// true if our interface is currently connected to a device
private var isConnected = false
/**
* If the user turns on bluetooth after we start, make sure to try and reconnected then
*/
private val bluetoothStateReceiver = BluetoothStateReceiver { enabled ->
if (enabled)
startInterface() // If bluetooth just got turned on, try to restart our ble link (which might be bluetooth)
else if (radioIf is BluetoothInterface)
stopInterface() // Was using bluetooth, need to shutdown
}
private fun broadcastConnectionChanged(isConnected: Boolean, isPermanent: Boolean) {
debug("Broadcasting connection=$isConnected")
val intent = Intent(RADIO_CONNECTED_ACTION)
@ -197,19 +200,35 @@ class RadioInterfaceService : Service(), Logging {
override fun onCreate() {
runningService = this
lifecycleDispatcher.onServicePreSuperOnCreate()
super.onCreate()
registerReceiver(bluetoothStateReceiver, bluetoothStateReceiver.intentFilter)
lifecycleOwner.lifecycle.coroutineScope.launch {
bluetoothRepository.state.collect { state ->
if (state.enabled) {
startInterface()
} else {
stopInterface()
}
}
}
}
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
lifecycleDispatcher.onServicePreSuperOnStart()
return super.onStartCommand(intent, flags, startId)
}
override fun onDestroy() {
unregisterReceiver(bluetoothStateReceiver)
stopInterface()
serviceScope.cancel("Destroying RadioInterface")
runningService = null
lifecycleDispatcher.onServicePreSuperOnDestroy()
super.onDestroy()
}
override fun onBind(intent: Intent?): IBinder? {
lifecycleDispatcher.onServicePreSuperOnBind()
return binder
}

Wyświetl plik

@ -243,7 +243,7 @@ class SoftwareUpdateService : JobIntentService(), Logging {
false // If we fail parsing our update info
}
/** Return a Pair of apploadfilename, spiffs filename this device needs to use as an update (or null if no update needed)
/** Return a Pair of appload filename, spiffs filename this device needs to use as an update (or null if no update needed)
*/
fun getUpdateFilename(
context: Context,
@ -290,9 +290,15 @@ class SoftwareUpdateService : JobIntentService(), Logging {
* you can use it for the software update.
*/
fun doUpdate(context: Context, sync: SafeBluetooth, assets: UpdateFilenames) {
// calculate total firmware size (spiffs + appLoad)
var totalFirmwareSize = 0
if (assets.appLoad != null && assets.spiffs != null) {
totalFirmwareSize += context.assets.open(assets.appLoad).available()
totalFirmwareSize += context.assets.open(assets.spiffs).available()
}
// we must attempt spiffs first, because if we update the appload the device will reboot afterwards
try {
assets.spiffs?.let { doUpdate(context, sync, it, FLASH_REGION_SPIFFS) }
assets.spiffs?.let { doUpdate(context, sync, it, FLASH_REGION_SPIFFS, totalFirmwareSize) }
} catch (_: BLECharacteristicNotFoundException) {
// If we can't update spiffs (because not supported by target), do not fail
errormsg("Ignoring failure to update spiffs on old appload")
@ -301,7 +307,7 @@ class SoftwareUpdateService : JobIntentService(), Logging {
errormsg("Device rejected invalid spiffs partition")
}
assets.appLoad?.let { doUpdate(context, sync, it, FLASH_REGION_APPLOAD) }
assets.appLoad?.let { doUpdate(context, sync, it, FLASH_REGION_APPLOAD, totalFirmwareSize) }
sendProgress(context, ProgressSuccess, true)
}
@ -317,7 +323,8 @@ class SoftwareUpdateService : JobIntentService(), Logging {
context: Context,
sync: SafeBluetooth,
assetName: String,
flashRegion: Int = FLASH_REGION_APPLOAD
flashRegion: Int = FLASH_REGION_APPLOAD,
totalFirmwareSize: Int = 0
) {
val isAppload = flashRegion == FLASH_REGION_APPLOAD
@ -378,13 +385,15 @@ class SoftwareUpdateService : JobIntentService(), Logging {
// Send all the blocks
var oldProgress = -1 // used to limit # of log spam
while (firmwareNumSent < firmwareSize) {
// If we are doing the spiffs partition, we limit progress to a max of 50%, so that the user doesn't think we are done
// yet
val maxProgress = if (flashRegion != FLASH_REGION_APPLOAD)
50 else 100
// If we are doing the spiffs partition, we limit progress to a max of maxProgress
// when updating the appload partition, progress from (100 - maxProgress) to 100%
// maxProgress = spiffs% = 100% - appLoad%; (int * 10 + 5) / 10 used for rounding
val maxProgress = ((firmwareSize * 1000 / totalFirmwareSize) + 5) / 10
val minProgress = if (flashRegion != FLASH_REGION_APPLOAD)
0 else (100 - maxProgress)
sendProgress(
context,
firmwareNumSent * maxProgress / firmwareSize,
minProgress + firmwareNumSent * maxProgress / firmwareSize,
isAppload
)
if (progress != oldProgress) {

Wyświetl plik

@ -14,6 +14,7 @@ import com.geeksville.mesh.model.ChannelOption
import com.geeksville.mesh.model.UIViewModel
import com.geeksville.mesh.service.MeshService
import com.geeksville.util.exceptionToSnackbar
import com.google.android.material.dialog.MaterialAlertDialogBuilder
import com.google.android.material.snackbar.Snackbar
import dagger.hilt.android.AndroidEntryPoint
@ -54,6 +55,8 @@ class AdvancedSettingsFragment : ScreenFragment("Advanced Settings"), Logging {
binding.positionBroadcastSwitch.isEnabled = connected
binding.lsSleepSwitch.isEnabled = connected
binding.isAlwaysPoweredSwitch.isEnabled = connected
binding.shutdownButton.isEnabled = connected
binding.rebootButton.isEnabled = connected
})
binding.positionBroadcastPeriodEditText.on(EditorInfo.IME_ACTION_DONE) {
@ -115,5 +118,27 @@ class AdvancedSettingsFragment : ScreenFragment("Advanced Settings"), Logging {
debug("User changed isAlwaysPowered to $isChecked")
}
}
binding.shutdownButton.setOnClickListener {
MaterialAlertDialogBuilder(requireContext())
.setMessage("${getString(R.string.shutdown)}?")
.setNeutralButton(R.string.cancel) { _, _ ->
}
.setPositiveButton(getString(R.string.okay)) { _, _ ->
model.requestShutdown()
}
.show()
}
binding.rebootButton.setOnClickListener {
MaterialAlertDialogBuilder(requireContext())
.setMessage("${getString(R.string.reboot)}?")
.setNeutralButton(R.string.cancel) { _, _ ->
}
.setPositiveButton(getString(R.string.okay)) { _, _ ->
model.requestReboot()
}
.show()
}
}
}

Wyświetl plik

@ -1,5 +1,6 @@
package com.geeksville.mesh.ui
import android.Manifest
import android.content.ActivityNotFoundException
import android.content.Intent
import android.graphics.ColorMatrix
@ -13,14 +14,15 @@ import android.view.ViewGroup
import android.view.inputmethod.EditorInfo
import android.widget.ArrayAdapter
import android.widget.ImageView
import androidx.activity.result.contract.ActivityResultContracts
import androidx.fragment.app.activityViewModels
import com.geeksville.analytics.DataPair
import com.geeksville.android.GeeksvilleApplication
import com.geeksville.android.Logging
import com.geeksville.android.hideKeyboard
import com.geeksville.android.isGooglePlayAvailable
import com.geeksville.mesh.AppOnlyProtos
import com.geeksville.mesh.ChannelProtos
import com.geeksville.mesh.MainActivity
import com.geeksville.mesh.R
import com.geeksville.mesh.android.hasCameraPermission
import com.geeksville.mesh.databinding.ChannelFragmentBinding
@ -31,8 +33,12 @@ import com.geeksville.mesh.model.UIViewModel
import com.geeksville.mesh.service.MeshService
import com.google.android.material.dialog.MaterialAlertDialogBuilder
import com.google.android.material.snackbar.Snackbar
import com.google.mlkit.vision.barcode.common.Barcode
import com.google.mlkit.vision.codescanner.GmsBarcodeScannerOptions
import com.google.mlkit.vision.codescanner.GmsBarcodeScanning
import com.google.protobuf.ByteString
import com.google.zxing.integration.android.IntentIntegrator
import com.journeyapps.barcodescanner.ScanContract
import com.journeyapps.barcodescanner.ScanOptions
import dagger.hilt.android.AndroidEntryPoint
import java.security.SecureRandom
@ -65,7 +71,7 @@ class ChannelFragment : ScreenFragment("Channel"), Logging {
override fun onCreateView(
inflater: LayoutInflater, container: ViewGroup?,
savedInstanceState: Bundle?
): View? {
): View {
_binding = ChannelFragmentBinding.inflate(inflater, container, false)
return binding.root
}
@ -188,6 +194,52 @@ class ChannelFragment : ScreenFragment("Channel"), Logging {
}
}
private fun zxingScan() {
debug("Starting zxing QR code scanner")
val zxingScan = ScanOptions()
zxingScan.setCameraId(0)
zxingScan.setPrompt("")
zxingScan.setBeepEnabled(false)
zxingScan.setDesiredBarcodeFormats(ScanOptions.QR_CODE)
barcodeLauncher.launch(zxingScan)
}
private fun requestPermissionAndScan() {
MaterialAlertDialogBuilder(requireContext())
.setTitle(R.string.camera_required)
.setMessage(R.string.why_camera_required)
.setNeutralButton(R.string.cancel) { _, _ ->
debug("Camera permission denied")
}
.setPositiveButton(getString(R.string.accept)) { _, _ ->
requestPermissionAndScanLauncher.launch(Manifest.permission.CAMERA)
}
.show()
}
private fun mlkitScan() {
debug("Starting ML Kit QR code scanner")
val options = GmsBarcodeScannerOptions.Builder()
.setBarcodeFormats(
Barcode.FORMAT_QR_CODE
)
.build()
val scanner = GmsBarcodeScanning.getClient(requireContext(), options)
scanner.startScan()
.addOnSuccessListener { barcode ->
if (barcode.rawValue != null)
model.setRequestChannelUrl(Uri.parse(barcode.rawValue))
}
.addOnFailureListener {
Snackbar.make(
requireView(),
R.string.channel_invalid,
Snackbar.LENGTH_SHORT
).show()
}
}
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
@ -195,7 +247,7 @@ class ChannelFragment : ScreenFragment("Channel"), Logging {
requireActivity().hideKeyboard()
}
binding.resetButton.setOnClickListener { _ ->
binding.resetButton.setOnClickListener {
// User just locked it, we should warn and then apply changes to radio
MaterialAlertDialogBuilder(requireContext())
.setTitle(R.string.reset_to_defaults)
@ -211,30 +263,19 @@ class ChannelFragment : ScreenFragment("Channel"), Logging {
}
binding.scanButton.setOnClickListener {
if ((requireActivity() as MainActivity).hasCameraPermission()) {
debug("Starting QR code scanner")
val zxingScan = IntentIntegrator.forSupportFragment(this)
zxingScan.setCameraId(0)
zxingScan.setPrompt("")
zxingScan.setBeepEnabled(false)
zxingScan.setDesiredBarcodeFormats(IntentIntegrator.QR_CODE)
zxingScan.initiateScan()
if (isGooglePlayAvailable(requireContext())) {
mlkitScan()
} else {
MaterialAlertDialogBuilder(requireContext())
.setTitle(R.string.camera_required)
.setMessage(R.string.why_camera_required)
.setNeutralButton(R.string.cancel) { _, _ ->
debug("Camera permission denied")
}
.setPositiveButton(getString(R.string.accept)) { _, _ ->
(requireActivity() as MainActivity).requestCameraPermission()
}
.show()
if (requireContext().hasCameraPermission()) {
zxingScan()
} else {
requestPermissionAndScan()
}
}
}
// Note: Do not use setOnCheckedChanged here because we don't want to be called when we programmatically disable editing
binding.editableCheckbox.setOnClickListener { _ ->
binding.editableCheckbox.setOnClickListener {
/// We use this to determine if the user tried to install a custom name
var originalName = ""
@ -275,7 +316,7 @@ class ChannelFragment : ScreenFragment("Channel"), Logging {
val random = SecureRandom()
val bytes = ByteArray(32)
random.nextBytes(bytes)
newSettings.name = newName
newSettings.name = newName.take(11)
newSettings.psk = ByteString.copyFrom(bytes)
} else {
debug("Switching back to default channel")
@ -299,14 +340,14 @@ class ChannelFragment : ScreenFragment("Channel"), Logging {
shareChannel()
}
model.channels.observe(viewLifecycleOwner, {
model.channels.observe(viewLifecycleOwner) {
setGUIfromModel()
})
}
// If connection state changes, we might need to enable/disable buttons
model.isConnected.observe(viewLifecycleOwner, {
model.isConnected.observe(viewLifecycleOwner) {
setGUIfromModel()
})
}
}
private fun getModemConfig(selectedChannelOptionString: String): ChannelProtos.ChannelSettings.ModemConfig {
@ -314,18 +355,18 @@ class ChannelFragment : ScreenFragment("Channel"), Logging {
if (getString(item.configRes) == selectedChannelOptionString)
return item.modemConfig
}
return ChannelProtos.ChannelSettings.ModemConfig.UNRECOGNIZED
}
override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?) {
val result = IntentIntegrator.parseActivityResult(requestCode, resultCode, data)
if (result != null) {
if (result.contents != null) {
((requireActivity() as MainActivity).perhapsChangeChannel(Uri.parse(result.contents)))
}
} else {
super.onActivityResult(requestCode, resultCode, data)
private val requestPermissionAndScanLauncher =
registerForActivityResult(ActivityResultContracts.RequestPermission()) { allowed ->
if (allowed) zxingScan()
}
// Register zxing launcher and result handler
private val barcodeLauncher = registerForActivityResult(ScanContract()) { result ->
if (result.contents != null) {
model.setRequestChannelUrl(Uri.parse(result.contents))
}
}
}

Wyświetl plik

@ -0,0 +1,358 @@
package com.geeksville.mesh.ui
import android.graphics.Color
import android.graphics.drawable.GradientDrawable
import android.os.Bundle
import android.view.*
import androidx.core.content.ContextCompat
import androidx.core.os.bundleOf
import androidx.fragment.app.activityViewModels
import androidx.fragment.app.setFragmentResult
import androidx.recyclerview.widget.LinearLayoutManager
import androidx.recyclerview.widget.RecyclerView
import com.geeksville.android.Logging
import com.geeksville.mesh.DataPacket
import com.geeksville.mesh.MainActivity
import com.geeksville.mesh.R
import com.geeksville.mesh.databinding.AdapterContactLayoutBinding
import com.geeksville.mesh.databinding.FragmentContactsBinding
import com.geeksville.mesh.model.UIViewModel
import com.google.android.material.dialog.MaterialAlertDialogBuilder
import dagger.hilt.android.AndroidEntryPoint
import java.text.DateFormat
import java.util.*
@AndroidEntryPoint
class ContactsFragment : ScreenFragment("Messages"), Logging {
private var actionMode: ActionMode? = null
private var _binding: FragmentContactsBinding? = null
// This property is only valid between onCreateView and onDestroyView.
private val binding get() = _binding!!
private val model: UIViewModel by activityViewModels()
private val dateTimeFormat: DateFormat =
DateFormat.getDateTimeInstance(DateFormat.SHORT, DateFormat.SHORT)
private val timeFormat: DateFormat =
DateFormat.getTimeInstance(DateFormat.SHORT)
private fun getShortDateTime(time: Date): String {
// return time if within 24 hours, otherwise date/time
val oneDayMsec = 60 * 60 * 24 * 1000L
return if (System.currentTimeMillis() - time.time > oneDayMsec) {
dateTimeFormat.format(time)
} else timeFormat.format(time)
}
// Provide a direct reference to each of the views within a data item
// Used to cache the views within the item layout for fast access
class ViewHolder(itemView: AdapterContactLayoutBinding) :
RecyclerView.ViewHolder(itemView.root) {
val shortName = itemView.shortName
val longName = itemView.longName
val lastMessageTime = itemView.lastMessageTime
val lastMessageText = itemView.lastMessageText
}
private val contactsAdapter = object : RecyclerView.Adapter<ViewHolder>() {
/**
* Called when RecyclerView needs a new [ViewHolder] of the given type to represent
* an item.
*
*
* This new ViewHolder should be constructed with a new View that can represent the items
* of the given type. You can either create a new View manually or inflate it from an XML
* layout file.
*
*
* The new ViewHolder will be used to display items of the adapter using
* [.onBindViewHolder]. Since it will be re-used to display
* different items in the data set, it is a good idea to cache references to sub views of
* the View to avoid unnecessary [View.findViewById] calls.
*
* @param parent The ViewGroup into which the new View will be added after it is bound to
* an adapter position.
* @param viewType The view type of the new View.
*
* @return A new ViewHolder that holds a View of the given view type.
* @see .getItemViewType
* @see .onBindViewHolder
*/
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): ViewHolder {
val inflater = LayoutInflater.from(requireContext())
// Inflate the custom layout
val contactsView = AdapterContactLayoutBinding.inflate(inflater, parent, false)
// Return a new holder instance
return ViewHolder(contactsView)
}
private var messages = arrayOf<DataPacket>()
private var contacts = arrayOf<DataPacket>()
private var selectedList = ArrayList<String>()
/**
* Returns the total number of items in the data set held by the adapter.
*
* @return The total number of items in this adapter.
*/
override fun getItemCount(): Int = contacts.size
/**
* Called by RecyclerView to display the data at the specified position. This method should
* update the contents of the [ViewHolder.itemView] to reflect the item at the given
* position.
*
*
* Note that unlike [android.widget.ListView], RecyclerView will not call this method
* again if the position of the item changes in the data set unless the item itself is
* invalidated or the new position cannot be determined. For this reason, you should only
* use the `position` parameter while acquiring the related data item inside
* this method and should not keep a copy of it. If you need the position of an item later
* on (e.g. in a click listener), use [ViewHolder.getAdapterPosition] which will
* have the updated adapter position.
*
* Override [.onBindViewHolder] instead if Adapter can
* handle efficient partial bind.
*
* @param holder The ViewHolder which should be updated to represent the contents of the
* item at the given position in the data set.
* @param position The position of the item within the adapter's data set.
*/
override fun onBindViewHolder(holder: ViewHolder, position: Int) {
val contact = contacts[position]
// Determine if this is my message (originated on this device)
val isLocal = contact.from == DataPacket.ID_LOCAL
val isBroadcast = contact.to == DataPacket.ID_BROADCAST
val contactId = if (isLocal || isBroadcast) contact.to else contact.from
// grab usernames from NodeInfo
val nodes = model.nodeDB.nodes.value!!
val node = nodes[if (isLocal) contact.to else contact.from]
//grab channel names from RadioConfig
val channels = model.channels.value
val primaryChannel = channels?.primaryChannel
val shortName = node?.user?.shortName ?: "???"
val longName =
if (isBroadcast) primaryChannel?.name ?: getString(R.string.channel_name)
else node?.user?.longName ?: getString(R.string.unknown_username)
holder.shortName.text = if (isBroadcast) "All" else shortName
holder.longName.text = longName
val text = if (isLocal) contact.text else "$shortName: ${contact.text}"
holder.lastMessageText.text = text
if (contact.time != 0L) {
holder.lastMessageTime.visibility = View.VISIBLE
holder.lastMessageTime.text = getShortDateTime(Date(contact.time))
} else holder.lastMessageTime.visibility = View.INVISIBLE
holder.itemView.setOnLongClickListener {
if (actionMode == null) {
actionMode =
(activity as MainActivity).startActionMode(object : ActionMode.Callback {
override fun onCreateActionMode(mode: ActionMode, menu: Menu): Boolean {
mode.menuInflater.inflate(R.menu.menu_messages, menu)
mode.title = "1"
return true
}
override fun onPrepareActionMode(
mode: ActionMode,
menu: Menu
): Boolean {
clickItem(holder, contactId)
return true
}
override fun onActionItemClicked(
mode: ActionMode,
item: MenuItem
): Boolean {
when (item.itemId) {
R.id.deleteButton -> {
val messagesByContactId = ArrayList<DataPacket>()
selectedList.forEach { contactId ->
messagesByContactId += messages.filter {
if (contactId == DataPacket.ID_BROADCAST)
it.to == DataPacket.ID_BROADCAST
else
it.from == contactId && it.to != DataPacket.ID_BROADCAST
|| it.from == DataPacket.ID_LOCAL && it.to == contactId
}
}
val deleteMessagesString = resources.getQuantityString(
R.plurals.delete_messages,
messagesByContactId.size,
messagesByContactId.size
)
MaterialAlertDialogBuilder(requireContext())
.setMessage(deleteMessagesString)
.setPositiveButton(getString(R.string.delete)) { _, _ ->
debug("User clicked deleteButton")
// all items selected --> deleteAllMessages()
if (messagesByContactId.size == messages.size) {
model.messagesState.deleteAllMessages()
} else {
messagesByContactId.forEach {
model.messagesState.deleteMessage(it)
}
}
mode.finish()
}
.setNeutralButton(R.string.cancel) { _, _ ->
}
.show()
}
R.id.selectAllButton -> {
// if all selected -> unselect all
if (selectedList.size == contacts.size) {
selectedList.clear()
mode.finish()
} else {
// else --> select all
selectedList.clear()
contacts.forEach {
if (it.from == DataPacket.ID_LOCAL || it.to == DataPacket.ID_BROADCAST)
selectedList.add(it.to!!) else selectedList.add(it.from!!)
}
}
actionMode?.title = selectedList.size.toString()
notifyDataSetChanged()
}
}
return true
}
override fun onDestroyActionMode(mode: ActionMode) {
selectedList.clear()
notifyDataSetChanged()
actionMode = null
}
})
} else {
// when action mode is enabled
clickItem(holder, contactId)
}
true
}
holder.itemView.setOnClickListener {
if (actionMode != null) clickItem(holder, contactId)
else {
debug("calling MessagesFragment filter:$contactId")
setFragmentResult(
"requestKey",
bundleOf("contactId" to contactId, "contactName" to longName)
)
parentFragmentManager.beginTransaction()
.replace(R.id.mainActivityLayout, MessagesFragment())
.addToBackStack(null)
.commit()
}
}
if (selectedList.contains(contactId)) {
holder.itemView.background = GradientDrawable().apply {
shape = GradientDrawable.RECTANGLE
cornerRadius = 32f
setColor(Color.rgb(127, 127, 127))
}
} else {
holder.itemView.background = GradientDrawable().apply {
shape = GradientDrawable.RECTANGLE
cornerRadius = 32f
setColor(
ContextCompat.getColor(
holder.itemView.context,
R.color.colorAdvancedBackground
)
)
}
}
}
private fun clickItem(
holder: ViewHolder,
contactId: String? = DataPacket.ID_BROADCAST
) {
val position = holder.bindingAdapterPosition
if (contactId != null && !selectedList.contains(contactId)) {
selectedList.add(contactId)
} else {
selectedList.remove(contactId)
}
if (selectedList.isEmpty()) {
// finish action mode when no items selected
actionMode?.finish()
} else {
// show total items selected on action mode title
actionMode?.title = selectedList.size.toString()
}
notifyItemChanged(position)
}
/// Called when our contacts DB changes
fun onContactsChanged(contactsIn: Collection<DataPacket>) {
contacts = contactsIn.sortedByDescending { it.time }.toTypedArray()
notifyDataSetChanged() // FIXME, this is super expensive and redraws all nodes
}
/// Called when our message DB changes
fun onMessagesChanged(msgIn: Collection<DataPacket>) {
messages = msgIn.toTypedArray()
}
fun onChannelsChanged() {
val oldBroadcast = contacts.find { it.to == DataPacket.ID_BROADCAST }
if (oldBroadcast != null) {
notifyItemChanged(contacts.indexOf(oldBroadcast))
}
}
}
override fun onPause() {
actionMode?.finish()
super.onPause()
}
override fun onCreateView(
inflater: LayoutInflater, container: ViewGroup?,
savedInstanceState: Bundle?
): View {
_binding = FragmentContactsBinding.inflate(inflater, container, false)
return binding.root
}
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
binding.contactsView.adapter = contactsAdapter
binding.contactsView.layoutManager = LinearLayoutManager(requireContext())
model.channels.observe(viewLifecycleOwner) {
contactsAdapter.onChannelsChanged()
}
model.nodeDB.nodes.observe(viewLifecycleOwner) {
contactsAdapter.notifyDataSetChanged()
}
model.messagesState.contacts.observe(viewLifecycleOwner) {
debug("New contacts received: ${it.size}")
contactsAdapter.onContactsChanged(it.values)
}
model.messagesState.messages.observe(viewLifecycleOwner) {
contactsAdapter.onMessagesChanged(it)
}
}
}

Wyświetl plik

@ -1,24 +1,24 @@
package com.geeksville.mesh.ui
import android.app.AlertDialog
import android.graphics.Color
import android.graphics.drawable.GradientDrawable
import android.os.Bundle
import android.text.InputType
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import android.view.*
import android.view.inputmethod.EditorInfo
import android.widget.EditText
import android.widget.ImageView
import android.widget.TextView
import androidx.cardview.widget.CardView
import androidx.core.content.ContextCompat
import androidx.fragment.app.Fragment
import androidx.fragment.app.activityViewModels
import androidx.lifecycle.Observer
import androidx.fragment.app.setFragmentResultListener
import androidx.recyclerview.widget.LinearLayoutManager
import androidx.recyclerview.widget.RecyclerView
import com.geeksville.android.Logging
import com.geeksville.mesh.DataPacket
import com.geeksville.mesh.MainActivity
import com.geeksville.mesh.MessageStatus
import com.geeksville.mesh.R
import com.geeksville.mesh.databinding.AdapterMessageLayoutBinding
@ -26,6 +26,7 @@ import com.geeksville.mesh.databinding.MessagesFragmentBinding
import com.geeksville.mesh.model.UIViewModel
import com.geeksville.mesh.service.MeshService
import com.google.android.material.chip.Chip
import com.google.android.material.dialog.MaterialAlertDialogBuilder
import dagger.hilt.android.AndroidEntryPoint
import java.text.DateFormat
import java.util.*
@ -37,31 +38,30 @@ fun EditText.on(actionId: Int, func: () -> Unit) {
if (actionId == receivedActionId) {
func()
}
true
}
}
@AndroidEntryPoint
class MessagesFragment : ScreenFragment("Messages"), Logging {
class MessagesFragment : Fragment(), Logging {
private var actionMode: ActionMode? = null
private var _binding: MessagesFragmentBinding? = null
// This property is only valid between onCreateView and onDestroyView.
private val binding get() = _binding!!
private var contactId: String = DataPacket.ID_BROADCAST
private var contactName: String = DataPacket.ID_BROADCAST
private val model: UIViewModel by activityViewModels()
// Allows textMultiline with IME_ACTION_SEND
fun EditText.onActionSend(func: () -> Unit) {
setImeOptions(EditorInfo.IME_ACTION_SEND)
setRawInputType(InputType.TYPE_CLASS_TEXT)
private fun EditText.onActionSend(func: () -> Unit) {
setOnEditorActionListener { _, actionId, _ ->
if (actionId == EditorInfo.IME_ACTION_SEND) {
func()
}
true
}
}
@ -73,22 +73,21 @@ class MessagesFragment : ScreenFragment("Messages"), Logging {
private fun getShortDateTime(time: Date): String {
// return time if within 24 hours, otherwise date/time
val one_day = 60 * 60 * 24 * 1000
if (System.currentTimeMillis() - time.time > one_day) {
return dateTimeFormat.format(time)
} else return timeFormat.format(time)
val oneDayMsec = 60 * 60 * 24 * 1000L
return if (System.currentTimeMillis() - time.time > oneDayMsec) {
dateTimeFormat.format(time)
} else timeFormat.format(time)
}
// Provide a direct reference to each of the views within a data item
// Used to cache the views within the item layout for fast access
class ViewHolder(itemView: AdapterMessageLayoutBinding) :
RecyclerView.ViewHolder(itemView.root) {
val card: CardView = itemView.Card
val username: Chip = itemView.username
val messageText: TextView = itemView.messageText
val messageTime: TextView = itemView.messageTime
val messageStatusIcon: ImageView = itemView.messageStatusIcon
val card: CardView = itemView.Card
}
private val messagesAdapter = object : RecyclerView.Adapter<ViewHolder>() {
@ -119,8 +118,6 @@ class MessagesFragment : ScreenFragment("Messages"), Logging {
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): ViewHolder {
val inflater = LayoutInflater.from(requireContext())
// Inflate the custom layout
// Inflate the custom layout
val contactViewBinding = AdapterMessageLayoutBinding.inflate(inflater, parent, false)
@ -128,6 +125,9 @@ class MessagesFragment : ScreenFragment("Messages"), Logging {
return ViewHolder(contactViewBinding)
}
var messages = arrayOf<DataPacket>()
var selectedList = ArrayList<DataPacket>()
/**
* Returns the total number of items in the data set held by the adapter.
*
@ -159,69 +159,56 @@ class MessagesFragment : ScreenFragment("Messages"), Logging {
override fun onBindViewHolder(holder: ViewHolder, position: Int) {
val msg = messages[position]
val nodes = model.nodeDB.nodes.value!!
val node = nodes.get(msg.from)
// Determine if this is my message (originated on this device).
// val isMe = model.myNodeInfo.value?.myNodeNum == node?.num
val isMe = msg.from == "^local"
// Determine if this is my message (originated on this device)
val isLocal = msg.from == DataPacket.ID_LOCAL
val isBroadcast = (msg.to == DataPacket.ID_BROADCAST
|| msg.delayed == 1) // MeshProtos.MeshPacket.Delayed.DELAYED_BROADCAST_VALUE == 1
// Filter messages by contactId
if (contactId == DataPacket.ID_BROADCAST) {
if (isBroadcast) {
holder.card.visibility = View.VISIBLE
} else holder.card.visibility = View.GONE
} else {
if (msg.from == contactId && msg.to != DataPacket.ID_BROADCAST
|| msg.from == DataPacket.ID_LOCAL && msg.to == contactId) {
holder.card.visibility = View.VISIBLE
} else holder.card.visibility = View.GONE
}
// Set cardview offset and color.
val marginParams = holder.card.layoutParams as ViewGroup.MarginLayoutParams
val messageOffset = resources.getDimensionPixelOffset(R.dimen.message_offset)
holder.card.setOnLongClickListener {
val deleteMessageDialog = AlertDialog.Builder(context)
deleteMessageDialog.setMessage(R.string.delete_selected_message)
deleteMessageDialog.setPositiveButton(
R.string.delete
) { _, _ ->
model.messagesState.deleteMessage((messages[position]), position)
}
deleteMessageDialog.setNeutralButton(
R.string.cancel
) { _, _ ->
}
deleteMessageDialog.setNegativeButton(
R.string.delete_all_messages
) { _, _ ->
model.messagesState.deleteAllMessages()
}
deleteMessageDialog.create()
deleteMessageDialog.show()
true
}
if (isMe) {
if (isLocal) {
holder.messageText.textAlignment = View.TEXT_ALIGNMENT_TEXT_END
marginParams.leftMargin = messageOffset
marginParams.rightMargin = 0
context?.let {
holder.card.setCardBackgroundColor(
ContextCompat.getColor(
it,
R.color.colorMyMsg
)
)
holder.card.setCardBackgroundColor(ContextCompat.getColor(it, R.color.colorMyMsg))
}
} else {
holder.messageText.textAlignment = View.TEXT_ALIGNMENT_TEXT_START
marginParams.rightMargin = messageOffset
marginParams.leftMargin = 0
context?.let {
holder.card.setCardBackgroundColor(
ContextCompat.getColor(
it,
R.color.colorMsg
)
)
holder.card.setCardBackgroundColor(ContextCompat.getColor(it, R.color.colorMsg))
}
}
// Hide the username chip for my messages
if (isMe) {
if (isLocal) {
holder.username.visibility = View.GONE
} else {
holder.username.visibility = View.VISIBLE
// If we can't find the sender, just use the ID
val node = nodes[msg.from]
val user = node?.user
holder.username.text = user?.shortName ?: msg.from
}
if (msg.errorMessage != null) {
context?.let { holder.card.setCardBackgroundColor(Color.RED) }
holder.itemView.context?.let {
holder.card.setCardBackgroundColor(Color.RED)
}
holder.messageText.text = msg.errorMessage
} else {
holder.messageText.text = msg.text
@ -243,9 +230,122 @@ class MessagesFragment : ScreenFragment("Messages"), Logging {
} else
holder.messageStatusIcon.visibility = View.INVISIBLE
holder.itemView.setOnLongClickListener {
if (actionMode == null) {
actionMode = (activity as MainActivity).startActionMode(object : ActionMode.Callback {
override fun onCreateActionMode(mode: ActionMode, menu: Menu): Boolean {
mode.menuInflater.inflate(R.menu.menu_messages, menu)
mode.title = "1"
return true
}
override fun onPrepareActionMode(mode: ActionMode, menu: Menu): Boolean {
clickItem(holder)
return true
}
override fun onActionItemClicked(
mode: ActionMode,
item: MenuItem
): Boolean {
when (item.itemId) {
R.id.deleteButton -> {
val deleteMessagesString = resources.getQuantityString(
R.plurals.delete_messages,
selectedList.size,
selectedList.size
)
MaterialAlertDialogBuilder(requireContext())
.setMessage(deleteMessagesString)
.setPositiveButton(getString(R.string.delete)) { _, _ ->
debug("User clicked deleteButton")
// all items selected --> deleteAllMessages()
if (selectedList.size == messages.size) {
model.messagesState.deleteAllMessages()
} else {
selectedList.forEach {
model.messagesState.deleteMessage(it)
}
}
mode.finish()
}
.setNeutralButton(R.string.cancel) { _, _ ->
}
.show()
}
R.id.selectAllButton -> {
// filter messages by ContactId
val messagesByContactId = messages.filter {
if (contactId == DataPacket.ID_BROADCAST)
it.to == DataPacket.ID_BROADCAST
else
it.from == contactId && it.to != DataPacket.ID_BROADCAST
|| it.from == DataPacket.ID_LOCAL && it.to == contactId
}
// if all selected -> unselect all
if (selectedList.size == messagesByContactId.size) {
selectedList.clear()
mode.finish()
} else {
// else --> select all
selectedList.clear()
selectedList.addAll(messagesByContactId)
}
actionMode?.title = selectedList.size.toString()
notifyDataSetChanged()
}
}
return true
}
override fun onDestroyActionMode(mode: ActionMode) {
selectedList.clear()
notifyDataSetChanged()
actionMode = null
}
})
} else {
// when action mode is enabled
clickItem(holder)
}
true
}
holder.itemView.setOnClickListener {
if (actionMode != null) clickItem(holder)
}
if (selectedList.contains(msg)) {
holder.itemView.background = GradientDrawable().apply {
shape = GradientDrawable.RECTANGLE
cornerRadius = 32f
setColor(Color.rgb(127, 127, 127))
}
} else {
holder.itemView.background = GradientDrawable().apply {
shape = GradientDrawable.RECTANGLE
cornerRadius = 32f
setColor(ContextCompat.getColor(holder.itemView.context, R.color.colorAdvancedBackground))
}
}
}
private var messages = arrayOf<DataPacket>()
private fun clickItem(holder: ViewHolder) {
val position = holder.bindingAdapterPosition
if (!selectedList.contains(messages[position])) {
selectedList.add(messages[position])
} else {
selectedList.remove(messages[position])
}
if (selectedList.isEmpty()) {
// finish action mode when no items selected
actionMode?.finish()
} else {
// show total items selected on action mode title
actionMode?.title = selectedList.size.toString()
}
notifyItemChanged(position)
}
/// Called when our node DB changes
fun onMessagesChanged(msgIn: Collection<DataPacket>) {
@ -258,22 +358,35 @@ class MessagesFragment : ScreenFragment("Messages"), Logging {
}
}
override fun onPause() {
actionMode?.finish()
super.onPause()
}
override fun onCreateView(
inflater: LayoutInflater, container: ViewGroup?,
savedInstanceState: Bundle?
): View? {
): View {
_binding = MessagesFragmentBinding.inflate(inflater, container, false)
return binding.root
}
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
setFragmentResultListener("requestKey") { _, bundle->
// get the result from bundle
contactId = bundle.getString("contactId").toString()
contactName = bundle.getString("contactName").toString()
binding.messageTitle.text = contactName
}
binding.sendButton.setOnClickListener {
debug("sendButton click")
debug("User clicked sendButton")
val str = binding.messageInputText.text.toString().trim()
if (str.isNotEmpty())
model.messagesState.sendMessage(str)
model.messagesState.sendMessage(str, contactId)
binding.messageInputText.setText("") // blow away the string the user just entered
// requireActivity().hideKeyboard()
@ -295,34 +408,20 @@ class MessagesFragment : ScreenFragment("Messages"), Logging {
layoutManager.stackFromEnd = true // We want the last rows to always be shown
binding.messageListView.layoutManager = layoutManager
model.messagesState.messages.observe(viewLifecycleOwner, Observer {
model.messagesState.messages.observe(viewLifecycleOwner) {
debug("New messages received: ${it.size}")
messagesAdapter.onMessagesChanged(it)
})
}
// If connection state _OR_ myID changes we have to fix our ability to edit outgoing messages
fun updateTextEnabled() {
binding.textInputLayout.isEnabled =
model.isConnected.value != MeshService.ConnectionState.DISCONNECTED
model.isConnected.observe(viewLifecycleOwner) { connectionState ->
// If we don't know our node ID and we are offline don't let user try to send
val connected = connectionState == MeshService.ConnectionState.CONNECTED
binding.textInputLayout.isEnabled = connected
binding.sendButton.isEnabled = connected
// Just being connected is enough to allow sending texts I think
// && model.nodeDB.myId.value != null && model.radioConfig.value != null
}
model.isConnected.observe(viewLifecycleOwner, Observer { _ ->
// If we don't know our node ID and we are offline don't let user try to send
updateTextEnabled()
})
/* model.nodeDB.myId.observe(viewLifecycleOwner, Observer { _ ->
// If we don't know our node ID and we are offline don't let user try to send
updateTextEnabled()
})
model.radioConfig.observe(viewLifecycleOwner, Observer { _ ->
// If we don't know our node ID and we are offline don't let user try to send
updateTextEnabled()
}) */
}
}

Wyświetl plik

@ -1,7 +1,6 @@
package com.geeksville.mesh.ui
import android.annotation.SuppressLint
import android.app.Activity
import android.app.Application
import android.app.PendingIntent
import android.bluetooth.BluetoothDevice
@ -21,9 +20,13 @@ import android.view.View
import android.view.ViewGroup
import android.view.inputmethod.EditorInfo
import android.widget.*
import androidx.activity.result.IntentSenderRequest
import androidx.activity.result.contract.ActivityResultContracts
import androidx.fragment.app.activityViewModels
import androidx.lifecycle.AndroidViewModel
import androidx.lifecycle.LiveData
import androidx.lifecycle.MutableLiveData
import androidx.lifecycle.ViewModel
import com.geeksville.analytics.DataPair
import com.geeksville.android.GeeksvilleApplication
import com.geeksville.android.Logging
import com.geeksville.android.hideKeyboard
@ -33,7 +36,9 @@ import com.geeksville.mesh.R
import com.geeksville.mesh.RadioConfigProtos
import com.geeksville.mesh.android.*
import com.geeksville.mesh.databinding.SettingsFragmentBinding
import com.geeksville.mesh.model.BluetoothViewModel
import com.geeksville.mesh.model.UIViewModel
import com.geeksville.mesh.repository.bluetooth.BluetoothRepository
import com.geeksville.mesh.service.*
import com.geeksville.mesh.service.SoftwareUpdateService.Companion.ACTION_UPDATE_PROGRESS
import com.geeksville.mesh.service.SoftwareUpdateService.Companion.ProgressNotStarted
@ -48,10 +53,12 @@ import com.google.android.material.dialog.MaterialAlertDialogBuilder
import com.google.android.material.snackbar.Snackbar
import com.hoho.android.usbserial.driver.UsbSerialDriver
import dagger.hilt.android.AndroidEntryPoint
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import java.util.regex.Pattern
import javax.inject.Inject
object SLogging : Logging
@ -106,28 +113,29 @@ private fun requestBonding(
}
}
class BTScanModel(app: Application) : AndroidViewModel(app), Logging {
@HiltViewModel
class BTScanModel @Inject constructor(
private val application: Application,
private val bluetoothRepository: BluetoothRepository,
) : ViewModel(), Logging {
private val context: Context get() = getApplication<Application>().applicationContext
private val context: Context get() = application.applicationContext
init {
debug("BTScanModel created")
}
open class DeviceListEntry(val name: String, val address: String, val bonded: Boolean) {
val bluetoothAddress
get() =
if (isBluetooth)
address.substring(1)
else
null
/** *fullAddress* = interface prefix + address (example: "x7C:9E:BD:F0:BE:BE") */
open class DeviceListEntry(val name: String, val fullAddress: String, val bonded: Boolean) {
val prefix get() = fullAddress[0]
val address get() = fullAddress.substring(1)
override fun toString(): String {
return "DeviceListEntry(name=${name.anonymize}, addr=${address.anonymize})"
return "DeviceListEntry(name=${name.anonymize}, addr=${fullAddress.anonymize}, bonded=$bonded)"
}
val isBluetooth: Boolean get() = address[0] == 'x'
val isSerial: Boolean get() = address[0] == 's'
val isBLE: Boolean get() = prefix == 'x'
val isUSB: Boolean get() = prefix == 's'
}
class USBDeviceListEntry(usbManager: UsbManager, val usb: UsbSerialDriver) : DeviceListEntry(
@ -141,7 +149,10 @@ class BTScanModel(app: Application) : AndroidViewModel(app), Logging {
debug("BTScanModel cleared")
}
val bluetoothAdapter = context.bluetoothManager?.adapter
private val bluetoothAdapter = context.bluetoothManager?.adapter
private val deviceManager get() = context.deviceManager
val hasCompanionDeviceApi get() = context.hasCompanionDeviceApi()
private val hasConnectPermission get() = context.hasConnectPermission()
private val usbManager get() = context.usbManager
var selectedAddress: String? = null
@ -158,15 +169,6 @@ class BTScanModel(app: Application) : AndroidViewModel(app), Logging {
null
}
/// If this address is for a USB device, return the macaddr portion, else null
val selectedUSB: String?
get() = selectedAddress?.let { a ->
if (a[0] == 's')
a.substring(1)
else
null
}
/// Use the string for the NopInterface
val selectedNotNull: String get() = selectedAddress ?: "n"
@ -220,7 +222,7 @@ class BTScanModel(app: Application) : AndroidViewModel(app), Logging {
private fun addDevice(entry: DeviceListEntry) {
val oldDevs = devices.value!!
oldDevs[entry.address] = entry // Add/replace entry
oldDevs[entry.fullAddress] = entry // Add/replace entry
devices.value = oldDevs // trigger gui updates
}
@ -232,9 +234,11 @@ class BTScanModel(app: Application) : AndroidViewModel(app), Logging {
scanner?.stopScan(scanCallback)
} catch (ex: Throwable) {
warn("Ignoring error stopping scan, probably BT adapter was disabled suddenly: ${ex.message}")
} finally {
scanner = null
_spinner.value = false
}
scanner = null
}
} else _spinner.value = false
}
/**
@ -257,13 +261,13 @@ class BTScanModel(app: Application) : AndroidViewModel(app), Logging {
DeviceListEntry("Meshtastic_32ac", "xbb", true) */
)
devices.value = (testnodes.map { it.address to it }).toMap().toMutableMap()
devices.value = (testnodes.map { it.fullAddress to it }).toMap().toMutableMap()
// If nothing was selected, by default select the first thing we see
if (selectedAddress == null)
changeScanSelection(
GeeksvilleApplication.currentActivity as MainActivity,
testnodes.first().address
testnodes.first().fullAddress
)
true
@ -286,6 +290,9 @@ class BTScanModel(app: Application) : AndroidViewModel(app), Logging {
// Include a placeholder for "None"
addDevice(DeviceListEntry(context.getString(R.string.none), "n", true))
// Include CompanionDeviceManager valid associations
addDeviceAssociations()
usbDrivers.forEach { d ->
addDevice(
USBDeviceListEntry(usbManager, d)
@ -299,13 +306,20 @@ class BTScanModel(app: Application) : AndroidViewModel(app), Logging {
}
}
fun startScan () {
if (hasCompanionDeviceApi) {
startCompanionScan()
} else startClassicScan()
}
@SuppressLint("MissingPermission")
fun startScan() {
private fun startClassicScan() {
/// The following call might return null if the user doesn't have bluetooth access permissions
val bluetoothLeScanner: BluetoothLeScanner? = bluetoothAdapter?.bluetoothLeScanner
val bluetoothLeScanner = bluetoothRepository.getBluetoothLeScanner()
if (bluetoothLeScanner != null) { // could be null if bluetooth is disabled
debug("starting scan")
debug("starting classic scan")
_spinner.value = true
// filter and only accept devices that have our service
val filter =
@ -324,6 +338,91 @@ class BTScanModel(app: Application) : AndroidViewModel(app), Logging {
}
}
/**
* @return DeviceListEntry from full Address (prefix + address).
* If Bluetooth is enabled and BLE Address is valid, get remote device information.
*/
@SuppressLint("MissingPermission")
fun getDeviceListEntry(fullAddress: String, bonded: Boolean = false): DeviceListEntry {
val address = fullAddress.substring(1)
val device = bluetoothRepository.getRemoteDevice(address)
return if (device != null && device.name != null) {
DeviceListEntry(device.name, fullAddress, device.bondState != BluetoothDevice.BOND_NONE)
} else {
DeviceListEntry(address, fullAddress, bonded)
}
}
@SuppressLint("NewApi")
fun addDeviceAssociations() {
if (hasCompanionDeviceApi) deviceManager?.associations?.forEach { bleAddress ->
val bleDevice = getDeviceListEntry("x$bleAddress", true)
// Disassociate after pairing is removed (if BLE is disabled, assume bonded)
if (bleDevice.name.startsWith("Mesh") && !bleDevice.bonded) {
debug("Forgetting old BLE association ${bleAddress.anonymize}")
deviceManager?.disassociate(bleAddress)
}
addDevice(bleDevice)
}
}
private val _spinner = MutableLiveData(false)
val spinner: LiveData<Boolean> get() = _spinner
private val _associationRequest = MutableLiveData<IntentSenderRequest?>(null)
val associationRequest: LiveData<IntentSenderRequest?> get() = _associationRequest
/**
* Called immediately after fragment observes CompanionDeviceManager activity result
*/
fun clearAssociationRequest() {
_associationRequest.value = null
}
@SuppressLint("NewApi")
private fun associationRequest(): AssociationRequest {
// To skip filtering based on name and supported feature flags (UUIDs),
// don't include calls to setNamePattern() and addServiceUuid(),
// respectively. This example uses Bluetooth.
// We only look for Mesh (rather than the full name) because NRF52 uses a very short name
val deviceFilter: BluetoothDeviceFilter = BluetoothDeviceFilter.Builder()
.setNamePattern(Pattern.compile("Mesh.*"))
// .addServiceUuid(ParcelUuid(RadioInterfaceService.BTM_SERVICE_UUID), null)
.build()
// The argument provided in setSingleDevice() determines whether a single
// device name or a list of device names is presented to the user as
// pairing options.
return AssociationRequest.Builder()
.addDeviceFilter(deviceFilter)
.setSingleDevice(false)
.build()
}
@SuppressLint("NewApi")
private fun startCompanionScan() {
debug("starting companion scan")
_spinner.value = true
deviceManager?.associate(
associationRequest(),
@SuppressLint("NewApi")
object : CompanionDeviceManager.Callback() {
override fun onDeviceFound(chooserLauncher: IntentSender) {
debug("CompanionDeviceManager - device found")
_spinner.value = false
chooserLauncher.let {
val request: IntentSenderRequest = IntentSenderRequest.Builder(it).build()
_associationRequest.value = request
}
}
override fun onFailure(error: CharSequence?) {
warn("BLE selection service failed $error")
}
}, null
)
}
val devices = object : MutableLiveData<MutableMap<String, DeviceListEntry>>(mutableMapOf()) {
/**
@ -339,7 +438,7 @@ class BTScanModel(app: Application) : AndroidViewModel(app), Logging {
*/
override fun onInactive() {
super.onInactive()
// stopScan()
stopScan()
}
}
@ -348,25 +447,24 @@ class BTScanModel(app: Application) : AndroidViewModel(app), Logging {
fun onSelected(activity: MainActivity, it: DeviceListEntry): Boolean {
// If the device is paired, let user select it, otherwise start the pairing flow
if (it.bonded) {
changeScanSelection(activity, it.address)
changeScanSelection(activity, it.fullAddress)
return true
} else {
// Handle requestng USB or bluetooth permissions for the device
debug("Requesting permissions for the device")
exceptionReporter {
val bleAddress = it.bluetoothAddress
if (bleAddress != null) {
if (it.isBLE) {
// Request bonding for bluetooth
// We ignore missing BT adapters, because it lets us run on the emulator
bluetoothAdapter
?.getRemoteDevice(bleAddress)?.let { device ->
bluetoothRepository
.getRemoteDevice(it.address)?.let { device ->
requestBonding(activity, device) { state ->
if (state == BOND_BONDED) {
errorText.value = activity.getString(R.string.pairing_completed)
changeScanSelection(
activity,
it.address
it.fullAddress
)
} else {
errorText.value =
@ -380,7 +478,7 @@ class BTScanModel(app: Application) : AndroidViewModel(app), Logging {
}
}
if (it.isSerial) {
if (it.isUSB) {
it as USBDeviceListEntry
val ACTION_USB_PERMISSION = "com.geeksville.mesh.USB_PERMISSION"
@ -399,7 +497,7 @@ class BTScanModel(app: Application) : AndroidViewModel(app), Logging {
)
) {
info("User approved USB access")
changeScanSelection(activity, it.address)
changeScanSelection(activity, it.fullAddress)
// Force the GUI to redraw
devices.value = devices.value
@ -447,20 +545,13 @@ class SettingsFragment : ScreenFragment("Settings"), Logging {
private val binding get() = _binding!!
private val scanModel: BTScanModel by activityViewModels()
private val bluetoothViewModel: BluetoothViewModel by activityViewModels()
private val model: UIViewModel by activityViewModels()
// FIXME - move this into a standard GUI helper class
private val guiJob = Job()
private val mainScope = CoroutineScope(Dispatchers.Main + guiJob)
private val hasCompanionDeviceApi: Boolean by lazy {
BluetoothInterface.hasCompanionDeviceApi(requireContext())
}
private val deviceManager: CompanionDeviceManager by lazy {
requireContext().getSystemService(Context.COMPANION_DEVICE_SERVICE) as CompanionDeviceManager
}
private val myActivity get() = requireActivity() as MainActivity
override fun onDestroy() {
@ -472,6 +563,10 @@ class SettingsFragment : ScreenFragment("Settings"), Logging {
model.meshService?.let { service ->
debug("User started firmware update")
GeeksvilleApplication.analytics.track(
"firmware_update",
DataPair("content_type", "start")
)
binding.updateFirmwareButton.isEnabled = false // Disable until things complete
binding.updateProgressBar.visibility = View.VISIBLE
binding.updateProgressBar.progress = 0 // start from scratch
@ -513,6 +608,10 @@ class SettingsFragment : ScreenFragment("Settings"), Logging {
} else
when (progress) {
ProgressSuccess -> {
GeeksvilleApplication.analytics.track(
"firmware_update",
DataPair("content_type", "success")
)
binding.scanStatusText.setText(R.string.update_successful)
binding.updateProgressBar.visibility = View.GONE
}
@ -521,6 +620,10 @@ class SettingsFragment : ScreenFragment("Settings"), Logging {
binding.updateProgressBar.visibility = View.GONE
}
else -> {
GeeksvilleApplication.analytics.track(
"firmware_update",
DataPair("content_type", "failure")
)
binding.scanStatusText.setText(R.string.update_failed)
binding.updateProgressBar.visibility = View.VISIBLE
}
@ -624,9 +727,12 @@ class SettingsFragment : ScreenFragment("Settings"), Logging {
regionAdapter.setDropDownViewResource(android.R.layout.simple_spinner_dropdown_item)
spinner.adapter = regionAdapter
model.bluetoothEnabled.observe(viewLifecycleOwner) {
if (it) binding.changeRadioButton.show()
else binding.changeRadioButton.hide()
bluetoothViewModel.enabled.observe(viewLifecycleOwner) { enabled ->
if (enabled) {
binding.changeRadioButton.show()
scanModel.setupScan()
if (binding.scanStatusText.text == getString(R.string.requires_bluetooth)) updateNodeInfo()
} else binding.changeRadioButton.hide()
}
model.ownerName.observe(viewLifecycleOwner) { name ->
@ -653,12 +759,28 @@ class SettingsFragment : ScreenFragment("Settings"), Logging {
updateNodeInfo()
}
scanModel.devices.observe(viewLifecycleOwner) { devices ->
updateDevicesButtons(devices)
}
scanModel.errorText.observe(viewLifecycleOwner) { errMsg ->
if (errMsg != null) {
binding.scanStatusText.text = errMsg
}
}
// show the spinner when [spinner] is true
scanModel.spinner.observe(viewLifecycleOwner) { show ->
binding.scanProgressBar.visibility = if (show) View.VISIBLE else View.GONE
}
scanModel.associationRequest.observe(viewLifecycleOwner) { request ->
request?.let {
associationResultLauncher.launch(request)
scanModel.clearAssociationRequest()
}
}
binding.updateFirmwareButton.setOnClickListener {
MaterialAlertDialogBuilder(requireContext())
.setMessage("${getString(R.string.update_firmware)}?")
@ -741,8 +863,7 @@ class SettingsFragment : ScreenFragment("Settings"), Logging {
b.text = device.name
b.id = View.generateViewId()
b.isEnabled = enabled
b.isChecked =
device.address == scanModel.selectedNotNull && device.bonded // Only show checkbox if device is still paired
b.isChecked = device.fullAddress == scanModel.selectedNotNull
binding.deviceRadioGroup.addView(b)
b.setOnClickListener {
@ -751,24 +872,18 @@ class SettingsFragment : ScreenFragment("Settings"), Logging {
b.isChecked =
scanModel.onSelected(myActivity, device)
if (!b.isSelected) {
binding.scanStatusText.text = getString(R.string.please_pair)
}
}
}
@SuppressLint("MissingPermission")
private fun updateDevicesButtons(devices: MutableMap<String, BTScanModel.DeviceListEntry>?) {
// Remove the old radio buttons and repopulate
binding.deviceRadioGroup.removeAllViews()
if (devices == null) return
val adapter = scanModel.bluetoothAdapter
var hasShownOurDevice = false
devices.values.forEach { device ->
if (device.address == scanModel.selectedNotNull)
if (device.fullAddress == scanModel.selectedNotNull)
hasShownOurDevice = true
addDeviceButton(device, true)
}
@ -779,150 +894,79 @@ class SettingsFragment : ScreenFragment("Settings"), Logging {
if (!hasShownOurDevice) {
// Note: we pull this into a tempvar, because otherwise some other thread can change selectedAddress after our null check
// and before use
val bleAddr = scanModel.selectedBluetooth
if (bleAddr != null && adapter != null && myActivity.hasConnectPermission()) {
val bDevice =
adapter.getRemoteDevice(bleAddr)
if (bDevice.name != null) { // ignore nodes that node have a name, that means we've lost them since they appeared
val curDevice = BTScanModel.DeviceListEntry(
bDevice.name,
scanModel.selectedAddress!!,
bDevice.bondState == BOND_BONDED
)
addDeviceButton(
curDevice,
model.isConnected.value == MeshService.ConnectionState.CONNECTED
)
}
} else if (scanModel.selectedUSB != null) {
// Must be a USB device, show a placeholder disabled entry
val curDevice = BTScanModel.DeviceListEntry(
scanModel.selectedUSB!!,
scanModel.selectedAddress!!,
false
val curAddr = scanModel.selectedAddress
if (curAddr != null) {
val curDevice = scanModel.getDeviceListEntry(curAddr)
addDeviceButton(
curDevice,
model.isConnected.value == MeshService.ConnectionState.CONNECTED
)
addDeviceButton(curDevice, false)
}
}
// get rid of the warning text once at least one device is paired.
// If we are running on an emulator, always leave this message showing so we can test the worst case layout
val curRadio = RadioInterfaceService.getBondedDeviceAddress(requireContext())
val curRadio = scanModel.selectedAddress
if (curRadio != null && !MockInterface.addressValid(requireContext(), "")) {
binding.warningNotPaired.visibility = View.GONE
// binding.scanStatusText.text = getString(R.string.current_pair).format(curRadio)
} else if (model.bluetoothEnabled.value == true){
} else if (bluetoothViewModel.enabled.value == true){
binding.warningNotPaired.visibility = View.VISIBLE
binding.scanStatusText.text = getString(R.string.not_paired_yet)
}
}
private fun initClassicScan() {
scanModel.devices.observe(viewLifecycleOwner) { devices ->
updateDevicesButtons(devices)
}
binding.changeRadioButton.setOnClickListener {
debug("User clicked changeRadioButton")
if (!myActivity.hasScanPermission()) {
myActivity.requestScanPermission()
} else {
checkLocationEnabled()
scanLeDevice()
}
}
}
// per https://developer.android.com/guide/topics/connectivity/bluetooth/find-ble-devices
private fun scanLeDevice() {
var scanning = false
val SCAN_PERIOD: Long = 5000 // Stops scanning after 5 seconds
val SCAN_PERIOD: Long = 10000 // Stops scanning after 10 seconds
if (!scanning) { // Stops scanning after a pre-defined scan period.
Handler(Looper.getMainLooper()).postDelayed({
scanning = false
binding.scanProgressBar.visibility = View.GONE
scanModel.stopScan()
}, SCAN_PERIOD)
scanning = true
binding.scanProgressBar.visibility = View.VISIBLE
scanModel.startScan()
} else {
scanning = false
binding.scanProgressBar.visibility = View.GONE
scanModel.stopScan()
}
}
private fun startCompanionScan() {
// Disable the change button until our scan has some results
binding.changeRadioButton.isEnabled = false
// To skip filtering based on name and supported feature flags (UUIDs),
// don't include calls to setNamePattern() and addServiceUuid(),
// respectively. This example uses Bluetooth.
// We only look for Mesh (rather than the full name) because NRF52 uses a very short name
val deviceFilter: BluetoothDeviceFilter = BluetoothDeviceFilter.Builder()
.setNamePattern(Pattern.compile("Mesh.*"))
// .addServiceUuid(ParcelUuid(RadioInterfaceService.BTM_SERVICE_UUID), null)
.build()
// The argument provided in setSingleDevice() determines whether a single
// device name or a list of device names is presented to the user as
// pairing options.
val pairingRequest: AssociationRequest = AssociationRequest.Builder()
.addDeviceFilter(deviceFilter)
.setSingleDevice(false)
.build()
// When the app tries to pair with the Bluetooth device, show the
// appropriate pairing request dialog to the user.
deviceManager.associate(
pairingRequest,
object : CompanionDeviceManager.Callback() {
override fun onDeviceFound(chooserLauncher: IntentSender) {
debug("Found one device - enabling changeRadioButton")
binding.changeRadioButton.isEnabled = true
binding.changeRadioButton.setOnClickListener {
debug("User clicked changeRadioButton")
try {
startIntentSenderForResult(
chooserLauncher,
MainActivity.SELECT_DEVICE_REQUEST_CODE, null, 0, 0, 0, null
)
} catch (ex: Throwable) {
errormsg("CompanionDevice startIntentSenderForResult error")
}
}
}
override fun onFailure(error: CharSequence?) {
warn("BLE selection service failed $error")
// changeDeviceSelection(myActivity, null) // deselect any device
}
}, null
)
}
private fun initModernScan() {
scanModel.devices.observe(viewLifecycleOwner) { devices ->
updateDevicesButtons(devices)
startCompanionScan()
}
@SuppressLint("MissingPermission")
val associationResultLauncher = registerForActivityResult(
ActivityResultContracts.StartIntentSenderForResult()
) {
it.data
?.getParcelableExtra<BluetoothDevice>(CompanionDeviceManager.EXTRA_DEVICE)
?.let { device ->
scanModel.onSelected(
myActivity,
BTScanModel.DeviceListEntry(
device.name,
"x${device.address}",
device.bondState == BOND_BONDED
)
)
}
}
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
initCommonUI()
if (hasCompanionDeviceApi)
initModernScan()
else
initClassicScan()
binding.changeRadioButton.setOnClickListener {
debug("User clicked changeRadioButton")
if (!myActivity.hasScanPermission()) {
myActivity.requestScanPermission()
} else {
if (!scanModel.hasCompanionDeviceApi) checkLocationEnabled()
scanLeDevice()
}
}
}
// If the user has not turned on location access throw up a toast warning
@ -1025,45 +1069,14 @@ class SettingsFragment : ScreenFragment("Settings"), Logging {
myActivity.registerReceiver(updateProgressReceiver, updateProgressFilter)
// Keep reminding user BLE is still off
val hasUSB = SerialInterface.findDrivers(myActivity).isNotEmpty()
if (!hasUSB) {
// Warn user if BLE is disabled
if (scanModel.bluetoothAdapter?.isEnabled != true) {
showSnackbar(getString(R.string.error_bluetooth))
} else {
if (binding.provideLocationCheckbox.isChecked)
checkLocationEnabled(getString(R.string.location_disabled))
}
}
}
@SuppressLint("MissingPermission")
override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?) {
if (hasCompanionDeviceApi && myActivity.hasConnectPermission()
&& requestCode == MainActivity.SELECT_DEVICE_REQUEST_CODE
&& resultCode == Activity.RESULT_OK
) {
val deviceToPair: BluetoothDevice =
data?.getParcelableExtra(CompanionDeviceManager.EXTRA_DEVICE)!!
// We only keep an association to one device at a time...
deviceManager.associations.forEach { old ->
if (deviceToPair.address != old) {
debug("Forgetting old BLE association ${old.anonymize}")
deviceManager.disassociate(old)
}
}
scanModel.onSelected(
myActivity,
BTScanModel.DeviceListEntry(
deviceToPair.name,
"x${deviceToPair.address}",
deviceToPair.bondState == BOND_BONDED
)
)
} else {
super.onActivityResult(requestCode, resultCode, data)
}
// Warn user if BLE is disabled
if (scanModel.selectedBluetooth != null && bluetoothViewModel.enabled.value == false) {
Toast.makeText(
requireContext(),
getString(R.string.error_bluetooth),
Toast.LENGTH_LONG
).show()
} else if (binding.provideLocationCheckbox.isChecked)
checkLocationEnabled(getString(R.string.location_disabled))
}
}

Wyświetl plik

@ -7,8 +7,10 @@ import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import androidx.core.content.ContextCompat
import androidx.core.os.bundleOf
import androidx.core.text.HtmlCompat
import androidx.fragment.app.activityViewModels
import androidx.fragment.app.setFragmentResult
import androidx.recyclerview.widget.LinearLayoutManager
import androidx.recyclerview.widget.RecyclerView
import com.geeksville.android.Logging
@ -165,13 +167,29 @@ class UsersFragment : ScreenFragment("Users"), Logging {
holder.signalView.visibility = View.VISIBLE
}
}
holder.itemView.setOnLongClickListener {
if (position > 0) {
debug("calling MessagesFragment filter:${n.user?.id}")
setFragmentResult(
"requestKey",
bundleOf("contactId" to n.user?.id, "contactName" to name)
)
parentFragmentManager.beginTransaction()
.replace(R.id.mainActivityLayout, MessagesFragment())
.addToBackStack(null)
.commit()
}
true
}
}
private var nodes = arrayOf<NodeInfo>()
/// Called when our node DB changes
fun onNodesChanged(nodesIn: Collection<NodeInfo>) {
nodes = nodesIn.toTypedArray()
fun onNodesChanged(nodesIn: Array<NodeInfo>) {
if (nodesIn.size > 1)
nodesIn.sortWith(compareByDescending { it.lastHeard }, 1)
nodes = nodesIn
notifyDataSetChanged() // FIXME, this is super expensive and redraws all nodes
}
}
@ -210,9 +228,9 @@ class UsersFragment : ScreenFragment("Users"), Logging {
binding.nodeListView.adapter = nodesAdapter
binding.nodeListView.layoutManager = LinearLayoutManager(requireContext())
model.nodeDB.nodes.observe(viewLifecycleOwner, {
nodesAdapter.onNodesChanged(it.values)
})
model.nodeDB.nodes.observe(viewLifecycleOwner) {
nodesAdapter.onNodesChanged(it.values.toTypedArray())
}
}
}

@ -1 +1 @@
Subproject commit 2930129e8eac348c094bbedeb929d86efafc2b62
Subproject commit f1476bf2f687a3926a98a9d8c86d5c2bba99c3cf

Wyświetl plik

@ -0,0 +1,15 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24"
android:tint="?attr/colorControlNormal">
<path
android:fillColor="@android:color/white"
android:pathData="M8,9h8v10H8z"
android:strokeAlpha="0.3"
android:fillAlpha="0.3"/>
<path
android:fillColor="@android:color/white"
android:pathData="M15.5,4l-1,-1h-5l-1,1H5v2h14V4zM6,19c0,1.1 0.9,2 2,2h8c1.1,0 2,-0.9 2,-2V7H6v12zM8,9h8v10H8V9z"/>
</vector>

Wyświetl plik

@ -0,0 +1,10 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24"
android:tint="?attr/colorControlNormal">
<path
android:fillColor="@android:color/white"
android:pathData="M3,5h2L5,3c-1.1,0 -2,0.9 -2,2zM3,13h2v-2L3,11v2zM7,21h2v-2L7,19v2zM3,9h2L5,7L3,7v2zM13,3h-2v2h2L13,3zM19,3v2h2c0,-1.1 -0.9,-2 -2,-2zM5,21v-2L3,19c0,1.1 0.9,2 2,2zM3,17h2v-2L3,15v2zM9,3L7,3v2h2L9,3zM11,21h2v-2h-2v2zM19,13h2v-2h-2v2zM19,21c1.1,0 2,-0.9 2,-2h-2v2zM19,9h2L21,7h-2v2zM19,17h2v-2h-2v2zM15,21h2v-2h-2v2zM15,5h2L17,3h-2v2zM7,17h10L17,7L7,7v10zM9,9h6v6L9,15L9,9z"/>
</vector>

Wyświetl plik

@ -62,11 +62,7 @@
android:layout_width="match_parent"
android:layout_height="wrap_content"
app:tabIconTint="@color/tab_color_selector"
app:tabIndicatorColor="@color/selectedColor"
>
</com.google.android.material.tabs.TabLayout>
app:tabIndicatorColor="@color/selectedColor" />
</com.google.android.material.appbar.AppBarLayout>
<androidx.viewpager2.widget.ViewPager2
@ -74,10 +70,5 @@
android:layout_width="match_parent"
android:layout_height="match_parent"
app:layout_behavior="com.google.android.material.appbar.AppBarLayout$ScrollingViewBehavior" />
</androidx.coordinatorlayout.widget.CoordinatorLayout>
</FrameLayout>
</FrameLayout>

Wyświetl plik

@ -0,0 +1,62 @@
<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
android:layout_width="match_parent"
android:layout_height="wrap_content">
<com.google.android.material.card.MaterialCardView
style="@style/Widget.App.CardView"
android:layout_width="match_parent"
android:layout_height="match_parent"
app:cardCornerRadius="12dp"
app:cardElevation="2dp"
app:cardUseCompatPadding="true">
<androidx.constraintlayout.widget.ConstraintLayout
android:layout_width="match_parent"
android:layout_height="wrap_content">
<com.google.android.material.chip.Chip
android:id="@+id/shortName"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_margin="8dp"
android:text="@string/some_username"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent" />
<TextView
android:id="@+id/longName"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_margin="8dp"
android:text="@string/unknown_username"
app:layout_constraintStart_toEndOf="@+id/shortName"
app:layout_constraintTop_toTopOf="parent" />
<TextView
android:id="@+id/lastMessageText"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_margin="8dp"
android:autoLink="all"
android:maxLines="2"
android:text="@string/sample_message"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toEndOf="@id/shortName"
app:layout_constraintTop_toBottomOf="@id/longName" />
<TextView
android:id="@+id/lastMessageTime"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_margin="8dp"
android:contentDescription="@string/message_reception_time"
android:text="3 minutes ago"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintTop_toTopOf="parent" />
</androidx.constraintlayout.widget.ConstraintLayout>
</com.google.android.material.card.MaterialCardView>
</LinearLayout>

Wyświetl plik

@ -42,7 +42,6 @@
android:layout_marginEnd="8dp"
android:autoLink="all"
android:text="@string/sample_message"
android:textIsSelectable="true"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toEndOf="@id/username"
app:layout_constraintTop_toTopOf="parent" />
@ -62,9 +61,9 @@
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginEnd="8dp"
android:layout_marginBottom="8dp"
android:contentDescription="@string/message_reception_time"
android:text="3 minutes ago"
android:textSize="12sp"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toStartOf="@id/messageStatusIcon"
app:layout_constraintTop_toBottomOf="@id/messageText" />

Wyświetl plik

@ -77,4 +77,26 @@
app:layout_constraintStart_toStartOf="@+id/lsSleepView"
app:layout_constraintTop_toBottomOf="@+id/lsSleepView" />
<com.google.android.material.button.MaterialButton
android:id="@+id/shutdownButton"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginStart="16dp"
android:layout_marginTop="16dp"
android:layout_marginEnd="4dp"
android:text="@string/shutdown"
app:layout_constraintEnd_toStartOf="@id/rebootButton"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@id/isAlwaysPoweredSwitch" />
<com.google.android.material.button.MaterialButton
android:id="@+id/rebootButton"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginStart="4dp"
android:layout_marginEnd="16dp"
android:text="@string/reboot"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toEndOf="@id/shutdownButton"
app:layout_constraintTop_toTopOf="@id/shutdownButton" />
</androidx.constraintlayout.widget.ConstraintLayout>

Wyświetl plik

@ -13,6 +13,8 @@
android:layout_marginTop="16dp"
android:layout_marginEnd="64dp"
android:hint="@string/channel_name"
app:counterEnabled="true"
app:counterMaxLength="11"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent">
@ -23,7 +25,6 @@
android:layout_height="wrap_content"
android:digits="ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz1234567890- "
android:imeOptions="actionDone"
android:maxLength="15"
android:singleLine="true"
android:text="@string/unset" />
</com.google.android.material.textfield.TextInputLayout>

Wyświetl plik

@ -0,0 +1,19 @@
<?xml version="1.0" encoding="utf-8"?>
<androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
android:layout_width="match_parent"
android:layout_height="match_parent">
<androidx.recyclerview.widget.RecyclerView
android:id="@+id/contactsView"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:layout_marginStart="8dp"
android:layout_marginTop="8dp"
android:layout_marginEnd="8dp"
android:layout_marginBottom="8dp"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent" />
</androidx.constraintlayout.widget.ConstraintLayout>

Wyświetl plik

@ -2,28 +2,52 @@
<androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
android:layout_width="match_parent"
android:layout_height="match_parent">
android:layout_height="match_parent"
android:background="@color/colorAdvancedBackground">
<com.google.android.material.appbar.MaterialToolbar
android:id="@+id/toolbar"
style="@style/MyToolbar"
android:layout_width="match_parent"
android:layout_height="?attr/actionBarSize"
android:background="@color/colorPrimary"
app:layout_constraintTop_toTopOf="parent">
<androidx.constraintlayout.widget.ConstraintLayout
android:layout_width="match_parent"
android:layout_height="match_parent">
<TextView
android:id="@+id/messageTitle"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="@string/channel_name"
android:textAppearance="@style/TextAppearance.Widget.AppCompat.Toolbar.Title"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent" />
</androidx.constraintlayout.widget.ConstraintLayout>
</com.google.android.material.appbar.MaterialToolbar>
<androidx.recyclerview.widget.RecyclerView
android:id="@+id/messageListView"
android:layout_width="0dp"
android:layout_height="0dp"
android:layout_marginStart="8dp"
android:layout_marginTop="8dp"
android:layout_marginEnd="8dp"
android:layout_marginBottom="8dp"
android:layout_margin="8dp"
android:contentDescription="@string/text_messages"
app:layout_constraintBottom_toTopOf="@+id/textInputLayout"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent" />
app:layout_constraintTop_toBottomOf="@id/toolbar" />
<com.google.android.material.textfield.TextInputLayout
android:id="@+id/textInputLayout"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginStart="8dp"
android:layout_marginBottom="8dp"
android:layout_marginStart="4dp"
android:layout_marginBottom="4dp"
android:hint="@string/send_text"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toStartOf="@+id/sendButton"
@ -33,7 +57,7 @@
android:id="@+id/messageInputText"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:inputType="textMultiLine"
android:inputType="textMultiLine|textCapSentences"
android:maxLength="228"
android:text="" />
@ -43,7 +67,6 @@
android:id="@+id/sendButton"
android:layout_width="64dp"
android:layout_height="64dp"
android:layout_marginBottom="4dp"
android:contentDescription="@string/send_text"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="parent"

Wyświetl plik

@ -0,0 +1,14 @@
<?xml version="1.0" encoding="utf-8"?>
<menu xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto">
<item
android:id="@+id/deleteButton"
android:icon="@drawable/ic_twotone_delete_24"
android:title="@string/delete"
app:showAsAction="ifRoom" />
<item
android:id="@+id/selectAllButton"
android:icon="@drawable/ic_twotone_select_all_24"
android:title="@string/select_all"
app:showAsAction="ifRoom" />
</menu>

Wyświetl plik

@ -48,11 +48,11 @@
<string name="share">Sdílet</string>
<string name="disconnected">Odpojeno</string>
<string name="device_sleeping">Zařízení spí</string>
<string name="connected_count">Pripojeno: %s z %s je online</string>
<string name="connected_count">Pripojeno: %1$s z %2$s je online</string>
<string name="list_of_nodes">Seznam vysílačů v síti</string>
<string name="update_firmware">Aktualizace softwaru</string>
<string name="connected_to">Připojeno k vysílači (%s)</string>
<string name="not_connected">Nepřipojeno, zvolte si vysílač</string>
<string name="not_connected">Nepřipojeno</string>
<string name="connected_sleeping">Připojené k uspanému vysílači.</string>
<string name="update_to">Aktualizovat na %s</string>
<string name="app_too_old">Aplikace je příliš stará</string>

Wyświetl plik

@ -1,142 +1,73 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<string name="action_settings">Ρυθμίσεις</string>
<string name="channel_name">Όνομα Καναλιού</string>
<string name="channel_options">Επιλογές Καναλιού</string>
<string name="share_button">Κοινή χρήση</string>
<string name="qr_code">Κώδικας QR</string>
<string name="unset">Αναίρεση</string>
<string name="connection_status">Κατάσταση Σύνδεσης</string>
<string name="application_icon">Εικονίδιο εφαρμογής </string>
<string name="unknown_username">Άγνωστο Όνομα Χρήστη</string>
<string name="user_avatar">Avatar Χρήστη</string>
<string name="sample_message">Βρήκα το σημείο, είναι διπλα στη τίγρη. Φοβάμαι λίγο.</string>
<string name="send_text">Αποστολή κειμένου</string>
<string name="warning_not_paired">Δεν έχετε κάνει ακόμη pair μια συσκευή συμβατή με Meshtastic με το τηλέφωνο. Παρακαλώ κάντε pair μια συσκευή και ορίστε το όνομα χρήστη.\n\nΗ εφαρμογή ανοιχτού κώδικα βρίσκεται σε alpha-testing, αν εντοπίσετε προβλήματα παρακαλώ δημοσιεύστε τα στο forum: meshtastic.discourse.group.\n\nΠερισσότερες πληροφορίες στην ιστοσελίδα - www.meshtastic.org.</string>
<string name="username_unset">Όνομα Χρήστη αναιρέθηκε</string>
<string name="your_name">Όνομα</string>
<string name="analytics_okay">Ανώνυμα στατιστικά χρήσης και αναφορές crash.</string>
<string name="looking_for_meshtastic_devices">Αναζήτηση συσκευών Meshtastic …</string>
<string name="requires_bluetooth">Η εφαρμογή απαιτεί bluetooth πρόσβαση. Παρακαλώ παρέχεται σχετική άδεια χρήσης στις ρυθμίσεις του android.</string>
<string name="error_bluetooth">Σφάλμα - η εφαρμογή απαιτεί bluetooth</string>
<string name="starting_pairing">Αρχή pairing</string>
<string name="pairing_failed">Pairing απέτυχε</string>
<string name="url_for_join">Διεύθυνση URL για συμμετοχή σε Meshtastic mesh</string>
<string name="accept">Αποδοχή</string>
<string name="cancel">Ακύρωση</string>
<string name="change_channel">Αλλαγή καναλιού</string>
<string name="are_you_sure_channel">Είστε βέβαιοι ότι θέλετε να αλλάξετε κανάλι? Η επικοινωνία με άλλες συσκευές θα σταματήσεις μέχρι να μοιραστείτε τις ρυθμίσεις του νέου καναλιού.</string>
<string name="new_channel_rcvd">Λήψη URL νέου καναλιού</string>
<string name="do_you_want_switch">Θέλετε να αλλάξετε %s κανάλι?</string>
<string name="map_not_allowed">Έχετε απενεργοποιήσει την ανάλυση δεδομένων. Δυστυχώς ο πάροχος χαρτών μας (mapbox) απαιτεί η ανάλυση δεδομένων να επιτρέπεται στο ‘δωρεάν’ πακέτο. Επομένως έχουμε απενεργοποιήσει το χάρτη.\n\n
Αν επιθυμείτε να δείτε το χάρτη, θα πρέπει να ενεργοποιήσετε την ανάλυση δεδομένων στις Ρυθμίσεις (επίσης - προσωρινά - θα πρέπει να επανεκκινήσετε την εφαρμογή).\n\n
Αν θεωρείτε ότι πρέπει να πληρώνουμε το mapbox (η να αλλάξουμε πάροχο χαρτών), παρακαλώ δημοσιεύστε στο meshtastic.discourse.group</string>
<string name="permission_missing">Λείπει μια απαιτούμενη άδεια, Meshtastic δεν θα λειτοργεί σωστά. Ενεργοποιήστε τις ρυθμίσεις εφαρμογής Android.</string>
<string name="radio_sleeping">Radio σε κατάσταση ύπνου, δεν γίνεται αλλαγή καναλιού</string>
<string name="report_bug">Αναφορά Bug</string>
<string name="report_a_bug">Αναφέρετε ένα bug</string>
<string name="report_bug_text">Είστε σίγουροι ότι θέλετε να αναφέρετε ένα bug? Μετά την αναφορά δημοσιεύστε στο meshtastic.discourse.group ώστε να συνδέσουμε την αναφορά με το συμβάν.</string>
<string name="report">Αναφορά</string>
<string name="select_radio">Επιλογή radio</string>
<string name="current_pair">Έχετε κάνει pair με radio %s</string>
<string name="not_paired_yet">Δεν έχετε κάνει pair με radio ακόμη.</string>
<string name="change_radio">Αλλαγή radio</string>
<string name="please_pair">Παρακαλώ κάντε pair μια συσκευή στις ρυθμίσεις Android.</string>
<string name="pairing_completed">Η διαδικασία pairing ολοκληρώθηκε, εκκίνηση υπηρεσίας</string>
<string name="pairing_failed_try_again">Η διαδικασία pairing απέτυχε, παρακαλώ επιλέξτε πάλι</string>
<string name="location_disabled">Ο εντοπισμός τοποθεσίας είναι απενεργοποιημένος, δε μπορούμε να μοιραστούμε τη θέση σας με το mesh.</string>
<string name="share">Κοινοποίηση</string>
<string name="disconnected">Αποσυνδεδεμένο</string>
<string name="device_sleeping">Συσκευή σε ύπνωση</string>
<string name="connected_count">Συνδεδεμένος: %s από %s online</string>
<string name="connected_count">Συνδεδεμένος: %1$s από %2$s online</string>
<string name="list_of_nodes">Λίστα κόμβων δικτύου</string>
<string name="update_firmware">Αναβάθμιση Firmware</string>
<string name="connected">Συνδεδεμένο στο radio</string>
<string name="connected_to">Συνδεδεμένο στο radio (%s)</string>
<string name="not_connected">Αποσυνδεδεμένο, επιλέξτε radio </string>
<string name="not_connected">Αποσυνδεδεμένο</string>
<string name="connected_sleeping">Συνδεδεμένο στο radio, αλλά βρίσκεται σε ύπνωση</string>
<string name="update_to">Αναβάθμιση σε %s</string>
<string name="app_too_old">Εφαρμογή πολύ παλαιά</string>
<string name="must_update">Πρέπει να ενημερώσετε την εφαρμογή μέσω Google Play store (ή Github). Είναι πολύ παλαιά ώστε να συνδεθεί με το radio.</string>
<string name="none">Κανένα (απενεργοποιημένο)</string>
<string name="modem_config_short">Μικρή εμβέλεια (αλλά γρήγορο)</string>
<string name="modem_config_medium">Μεσαία εμβέλεια (αλλά γρήγορο)</string>
<string name="modem_config_long">Μεγάλη εμβέλεια (αλλά αργό)</string>
<string name="modem_config_very_long">Πολύ μεγάλη εμβέλεια (αλλά αργό)</string>
<string name="modem_config_unrecognized">ΜΗ ΑΝΑΓΝΩΡΙΣΙΜΟ</string>
<string name="meshtastic_service_notifications">Ειδοποιήσεις Υπηρεσίας Meshtastic</string>
<string name="location_disabled_warning">Πρέπει να ενεργοποιήσετε τις υπηρεσίες εντοπισμού τοποθεσίας στις ρυθμίσεις Android</string>
<string name="about">Σχετικά</string>
<string name="a_list_of_nodes_in_the_mesh">Λίστα κόμβων στο mesh</string>
<string name="text_messages">Μηνύματα</string>
<string name="channel_invalid">Αυτό το κανάλι URL δεν είναι ορθό και δεν μπορεί να χρησιμοποιηθεί</string>
</resources>

Wyświetl plik

@ -45,12 +45,12 @@
<string name="share">Compartir</string>
<string name="disconnected">Desconectado</string>
<string name="device_sleeping">Dispositivo en reposo</string>
<string name="connected_count">Conectado: %s de %s en línea</string>
<string name="connected_count">Conectado: %1$s de %2$s en línea</string>
<string name="list_of_nodes">Una lista de nodos en la red</string>
<string name="update_firmware">Actualizar el firmware</string>
<string name="connected">Conectado a la radio</string>
<string name="connected_to">Conectado a la radio (%s)</string>
<string name="not_connected">No está conectado seleccione la radio de abajo</string>
<string name="not_connected">No está conectado</string>
<string name="connected_sleeping">Conectado a la radio pero está en reposo</string>
<string name="update_to">Actualizar a %s</string>
<string name="app_too_old">Es necesario actualizar la aplicación</string>

Wyświetl plik

@ -49,12 +49,12 @@
<string name="share">Partager</string>
<string name="disconnected">Déconnecté</string>
<string name="device_sleeping">Appareil en veille</string>
<string name="connected_count">Connecté: %s sur %s en ligne</string>
<string name="connected_count">Connecté: %1$s sur %2$s en ligne</string>
<string name="list_of_nodes">Une liste de nœuds dans le réseau</string>
<string name="update_firmware">Mise à jour du Firmware</string>
<string name="connected">Connecté à une radio</string>
<string name="connected_to">Connecté à la radio (%s)</string>
<string name="not_connected">Non connecté, veuillez sélectionner une radio ci-dessous</string>
<string name="not_connected">Non connecté</string>
<string name="connected_sleeping">Connecté à la radio, mais en mode veille</string>
<string name="none">Aucun (désactivé)</string>
<string name="must_update">Vous devez mettre à jour l\'application sur le Google Play Store (ou Github). Cette version n\'est plus compatible avec la radio.</string>

Wyświetl plik

@ -46,12 +46,12 @@
<string name="share">Pataje</string>
<string name="disconnected">Dekonekte</string>
<string name="device_sleeping">Aparèy ap dòmi</string>
<string name="connected_count">Konekte: %s nan %s disponib</string>
<string name="connected_count">Konekte: %1$s nan %2$s disponib</string>
<string name="list_of_nodes">Yon lis ne elektwonik nan rezo a</string>
<string name="update_firmware">Mete ajou mikrolojisyèl</string>
<string name="connected">Konekte ak radyo</string>
<string name="connected_to">Konekte ak radyo (%s)</string>
<string name="not_connected">Pa konekte, chwazi radyo anba a</string>
<string name="not_connected">Pa konekte</string>
<string name="connected_sleeping">Konekte ak radyo, men li ap dòmi</string>
<string name="update_to">Mizajou %s</string>
<string name="app_too_old">Aplikasyon twò ansyen</string>

Wyświetl plik

@ -47,16 +47,16 @@
<string name="share">Megosztás</string>
<string name="disconnected">Szétkapcsolva</string>
<string name="device_sleeping">Az eszköz alszik</string>
<string name="connected_count">Kapcsolódva: %s a %s-ból(ből) elérhető</string>
<string name="connected_count">Kapcsolódva: %1$s a %2$s-ból(ből) elérhető</string>
<string name="list_of_nodes">Hálózati állomások listája</string>
<string name="update_firmware">Firmware frissítés</string>
<string name="connected">Kapcsolódva a rádióhoz</string>
<string name="connected_to">Kapcsolódva a(z) %s rádióhoz</string>
<string name="not_connected">Nincs kapcsolat, válasszon egy rádiót alább</string>
<string name="not_connected">Nincs kapcsolat</string>
<string name="connected_sleeping">Kapcsolódva a rádióhoz, de az alvó üzemmódban van</string>
<string name="update_to">Frissítés %s verzióra</string>
<string name="app_too_old">Az alkalmazás frissítése szükséges</string>
<string name="must_update">Frissítenie kell ezt az alkalmazást a Google Play áruházban (vagy a GitHub-ról), mert túl régi, hogy kommunikálni tudjob ezzel a rádió firmware-rel. Kérem olvassa el a tudnivalókat ebből a <a href="https://www.meshtastic.org/software/android-too-old.html">wiki</a>-ből.</string>
<string name="must_update">Frissítenie kell ezt az alkalmazást a Google Play áruházban (vagy a GitHub-ról), mert túl régi, hogy kommunikálni tudjob ezzel a rádió firmware-rel. Kérem olvassa el a tudnivalókat ebből a <a href="https://meshtastic.org/docs/software/android/android-installation">docs</a>-ből.</string>
<string name="none">Egyik sem (letiltás)</string>
<string name="modem_config_short">Rövid hatótáv (nagyon gyors)</string>
<string name="modem_config_medium">Közepes hatótáv (gyors)</string>

Wyświetl plik

@ -48,11 +48,11 @@ mapboxの有償プランまたは代替地図プロバイダを検討さ
<string name="share">シェア</string>
<string name="disconnected">切断</string>
<string name="device_sleeping">スリープ</string>
<string name="connected_count">接続済み:%s人オンライン%s人中</string>
<string name="connected_count">接続済み:%1$s人オンライン%2$s人中</string>
<string name="list_of_nodes">ネットワーク内のノードリスト</string>
<string name="update_firmware">ファームウェアアップデート</string>
<string name="connected_to">Meshtasticデバイスに接続しました。(%s)</string>
<string name="not_connected">接続されていません。下記のMeshtasticデバイスを選択してください。</string>
<string name="not_connected">接続されていません</string>
<string name="connected_sleeping">接続しましたが、Meshtasticデバイスはスリープ状態です。</string>
<string name="update_to">%s更新</string>
</resources>

Wyświetl plik

@ -46,12 +46,12 @@
<string name="share">공유</string>
<string name="disconnected">연결 해제</string>
<string name="device_sleeping">장치 잠자기</string>
<string name="connected_count">연결: %s 온라인( 전체 %s)</string>
<string name="connected_count">연결: %1$s 온라인( 전체 %2$s)</string>
<string name="list_of_nodes">네트워크안은 모든 노드의 목록</string>
<string name="update_firmware">펌웨어 업데이트</string>
<string name="connected">라디오로 연결됨</string>
<string name="connected_to">라디오로 연결됨 (%s)</string>
<string name="not_connected">연결되지 않음, 아래에서 라이오를 선택하세요.</string>
<string name="not_connected">연결되지 않음</string>
<string name="connected_sleeping">라디오에 연결됨, 해당 라이도는 잠자기중.</string>
<string name="update_to">%s로 업데이트</string>
<string name="app_too_old">너무 오래된 앱</string>

Wyświetl plik

@ -48,12 +48,12 @@
<string name="share">Deel</string>
<string name="disconnected">Niet verbonden</string>
<string name="device_sleeping">Apparaat in slaapstand</string>
<string name="connected_count">Verbonden: %s van %s online</string>
<string name="connected_count">Verbonden: %1$s van %2$s online</string>
<string name="list_of_nodes">Een lijst van de aansluitpunten in het netwerk</string>
<string name="update_firmware">Programma Updaten</string>
<string name="connected">Verbonden met een radio</string>
<string name="connected_to">Verbonden met radio (%s)</string>
<string name="not_connected">Niet verbonden, selecteer radio hieronder</string>
<string name="not_connected">Niet verbonden</string>
<string name="connected_sleeping">Verbonden met radio in slaapstand</string>
<string name="update_to">Updaten naar %s</string>
<string name="app_too_old">Applicatie te oud</string>

Wyświetl plik

@ -48,12 +48,12 @@
<string name="share">Del</string>
<string name="disconnected">Frakoblet</string>
<string name="device_sleeping">Enhet sover</string>
<string name="connected_count">Tilkoblet: %s av %s på nett</string>
<string name="connected_count">Tilkoblet: %1$s av %2$s på nett</string>
<string name="list_of_nodes">En liste over noder i nettverket</string>
<string name="update_firmware">Oppdater Firmware</string>
<string name="connected">Tilkoblet radio</string>
<string name="connected_to">Tilkoblet til radio (%s)</string>
<string name="not_connected">Ikke tilkoblet. velg radio nedenfor</string>
<string name="not_connected">Ikke tilkoblet</string>
<string name="connected_sleeping">Tilkoblet radio, men den sover</string>
<string name="update_to">Oppdater til %s</string>
<string name="app_too_old">Applikasjon for gammel</string>

Wyświetl plik

@ -53,7 +53,7 @@
<string name="share">Udostępnij</string>
<string name="disconnected">Rozłączone</string>
<string name="device_sleeping">Urządzenie uśpione.</string>
<string name="connected_count">Połączono: %s of %s online</string>
<string name="connected_count">Połączono: %1$s of %2$s online</string>
<string name="list_of_nodes">Lista użytkowników w sieci</string>
<string name="update_firmware">Aktualizuj oprogramowanie.</string>
<string name="connected">Połączony z urządzeniem</string>
@ -62,7 +62,7 @@
<string name="connected_sleeping">Połączony z urządzeniem, ale jest w trybie uśpienia.</string>
<string name="update_to">Zaktualizuj do %s.</string>
<string name="app_too_old">Wymagana jest aktualizacja aplikacji.</string>
<string name="must_update">Musisz zaktualizować tę aplikację w Google Play store (lub ręcznie przez Github). Wersja aplikacji jest za stara, aby komunikować się z urządzeniem. Więcej informacji na ten temat znajdziesz tu: <a href="https://www.meshtastic.org/software/android-too-old.html">wiki</a></string>
<string name="must_update">Musisz zaktualizować tę aplikację w Google Play store (lub ręcznie przez Github). Wersja aplikacji jest za stara, aby komunikować się z urządzeniem. Więcej informacji na ten temat znajdziesz tu: <a href="https://meshtastic.org/docs/software/android/android-installation">docs</a></string>
<string name="none">Żadne (Wyłącz)</string>
<string name="modem_config_short">Bliski zasięg (ale szybki transfer)</string>
<string name="modem_config_medium">Średni zasięg (ale szybki transfer)</string>

Wyświetl plik

@ -1,5 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<resources xmlns:tools="http://schemas.android.com/tools">
<string name="action_settings">Configurações</string>
<string name="channel_name">Nome do canal</string>
<string name="channel_options">Opções do canal</string>
@ -48,16 +48,16 @@
<string name="share">Compartilhar</string>
<string name="disconnected">Desconectado</string>
<string name="device_sleeping">Dispositivo em suspensão (sleep)</string>
<string name="connected_count">Conectado: %s de %s online</string>
<string name="connected_count">Conectado: %1$s de %2$s online</string>
<string name="list_of_nodes">Lista de dispositivos na rede</string>
<string name="update_firmware">Atualizar Firmware</string>
<string name="connected">Conectado ao rádio</string>
<string name="connected_to">Conectado ao rádio (%s)</string>
<string name="not_connected">Não conectado, selecione um rádio abaixo</string>
<string name="not_connected">Não conectado</string>
<string name="connected_sleeping">Conectado ao rádio, mas ele está em suspensão (sleep)</string>
<string name="update_to">Atualização para %s</string>
<string name="app_too_old">Atualização do aplicativo necessária</string>
<string name="must_update">Será necessário atualizar este aplicativo no Google Play (ou Github). Versão muito antiga para comunicar com o firmware do rádio. Favor consultar <a href="https://www.meshtastic.org/software/android-too-old.html">wiki</a>.</string>
<string name="must_update">Será necessário atualizar este aplicativo no Google Play (ou Github). Versão muito antiga para comunicar com o firmware do rádio. Favor consultar <a href="https://meshtastic.org/docs/software/android/android-installation">docs</a>.</string>
<string name="none">Nenhum (desabilitado)</string>
<string name="modem_config_short">Curto alcance / rápido</string>
<string name="modem_config_medium">Médio alcance / rápido</string>
@ -113,7 +113,13 @@
<string name="allow_will_show">Permitir (exibe diálogo)</string>
<string name="provide_location_to_mesh">Fornecer localização para mesh</string>
<string name="camera_required">Permissão da câmera</string>
<string name="why_camera_required">Precisamos acessar a câmera para escanear códigos QR. Nenhuma foto ou video são armazenados.</string>
<string name="why_camera_required">Precisamos acessar a câmera para escanear códigos QR. Nenhuma foto ou vídeo são armazenados.</string>
<string name="modem_config_slow_short">Curto alcance / lento</string>
<string name="modem_config_slow_medium">Médio alcance / lento</string>
<plurals name="delete_messages">
<item quantity="one" tools:ignore="ImpliedQuantity">Excluir mensagem?</item>
<item quantity="other">Excluir %s mensagens?</item>
</plurals>
<string name="delete">Excluir</string>
<string name="select_all">Selecionar tudo</string>
</resources>

Wyświetl plik

@ -1,4 +1,4 @@
<resources>
<resources xmlns:tools="http://schemas.android.com/tools">
<string name="action_settings">Configurações</string>
<string name="channel_name">Nome do Canal</string>
<string name="channel_options">Opções do Canal</string>
@ -47,12 +47,12 @@
<string name="share">Partilha</string>
<string name="disconnected">Desconectado</string>
<string name="device_sleeping">Dispositivo a dormir</string>
<string name="connected_count">Conectado: %s de %s online</string>
<string name="connected_count">Conectado: %1$s de %2$s online</string>
<string name="list_of_nodes">Lista de nós na rede</string>
<string name="update_firmware">Atualizar Firmware</string>
<string name="connected">Conectado ao rádio</string>
<string name="connected_to">Conectado ao rádio (%s)</string>
<string name="not_connected">Não conectado, escolha um rádio em baixo</string>
<string name="not_connected">Não conectado</string>
<string name="connected_sleeping">Conectado ao rádio, mas está a dormir</string>
<string name="update_to">Atualização para %s</string>
<string name="app_too_old">A aplicação é muito antiga</string>
@ -112,8 +112,14 @@
<string name="cancel_no_radio">Cancelar (sem acesso ao rádio)</string>
<string name="allow_will_show">Permitir (exibe diálogo)</string>
<string name="provide_location_to_mesh">Fornecer localização para mesh</string>
<string name="why_camera_required">Precisamos acessar a câmera para escanear códigos QR. Nenhuma foto ou video são armazenados.</string>
<string name="why_camera_required">Precisamos acessar a câmera para escanear códigos QR. Nenhuma foto ou vídeo são armazenados.</string>
<string name="camera_required">Permissão da câmera</string>
<string name="modem_config_slow_short">Curto alcance / lento</string>
<string name="modem_config_slow_medium">Médio alcance / lento</string>
<plurals name="delete_messages">
<item quantity="one" tools:ignore="ImpliedQuantity">Excluir mensagem?</item>
<item quantity="other">Excluir %s mensagens?</item>
</plurals>
<string name="delete">Excluir</string>
<string name="select_all">Selecionar tudo</string>
</resources>

Wyświetl plik

@ -48,12 +48,12 @@
<string name="share">Distribuie</string>
<string name="disconnected">Deconectat</string>
<string name="device_sleeping">Dispozitiv în sleep mode</string>
<string name="connected_count">Connectat: %s din %s online</string>
<string name="connected_count">Connectat: %1$s din %2$s online</string>
<string name="list_of_nodes">O lista cu nodurile din rețea</string>
<string name="update_firmware">Updateaza firmware-ul</string>
<string name="connected">Connectat la dispozitiv</string>
<string name="connected_to">Conectat la dispozitivul (%s)</string>
<string name="not_connected">Neconectat, selectează dispozitivul din lista de jos</string>
<string name="not_connected">Neconectat</string>
<string name="connected_sleeping">Connectat la dispozitivi, dar e în modul de sleep</string>
<string name="update_to">Updateaza către %s</string>
<string name="app_too_old">Aplicație prea veche</string>

Wyświetl plik

@ -48,16 +48,16 @@
<string name="share">Zdieľať</string>
<string name="disconnected">Odpojené</string>
<string name="device_sleeping">Vysielač uspatý</string>
<string name="connected_count">Pripojený: %s z %s je online</string>
<string name="connected_count">Pripojený: %1$s z %2$s je online</string>
<string name="list_of_nodes">Zoznam vysielačov v sieti</string>
<string name="update_firmware">Aktualizácia firmvéru</string>
<string name="connected">Pripojené k vysielaču</string>
<string name="connected_to">Pripojené k vysielaču (%s)</string>
<string name="not_connected">Nepripojené, zvoľte si vysielač.</string>
<string name="not_connected">Nepripojené</string>
<string name="connected_sleeping">Pripojené k uspatému vysielaču.</string>
<string name="update_to">Aktualizovať na %s</string>
<string name="app_too_old">Aplikácia je príliš stará</string>
<string name="must_update">Musíte aktualizovať aplikáciu na Google Play store (alebo z Github). Je príliš stará pre komunikáciu s touto verziou firmvéru vysielača. Viac informácií k tejto téme nájdete na <a href="https://www.meshtastic.org/software/android-too-old.html">Meshtastic wiki</a>.</string>
<string name="must_update">Musíte aktualizovať aplikáciu na Google Play store (alebo z Github). Je príliš stará pre komunikáciu s touto verziou firmvéru vysielača. Viac informácií k tejto téme nájdete na <a href="https://meshtastic.org/docs/software/android/android-installation">Meshtastic docs</a>.</string>
<string name="none">Žiaden (zakázať)</string>
<string name="rate_dialog_no_en">Nie, ďakujem</string>
<string name="rate_dialog_cancel_en">Pripomenúť neskôr</string>

Wyświetl plik

@ -46,12 +46,12 @@
<string name="share">Deliti</string>
<string name="disconnected">Prekinjeno</string>
<string name="device_sleeping">Naprava je v "spanju"</string>
<string name="connected_count">Povezano: %s od %s je na mreži</string>
<string name="connected_count">Povezano: %1$s od %2$s je na mreži</string>
<string name="list_of_nodes">Seznam vozlišč v omrežju</string>
<string name="update_firmware">Posodobite vdelano programsko opremo</string>
<string name="connected">Povezana z radiem</string>
<string name="connected_to">Povezana z radiem (%s)</string>
<string name="not_connected">Ni povezano. Izberite radio spodaj</string>
<string name="not_connected">Ni povezano</string>
<string name="connected_sleeping">Povezan z radiem, vendar radio "spi"</string>
<string name="update_to">Posodobi v %s</string>
<string name="app_too_old">Aplikacija je prestara</string>

Wyświetl plik

@ -48,12 +48,12 @@
<string name="share">Paylaş</string>
<string name="disconnected">Bağlantı sonlandı</string>
<string name="device_sleeping">Cihaz uyku durumunda</string>
<string name="connected_count">Bağlandı: %s / %s online</string>
<string name="connected_count">Bağlandı: %1$s / %2$s online</string>
<string name="list_of_nodes">Ağdaki node listesi</string>
<string name="update_firmware">Yazılım güncelle</string>
<string name="connected">Radyoya bağlandı</string>
<string name="connected_to">(%s) telsizine bağlandı </string>
<string name="not_connected">Bağlı değil, aşağıdan bir radyo seçiniz</string>
<string name="not_connected">Bağlı değil</string>
<string name="connected_sleeping">Telsize bağlandı, ancak uyku durumunda</string>
<string name="update_to">%s\'e güncelle</string>
<string name="app_too_old">Uygulama çok eski</string>

Wyświetl plik

@ -48,16 +48,16 @@
<string name="share">分享</string>
<string name="disconnected">断开连接</string>
<string name="device_sleeping">设备休眠中</string>
<string name="connected_count">连接: %s 中 %s 在线</string>
<string name="connected_count">连接: %1$s 中 %2$s 在线</string>
<string name="list_of_nodes">网络中节点列表</string>
<string name="update_firmware">更新固件</string>
<string name="connected">连接设备</string>
<string name="connected_to">连接到设备(%s)</string>
<string name="not_connected">未连接,请选择下方的设备</string>
<string name="not_connected">未连接</string>
<string name="connected_sleeping">已连接到设备,正在休眠中</string>
<string name="update_to">更新到%s</string>
<string name="app_too_old">需要应用程序更新</string>
<string name="must_update">您必须在 Google Play或Github上更新此应用程序.固件太旧,请阅读我们的 <a href="https://www.meshtastic.org/software/android-too-old.html">wiki</a> 这个话题.</string>
<string name="must_update">您必须在 Google Play或Github上更新此应用程序.固件太旧,请阅读我们的 <a href="https://meshtastic.org/docs/software/android/android-installation">docs</a> 这个话题.</string>
<string name="none">无(禁用)</string>
<string name="modem_config_short">短距离(速度快)</string>
<string name="modem_config_medium">中等距离(速度快)</string>

Wyświetl plik

@ -52,16 +52,16 @@
<string name="share">Share</string>
<string name="disconnected">Disconnected</string>
<string name="device_sleeping">Device sleeping</string>
<string name="connected_count">Connected: %s of %s online</string>
<string name="connected_count">Connected: %1$s of %2$s online</string>
<string name="list_of_nodes">A list of nodes in the network</string>
<string name="update_firmware">Update Firmware</string>
<string name="connected">Connected to radio</string>
<string name="connected_to">Connected to radio (%s)</string>
<string name="not_connected">Not connected, select radio below</string>
<string name="not_connected">Not connected</string>
<string name="connected_sleeping">Connected to radio, but it is sleeping</string>
<string name="update_to">Update to %s</string>
<string name="app_too_old">Application update required</string>
<string name="must_update">You must update this application on the app store (or Github). It is too old to talk to this radio firmware. Please read our <a href="https://www.meshtastic.org/software/android-too-old.html">wiki</a> on this topic.</string>
<string name="must_update">You must update this application on the app store (or Github). It is too old to talk to this radio firmware. Please read our <a href="https://meshtastic.org/docs/software/android/android-installation">docs</a> on this topic.</string>
<string name="none">None (disable)</string>
<string name="modem_config_short">Short Range / Fast</string>
<string name="modem_config_medium">Medium Range / Fast</string>
@ -120,7 +120,12 @@
<string name="why_camera_required">We must be granted access to the camera to read QR codes. No pictures or videos will be saved.</string>
<string name="modem_config_slow_short">Short Range / Slow</string>
<string name="modem_config_slow_medium">Medium Range / Slow</string>
<string name="delete_selected_message">Delete selected message?</string>
<plurals name="delete_messages">
<item quantity="one">Delete message?</item>
<item quantity="other">Delete %s messages?</item>
</plurals>
<string name="delete">Delete</string>
<string name="delete_all_messages">Delete All Messages</string>
<string name="select_all">Select all</string>
<string name="shutdown">Shutdown</string>
<string name="reboot">Reboot</string>
</resources>

Wyświetl plik

@ -11,7 +11,8 @@
<item name="windowNoTitle">true</item>
<item name="android:itemTextAppearance">@style/menu_item_color</item>
<item name="actionModeStyle">@style/MyActionMode</item>
<item name="windowActionModeOverlay">true</item>
</style>
<style name="AppTheme.Spinner">
@ -73,6 +74,12 @@
<item name="materialThemeOverlay">@style/MyThemeOverlay_Toolbar</item>
</style>
<style name="MyActionMode" parent="Base.Widget.AppCompat.ActionMode">
<item name="background">@color/colorPrimary</item>
<item name="android:textSize">16sp</item>
<item name="android:textColorPrimary">@color/colorOnPrimary</item>
</style>
<style name="Theme.App.Starting" parent="Theme.SplashScreen">
// Set the splash screen background, animated icon, and animation duration.
<item name="windowSplashScreenBackground">@color/selectedColor</item>

Wyświetl plik

@ -2,7 +2,7 @@
buildscript {
ext.kotlin_version = '1.6.10'
ext.coroutines_version = "1.5.2"
ext.coroutines_version = "1.6.0"
ext.hilt_version = '2.40.5'
repositories {
@ -10,7 +10,7 @@ buildscript {
mavenCentral()
}
dependencies {
classpath 'com.android.tools.build:gradle:7.1.1'
classpath 'com.android.tools.build:gradle:7.1.2'
classpath "org.jetbrains.kotlin:kotlin-gradle-plugin:$kotlin_version"
classpath "org.jetbrains.kotlin:kotlin-serialization:$kotlin_version"