Initial support for thermal printers

This commit is contained in:
Daniele Gobbetti
2025-07-06 18:08:46 +02:00
committed by daniele
parent 0996b5d810
commit 07d0895c06
12 changed files with 1100 additions and 0 deletions
+4
View File
@@ -264,6 +264,10 @@
android:name=".devices.ultrahuman.UltrahumanBreathingActivity"
android:label="@string/devicetype_ultrahuma_ring_air"
android:parentActivityName=".activities.ControlCenterv2" />
<activity
android:name=".devices.thermalprinter.SendToPrinterActivity"
android:label="@string/devicetype_generic_thermal_printer"
android:parentActivityName=".activities.ControlCenterv2" />
<activity
android:name=".activities.FwAppInstallerActivity"
android:label="@string/title_activity_fw_app_insaller"
+72
View File
@@ -0,0 +1,72 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?><!-- Created with Inkscape (http://www.inkscape.org/) -->
<svg xmlns:cc="http://creativecommons.org/ns#" xmlns:dc="http://purl.org/dc/elements/1.1/" xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape" xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#" xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
xmlns:svg="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink"
height="28.740126"
id="svg2"
version="1.1" viewBox="0 0 28.740126 28.740126"
width="28.740126" xmlns="http://www.w3.org/2000/svg"
inkscape:version="1.2.2 (b0a8486541, 2022-12-01)" sodipodi:docname="bluetooth_printer.svg">
<defs id="defs6">
<inkscape:perspective id="perspective903" inkscape:persp3d-origin="14.370063 : 9.580042 : 1"
inkscape:vp_x="0 : 14.370063 : 1" inkscape:vp_y="0 : 1000 : 0"
inkscape:vp_z="28.740126 : 14.370063 : 1" sodipodi:type="inkscape:persp3d" />
</defs>
<g style="display:inline" id="layer1" inkscape:groupmode="layer" inkscape:label="Background">
<circle
style="fill:none;fill-opacity:0.846154;stroke:#ffffff;stroke-width:1.04987;stroke-linecap:round;stroke-linejoin:round;stroke-dasharray:none;stroke-dashoffset:0;stroke-opacity:1"
cx="14.370063" cy="19.524071" id="path1229" r="2.2537029" />
<g style="display:inline" id="layer3" inkscape:groupmode="layer" inkscape:label="Middle"
sodipodi:insensitive="true">
<rect
style="display:inline;fill:#4dabf5;fill-opacity:1;stroke:none;stroke-width:3.57115;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1"
height="21.904638" id="rect817-7" ry="0.9472276" width="22.819572" x="2.9317887"
y="3.0349438" />
</g>
<g style="display:inline" id="layer2" inkscape:groupmode="layer" inkscape:label="Top">
<rect
style="fill:#2196f3;fill-opacity:1;stroke:none;stroke-width:3.57115;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1"
height="21.904638" id="rect817" ry="0.9472276" width="22.819572" x="2.9238882"
y="3.4130721" />
</g>
<g style="fill:#fffffe;fill-opacity:1;stroke:none;stroke-width:1.17455" id="g870"
transform="matrix(0.851388,0,0,0.851388,4.1035946,4.1325617)" />
<g style="display:inline" id="layer4" inkscape:groupmode="layer" inkscape:label="Bottom"
sodipodi:insensitive="true">
<rect
style="display:inline;fill:#1f7fdb;fill-opacity:1;stroke:none;stroke-width:3.57115;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1"
height="21.904638" id="rect817-5" ry="0.9472276" width="22.819572" x="2.9239759"
y="3.8768768" />
</g>
<path
style="fill:none;fill-rule:evenodd;stroke:#ffffff;stroke-width:0.524934;stroke-linecap:butt;stroke-linejoin:miter;stroke-miterlimit:4;stroke-dasharray:2.09974, 1.04987;stroke-dashoffset:0;stroke-opacity:1"
d="M 4.2838873,14.928158 H 24.456238" id="path1465" inkscape:label="path1465" />
<rect
style="fill:none;stroke:#ffffff;stroke-width:1.2;stroke-linecap:round;stroke-linejoin:round;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1;paint-order:fill markers stroke;stop-color:#000000"
height="19" id="rect846" ry="5.7287159" width="20.062387" x="4.3388691" y="4.8700628" />
</g>
<g style="display:none" id="layer5" inkscape:groupmode="layer" inkscape:label="Original">
<image style="opacity:0.490741;image-rendering:optimizeQuality" height="28.710279" id="image4124"
preserveAspectRatio="none"
width="28.710279" x="0.010951031"
y="0.0079936879" xlink:href="data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAGAAAABgCAMAAADVRocKAAAAA3NCSVQICAjb4U/gAAAACXBIWXMA ADFOAAAxTgGacD5yAAAAGXRFWHRTb2Z0d2FyZQB3d3cuaW5rc2NhcGUub3Jnm+48GgAAABF0RVh0 VGl0bGUAUGViYmxlIEljb26KvwD9AAAADHRFWHRBdXRob3IAeHBobnh/4OzMAAAAY3RFWHRDb3B5 cmlnaHQAQ0MgQXR0cmlidXRpb24tTm9uQ29tbWVyY2lhbC1TaGFyZUFsaWtlIGh0dHA6Ly9jcmVh dGl2ZWNvbW1vbnMub3JnL2xpY2Vuc2VzL2J5LW5jLXNhLzMuMC/eBBrlAAABy1BMVEUAAAAAAAAA AP8AAIAAAFUAAKoAVaoAAIAAQIAAM2YAM5kzM2YzM5krK4AkJG0kJJIgIIAcHHEcHI4aGoAXF3QX F4sXLnQXLosVK4AUJ3YUJ4kSJIAkJIARInciInciIogQIIAgIIAeHngeHoccHIAcK4AbKHkbKIYa GoAaJoAYJHkXI4AXI4sWIXoWIYUVIIAfH3ofH4UdJ4ATHIQcJnsbJIAaI4QaIoAZIXsZIYQYIHgY IIAXH4MXJ3wXHoAXJoBLpfAWJHwdJHwdJIMcI4Acas0VIoMbIoAaIXwaIIAZH3wZH4MZJXwYHoAY HoYYJIAehtsYHoIefdtHoO0jetZGovNEn+4gg+EfgN5Ep/VKqfREpvQgfdkgfttKqvQggd4hjesw nPMwnPRJqfUhgd4hjutJqfUgfttNq/VNq/QhlPEhlPIhlvMkmPMglvIff9sgjeoglPAhk/AhlPEh lPIhlvMkl/MkmPMlmPMmmPMnmfMpmvMzn/Q0n/Q1n/Q2oPRIqfVNq/VasfZbsvZhtPZitfdtuveQ yvmXzvmj0/qk1Pqu2fu83/vR6f3T6v3Z7f3i8f3j8v3l8/7m8/7p9f7q9f7x+P7y+f7////ougeB AAAAb3RSTlMAAQECAwMDBAQFBQUFBgcHCAkJCgsLCwsMDQ0ODg8PDxAQERESEhMTFBQVFhYXFxgZ GRobGxwdHh8fICAhISIiIiMjIyQkJSYnKCkpKSoqKiorKyssLC2xs7O0tba2tu7u7u7u7+/w8fHy +/v7+/ywJhX9AAAC0klEQVRo3u2a+VfTQBCAm00iW0nSTVNKbKQYbIMUC6jU4q2IB2rF+74vPEAR LSjIIXgAAiqef65py6MNJe17y1RF8/26L/N1N9O8ZGZcLof/DiYFoiV1cZHwiGVZjh7zalRAYYbn sFsQ6RFwGccyBeJjwSMrPn8lHX6fQqRy3tbAsFiU1So9FA4bNIRD63XNTwTO5pSYc9fuvwbgwaW9 SxqYs/eGBgcAGBy+fRrlpRPDsJdfJoEYuMgvzibz/vK3kmDcFMuxNZvM/BG74QTdFV6PJZsYlhe9 XXCCrlCVStwcyhG45QCkoCmiV0o4uwXEib4aQMHjbc0RTXYvCMx/mGdNHaSgraWxRhEXzsgUEC0C KWjfszlUYRWsrYcUHGttNlSRzxUEo5CCjrZYrSqVVlD3Nwj6vqcZsovzKrPeTy3o/5lmxE4wnFl/ 4QhKLvjy2YY5IEExHMH0B1umQQSj9o+EUUewIBh/m8c4qOBbfu58dQRLCmYnJmZLKphKJicdwR8X vJsymSmhIM37FSv4OJlhxnlUOAJHAC8Ye5PHmPNe9PsEJX/5dT5AigvmPtkw53wnr3xB34809uWc zDp9OWflV7xgBaBlzY79MWORALhuui+vbhoArfwe3rUp5MtWfl2Il1QDUtAaj+rKakv1XdEhBfGm Wo1kq+/pM1Ih+wdRI6iIq3IFvEAewgke6QHFg1mU26PjhbtwghsKEbG1X8ew3PmnUPGfXRAwv7hr yqAD13t6nwPQ23PnBMeivG4gg85c7XwCQOeVU+avZ5bslcrrNm7dfehIInGSguOJo+0Hd27ZoOam j3ULvOjTI02x+PYddLTEY42GJpfnpo/L0g50k8pqI1IfbWikoSFaXxfSFKnMrqFsZpKbKKoWDFbT EdQCqlfCdhtIG7AoESLTQogk4EJd/VRTn8fLgecKTg3MjyUsA4SKDD7MT1bQ8++NmvwCRmydh2Fg UvEAAAAASUVORK5CYII= " />
</g>
<metadata id="metadata8">
<rdf:RDF>
<cc:Work rdf:about="">
<dc:format>image/svg+xml</dc:format>
<dc:type rdf:resource="http://purl.org/dc/dcmitype/StillImage" />
</cc:Work>
</rdf:RDF>
</metadata>
<sodipodi:namedview bordercolor="#666666" borderopacity="1" gridtolerance="10"
guidetolerance="10" id="namedview4" objecttolerance="10" pagecolor="#ffffff"
showgrid="false" showguides="true" inkscape:current-layer="layer1"
inkscape:cx="7.711883" inkscape:cy="13.545514" inkscape:deskcolor="#d1d1d1" inkscape:guide-bbox="true"
inkscape:pagecheckerboard="0" inkscape:pageopacity="0" inkscape:pageshadow="2"
inkscape:showpageshadow="2" inkscape:window-height="1365" inkscape:window-maximized="1"
inkscape:window-width="2560" inkscape:window-x="0" inkscape:window-y="0"
inkscape:zoom="22.627418">
<inkscape:grid id="grid972" type="xygrid" />
</sodipodi:namedview>
</svg>

After

Width:  |  Height:  |  Size: 6.9 KiB

@@ -0,0 +1,65 @@
package nodomain.freeyourgadget.gadgetbridge.devices.thermalprinter;
import android.content.Context;
import android.graphics.Bitmap;
import android.graphics.BitmapFactory;
import android.net.Uri;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import java.io.FileNotFoundException;
import java.io.IOException;
import nodomain.freeyourgadget.gadgetbridge.activities.InstallActivity;
import nodomain.freeyourgadget.gadgetbridge.devices.InstallHandler;
import nodomain.freeyourgadget.gadgetbridge.impl.GBDevice;
import nodomain.freeyourgadget.gadgetbridge.util.UriHelper;
public class ImageFilePrinterHandler implements InstallHandler {
private static final Logger LOG = LoggerFactory.getLogger(ImageFilePrinterHandler.class);
protected final Context mContext;
private Bitmap incomingBitmap;
public ImageFilePrinterHandler(final Uri uri, final Context context) {
this.mContext = context;
final UriHelper uriHelper;
try {
uriHelper = UriHelper.get(uri, context);
} catch (final IOException e) {
LOG.error("Failed to get uri", e);
return;
}
try {
incomingBitmap = BitmapFactory.decodeStream(uriHelper.openInputStream());
} catch (FileNotFoundException e) {
LOG.error("Failed to create bitmap", e);
}
}
public Bitmap getIncomingBitmap() {
return incomingBitmap;
}
@Override
public boolean isValid() {
return incomingBitmap != null;
}
@Override
public void validateInstallation(InstallActivity installActivity, GBDevice device) {
installActivity.setPreview(incomingBitmap);
installActivity.setInstallEnabled(true);
}
@Override
public void onStartInstall(GBDevice device) {
}
}
@@ -0,0 +1,132 @@
package nodomain.freeyourgadget.gadgetbridge.devices.thermalprinter;
import android.content.Intent;
import android.graphics.Bitmap;
import android.graphics.BitmapFactory;
import android.net.Uri;
import android.os.Bundle;
import android.view.View;
import android.widget.Button;
import android.widget.ImageView;
import android.widget.TextView;
import androidx.localbroadcastmanager.content.LocalBroadcastManager;
import com.google.android.material.materialswitch.MaterialSwitch;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import java.io.ByteArrayOutputStream;
import java.io.FileNotFoundException;
import java.io.IOException;
import java.util.List;
import nodomain.freeyourgadget.gadgetbridge.GBApplication;
import nodomain.freeyourgadget.gadgetbridge.R;
import nodomain.freeyourgadget.gadgetbridge.activities.AbstractGBActivity;
import nodomain.freeyourgadget.gadgetbridge.impl.GBDevice;
import nodomain.freeyourgadget.gadgetbridge.service.devices.thermalprinter.BitmapToBitSet;
import nodomain.freeyourgadget.gadgetbridge.service.devices.thermalprinter.GenericThermalPrinterSupport;
import nodomain.freeyourgadget.gadgetbridge.util.UriHelper;
public class SendToPrinterActivity extends AbstractGBActivity {
private static final Logger LOG = LoggerFactory.getLogger(SendToPrinterActivity.class);
private Bitmap bitmap;
private ImageView previewImage;
private MaterialSwitch dithering;
private BitmapToBitSet bitmapToBitSet;
@Override
protected void onCreate(Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
setContentView(R.layout.activity_print_image);
ImageView incomingImage = findViewById(R.id.incomingImage);
previewImage = findViewById(R.id.convertedImage);
Button sendToPrinter = findViewById(R.id.sendToPrinterButton);
dithering = findViewById(R.id.switchDithering);
final TextView warning = findViewById(R.id.warning_devices);
final List<GBDevice> devices = ((GBApplication) getApplicationContext()).getDeviceManager().getSelectedDevices();
GBDevice device = devices.get(0);
switch (devices.size()) {
case 0:
warning.setText(R.string.open_fw_installer_connect_minimum_one_device);
sendToPrinter.setEnabled(false);
break;
case 1:
warning.setText(String.format(getString(R.string.open_fw_installer_select_file), device.getAliasOrName()));
sendToPrinter.setEnabled(true);
break;
default:
warning.setText(R.string.open_fw_installer_connect_maximum_one_device);
sendToPrinter.setEnabled(false);
}
dithering.setOnClickListener(new View.OnClickListener() {
@Override
public void onClick(View view) {
LOG.info("dithering is : {}", dithering.isChecked());
bitmapToBitSet.toBlackAndWhite(dithering.isChecked());
previewImage.setImageBitmap(bitmapToBitSet.getPreview());
}
});
Uri uri = getIntent().getData();
if (uri == null) { // For "share" intent
uri = getIntent().getParcelableExtra(GenericThermalPrinterSupport.INTENT_EXTRA_URI);
}
final UriHelper uriHelper;
try {
uriHelper = UriHelper.get(uri, getApplicationContext());
} catch (final IOException e) {
LOG.error("Failed to get uri", e);
return;
}
try {
final Bitmap incoming = BitmapFactory.decodeStream(uriHelper.openInputStream());
if (incoming.getWidth() > 384) {
float aspectRatio = (float) incoming.getHeight() / incoming.getWidth();
bitmap = Bitmap.createScaledBitmap(incoming, 384, (int) (384 * aspectRatio), true);
} else {
bitmap = incoming;
}
} catch (FileNotFoundException e) {
LOG.error("Failed to create bitmap", e);
}
incomingImage.setImageBitmap(bitmap);
bitmapToBitSet = new BitmapToBitSet(bitmap);
bitmapToBitSet.toBlackAndWhite(dithering.isChecked());
previewImage.setImageBitmap(bitmapToBitSet.getPreview());
sendToPrinter.setOnClickListener(new View.OnClickListener() {
@Override
public void onClick(View v) {
sendToPrinter();
}
});
}
private void sendToPrinter() {
Intent intent = new Intent(GenericThermalPrinterSupport.INTENT_ACTION_PRINT_BITMAP);
//TODO: this is horrible
ByteArrayOutputStream stream = new ByteArrayOutputStream();
bitmap.compress(Bitmap.CompressFormat.PNG, 100, stream);
intent.putExtra(GenericThermalPrinterSupport.INTENT_EXTRA_BITMAP, stream.toByteArray());
intent.putExtra(GenericThermalPrinterSupport.INTENT_EXTRA_APPLY_DITHERING, dithering.isChecked());
LocalBroadcastManager.getInstance(this).sendBroadcast(intent);
}
}
@@ -0,0 +1,81 @@
package nodomain.freeyourgadget.gadgetbridge.devices.thermalprinter;
import android.content.Context;
import android.content.Intent;
import android.net.Uri;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import nodomain.freeyourgadget.gadgetbridge.GBException;
import nodomain.freeyourgadget.gadgetbridge.R;
import nodomain.freeyourgadget.gadgetbridge.devices.AbstractBLEDeviceCoordinator;
import nodomain.freeyourgadget.gadgetbridge.devices.InstallHandler;
import nodomain.freeyourgadget.gadgetbridge.entities.DaoSession;
import nodomain.freeyourgadget.gadgetbridge.entities.Device;
import nodomain.freeyourgadget.gadgetbridge.impl.GBDevice;
import nodomain.freeyourgadget.gadgetbridge.impl.GBDeviceCandidate;
import nodomain.freeyourgadget.gadgetbridge.service.DeviceSupport;
import nodomain.freeyourgadget.gadgetbridge.service.devices.thermalprinter.GenericThermalPrinterSupport;
public class ThermalPrinterCoordinator extends AbstractBLEDeviceCoordinator {
@Override
protected void deleteDevice(@NonNull GBDevice gbDevice, @NonNull Device device, @NonNull DaoSession session) throws GBException {
}
@Override
public int getBondingStyle() {
return BONDING_STYLE_LAZY;
}
@Override
public int getDefaultIconResource() {
return R.drawable.ic_device_bluetooth_printer;
}
@Override
public String getManufacturer() {
return "";
}
@NonNull
@Override
public Class<? extends DeviceSupport> getDeviceSupportClass(GBDevice gbDevice) {
return GenericThermalPrinterSupport.class;
}
@Override
public int getDeviceNameResource() {
return R.string.devicetype_generic_thermal_printer;
}
@Override
public boolean supports(GBDeviceCandidate candidate) {
return candidate.supportsService(GenericThermalPrinterSupport.discoveryService);
}
@Override
public boolean supportsFlashing() {
return true;
}
@Override
public boolean addBatteryPollingSettings() {
return true;
}
@Nullable
@Override
public InstallHandler findInstallHandler(Uri uri, Context context) {
//TODO: maybe there is another way to implement opening/receiving pictures
final ImageFilePrinterHandler imageFilePrinterHandler = new ImageFilePrinterHandler(uri, context);
if (imageFilePrinterHandler.isValid()) {
Intent instentStartPrintActivity = new Intent(context, SendToPrinterActivity.class);
instentStartPrintActivity.putExtra(GenericThermalPrinterSupport.INTENT_EXTRA_URI, uri);
context.startActivity(instentStartPrintActivity);
}
return null;
}
}
@@ -329,6 +329,7 @@ import nodomain.freeyourgadget.gadgetbridge.devices.soundcore.motion300.Soundcor
import nodomain.freeyourgadget.gadgetbridge.devices.soundcore.q30.SoundcoreQ30Coordinator;
import nodomain.freeyourgadget.gadgetbridge.devices.supercars.SuperCarsCoordinator;
import nodomain.freeyourgadget.gadgetbridge.devices.test.TestDeviceCoordinator;
import nodomain.freeyourgadget.gadgetbridge.devices.thermalprinter.ThermalPrinterCoordinator;
import nodomain.freeyourgadget.gadgetbridge.devices.tlw64.TLW64Coordinator;
import nodomain.freeyourgadget.gadgetbridge.devices.ultrahuman.UltrahumanDeviceCoordinator;
import nodomain.freeyourgadget.gadgetbridge.devices.um25.Coordinator.UM25Coordinator;
@@ -731,6 +732,7 @@ public enum DeviceType {
BLE_GATT_CLIENT(BleGattClientCoordinator.class),
MARSTEK_B2500(MarstekB2500DeviceCoordinator.class),
GENERIC_HEADPHONES(GenericHeadphonesCoordinator.class),
GENERIC_THERMAL_PRINTER(ThermalPrinterCoordinator.class),
TEST(TestDeviceCoordinator.class),
ULTRAHUMAN_RING_AIR(UltrahumanDeviceCoordinator.class),
ATC_BLE_OEPL(ATCBLEOEPLCoordinator.class);
@@ -0,0 +1,105 @@
package nodomain.freeyourgadget.gadgetbridge.service.devices.thermalprinter;
import android.graphics.Bitmap;
import android.graphics.Color;
import java.util.Arrays;
import java.util.BitSet;
public class BitmapToBitSet {
public int getWidth() {
return width;
}
private final int width;
private final int height;
private final Bitmap bitmap;
private final BitSet bwPixels;
private int threshold = 128;
public BitmapToBitSet(Bitmap bitmap) {
this.bitmap = bitmap;
this.width = bitmap.getWidth();
this.height = bitmap.getHeight();
this.bwPixels = new BitSet(this.width * this.height);
}
public int getThreshold() {
return threshold;
}
public void setThreshold(int threshold) {
this.threshold = threshold;
}
public Bitmap getPreview() {
final Bitmap bwBitmap = Bitmap.createBitmap(width, height, Bitmap.Config.ARGB_8888);
final int[] pixels = new int[width * height];
Arrays.fill(pixels, Color.WHITE);
for (int i = bwPixels.nextSetBit(0); i >= 0; i = bwPixels.nextSetBit(i + 1)) {
pixels[i] = Color.BLACK;
}
bwBitmap.setPixels(pixels, 0, width, 0, 0, width, height);
return bwBitmap;
}
public BitSet toBlackAndWhite(final boolean applyDithering) {
bwPixels.clear();
int[] pixels = new int[width * height];
bitmap.getPixels(pixels, 0, width, 0, 0, width, height);
for (int y = 0; y < height; y++) {
for (int x = 0; x < width; x++) {
int index = y * width + x;
int currentPixelColor = pixels[index];
if (Color.alpha(currentPixelColor) == 0) {
pixels[index] = Color.WHITE; //flatten transparent pixels to white
continue;
}
int currentPixelGrayscale = getGrayscaleValue(currentPixelColor);
boolean isBlack = (currentPixelGrayscale < threshold);
if (isBlack)
bwPixels.set(index);
if (applyDithering) {
int bwResultingPixel = (isBlack) ? 0 : 255;
pixels[index] = Color.argb(Color.alpha(currentPixelColor), bwResultingPixel, bwResultingPixel, bwResultingPixel);
int quantError = currentPixelGrayscale - bwResultingPixel;
// Apply dithering to neighboring pixels
if (x + 1 < width) {
applyErrorToPixel(pixels, y * width + (x + 1), quantError * 7 / 16);
}
if (x - 1 >= 0 && y + 1 < height) {
applyErrorToPixel(pixels, (y + 1) * width + (x - 1), quantError * 3 / 16);
}
if (y + 1 < height) {
applyErrorToPixel(pixels, (y + 1) * width + x, quantError * 5 / 16);
}
if (x + 1 < width && y + 1 < height) {
applyErrorToPixel(pixels, (y + 1) * width + (x + 1), quantError / 16);
}
}
}
}
return bwPixels;
}
private void applyErrorToPixel(int[] pixels, int index, int error) {
int currentPixelColor = pixels[index];
final int newGray = Math.max(0, Math.min(255, getGrayscaleValue(currentPixelColor) + error));
pixels[index] = Color.argb(Color.alpha(currentPixelColor), newGray, newGray, newGray);
}
private int getGrayscaleValue(int color) {
return (int) (0.3 * Color.red(color) + 0.59 * Color.green(color) + 0.11 * Color.blue(color));
}
}
@@ -0,0 +1,472 @@
package nodomain.freeyourgadget.gadgetbridge.service.devices.thermalprinter;
import android.bluetooth.BluetoothGatt;
import android.bluetooth.BluetoothGattCharacteristic;
import android.content.BroadcastReceiver;
import android.content.Context;
import android.content.Intent;
import android.content.IntentFilter;
import android.graphics.Bitmap;
import android.graphics.BitmapFactory;
import android.graphics.Canvas;
import android.graphics.Color;
import android.graphics.Paint;
import android.net.Uri;
import android.os.Handler;
import android.os.Looper;
import android.widget.Toast;
import androidx.localbroadcastmanager.content.LocalBroadcastManager;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import java.nio.BufferOverflowException;
import java.nio.ByteBuffer;
import java.time.ZonedDateTime;
import java.util.BitSet;
import java.util.UUID;
import nodomain.freeyourgadget.gadgetbridge.GBApplication;
import nodomain.freeyourgadget.gadgetbridge.R;
import nodomain.freeyourgadget.gadgetbridge.activities.devicesettings.DeviceSettingsPreferenceConst;
import nodomain.freeyourgadget.gadgetbridge.deviceevents.GBDeviceEventBatteryInfo;
import nodomain.freeyourgadget.gadgetbridge.impl.GBDevice;
import nodomain.freeyourgadget.gadgetbridge.service.btle.AbstractBTLEDeviceSupport;
import nodomain.freeyourgadget.gadgetbridge.service.btle.AbstractBTLESingleDeviceSupport;
import nodomain.freeyourgadget.gadgetbridge.service.btle.TransactionBuilder;
import nodomain.freeyourgadget.gadgetbridge.service.btle.actions.SetDeviceStateAction;
import nodomain.freeyourgadget.gadgetbridge.util.GB;
public class GenericThermalPrinterSupport extends AbstractBTLESingleDeviceSupport {
private static final Logger LOG = LoggerFactory.getLogger(GenericThermalPrinterSupport.class);
public static final String INTENT_ACTION_PRINT_BITMAP = "print_bitmap";
public static final String INTENT_EXTRA_URI = "picture_uri";
public static final String INTENT_EXTRA_BITMAP = "picture";
public static final String INTENT_EXTRA_APPLY_DITHERING = "apply_dithering";
public static final UUID discoveryService = UUID.fromString("0000af30-0000-1000-8000-00805f9b34fb");
private final UUID writeCharUUID = UUID.fromString("0000ae01-0000-1000-8000-00805f9b34fb");
private final UUID notifCharUUID = UUID.fromString("0000ae02-0000-1000-8000-00805f9b34fb");
private final Handler handler = new Handler(Looper.getMainLooper());
private final Runnable batteryRunner = () -> {
LOG.info("Running retrieving battery through runner.");
send("getDevState", PrinterCommand.getDevState.message(new byte[]{0x00}));
};
private boolean useRunLengthEncoding = false;
private boolean canPrint = false;
private final int IMAGE_WIDTH = 384;
private final int PRINT_INTENSITY = 8000;
private final int PRINT_SPEED = 10;
private final int PRINT_TYPE = 0; //1 also observed
public GenericThermalPrinterSupport() {
super(LOG);
addSupportedService(UUID.fromString("0000ae30-0000-1000-8000-00805f9b34fb"));
IntentFilter commandFilter = new IntentFilter();
commandFilter.addAction(INTENT_ACTION_PRINT_BITMAP);
BroadcastReceiver commandReceiver = new BroadcastReceiver() {
@Override
public void onReceive(Context context, Intent intent) {
switch (intent.getAction()) {
case INTENT_ACTION_PRINT_BITMAP:
byte[] bitmapData = intent.getByteArrayExtra(INTENT_EXTRA_BITMAP);
if (bitmapData != null) {
Bitmap bitmap = BitmapFactory.decodeByteArray(bitmapData, 0, bitmapData.length);
boolean dither = intent.getBooleanExtra(INTENT_EXTRA_APPLY_DITHERING, false);
printImage(bitmap, dither);
}
}
}
};
LocalBroadcastManager.getInstance(GBApplication.getContext()).registerReceiver(commandReceiver, commandFilter);
}
private static byte[] byteBufferToArray(final ByteBuffer input) {
input.flip();
final byte[] result = new byte[input.limit()];
input.get(result);
return result;
}
private static int getCrc8(final byte[] seq) {
int crc = 0x00;
for (byte b : seq) {
crc ^= (b & 0xFF);
for (int i = 0; i < 8; i++) {
if ((crc & 0x80) != 0) {
crc = (crc << 1) ^ 0x07;
} else {
crc <<= 1;
}
}
}
return (byte) (crc & 0xFF);
}
private byte[] encodePictureToPrinterCommands(BitSet imageBits, int imageWidth) {
final int maxOctets = imageWidth / 8;
final ByteBuffer result = ByteBuffer.allocate(imageBits.length() + 1000);
for (int row = 0; row < (imageBits.length() / imageWidth); row++) {
boolean rowRLE = useRunLengthEncoding;
final ByteBuffer rowContent = ByteBuffer.allocate(maxOctets);
final BitSet rowBits = imageBits.get(row * imageWidth, row * imageWidth + imageWidth);
if (useRunLengthEncoding) {
try {
rowContent.put(EncodingUtils.encodeRowRLE(rowBits, maxOctets));
} catch (BufferOverflowException e) {
//if compressed data is bigger than uncompressed, we use uncompressed
rowRLE = false;
rowContent.clear();
rowContent.put(EncodingUtils.encodeRowPlain(rowBits));
}
} else {
rowContent.put(EncodingUtils.encodeRowPlain(rowBits));
}
result.put(rowRLE ? PrinterCommand.printRowRLE.message(byteBufferToArray(rowContent)) :
PrinterCommand.printRowPlain.message(byteBufferToArray(rowContent))
);
}
return byteBufferToArray(result);
}
@Override
public boolean useAutoConnect() {
return false;
}
public void printImage(Bitmap bitmap, boolean applyDithering) {
if (!canPrint) {
LOG.error("Printer cannot print.");
return;
}
final BitmapToBitSet bitmapToBitSet = new BitmapToBitSet(bitmap);
final ByteBuffer result = ByteBuffer.allocate(500 + (Math.round(bitmap.getWidth() / 8) + 8) * bitmap.getHeight()); //TODO: approximate better?
result.put(PrinterCommand.intensity(PRINT_INTENSITY));
result.put(PrinterCommand.printSpeed.message(new byte[]{PRINT_SPEED}));
result.put(PrinterCommand.printType.message(new byte[]{PRINT_TYPE}));
result.put(PrinterCommand.quality(5));
result.put(encodePictureToPrinterCommands(bitmapToBitSet.toBlackAndWhite(applyDithering), bitmapToBitSet.getWidth()));
result.put(PrinterCommand.feedPaper(72));
result.put(PrinterCommand.getDevState.message(new byte[]{0})); //to get back the current device status
send("Print...", byteBufferToArray(result));
}
@Override
public boolean onCharacteristicChanged(BluetoothGatt gatt, BluetoothGattCharacteristic characteristic, byte[] value) {
if (super.onCharacteristicChanged(gatt, characteristic, value)) {
return true;
}
ByteBuffer data = ByteBuffer.wrap(value);
if (data.get() != PrinterCommand.preamble[0] || data.get() != PrinterCommand.preamble[1]) {
LOG.error("Incoming message does not start with preamble: {}", data.array());
return true;
}
final int cmdId = data.get();
PrinterCommand command = PrinterCommand.fromId(cmdId);
if (command == null) {
LOG.error("Unkwnown incoming command{} in message: {}", cmdId, GB.hexdump(data.array()));
return true;
}
int response = data.get();
if (0x01 != response) {
LOG.error("Incoming message is not a response: {}", GB.hexdump(data.array()));
return true;
}
int payloadLength = data.get();
if (data.remaining() < payloadLength + 3) {
LOG.error("Incoming message is incomplete: {}", GB.hexdump(data.array()));
return true;
}
data.get();
byte[] payload = new byte[payloadLength];
data.get(payload);
int checksum = data.get();
if (checksum != getCrc8(payload)) {
LOG.error("Incoming message CRC error does not match: {}", GB.hexdump(data.array()));
return true;
}
if (((byte) 0xff) != data.get()) {
LOG.error("Incoming message does not have correct terminator: {}", GB.hexdump(data.array()));
return true;
}
LOG.debug("Incoming message: {}", GB.hexdump(data.array()));
final ByteBuffer payloadBB = ByteBuffer.wrap(payload);
switch (command) {
case getDevInfo:
if (payloadBB.get() != 0) {
LOG.info("Setting the device supports Run Length Encoding");
this.useRunLengthEncoding = true;
} else {
LOG.info("Device does not support Run Length Encoding");
}
payloadBB.get();//unk
payloadBB.get();//unk
byte[] version = new byte[payloadBB.remaining()];
payloadBB.get(version);
String versionString = new String(version);
LOG.info("Device version(??): {}", versionString);
return true;
case getDevState:
//5178A3010300 000E28 0EFF observed values
//5178A3010300 000226 D8FF
//5178A3010300 000526 B3FF
//5178A3010300 000C25 07FF
//5178A3010300 000D24 15FF
//5178A3010300 080D23 51FF (here starts blinking red)
int status = payloadBB.get();
if (status == 0) {
LOG.info("Enabling print");
canPrint = true;
} else if (status == 1) {
GB.toast("Printer out of paper", Toast.LENGTH_LONG, GB.ERROR);
canPrint = false;
} else if (status == 8) {
GB.toast("Printer battery almost empty", Toast.LENGTH_LONG, GB.ERROR);
canPrint = true;
} else {
LOG.warn("Status != 0, disabling print functionality. Status: {}", status);
canPrint = false;
}
if (payloadBB.hasRemaining()) {
int unk = payloadBB.get();
int battery = payloadBB.get(); //possibly
final GBDeviceEventBatteryInfo batteryCmd = new GBDeviceEventBatteryInfo();
batteryCmd.level = ((short) battery - 35) * 18 + 10;
handleGBDeviceEvent(batteryCmd);
}
return true;
default:
LOG.info("Incoming message: {}", GB.hexdump(data.array()));
}
return true;
}
protected TransactionBuilder initializeDevice(TransactionBuilder builder) {
builder.add(new SetDeviceStateAction(getDevice(), GBDevice.State.INITIALIZING, getContext()));
builder.setCallback(this);
getDevice().setFirmwareVersion("N/A");
getDevice().setFirmwareVersion2("N/A");
builder.requestMtu(512);
builder.notify(getCharacteristic(notifCharUUID), true);
builder.write(getCharacteristic(writeCharUUID), PrinterCommand.getDevInfo.message(new byte[]{0x00}));
builder.write(getCharacteristic(writeCharUUID), PrinterCommand.getDevState.message(new byte[]{0x00}));
builder.add(new SetDeviceStateAction(getDevice(), GBDevice.State.INITIALIZED, getContext()));
LOG.debug("Connected to: " + gbDevice.getName());
return builder;
}
public void send(String taskname, byte[] command) {
TransactionBuilder builder = new TransactionBuilder(taskname);
builder.writeChunkedData(getCharacteristic(writeCharUUID), command, 123);
builder.queue(getQueue());
}
@Override
public void onTestNewFunction() {
// send(PrinterCommand.feedPaper.message(new byte[]{1}));
sendTestPrint();
}
private void sendTestPrint() {
Bitmap bitmap = BitmapFactory.decodeResource(GBApplication.app().getResources(), R.drawable.ic_launcher);
Bitmap newBitmap = Bitmap.createBitmap(IMAGE_WIDTH, bitmap.getHeight(), Bitmap.Config.ARGB_8888);
Canvas canvas = new Canvas(newBitmap);
canvas.drawColor(Color.WHITE);
// Calculate the top-left coordinates for centering the original Bitmap
int posLeft = 0;
int posMiddle = (newBitmap.getWidth() - bitmap.getWidth()) / 2;
int posRight = newBitmap.getWidth() - bitmap.getWidth();
int left = posRight;
int top = (newBitmap.getHeight() - bitmap.getHeight()) / 2;
canvas.drawBitmap(bitmap, left, top, new Paint());
canvas.drawText(ZonedDateTime.now().toString(), newBitmap.getHeight(), 0, new Paint());
printImage(newBitmap, false);
}
@Override
public void onSendConfiguration(String config) {
switch (config) {
case DeviceSettingsPreferenceConst.PREF_BATTERY_POLLING_ENABLE:
if (!GBApplication.getDevicePrefs(gbDevice).getBatteryPollingEnabled()) {
stopBatteryRunnerDelayed();
break;
}
// Fall through if enabled
case DeviceSettingsPreferenceConst.PREF_BATTERY_POLLING_INTERVAL:
if (!startBatteryRunnerDelayed()) {
GB.toast(getContext(), R.string.battery_polling_failed_start, Toast.LENGTH_SHORT, GB.ERROR);
LOG.error("Failed to start the battery polling");
}
break;
}
}
public boolean startBatteryRunnerDelayed() {
int interval_minutes = GBApplication.getDevicePrefs(gbDevice).getBatteryPollingIntervalMinutes();
int interval = interval_minutes * 60 * 1000;
LOG.debug("Starting battery runner delayed by {} ({} minutes)", interval, interval_minutes);
handler.removeCallbacks(batteryRunner);
return handler.postDelayed(batteryRunner, interval);
}
public void stopBatteryRunnerDelayed() {
LOG.debug("Stopping battery runner delayed");
handler.removeCallbacks(batteryRunner);
}
@Override
public void onInstallApp(Uri uri) {
//we just abuse the install functionality to start our own activity
}
private enum PrinterCommand {
feedPaper(0xa1),
printRowPlain(0xa2),
getDevState(0xa3),
quality(0xa4),
printHeadSetup(0xa6), //possibly
getDevInfo(0xa8),
intensity(0xaf),
printSpeed(0xbd),
printType(0xbe),
printRowRLE(0xbf),
;
private final int cmdId;
public static final byte[] preamble = new byte[]{0x51, 0x78};
//Following messages observed in dumps before and after the actual print, seem to make no difference hence we're not sending them ATM
public static final byte[] setupPrintBegin = printHeadSetup.message(new byte[]{-86, 85, 23, 56, 68, 95, 95, 95, 68, 56, 44});
public static final byte[] setupPrintEnd = printHeadSetup.message(new byte[]{-86, 85, 23, 0, 0, 0, 0, 0, 0, 0, 23});
PrinterCommand(int cmdId) {
this.cmdId = cmdId;
}
public static PrinterCommand fromId(int id) {
for (final PrinterCommand printerCommand : PrinterCommand.values()) {
if (printerCommand.cmdId == (id & 0xff)) {
return printerCommand;
}
}
return null;
}
private static byte[] feedPaper(final int lines) {
return feedPaper.message(new byte[]{(byte) (lines & 0xff), (byte) ((lines >> 8) & 0xFF)});
}
private static byte[] intensity(final int value) {
return intensity.message(new byte[]{(byte) (value & 0xff), (byte) ((value >> 8) & 0xFF)});
}
private static byte[] quality(final int level) {
if (level < 2)
return quality.message(new byte[]{49});
if (level > 4)
return quality.message(new byte[]{53});
return quality.message(new byte[]{(byte) (48 + level)});
}
public byte[] message(byte[] payload) {
final ByteBuffer buf = ByteBuffer.allocate(preamble.length + 4 + payload.length + 1 + 1);
buf.put(preamble);
buf.put((byte) this.cmdId);
buf.put((byte) 0x00); //0x00 phone->printer (request), 0x01 printer->phone (response)
buf.put((byte) payload.length);
buf.put((byte) 0x00);
buf.put(payload);
buf.put((byte) getCrc8(payload));
buf.put((byte) 0xff);
return buf.array();
}
}
private static class EncodingUtils {
private static ByteBuffer encodeRowRLE(BitSet binaryData, int maxEncodedLength) throws BufferOverflowException {
final ByteBuffer encodedData = ByteBuffer.allocate(maxEncodedLength);
boolean currentValue = binaryData.get(0);
int count = 0;
for (int i = 0; i < binaryData.length(); i++) {
boolean bit = binaryData.get(i);
if (bit == currentValue) {
count++;
if (count == 128) {
encodedData.put(rleCountToByte(currentValue, 127));
count = 1;
}
} else {
encodedData.put(rleCountToByte(currentValue, count));
currentValue = bit;
count = 1;
}
}
encodedData.put(rleCountToByte(currentValue, count));
encodedData.flip();
return encodedData;
}
private static byte rleCountToByte(boolean value, int count) {
return (byte) ((value ? 1 : 0) << 7 | (count & 0x7F));
}
private static byte readBitsAsInt(BitSet bitSet) {
int result = 0;
for (int i = 7; i >= 0; i--) {
if (bitSet.get(i)) {
result |= (1 << (i));
}
}
return (byte) (result & 0xff);
}
private static ByteBuffer encodeRowPlain(BitSet rowBits) {
final ByteBuffer rowContent = ByteBuffer.allocate(rowBits.size() / 8);
for (int octetIdx = 0; octetIdx < rowBits.size() / 8; octetIdx++) {
final int bitIdx = octetIdx * 8;
rowContent.put(readBitsAsInt(rowBits.get(bitIdx, bitIdx + 8)));
}
rowContent.flip();
return rowContent;
}
}
}
@@ -0,0 +1,51 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="45dp"
android:height="45dp"
android:viewportWidth="28.74"
android:viewportHeight="28.74">
<path
android:fillColor="#1f7fdb"
android:pathData="M3.871,3.877L24.796,3.877A0.947,0.947 0,0 1,25.744 4.824L25.744,24.834A0.947,0.947 0,0 1,24.796 25.782L3.871,25.782A0.947,0.947 0,0 1,2.924 24.834L2.924,4.824A0.947,0.947 0,0 1,3.871 3.877z"
android:strokeWidth="3.57115"
android:strokeColor="#00000000" />
<path
android:fillColor="#4dabf5"
android:pathData="M3.879,3.035L24.804,3.035A0.947,0.947 0,0 1,25.751 3.982L25.751,23.992A0.947,0.947 0,0 1,24.804 24.94L3.879,24.94A0.947,0.947 0,0 1,2.932 23.992L2.932,3.982A0.947,0.947 0,0 1,3.879 3.035z"
android:strokeWidth="3.57115"
android:strokeColor="#00000000" />
<path
android:fillColor="#2196f3"
android:pathData="M3.871,3.413L24.796,3.413A0.947,0.947 0,0 1,25.743 4.36L25.743,24.37A0.947,0.947 0,0 1,24.796 25.318L3.871,25.318A0.947,0.947 0,0 1,2.924 24.37L2.924,4.36A0.947,0.947 0,0 1,3.871 3.413z"
android:strokeWidth="3.57115"
android:strokeColor="#00000000" />
<path
android:fillColor="#00000000"
android:pathData="M10.068,4.87L18.673,4.87A5.729,5.729 0,0 1,24.401 10.599L24.401,18.141A5.729,5.729 0,0 1,18.673 23.87L10.068,23.87A5.729,5.729 0,0 1,4.339 18.141L4.339,10.599A5.729,5.729 0,0 1,10.068 4.87z"
android:strokeWidth="1.2"
android:strokeColor="#ffffff"
android:strokeLineCap="round"
android:strokeLineJoin="round" />
<path
android:fillColor="#00000000"
android:fillType="evenOdd"
android:pathData="M4.284,14.928H24.456"
android:strokeWidth="0.524934"
android:strokeColor="#ffffff"
android:strokeLineCap="butt"
android:strokeLineJoin="miter" />
<path
android:fillAlpha="0.846154"
android:fillColor="#00000000"
android:pathData="M14.37,19.524m-2.254,0a2.254,2.254 0,1 1,4.507 0a2.254,2.254 0,1 1,-4.507 0"
android:strokeWidth="1.04987"
android:strokeColor="#ffffff"
android:strokeLineCap="round"
android:strokeLineJoin="round" />
</vector>
@@ -0,0 +1,47 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="45dp"
android:height="45dp"
android:viewportWidth="28.74"
android:viewportHeight="28.74">
<path
android:fillColor="#7a7a7a"
android:pathData="M3.871 3.877h20.925a0.947 0.947 0 0 1 0.948 0.947v20.01a0.947 0.947 0 0 1-0.948 0.948H3.871a0.947 0.947 0 0 1-0.947-0.948V4.824a0.947 0.947 0 0 1 0.947-0.947z"
android:strokeWidth="3.57115173" />
<path
android:fillAlpha="0.9411765"
android:fillColor="#9f9f9f"
android:pathData="M3.879 3.035h20.925a0.947 0.947 0 0 1 0.947 0.947v20.01a0.947 0.947 0 0 1-0.947 0.948H3.88a0.947 0.947 0 0 1-0.947-0.948V3.982A0.947 0.947 0 0 1 3.88 3.035z"
android:strokeWidth="3.57115173" />
<path
android:fillColor="#8a8a8a"
android:pathData="M3.871 3.413h20.925a0.947 0.947 0 0 1 0.948 0.947v20.01a0.947 0.947 0 0 1-0.948 0.948H3.871a0.947 0.947 0 0 1-0.947-0.948V4.36A0.947 0.947 0 0 1 3.87 3.413z"
android:strokeWidth="3.57115173" />
<path
android:fillColor="#00000000"
android:pathData="M10.068,4.87L18.673,4.87A5.729,5.729 0,0 1,24.401 10.599L24.401,18.141A5.729,5.729 0,0 1,18.673 23.87L10.068,23.87A5.729,5.729 0,0 1,4.339 18.141L4.339,10.599A5.729,5.729 0,0 1,10.068 4.87z"
android:strokeWidth="1.2"
android:strokeColor="#ffffff"
android:strokeLineCap="round"
android:strokeLineJoin="round" />
<path
android:fillColor="#00000000"
android:fillType="evenOdd"
android:pathData="M4.284,14.928H24.456"
android:strokeWidth="0.524934"
android:strokeColor="#ffffff"
android:strokeLineCap="butt"
android:strokeLineJoin="miter" />
<path
android:fillAlpha="0.846154"
android:fillColor="#00000000"
android:pathData="M14.37,19.524m-2.254,0a2.254,2.254 0,1 1,4.507 0a2.254,2.254 0,1 1,-4.507 0"
android:strokeWidth="1.04987"
android:strokeColor="#ffffff"
android:strokeLineCap="round"
android:strokeLineJoin="round" />
</vector>
@@ -0,0 +1,66 @@
<?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"
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent"
android:layout_height="match_parent">
<Button
android:id="@+id/sendToPrinterButton"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginStart="24dp"
android:layout_marginTop="8dp"
android:layout_marginEnd="24dp"
android:text="@string/activity_print__image_print_button"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintHorizontal_bias="0.0"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@+id/warning_devices" />
<ImageView
android:id="@+id/convertedImage"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginTop="20dp"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintHorizontal_bias="0.498"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@+id/switchDithering" />
<ImageView
android:id="@+id/incomingImage"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginStart="24dp"
android:layout_marginTop="16dp"
android:layout_marginEnd="24dp"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent" />
<TextView
android:id="@+id/warning_devices"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="8dp"
android:layout_marginBottom="8dp"
android:text=""
app:layout_constraintBottom_toTopOf="@+id/sendToPrinterButton"
app:layout_constraintTop_toBottomOf="@+id/convertedImage"
tools:layout_editor_absoluteX="0dp" />
<com.google.android.material.materialswitch.MaterialSwitch
android:id="@+id/switchDithering"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginStart="24dp"
android:layout_marginTop="8dp"
android:layout_marginEnd="24dp"
android:text="@string/activity_print_image_apply_dithering_switch"
app:layout_constraintBottom_toTopOf="@+id/warning_devices"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@+id/incomingImage" />
</androidx.constraintlayout.widget.ConstraintLayout>
+3
View File
@@ -2081,6 +2081,7 @@
<string name="devicetype_bohemic_smart_bracelet">Bohemic Smart Bracelet</string>
<string name="devicetype_vivitar_hr_bp_monitor_activity_tracker">Vivitar HR &amp; BP Monitor Activity Tracker</string>
<string name="devicetype_hama_fit6900">Hama Fit6900</string>
<string name="devicetype_generic_thermal_printer">Thermal Printer</string>
<!-- Menus on the smart device -->
<string name="menuitem_nothing">Nothing</string>
<string name="menuitem_status">Status</string>
@@ -4102,4 +4103,6 @@
<string name="pref_atc_ble_oepl_ble_adv_interval_title">BLE Advertising Interval</string>
<string name="pref_atc_ble_oepl_oepl_protocol_enable_title">Enable OEPL protocol</string>
<string name="pref_atc_ble_oepl_oepl_protocol_enable_summary">Disable this if only using BLE to save power</string>
<string name="activity_print__image_print_button">Print</string>
<string name="activity_print_image_apply_dithering_switch">Apply Dithering</string>
</resources>