mirror of
https://github.com/MaintainTeam/Hypatia.git
synced 2025-02-28 21:38:21 +03:00
Many small improvements
- Fix an exception with the notification counter - Ensure the database is really loaded before a scan - Inform the user when a scan is cancelled - Allow quicker cancelling of scan (when in the middle of hashing) - Don't block main thread when updating databases - Change the fab color depending on the scan state - Add a simple textual progress bar to scans - Make text output selectable - Add a test signature - Small sanity checks Partially fixes https://github.com/Divested-Mobile/hypatia/issues/1#issuecomment-707734221 Closes https://gitlab.com/divested-mobile/hypatia/-/issues/3
This commit is contained in:
parent
e1bc93fbe0
commit
eccaaadd5d
13 changed files with 122 additions and 52 deletions
BIN
.idea/caches/build_file_checksums.ser
generated
BIN
.idea/caches/build_file_checksums.ser
generated
Binary file not shown.
|
@ -6,8 +6,8 @@ android {
|
|||
applicationId "us.spotco.malwarescanner"
|
||||
minSdkVersion 16
|
||||
targetSdkVersion 30
|
||||
versionCode 55
|
||||
versionName "2.14"
|
||||
versionCode 56
|
||||
versionName "2.15"
|
||||
resConfigs "en", "de", "fr"
|
||||
}
|
||||
buildTypes {
|
||||
|
|
|
@ -33,7 +33,6 @@ import java.net.InetSocketAddress;
|
|||
import java.net.Proxy;
|
||||
import java.net.URL;
|
||||
import java.text.DateFormat;
|
||||
import java.text.SimpleDateFormat;
|
||||
import java.util.Date;
|
||||
import java.util.HashMap;
|
||||
import java.util.HashSet;
|
||||
|
@ -47,6 +46,7 @@ class Database {
|
|||
private static SharedPreferences prefs = null;
|
||||
private static File databasePath = null;
|
||||
private static ThreadPoolExecutor threadPoolExecutor = null;
|
||||
private static boolean databaseFullyLoaded = false;
|
||||
|
||||
public final static HashSet<SignatureDatabase> signatureDatabases = new HashSet<>();
|
||||
public final static String baseURL = "https://divested.dev/MalwareScannerSignatures/";
|
||||
|
@ -56,7 +56,7 @@ class Database {
|
|||
public final static HashMap<String, String> signaturesSHA1 = new HashMap<>();
|
||||
public final static HashMap<String, String> signaturesSHA256 = new HashMap<>();
|
||||
|
||||
private static final DateFormat dateFormat = new SimpleDateFormat(Utils.getContext().getString(R.string.simple_date_format_short));
|
||||
private static final DateFormat dateFormat = DateFormat.getDateInstance();
|
||||
|
||||
public Database(TextView log) {
|
||||
Database.log = log;
|
||||
|
@ -68,7 +68,7 @@ class Database {
|
|||
}
|
||||
|
||||
public static boolean isDatabaseLoaded() {
|
||||
return signaturesMD5.size() > 0 && signaturesSHA1.size() > 0 && signaturesSHA256.size() > 0;
|
||||
return databaseFullyLoaded && signaturesMD5.size() > 0 && signaturesSHA1.size() > 0 && signaturesSHA256.size() > 0;
|
||||
}
|
||||
|
||||
public static int getSignatureCount() {
|
||||
|
@ -77,7 +77,7 @@ class Database {
|
|||
|
||||
public static void updateDatabase(Context context, HashSet<SignatureDatabase> signatureDatabases) {
|
||||
initDatabase(context);
|
||||
log.append(context.getString(R.string.main_database_updating, signatureDatabases.size() + "")+ "\n");
|
||||
log.append(context.getString(R.string.main_database_updating, signatureDatabases.size() + "") + "\n");
|
||||
for (SignatureDatabase signatureDatabase : signatureDatabases) {
|
||||
boolean onionRouting = prefs.getBoolean("ONION_ROUTING", false);
|
||||
new Downloader().executeOnExecutor(threadPoolExecutor, onionRouting, signatureDatabase.getUrl(), databasePath + "/" + signatureDatabase.getName());
|
||||
|
@ -114,6 +114,7 @@ class Database {
|
|||
|
||||
public static void loadDatabase(Context context, boolean ignoreifLoaded, HashSet<SignatureDatabase> signatureDatabases) {
|
||||
if (!isDatabaseLoaded() || !ignoreifLoaded && isDatabaseLoaded()) {
|
||||
databaseFullyLoaded = false;
|
||||
initDatabase(context);
|
||||
signaturesMD5.clear();
|
||||
signaturesSHA1.clear();
|
||||
|
@ -132,22 +133,22 @@ class Database {
|
|||
String line;
|
||||
if (database.getName().contains(".hdb")) {//.hdb format: md5, size, name
|
||||
while ((line = reader.readLine()) != null) {
|
||||
String[] lineS = line.split(":");
|
||||
if (Utils.TRIM_VARIANT_NUMBER) {
|
||||
lineS[2] = lineS[2].split("-")[0];
|
||||
if (line.length() > 0) {
|
||||
String[] lineS = line.split(":");
|
||||
if (lineS[0].length() > 0) {
|
||||
signaturesMD5.put(lineS[0].substring(0, Utils.MAX_HASH_LENGTH), lineS[2]);
|
||||
}
|
||||
}
|
||||
signaturesMD5.put(lineS[0].substring(0, Utils.MAX_HASH_LENGTH), lineS[2]);
|
||||
}
|
||||
} else if (database.getName().contains(".hsb")) {//.hsb format: sha256, size, name
|
||||
while ((line = reader.readLine()) != null) {
|
||||
String[] lineS = line.split(":");
|
||||
if (Utils.TRIM_VARIANT_NUMBER) {
|
||||
lineS[2] = lineS[2].split("-")[0];
|
||||
}
|
||||
if (lineS[0].length() == 32) {
|
||||
signaturesSHA1.put(lineS[0].substring(0, Utils.MAX_HASH_LENGTH), lineS[2]);
|
||||
} else {
|
||||
signaturesSHA256.put(lineS[0].substring(0, Utils.MAX_HASH_LENGTH), lineS[2]);
|
||||
if (line.length() > 0) {
|
||||
String[] lineS = line.split(":");
|
||||
if (lineS[0].length() == 32) {
|
||||
signaturesSHA1.put(lineS[0].substring(0, Utils.MAX_HASH_LENGTH), lineS[2]);
|
||||
} else if (lineS[0].length() > 0) {
|
||||
signaturesSHA256.put(lineS[0].substring(0, Utils.MAX_HASH_LENGTH), lineS[2]);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -158,7 +159,9 @@ class Database {
|
|||
}
|
||||
}
|
||||
signaturesMD5.put("44d88612fea8a8f36de82e1278abb02f".substring(0, Utils.MAX_HASH_LENGTH), "Eicar-Test-Signature");
|
||||
signaturesSHA256.put("6a0b4866f143c32e651662cebf7f380d27b0db809db3b6a34cf34c7436ab6bbf".substring(0, Utils.MAX_HASH_LENGTH), "Hypatia-Test-Signature");
|
||||
System.gc();
|
||||
databaseFullyLoaded = true;
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -196,7 +199,7 @@ class Database {
|
|||
}
|
||||
FileOutputStream fileOutputStream = new FileOutputStream(out);
|
||||
|
||||
final byte data[] = new byte[1024];
|
||||
final byte[] data = new byte[1024];
|
||||
int count;
|
||||
while ((count = connection.getInputStream().read(data, 0, 1024)) != -1) {
|
||||
fileOutputStream.write(data, 0, count);
|
||||
|
|
|
@ -26,16 +26,10 @@ import android.content.Intent;
|
|||
import android.content.SharedPreferences;
|
||||
import android.content.pm.ApplicationInfo;
|
||||
import android.content.pm.PackageManager;
|
||||
import android.os.AsyncTask;
|
||||
import android.content.res.ColorStateList;
|
||||
import android.os.Build;
|
||||
import android.os.Bundle;
|
||||
import android.os.Environment;
|
||||
import com.google.android.material.floatingactionbutton.FloatingActionButton;
|
||||
|
||||
import androidx.appcompat.app.AppCompatDelegate;
|
||||
import androidx.core.app.ActivityCompat;
|
||||
import androidx.core.content.ContextCompat;
|
||||
import androidx.appcompat.app.AppCompatActivity;
|
||||
import androidx.appcompat.widget.Toolbar;
|
||||
import android.text.method.ScrollingMovementMethod;
|
||||
import android.view.Menu;
|
||||
import android.view.MenuItem;
|
||||
|
@ -44,6 +38,14 @@ import android.view.WindowManager;
|
|||
import android.widget.TextView;
|
||||
import android.widget.Toast;
|
||||
|
||||
import androidx.appcompat.app.AppCompatActivity;
|
||||
import androidx.appcompat.app.AppCompatDelegate;
|
||||
import androidx.appcompat.widget.Toolbar;
|
||||
import androidx.core.app.ActivityCompat;
|
||||
import androidx.core.content.ContextCompat;
|
||||
|
||||
import com.google.android.material.floatingactionbutton.FloatingActionButton;
|
||||
|
||||
import java.io.File;
|
||||
import java.util.HashSet;
|
||||
import java.util.Set;
|
||||
|
@ -75,6 +77,7 @@ public class MainActivity extends AppCompatActivity {
|
|||
|
||||
logView = findViewById(R.id.txtLogOutput);
|
||||
logView.setMovementMethod(new ScrollingMovementMethod());
|
||||
logView.setTextIsSelectable(true);
|
||||
logView.append(getString(R.string.app_copyright) + "\n");
|
||||
logView.append(getString(R.string.app_license) + "\n");
|
||||
logView.append(getString(R.string.app_db_type_clamav) + "\n\n");
|
||||
|
@ -83,15 +86,35 @@ public class MainActivity extends AppCompatActivity {
|
|||
|
||||
prefs = getSharedPreferences(BuildConfig.APPLICATION_ID, Context.MODE_PRIVATE);
|
||||
|
||||
FloatingActionButton fab = findViewById(R.id.fab);
|
||||
final FloatingActionButton fab = findViewById(R.id.fab);
|
||||
fab.setOnClickListener(new View.OnClickListener() {
|
||||
@Override
|
||||
public void onClick(View view) {
|
||||
if (!malwareScanner.getStatus().equals(AsyncTask.Status.RUNNING)) {
|
||||
if (!malwareScanner.running) {
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
|
||||
fab.setBackgroundTintList(ColorStateList.valueOf(getColor(R.color.red)));
|
||||
}
|
||||
startScanner();
|
||||
Utils.getThreadPoolExecutor().execute(new Runnable() {
|
||||
@Override
|
||||
public void run() {
|
||||
while (malwareScanner.running) {
|
||||
try {
|
||||
Thread.sleep(1000);
|
||||
} catch (InterruptedException e) {
|
||||
e.printStackTrace();
|
||||
}
|
||||
}
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
|
||||
fab.setBackgroundTintList(ColorStateList.valueOf(getColor(R.color.light_blue)));
|
||||
}
|
||||
|
||||
}
|
||||
});
|
||||
} else {
|
||||
logView.append(getString(R.string.main_cancelling_scan) + "\n\n");
|
||||
malwareScanner.cancel(true);
|
||||
logView.append(getString(R.string.app_db_type_clamav) + "\n\n");
|
||||
malwareScanner.running = false;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
@ -226,13 +249,19 @@ public class MainActivity extends AppCompatActivity {
|
|||
filesToScan.addAll(Utils.getFilesRecursive(new File("/storage")));
|
||||
}
|
||||
malwareScanner.executeOnExecutor(Utils.getThreadPoolExecutor(), filesToScan);
|
||||
malwareScanner.running = true;
|
||||
}
|
||||
|
||||
private void updateDatabase() {
|
||||
new Database((TextView) findViewById(R.id.txtLogOutput));
|
||||
Database.updateDatabase(this, Database.signatureDatabases);
|
||||
if (Database.isDatabaseLoaded()) {
|
||||
Database.loadDatabase(this, false, Database.signatureDatabases);
|
||||
Utils.getThreadPoolExecutor().execute(new Runnable() {
|
||||
@Override
|
||||
public void run() {
|
||||
Database.loadDatabase(getApplicationContext(), true, Database.signatureDatabases);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -26,10 +26,10 @@ import android.os.AsyncTask;
|
|||
import android.os.Build;
|
||||
import android.os.Environment;
|
||||
import android.os.SystemClock;
|
||||
import androidx.core.app.NotificationCompat;
|
||||
import android.util.Log;
|
||||
import android.widget.TextView;
|
||||
|
||||
import androidx.core.app.NotificationCompat;
|
||||
|
||||
import java.io.File;
|
||||
import java.io.FileInputStream;
|
||||
import java.io.InputStream;
|
||||
|
@ -50,6 +50,7 @@ class MalwareScanner extends AsyncTask<Set<File>, Object, String> {
|
|||
private final HashMap<String, File> fileHashesMD5 = new HashMap<>();
|
||||
private final HashMap<String, File> fileHashesSHA1 = new HashMap<>();
|
||||
private final HashMap<String, File> fileHashesSHA256 = new HashMap<>();
|
||||
public boolean running = false;
|
||||
|
||||
public MalwareScanner(Activity activity, Context context, boolean userFacing) {
|
||||
this.context = context;
|
||||
|
@ -68,7 +69,10 @@ class MalwareScanner extends AsyncTask<Set<File>, Object, String> {
|
|||
|
||||
private void logResult(String result, boolean userFacingOnly) {
|
||||
if (userFacing) {
|
||||
logOutput.append(result + "\n");
|
||||
logOutput.append(result);
|
||||
if (!(result.length() <= 3)) {
|
||||
logOutput.append("\n");
|
||||
}
|
||||
} else if (!userFacingOnly) {
|
||||
String[] malwareDetect = result.split(" in ");
|
||||
NotificationCompat.Builder mBuilder =
|
||||
|
@ -79,13 +83,12 @@ class MalwareScanner extends AsyncTask<Set<File>, Object, String> {
|
|||
.setPriority(Notification.PRIORITY_MAX)
|
||||
.setDefaults(Notification.DEFAULT_VIBRATE);
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP) {
|
||||
mBuilder.setVisibility(Notification.VISIBILITY_SECRET);
|
||||
mBuilder.setVisibility(NotificationCompat.VISIBILITY_SECRET);
|
||||
}
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
|
||||
mBuilder.setChannelId("DETECTION");
|
||||
}
|
||||
notificationManager.notify(new Random().nextInt(), mBuilder.build());
|
||||
//Log.d("Hypatia", result);
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -97,6 +100,8 @@ class MalwareScanner extends AsyncTask<Set<File>, Object, String> {
|
|||
|
||||
@Override
|
||||
protected final String doInBackground(Set<File>[] filesToScan) {
|
||||
running = true;
|
||||
|
||||
//Pre
|
||||
fileHashesMD5.clear();
|
||||
fileHashesSHA1.clear();
|
||||
|
@ -105,15 +110,46 @@ class MalwareScanner extends AsyncTask<Set<File>, Object, String> {
|
|||
publishProgress("\t" + context.getString(R.string.main_files_pending_scan, filesToScan[0].size() + "") + "\n", true);
|
||||
|
||||
Database.loadDatabase(context, true, Database.signatureDatabases);
|
||||
if (Database.getSignatureCount() >= 0) {
|
||||
int delayCount = 0;
|
||||
while (!Database.isDatabaseLoaded() && delayCount <= 90) {
|
||||
try {
|
||||
Thread.sleep(1000);
|
||||
delayCount++;
|
||||
//publishProgress("\t" + context.getString(R.string.main_database_loading), true);
|
||||
} catch (InterruptedException e) {
|
||||
e.printStackTrace();
|
||||
}
|
||||
}
|
||||
if (Database.isDatabaseLoaded()) {
|
||||
publishProgress("\t" + context.getString(R.string.main_database_loaded, Database.getSignatureCount() + "") + "\n", true);
|
||||
|
||||
//Get file hashes
|
||||
publishProgress("\t" + context.getString(R.string.main_hashing_files), true);
|
||||
for (File file : filesToScan[0]) {
|
||||
getFileHashes(file);
|
||||
publishProgress("\t", true);
|
||||
int fileScannedCount = 0;
|
||||
int percentIncrement = (filesToScan[0].size() / 20);
|
||||
if (percentIncrement < 1) { //Prevent divide by zero
|
||||
percentIncrement = 1;
|
||||
}
|
||||
publishProgress("\t" + context.getString(R.string.main_hashing_done) + "\n", true);
|
||||
String spinnerCur = " ~ ";
|
||||
for (File file : filesToScan[0]) {
|
||||
if (this.isCancelled()) { //Allow quicker cancels
|
||||
//publishProgress("\t" + context.getString(R.string.main_cancelled_scan), true);
|
||||
running = false;
|
||||
return null;
|
||||
}
|
||||
getFileHashes(file);
|
||||
fileScannedCount++;
|
||||
if ((fileScannedCount % percentIncrement) == 0) {
|
||||
publishProgress(spinnerCur, true);
|
||||
if (spinnerCur.equals(" = ")) {
|
||||
spinnerCur = " ~ ";
|
||||
} else {
|
||||
spinnerCur = " = ";
|
||||
}
|
||||
}
|
||||
}
|
||||
publishProgress("\n\t" + context.getString(R.string.main_hashing_done) + "\n", true);
|
||||
|
||||
//Check the hashes
|
||||
checkSignature("MD5", fileHashesMD5, Database.signaturesMD5);
|
||||
|
@ -129,7 +165,9 @@ class MalwareScanner extends AsyncTask<Set<File>, Object, String> {
|
|||
publishProgress(context.getString(R.string.main_scanning_done, ((SystemClock.elapsedRealtime() - scanTime) / 1000) + "") + "\n\n\n\n", true);
|
||||
} else {
|
||||
publishProgress("\t" + context.getString(R.string.main_no_database_available), true);
|
||||
running = false;
|
||||
}
|
||||
running = false;
|
||||
return null;
|
||||
}
|
||||
|
||||
|
@ -139,6 +177,7 @@ class MalwareScanner extends AsyncTask<Set<File>, Object, String> {
|
|||
}
|
||||
|
||||
private void checkSignature(String hashType, HashMap<String, File> signaturesToCheck, HashMap<String, String> signatureDatabase) {
|
||||
//XXX: TODO: This is a map with hash,file meaning multiple files with the same hashes will only match once!
|
||||
if (signatureDatabase.size() > 0) {
|
||||
for (Map.Entry<String, File> file : signaturesToCheck.entrySet()) {
|
||||
if (signatureDatabase.containsKey(file.getKey())) {
|
||||
|
|
|
@ -27,6 +27,8 @@ import android.os.Build;
|
|||
import android.os.Environment;
|
||||
import android.os.FileObserver;
|
||||
import android.os.IBinder;
|
||||
import android.widget.Toast;
|
||||
|
||||
import androidx.core.app.NotificationCompat;
|
||||
|
||||
import java.io.File;
|
||||
|
@ -107,7 +109,7 @@ public class MalwareScannerService extends Service {
|
|||
}
|
||||
malwareMonitors.clear();
|
||||
System.gc();
|
||||
//Toast.makeText(this, "Hypatia: Realtime Scanning Stopped", Toast.LENGTH_SHORT).show(); //TODO: Move to strings.xml
|
||||
Toast.makeText(this, "Hypatia: Realtime Scanning Stopped", Toast.LENGTH_SHORT).show(); //TODO: Move to strings.xml
|
||||
}
|
||||
|
||||
private void setForeground() {
|
||||
|
@ -127,7 +129,7 @@ public class MalwareScannerService extends Service {
|
|||
}
|
||||
|
||||
private void updateForegroundNotification() {
|
||||
foregroundNotification.setSubText(getString(R.string.main_files_scanned_count, Utils.FILES_SCANNED) + "");
|
||||
foregroundNotification.setSubText(getString(R.string.main_files_scanned_count, Utils.FILES_SCANNED + ""));
|
||||
notificationManager.notify(-1, foregroundNotification.build());
|
||||
}
|
||||
|
||||
|
|
|
@ -37,7 +37,6 @@ class Utils {
|
|||
public final static int MAX_SCAN_SIZE_REALTIME = MAX_SCAN_SIZE / 2; //40MB
|
||||
|
||||
public final static int MAX_HASH_LENGTH = 12;
|
||||
public final static boolean TRIM_VARIANT_NUMBER = false;
|
||||
|
||||
public static int FILES_SCANNED = 0;
|
||||
private static ThreadPoolExecutor threadPoolExecutor = null;
|
||||
|
@ -51,8 +50,8 @@ class Utils {
|
|||
|
||||
public static int getMaxThreads() {
|
||||
int maxTheads = Runtime.getRuntime().availableProcessors();
|
||||
if (maxTheads >= 2) {
|
||||
maxTheads /= 2;
|
||||
if (maxTheads > 4) {
|
||||
maxTheads = 4;
|
||||
}
|
||||
return maxTheads;
|
||||
}
|
||||
|
|
|
@ -22,8 +22,6 @@
|
|||
<string name="lblNotificationRealtimeText">Bekannte Malware wird in Echtzeit erkannt</string>
|
||||
<string name="lblRealtimeScannerToggle">Echtzeit-Scanner</string>
|
||||
|
||||
<string name="simple_date_format_short">dd.MM.yyyy</string>
|
||||
|
||||
<string name="main_database_updating">Update von %s Datenbank(en) ...</string>
|
||||
<string name="main_database_downloading">Download von %s</string>
|
||||
<string name="main_database_download_success">Erfolgreich heruntergeladen</string>
|
||||
|
|
|
@ -23,8 +23,6 @@
|
|||
<string name="lblNotificationRealtimeDetection">Malware détecté:</string>
|
||||
<string name="lblRealtimeScannerToggle">Scanner en temps réel</string>
|
||||
|
||||
<string name="simple_date_format_short">dd.MM.yyyy</string>
|
||||
|
||||
<string name="main_database_updating">Mise à jour des %s bases de données ...</string>
|
||||
<string name="main_database_downloading">Télécharger %s</string>
|
||||
<string name="main_database_download_success">Télédéchargement réussi</string>
|
||||
|
|
|
@ -2,4 +2,5 @@
|
|||
<resources>
|
||||
<color name="colorPrimary">#4CAF50</color>
|
||||
<color name="light_blue">#03a9f4</color>
|
||||
<color name="red">#f44336</color>
|
||||
</resources>
|
||||
|
|
|
@ -23,8 +23,6 @@
|
|||
<string name="lblNotificationRealtimeDetection">Malware Detected:</string>
|
||||
<string name="lblRealtimeScannerToggle">Realtime Scanner</string>
|
||||
|
||||
<string name="simple_date_format_short">yyyy/MM/dd</string>
|
||||
|
||||
<string name="main_database_updating">Updating %s databases...</string>
|
||||
<string name="main_database_downloading">Downloading %s</string>
|
||||
<string name="main_database_download_success">Successfully downloaded</string>
|
||||
|
@ -36,12 +34,15 @@
|
|||
<string name="main_database_download_error_logcat">Failed to download, check logcat</string>
|
||||
|
||||
<string name="main_starting_scan">Starting scan...</string>
|
||||
<string name="main_cancelling_scan">Cancelling scan...</string>
|
||||
<string name="main_cancelled_scan">Cancelled scan</string>
|
||||
<string name="main_files_pending_scan">%s files pending scan</string>
|
||||
<string name="main_database_loading">Loading database...</string>
|
||||
<string name="main_database_loaded">Loaded database with %s signatures</string>
|
||||
<string name="main_hashing_files">Hashing files...</string>
|
||||
<string name="main_hashing_done">Calculated hashes for all files</string>
|
||||
<string name="main_hash_scan_done">Checked all %s hashes against signature databases</string>
|
||||
<string name="main_no_hashes_available">No %s hashes signatures available</string>
|
||||
<string name="main_scanning_done">Scan completed in %s seconds!</string>
|
||||
<string name="main_files_scanned_count">% files scanned</string>
|
||||
<string name="main_files_scanned_count">%s files scanned</string>
|
||||
</resources>
|
||||
|
|
BIN
graphics/hypatia-test-file.png
Normal file
BIN
graphics/hypatia-test-file.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 13 KiB |
BIN
graphics/hypatia-test-file.xcf
Normal file
BIN
graphics/hypatia-test-file.xcf
Normal file
Binary file not shown.
Loading…
Add table
Reference in a new issue