<NME> GpxExport.java
<BEF> package cgeo.geocaching.export;

import cgeo.geocaching.CgeoApplication;
import cgeo.geocaching.R;
import cgeo.geocaching.activity.ActivityMixin;
import cgeo.geocaching.models.Geocache;
import cgeo.geocaching.settings.Settings;
import cgeo.geocaching.storage.ContentStorage;
import cgeo.geocaching.storage.PersistableFolder;
import cgeo.geocaching.ui.dialog.Dialogs;
import cgeo.geocaching.utils.AsyncTaskWithProgress;
import cgeo.geocaching.utils.FileNameCreator;
import cgeo.geocaching.utils.Log;
import cgeo.geocaching.utils.ShareUtils;
import cgeo.geocaching.utils.UriUtils;

import android.app.Activity;
import android.app.Dialog;
import android.net.Uri;
import android.view.View;
import android.widget.CheckBox;
import android.widget.TextView;

import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import androidx.appcompat.app.AlertDialog;

import java.io.BufferedWriter;
import java.io.IOException;
import java.io.OutputStream;
import java.io.OutputStreamWriter;
import java.nio.charset.StandardCharsets;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.List;

import org.apache.commons.io.IOUtils;
import org.apache.commons.lang3.StringUtils;

public class GpxExport extends AbstractExport {

    private String fileName = "geocache.gpx"; // used in tests
    private String title = null;

    public GpxExport() {
        super(R.string.export_gpx);
        private final Activity activity;
        private final Progress progress = new Progress();
        private File exportFile;

        /**
         * Instantiates and configures the task for exporting field notes.
    }

    @Override
    public void export(@NonNull final List<Geocache> caches, @Nullable final Activity activity) {
        final String[] geocodes = getGeocodes(caches);
        calculateFileName(geocodes);
        if (activity == null) {
            // No activity given, so no user interaction possible.
            // Start export with default parameters.
            new ExportTask(null).execute(geocodes);

        } else {
            // Show configuration dialog
            getExportDialog(geocodes, activity).show();
        }
    }

    private void calculateFileName(final String[] geocodes) {
        if (geocodes.length == 1) {
            // geocode as file name
            fileName = geocodes[0] + (StringUtils.isNotBlank(title) ? " " + title : "") + ".gpx";
        } else {
            fileName = FileNameCreator.GPX_EXPORT.createName();
                return false;
            }

            Writer gpx = null;

            try {
                exportLocation.mkdirs();

                } else {
                    fileName += " " + title;
                }
            }
        }
    }

    private Dialog getExportDialog(final String[] geocodes, final Activity activity) {
        final AlertDialog.Builder builder = Dialogs.newBuilder(activity);
        builder.setTitle(activity.getString(R.string.export_confirm_title, activity.getString(R.string.export_gpx)));

        final View layout = View.inflate(activity, R.layout.gpx_export_dialog, null);
        builder.setView(layout);

        final TextView text = layout.findViewById(R.id.info);
        text.setText(activity.getString(R.string.export_confirm_message, PersistableFolder.GPX.toUserDisplayableValue(), fileName));

        final CheckBox includeFoundStatus = layout.findViewById(R.id.include_found_status);
        includeFoundStatus.setChecked(Settings.getIncludeFoundStatus());

        builder.setPositiveButton(R.string.export, (dialog, which) -> {
            Settings.setIncludeFoundStatus(includeFoundStatus.isChecked());
            dialog.dismiss();
            new ExportTask(activity).execute(geocodes);
        });

        return builder.create();
    }

    private static String[] getGeocodes(final List<Geocache> caches) {
        return Geocache.getGeocodes(caches).toArray(new String[caches.size()]);
    }

    protected class ExportTask extends AsyncTaskWithProgress<String, Uri> {

        /**
         * Instantiates and configures the task for exporting field notes.
         *
         * @param activity optional: Show a progress bar and toasts
         */
        public ExportTask(final Activity activity) {
            super(activity, getProgressTitle());
        }

        @Override
        protected Uri doInBackgroundInternal(final String[] geocodes) {
            final List<String> allGeocodes = new ArrayList<>(Arrays.asList(geocodes));

            setMessage(CgeoApplication.getInstance().getResources().getQuantityString(R.plurals.cache_counts, allGeocodes.size(), allGeocodes.size()));

            final Uri uri = ContentStorage.get().create(PersistableFolder.GPX, fileName);
            if (uri == null) {
                return null;
            }

            BufferedWriter writer = null;
            try (OutputStream os = ContentStorage.get().openForWrite(uri)) {
                if (os == null) {
                    return null;
                }

                writer = new BufferedWriter(new OutputStreamWriter(os, StandardCharsets.UTF_8));
                new GpxSerializer().writeGPX(allGeocodes, writer, ExportTask.this::publishProgress);
            } catch (final IOException e) {
                Log.e("GpxExport.ExportTask export", e);
                // delete partial GPX file on error
                    gpx.write(StringEscapeUtils.escapeXml(cache.getSize().id));
                    gpx.write("</groundspeak:container>");

                    if (cache.hasAttributes()) {
                        //TODO: Attribute conversion required: English verbose name, gpx-id
                        gpx.write("<groundspeak:attributes>");

                        for (String attribute : cache.getAttributes()) {
                            final CacheAttribute attr = CacheAttribute.getByGcRawName(CacheAttribute.trimAttributeName(attribute));
                            final boolean enabled = CacheAttribute.isEnabled(attribute);

                            gpx.write("<groundspeak:attribute id=\"");
                            gpx.write(Integer.toString(attr.id));
                            gpx.write("\" inc=\"");
                            if (enabled) {
                                gpx.write('1');
                            } else {
                                gpx.write('0');
                            }
                            gpx.write("\">");
                            gpx.write(StringEscapeUtils.escapeXml(attr.getL10n(enabled)));
                            gpx.write("</groundspeak:attribute>");
                        }

                        gpx.write("</groundspeak:attributes>");
                    }

                    gpx.write("<groundspeak:difficulty>");
                    gpx.write(Float.toString(cache.getDifficulty()));
            return uri;
        }

        @Override
        protected void onPostExecuteInternal(final Uri uri) {
            final Activity activityLocal = activity;
            if (activityLocal != null) {
                if (uri != null) {
                    ShareUtils.shareOrDismissDialog(activityLocal, uri, "application/xml", R.string.export, getName() + ' ' + activityLocal.getString(R.string.export_exportedto) + ": " + UriUtils.toUserDisplayableString(uri));
                } else {
                    ActivityMixin.showToast(activityLocal, activityLocal.getString(R.string.export_failed));
                }
            }
        }

    }
}
                    gpx.write(StringEscapeUtils.escapeXml(cache.getHint()));
                    gpx.write("</groundspeak:encoded_hints>");

