mirror of
https://github.com/oliexdev/openScale.git
synced 2025-08-17 14:10:54 +02:00
Merge branch 'master' into MY4836
This commit is contained in:
@@ -22,6 +22,10 @@ android {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
buildFeatures {
|
||||||
|
buildConfig = true
|
||||||
|
}
|
||||||
|
|
||||||
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
|
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
|
||||||
}
|
}
|
||||||
sourceSets {
|
sourceSets {
|
||||||
@@ -138,18 +142,18 @@ android {
|
|||||||
}
|
}
|
||||||
|
|
||||||
dependencies {
|
dependencies {
|
||||||
coreLibraryDesugaring 'com.android.tools:desugar_jdk_libs:2.0.4'
|
coreLibraryDesugaring 'com.android.tools:desugar_jdk_libs:2.1.3'
|
||||||
|
|
||||||
implementation 'com.google.android.material:material:1.12.0'
|
implementation 'com.google.android.material:material:1.12.0'
|
||||||
implementation 'androidx.legacy:legacy-support-v4:1.0.0'
|
implementation 'androidx.legacy:legacy-support-v4:1.0.0'
|
||||||
implementation 'androidx.appcompat:appcompat:1.6.1'
|
implementation 'androidx.appcompat:appcompat:1.7.0'
|
||||||
implementation 'androidx.recyclerview:recyclerview:1.3.2'
|
implementation 'androidx.recyclerview:recyclerview:1.3.2'
|
||||||
implementation 'androidx.constraintlayout:constraintlayout:2.1.4'
|
implementation 'androidx.constraintlayout:constraintlayout:2.2.0'
|
||||||
implementation 'androidx.preference:preference:1.2.1'
|
implementation 'androidx.preference:preference:1.2.1'
|
||||||
implementation 'androidx.navigation:navigation-fragment:2.7.7'
|
implementation 'androidx.navigation:navigation-fragment:2.8.4'
|
||||||
implementation 'androidx.navigation:navigation-ui:2.7.7'
|
implementation 'androidx.navigation:navigation-ui:2.8.4'
|
||||||
implementation "android.arch.lifecycle:extensions:1.1.1"
|
implementation "android.arch.lifecycle:extensions:1.1.1"
|
||||||
annotationProcessor "androidx.lifecycle:lifecycle-common-java8:2.8.0"
|
annotationProcessor "androidx.lifecycle:lifecycle-common-java8:2.8.7"
|
||||||
|
|
||||||
// MPAndroidChart
|
// MPAndroidChart
|
||||||
implementation 'com.github.PhilJay:MPAndroidChart:v3.1.0'
|
implementation 'com.github.PhilJay:MPAndroidChart:v3.1.0'
|
||||||
@@ -171,11 +175,11 @@ dependencies {
|
|||||||
// Local unit tests
|
// Local unit tests
|
||||||
testImplementation 'junit:junit:4.13.2'
|
testImplementation 'junit:junit:4.13.2'
|
||||||
// Instrumented unit tests
|
// Instrumented unit tests
|
||||||
implementation 'androidx.annotation:annotation:1.8.0'
|
implementation 'androidx.annotation:annotation:1.9.1'
|
||||||
androidTestImplementation 'androidx.test.ext:junit:1.1.5'
|
androidTestImplementation 'androidx.test.ext:junit:1.2.1'
|
||||||
androidTestImplementation 'androidx.test:rules:1.5.0'
|
androidTestImplementation 'androidx.test:rules:1.6.1'
|
||||||
androidTestImplementation 'androidx.test.espresso:espresso-core:3.5.1'
|
androidTestImplementation 'androidx.test.espresso:espresso-core:3.6.1'
|
||||||
androidTestImplementation 'androidx.test.espresso:espresso-contrib:3.5.1'
|
androidTestImplementation 'androidx.test.espresso:espresso-contrib:3.6.1'
|
||||||
}
|
}
|
||||||
|
|
||||||
tasks.withType(Test) {
|
tasks.withType(Test) {
|
||||||
|
@@ -0,0 +1,246 @@
|
|||||||
|
package com.health.openscale.core.bluetooth;
|
||||||
|
|
||||||
|
import android.content.Context;
|
||||||
|
|
||||||
|
import com.health.openscale.core.OpenScale;
|
||||||
|
import com.health.openscale.core.datatypes.ScaleMeasurement;
|
||||||
|
import com.health.openscale.core.datatypes.ScaleUser;
|
||||||
|
import com.health.openscale.core.utils.Converters;
|
||||||
|
|
||||||
|
import org.jetbrains.annotations.Nullable;
|
||||||
|
|
||||||
|
import java.util.Date;
|
||||||
|
import java.util.UUID;
|
||||||
|
|
||||||
|
import timber.log.Timber;
|
||||||
|
|
||||||
|
public class BluetoothES26BBB extends BluetoothCommunication {
|
||||||
|
|
||||||
|
private static final UUID WEIGHT_MEASUREMENT_SERVICE = BluetoothGattUuid.fromShortCode(0x1a10);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Notify
|
||||||
|
*/
|
||||||
|
private static final UUID NOTIFY_MEASUREMENT_CHARACTERISTIC = BluetoothGattUuid.fromShortCode(0x2a10);
|
||||||
|
/**
|
||||||
|
* Write
|
||||||
|
*/
|
||||||
|
private static final UUID WRITE_MEASUREMENT_CHARACTERISTIC = BluetoothGattUuid.fromShortCode(0x2a11);
|
||||||
|
|
||||||
|
public BluetoothES26BBB(Context context) {
|
||||||
|
super(context);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public String driverName() {
|
||||||
|
// TODO idk what to put here
|
||||||
|
return "RENPHO ES-26BB-B";
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
protected boolean onNextStep(int stepNr) {
|
||||||
|
Timber.i("onNextStep(%d)", stepNr);
|
||||||
|
|
||||||
|
switch (stepNr) {
|
||||||
|
case 0:
|
||||||
|
// set notification on for custom characteristic 1 (weight, time, and others)
|
||||||
|
setNotificationOn(WEIGHT_MEASUREMENT_SERVICE, NOTIFY_MEASUREMENT_CHARACTERISTIC);
|
||||||
|
break;
|
||||||
|
case 1:
|
||||||
|
// TODO investigate what these mean
|
||||||
|
byte[] ffe3magicBytes = new byte[]{(byte) 0x55, (byte) 0xaa, (byte) 0x90, (byte) 0x00, (byte) 0x04, (byte) 0x01, (byte) 0x00, (byte) 0x00, (byte) 0x00, (byte) 0x94};
|
||||||
|
writeBytes(WEIGHT_MEASUREMENT_SERVICE, WRITE_MEASUREMENT_CHARACTERISTIC, ffe3magicBytes);
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void onBluetoothNotify(UUID characteristic, byte[] value) {
|
||||||
|
if (characteristic.equals(NOTIFY_MEASUREMENT_CHARACTERISTIC)) {
|
||||||
|
parseNotifyPacket(value);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Parse a packet sent by the scale, through characteristic NOTIFY_MEASUREMENT_CHARACTERISTIC.
|
||||||
|
*
|
||||||
|
* @param data The data payload (in bytes)
|
||||||
|
*/
|
||||||
|
private void parseNotifyPacket(byte[] data) {
|
||||||
|
String dataStr = byteInHex(data);
|
||||||
|
Timber.d("Received measurement packet: %s", dataStr);
|
||||||
|
|
||||||
|
if (!isChecksumValid(data)) {
|
||||||
|
Timber.w("Checksum of packet did not match. Ignoring measurement. Packet: %s", dataStr);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Bytes 0, 1, 3 and 4 seem to be ignored by the original implementation
|
||||||
|
|
||||||
|
byte action = data[2];
|
||||||
|
switch (action) {
|
||||||
|
case 0x14:
|
||||||
|
handleMeasurementPayload(data);
|
||||||
|
break;
|
||||||
|
case 0x11:
|
||||||
|
// TODO this seems to be sent at the start and at the end of the measurement (?)
|
||||||
|
// This sends scale information, such as power status, unit, precision, offline count and battery
|
||||||
|
byte powerStatus = data[5];
|
||||||
|
byte unit = data[6];
|
||||||
|
byte precision = data[7];
|
||||||
|
byte offlineCount = data[8];
|
||||||
|
byte battery = data[9];
|
||||||
|
|
||||||
|
Timber.d(
|
||||||
|
"Received scale information. Power status: %d, Unit: %d, Precision: %d, Offline count: %d, Battery: %d",
|
||||||
|
powerStatus, // 1: turned on; 0: shutting down
|
||||||
|
unit, // 1: kg
|
||||||
|
precision, // seems to be 1, not sure when it would not be
|
||||||
|
offlineCount, // how many offline measurements stored, I think we can ignore
|
||||||
|
battery // seems to be 0, not sure what would happen when battery is low
|
||||||
|
);
|
||||||
|
// TODO
|
||||||
|
|
||||||
|
break;
|
||||||
|
case 0x15:
|
||||||
|
// This is offline data (one packet per measurement)
|
||||||
|
handleOfflineMeasurementPayload(data);
|
||||||
|
break;
|
||||||
|
case 0x10:
|
||||||
|
// This is callback for some action I can't figure out
|
||||||
|
// Original implementation only prints stuff to log, doesn't do anything else
|
||||||
|
byte success = data[5];
|
||||||
|
if (success == 1) {
|
||||||
|
Timber.d("Received success for operation");
|
||||||
|
} else {
|
||||||
|
Timber.d("Received failure for operation");
|
||||||
|
}
|
||||||
|
default:
|
||||||
|
Timber.w("Unknown action sent from scale: %x. Full packet: %s", action, dataStr);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The last byte of the payload is a checksum.
|
||||||
|
* It is calculated by summing all the other bytes and AND'ing it with 255 (that is, truncate to byte).
|
||||||
|
*
|
||||||
|
* @param data The payload to check, where the last byte is the checksum
|
||||||
|
* @return True if the checksum matches, false otherwise
|
||||||
|
*/
|
||||||
|
private boolean isChecksumValid(byte[] data) {
|
||||||
|
if (data.length == 0) {
|
||||||
|
Timber.d("Could not validate checksum because payload is empty");
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
byte checksum = data[data.length - 1];
|
||||||
|
byte sum = sumChecksum(data, 0, data.length - 1);
|
||||||
|
Timber.d("Comparing checksum (%x == %x)", sum, checksum);
|
||||||
|
return sum == checksum;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handle a packet of type "measurement" (0x15).
|
||||||
|
* Offline measurements always have resistance (it wouldn't make sense to store weight-only
|
||||||
|
* measurements since people can just look at the scale).
|
||||||
|
* <p>
|
||||||
|
* This will create and save a measurement.
|
||||||
|
*
|
||||||
|
* @param data The data payload (in bytes)
|
||||||
|
*/
|
||||||
|
private void handleMeasurementPayload(byte[] data) {
|
||||||
|
Timber.d("Parsing measurement");
|
||||||
|
|
||||||
|
// 0x01 and 0x11 are final measurements, 0x00 and 0x10 are real-time measurements
|
||||||
|
byte measurementType = data[5];
|
||||||
|
|
||||||
|
if (measurementType != 0x01 && measurementType != 0x11) {
|
||||||
|
// This byte indicates whether the measurement is final or not
|
||||||
|
// Discard if it isn't, we only want the final value
|
||||||
|
Timber.d("Discarded measurement since it is not final");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
Timber.d("Saving measurement");
|
||||||
|
// Weight (in kg) is stored as big-endian in bytes 6 to 9
|
||||||
|
long weightKg = Converters.fromUnsignedInt32Be(data, 6);
|
||||||
|
// Resistance/Impedance is stored as big-endian in bytes 10 to 11
|
||||||
|
int resistance = Converters.fromUnsignedInt16Be(data, 10);
|
||||||
|
|
||||||
|
Timber.d("Got measurement from scale. Weight: %d, Resistance: %d", weightKg, resistance);
|
||||||
|
|
||||||
|
// FIXME weight might be in other units, investigate
|
||||||
|
|
||||||
|
saveMeasurement(weightKg, resistance, null);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handle a packet of type "ofline measurement" (0x14).
|
||||||
|
* There are two types: real time (0x00/0x10) or final (0x01/0x11), indicated by byte 5.
|
||||||
|
* Real time measurements only have weight, whereas final measurements can also have resistance.
|
||||||
|
* <p>
|
||||||
|
* This will create and save a measurement if it is final, discarding real time measurements.
|
||||||
|
*
|
||||||
|
* @param data The data payload (in bytes)
|
||||||
|
*/
|
||||||
|
private void handleOfflineMeasurementPayload(byte[] data) {
|
||||||
|
Timber.d("Parsing offline measurement");
|
||||||
|
|
||||||
|
// Weight (in kg) is stored as big-endian in bytes 5 to 8
|
||||||
|
long weightKg = Converters.fromUnsignedInt32Be(data, 5);
|
||||||
|
// Resistance/Impedance is stored as big-endian in bytes 9 to 10
|
||||||
|
int resistance = Converters.fromUnsignedInt16Be(data, 9);
|
||||||
|
// Scale returns the seconds elapsed since the measurement as big-endian in bytes 11 to 14
|
||||||
|
long secondsSinceMeasurement = Converters.fromUnsignedInt32Be(data, 11);
|
||||||
|
long measurementTimestamp = System.currentTimeMillis() - secondsSinceMeasurement * 1000;
|
||||||
|
|
||||||
|
Timber.d("Got offline measurement from scale. Weight: %d, Resistance: %d, Timestamp: %tc", weightKg, resistance, measurementTimestamp);
|
||||||
|
|
||||||
|
saveMeasurement(weightKg, resistance, measurementTimestamp);
|
||||||
|
|
||||||
|
acknowledgeOfflineMeasurement();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Send acknowledge to the scale that we received one offline measurement payload,
|
||||||
|
* so that it can delete it from memory.
|
||||||
|
* <p>
|
||||||
|
* For each offline measurement, we have to send one of these.
|
||||||
|
*/
|
||||||
|
private void acknowledgeOfflineMeasurement() {
|
||||||
|
final byte[] payload = {(byte) 0x55, (byte) 0xAA, (byte) 0x95, (byte) 0x0, (byte) 0x1, (byte) 0x1, 0};
|
||||||
|
payload[payload.length - 1] = sumChecksum(payload, 0, payload.length - 1);
|
||||||
|
writeBytes(WEIGHT_MEASUREMENT_SERVICE, WRITE_MEASUREMENT_CHARACTERISTIC, payload);
|
||||||
|
Timber.d("Acknowledge offline measurement");
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Save a measurement from the scale to openScale.
|
||||||
|
*
|
||||||
|
* @param weightKg The weight, in kilograms, multiplied by 100 (that is, as an integer)
|
||||||
|
* @param resistance The resistance (impedance) given by the scale. Can be zero if not barefoot
|
||||||
|
* @param timestamp For offline measurements, provide the timestamp. If null, the current timestamp will be used
|
||||||
|
*/
|
||||||
|
private void saveMeasurement(long weightKg, int resistance, @Nullable Long timestamp) {
|
||||||
|
|
||||||
|
final ScaleUser scaleUser = OpenScale.getInstance().getSelectedScaleUser();
|
||||||
|
|
||||||
|
Timber.d("Saving measurement for scale user %s", scaleUser);
|
||||||
|
|
||||||
|
final ScaleMeasurement btScaleMeasurement = new ScaleMeasurement();
|
||||||
|
btScaleMeasurement.setWeight(weightKg / 100f);
|
||||||
|
if (resistance != 0) {
|
||||||
|
// TODO add more measurements
|
||||||
|
// This will require us to revert engineer libnative-lib.so
|
||||||
|
}
|
||||||
|
if (timestamp != null) {
|
||||||
|
btScaleMeasurement.setDateTime(new Date(timestamp));
|
||||||
|
}
|
||||||
|
|
||||||
|
addScaleMeasurement(btScaleMeasurement);
|
||||||
|
}
|
||||||
|
}
|
@@ -139,7 +139,10 @@ public class BluetoothFactory {
|
|||||||
if (deviceName.equals("CH100")) {
|
if (deviceName.equals("CH100")) {
|
||||||
return new BluetoothHuaweiAH100(context);
|
return new BluetoothHuaweiAH100(context);
|
||||||
}
|
}
|
||||||
if (deviceName.equals("Yoda1")) {
|
if (deviceName.equals("ES-26BB-B")){
|
||||||
|
return new BluetoothES26BBB(context);
|
||||||
|
}
|
||||||
|
if (deviceName.equals("Yoda1")){
|
||||||
return new BluetoothYoda1Scale(context);
|
return new BluetoothYoda1Scale(context);
|
||||||
}
|
}
|
||||||
if (deviceName.equals("AAA002") || deviceName.equals("AAA007")) {
|
if (deviceName.equals("AAA002") || deviceName.equals("AAA007")) {
|
||||||
|
@@ -22,6 +22,7 @@ import android.content.DialogInterface;
|
|||||||
import android.content.Intent;
|
import android.content.Intent;
|
||||||
import android.content.SharedPreferences;
|
import android.content.SharedPreferences;
|
||||||
import android.content.pm.PackageManager;
|
import android.content.pm.PackageManager;
|
||||||
|
import android.net.Uri;
|
||||||
import android.os.Build;
|
import android.os.Build;
|
||||||
import android.os.Bundle;
|
import android.os.Bundle;
|
||||||
import android.provider.Settings;
|
import android.provider.Settings;
|
||||||
@@ -185,7 +186,7 @@ public class ReminderPreferences extends PreferenceFragmentCompat
|
|||||||
builder.setMessage(R.string.permission_notification_info);
|
builder.setMessage(R.string.permission_notification_info);
|
||||||
builder.setPositiveButton(R.string.label_ok, new DialogInterface.OnClickListener() {
|
builder.setPositiveButton(R.string.label_ok, new DialogInterface.OnClickListener() {
|
||||||
public void onClick(DialogInterface dialogInterface, int i) {
|
public void onClick(DialogInterface dialogInterface, int i) {
|
||||||
Intent intent = new Intent(Settings.ACTION_APPLICATION_DETAILS_SETTINGS);
|
Intent intent = new Intent(Settings.ACTION_APPLICATION_DETAILS_SETTINGS, Uri.parse("package:" + getContext().getPackageName()));
|
||||||
getContext().startActivity(intent);
|
getContext().startActivity(intent);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
@@ -27,7 +27,10 @@
|
|||||||
<item name="colorOnSurfaceInverse">@color/md_theme_dark_inverseOnSurface</item>
|
<item name="colorOnSurfaceInverse">@color/md_theme_dark_inverseOnSurface</item>
|
||||||
<item name="colorSurfaceInverse">@color/md_theme_dark_inverseSurface</item>
|
<item name="colorSurfaceInverse">@color/md_theme_dark_inverseSurface</item>
|
||||||
<item name="colorPrimaryInverse">@color/md_theme_dark_inversePrimary</item>
|
<item name="colorPrimaryInverse">@color/md_theme_dark_inversePrimary</item>
|
||||||
<item name="alertDialogTheme">@style/AppTheme.Dialog</item>
|
<item name="android:alertDialogTheme">@style/AppTheme.Dialog</item> <!-- for material 3 dialog button color -->
|
||||||
|
<item name="alertDialogTheme">@style/AppTheme.Dialog</item> <!-- for preference dialog button color -->
|
||||||
|
<item name="materialCalendarTheme">@style/MaterialCalendarTheme</item> <!-- for material calendar button color -->
|
||||||
|
<item name="preferenceTheme">@style/MaterialPreferenceTheme</item>
|
||||||
<item name="colorAccent">@color/md_theme_dark_onPrimary</item>
|
<item name="colorAccent">@color/md_theme_dark_onPrimary</item>
|
||||||
</style>
|
</style>
|
||||||
|
|
||||||
@@ -35,8 +38,29 @@
|
|||||||
<item name="colorSecondaryContainer">@android:color/transparent</item>
|
<item name="colorSecondaryContainer">@android:color/transparent</item>
|
||||||
</style>
|
</style>
|
||||||
|
|
||||||
<style name="AppTheme.Dialog" parent="Theme.MaterialComponents.DayNight.Dialog">
|
<style name="MaterialPreferenceTheme" parent="PreferenceThemeOverlay">
|
||||||
|
<item name="buttonBarPositiveButtonStyle">@style/PositiveButtonStyle</item>
|
||||||
|
<item name="buttonBarNegativeButtonStyle">@style/NegativeButtonStyle</item>
|
||||||
|
</style>
|
||||||
|
|
||||||
|
<style name="MaterialCalendarTheme" parent="ThemeOverlay.MaterialComponents.MaterialCalendar">
|
||||||
|
<item name="buttonBarPositiveButtonStyle">@style/PositiveButtonStyle</item>
|
||||||
|
<item name="buttonBarNegativeButtonStyle">@style/NegativeButtonStyle</item>
|
||||||
|
</style>
|
||||||
|
|
||||||
|
<style name="AppTheme.Dialog" parent="ThemeOverlay.Material3.Dialog">
|
||||||
<item name="colorPrimary">@color/md_theme_dark_onBackground</item>
|
<item name="colorPrimary">@color/md_theme_dark_onBackground</item>
|
||||||
|
<item name="android:buttonBarNegativeButtonStyle">@style/NegativeButtonStyle</item>
|
||||||
|
<item name="android:buttonBarPositiveButtonStyle">@style/PositiveButtonStyle</item>
|
||||||
|
<item name="android:buttonBarNeutralButtonStyle">@style/PositiveButtonStyle</item>
|
||||||
|
</style>
|
||||||
|
|
||||||
|
<style name="NegativeButtonStyle" parent="Widget.MaterialComponents.Button.TextButton.Dialog">
|
||||||
|
<item name="android:textColor">@color/md_theme_dark_onBackground</item>
|
||||||
|
</style>
|
||||||
|
|
||||||
|
<style name="PositiveButtonStyle" parent="Widget.MaterialComponents.Button.TextButton.Dialog">
|
||||||
|
<item name="android:textColor">@color/md_theme_dark_onBackground</item>
|
||||||
</style>
|
</style>
|
||||||
|
|
||||||
<style name="AppTheme.RadioButton" parent="Widget.Material3.CompoundButton.RadioButton">
|
<style name="AppTheme.RadioButton" parent="Widget.Material3.CompoundButton.RadioButton">
|
||||||
|
@@ -5,8 +5,8 @@ buildscript {
|
|||||||
mavenCentral()
|
mavenCentral()
|
||||||
}
|
}
|
||||||
dependencies {
|
dependencies {
|
||||||
classpath 'com.android.tools.build:gradle:8.4.0'
|
classpath 'com.android.tools.build:gradle:8.4.2'
|
||||||
classpath "androidx.navigation:navigation-safe-args-gradle-plugin:2.7.7"
|
classpath "androidx.navigation:navigation-safe-args-gradle-plugin:2.8.4"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@@ -1,4 +1,3 @@
|
|||||||
android.defaults.buildfeatures.buildconfig=true
|
|
||||||
android.enableJetifier=true
|
android.enableJetifier=true
|
||||||
android.nonFinalResIds=false
|
android.nonFinalResIds=false
|
||||||
android.nonTransitiveRClass=false
|
android.nonTransitiveRClass=false
|
||||||
|
Reference in New Issue
Block a user