Add pdf compressor

This commit is contained in:
2026-06-12 16:12:23 +07:00
parent 2e45dfafbb
commit 30d84eef72
62 changed files with 2120 additions and 319 deletions
+23 -3
View File
@@ -20,18 +20,29 @@ set(IMAGE_COMPRESS_INCLUDE_DIR
set(IMAGE_COMPRESS_LIB_DIR
${IMAGE_COMPRESS_ROOT}/lib)
set(PDF_COMPRESS_ROOT
${CMAKE_SOURCE_DIR}/third_party/pdf-android/${ANDROID_ABI})
set(PDF_COMPRESS_INCLUDE_DIR
${PDF_COMPRESS_ROOT}/include)
set(PDF_COMPRESS_LIB_DIR
${PDF_COMPRESS_ROOT}/lib)
add_library(kcompressor SHARED
native_compressor.cpp
compressor_common.cpp
video_compressor.cpp
image_compressor.cpp)
image_compressor.cpp
pdf_compressor.cpp)
target_include_directories(kcompressor PRIVATE
${CMAKE_CURRENT_SOURCE_DIR}
${FFMPEG_INCLUDE_DIR}
${FFMPEG_INCLUDE_DIR}/x264
${FFMPEG_INCLUDE_DIR}/x265
${IMAGE_COMPRESS_INCLUDE_DIR})
${IMAGE_COMPRESS_INCLUDE_DIR}
${PDF_COMPRESS_INCLUDE_DIR})
function(import_static_required target_name lib_dir lib_name)
set(full_path ${lib_dir}/${lib_name})
@@ -65,11 +76,17 @@ import_static_required(imagequant ${IMAGE_COMPRESS_LIB_DIR} libimagequant.a)
import_static_required(oxipng_ffi ${IMAGE_COMPRESS_LIB_DIR} liboxipng_ffi.a)
import_static_required(kimage_png ${IMAGE_COMPRESS_LIB_DIR} libkimage_png.a)
import_static_required(qpdf ${PDF_COMPRESS_LIB_DIR} libqpdf.a)
import_static_required(pdf_zopfli ${PDF_COMPRESS_LIB_DIR} libzopfli.a)
import_static_required(pdf_zlib ${PDF_COMPRESS_LIB_DIR} libz.a)
target_compile_features(kcompressor PRIVATE cxx_std_17)
target_compile_options(kcompressor PRIVATE
-Wall
-Wextra
-fexceptions
-frtti
-Wno-unused-parameter
-Wno-deprecated-declarations)
@@ -86,6 +103,10 @@ target_link_libraries(kcompressor
x264
x265
qpdf
pdf_zopfli
pdf_zlib
mozjpeg_turbojpeg
mozjpeg_jpeg
webp
@@ -104,7 +125,6 @@ target_link_libraries(kcompressor
mediandk
android
log
z
m
dl
atomic
+33
View File
@@ -5,6 +5,7 @@
#include "compressor_common.h"
#include "video_compressor.h"
#include "image_compressor.h"
#include "pdf_compressor.h"
extern "C" {
#include <libavcodec/avcodec.h>
@@ -362,3 +363,35 @@ Java_com_kikyps_kcompressor_NativeCompressor_compressImageFd(JNIEnv *env,
return static_cast<jint>(ret);
}
extern "C"
JNIEXPORT jint JNICALL
Java_com_kikyps_kcompressor_NativeCompressor_checkPdfPasswordStatus(JNIEnv *env,
jclass,
jint inputFd,
jstring password) {
std::string password_str = jstring_to_string(env, password, "");
return check_pdf_password_status_fd_impl(env, inputFd, password_str);
}
extern "C"
JNIEXPORT jint JNICALL
Java_com_kikyps_kcompressor_NativeCompressor_compressPdfFdToPath(JNIEnv *env,
jclass,
jint inputFd,
jstring outputPath,
jstring password,
jboolean useZopfli,
jobject callback) {
std::string output_path = jstring_to_string(env, outputPath, "");
std::string password_str = jstring_to_string(env, password, "");
return compress_pdf_fd_to_path_impl(
env,
inputFd,
output_path,
password_str,
useZopfli == JNI_TRUE,
callback
);
}
+593
View File
@@ -0,0 +1,593 @@
#include "pdf_compressor.h"
#include "compressor_common.h"
#include <cerrno>
#include <cstdint>
#include <cstdlib>
#include <cstring>
#include <string>
#include <vector>
#include <sstream>
#include <chrono>
#include <android/log.h>
#include <fcntl.h>
#include <unistd.h>
#include <sys/stat.h>
#include <sys/types.h>
#include <qpdf/qpdfjob-c.h>
#define LOG_TAG "KCompressorPDF"
#define LOGD(...) __android_log_print(ANDROID_LOG_DEBUG, LOG_TAG, __VA_ARGS__)
#define LOGI(...) __android_log_print(ANDROID_LOG_INFO, LOG_TAG, __VA_ARGS__)
#define LOGW(...) __android_log_print(ANDROID_LOG_WARN, LOG_TAG, __VA_ARGS__)
#define LOGE(...) __android_log_print(ANDROID_LOG_ERROR, LOG_TAG, __VA_ARGS__)
static std::string make_proc_fd_path(int fd) {
return std::string("/proc/self/fd/") + std::to_string(fd);
}
static std::string make_temp_input_path(const std::string &output_path) {
return output_path + ".input.pdf";
}
static int64_t fd_size(int fd) {
if (fd < 0) return -1;
struct stat st {};
if (fstat(fd, &st) == 0 && st.st_size > 0) {
return static_cast<int64_t>(st.st_size);
}
off64_t current = lseek64(fd, 0, SEEK_CUR);
if (current < 0) return -1;
off64_t end = lseek64(fd, 0, SEEK_END);
if (end < 0) {
lseek64(fd, current, SEEK_SET);
return -1;
}
lseek64(fd, current, SEEK_SET);
return static_cast<int64_t>(end);
}
static int64_t path_size(const std::string &path) {
if (path.empty()) return -1;
struct stat st {};
if (stat(path.c_str(), &st) == 0 && st.st_size > 0) {
return static_cast<int64_t>(st.st_size);
}
return -1;
}
static bool path_exists(const std::string &path) {
struct stat st {};
return !path.empty() && stat(path.c_str(), &st) == 0;
}
static std::string parent_dir_of(const std::string &path) {
size_t pos = path.find_last_of('/');
if (pos == std::string::npos || pos == 0) {
return std::string();
}
return path.substr(0, pos);
}
static bool ensure_parent_dir_exists(const std::string &path) {
std::string parent = parent_dir_of(path);
if (parent.empty()) {
return true;
}
struct stat st {};
if (stat(parent.c_str(), &st) == 0 && S_ISDIR(st.st_mode)) {
return true;
}
LOGE("parent dir does not exist: %s errno=%d %s",
parent.c_str(), errno, strerror(errno));
return false;
}
static void safe_unlink(const std::string &path) {
if (!path.empty()) {
unlink(path.c_str());
}
}
static bool copy_fd_to_path(int input_fd, const std::string &output_path) {
if (input_fd < 0 || output_path.empty()) {
LOGE("copy_fd_to_path invalid input_fd=%d output_path_empty=%s",
input_fd, output_path.empty() ? "true" : "false");
return false;
}
if (!ensure_parent_dir_exists(output_path)) {
return false;
}
int in_fd = dup(input_fd);
if (in_fd < 0) {
LOGE("copy_fd_to_path dup failed errno=%d %s", errno, strerror(errno));
return false;
}
if (lseek64(in_fd, 0, SEEK_SET) < 0) {
LOGW("copy_fd_to_path input fd not seekable at start: errno=%d %s", errno, strerror(errno));
}
int out_fd = open(output_path.c_str(), O_CREAT | O_TRUNC | O_WRONLY | O_CLOEXEC, 0600);
if (out_fd < 0) {
LOGE("copy_fd_to_path open output failed path=%s errno=%d %s",
output_path.c_str(), errno, strerror(errno));
close(in_fd);
return false;
}
uint8_t buffer[128 * 1024];
bool ok = true;
int64_t total = 0;
while (true) {
if (is_cancel_requested()) {
ok = false;
break;
}
ssize_t r = read(in_fd, buffer, sizeof(buffer));
if (r == 0) break;
if (r < 0) {
if (errno == EINTR) continue;
LOGE("copy_fd_to_path read failed errno=%d %s", errno, strerror(errno));
ok = false;
break;
}
ssize_t written = 0;
while (written < r) {
ssize_t w = write(out_fd, buffer + written, static_cast<size_t>(r - written));
if (w < 0) {
if (errno == EINTR) continue;
LOGE("copy_fd_to_path write failed path=%s errno=%d %s",
output_path.c_str(), errno, strerror(errno));
ok = false;
break;
}
if (w == 0) {
LOGE("copy_fd_to_path write returned 0");
ok = false;
break;
}
written += w;
total += w;
}
if (!ok) break;
}
fsync(out_fd);
close(out_fd);
close(in_fd);
if (!ok || total <= 0) {
LOGE("copy_fd_to_path failed path=%s total=%lld exists=%s size=%lld",
output_path.c_str(),
static_cast<long long>(total),
path_exists(output_path) ? "true" : "false",
static_cast<long long>(path_size(output_path)));
return false;
}
LOGI("copy_fd_to_path success path=%s total=%lld size=%lld",
output_path.c_str(),
static_cast<long long>(total),
static_cast<long long>(path_size(output_path)));
return true;
}
static std::string mask_qpdf_arg_for_log(const std::string &arg) {
if (arg.rfind("--password=", 0) == 0) {
return "--password=***";
}
return arg;
}
static std::string join_qpdf_args_for_log(const std::vector<std::string> &args) {
std::ostringstream oss;
for (size_t i = 0; i < args.size(); ++i) {
if (i > 0) oss << ' ';
oss << mask_qpdf_arg_for_log(args[i]);
}
return oss.str();
}
static bool is_expected_code(int code, const std::vector<int> &expected_codes) {
for (int expected : expected_codes) {
if (code == expected) return true;
}
return false;
}
static int run_qpdf_job_logged(const char *stage,
const std::vector<std::string> &args,
const std::vector<int> &expected_codes = std::vector<int>{0}) {
std::vector<const char *> argv;
argv.reserve(args.size() + 1);
for (const std::string &arg : args) {
argv.push_back(arg.c_str());
}
argv.push_back(nullptr);
LOGI("qpdf stage=%s argc=%zu args=%s",
stage ? stage : "-",
args.size(),
join_qpdf_args_for_log(args).c_str());
errno = 0;
auto start_time = std::chrono::steady_clock::now();
int result = qpdfjob_run_from_argv(argv.data());
auto end_time = std::chrono::steady_clock::now();
long long duration_ms = std::chrono::duration_cast<std::chrono::milliseconds>(
end_time - start_time
).count();
if (is_expected_code(result, expected_codes)) {
LOGI("qpdf stage=%s expected code=%d duration_ms=%lld",
stage ? stage : "-", result, duration_ms);
} else {
LOGE("qpdf stage=%s failed code=%d duration_ms=%lld errno=%d %s",
stage ? stage : "-", result, duration_ms, errno, strerror(errno));
}
return result;
}
static const char *password_status_to_string(int status) {
switch (status) {
case PDF_PASSWORD_NOT_ENCRYPTED: return "NOT_ENCRYPTED";
case PDF_PASSWORD_REQUIRED: return "PASSWORD_REQUIRED";
case PDF_PASSWORD_OK: return "PASSWORD_OK";
case PDF_PASSWORD_WRONG: return "PASSWORD_WRONG";
case PDF_PASSWORD_ERROR: return "PASSWORD_ERROR";
default: return "UNKNOWN";
}
}
static std::string qpdf_error_message(const char *stage,
int code,
const std::string &input_path,
const std::string &output_path) {
(void) stage;
std::ostringstream oss;
oss << "Proses kompresi PDF gagal";
oss << ". Code: " << code;
oss << ". Input ada: " << (path_exists(input_path) ? "ya" : "tidak");
oss << ", output ada: " << (path_exists(output_path) ? "ya" : "tidak");
return oss.str();
}
static int map_requires_password_result(int result, bool password_empty) {
// qpdf --requires-password exit code:
// 0 = membutuhkan password
// 2 = tidak terenkripsi
// 3 = terenkripsi dan password yang diberikan benar
if (password_empty) {
if (result == 0) return PDF_PASSWORD_REQUIRED;
if (result == 2) return PDF_PASSWORD_NOT_ENCRYPTED;
if (result == 3) return PDF_PASSWORD_OK;
return PDF_PASSWORD_ERROR;
}
if (result == 3) return PDF_PASSWORD_OK;
if (result == 2) return PDF_PASSWORD_NOT_ENCRYPTED;
if (result == 0) return PDF_PASSWORD_WRONG;
return PDF_PASSWORD_WRONG;
}
static int check_pdf_password_status_path_impl(const std::string &input_path,
const std::string &password) {
if (input_path.empty() || !path_exists(input_path)) {
LOGE("check password path invalid input_path=%s exists=%s",
input_path.c_str(), path_exists(input_path) ? "true" : "false");
return PDF_PASSWORD_ERROR;
}
std::vector<std::string> args;
args.emplace_back("qpdf");
if (!password.empty()) {
args.emplace_back("--password=" + password);
}
args.emplace_back("--requires-password");
args.emplace_back(input_path);
LOGI("check password path input=%s password_empty=%s size=%lld",
input_path.c_str(),
password.empty() ? "true" : "false",
static_cast<long long>(path_size(input_path)));
int result = run_qpdf_job_logged("requires-password-path", args, {0, 2, 3});
int status = map_requires_password_result(result, password.empty());
LOGI("check password path result code=%d status=%s(%d)",
result, password_status_to_string(status), status);
return status;
}
int check_pdf_password_status_fd_impl(JNIEnv *,
int input_fd,
const std::string &password) {
if (input_fd < 0) {
LOGE("check password fd invalid input_fd=%d", input_fd);
return PDF_PASSWORD_ERROR;
}
int owned_fd = dup(input_fd);
if (owned_fd < 0) {
LOGE("check password dup failed errno=%d %s", errno, strerror(errno));
return PDF_PASSWORD_ERROR;
}
if (lseek64(owned_fd, 0, SEEK_SET) < 0) {
LOGW("input pdf fd not seekable for password check: errno=%d %s", errno, strerror(errno));
}
std::string input_path = make_proc_fd_path(owned_fd);
std::vector<std::string> args;
args.emplace_back("qpdf");
if (!password.empty()) {
args.emplace_back("--password=" + password);
}
args.emplace_back("--requires-password");
args.emplace_back(input_path);
LOGI("check password fd input_fd=%d owned_fd=%d input_path=%s password_empty=%s fd_size=%lld",
input_fd,
owned_fd,
input_path.c_str(),
password.empty() ? "true" : "false",
static_cast<long long>(fd_size(owned_fd)));
int result = run_qpdf_job_logged("requires-password-fd", args, {0, 2, 3});
close(owned_fd);
int status = map_requires_password_result(result, password.empty());
LOGI("check password fd result code=%d status=%s(%d)",
result, password_status_to_string(status), status);
return status;
}
static void add_common_lossless_qpdf_args(std::vector<std::string> &args) {
args.emplace_back("--warning-exit-0");
args.emplace_back("--compress-streams=y");
args.emplace_back("--decode-level=generalized");
args.emplace_back("--recompress-flate");
args.emplace_back("--compression-level=9");
args.emplace_back("--object-streams=generate");
}
int compress_pdf_fd_to_path_impl(JNIEnv *env,
int input_fd,
const std::string &output_path,
const std::string &password,
bool use_zopfli,
jobject callback) {
reset_cancel_requested();
LOGI("compress_pdf start input_fd=%d output_path=%s password_empty=%s engine=%s",
input_fd,
output_path.c_str(),
password.empty() ? "true" : "false",
use_zopfli ? "zopfli" : "zlib");
if (input_fd < 0 || output_path.empty()) {
LOGE("compress_pdf invalid input: input_fd=%d output_path_empty=%s",
input_fd,
output_path.empty() ? "true" : "false");
callback_progress(env, callback, 0, "PDF error: input/output tidak valid");
return -1;
}
if (!ensure_parent_dir_exists(output_path)) {
callback_progress(env, callback, 0, "PDF error: folder output sementara tidak ada");
return -30;
}
callback_progress(env, callback, 1, "Membuka PDF...");
const int64_t original_size_before = fd_size(input_fd);
std::string input_temp_path = make_temp_input_path(output_path);
safe_unlink(input_temp_path);
safe_unlink(output_path);
// Penting:
// Jangan langsung beri /proc/self/fd ke qpdf untuk proses utama.
// Pada beberapa device/SAF, qpdf gagal code=2 tanpa pesan jelas karena path fd tidak dianggap file biasa.
// Maka input PDF disalin dulu ke cache path biasa, baru qpdf bekerja path-to-path.
callback_progress(env, callback, 5, "Menyiapkan file sementara PDF...");
if (!copy_fd_to_path(input_fd, input_temp_path)) {
LOGE("compress_pdf failed to create temp input path=%s", input_temp_path.c_str());
callback_progress(env, callback, 0, "PDF error: gagal membuat input sementara");
safe_unlink(input_temp_path);
return -31;
}
LOGI("compress_pdf temp input ready input_temp=%s exists=%s size=%lld original_fd_size=%lld output_path=%s",
input_temp_path.c_str(),
path_exists(input_temp_path) ? "true" : "false",
static_cast<long long>(path_size(input_temp_path)),
static_cast<long long>(original_size_before),
output_path.c_str());
int pass_status = check_pdf_password_status_path_impl(input_temp_path, password);
LOGI("compress_pdf password_status=%s(%d)", password_status_to_string(pass_status), pass_status);
if (pass_status == PDF_PASSWORD_REQUIRED || pass_status == PDF_PASSWORD_WRONG) {
LOGE("compress_pdf password wrong/required status=%s(%d)",
password_status_to_string(pass_status), pass_status);
callback_progress(env, callback, 0, "Password PDF salah atau belum diisi");
safe_unlink(input_temp_path);
return -21;
}
if (pass_status == PDF_PASSWORD_ERROR) {
LOGE("compress_pdf password check error status=%s(%d)",
password_status_to_string(pass_status), pass_status);
callback_progress(env, callback, 0, "PDF error: tidak valid atau tidak bisa dibuka");
safe_unlink(input_temp_path);
return -22;
}
if (is_cancel_requested()) {
safe_unlink(input_temp_path);
return -10;
}
// Engine Deflate/Flate PDF:
// - zlib : cepat, cocok default Android.
// - zopfli: ukuran bisa lebih kecil sedikit, tetapi jauh lebih lambat.
// qpdf membaca QPDF_ZOPFLI saat runtime jika qpdf dibuild dengan zopfli.
if (use_zopfli) {
setenv("QPDF_ZOPFLI", "silent", 1);
LOGI("compress_pdf engine selected: zopfli");
} else {
unsetenv("QPDF_ZOPFLI");
LOGI("compress_pdf engine selected: zlib");
}
callback_progress(env, callback, 12, "Menganalisis struktur PDF...");
std::vector<std::string> args;
args.emplace_back("qpdf");
if (!password.empty()) {
args.emplace_back("--password=" + password);
}
add_common_lossless_qpdf_args(args);
args.emplace_back("--remove-unreferenced-resources=auto");
args.emplace_back("--optimize-images");
args.emplace_back("--jpeg-quality=88");
args.emplace_back("--oi-min-width=256");
args.emplace_back("--oi-min-height=256");
args.emplace_back("--oi-min-area=65536");
args.emplace_back(input_temp_path);
args.emplace_back(output_path);
callback_progress(env, callback, 28, use_zopfli
? "Mengompres PDF dengan Zopfli... lebih kecil tetapi bisa sangat lama"
: "Mengompres PDF dengan zlib... cepat dan tetap optimal");
int result = run_qpdf_job_logged("smart-compress-path", args);
LOGI("smart-compress-path returned code=%d output_exists=%s output_size=%lld",
result,
path_exists(output_path) ? "true" : "false",
static_cast<long long>(path_size(output_path)));
if (is_cancel_requested()) {
LOGW("compress_pdf canceled after smart-compress");
safe_unlink(input_temp_path);
safe_unlink(output_path);
return -10;
}
if (result != 0 || path_size(output_path) <= 0) {
LOGW("smart-compress failed/empty code=%d output_exists=%s output_size=%lld, trying lossless fallback",
result,
path_exists(output_path) ? "true" : "false",
static_cast<long long>(path_size(output_path)));
callback_progress(env, callback, 34, "Mode utama gagal, mencoba mode aman...");
safe_unlink(output_path);
std::vector<std::string> fallback_args;
fallback_args.emplace_back("qpdf");
if (!password.empty()) {
fallback_args.emplace_back("--password=" + password);
}
add_common_lossless_qpdf_args(fallback_args);
fallback_args.emplace_back(input_temp_path);
fallback_args.emplace_back(output_path);
result = run_qpdf_job_logged("lossless-fallback-path", fallback_args);
LOGI("lossless-fallback-path returned code=%d output_exists=%s output_size=%lld",
result,
path_exists(output_path) ? "true" : "false",
static_cast<long long>(path_size(output_path)));
}
if (is_cancel_requested()) {
LOGW("compress_pdf canceled after fallback");
safe_unlink(input_temp_path);
safe_unlink(output_path);
return -10;
}
if (result != 0 || path_size(output_path) <= 0) {
int64_t failed_output_size = path_size(output_path);
LOGE("compress_pdf qpdf failed final code=%d input_temp=%s input_exists=%s input_size=%lld output_path=%s output_exists=%s output_size=%lld errno=%d %s",
result,
input_temp_path.c_str(),
path_exists(input_temp_path) ? "true" : "false",
static_cast<long long>(path_size(input_temp_path)),
output_path.c_str(),
path_exists(output_path) ? "true" : "false",
static_cast<long long>(failed_output_size),
errno,
strerror(errno));
std::string msg = qpdf_error_message("compress", result, input_temp_path, output_path);
callback_progress(env, callback, 0, msg);
safe_unlink(input_temp_path);
safe_unlink(output_path);
return result != 0 ? result : -32;
}
callback_progress(env, callback, 88, "Mengecek ukuran hasil...");
int64_t original_size = path_size(input_temp_path);
int64_t compressed_size = path_size(output_path);
LOGI("compress_pdf size check original=%lld compressed=%lld input_temp=%s output_path=%s",
static_cast<long long>(original_size),
static_cast<long long>(compressed_size),
input_temp_path.c_str(),
output_path.c_str());
// Jangan biarkan hasil akhir lebih besar dari file original.
if (original_size > 0 && compressed_size > 0 && compressed_size >= original_size) {
callback_progress(env, callback, 92, "PDF sudah optimal, menjaga file asli...");
if (!copy_fd_to_path(input_fd, output_path)) {
LOGE("compress_pdf copy original fallback failed output_path=%s errno=%d %s",
output_path.c_str(), errno, strerror(errno));
callback_progress(env, callback, 0, "PDF error: gagal menjaga file asli setelah hasil lebih besar");
safe_unlink(input_temp_path);
safe_unlink(output_path);
return -23;
}
int64_t copied_size = path_size(output_path);
LOGI("compress_pdf copied original to output copied_size=%lld output_path=%s",
static_cast<long long>(copied_size), output_path.c_str());
}
safe_unlink(input_temp_path);
if (is_cancel_requested()) {
safe_unlink(output_path);
return -10;
}
LOGI("compress_pdf success output_path=%s final_size=%lld engine=%s",
output_path.c_str(),
static_cast<long long>(path_size(output_path)),
use_zopfli ? "zopfli" : "zlib");
callback_progress(env, callback, 100, "Done");
return 0;
}
+23
View File
@@ -0,0 +1,23 @@
#pragma once
#include <jni.h>
#include <string>
enum PdfPasswordStatus {
PDF_PASSWORD_NOT_ENCRYPTED = 0,
PDF_PASSWORD_REQUIRED = 1,
PDF_PASSWORD_OK = 2,
PDF_PASSWORD_WRONG = 3,
PDF_PASSWORD_ERROR = 4
};
int check_pdf_password_status_fd_impl(JNIEnv *env,
int input_fd,
const std::string &password);
int compress_pdf_fd_to_path_impl(JNIEnv *env,
int input_fd,
const std::string &output_path,
const std::string &password,
bool use_zopfli,
jobject callback);
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -8,6 +8,7 @@ import android.content.pm.PackageManager;
import android.database.Cursor;
import android.graphics.BitmapFactory;
import android.graphics.Color;
import android.graphics.Rect;
import android.graphics.drawable.ColorDrawable;
import android.media.MediaMetadataRetriever;
import android.media.MediaExtractor;
@@ -17,6 +18,7 @@ import android.media.MediaCodecList;
import android.net.Uri;
import android.os.Build;
import android.os.Bundle;
import android.os.ParcelFileDescriptor;
import android.provider.MediaStore;
import android.provider.OpenableColumns;
import android.view.Gravity;
@@ -25,6 +27,7 @@ import android.widget.AdapterView;
import android.widget.ArrayAdapter;
import android.widget.Button;
import android.widget.ImageButton;
import android.widget.EditText;
import android.widget.LinearLayout;
import android.widget.PopupWindow;
import android.widget.SeekBar;
@@ -32,6 +35,7 @@ import android.widget.Spinner;
import android.widget.TextView;
import android.widget.Toast;
import java.io.InputStream;
import java.util.ArrayList;
import java.util.Locale;
@@ -41,9 +45,11 @@ public class MainActivity extends Activity {
private static final int REQ_PICK_VIDEO = 1001;
private static final int REQ_WRITE_PERMISSION = 1002;
private static final int REQ_PICK_IMAGE = 1003;
private static final int REQ_PICK_PDF = 1004;
static final String JOB_TYPE_VIDEO = "video";
static final String JOB_TYPE_IMAGE = "image";
static final String JOB_TYPE_PDF = "pdf";
static final String EXTRA_INPUT_URI = "input_uri";
static final String EXTRA_TARGET_SHORT = "target_short";
@@ -69,6 +75,11 @@ public class MainActivity extends Activity {
static final String EXTRA_IMAGE_QUALITY = "image_quality";
static final String EXTRA_IMAGE_QUALITY_LABEL = "image_quality_label";
static final String EXTRA_IMAGE_RESOLUTION_LABEL = "image_resolution_label";
static final String EXTRA_PDF_PASSWORD = "pdf_password";
static final String EXTRA_PDF_PASSWORD_REQUIRED = "pdf_password_required";
static final String EXTRA_PDF_DISPLAY_NAME = "pdf_display_name";
static final String EXTRA_PDF_COMPRESSION_ENGINE = "pdf_compression_engine";
static final String EXTRA_PDF_COMPRESSION_LABEL = "pdf_compression_label";
private static final String PREF_NAME = "kcompressor_settings";
private static final String KEY_CODEC_INDEX = "codec_index";
@@ -82,6 +93,7 @@ public class MainActivity extends Activity {
private static final String KEY_IMAGE_TARGET_SHORT = "image_target_short";
private static final String KEY_IMAGE_QUALITY = "image_quality";
private static final String KEY_IMAGE_SETTINGS_EXPANDED = "image_settings_expanded";
private static final String KEY_PDF_COMPRESSION_ENGINE_INDEX = "pdf_compression_engine_index";
private static final int CRF_MIN = 18;
private static final int CRF_MAX = 40;
@@ -97,6 +109,8 @@ public class MainActivity extends Activity {
private VideoInfo currentVideoInfo;
private Uri imageInputUri;
private ImageInfo currentImageInfo;
private Uri pdfInputUri;
private PdfInfo currentPdfInfo;
private String pendingPermissionJob = JOB_TYPE_VIDEO;
private TextView txtSelected;
@@ -122,6 +136,18 @@ public class MainActivity extends Activity {
private Button btnPickImage;
private Button btnCompressImage;
private TextView txtPdfSelected;
private TextView txtPdfInfo;
private TextView txtPdfCompressionSummary;
private ImageButton btnPdfCompressionHelp;
private Spinner spPdfCompressionMode;
private View layoutPdfEngineRow;
private View pdfPasswordLayout;
private EditText pdfPasswordInput;
private TextView txtPdfPasswordError;
private Button btnPickPdf;
private Button btnCompressPdf;
private Spinner spCodec;
private Spinner spResolution;
private Spinner spPreset;
@@ -143,6 +169,31 @@ public class MainActivity extends Activity {
private boolean imageSettingsExpanded = false;
private static final class PdfInfo {
long fileSizeBytes;
String mime;
String displayName;
boolean passwordRequired;
int passwordStatus;
}
private static final class PdfCompressionOption {
final String label;
final String engine;
final boolean useZopfli;
PdfCompressionOption(String label, String engine, boolean useZopfli) {
this.label = label;
this.engine = engine;
this.useZopfli = useZopfli;
}
@Override
public String toString() {
return label;
}
}
private static final class ImageInfo {
int width;
int height;
@@ -312,6 +363,25 @@ public class MainActivity extends Activity {
btnCompressImage.setVisibility(View.GONE);
layoutImageSettings.setVisibility(View.GONE);
txtPdfSelected = findViewById(R.id.txtPdfSelected);
txtPdfInfo = findViewById(R.id.txtPdfInfo);
txtPdfCompressionSummary = findViewById(R.id.txtPdfCompressionSummary);
btnPdfCompressionHelp = findViewById(R.id.btnPdfCompressionHelp);
spPdfCompressionMode = findViewById(R.id.spPdfCompressionMode);
layoutPdfEngineRow = findViewById(R.id.layoutPdfEngineRow);
pdfPasswordLayout = findViewById(R.id.pdfPasswordLayout);
pdfPasswordInput = findViewById(R.id.pdfPasswordInput);
txtPdfPasswordError = findViewById(R.id.txtPdfPasswordError);
btnPickPdf = findViewById(R.id.btnPickPdf);
btnCompressPdf = findViewById(R.id.btnCompressPdf);
btnCompressPdf.setEnabled(false);
btnCompressPdf.setVisibility(View.GONE);
setPdfSettingsVisible(false);
if (pdfPasswordLayout != null) {
pdfPasswordLayout.setVisibility(View.GONE);
}
clearPdfPasswordError();
spCodec = findViewById(R.id.spCodec);
spResolution = findViewById(R.id.spResolution);
spPreset = findViewById(R.id.spPreset);
@@ -328,6 +398,7 @@ public class MainActivity extends Activity {
setupSpinners();
setupCrfSlider();
setupImageSettings();
setupPdfSettings();
setSettingsExpanded(false);
setImageSettingsExpanded(false);
updateCompressionSummary();
@@ -335,10 +406,14 @@ public class MainActivity extends Activity {
btnPick.setOnClickListener(v -> openVideoPicker());
btnPickImage.setOnClickListener(v -> openImagePicker());
btnPickPdf.setOnClickListener(v -> openPdfPicker());
txtSettingsToggle.setOnClickListener(v -> setSettingsExpanded(!settingsExpanded));
txtImageSettingsToggle.setOnClickListener(v -> setImageSettingsExpanded(!imageSettingsExpanded));
btnCodecHelp.setOnClickListener(v -> showCodecOutputGuide());
btnImageFormatHelp.setOnClickListener(v -> showImageFormatGuide());
if (btnPdfCompressionHelp != null) {
btnPdfCompressionHelp.setOnClickListener(v -> showPdfCompressionGuide());
}
btnCompress.setOnClickListener(v -> {
if (inputUri == null) {
@@ -374,6 +449,43 @@ public class MainActivity extends Activity {
openImageProcessingActivity();
});
btnCompressPdf.setOnClickListener(v -> {
if (pdfInputUri == null) {
toast("Pilih PDF dulu");
return;
}
if (currentPdfInfo != null && currentPdfInfo.passwordRequired) {
String password = pdfPasswordInput != null && pdfPasswordInput.getText() != null
? pdfPasswordInput.getText().toString()
: "";
if (password.length() == 0) {
showPdfPasswordError("Masukkan password PDF");
return;
}
int status = checkPdfPassword(pdfInputUri, password);
if (status != NativeCompressor.PDF_PASSWORD_OK &&
status != NativeCompressor.PDF_PASSWORD_NOT_ENCRYPTED) {
showPdfPasswordError("Password salah");
return;
}
clearPdfPasswordError();
}
if (Build.VERSION.SDK_INT < 29 &&
checkSelfPermissionCompat(Manifest.permission.WRITE_EXTERNAL_STORAGE)
!= PackageManager.PERMISSION_GRANTED) {
pendingPermissionJob = JOB_TYPE_PDF;
requestPermissions(new String[]{Manifest.permission.WRITE_EXTERNAL_STORAGE}, REQ_WRITE_PERMISSION);
return;
}
openPdfProcessingActivity();
});
txtProgress.setText("Developed by Kikyps");
}
@@ -581,6 +693,68 @@ public class MainActivity extends Activity {
}
private PdfCompressionOption getSelectedPdfCompressionOption() {
Object item = spPdfCompressionMode != null ? spPdfCompressionMode.getSelectedItem() : null;
if (item instanceof PdfCompressionOption) {
return (PdfCompressionOption) item;
}
return new PdfCompressionOption("zlib Level 9 (Cepat)", "zlib", false);
}
private void updatePdfCompressionSummary() {
if (txtPdfCompressionSummary == null) {
return;
}
PdfCompressionOption option = getSelectedPdfCompressionOption();
if (option.useZopfli) {
txtPdfCompressionSummary.setText(
"Mode: Smart PDF Compress • Zopfli • ukuran bisa sedikit lebih kecil, proses lebih lama"
);
} else {
txtPdfCompressionSummary.setText(
"Mode: Smart PDF Compress • zlib level 9 • cepat, stabil, cocok untuk default"
);
}
}
private void showPdfCompressionGuide() {
PdfCompressionOption option = getSelectedPdfCompressionOption();
String title = option.label;
String message;
String recommendation;
if (option.useZopfli) {
message =
"Zopfli memakai format Deflate/Flate yang tetap kompatibel dengan PDF, tetapi mencari kombinasi kompresi lebih optimal.\n\n" +
"Kelebihan:\n" +
"• Ukuran bisa sedikit lebih kecil dari zlib.\n" +
"• Aman untuk text/vector dan stream Flate.\n\n" +
"Kekurangan:\n" +
"• Bisa sangat lama di Android.\n" +
"• Pada PDF yang 90% foto/JPEG, bedanya sering kecil karena gambar JPEG sudah terkompresi sendiri.\n\n" +
"Catatan gambar PDF:\n" +
"KCompressor mengoptimalkan image object yang didukung dengan kualitas JPEG tinggi agar ukuran turun tanpa kualitas tampak rusak.";
recommendation = "Gunakan Zopfli hanya jika prioritas utama adalah ukuran sekecil mungkin dan pengguna rela menunggu lebih lama.";
} else {
message =
"zlib level 9 adalah kompresi Deflate/Flate cepat yang umum dipakai di PDF. Ini pilihan terbaik untuk mode default Android.\n\n" +
"Kelebihan:\n" +
"• Jauh lebih cepat dari Zopfli.\n" +
"• Hasil tetap kecil dan stabil.\n" +
"• Cocok untuk penggunaan harian.\n\n" +
"Kekurangan:\n" +
"• Ukuran bisa sedikit lebih besar dibanding Zopfli.\n\n" +
"Catatan gambar PDF:\n" +
"Untuk PDF full gambar, penghematan terbesar biasanya berasal dari optimasi image JPEG, bukan dari zlib/Zopfli.";
recommendation = "Recommended: pilih zlib untuk default karena lebih cepat, stabil, dan perbedaan ukuran dengan Zopfli biasanya kecil pada PDF berisi gambar.";
}
showGuidePopup(spPdfCompressionMode != null ? spPdfCompressionMode : btnPdfCompressionHelp,
title, message, recommendation);
}
private void showCodecOutputGuide() {
Object selected = spCodec != null ? spCodec.getSelectedItem() : null;
String title = "Codec Output";
@@ -728,14 +902,9 @@ public class MainActivity extends Activity {
root.setBackgroundColor(Color.TRANSPARENT);
TextView arrow = new TextView(this);
arrow.setText("");
arrow.setTextColor(0xFF111827);
arrow.setTextSize(20f);
arrow.setGravity(Gravity.CENTER);
root.addView(arrow, new LinearLayout.LayoutParams(
LinearLayout.LayoutParams.MATCH_PARENT,
LinearLayout.LayoutParams.WRAP_CONTENT
));
LinearLayout card = new LinearLayout(this);
card.setOrientation(LinearLayout.VERTICAL);
@@ -774,11 +943,73 @@ public class MainActivity extends Activity {
recParams.topMargin = dp(10);
card.addView(recView, recParams);
Rect visibleFrame = new Rect();
getWindow().getDecorView().getWindowVisibleDisplayFrame(visibleFrame);
int[] location = new int[2];
anchor.getLocationOnScreen(location);
int anchorLeft = location[0];
int anchorTop = location[1];
int anchorBottom = anchorTop + anchor.getHeight();
root.removeAllViews();
arrow.setText("");
root.addView(arrow, new LinearLayout.LayoutParams(
LinearLayout.LayoutParams.MATCH_PARENT,
LinearLayout.LayoutParams.WRAP_CONTENT
));
root.addView(card, new LinearLayout.LayoutParams(
LinearLayout.LayoutParams.MATCH_PARENT,
LinearLayout.LayoutParams.WRAP_CONTENT
));
int widthSpec = View.MeasureSpec.makeMeasureSpec(popupWidth, View.MeasureSpec.EXACTLY);
int heightSpec = View.MeasureSpec.makeMeasureSpec(0, View.MeasureSpec.UNSPECIFIED);
root.measure(widthSpec, heightSpec);
int popupHeight = root.getMeasuredHeight();
int spaceBelow = visibleFrame.bottom - anchorBottom;
int spaceAbove = anchorTop - visibleFrame.top;
boolean showAbove = spaceBelow < popupHeight + dp(8) && spaceAbove > spaceBelow;
root.removeAllViews();
if (showAbove) {
root.setPadding(margin, margin, margin, 0);
root.addView(card, new LinearLayout.LayoutParams(
LinearLayout.LayoutParams.MATCH_PARENT,
LinearLayout.LayoutParams.WRAP_CONTENT
));
arrow.setText("");
root.addView(arrow, new LinearLayout.LayoutParams(
LinearLayout.LayoutParams.MATCH_PARENT,
LinearLayout.LayoutParams.WRAP_CONTENT
));
} else {
root.setPadding(margin, 0, margin, margin);
arrow.setText("");
root.addView(arrow, new LinearLayout.LayoutParams(
LinearLayout.LayoutParams.MATCH_PARENT,
LinearLayout.LayoutParams.WRAP_CONTENT
));
root.addView(card, new LinearLayout.LayoutParams(
LinearLayout.LayoutParams.MATCH_PARENT,
LinearLayout.LayoutParams.WRAP_CONTENT
));
}
root.measure(widthSpec, heightSpec);
popupHeight = root.getMeasuredHeight();
int x = anchorLeft - margin;
int maxX = visibleFrame.right - popupWidth - margin;
if (x < visibleFrame.left + margin) x = visibleFrame.left + margin;
if (x > maxX) x = Math.max(visibleFrame.left + margin, maxX);
int y = showAbove
? Math.max(visibleFrame.top + dp(4), anchorTop - popupHeight - dp(2))
: Math.min(anchorBottom + dp(2), visibleFrame.bottom - popupHeight - dp(4));
PopupWindow popup = new PopupWindow(
root,
popupWidth,
@@ -789,8 +1020,7 @@ public class MainActivity extends Activity {
popup.setOutsideTouchable(true);
popup.setBackgroundDrawable(new ColorDrawable(Color.TRANSPARENT));
popup.setElevation(dp(8));
popup.showAsDropDown(anchor, -margin, dp(2));
popup.showAtLocation(anchor, Gravity.NO_GRAVITY, x, y);
}
private int dp(int value) {
@@ -973,6 +1203,52 @@ public class MainActivity extends Activity {
}
private void setPdfSettingsVisible(boolean visible) {
int visibility = visible ? View.VISIBLE : View.GONE;
if (layoutPdfEngineRow != null) {
layoutPdfEngineRow.setVisibility(visibility);
}
if (spPdfCompressionMode != null) {
spPdfCompressionMode.setVisibility(visibility);
}
if (txtPdfCompressionSummary != null) {
txtPdfCompressionSummary.setVisibility(visibility);
}
}
private void setupPdfSettings() {
if (spPdfCompressionMode == null) {
return;
}
ArrayList<PdfCompressionOption> pdfModes = new ArrayList<>();
pdfModes.add(new PdfCompressionOption("zlib Level 9 (Cepat)", "zlib", false));
pdfModes.add(new PdfCompressionOption("Zopfli (Lebih kecil, lama)", "zopfli", true));
ArrayAdapter<PdfCompressionOption> pdfModeAdapter = makeDarkAdapter(pdfModes);
pdfModeAdapter.setDropDownViewResource(android.R.layout.simple_spinner_dropdown_item);
spPdfCompressionMode.setAdapter(pdfModeAdapter);
spPdfCompressionMode.setSelection(clamp(
prefs.getInt(KEY_PDF_COMPRESSION_ENGINE_INDEX, 0),
0,
pdfModes.size() - 1
));
spPdfCompressionMode.setOnItemSelectedListener(new AdapterView.OnItemSelectedListener() {
@Override
public void onItemSelected(AdapterView<?> parent, View view, int position, long id) {
savePdfSettingsOnly();
updatePdfCompressionSummary();
}
@Override
public void onNothingSelected(AdapterView<?> parent) {}
});
updatePdfCompressionSummary();
}
private void setImageQualitySlider(int quality) {
if (seekImageQuality == null) {
return;
@@ -1335,6 +1611,22 @@ public class MainActivity extends Activity {
}
private void openPdfPicker() {
Intent intent = new Intent(Intent.ACTION_OPEN_DOCUMENT);
intent.setType("application/pdf");
intent.addCategory(Intent.CATEGORY_OPENABLE);
intent.putExtra(Intent.EXTRA_MIME_TYPES, new String[]{"application/pdf"});
intent.addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION);
intent.addFlags(Intent.FLAG_GRANT_PERSISTABLE_URI_PERMISSION);
try {
startActivityForResult(intent, REQ_PICK_PDF);
} catch (Exception e) {
toast("Picker PDF tidak tersedia");
}
}
@Override
protected void onActivityResult(int requestCode, int resultCode, Intent data) {
super.onActivityResult(requestCode, resultCode, data);
@@ -1404,6 +1696,50 @@ public class MainActivity extends Activity {
btnCompressImage.setVisibility(View.GONE);
btnCompressImage.setEnabled(false);
}
return;
}
if (requestCode == REQ_PICK_PDF) {
if (resultCode == RESULT_OK && data != null) {
pdfInputUri = data.getData();
if (pdfInputUri != null) {
persistReadPermissionIfPossible(data, pdfInputUri);
currentPdfInfo = readPdfInfo(pdfInputUri);
if (currentPdfInfo == null ||
currentPdfInfo.passwordStatus == NativeCompressor.PDF_PASSWORD_ERROR) {
toast("PDF tidak valid atau tidak bisa dibuka");
clearPdfSelection();
return;
}
txtPdfSelected.setText("PDF dipilih");
txtPdfInfo.setText(makePdfInfoText(currentPdfInfo));
if (currentPdfInfo.passwordRequired) {
if (pdfPasswordLayout != null) {
pdfPasswordLayout.setVisibility(View.VISIBLE);
}
if (pdfPasswordInput != null) {
pdfPasswordInput.setText("");
}
clearPdfPasswordError();
} else if (pdfPasswordLayout != null) {
pdfPasswordLayout.setVisibility(View.GONE);
clearPdfPasswordError();
}
setPdfSettingsVisible(true);
btnCompressPdf.setVisibility(View.VISIBLE);
btnCompressPdf.setEnabled(true);
} else {
clearPdfSelection();
}
} else if (pdfInputUri == null) {
clearPdfSelection();
}
}
}
@@ -1625,6 +1961,139 @@ public class MainActivity extends Activity {
return sb.toString();
}
private PdfInfo readPdfInfo(Uri uri) {
PdfInfo info = new PdfInfo();
info.mime = normalizePdfMime(safeString(getContentResolver().getType(uri), ""));
info.fileSizeBytes = readOriginalFileSize(uri);
info.displayName = readDisplayName(uri);
info.passwordStatus = checkPdfPassword(uri, null);
info.passwordRequired = info.passwordStatus == NativeCompressor.PDF_PASSWORD_REQUIRED;
if (info.mime == null || info.mime.length() == 0) {
info.mime = mimeFromDisplayName(info.displayName);
}
if (!"application/pdf".equalsIgnoreCase(info.mime)) {
info.mime = "application/pdf";
}
return info;
}
private int checkPdfPassword(Uri uri, String password) {
ParcelFileDescriptor pfd = null;
try {
pfd = getContentResolver().openFileDescriptor(uri, "r");
if (pfd == null) {
return NativeCompressor.PDF_PASSWORD_ERROR;
}
return NativeCompressor.checkPdfPasswordStatus(pfd.getFd(), password);
} catch (Throwable ignored) {
return NativeCompressor.PDF_PASSWORD_ERROR;
} finally {
if (pfd != null) {
try {
pfd.close();
} catch (Throwable ignored) {}
}
}
}
private String makePdfInfoText(PdfInfo info) {
if (info == null) {
return "Info PDF tidak terbaca";
}
StringBuilder sb = new StringBuilder();
sb.append("Ukuran file: ").append(formatFileSize(info.fileSizeBytes));
sb.append("\nFormat: PDF");
sb.append("\nKeamanan: ")
.append(info.passwordRequired ? "Membutuhkan password" : "Tidak memakai password buka");
if (info.displayName != null && info.displayName.length() > 0) {
sb.append("\nNama file: ").append(info.displayName);
}
return sb.toString();
}
private String normalizePdfMime(String mime) {
if (mime == null || mime.length() == 0) {
return "";
}
String lower = mime.toLowerCase(Locale.US);
if (lower.equals("application/pdf") || lower.contains("pdf")) {
return "application/pdf";
}
return mime;
}
private void persistReadPermissionIfPossible(Intent data, Uri uri) {
if (data == null || uri == null || Build.VERSION.SDK_INT < 19) {
return;
}
final int flags = data.getFlags() &
(Intent.FLAG_GRANT_READ_URI_PERMISSION | Intent.FLAG_GRANT_WRITE_URI_PERMISSION);
try {
getContentResolver().takePersistableUriPermission(
uri,
flags & Intent.FLAG_GRANT_READ_URI_PERMISSION
);
} catch (Throwable ignored) {
}
}
private void showPdfPasswordError(String message) {
if (pdfPasswordInput != null) {
pdfPasswordInput.setError(message);
pdfPasswordInput.requestFocus();
}
if (txtPdfPasswordError != null) {
txtPdfPasswordError.setText(message);
txtPdfPasswordError.setVisibility(View.VISIBLE);
}
}
private void clearPdfPasswordError() {
if (pdfPasswordInput != null) {
pdfPasswordInput.setError(null);
}
if (txtPdfPasswordError != null) {
txtPdfPasswordError.setVisibility(View.GONE);
}
}
private void clearPdfSelection() {
pdfInputUri = null;
currentPdfInfo = null;
if (txtPdfSelected != null) {
txtPdfSelected.setText("Belum ada PDF dipilih");
}
if (txtPdfInfo != null) {
txtPdfInfo.setText("Pilih PDF menggunakan SAF. Jika PDF memakai password, input password akan muncul otomatis.");
}
setPdfSettingsVisible(false);
if (pdfPasswordLayout != null) {
pdfPasswordLayout.setVisibility(View.GONE);
}
if (pdfPasswordInput != null) {
pdfPasswordInput.setText("");
}
clearPdfPasswordError();
if (btnCompressPdf != null) {
btnCompressPdf.setVisibility(View.GONE);
btnCompressPdf.setEnabled(false);
}
}
private String readDisplayName(Uri uri) {
Cursor cursor = null;
try {
@@ -2086,6 +2555,29 @@ public class MainActivity extends Activity {
startActivity(intent);
}
private void openPdfProcessingActivity() {
String password = "";
if (currentPdfInfo != null && currentPdfInfo.passwordRequired &&
pdfPasswordInput != null && pdfPasswordInput.getText() != null) {
password = pdfPasswordInput.getText().toString();
}
Intent intent = new Intent(this, ProcessingActivity.class);
intent.putExtra(EXTRA_JOB_TYPE, JOB_TYPE_PDF);
intent.putExtra(EXTRA_INPUT_URI, pdfInputUri.toString());
intent.putExtra(EXTRA_INPUT_MIME, "application/pdf");
intent.putExtra(EXTRA_PDF_PASSWORD, password);
intent.putExtra(EXTRA_PDF_PASSWORD_REQUIRED,
currentPdfInfo != null && currentPdfInfo.passwordRequired);
PdfCompressionOption pdfOption = getSelectedPdfCompressionOption();
intent.putExtra(EXTRA_PDF_DISPLAY_NAME,
currentPdfInfo != null ? safeString(currentPdfInfo.displayName, "") : "");
intent.putExtra(EXTRA_PDF_COMPRESSION_ENGINE, pdfOption.engine);
intent.putExtra(EXTRA_PDF_COMPRESSION_LABEL, pdfOption.label);
startActivity(intent);
}
private void saveImageSettings(int maxLongSide, int quality) {
prefs.edit()
.putInt(KEY_IMAGE_FORMAT_INDEX, spImageFormat != null ? spImageFormat.getSelectedItemPosition() : 0)
@@ -2099,6 +2591,14 @@ public class MainActivity extends Activity {
IMAGE_QUALITY_MIN + seekImageQuality.getProgress());
}
private void savePdfSettingsOnly() {
if (spPdfCompressionMode != null) {
prefs.edit()
.putInt(KEY_PDF_COMPRESSION_ENGINE_INDEX, spPdfCompressionMode.getSelectedItemPosition())
.apply();
}
}
private int getSelectedImageMaxLongSide() {
Object item = spImageResolution.getSelectedItem();
if (item instanceof ImageResolutionOption) {
@@ -2154,6 +2654,8 @@ public class MainActivity extends Activity {
if (grantResults.length > 0 && grantResults[0] == PackageManager.PERMISSION_GRANTED) {
if (JOB_TYPE_IMAGE.equals(pendingPermissionJob)) {
openImageProcessingActivity();
} else if (JOB_TYPE_PDF.equals(pendingPermissionJob)) {
openPdfProcessingActivity();
} else {
openProcessingActivity();
}
@@ -7,6 +7,12 @@ public final class NativeCompressor {
private NativeCompressor() {}
public static final int PDF_PASSWORD_NOT_ENCRYPTED = 0;
public static final int PDF_PASSWORD_REQUIRED = 1;
public static final int PDF_PASSWORD_OK = 2;
public static final int PDF_PASSWORD_WRONG = 3;
public static final int PDF_PASSWORD_ERROR = 4;
public interface ProgressCallback {
void onProgress(int percent, String message);
}
@@ -116,6 +122,33 @@ public final class NativeCompressor {
ProgressCallback callback
);
/**
* Cek status password PDF:
* PDF_PASSWORD_NOT_ENCRYPTED = PDF tidak terenkripsi.
* PDF_PASSWORD_REQUIRED = PDF membutuhkan password.
* PDF_PASSWORD_OK = password benar.
* PDF_PASSWORD_WRONG = password salah.
* PDF_PASSWORD_ERROR = PDF tidak valid / tidak bisa dibuka.
*/
public static native int checkPdfPasswordStatus(
int inputFd,
String password
);
/**
* Compress PDF ke path temporary biasa.
*
* ProcessingActivity akan menyalin hasil temp file ke MediaStore/Downloads.
* Ini lebih aman daripada output langsung ke FD SAF karena proses PDF membutuhkan path seekable.
*/
public static native int compressPdfFdToPath(
int inputFd,
String outputPath,
String password,
boolean useZopfli,
ProgressCallback callback
);
/**
* Cek apakah FFmpeg build native memiliki encoder tertentu.
* Contoh:
@@ -19,6 +19,7 @@ import android.os.Looper;
import android.os.ParcelFileDescriptor;
import android.provider.MediaStore;
import android.provider.OpenableColumns;
import android.util.Log;
import android.view.MotionEvent;
import android.view.View;
import android.view.Window;
@@ -36,6 +37,7 @@ import java.util.Locale;
public class ProcessingActivity extends Activity {
private static final String TAG = "KCompressorProcess";
private static final long DIM_DELAY_MS = 10_000L;
private static final float DIM_BRIGHTNESS = 0.01f;
private static final int COLOR_PROGRESS_SUCCESS = 0xFF22C55E;
@@ -63,6 +65,7 @@ public class ProcessingActivity extends Activity {
private Uri outputUri;
private String outputMime = "video/mp4";
private boolean imageJob = false;
private boolean pdfJob = false;
private int targetShortSide;
private int inputRotationDegrees;
@@ -88,8 +91,15 @@ public class ProcessingActivity extends Activity {
private int imageQuality;
private String imageQualityLabel;
private String pdfPassword;
private boolean pdfPasswordRequired;
private String pdfDisplayName;
private String pdfCompressionEngine;
private String pdfCompressionLabel;
private volatile boolean cancelRequested = false;
private volatile boolean finished = false;
private volatile String lastNativeProgressMessage = "";
private int lastDisplayedProgress = 0;
private int targetProgress = 0;
private boolean smoothProgressRunning = false;
@@ -165,9 +175,9 @@ public class ProcessingActivity extends Activity {
readIntentExtras();
updateConfigCard();
txtProcessingTitle.setText(imageJob ? "Compressing Image" : "Compressing Video");
txtProcessingTitle.setText(pdfJob ? "Compressing PDF" : (imageJob ? "Compressing Image" : "Compressing Video"));
btnShareVideo.setText(imageJob ? "Share Image" : "Share Video");
btnShareDoc.setText("Share Doc");
btnShareDoc.setText(pdfJob ? "Share Doc" : "Share Doc");
btnCancel.setOnClickListener(v -> showCancelDialog());
btnShareVideo.setOnClickListener(v -> shareCompressedFile(false));
@@ -187,6 +197,7 @@ public class ProcessingActivity extends Activity {
String jobType = getIntent().getStringExtra(MainActivity.EXTRA_JOB_TYPE);
imageJob = MainActivity.JOB_TYPE_IMAGE.equals(jobType);
pdfJob = MainActivity.JOB_TYPE_PDF.equals(jobType);
targetShortSide = getIntent().getIntExtra(MainActivity.EXTRA_TARGET_SHORT, 0);
inputRotationDegrees = getIntent().getIntExtra(MainActivity.EXTRA_VIDEO_ROTATION, 0);
@@ -212,6 +223,12 @@ public class ProcessingActivity extends Activity {
imageQuality = getIntent().getIntExtra(MainActivity.EXTRA_IMAGE_QUALITY, 82);
imageQualityLabel = getIntent().getStringExtra(MainActivity.EXTRA_IMAGE_QUALITY_LABEL);
pdfPassword = getIntent().getStringExtra(MainActivity.EXTRA_PDF_PASSWORD);
pdfPasswordRequired = getIntent().getBooleanExtra(MainActivity.EXTRA_PDF_PASSWORD_REQUIRED, false);
pdfDisplayName = getIntent().getStringExtra(MainActivity.EXTRA_PDF_DISPLAY_NAME);
pdfCompressionEngine = getIntent().getStringExtra(MainActivity.EXTRA_PDF_COMPRESSION_ENGINE);
pdfCompressionLabel = getIntent().getStringExtra(MainActivity.EXTRA_PDF_COMPRESSION_LABEL);
if (encoderName == null) encoderName = "libx264";
if (codecLabel == null) codecLabel = "H.264 / AVC";
if (resolutionLabel == null) resolutionLabel = "Original";
@@ -229,10 +246,32 @@ public class ProcessingActivity extends Activity {
if (imageFormatLabel == null) imageFormatLabel = "JPEG / MozJPEG";
if (imageResolutionLabel == null) imageResolutionLabel = "Original";
if (imageQualityLabel == null) imageQualityLabel = "Quality " + imageQuality;
outputMime = imageJob ? mimeForImageFormat(outputFormat) : "video/mp4";
if (pdfPassword == null) pdfPassword = "";
if (pdfDisplayName == null) pdfDisplayName = "";
if (pdfCompressionEngine == null || pdfCompressionEngine.length() == 0) pdfCompressionEngine = "zlib";
if (pdfCompressionLabel == null || pdfCompressionLabel.length() == 0) {
pdfCompressionLabel = "zopfli".equalsIgnoreCase(pdfCompressionEngine)
? "Zopfli (Lebih kecil, lama)"
: "zlib Level 9 (Cepat)";
}
outputMime = pdfJob ? "application/pdf" : (imageJob ? mimeForImageFormat(outputFormat) : "video/mp4");
}
private void updateConfigCard() {
if (pdfJob) {
txtConfig.setText(
"Format : PDF\n" +
"Mode : Smart PDF Compress\n" +
"Engine : " + pdfCompressionLabel + "\n" +
"Kualitas : Visually lossless\n" +
"Password : " + (pdfPasswordRequired ? "Ya, tervalidasi" : "Tidak") +
(pdfDisplayName != null && pdfDisplayName.length() > 0
? "\nFile : " + pdfDisplayName
: "")
);
return;
}
if (imageJob) {
txtConfig.setText(
"Format : " + imageFormatLabel + "\n" +
@@ -308,6 +347,7 @@ public class ProcessingActivity extends Activity {
targetProgress = 0;
smoothProgressRunning = false;
resetProcessingVisualState();
lastNativeProgressMessage = "";
circleProgress.resetProgress();
txtElapsed.setText("Waktu berjalan: 00:00");
handler.post(elapsedRunnable);
@@ -315,9 +355,11 @@ public class ProcessingActivity extends Activity {
workerThread = new Thread(() -> {
ParcelFileDescriptor inputPfd = null;
ParcelFileDescriptor outputPfd = null;
File tempVideoFile = null;
File tempOutputFile = null;
try {
Log.i(TAG, "start job pdf=" + pdfJob + " image=" + imageJob + " inputUri=" + inputUri);
inputPfd = getContentResolver().openFileDescriptor(inputUri, "r");
if (inputPfd == null) {
throw new IllegalStateException("Input PFD null");
@@ -325,25 +367,31 @@ public class ProcessingActivity extends Activity {
final long originalSizeBytes = getUriSize(inputUri);
String outputName = imageJob
String outputName = pdfJob
? makePdfOutputName()
: (imageJob
? makeImageOutputName(outputFormat, targetShortSide, imageQuality)
: makeVideoOutputName(encoderName, targetShortSide, crf, videoBitrate);
: makeVideoOutputName(encoderName, targetShortSide, crf, videoBitrate));
outputPfd = createOutputFile(outputName, outputMime, imageJob);
outputPfd = createOutputFile(outputName, outputMime, imageJob, pdfJob);
if (outputPfd == null) {
throw new IllegalStateException("Output PFD null");
}
final Uri finalOutputUri = outputUri;
// Video memakai temp file cache dulu agar -movflags +faststart aman.
// Jangan encode video langsung ke MediaStore FD, karena faststart bisa gagal
// saat av_write_trailer() pada custom AVIO/ContentResolver FD.
// Video dan PDF memakai temp file cache dulu agar output aman dan seekable.
if (!imageJob) {
closeQuietly(outputPfd);
outputPfd = null;
tempVideoFile = createVideoTempFile(outputName);
tempOutputFile = pdfJob
? createPdfTempFile(outputName)
: createVideoTempFile(outputName);
if (tempOutputFile != null) {
Log.i(TAG, "temp output=" + tempOutputFile.getAbsolutePath());
}
}
int result;
@@ -359,10 +407,25 @@ public class ProcessingActivity extends Activity {
updateCompressionProgress(percent, message)
)
);
} else if (pdfJob) {
final boolean useZopfli = "zopfli".equalsIgnoreCase(pdfCompressionEngine);
Log.i(TAG, "call compressPdfFdToPath inputFd=" + inputPfd.getFd()
+ " output=" + (tempOutputFile != null ? tempOutputFile.getAbsolutePath() : "null")
+ " passwordEmpty=" + (pdfPassword == null || pdfPassword.length() == 0)
+ " engine=" + pdfCompressionEngine);
result = NativeCompressor.compressPdfFdToPath(
inputPfd.getFd(),
tempOutputFile.getAbsolutePath(),
pdfPassword,
useZopfli,
(percent, message) -> runOnUiThread(() ->
updateCompressionProgress(percent, message)
)
);
} else {
result = NativeCompressor.compressFdToPath(
inputPfd.getFd(),
tempVideoFile.getAbsolutePath(),
tempOutputFile.getAbsolutePath(),
targetShortSide,
inputRotationDegrees,
crf,
@@ -377,6 +440,7 @@ public class ProcessingActivity extends Activity {
);
}
Log.i(TAG, "native result=" + result + " lastNativeProgressMessage=" + lastNativeProgressMessage);
endTimeMs = System.currentTimeMillis();
if (cancelRequested) {
@@ -385,9 +449,9 @@ public class ProcessingActivity extends Activity {
handler.removeCallbacks(elapsedRunnable);
handler.removeCallbacks(smoothProgressRunnable);
allowScreenOffAfterProcessing();
txtStatus.setText(imageJob ? "Gambar dibatalkan" : "Video dibatalkan");
txtStatus.setText(pdfJob ? "PDF dibatalkan" : (imageJob ? "Gambar dibatalkan" : "Video dibatalkan"));
txtElapsed.setText("Total waktu: " + formatElapsed(endTimeMs - startTimeMs));
toast(imageJob ? "Compress gambar dibatalkan" : "Compress video dibatalkan");
toast(pdfJob ? "Compress PDF dibatalkan" : (imageJob ? "Compress gambar dibatalkan" : "Compress video dibatalkan"));
finish();
});
return;
@@ -399,12 +463,12 @@ public class ProcessingActivity extends Activity {
outputPfd = null;
if (!imageJob) {
copyTempVideoToOutputUri(tempVideoFile, finalOutputUri);
deleteTempFile(tempVideoFile);
tempVideoFile = null;
copyTempVideoToOutputUri(tempOutputFile, finalOutputUri);
deleteTempFile(tempOutputFile);
tempOutputFile = null;
}
publishOutput(finalOutputUri, outputMime, imageJob);
publishOutput(finalOutputUri, outputMime, imageJob, pdfJob);
final long compressedSizeBytes = getUriSize(finalOutputUri);
final String outputResultText = buildOutputResultText(
@@ -423,19 +487,26 @@ public class ProcessingActivity extends Activity {
layoutOutputCard.setVisibility(View.VISIBLE);
}
txtOutput.setText(outputResultText);
btnShareVideo.setVisibility(View.VISIBLE);
btnShareDoc.setVisibility(View.VISIBLE);
if (pdfJob) {
btnShareVideo.setVisibility(View.GONE);
btnShareDoc.setVisibility(View.VISIBLE);
} else {
btnShareVideo.setVisibility(View.VISIBLE);
btnShareDoc.setVisibility(View.VISIBLE);
}
btnCancel.setText("Selesai");
btnCancel.setEnabled(true);
btnCancel.setOnClickListener(v -> finish());
toast(imageJob ? "Gambar disimpan di gallery" : "Video disimpan di gallery");
toast(pdfJob ? "PDF disimpan ke Download" : (imageJob ? "Gambar disimpan di gallery" : "Video disimpan di gallery"));
});
} else {
Log.e(TAG, "job failed result=" + result + " lastNativeProgressMessage=" + lastNativeProgressMessage);
deleteFailedOutput(finalOutputUri);
runOnUiThread(() -> showFailed(result));
}
} catch (Exception e) {
Log.e(TAG, "worker exception pdf=" + pdfJob + " image=" + imageJob, e);
endTimeMs = System.currentTimeMillis();
Uri failedUri = outputUri;
deleteFailedOutput(failedUri);
@@ -458,9 +529,9 @@ public class ProcessingActivity extends Activity {
} finally {
closeQuietly(inputPfd);
closeQuietly(outputPfd);
deleteTempFile(tempVideoFile);
deleteTempFile(tempOutputFile);
}
}, imageJob ? "KCompressor-Image" : "KCompressor-Video");
}, pdfJob ? "KCompressor-PDF" : (imageJob ? "KCompressor-Image" : "KCompressor-Video"));
workerThread.start();
}
@@ -475,7 +546,7 @@ public class ProcessingActivity extends Activity {
txtStatus.setTextColor(COLOR_STATUS_NORMAL);
txtStatus.setTextSize(STATUS_TEXT_SIZE_NORMAL_SP);
txtStatus.setTypeface(null, android.graphics.Typeface.NORMAL);
txtStatus.setText(imageJob ? "Menyiapkan gambar..." : "Menyiapkan video...");
txtStatus.setText(pdfJob ? "Menyiapkan PDF..." : (imageJob ? "Menyiapkan gambar..." : "Menyiapkan video..."));
}
private void showSuccessVisualState() {
@@ -496,6 +567,10 @@ public class ProcessingActivity extends Activity {
private void showFailed(int result) {
finished = true;
smoothProgressRunning = false;
if (circleProgress != null) {
circleProgress.setVisibility(View.GONE);
}
if (layoutOutputCard != null) {
layoutOutputCard.setVisibility(View.GONE);
}
@@ -504,7 +579,8 @@ public class ProcessingActivity extends Activity {
allowScreenOffAfterProcessing();
btnShareVideo.setVisibility(View.GONE);
btnShareDoc.setVisibility(View.GONE);
txtStatus.setText("Gagal compress. Code: " + result);
String detail = buildFailureMessage(result);
txtStatus.setText(detail);
txtElapsed.setText("Total waktu: " + formatElapsed(endTimeMs - startTimeMs));
btnCancel.setText("Kembali");
btnCancel.setEnabled(true);
@@ -512,16 +588,38 @@ public class ProcessingActivity extends Activity {
toast("Gagal compress");
}
private String buildFailureMessage(int result) {
StringBuilder builder = new StringBuilder();
if (pdfJob) {
builder.append("Gagal compress PDF. Code: ").append(result);
if (lastNativeProgressMessage != null && lastNativeProgressMessage.length() > 0) {
builder.append("\nDetail: ").append(normalizeProcessMessage(lastNativeProgressMessage));
}
return builder.toString();
}
builder.append("Gagal compress. Code: ").append(result);
if (lastNativeProgressMessage != null && lastNativeProgressMessage.length() > 0) {
builder.append("\nDetail: ").append(lastNativeProgressMessage);
}
return builder.toString();
}
private void showCancelDialog() {
if (finished) {
finish();
return;
}
String title = imageJob ? "Batalkan kompresi gambar?" : "Batalkan kompresi video?";
String message = imageJob
String title = pdfJob ? "Batalkan kompresi PDF?" : (imageJob ? "Batalkan kompresi gambar?" : "Batalkan kompresi video?");
String message = pdfJob
? "Output PDF sementara akan dihapus."
: (imageJob
? "Output gambar sementara akan dihapus."
: "Output video sementara akan dihapus.";
: "Output video sementara akan dihapus.");
new AlertDialog.Builder(this)
.setTitle(title)
@@ -537,7 +635,7 @@ public class ProcessingActivity extends Activity {
}
cancelRequested = true;
txtStatus.setText(imageJob ? "Membatalkan gambar..." : "Membatalkan video...");
txtStatus.setText(pdfJob ? "Membatalkan PDF..." : (imageJob ? "Membatalkan gambar..." : "Membatalkan video..."));
btnCancel.setEnabled(false);
// Berlaku untuk video dan image karena keduanya memakai flag cancel native yang sama.
@@ -546,14 +644,18 @@ public class ProcessingActivity extends Activity {
@Override
public boolean dispatchTouchEvent(MotionEvent ev) {
restoreBrightness();
scheduleDim();
if (!finished && !cancelRequested) {
restoreBrightness();
scheduleDim();
}
return super.dispatchTouchEvent(ev);
}
private void scheduleDim() {
handler.removeCallbacks(dimRunnable);
handler.postDelayed(dimRunnable, DIM_DELAY_MS);
if (!finished && !cancelRequested) {
handler.postDelayed(dimRunnable, DIM_DELAY_MS);
}
}
private void setScreenBrightness(float value) {
@@ -595,6 +697,18 @@ public class ProcessingActivity extends Activity {
}
private void updateCompressionProgress(int percent, String message) {
if (finished) {
if (pdfJob) {
Log.i(TAG, "ignore PDF progress after finished percent=" + percent + " message=" + message);
}
return;
}
lastNativeProgressMessage = message == null ? "" : message;
if (pdfJob) {
Log.i(TAG, "PDF progress percent=" + percent + " message=" + lastNativeProgressMessage);
}
int safePercent = percent;
if (safePercent < 0) safePercent = 0;
@@ -618,7 +732,7 @@ public class ProcessingActivity extends Activity {
private String normalizeProcessMessage(String message) {
if (message == null || message.length() == 0) {
return imageJob ? "Memproses gambar..." : "Memproses video...";
return pdfJob ? "Memproses PDF..." : (imageJob ? "Memproses gambar..." : "Memproses video...");
}
String lower = message.toLowerCase(Locale.US);
@@ -631,6 +745,17 @@ public class ProcessingActivity extends Activity {
return "Dibatalkan";
}
if (pdfJob) {
if (lower.contains("password")) return "Memeriksa password PDF...";
if (lower.contains("membuka")) return "Membuka PDF...";
if (lower.contains("struktur") || lower.contains("analy")) return "Menganalisis PDF...";
if (lower.contains("kompres") || lower.contains("compress")) return "Mengompres PDF...";
if (lower.contains("gagal") && lower.contains("mode")) return "Mode utama gagal, mencoba mode aman...";
if (lower.contains("ukuran") || lower.contains("optimal")) return "Mengoptimalkan ukuran PDF...";
if (lower.contains("qpdf")) return "Mengompres PDF...";
return message;
}
if (imageJob) {
if (lower.contains("membaca")) return "Membaca gambar...";
if (lower.contains("decode")) return "Membuka gambar...";
@@ -661,13 +786,15 @@ public class ProcessingActivity extends Activity {
private void shareCompressedFile(boolean asDocument) {
if (outputUri == null) {
toast(imageJob ? "Gambar output belum tersedia" : "Video output belum tersedia");
toast(pdfJob ? "PDF output belum tersedia" : (imageJob ? "Gambar output belum tersedia" : "Video output belum tersedia"));
return;
}
Intent sendIntent = new Intent(Intent.ACTION_SEND);
if (asDocument) {
if (pdfJob) {
sendIntent.setType("application/pdf");
} else if (asDocument) {
sendIntent.setType("application/octet-stream");
} else {
sendIntent.setType(outputMime);
@@ -678,7 +805,7 @@ public class ProcessingActivity extends Activity {
Intent chooser = Intent.createChooser(
sendIntent,
asDocument ? "Share as Doc" : (imageJob ? "Share as Image" : "Share as Video")
pdfJob ? "Share PDF" : (asDocument ? "Share as Doc" : (imageJob ? "Share as Image" : "Share as Video"))
);
try {
@@ -705,6 +832,24 @@ public class ProcessingActivity extends Activity {
return file;
}
private File createPdfTempFile(String outputName) throws Exception {
File dir = new File(getCacheDir(), "pdf_compress");
if (!dir.exists() && !dir.mkdirs()) {
throw new IllegalStateException("Gagal membuat cache PDF: " + dir);
}
String safeName = outputName == null ? "kcompressor_temp.pdf" : outputName;
safeName = safeName.replaceAll("[^a-zA-Z0-9._-]", "_");
File file = new File(dir, "tmp_" + System.currentTimeMillis() + "_" + safeName);
if (file.exists() && !file.delete()) {
throw new IllegalStateException("Gagal membersihkan temp PDF lama: " + file);
}
return file;
}
private void copyTempVideoToOutputUri(File tempFile, Uri targetUri) throws Exception {
if (tempFile == null || !tempFile.exists() || tempFile.length() <= 0L) {
throw new IllegalStateException("Temp video kosong");
@@ -836,20 +981,45 @@ public class ProcessingActivity extends Activity {
return "KCompressor_" + tag + "_" + res + "_q" + quality + "_" + stamp + "." + ext;
}
private ParcelFileDescriptor createOutputFile(String displayName, String mimeType, boolean image) throws Exception {
private String makePdfOutputName() {
String base = "KCompressor_pdf_smart";
if (pdfDisplayName != null && pdfDisplayName.length() > 0) {
String name = pdfDisplayName;
int dot = name.lastIndexOf('.');
if (dot > 0) {
name = name.substring(0, dot);
}
name = name.replaceAll("[^a-zA-Z0-9._-]", "_");
if (name.length() > 0) {
base = name + "_compressed";
}
}
String stamp = new SimpleDateFormat("yyyyMMdd_HHmmss", Locale.US).format(new Date());
return base + "_" + stamp + ".pdf";
}
private ParcelFileDescriptor createOutputFile(String displayName,
String mimeType,
boolean image,
boolean pdf) throws Exception {
if (Build.VERSION.SDK_INT >= 29) {
ContentValues values = new ContentValues();
values.put(image ? MediaStore.Images.Media.DISPLAY_NAME : MediaStore.Video.Media.DISPLAY_NAME, displayName);
values.put(image ? MediaStore.Images.Media.MIME_TYPE : MediaStore.Video.Media.MIME_TYPE, mimeType);
values.put(image ? MediaStore.Images.Media.RELATIVE_PATH : MediaStore.Video.Media.RELATIVE_PATH,
Environment.DIRECTORY_DCIM + "/KCompressor");
values.put(image ? MediaStore.Images.Media.IS_PENDING : MediaStore.Video.Media.IS_PENDING, 1);
values.put(MediaStore.MediaColumns.DISPLAY_NAME, displayName);
values.put(MediaStore.MediaColumns.MIME_TYPE, mimeType);
values.put(MediaStore.MediaColumns.RELATIVE_PATH,
(pdf ? Environment.DIRECTORY_DOWNLOADS : Environment.DIRECTORY_DCIM) + "/KCompressor");
values.put(MediaStore.MediaColumns.IS_PENDING, 1);
ContentResolver resolver = getContentResolver();
outputUri = resolver.insert(
image ? MediaStore.Images.Media.EXTERNAL_CONTENT_URI : MediaStore.Video.Media.EXTERNAL_CONTENT_URI,
values
);
Uri collection = pdf
? MediaStore.Downloads.EXTERNAL_CONTENT_URI
: (image
? MediaStore.Images.Media.EXTERNAL_CONTENT_URI
: MediaStore.Video.Media.EXTERNAL_CONTENT_URI);
outputUri = resolver.insert(collection, values);
if (outputUri == null) {
throw new IllegalStateException("Gagal membuat MediaStore output");
@@ -864,7 +1034,9 @@ public class ProcessingActivity extends Activity {
}
File dir = new File(
Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_DCIM),
Environment.getExternalStoragePublicDirectory(
pdf ? Environment.DIRECTORY_DOWNLOADS : Environment.DIRECTORY_DCIM
),
"KCompressor"
);
@@ -884,12 +1056,12 @@ public class ProcessingActivity extends Activity {
}
}
private void publishOutput(Uri uri, String mimeType, boolean image) {
private void publishOutput(Uri uri, String mimeType, boolean image, boolean pdf) {
if (uri == null) return;
if (Build.VERSION.SDK_INT >= 29) {
ContentValues values = new ContentValues();
values.put(image ? MediaStore.Images.Media.IS_PENDING : MediaStore.Video.Media.IS_PENDING, 0);
values.put(MediaStore.MediaColumns.IS_PENDING, 0);
getContentResolver().update(uri, values, null, null);
} else {
MediaScannerConnection.scanFile(
@@ -990,7 +1162,7 @@ public class ProcessingActivity extends Activity {
}
private String buildOutputResultText(long originalSizeBytes, long compressedSizeBytes) {
String savedText = imageJob ? "Gambar disimpan di Gallery" : "Video disimpan di Gallery";
String savedText = pdfJob ? "PDF disimpan ke Download" : (imageJob ? "Gambar disimpan di Gallery" : "Video disimpan di Gallery");
StringBuilder builder = new StringBuilder();
builder.append(savedText).append("\n\n");
@@ -1,5 +1,6 @@
<?xml version="1.0" encoding="utf-8"?>
<ScrollView xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:background="@drawable/bg_amoled_screen"
@@ -468,6 +469,160 @@
</LinearLayout>
<LinearLayout
android:id="@+id/cardPdfCompression"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="18dp"
android:background="@drawable/bg_amoled_card"
android:orientation="vertical"
android:padding="16dp">
<TextView
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:text="PDF"
android:textColor="#FFFFFF"
android:textSize="16sp"
android:textStyle="bold" />
<TextView
android:id="@+id/txtPdfSelected"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="10dp"
android:text="Belum ada PDF dipilih"
android:textColor="#E5E7EB"
android:textSize="14sp" />
<TextView
android:id="@+id/txtPdfInfo"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="8dp"
android:lineSpacingExtra="3dp"
android:text="Pilih PDF menggunakan SAF. Jika PDF memakai password, input password akan muncul otomatis."
android:textColor="#9CA3AF"
android:textSize="13sp" />
<Button
android:id="@+id/btnPickPdf"
android:layout_width="match_parent"
android:layout_height="48dp"
android:layout_marginTop="14dp"
android:background="@drawable/bg_button_dark_secondary"
android:text="Pilih PDF"
android:textAllCaps="false"
android:textColor="#FFFFFF" />
<LinearLayout
android:id="@+id/layoutPdfEngineRow"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="14dp"
android:gravity="center_vertical"
android:orientation="horizontal"
android:visibility="gone">
<TextView
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1"
android:text="Engine PDF"
android:textColor="#9CA3AF"
android:textSize="13sp" />
<ImageButton
android:id="@+id/btnPdfCompressionHelp"
android:layout_width="32dp"
android:layout_height="32dp"
android:background="@android:color/transparent"
android:contentDescription="Info engine kompresi PDF"
android:padding="6dp"
android:src="@android:drawable/ic_menu_help" />
</LinearLayout>
<Spinner
android:id="@+id/spPdfCompressionMode"
android:layout_width="match_parent"
android:layout_height="48dp"
android:layout_marginTop="4dp"
android:background="@drawable/bg_spinner_dark"
android:paddingStart="12dp"
android:paddingEnd="12dp"
android:visibility="gone" />
<TextView
android:id="@+id/txtPdfCompressionSummary"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="12dp"
android:lineSpacingExtra="3dp"
android:text="Mode: Smart PDF Compress • zlib level 9 • cepat dan stabil"
android:textColor="#9CA3AF"
android:textSize="13sp"
android:visibility="gone" />
<LinearLayout
android:id="@+id/pdfPasswordLayout"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="14dp"
android:orientation="vertical"
android:visibility="gone">
<TextView
android:id="@+id/txtPdfPasswordLabel"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:text="Password PDF"
android:textColor="#9CA3AF"
android:textSize="13sp" />
<EditText
android:id="@+id/pdfPasswordInput"
android:layout_width="match_parent"
android:layout_height="48dp"
android:layout_marginTop="6dp"
android:background="@drawable/bg_spinner_dark"
android:hint="Masukkan password PDF"
android:inputType="textPassword"
android:paddingStart="14dp"
android:paddingEnd="14dp"
android:singleLine="true"
android:textColor="#FFFFFF"
android:textColorHint="#6B7280"
android:textSize="14sp" />
<TextView
android:id="@+id/txtPdfPasswordError"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="6dp"
android:text="Password salah"
android:textColor="#EF4444"
android:textSize="12sp"
android:visibility="gone" />
</LinearLayout>
<Button
android:id="@+id/btnCompressPdf"
android:layout_width="match_parent"
android:layout_height="54dp"
android:layout_marginTop="18dp"
android:background="@drawable/bg_button_green"
android:enabled="false"
android:text="Compress PDF"
android:textAllCaps="false"
android:textColor="#020617"
android:textStyle="bold"
android:visibility="gone" />
</LinearLayout>
<TextView
android:id="@+id/txtProgress"
android:layout_width="match_parent"