Printer: Use external library for dithering, cache file and fix image preview

Use a cache file in the cache directory to hold the bitmap, this way we can avoid passing huge byte arrays around for printing.

Also:
allow to open files from the SendToPrinterActivity if it's launched from the device card
fix the encoding of rows when RLE is not in use
show the previews in full width in the activity
This commit is contained in:
Daniele Gobbetti
2025-07-06 18:08:46 +02:00
committed by daniele
parent b785bda9d1
commit 744d17b45f
4 changed files with 176 additions and 171 deletions
@@ -1,5 +1,6 @@
package nodomain.freeyourgadget.gadgetbridge.devices.thermalprinter;
import android.app.Activity;
import android.content.Intent;
import android.graphics.Bitmap;
import android.graphics.BitmapFactory;
@@ -10,6 +11,8 @@ import android.widget.Button;
import android.widget.ImageView;
import android.widget.TextView;
import androidx.activity.result.ActivityResultLauncher;
import androidx.activity.result.contract.ActivityResultContracts;
import androidx.localbroadcastmanager.content.LocalBroadcastManager;
import com.google.android.material.materialswitch.MaterialSwitch;
@@ -17,10 +20,13 @@ 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.File;
import java.io.FileOutputStream;
import java.io.IOException;
import java.io.InputStream;
import java.util.List;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import nodomain.freeyourgadget.gadgetbridge.GBApplication;
import nodomain.freeyourgadget.gadgetbridge.R;
@@ -33,19 +39,30 @@ import nodomain.freeyourgadget.gadgetbridge.util.UriHelper;
public class SendToPrinterActivity extends AbstractGBActivity {
private static final Logger LOG = LoggerFactory.getLogger(SendToPrinterActivity.class);
private final ExecutorService executor = Executors.newSingleThreadExecutor();
private Bitmap bitmap;
private ImageView previewImage;
private ImageView incomingImage;
private MaterialSwitch dithering;
private BitmapToBitSet bitmapToBitSet;
private File printPicture = null;
ActivityResultLauncher<Intent> pickImageLauncher = registerForActivityResult(
new ActivityResultContracts.StartActivityForResult(),
result -> {
if (result.getResultCode() == Activity.RESULT_OK) {
Uri uri = result.getData().getData();
processUriAsync(uri);
}
}
);
@Override
protected void onCreate(Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
setContentView(R.layout.activity_print_image);
ImageView incomingImage = findViewById(R.id.incomingImage);
incomingImage = findViewById(R.id.incomingImage);
previewImage = findViewById(R.id.convertedImage);
Button sendToPrinter = findViewById(R.id.sendToPrinterButton);
dithering = findViewById(R.id.switchDithering);
@@ -72,61 +89,84 @@ public class SendToPrinterActivity extends AbstractGBActivity {
@Override
public void onClick(View view) {
LOG.info("dithering is : {}", dithering.isChecked());
bitmapToBitSet.toBlackAndWhite(dithering.isChecked());
previewImage.setImageBitmap(bitmapToBitSet.getPreview());
updatePreview();
}
});
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();
}
});
printPicture = new File(getCacheDir(), "temp_bitmap.png");
final Uri uri = getIntent().getParcelableExtra(GenericThermalPrinterSupport.INTENT_EXTRA_URI);
if (uri != null) {
processUriAsync(uri);
} else {
Intent intent = new Intent(Intent.ACTION_OPEN_DOCUMENT);
intent.addCategory(Intent.CATEGORY_OPENABLE);
intent.setType("image/*");
pickImageLauncher.launch(intent);
}
}
private void processUriAsync(Uri uri) {
executor.execute(() -> {
cleanUpPrintPictureCache();
try {
UriHelper uriHelper = UriHelper.get(uri, getApplicationContext());
Bitmap incoming;
try (InputStream stream = uriHelper.openInputStream()) {
incoming = BitmapFactory.decodeStream(stream);
}
Bitmap scaledBitmap;
if (incoming.getWidth() > 384) {
float aspectRatio = (float) incoming.getHeight() / incoming.getWidth();
scaledBitmap = Bitmap.createScaledBitmap(incoming, 384, (int) (384 * aspectRatio), true);
} else {
scaledBitmap = incoming;
}
try (FileOutputStream out = new FileOutputStream(printPicture)) {
scaledBitmap.compress(Bitmap.CompressFormat.PNG, 100, out);
} catch (IOException e) {
LOG.error("Failed to save picture to print: {}", e.getMessage());
}
runOnUiThread(() -> {
bitmap = scaledBitmap;
updatePreview();
});
} catch (IOException e) {
LOG.error("Failed to load or process bitmap", e);
}
});
}
private void updatePreview() {
incomingImage.setImageBitmap(bitmap);
bitmapToBitSet = new BitmapToBitSet(bitmap);
bitmapToBitSet.toBlackAndWhite(dithering.isChecked());
previewImage.setImageBitmap(bitmapToBitSet.getPreview());
}
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_BITMAP_CACHE_FILE_PATH, printPicture.getAbsolutePath());
intent.putExtra(GenericThermalPrinterSupport.INTENT_EXTRA_APPLY_DITHERING, dithering.isChecked());
LocalBroadcastManager.getInstance(this).sendBroadcast(intent);
}
private void cleanUpPrintPictureCache() {
if (printPicture == null)
return;
printPicture.delete();
}
}
@@ -3,19 +3,16 @@ package nodomain.freeyourgadget.gadgetbridge.service.devices.thermalprinter;
import android.graphics.Bitmap;
import android.graphics.Color;
import com.android.nQuant.PnnLABQuantizer;
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;
@@ -24,12 +21,12 @@ public class BitmapToBitSet {
this.bwPixels = new BitSet(this.width * this.height);
}
public int getThreshold() {
return threshold;
public int getHeight() {
return height;
}
public void setThreshold(int threshold) {
this.threshold = threshold;
public int getWidth() {
return width;
}
public Bitmap getPreview() {
@@ -48,58 +45,16 @@ public class BitmapToBitSet {
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];
final PnnLABQuantizer pnnLABQuantizer = new PnnLABQuantizer(bitmap);
pnnLABQuantizer.convert(2, applyDithering).first.getPixels(pixels, 0, width, 0, 0, width, height);
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);
}
}
}
for (int i = 0; i < pixels.length; i++) {
if (pixels[i] == Color.BLACK)
bwPixels.set(i);
}
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));
}
}
@@ -37,7 +37,6 @@ import nodomain.freeyourgadget.gadgetbridge.activities.devicesettings.DeviceSett
import nodomain.freeyourgadget.gadgetbridge.deviceevents.GBDeviceEventBatteryInfo;
import nodomain.freeyourgadget.gadgetbridge.impl.GBDevice;
import nodomain.freeyourgadget.gadgetbridge.model.NotificationSpec;
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;
@@ -47,7 +46,7 @@ public class GenericThermalPrinterSupport extends AbstractBTLESingleDeviceSuppor
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_BITMAP_CACHE_FILE_PATH = "bitmap_cache_file_path";
public static final String INTENT_EXTRA_APPLY_DITHERING = "apply_dithering";
public static final UUID discoveryService = UUID.fromString("0000af30-0000-1000-8000-00805f9b34fb");
@@ -78,12 +77,19 @@ public class GenericThermalPrinterSupport extends AbstractBTLESingleDeviceSuppor
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);
String picturePath = intent.getStringExtra(INTENT_EXTRA_BITMAP_CACHE_FILE_PATH);
if (picturePath.isEmpty()) {
LOG.error("Cannot print: picturePath is empty");
return;
}
Bitmap bitmap = BitmapFactory.decodeFile(picturePath);
if (bitmap == null) {
LOG.error("Cannot print: bitmap is null");
return;
}
boolean dither = intent.getBooleanExtra(INTENT_EXTRA_APPLY_DITHERING, false);
printImage(bitmap, dither);
}
}
};
@@ -164,17 +170,17 @@ public class GenericThermalPrinterSupport extends AbstractBTLESingleDeviceSuppor
return (byte) (crc & 0xFF);
}
private byte[] encodePictureToPrinterCommands(BitSet imageBits, int imageWidth) {
final int maxOctets = imageWidth / 8;
private byte[] encodePictureToPrinterCommands(BitSet imageBits, int imageWidth, int imageHeight) {
final int maxOctetsPerRow = (imageWidth + 7) / 8; //always round up
final ByteBuffer result = ByteBuffer.allocate(imageBits.length() + 1000);
for (int row = 0; row < (imageBits.length() / imageWidth); row++) {
for (int row = 0; row < imageHeight; row++) {
boolean rowRLE = useRunLengthEncoding;
final ByteBuffer rowContent = ByteBuffer.allocate(maxOctets);
final ByteBuffer rowContent = ByteBuffer.allocate(maxOctetsPerRow);
final BitSet rowBits = imageBits.get(row * imageWidth, row * imageWidth + imageWidth);
if (useRunLengthEncoding) {
try {
rowContent.put(EncodingUtils.encodeRowRLE(rowBits, maxOctets));
rowContent.put(EncodingUtils.encodeRowRLE(rowBits, maxOctetsPerRow));
} catch (BufferOverflowException e) {
//if compressed data is bigger than uncompressed, we use uncompressed
rowRLE = false;
@@ -204,14 +210,14 @@ public class GenericThermalPrinterSupport extends AbstractBTLESingleDeviceSuppor
}
final BitmapToBitSet bitmapToBitSet = new BitmapToBitSet(bitmap);
final ByteBuffer result = ByteBuffer.allocate(500 + (Math.round(bitmap.getWidth() / 8) + 8) * bitmap.getHeight()); //TODO: approximate better?
final ByteBuffer result = ByteBuffer.allocate(500 + (int) (Math.ceil(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(encodePictureToPrinterCommands(bitmapToBitSet.toBlackAndWhite(applyDithering), bitmapToBitSet.getWidth(), bitmapToBitSet.getHeight()));
result.put(PrinterCommand.feedPaper(72));
result.put(PrinterCommand.getDevState.message(new byte[]{0})); //to get back the current device status
@@ -526,8 +532,8 @@ public class GenericThermalPrinterSupport extends AbstractBTLESingleDeviceSuppor
}
private static ByteBuffer encodeRowPlain(BitSet rowBits) {
final ByteBuffer rowContent = ByteBuffer.allocate(rowBits.size() / 8);
for (int octetIdx = 0; octetIdx < rowBits.size() / 8; octetIdx++) {
final ByteBuffer rowContent = ByteBuffer.allocate((rowBits.length() + 7) / 8);
for (int octetIdx = 0; octetIdx < (rowBits.length() + 7) / 8; octetIdx++) {
final int bitIdx = octetIdx * 8;
rowContent.put(readBitsAsInt(rowBits.get(bitIdx, bitIdx + 8)));
}
@@ -17,63 +17,67 @@
<androidx.constraintlayout.widget.ConstraintLayout
android:layout_width="match_parent"
android:layout_height="wrap_content">
<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" />
<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/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" />
<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"
android:adjustViewBounds="true"
android:scaleType="fitCenter"
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" />
<ImageView
android:id="@+id/convertedImage"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginStart="24dp"
android:layout_marginTop="16dp"
android:layout_marginEnd="24dp"
android:adjustViewBounds="true"
android:scaleType="fitCenter"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@+id/switchDithering" />
<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" />
<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_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_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@+id/incomingImage" />
</androidx.constraintlayout.widget.ConstraintLayout>