                    gpx.write("</groundspeak:cache>");

                    if (cache.getLogs().size() > 0) {
                        gpx.write("<groundspeak:logs>");

                        for (LogEntry log : cache.getLogs()) {
                            gpx.write("<groundspeak:log id=\"");
                            gpx.write(Integer.toString(log.id));
                            gpx.write("\">");

                            gpx.write("<groundspeak:date>");
                            gpx.write(StringEscapeUtils.escapeXml(dateFormatZ.format(new Date(log.date))));
                            gpx.write("</groundspeak:date>");

                            gpx.write("<groundspeak:type>");
                            gpx.write(StringEscapeUtils.escapeXml(log.type.type));
                            gpx.write("</groundspeak:type>");

                            gpx.write("<groundspeak:finder id=\"\">");
                            gpx.write(StringEscapeUtils.escapeXml(log.author));
                            gpx.write("</groundspeak:finder>");

                            gpx.write("<groundspeak:text encoded=\"False\">");
                            gpx.write(StringEscapeUtils.escapeXml(log.log));
                            gpx.write("</groundspeak:text>");

                            gpx.write("</groundspeak:log>");
                        }

                        gpx.write("</groundspeak:logs>");
                    }

                    gpx.write("</wpt>");

                    for (cgWaypoint wp : cache.getWaypoints()) {
                        gpx.write("<wpt lat=\"");
                        final Geopoint coords = wp.getCoords();
                        gpx.write(coords != null ? Double.toString(coords.getLatitude()) : ""); // TODO: check whether is the best way to handle unknown waypoint coordinates
                        gpx.write("\" lon=\"");
                        gpx.write(coords != null ? Double.toString(coords.getLongitude()) : "");
                        gpx.write("\">");

                        gpx.write("<name>");
                        gpx.write(StringEscapeUtils.escapeXml(wp.getPrefix()));
                        gpx.write(StringEscapeUtils.escapeXml(cache.getGeocode().substring(2)));
                        gpx.write("</name>");

                        gpx.write("<cmt />");

                        gpx.write("<desc>");
                        gpx.write(StringEscapeUtils.escapeXml(wp.getNote()));
                        gpx.write("</desc>");

                        gpx.write("<sym>");
                        gpx.write(StringEscapeUtils.escapeXml(wp.getWaypointType().toString())); //TODO: Correct identifier string
                        gpx.write("</sym>");

                        gpx.write("<type>Waypoint|");
                        gpx.write(StringEscapeUtils.escapeXml(wp.getWaypointType().toString())); //TODO: Correct identifier string
                        gpx.write("</type>");

                        gpx.write("</wpt>");
                    }

