Laserlicht пре 1 месец
родитељ
комит
6c9524a8a3

+ 9 - 0
android/AndroidManifest.xml

@@ -102,6 +102,15 @@
 			android:process="eu.vcmi.vcmi.srv"
 			android:description="@string/server_name"
 			android:exported="false"/>
+
+		<!-- FileProvider for sharing files from app cache -->
+		<provider
+			android:name="androidx.core.content.FileProvider"
+			android:authorities="${applicationId}.fileprovider"
+			android:exported="false"
+			android:grantUriPermissions="true">
+			<meta-data android:name="android.support.FILE_PROVIDER_PATHS" android:resource="@xml/file_paths" />
+		</provider>
 	</application>
 
 </manifest>

+ 5 - 0
android/res/xml/file_paths.xml

@@ -0,0 +1,5 @@
+<?xml version="1.0" encoding="utf-8"?>
+<paths xmlns:android="http://schemas.android.com/apk/res/android">
+    <!-- allow sharing files from app cache -->
+    <cache-path name="cache" path="." />
+</paths>

+ 42 - 0
android/vcmi-app/src/main/java/eu/vcmi/vcmi/ActivityLauncher.java

@@ -12,6 +12,12 @@ import android.view.WindowManager;
 import androidx.annotation.Nullable;
 
 import java.io.File;
+import java.io.FileInputStream;
+import java.io.FileOutputStream;
+import java.io.IOException;
+import java.io.InputStream;
+import java.io.OutputStream;
+import androidx.core.content.FileProvider;
 
 import eu.vcmi.vcmi.VcmiSDLActivity;
 import eu.vcmi.vcmi.util.FileUtil;
@@ -69,4 +75,40 @@ public class ActivityLauncher extends org.qtproject.qt5.android.bindings.QtActiv
     {
         startActivity(new Intent(ActivityLauncher.this, VcmiSDLActivity.class));
     }
+
+    public void shareFile(String filePath)
+    {
+        File src = new File(filePath);
+        if (!src.exists())
+            return;
+
+        // copy to cache so we can share via FileProvider
+        File dest = new File(getCacheDir(), src.getName());
+        try (InputStream in = new FileInputStream(src); OutputStream out = new FileOutputStream(dest))
+        {
+            byte[] buf = new byte[4096];
+            int len;
+            while ((len = in.read(buf)) != -1)
+                out.write(buf, 0, len);
+        }
+        catch (IOException e)
+        {
+            e.printStackTrace();
+            return;
+        }
+
+        try
+        {
+            android.net.Uri uri = FileProvider.getUriForFile(this, getPackageName() + ".fileprovider", dest);
+            Intent intent = new Intent(Intent.ACTION_SEND);
+            intent.setType("application/zip");
+            intent.putExtra(Intent.EXTRA_STREAM, uri);
+            intent.addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION);
+            startActivity(Intent.createChooser(intent, "Share"));
+        }
+        catch (Exception e)
+        {
+            e.printStackTrace();
+        }
+    }
 }

+ 6 - 0
ios/iOS_utils.h

@@ -10,6 +10,7 @@
 #pragma once
 
 #include <TargetConditionals.h>
+#include <string>
 
 #pragma GCC visibility push(default)
 namespace iOS_utils
@@ -17,6 +18,11 @@ namespace iOS_utils
 const char *documentsPath();
 const char *cachesPath();
 
+// share file using system share sheet (e.g. Mail app)
+void shareFile(const std::string & filePath);
+
+std::string iphoneHardwareId();
+
 #if TARGET_OS_SIMULATOR
 const char *hostApplicationSupportPath();
 #endif

+ 29 - 0
ios/iOS_utils.mm

@@ -12,6 +12,9 @@
 
 #import <Foundation/Foundation.h>
 #import <UIKit/UIKit.h>
+#import <MobileCoreServices/MobileCoreServices.h>
+#import <objc/message.h>
+#import <sys/utsname.h>
 
 namespace
 {
@@ -57,4 +60,30 @@ void keepScreenOn(bool isEnabled)
 {
 	UIApplication.sharedApplication.idleTimerDisabled = isEnabled ? YES : NO;
 }
+
+void shareFile(const std::string & filePath)
+{
+	NSString *nsPath = [NSString stringWithUTF8String:filePath.c_str()];
+	if (nsPath == nil)
+		return;
+
+	NSURL *url = [NSURL fileURLWithPath:nsPath];
+	if (!url) return;
+
+	NSArray *items = @[url];
+	UIActivityViewController *controller = [[UIActivityViewController alloc] initWithActivityItems:items applicationActivities:nil];
+
+	UIViewController *root = UIApplication.sharedApplication.keyWindow.rootViewController;
+	if (root.presentedViewController != nil)
+		[root.presentedViewController presentViewController:controller animated:YES completion:nil];
+	else
+		[root presentViewController:controller animated:YES completion:nil];
+}
+
+std::string iphoneHardwareId()
+{
+    struct utsname systemInfo;
+    uname(&systemInfo);
+    return std::string(systemInfo.machine);
+}
 }

