In-app user reports

Recently I had to implement user bug reporting feature. What it does is it makes possible for the user to report bugs (or make suggestions, etc.) from within a java app, which then sends the report over to the web server (via POST data through php script), which in turns sends an email to the developer containing the user report. This system is made out of two parts:
  1. Java code that sends the data to the server
  2. PHP script that reads sent data via POST and sends an email to local email server
The code presented below was implemented using libGDX framework (plus some my personal stuff), so it won’t compile off the bat, but it should serve as an example on how to solve this problem. Just reimplement the GUI part and the rest that is based off the libGDX library. Note: Java code was implemented using Apache’s HttpComponents library. I had to add this line to my gradle script in order to include it: implementation 'org.apache.httpcomponents.client5:httpclient5:5.2.1' Also note, that this didn’t work on Android for some reason. It was throwing com.android.builder.merge.DuplicateRelativeFileException: 3 files found with path 'META-INF/DEPENDENCIES' exception. I solved this problem by adding exclude to the Android’s gradle build script:
packagingOptions {
	resources {
		excludes += ['META-INF/robovm/ios/robovm.xml']
		excludes += ['META-INF/DEPENDENCIES']
	}
}
Usage demonstration:
Java Report class:
package com.betalord.sgx.core;

import com.badlogic.gdx.Gdx;
import com.badlogic.gdx.Input;
import com.badlogic.gdx.scenes.scene2d.ui.Table;
import com.badlogic.gdx.scenes.scene2d.ui.TextArea;
import com.betalord.sgx.ui.DialogUtils;
import com.betalord.sgx.ui.SGXDialog;
import com.betalord.sgx.ui.SGXLabel;
import com.betalord.sgx.ui.SGXTextArea;
import com.betalord.sgx.ui.SGXTextField;
import com.betalord.sgx.util.ConsistencyResult;
import com.betalord.sgx.util.SGXCallableObj;

import org.apache.hc.client5.http.classic.methods.HttpPost;
import org.apache.hc.client5.http.entity.mime.MultipartEntityBuilder;
import org.apache.hc.client5.http.entity.mime.StringBody;
import org.apache.hc.client5.http.impl.classic.CloseableHttpClient;
import org.apache.hc.client5.http.impl.classic.HttpClients;
import org.apache.hc.core5.http.ContentType;
import org.apache.hc.core5.http.HttpEntity;
import org.apache.hc.core5.http.io.entity.EntityUtils;
import org.apache.hc.core5.http.message.StatusLine;

import java.io.IOException;

/**
 * Class representing user report (bug report, suggestion or other).
 *
 * @author Betalord
 */
public class Report {
	private final String type;
	/**
	 * Dialog that we open which contains edit box and "Send" button, among other things.
	 */
	private final SGXDialog reportDialog;
	/**
	 * Dialog that we show upon receiving a response from the server. We need a reference to it as we access it from the result() method.
	 */
	private SGXDialog resultDialog;
	
	/**
	 * Creates the report widget. Call {@link #show()} to actually open the dialog so that user can fill in the report.
	 *
	 * @param type type of this report (e.g. bug report, suggestion, ...). Will be sent to the server (may be any string really).
	 */
	public Report(String type) {
		SGXGame game = SGXGame.get(); // a shortcut
		this.type = type;
		
		Table tabEmail = new Table();
		tabEmail.add(new SGXLabel("Contact e-mail:", game.uiSkin5, "small"));
		SGXTextField editEmail = new SGXTextField("", game.uiSkin5, "default");
		editEmail.setMessageText("Click to type...");
		tabEmail.add(editEmail).growX();
		
		Table tab = new Table();
		tab.add(tabEmail).growX();
		tab.row();
		TextArea taReport = new SGXTextArea("", game.uiSkin5);
		taReport.setMessageText("Enter your report");
		tab.add(taReport).growX().height(350);
		tab.row();
		tab.add(new SGXLabel("Alternatively you can submit your report at https://sgx.betalord.com site.", game.uiSkin5, "small"));
		
		reportDialog = DialogUtils.DialogSetup.setup("Create report")
				.table(tab)
				.button("Send", "SEND")
				.button("Cancel", Input.Keys.ESCAPE, "CANCEL")
				.addCloseButtonInTitleBar("CANCEL")
				.width(600)
				.quick()
				.result(new SGXCallableObj() {
					@Override
					public void call(Object obj) {
						if (obj == "SEND") {
							reportDialog.cancel(); // don't hide the dialog when user presses "SEND" button
							String reportData = taReport.getText(); // never null, may be empty ("")
							String senderEmail = editEmail.getText(); // never null, may be empty ("")
							boolean emailValid = Misc.emailPattern.matcher(senderEmail).matches();
							if (reportData.trim().equals("")) {
								// report is empty!
								DialogUtils.DialogSetup.setup("Unable to submit the report", "Your report is empty. Please fill it in before sending it.")
										.quick()
										.addOKButton()
										.show();
							} else if (senderEmail.trim().equals("") || !emailValid) {
								// sender email is empty!
								DialogUtils.DialogSetup.setup("Unable to submit the report", "Sender e-mail address is empty or invalid. Please fill it in as we need it in case we need to contact you for further info. Thank you!")
										.quick()
										.addOKButton()
										.show();
							} else {
								// all OK, file the report!
								fileReport(reportData, senderEmail);
							}
						}
					}
				})
				.create();
	}
	
	/**
	 * Open the report dialog so that the user can fill in the report and send it to the server.
	 */
	public void show() {
		reportDialog.show();
	}
	