                    publishProgress(i + 1);
                }
            return true;
        }

        @Override
        protected void onPostExecute(Boolean result) {
            if (null != activity) {

<MSG> fix: wrong nesting of logs leads to import errors
<DFF> @@ -47,6 +47,7 @@ class GpxExport extends AbstractExport {
         private final Activity activity;
         private final Progress progress = new Progress();
         private File exportFile;
+        private Writer gpx;
 
         /**
          * Instantiates and configures the task for exporting field notes.
@@ -76,8 +77,6 @@ class GpxExport extends AbstractExport {
                 return false;
             }
 
-            Writer gpx = null;
-
             try {
                 exportLocation.mkdirs();
 
@@ -150,29 +149,7 @@ class GpxExport extends AbstractExport {
                     gpx.write(StringEscapeUtils.escapeXml(cache.getSize().id));
                     gpx.write("</groundspeak:container>");
 
-                    if (cache.hasAttributes()) {
-                        //TODO: Attribute conversion required: English verbose name, gpx-id
-                        gpx.write("<groundspeak:attributes>");
-
-                        for (String attribute : cache.getAttributes()) {
-                            final CacheAttribute attr = CacheAttribute.getByGcRawName(CacheAttribute.trimAttributeName(attribute));
-                            final boolean enabled = CacheAttribute.isEnabled(attribute);
-
-                            gpx.write("<groundspeak:attribute id=\"");
-                            gpx.write(Integer.toString(attr.id));
-                            gpx.write("\" inc=\"");
-                            if (enabled) {
-                                gpx.write('1');
-                            } else {
-                                gpx.write('0');
-                            }
-                            gpx.write("\">");
-                            gpx.write(StringEscapeUtils.escapeXml(attr.getL10n(enabled)));
-                            gpx.write("</groundspeak:attribute>");
-                        }
-
-                        gpx.write("</groundspeak:attributes>");
-                    }
+                    writeAttributes(cache);
 
                     gpx.write("<groundspeak:difficulty>");
                     gpx.write(Float.toString(cache.getDifficulty()));
@@ -212,69 +189,13 @@ class GpxExport extends AbstractExport {
                     gpx.write(StringEscapeUtils.escapeXml(cache.getHint()));
                     gpx.write("</groundspeak:encoded_hints>");
 
-                    gpx.write("</groundspeak:cache>");
-
-                    if (cache.getLogs().size() > 0) {
-                        gpx.write("<groundspeak:logs>");
-
-                        for (LogEntry log : cache.getLogs()) {
-                            gpx.write("<groundspeak:log id=\"");
-                            gpx.write(Integer.toString(log.id));
-                            gpx.write("\">");
-
-                            gpx.write("<groundspeak:date>");
-                            gpx.write(StringEscapeUtils.escapeXml(dateFormatZ.format(new Date(log.date))));
-                            gpx.write("</groundspeak:date>");
+                    writeLogs(cache);
 
-                            gpx.write("<groundspeak:type>");
-                            gpx.write(StringEscapeUtils.escapeXml(log.type.type));
-                            gpx.write("</groundspeak:type>");
-
-                            gpx.write("<groundspeak:finder id=\"\">");
-                            gpx.write(StringEscapeUtils.escapeXml(log.author));
-                            gpx.write("</groundspeak:finder>");
-
-                            gpx.write("<groundspeak:text encoded=\"False\">");
-                            gpx.write(StringEscapeUtils.escapeXml(log.log));
-                            gpx.write("</groundspeak:text>");
-
-                            gpx.write("</groundspeak:log>");
-                        }
-
-                        gpx.write("</groundspeak:logs>");
-                    }
+                    gpx.write("</groundspeak:cache>");
 
                     gpx.write("</wpt>");
 
-                    for (cgWaypoint wp : cache.getWaypoints()) {
-                        gpx.write("<wpt lat=\"");
-                        final Geopoint coords = wp.getCoords();
-                        gpx.write(coords != null ? Double.toString(coords.getLatitude()) : ""); // TODO: check whether is the best way to handle unknown waypoint coordinates
-                        gpx.write("\" lon=\"");
-                        gpx.write(coords != null ? Double.toString(coords.getLongitude()) : "");
-                        gpx.write("\">");
-
-                        gpx.write("<name>");
-                        gpx.write(StringEscapeUtils.escapeXml(wp.getPrefix()));
-                        gpx.write(StringEscapeUtils.escapeXml(cache.getGeocode().substring(2)));
-                        gpx.write("</name>");
-
-                        gpx.write("<cmt />");
-
-                        gpx.write("<desc>");
-                        gpx.write(StringEscapeUtils.escapeXml(wp.getNote()));
-                        gpx.write("</desc>");
-
-                        gpx.write("<sym>");
-                        gpx.write(StringEscapeUtils.escapeXml(wp.getWaypointType().toString())); //TODO: Correct identifier string
-                        gpx.write("</sym>");
-
-                        gpx.write("<type>Waypoint|");
-                        gpx.write(StringEscapeUtils.escapeXml(wp.getWaypointType().toString())); //TODO: Correct identifier string
-                        gpx.write("</type>");
-
-                        gpx.write("</wpt>");
-                    }
+                    writeWaypoints(cache);
 
                     publishProgress(i + 1);
                 }
@@ -303,6 +224,98 @@ class GpxExport extends AbstractExport {
             return true;
         }
 
+        private void writeWaypoints(final cgCache cache) throws IOException {
+            for (cgWaypoint wp : cache.getWaypoints()) {
+                gpx.write("<wpt lat=\"");
+                final Geopoint coords = wp.getCoords();
+                gpx.write(coords != null ? Double.toString(coords.getLatitude()) : ""); // TODO: check whether is the best way to handle unknown waypoint coordinates
+                gpx.write("\" lon=\"");
+                gpx.write(coords != null ? Double.toString(coords.getLongitude()) : "");
+                gpx.write("\">");
+
+                gpx.write("<name>");
+                gpx.write(StringEscapeUtils.escapeXml(wp.getPrefix()));
+                gpx.write(StringEscapeUtils.escapeXml(cache.getGeocode().substring(2)));
+                gpx.write("</name>");
+
+                gpx.write("<cmt />");
+
+                gpx.write("<desc>");
+                gpx.write(StringEscapeUtils.escapeXml(wp.getNote()));
+                gpx.write("</desc>");
+
+                gpx.write("<sym>");
+                gpx.write(StringEscapeUtils.escapeXml(wp.getWaypointType().toString())); //TODO: Correct identifier string
+                gpx.write("</sym>");
+
+                gpx.write("<type>Waypoint|");
+                gpx.write(StringEscapeUtils.escapeXml(wp.getWaypointType().toString())); //TODO: Correct identifier string
+                gpx.write("</type>");
+
+                gpx.write("</wpt>");
+            }
+        }
+
+        private void writeLogs(final cgCache cache) throws IOException {
+            if (cache.getLogs().size() <= 0) {
+                return;
+            }
+            gpx.write("<groundspeak:logs>");
+
+            for (LogEntry log : cache.getLogs()) {
+                gpx.write("<groundspeak:log id=\"");
+                gpx.write(Integer.toString(log.id));
+                gpx.write("\">");
+
+                gpx.write("<groundspeak:date>");
+                gpx.write(StringEscapeUtils.escapeXml(dateFormatZ.format(new Date(log.date))));
+                gpx.write("</groundspeak:date>");
+
+                gpx.write("<groundspeak:type>");
+                gpx.write(StringEscapeUtils.escapeXml(log.type.type));
+                gpx.write("</groundspeak:type>");
+
+                gpx.write("<groundspeak:finder id=\"\">");
+                gpx.write(StringEscapeUtils.escapeXml(log.author));
+                gpx.write("</groundspeak:finder>");
+
+                gpx.write("<groundspeak:text encoded=\"False\">");
+                gpx.write(StringEscapeUtils.escapeXml(log.log));
+                gpx.write("</groundspeak:text>");
+
+                gpx.write("</groundspeak:log>");
+            }
+
+            gpx.write("</groundspeak:logs>");
+        }
+
+        private void writeAttributes(final cgCache cache) throws IOException {
+            if (!cache.hasAttributes()) {
+                return;
+            }
+            //TODO: Attribute conversion required: English verbose name, gpx-id
+            gpx.write("<groundspeak:attributes>");
+
+            for (String attribute : cache.getAttributes()) {
+                final CacheAttribute attr = CacheAttribute.getByGcRawName(CacheAttribute.trimAttributeName(attribute));
+                final boolean enabled = CacheAttribute.isEnabled(attribute);
+
+                gpx.write("<groundspeak:attribute id=\"");
+                gpx.write(Integer.toString(attr.id));
+                gpx.write("\" inc=\"");
+                if (enabled) {
+                    gpx.write('1');
+                } else {
+                    gpx.write('0');
+                }
+                gpx.write("\">");
+                gpx.write(StringEscapeUtils.escapeXml(attr.getL10n(enabled)));
+                gpx.write("</groundspeak:attribute>");
+            }
+
+            gpx.write("</groundspeak:attributes>");
+        }
+
         @Override
         protected void onPostExecute(Boolean result) {
             if (null != activity) {

fix: wrong nesting of logs leads to import errors