+ 160 - 0
launcher/aboutProject/aboutproject_moc.cpp

@@ -11,11 +11,19 @@
 #include "aboutproject_moc.h"
 #include "ui_aboutproject_moc.h"
 
+#if defined(VCMI_ANDROID)
+#include <QAndroidJniObject>
+#endif
+#if defined(VCMI_IOS)
+#include "ios/iOS_utils.h"
+#endif
+
 #include "../updatedialog_moc.h"
 #include "../helper.h"
 
 #include "../../lib/GameConstants.h"
 #include "../../lib/VCMIDirs.h"
+#include "../../lib/filesystem/CZipSaver.h"
 
 void AboutProjectView::hideAndStretchWidget(QGridLayout * layout, QWidget * toHide, QWidget * toStretch)
 {
@@ -110,3 +118,155 @@ void AboutProjectView::on_pushButtonBugreport_clicked()
 {
 	QDesktopServices::openUrl(QUrl("https://github.com/vcmi/vcmi/issues"));
 }
+
+static QString gatherDeviceInfo()
+{
+	QString info;
+	QTextStream ts(&info);
+	ts << "Operating system: " << QSysInfo::prettyProductName() << " (" << QSysInfo::kernelVersion() << ")" << '\n';
+	ts << "Product name: " << QSysInfo::prettyProductName() << '\n';
+	ts << "CPU architecture: " << QSysInfo::currentCpuArchitecture() << '\n';
+	ts << "Qt version: " << QT_VERSION_STR << '\n';
+#if defined(VCMI_ANDROID)
+	QString model = QAndroidJniObject::getStaticObjectField(
+		"android/os/Build",
+		"MODEL",
+		"Ljava/lang/String;"
+	).toString();
+	QString manufacturer = QAndroidJniObject::getStaticObjectField(
+		"android/os/Build",
+		"MANUFACTURER",
+		"Ljava/lang/String;"
+	).toString();
+	ts << "Device model: " << model << '\n';
+	ts << "Manufacturer: " << manufacturer << '\n';
+#endif
+#if defined(VCMI_IOS)
+	ts << "Device model: " << QString::fromStdString(iOS_utils::iphoneHardwareId()) << '\n';
+#endif
+	return info;
+}
+
+void AboutProjectView::on_pushButtonSendLogs_clicked()
+{
+	QDir tempDir(ui->lineEditTempDir->text());
+
+#if defined(VCMI_ANDROID) || defined(VCMI_IOS)
+    // cleanup old temp archives from previous runs (delete now)
+    {
+        QDir tdir(QDir::tempPath());
+        const QFileInfoList old = tdir.entryInfoList(QStringList() << "vcmi-logs-*.zip", QDir::Files, QDir::Name);
+        for (const QFileInfo & fi : old)
+            QFile::remove(fi.absoluteFilePath());
+    }
+	// On mobile: write archive to system temp and send via platform share (no save dialog)
+	const QString tmpDir = QDir::tempPath();
+	const QString outPath = QDir(tmpDir).filePath(QString("vcmi-logs-%1.zip").arg(QString::number(QDateTime::currentMSecsSinceEpoch())));
+#else
+	const QString defaultName = tempDir.filePath("vcmi-logs.zip");
+	QString outPath = QFileDialog::getSaveFileName(this, tr("Save logs"), defaultName, tr("Zip archives (*.zip)"));
+	if (outPath.isEmpty())
+		return;
+
+	if (!outPath.endsWith(".zip", Qt::CaseInsensitive))
+		outPath += ".zip";
+#endif
+
+	QFileInfoList files = tempDir.entryInfoList({ "*.txt" }, QDir::Files, QDir::Name);
+	files.append(QDir(ui->lineEditConfigDir->text()).entryInfoList({ "*.json", "*.ini" }, QDir::Files, QDir::Name));
+
+	// build data dir file/folder listing and add as a virtual text file
+	const QString dataDirPath = ui->lineEditUserDataDir->text();
+	QString listing;
+	QDir dataDir(dataDirPath);
+	QDirIterator it(dataDirPath, QDir::NoDotAndDotDot | QDir::AllEntries, QDirIterator::Subdirectories);
+	QTextStream ts(&listing);
+	while (it.hasNext())
+	{
+		const QString path = it.next();
+		const QString rel = dataDir.relativeFilePath(path);
+		QFileInfo info(path);
+
+		if (rel.startsWith(QLatin1String("Saves/")))
+			continue;
+
+		if (info.isDir())
+			ts << QChar('D') << QLatin1Char(' ') << rel << '\n';
+		else
+			ts << QChar('F') << QLatin1Char(' ') << rel << QLatin1String(" (") << info.size() << QLatin1String(")") << '\n';
+	}
+
+	try
+	{
+		// create zip and add .txt files
+		std::shared_ptr<CIOApi> api = std::make_shared<CDefaultIOApi>();
+		boost::filesystem::path archivePath(outPath.toStdString());
+		CZipSaver saver(api, archivePath);
+
+		for (const QFileInfo & fi : files)
+		{
+			QFile f(fi.absoluteFilePath());
+			if (!f.open(QIODevice::ReadOnly))
+				continue;
+
+			QByteArray data = f.readAll();
+			// If JSON, sanitize sensitive fields before packing
+			QByteArray toWrite = data;
+			if (fi.suffix().toLower() == "json")
+			{
+				QStringList lines = QString::fromUtf8(data).split('\n');
+				const QStringList keys = { "accountCookie", "accountID", "displayName" };
+				for (QString &line : lines)
+				{
+					for (const QString &key : keys)
+					{
+						if (line.contains(QRegularExpression(QString("\\b%1\\b").arg(QRegularExpression::escape(key)))))
+						{
+							QRegularExpression re("^(\\s*)");
+							auto m = re.match(line);
+							QString indent = m.hasMatch() ? m.captured(1) : QString();
+							line = indent + QString("// [removed %1]").arg(key);
+							break;
+						}
+					}
+				}
+				toWrite = lines.join('\n').toUtf8();
+			}
+
+			auto stream = saver.addFile(fi.fileName().toStdString());
+			stream->write(reinterpret_cast<const ui8 *>(toWrite.constData()), toWrite.size());
+		}
+
+		// add generated listing as game-directory-structure.txt
+		if (!listing.isEmpty())
+		{
+			QByteArray data = listing.toUtf8();
+			auto stream = saver.addFile(std::string("data-directory-structure.txt"));
+			stream->write(reinterpret_cast<const ui8 *>(data.constData()), data.size());
+		}
+
+		// add device information as device-info.txt
+		{
+			const QString deviceInfo = gatherDeviceInfo();
+			if (!deviceInfo.isEmpty())
+			{
+				QByteArray dataDev = deviceInfo.toUtf8();
+				auto streamDev = saver.addFile(std::string("device-info.txt"));
+				streamDev->write(reinterpret_cast<const ui8 *>(dataDev.constData()), dataDev.size());
+			}
+		}
+	}
+	catch (const std::exception & e)
+	{
+		QMessageBox::critical(this, tr("Error"), tr("Failed to create archive: %1").arg(QString::fromUtf8(e.what())));
+		return;
+	}
+	// On mobile platforms, send file via platform and remove temporary file afterwards.
+#if defined(VCMI_ANDROID) || defined(VCMI_IOS)
+	QMessageBox::information(this, tr("Send logs"), tr("The archive will be sent via another application. Share your logs e.g. over discord to developers."));
+	Helper::sendFileToApp(outPath);
+#else
+	// desktop: notify user and do not auto-send
+	QMessageBox::information(this, tr("Success"), tr("Logs saved to %1, please send them to the developers").arg(outPath));
+#endif
+}

+ 2 - 0
launcher/aboutProject/aboutproject_moc.h

@@ -47,6 +47,8 @@ private slots:
 
 	void on_pushButtonBugreport_clicked();
 
+	void on_pushButtonSendLogs_clicked();
+
 	void on_openConfigDir_clicked();
 
 private:

+ 13 - 0
launcher/aboutProject/aboutproject_moc.ui

@@ -304,6 +304,19 @@
        </property>
       </widget>
      </item>
+     <item>
+      <widget class="QPushButton" name="pushButtonSendLogs">
+       <property name="minimumSize">
+        <size>
+         <width>200</width>
+         <height>0</height>
+        </size>
+       </property>
+       <property name="text">
+        <string>Send logs</string>
+       </property>
+      </widget>
+     </item>
      <item>
       <spacer name="horizontalSpacer">
        <property name="orientation">

+ 16 - 0
launcher/helper.cpp

@@ -145,4 +145,20 @@ void keepScreenOn(bool isEnabled)
 	iOS_utils::keepScreenOn(isEnabled);
 #endif
 }
+
+void sendFileToApp(QString path)
+{
+#if defined(VCMI_ANDROID)
+	// delegate to Android activity which will copy to cache and share via FileProvider
+	auto jstr = QAndroidJniObject::fromString(path);
+	QtAndroid::runOnAndroidThread([jstr]() mutable {
+		QtAndroid::androidActivity().callMethod<void>("shareFile", "(Ljava/lang/String;)V", jstr.object<jstring>());
+	});
+#elif defined(VCMI_IOS)
+	// use iOS share sheet
+	iOS_utils::shareFile(path.toStdString());
+#else
+	Q_UNUSED(path);
+#endif
+}
 }

+ 1 - 0
launcher/helper.h

@@ -24,4 +24,5 @@ bool performNativeCopy(QString src, QString dst);
 void revealDirectoryInFileBrowser(QString path);
 MainWindow * getMainWindow();
 void keepScreenOn(bool isEnabled);
+void sendFileToApp(QString path);
 }