	/**
	 * Will send what user wrote to the server and show a dialog telling user that we are contacting the server and will also notify user of success or failure.
	 */
	private void fileReport(String reportData, String senderEmail) {
		final SGXDialog dialog = DialogUtils.DialogSetup.setup("Reporting", "Contacting server and filing the report...")
				.quick()
				.show();
		
		final Thread thread = new Thread(() -> {
			ConsistencyResult result = ConsistencyResult.consistent();
			try {
				String response = sendReportToServer(reportData, senderEmail);
				if (response == null)
					result = ConsistencyResult.inconsistent("No response from the server.");
				else if (response.startsWith("OK")) {
					// All fine - no need to do anything!
				} else {
					// Server response starts with "FAIL" or some other error string that precedes it (e.g. "<b>Warning</b>:  mail(): Failed to connect to mailserver...")
					result = ConsistencyResult.inconsistent("Server responded: '" + response + "'");
				}
			} catch (IOException e) {
				result = ConsistencyResult.inconsistent("Unable to contact server.");
				Misc.err("Error filing report: " + e.getLocalizedMessage());
				e.printStackTrace();
			}
			
			final ConsistencyResult finalResult = result;
			
			Gdx.app.postRunnable(() -> {
				dialog.hide();
				
				if (finalResult.isConsistent()) { // success
					reportDialog.hide();
					
					resultDialog = DialogUtils.DialogSetup.setup("Report submitted", "Your report has been successfully filed. Thank you!")
							.addOKButton("Close")
							.quick()
							.create();
					resultDialog.show();
				} else { // failed
					resultDialog = DialogUtils.DialogSetup.setup("Report failed", "Unable to submit report: an error occurred. Please try again later or submit your report manually at sgx.betalord.com web site.")
							.addOKButton("Close")
							.button("Error log", "LOG")
							.addCloseButtonInTitleBar()
							.quick()
							.result(obj -> {
								if (obj == "LOG") { // we clicked the "Error log" button
									resultDialog.cancel(); // don't close the dialog upon clicking on "Error log" button
									DialogUtils.DialogSetup.setup("Error log", finalResult.getReason())
											.quick()
											.addOKButton()
											.show();
								}
							})
							.create();
					
					resultDialog.show();
				}
			});
		}, "File Report Thread");
		
		// start:
		thread.start();
	}
	
	/**
	 * Low-level method that sends report data to the server.
	 * This method is thread-safe.
	 *
	 * @return response from the server (the generated page), or null in case of an error
	 */
	private String sendReportToServer(String reportData, String senderEmail) throws IOException {
		// Partially based on this example: https://github.com/apache/httpcomponents-client/blob/master/httpclient5/src/test/java/org/apache/hc/client5/http/examples/ClientMultipartFormPost.java
		
		String result;
		try (final CloseableHttpClient httpclient = HttpClients.createDefault()) {
			//final HttpPost httppost = new HttpPost("http://localhost:80/web_reporting/report.php");
			final HttpPost httppost = new HttpPost("https://sgx.betalord.com/admin/report.php"); // note: putting "http://" instead of "https://" will cause $_POST to be empty (due to rewrite rules for the apache web server). This was actually a problem I was having (see: https://stackoverflow.com/questions/1282909/php-post-array-empty-upon-form-submission)
			
			final StringBody type = new StringBody(Report.this.type, ContentType.TEXT_PLAIN);
			final StringBody sender = new StringBody(senderEmail, ContentType.TEXT_PLAIN);
			final StringBody contents = new StringBody(reportData, ContentType.TEXT_PLAIN);
			
			final HttpEntity reqEntity = MultipartEntityBuilder.create()
					//.addPart("bin", bin)
					.addPart("type", type)
					.addPart("sender", sender)
					.addPart("report", contents)
					.addPart("auth", new StringBody("password", ContentType.TEXT_PLAIN))
					.build();
			
			httppost.setEntity(reqEntity);
			
			result = httpclient.execute(httppost, response -> {
				if (Version.areWeDeveloping())
					System.out.println(httppost + "->" + new StatusLine(response));
				final HttpEntity resEntity = response.getEntity();
				String serverResponse = null;
				if (resEntity != null) {
					serverResponse = EntityUtils.toString(response.getEntity()); // process response message and convert it into a value object
				}
				EntityUtils.consume(response.getEntity());
				return serverResponse; // we must not return "EntityUtils.toString(response.getEntity())" at this point as the stream is already closed (by Entityutils.consume() method), or else we'll get this exception: org.apache.hc.core5.http.StreamClosedException: Stream already closed
			});
		}
		return result;
	}
}
PHP report.php file:
<?php
/*
Script that sends an email upon receiving a user report from the SGX application through POST mechanism.

(c) Betalord 2023
*/

	$AUTH_CODE = "password"; // some password just to make sure some hacker doesn't abuse of this email sending script

	if (!isset($_POST)) die("FAIL (no POST data)");
	$auth = $_POST["auth"]; // authentication pass code
	$type = $_POST["type"];
	$sender = $_POST["sender"]; // may be empty string, but not undefined
	$report = $_POST["report"];

	if (!isset($auth) || $auth != $AUTH_CODE)
		die("FAIL (wrong auth)");

	if (!isset($sender) || !isset($type) || empty($type) || !isset($report) || empty($report))
		die("FAIL (missing POST data)");

	$to = "sgx@betalord.com";
	$subject = "SGX IN-GAME REPORT (" . $type . ")";

	$message = "SENDER: " . $sender . "\r\n";
	$message .= "TYPE: " . $type . "\r\n";
	$message .= "\r\n";
	$message .= $report;

	$headers = array(
		'From' => 'sgx@betalord.com',
		'Reply-To' => $sender,
		'X-Mailer' => 'PHP/' . phpversion()
	);
	
	$retval = mail($to, $subject, $message, $headers);
	
	if ($retval == true ) {
		die("OK");
	} else {
		die("FAIL (mail function failed)");
	}
	
	die("OK");
?>