Create Purple.ScheduleTask

Thu, 24 Jul 2025 23:33:18 -0500

author
Gary Kramlich <grim@reaperworld.com>
date
Thu, 24 Jul 2025 23:33:18 -0500
changeset 43292
03fe500d5aa5
parent 43291
a14a8ae209a9
child 43293
f5d33dbc18a9

Create Purple.ScheduleTask

This is the first piece of creating a task scheduling API for things like
account reconnecting, scheduled messages, reminders, automatic avatar and
status changes and so on.

Testing Done:
Ran the unit tests under valgrind and called in the turtles.

Bugs closed: PIDGIN-18105

Reviewed at https://reviews.imfreedom.org/r/4072/

libpurple/meson.build file | annotate | diff | comparison | revisions
libpurple/purplescheduledtask.c file | annotate | diff | comparison | revisions
libpurple/purplescheduledtask.h file | annotate | diff | comparison | revisions
libpurple/tests/meson.build file | annotate | diff | comparison | revisions
libpurple/tests/test_scheduled_task.c file | annotate | diff | comparison | revisions
po/POTFILES.in file | annotate | diff | comparison | revisions
--- a/libpurple/meson.build	Tue Jul 22 16:06:33 2025 -0500
+++ b/libpurple/meson.build	Thu Jul 24 23:33:18 2025 -0500
@@ -70,6 +70,7 @@
 	'purpleprotocolwhiteboard.c',
 	'purpleproxyinfo.c',
 	'purplesavedpresence.c',
+	'purplescheduledtask.c',
 	'purplesqlitehistoryadapter.c',
 	'purpletags.c',
 	'purpleui.c',
@@ -165,6 +166,7 @@
 	'purpleprotocolwhiteboard.h',
 	'purpleproxyinfo.h',
 	'purplesavedpresence.h',
+	'purplescheduledtask.h',
 	'purplesqlitehistoryadapter.h',
 	'purpletags.h',
 	'purpletyping.h',
@@ -256,6 +258,7 @@
 	'purplepresence.h',
 	'purpleprotocol.h',
 	'purpleproxyinfo.h',
+	'purplescheduledtask.h',
 	'purpletyping.h',
 	'xmlnode.h'
 ]
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/libpurple/purplescheduledtask.c	Thu Jul 24 23:33:18 2025 -0500
@@ -0,0 +1,617 @@
+/*
+ * Purple - Internet Messaging Library
+ * Copyright (C) Pidgin Developers <devel@pidgin.im>
+ *
+ * Purple is the legal property of its developers, whose names are too numerous
+ * to list here. Please refer to the COPYRIGHT file distributed with this
+ * source distribution.
+ *
+ * This library is free software; you can redistribute it and/or modify it
+ * under the terms of the GNU General Public License as published by the Free
+ * Software Foundation; either version 2 of the License, or (at your option)
+ * any later version.
+ *
+ * This library is distributed in the hope that it will be useful, but WITHOUT
+ * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or
+ * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for
+ * more details.
+ *
+ * You should have received a copy of the GNU General Public License along with
+ * this library; if not, see <https://www.gnu.org/licenses/>.
+ */
+
+#include <birb/birb.h>
+
+#include "purplescheduledtask.h"
+
+#include "purpleenums.h"
+
+struct _PurpleScheduledTask {
+	GObject parent;
+
+	gboolean cancellable;
+	GDateTime *execute_at;
+	char *id;
+	gboolean persistent;
+	guint source_id;
+	PurpleScheduledTaskState state;
+	char *subtitle;
+	PurpleTags *tags;
+	char *task_type;
+	char *title;
+};
+
+enum {
+	PROP_0,
+	PROP_CANCELLABLE,
+	PROP_EXECUTE_AT,
+	PROP_ID,
+	PROP_PERSISTENT,
+	PROP_STATE,
+	PROP_SUBTITLE,
+	PROP_TAGS,
+	PROP_TASK_TYPE,
+	PROP_TITLE,
+	N_PROPERTIES,
+};
+static GParamSpec *properties[N_PROPERTIES] = {NULL, };
+
+enum {
+	SIG_EXECUTE,
+	N_SIGNALS,
+};
+static guint signals[N_SIGNALS] = {0, };
+
+G_DEFINE_QUARK(purple-scheduled-task-error, purple_scheduled_task_error)
+
+/******************************************************************************
+ * Helpers
+ *****************************************************************************/
+static void
+purple_scheduled_task_set_cancellable(PurpleScheduledTask *task,
+                                      gboolean cancellable)
+{
+	g_return_if_fail(PURPLE_IS_SCHEDULED_TASK(task));
+
+	if(task->cancellable != cancellable) {
+		task->cancellable = cancellable;
+
+		g_object_notify_by_pspec(G_OBJECT(task), properties[PROP_CANCELLABLE]);
+	}
+}
+
+static void
+purple_scheduled_task_set_id(PurpleScheduledTask *task, const char *id) {
+	g_return_if_fail(PURPLE_IS_SCHEDULED_TASK(task));
+
+	if(g_set_str(&task->id, id)) {
+		g_object_notify_by_pspec(G_OBJECT(task), properties[PROP_ID]);
+	}
+}
+
+static void
+purple_scheduled_task_set_task_type(PurpleScheduledTask *task,
+                                    const char *task_type)
+{
+	g_return_if_fail(PURPLE_IS_SCHEDULED_TASK(task));
+
+	if(g_set_str(&task->task_type, task_type)) {
+		g_object_notify_by_pspec(G_OBJECT(task), properties[PROP_TASK_TYPE]);
+	}
+}
+
+static void
+purple_scheduled_task_set_title(PurpleScheduledTask *task,
+                                const char *title)
+{
+	g_return_if_fail(PURPLE_IS_SCHEDULED_TASK(task));
+
+	if(g_set_str(&task->title, title)) {
+		g_object_notify_by_pspec(G_OBJECT(task), properties[PROP_TITLE]);
+	}
+}
+
+/******************************************************************************
+ * Callbacks
+ *****************************************************************************/
+static gboolean
+purple_scheduled_task_timeout_cb(gpointer data) {
+	PurpleScheduledTask *task = data;
+
+	task->state = PURPLE_SCHEDULED_TASK_STATE_EXECUTING;
+	g_object_notify_by_pspec(G_OBJECT(task), properties[PROP_STATE]);
+
+	g_signal_emit(data, signals[SIG_EXECUTE], 0);
+
+	task->state = PURPLE_SCHEDULED_TASK_STATE_EXECUTED;
+	g_object_notify_by_pspec(G_OBJECT(task), properties[PROP_STATE]);
+
+	task->source_id = 0;
+
+	return G_SOURCE_REMOVE;
+}
+
+/******************************************************************************
+ * GObject Implementation
+ *****************************************************************************/
+G_DEFINE_FINAL_TYPE(PurpleScheduledTask, purple_scheduled_task, G_TYPE_OBJECT)
+
+static void
+purple_scheduled_task_dispose(GObject *obj) {
+	PurpleScheduledTask *task = PURPLE_SCHEDULED_TASK(obj);
+
+	g_clear_handle_id(&task->source_id, g_source_remove);
+
+	G_OBJECT_CLASS(purple_scheduled_task_parent_class)->dispose(obj);
+}
+
+static void
+purple_scheduled_task_finalize(GObject *obj) {
+	PurpleScheduledTask *task = PURPLE_SCHEDULED_TASK(obj);
+
+	g_clear_pointer(&task->execute_at, g_date_time_unref);
+	g_clear_pointer(&task->id, g_free);
+	g_clear_pointer(&task->subtitle, g_free);
+	g_clear_object(&task->tags);
+	g_clear_pointer(&task->task_type, g_free);
+	g_clear_pointer(&task->title, g_free);
+
+	G_OBJECT_CLASS(purple_scheduled_task_parent_class)->finalize(obj);
+}
+
+static void
+purple_scheduled_task_get_property(GObject *obj, guint param_id, GValue *value,
+                                   GParamSpec *pspec)
+{
+	PurpleScheduledTask *task = PURPLE_SCHEDULED_TASK(obj);
+
+	switch(param_id) {
+	case PROP_CANCELLABLE:
+		g_value_set_boolean(value,
+		                    purple_scheduled_task_get_cancellable(task));
+		break;
+	case PROP_EXECUTE_AT:
+		g_value_set_boxed(value, purple_scheduled_task_get_execute_at(task));
+		break;
+	case PROP_ID:
+		g_value_set_string(value, purple_scheduled_task_get_id(task));
+		break;
+	case PROP_PERSISTENT:
+		g_value_set_boolean(value, purple_scheduled_task_get_persistent(task));
+		break;
+	case PROP_STATE:
+		g_value_set_enum(value, purple_scheduled_task_get_state(task));
+		break;
+	case PROP_SUBTITLE:
+		g_value_set_string(value, purple_scheduled_task_get_subtitle(task));
+		break;
+	case PROP_TAGS:
+		g_value_set_object(value, purple_scheduled_task_get_tags(task));
+		break;
+	case PROP_TASK_TYPE:
+		g_value_set_string(value, purple_scheduled_task_get_task_type(task));
+		break;
+	case PROP_TITLE:
+		g_value_set_string(value, purple_scheduled_task_get_title(task));
+		break;
+	default:
+		G_OBJECT_WARN_INVALID_PROPERTY_ID(obj, param_id, pspec);
+		break;
+	}
+}
+
+static void
+purple_scheduled_task_set_property(GObject *obj, guint param_id,
+                                   const GValue *value, GParamSpec *pspec)
+{
+	PurpleScheduledTask *task = PURPLE_SCHEDULED_TASK(obj);
+
+	switch(param_id) {
+	case PROP_CANCELLABLE:
+		purple_scheduled_task_set_cancellable(task,
+		                                      g_value_get_boolean(value));
+		break;
+	case PROP_ID:
+		purple_scheduled_task_set_id(task, g_value_get_string(value));
+		break;
+	case PROP_PERSISTENT:
+		purple_scheduled_task_set_persistent(task, g_value_get_boolean(value));
+		break;
+	case PROP_SUBTITLE:
+		purple_scheduled_task_set_subtitle(task, g_value_get_string(value));
+		break;
+	case PROP_TASK_TYPE:
+		purple_scheduled_task_set_task_type(task, g_value_get_string(value));
+		break;
+	case PROP_TITLE:
+		purple_scheduled_task_set_title(task, g_value_get_string(value));
+		break;
+	default:
+		G_OBJECT_WARN_INVALID_PROPERTY_ID(obj, param_id, pspec);
+		break;
+	}
+}
+
+static void
+purple_scheduled_task_init(PurpleScheduledTask *task) {
+	task->state = PURPLE_SCHEDULED_TASK_STATE_UNSCHEDULED;
+	task->tags = purple_tags_new();
+}
+
+static void
+purple_scheduled_task_class_init(PurpleScheduledTaskClass *klass) {
+	GObjectClass *obj_class = G_OBJECT_CLASS(klass);
+
+	obj_class->dispose = purple_scheduled_task_dispose;
+	obj_class->finalize = purple_scheduled_task_finalize;
+	obj_class->get_property = purple_scheduled_task_get_property;
+	obj_class->set_property = purple_scheduled_task_set_property;
+
+	/**
+	 * PurpleScheduledTask:cancellable:
+	 *
+	 * Whether or not the task can be cancelled by the user.
+	 *
+	 * Since: 3.0
+	 */
+	properties[PROP_CANCELLABLE] = g_param_spec_boolean(
+		"cancellable", NULL, NULL,
+		TRUE,
+		G_PARAM_READWRITE | G_PARAM_CONSTRUCT_ONLY | G_PARAM_STATIC_STRINGS |
+		G_PARAM_EXPLICIT_NOTIFY);
+
+	/**
+	 * PurpleScheduledTask:execute-at:
+	 *
+	 * The date time when the task will be executed.
+	 *
+	 * Since: 3.0
+	 */
+	properties[PROP_EXECUTE_AT] = g_param_spec_boxed(
+		"execute-at", NULL, NULL,
+		G_TYPE_DATE_TIME,
+		G_PARAM_READABLE | G_PARAM_STATIC_STRINGS);
+
+	/**
+	 * PurpleScheduledTask:id:
+	 *
+	 * The id of the task.
+	 *
+	 * This is primarily for internal use.
+	 *
+	 * Since: 3.0
+	 */
+	properties[PROP_ID] = g_param_spec_string(
+		"id", NULL, NULL,
+		NULL,
+		G_PARAM_READWRITE | G_PARAM_CONSTRUCT_ONLY | G_PARAM_STATIC_STRINGS |
+		G_PARAM_EXPLICIT_NOTIFY);
+
+	/**
+	 * PurpleScheduledTask:persistent:
+	 *
+	 * Whether or not the task should remembered across invocations of the
+	 * program.
+	 *
+	 * Since: 3.0
+	 */
+	properties[PROP_PERSISTENT] = g_param_spec_boolean(
+		"persistent", NULL, NULL,
+		FALSE,
+		G_PARAM_READWRITE | G_PARAM_STATIC_STRINGS | G_PARAM_EXPLICIT_NOTIFY);
+
+	/**
+	 * PurpleScheduledTask:state:
+	 *
+	 * The state of the task.
+	 *
+	 * Since: 3.0
+	 */
+	properties[PROP_STATE] = g_param_spec_enum(
+		"state", NULL, NULL,
+		PURPLE_TYPE_SCHEDULED_TASK_STATE,
+		PURPLE_SCHEDULED_TASK_STATE_UNSCHEDULED,
+		G_PARAM_READABLE | G_PARAM_STATIC_STRINGS | G_PARAM_EXPLICIT_NOTIFY);
+
+	/**
+	 * PurpleScheduledTask:subtitle:
+	 *
+	 * The subtitle for the task.
+	 *
+	 * Since: 3.0
+	 */
+	properties[PROP_SUBTITLE] = g_param_spec_string(
+		"subtitle", NULL, NULL,
+		NULL,
+		G_PARAM_READWRITE | G_PARAM_STATIC_STRINGS | G_PARAM_EXPLICIT_NOTIFY);
+
+	/**
+	 * PurpleScheduledTask:tags:
+	 *
+	 * The tags for the task.
+	 *
+	 * Since: 3.0
+	 */
+	properties[PROP_TAGS] = g_param_spec_object(
+		"tags", NULL, NULL,
+		PURPLE_TYPE_TAGS,
+		G_PARAM_READABLE | G_PARAM_STATIC_STRINGS);
+
+	/**
+	 * PurpleScheduledTask:task-type:
+	 *
+	 * The type of the task.
+	 *
+	 * Since: 3.0
+	 */
+	properties[PROP_TASK_TYPE] = g_param_spec_string(
+		"task-type", NULL, NULL,
+		NULL,
+		G_PARAM_READWRITE | G_PARAM_CONSTRUCT_ONLY | G_PARAM_STATIC_STRINGS |
+		G_PARAM_EXPLICIT_NOTIFY);
+
+	/**
+	 * PurpleScheduledTask:title:
+	 *
+	 * The title of the task.
+	 *
+	 * Since: 3.0
+	 */
+	properties[PROP_TITLE] = g_param_spec_string(
+		"title", NULL, NULL,
+		NULL,
+		G_PARAM_READWRITE | G_PARAM_CONSTRUCT_ONLY | G_PARAM_STATIC_STRINGS |
+		G_PARAM_EXPLICIT_NOTIFY);
+
+	g_object_class_install_properties(obj_class, N_PROPERTIES, properties);
+
+	/**
+	 * PurpleScheduledTask::execute:
+	 * @task: the instance
+	 *
+	 * This signal is emitted the task is executing.
+	 *
+	 * The execution of the task is scheduled with
+	 * [method@ScheduledTask.schedule].
+	 *
+	 * Since: 3.0
+	 */
+	signals[SIG_EXECUTE] = g_signal_new_class_handler(
+		"execute",
+		G_OBJECT_CLASS_TYPE(klass),
+		G_SIGNAL_RUN_LAST | G_SIGNAL_DETAILED,
+		NULL,
+		NULL,
+		NULL,
+		NULL,
+		G_TYPE_NONE,
+		0);
+}
+
+/******************************************************************************
+ * Public API
+ *****************************************************************************/
+void
+purple_scheduled_task_cancel(PurpleScheduledTask *task) {
+	GObject *obj = NULL;
+
+	g_return_if_fail(PURPLE_IS_SCHEDULED_TASK(task));
+
+	if(task->state != PURPLE_SCHEDULED_TASK_STATE_SCHEDULED) {
+		return;
+	}
+
+	if(!task->cancellable) {
+		return;
+	}
+
+	g_clear_pointer(&task->execute_at, g_date_time_unref);
+	g_clear_handle_id(&task->source_id, g_source_remove);
+
+	task->state = PURPLE_SCHEDULED_TASK_STATE_CANCELLED;
+
+	obj = G_OBJECT(task);
+	g_object_freeze_notify(obj);
+	g_object_notify_by_pspec(obj, properties[PROP_EXECUTE_AT]);
+	g_object_notify_by_pspec(obj, properties[PROP_STATE]);
+	g_object_thaw_notify(obj);
+}
+
+gboolean
+purple_scheduled_task_get_cancellable(PurpleScheduledTask *task) {
+	g_return_val_if_fail(PURPLE_IS_SCHEDULED_TASK(task), FALSE);
+
+	/* execute-at gets set to NULL when executed, so if it's NULL we can't
+	 * cancel a task that's already been executed.
+	 */
+	if(task->cancellable && task->execute_at == NULL) {
+		return FALSE;
+	}
+
+	return task->cancellable;
+}
+
+GDateTime *
+purple_scheduled_task_get_execute_at(PurpleScheduledTask *task) {
+	g_return_val_if_fail(PURPLE_IS_SCHEDULED_TASK(task), NULL);
+
+	return task->execute_at;
+}
+
+const char *
+purple_scheduled_task_get_id(PurpleScheduledTask *task) {
+	g_return_val_if_fail(PURPLE_IS_SCHEDULED_TASK(task), NULL);
+
+	/* If we weren't given an ID at construction generate one on the fly. */
+	if(birb_str_is_empty(task->id)) {
+		task->id = g_uuid_string_random();
+	}
+
+	return task->id;
+}
+
+gboolean
+purple_scheduled_task_get_persistent(PurpleScheduledTask *task) {
+	g_return_val_if_fail(PURPLE_IS_SCHEDULED_TASK(task), FALSE);
+
+	return task->persistent;
+}
+
+PurpleScheduledTaskState
+purple_scheduled_task_get_state(PurpleScheduledTask *task) {
+	g_return_val_if_fail(PURPLE_IS_SCHEDULED_TASK(task),
+	                     PURPLE_SCHEDULED_TASK_STATE_UNSCHEDULED);
+
+	return task->state;
+}
+
+const char *
+purple_scheduled_task_get_subtitle(PurpleScheduledTask *task) {
+	g_return_val_if_fail(PURPLE_IS_SCHEDULED_TASK(task), NULL);
+
+	return task->subtitle;
+}
+
+PurpleTags *
+purple_scheduled_task_get_tags(PurpleScheduledTask *task) {
+	g_return_val_if_fail(PURPLE_IS_SCHEDULED_TASK(task), NULL);
+
+	return task->tags;
+}
+
+const char *
+purple_scheduled_task_get_task_type(PurpleScheduledTask *task) {
+	g_return_val_if_fail(PURPLE_IS_SCHEDULED_TASK(task), NULL);
+
+	return task->task_type;
+}
+
+const char *
+purple_scheduled_task_get_title(PurpleScheduledTask *task) {
+	g_return_val_if_fail(PURPLE_IS_SCHEDULED_TASK(task), NULL);
+
+	return task->title;
+}
+
+PurpleScheduledTask *
+purple_scheduled_task_new(const char *task_type, const char *title,
+                          gboolean cancellable)
+{
+	g_return_val_if_fail(!birb_str_is_empty(task_type), NULL);
+	g_return_val_if_fail(!birb_str_is_empty(title), NULL);
+
+	return g_object_new(
+		PURPLE_TYPE_SCHEDULED_TASK,
+		"cancellable", cancellable,
+		"task-type", task_type,
+		"title", title,
+		NULL);
+}
+
+gboolean
+purple_scheduled_task_schedule(PurpleScheduledTask *task,
+                               GDateTime *execute_at, GError **error)
+{
+	GDateTime *now = NULL;
+	GObject *obj = NULL;
+	GTimeSpan difference = 0;
+
+	g_return_val_if_fail(PURPLE_IS_SCHEDULED_TASK(task), FALSE);
+	g_return_val_if_fail(execute_at != NULL, FALSE);
+
+	if(task->state == PURPLE_SCHEDULED_TASK_STATE_EXECUTING) {
+		g_set_error(error,
+		            PURPLE_SCHEDULED_TASK_ERROR,
+		            PURPLE_SCHEDULED_TASK_ERROR_RESCHEDULE_EXECUTING_TASK,
+		            "can not reschedule a task that is currently executing");
+
+		return FALSE;
+	}
+
+	if(task->state == PURPLE_SCHEDULED_TASK_STATE_SCHEDULED) {
+		purple_scheduled_task_cancel(task);
+	}
+
+	/* Check if our execute_at is valid. */
+	now = g_date_time_new_now_local();
+	difference = g_date_time_difference(execute_at, now);
+	g_date_time_unref(now);
+
+	if(difference < 0) {
+		char *iso8601 = g_date_time_format_iso8601(execute_at);
+
+		g_set_error(error,
+		            PURPLE_SCHEDULED_TASK_ERROR,
+		            PURPLE_SCHEDULED_TASK_ERROR_EXECUTE_AT_IN_PAST,
+		            "%s is in the past", iso8601);
+
+		g_free(iso8601);
+
+		return FALSE;
+	}
+
+	/* Save the execute_at. */
+	g_clear_pointer(&task->execute_at, g_date_time_unref);
+	task->execute_at = g_date_time_ref(execute_at);
+
+	task->source_id = g_timeout_add_seconds_full(G_PRIORITY_DEFAULT,
+	                                             difference / G_TIME_SPAN_SECOND,
+	                                             purple_scheduled_task_timeout_cb,
+	                                             g_object_ref(task),
+	                                             g_object_unref);
+
+	task->state = PURPLE_SCHEDULED_TASK_STATE_SCHEDULED;
+
+	obj = G_OBJECT(task);
+	g_object_freeze_notify(obj);
+	g_object_notify_by_pspec(obj, properties[PROP_EXECUTE_AT]);
+	g_object_notify_by_pspec(obj, properties[PROP_STATE]);
+	g_object_thaw_notify(obj);
+
+	return TRUE;
+}
+
+gboolean
+purple_scheduled_task_schedule_relative(PurpleScheduledTask *task,
+                                        GTimeSpan when, GError **error)
+{
+	GDateTime *now = NULL;
+	GDateTime *execute_at = NULL;
+	gboolean ret = FALSE;
+
+	g_return_val_if_fail(PURPLE_IS_SCHEDULED_TASK(task), FALSE);
+
+	now = g_date_time_new_now_local();
+	execute_at = g_date_time_add(now, when);
+	g_date_time_unref(now);
+
+	ret = purple_scheduled_task_schedule(task, execute_at, error);
+
+	g_date_time_unref(execute_at);
+
+	return ret;
+}
+
+void
+purple_scheduled_task_set_persistent(PurpleScheduledTask *task,
+                                     gboolean persistent)
+{
+	g_return_if_fail(PURPLE_IS_SCHEDULED_TASK(task));
+
+	if(task->persistent != persistent) {
+		task->persistent = persistent;
+
+		g_object_notify_by_pspec(G_OBJECT(task), properties[PROP_PERSISTENT]);
+	}
+}
+
+void
+purple_scheduled_task_set_subtitle(PurpleScheduledTask *task,
+                                   const char *subtitle)
+{
+	g_return_if_fail(PURPLE_IS_SCHEDULED_TASK(task));
+
+	if(g_set_str(&task->subtitle, subtitle)) {
+		g_object_notify_by_pspec(G_OBJECT(task), properties[PROP_SUBTITLE]);
+	}
+}
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/libpurple/purplescheduledtask.h	Thu Jul 24 23:33:18 2025 -0500
@@ -0,0 +1,308 @@
+/*
+ * Purple - Internet Messaging Library
+ * Copyright (C) Pidgin Developers <devel@pidgin.im>
+ *
+ * Purple is the legal property of its developers, whose names are too numerous
+ * to list here. Please refer to the COPYRIGHT file distributed with this
+ * source distribution.
+ *
+ * This library is free software; you can redistribute it and/or modify it
+ * under the terms of the GNU General Public License as published by the Free
+ * Software Foundation; either version 2 of the License, or (at your option)
+ * any later version.
+ *
+ * This library is distributed in the hope that it will be useful, but WITHOUT
+ * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or
+ * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for
+ * more details.
+ *
+ * You should have received a copy of the GNU General Public License along with
+ * this library; if not, see <https://www.gnu.org/licenses/>.
+ */
+
+#if !defined(PURPLE_GLOBAL_HEADER_INSIDE) && !defined(PURPLE_COMPILATION)
+# error "only <purple.h> may be included directly"
+#endif
+
+#ifndef PURPLE_SCHEDULED_TASK_H
+#define PURPLE_SCHEDULED_TASK_H
+
+#include <glib.h>
+#include <glib-object.h>
+
+#include "purpletags.h"
+#include "purpleversion.h"
+
+G_BEGIN_DECLS
+
+#define PURPLE_SCHEDULED_TASK_ERROR purple_scheduled_task_error_quark()
+
+/**
+ * PurpleScheduledTaskError:
+ * @PURPLE_SCHEDULED_TASK_ERROR_EXECUTE_AT_IN_PAST: The given time is in the
+ *  past.
+ * @PURPLE_SCHEDULED_TASK_ERROR_RESCHEDULE_EXECUTING_TASK: A task that is
+ *  currently executing can not be cancelled.
+ *
+ * Error codes returned by scheduled tasks.
+ *
+ * Since: 3.0
+ */
+typedef enum {
+	PURPLE_SCHEDULED_TASK_ERROR_EXECUTE_AT_IN_PAST PURPLE_AVAILABLE_ENUMERATOR_IN_3_0,
+	PURPLE_SCHEDULED_TASK_ERROR_RESCHEDULE_EXECUTING_TASK PURPLE_AVAILABLE_ENUMERATOR_IN_3_0,
+} PurpleScheduledTaskError;
+
+/**
+ * purple_scheduled_task_error_quark:
+ *
+ * The error domain to identify errors with scheduled tasks.
+ *
+ * Returns: The error domain.
+ *
+ * Since: 3.0
+ */
+PURPLE_AVAILABLE_IN_3_0
+GQuark purple_scheduled_task_error_quark(void);
+
+/**
+ * PurpleScheduledTaskState:
+ * @PURPLE_SCHEDULED_TASK_STATE_UNSCHEDULED: the task has not yet been
+ *                                           scheduled
+ * @PURPLE_SCHEDULED_TASK_STATE_SCHEDULED: the task has been scheduled but not
+ *                                         yet executed
+ * @PURPLE_SCHEDULED_TASK_STATE_CANCELLED: the task has been cancelled
+ * @PURPLE_SCHEDULED_TASK_STATE_EXECUTING: the task is currently executing
+ * @PURPLE_SCHEDULED_TASK_STATE_EXECUTED: the task has been executed
+ *
+ *
+ * The possible states that a task can be in.
+ *
+ * Since: 3.0
+ */
+typedef enum {
+	PURPLE_SCHEDULED_TASK_STATE_UNSCHEDULED PURPLE_AVAILABLE_ENUMERATOR_IN_3_0,
+	PURPLE_SCHEDULED_TASK_STATE_SCHEDULED PURPLE_AVAILABLE_ENUMERATOR_IN_3_0,
+	PURPLE_SCHEDULED_TASK_STATE_CANCELLED PURPLE_AVAILABLE_ENUMERATOR_IN_3_0,
+	PURPLE_SCHEDULED_TASK_STATE_EXECUTING PURPLE_AVAILABLE_ENUMERATOR_IN_3_0,
+	PURPLE_SCHEDULED_TASK_STATE_EXECUTED PURPLE_AVAILABLE_ENUMERATOR_IN_3_0,
+} PurpleScheduledTaskState;
+
+/**
+ * PurpleScheduledTask:
+ *
+ * An object that represents a scheduled task.
+ *
+ * Since: 3.0
+ */
+
+#define PURPLE_TYPE_SCHEDULED_TASK (purple_scheduled_task_get_type())
+
+PURPLE_AVAILABLE_IN_3_0
+G_DECLARE_FINAL_TYPE(PurpleScheduledTask, purple_scheduled_task, PURPLE,
+                     SCHEDULED_TASK, GObject)
+
+/**
+ * purple_scheduled_task_cancel:
+ *
+ * Cancels the task.
+ *
+ * If the task is not scheduled, this does nothing.
+ *
+ * Since: 3.0
+ */
+PURPLE_AVAILABLE_IN_3_0
+void purple_scheduled_task_cancel(PurpleScheduledTask *task);
+
+/**
+ * purple_scheduled_task_get_cancellable:
+ *
+ * Gets whether or not the task can be cancelled by the user.
+ *
+ * Returns: true if the task can be cancelled.
+ *
+ * Since: 3.0
+ */
+PURPLE_AVAILABLE_IN_3_0
+gboolean purple_scheduled_task_get_cancellable(PurpleScheduledTask *task);
+
+/**
+ * purple_scheduled_task_get_execute_at:
+ *
+ * Gets the date time when the task will execute.
+ *
+ * Returns: (transfer none) (nullable): The date time of when the task will
+ *          execute.
+ *
+ * Since: 3.0
+ */
+PURPLE_AVAILABLE_IN_3_0
+GDateTime *purple_scheduled_task_get_execute_at(PurpleScheduledTask *task);
+
+/**
+ * purple_scheduled_task_get_id:
+ *
+ * Gets the id of the task.
+ *
+ * Returns: (transfer none) (nullable): The id of the task.
+ *
+ * Since: 3.0
+ */
+PURPLE_AVAILABLE_IN_3_0
+const char *purple_scheduled_task_get_id(PurpleScheduledTask *task);
+
+/**
+ * purple_scheduled_task_get_persistent:
+ *
+ * Gets whether or not the task will be remembered across invocations of the
+ * program.
+ *
+ * Returns: true if the task will be remembered.
+ *
+ * Since: 3.0
+ */
+PURPLE_AVAILABLE_IN_3_0
+gboolean purple_scheduled_task_get_persistent(PurpleScheduledTask *task);
+
+/**
+ * purple_scheduled_task_get_state:
+ *
+ * Gets the current state of the task.
+ *
+ * Returns: The state of the task.
+ *
+ * Since: 3.0
+ */
+PURPLE_AVAILABLE_IN_3_0
+PurpleScheduledTaskState purple_scheduled_task_get_state(PurpleScheduledTask *task);
+
+/**
+ * purple_scheduled_task_get_subtitle:
+ *
+ * Gets the subtitle of the task.
+ *
+ * Returns: (nullable): The subtitle of the task.
+ *
+ * Since: 3.0
+ */
+PURPLE_AVAILABLE_IN_3_0
+const char *purple_scheduled_task_get_subtitle(PurpleScheduledTask *task);
+
+/**
+ * purple_scheduled_task_get_tags:
+ *
+ * Gets the tags for the task.
+ *
+ * Returns: (transfer none): The tags.
+ *
+ * Since: 3.0
+ */
+PURPLE_AVAILABLE_IN_3_0
+PurpleTags *purple_scheduled_task_get_tags(PurpleScheduledTask *task);
+
+/**
+ * purple_scheduled_task_get_task_type:
+ *
+ * Gets the type of the task.
+ *
+ * Returns: The type of the task.
+ *
+ * Since: 3.0
+ */
+PURPLE_AVAILABLE_IN_3_0
+const char *purple_scheduled_task_get_task_type(PurpleScheduledTask *task);
+
+/**
+ * purple_scheduled_task_get_title:
+ *
+ * Gets the title of the task.
+ *
+ * Returns: The title.
+ *
+ * Since: 3.0
+ */
+PURPLE_AVAILABLE_IN_3_0
+const char *purple_scheduled_task_get_title(PurpleScheduledTask *task);
+
+/**
+ * purple_scheduled_task_new:
+ * @task_type: a well-known type name
+ * @title: a title that can be displayed to users
+ * @cancellable: true if the task can be cancelled by the user; false
+ *               otherwise
+ *
+ * Creates a new scheduled task.
+ *
+ * Returns: (transfer full) (nullable): The new scheduled task.
+ *
+ * Since: 3.0
+ */
+PURPLE_AVAILABLE_IN_3_0
+PurpleScheduledTask *purple_scheduled_task_new(const char *task_type, const char *title, gboolean cancellable);
+
+/**
+ * purple_scheduled_task_schedule:
+ * @execute_at: the date time for when the task should be executed
+ * @error: (out) (nullable): a return address for a #GError
+ *
+ * Schedules the task.
+ *
+ * If the task has already been scheduled it will be cancelled and rescheduled
+ * for the new time.
+ *
+ * If the @execute_at is in the past an error will be returned.
+ *
+ * Returns: true if the task was scheduled successfully.
+ *
+ * Since: 3.0
+ */
+PURPLE_AVAILABLE_IN_3_0
+gboolean purple_scheduled_task_schedule(PurpleScheduledTask *task, GDateTime *execute_at, GError **error);
+
+/**
+ * purple_scheduled_task_schedule_relative:
+ * @when: a relative time span when to execute
+ * @error: (out) (nullable): a return address for a #GError
+ *
+ * Schedules the task with a relative time.
+ *
+ * This is a wrapper around [method@ScheduledTask.schedule] that will add @when
+ * to the current time for you.
+ *
+ * If the task has already been scheduled it will be cancelled and rescheduled
+ * for the new time.
+ *
+ * If the @when is in the past an error will be returned.
+ *
+ * Returns: true if the task was scheduled successfully.
+ *
+ * Since: 3.0
+ */
+PURPLE_AVAILABLE_IN_3_0
+gboolean purple_scheduled_task_schedule_relative(PurpleScheduledTask *task, GTimeSpan when, GError **error);
+
+/**
+ * purple_scheduled_task_set_persistent:
+ * @persistent: the new value
+ *
+ * Sets whether the task should be remembered across invocations or not.
+ *
+ * Since: 3.0
+ */
+PURPLE_AVAILABLE_IN_3_0
+void purple_scheduled_task_set_persistent(PurpleScheduledTask *task, gboolean persistent);
+
+/**
+ * purple_scheduled_task_set_subtitle:
+ * @subtitle: the new subtitle
+ *
+ * Sets the subtitle for the task.
+ *
+ * Since: 3.0
+ */
+PURPLE_AVAILABLE_IN_3_0
+void purple_scheduled_task_set_subtitle(PurpleScheduledTask *task, const char *subtitle);
+
+G_END_DECLS
+
+#endif /* PURPLE_SCHEDULED_TASK_H */
--- a/libpurple/tests/meson.build	Tue Jul 22 16:06:33 2025 -0500
+++ b/libpurple/tests/meson.build	Thu Jul 24 23:33:18 2025 -0500
@@ -53,6 +53,7 @@
     'request_group',
     'request_page',
     'saved_presence',
+    'scheduled_task',
     'str',
     'tags',
     'util',
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/libpurple/tests/test_scheduled_task.c	Thu Jul 24 23:33:18 2025 -0500
@@ -0,0 +1,443 @@
+/*
+ * Purple - Internet Messaging Library
+ * Copyright (C) Pidgin Developers <devel@pidgin.im>
+ *
+ * This library is free software; you can redistribute it and/or
+ * modify it under the terms of the GNU Lesser General Public
+ * License as published by the Free Software Foundation; either
+ * version 2 of the License, or (at your option) any later version.
+ *
+ * This library is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the GNU
+ * Lesser General Public License for more details.
+ *
+ * You should have received a copy of the GNU Lesser General Public
+ * License along with this library; if not, see <https://www.gnu.org/licenses/>.
+ */
+
+#include <glib.h>
+
+#include <birb.h>
+
+#include <purple.h>
+
+/******************************************************************************
+ * Tests
+ *****************************************************************************/
+static void
+test_purple_scheduled_task_new(void) {
+	PurpleScheduledTask *task = NULL;
+
+	task = purple_scheduled_task_new("test-task", "A task for testing", TRUE);
+
+	birb_assert_type(task, PURPLE_TYPE_SCHEDULED_TASK);
+
+	g_assert_finalize_object(task);
+}
+
+static void
+test_purple_scheduled_task_properties(void) {
+	PurpleScheduledTask *task = NULL;
+	PurpleScheduledTaskState state = PURPLE_SCHEDULED_TASK_STATE_UNSCHEDULED;
+	PurpleTags *tags = NULL;
+	GDateTime *execute_at = NULL;
+	gboolean cancellable = TRUE;
+	gboolean persistent = FALSE;
+	char *id = NULL;
+	char *subtitle = NULL;
+	char *task_type = NULL;
+	char *title = NULL;
+
+	task = g_object_new(
+		PURPLE_TYPE_SCHEDULED_TASK,
+		"cancellable", FALSE,
+		"id", "0xabc123",
+		"persistent", TRUE,
+		"subtitle", "a task for testing",
+		"task-type", "test",
+		"title", "Test",
+		NULL);
+
+	g_assert_true(PURPLE_IS_SCHEDULED_TASK(task));
+
+	g_object_get(
+		G_OBJECT(task),
+		"cancellable", &cancellable,
+		"execute-at", &execute_at,
+		"id", &id,
+		"persistent", &persistent,
+		"state", &state,
+		"subtitle", &subtitle,
+		"tags", &tags,
+		"task-type", &task_type,
+		"title", &title,
+		NULL);
+
+	g_assert_false(cancellable);
+
+	/* The task hasn't been scheduled so it doesn't have an execution time. */
+	g_assert_null(execute_at);
+
+	g_assert_cmpstr(id, ==, "0xabc123");
+	g_clear_pointer(&id, g_free);
+
+	g_assert_true(persistent);
+
+	g_assert_cmpuint(state, ==, PURPLE_SCHEDULED_TASK_STATE_UNSCHEDULED);
+
+	g_assert_cmpstr(subtitle, ==, "a task for testing");
+	g_clear_pointer(&subtitle, g_free);
+
+	birb_assert_type(tags, PURPLE_TYPE_TAGS);
+	g_clear_object(&tags);
+
+	g_assert_cmpstr(task_type, ==, "test");
+	g_clear_pointer(&task_type, g_free);
+
+	g_assert_cmpstr(title, ==, "Test");
+	g_clear_pointer(&title, g_free);
+
+	g_assert_finalize_object(task);
+}
+
+/******************************************************************************
+ * Schedule Tests
+ *****************************************************************************/
+static void
+test_purple_scheduled_task_executed_counter_cb(PurpleScheduledTask *task,
+                                               gpointer data)
+{
+	PurpleScheduledTaskState state = PURPLE_SCHEDULED_TASK_STATE_UNSCHEDULED;
+	guint *counter = data;
+
+	birb_assert_type(task, PURPLE_TYPE_SCHEDULED_TASK);
+
+	state = purple_scheduled_task_get_state(task);
+	g_assert_cmpuint(state, ==, PURPLE_SCHEDULED_TASK_STATE_EXECUTING);
+
+	*counter = *counter + 1;
+}
+
+static void
+test_purple_scheduled_task_executed_cancel_cb(PurpleScheduledTask *task,
+                                              G_GNUC_UNUSED gpointer data)
+{
+	GError *error = NULL;
+	gboolean result = FALSE;
+
+	result = purple_scheduled_task_schedule_relative(task,
+	                                                 10 * G_TIME_SPAN_MINUTE,
+	                                                 &error);
+	g_assert_error(error,
+	               PURPLE_SCHEDULED_TASK_ERROR,
+	               PURPLE_SCHEDULED_TASK_ERROR_RESCHEDULE_EXECUTING_TASK);
+	g_clear_error(&error);
+	g_assert_false(result);
+}
+
+static void
+test_purple_scheduled_task_executed_quit_cb(PurpleScheduledTask *task,
+                                            gpointer data)
+{
+	birb_assert_type(task, PURPLE_TYPE_SCHEDULED_TASK);
+
+	g_main_loop_quit(data);
+}
+
+static void
+test_purple_scheduled_task_main_loop_timeout_cb(gpointer data) {
+	g_main_loop_quit(data);
+
+	g_assert_not_reached();
+}
+
+static void
+test_purple_scheduled_task_schedule_normal(void) {
+	PurpleScheduledTask *task = NULL;
+	PurpleScheduledTaskState state = PURPLE_SCHEDULED_TASK_STATE_UNSCHEDULED;
+	GDateTime *execute_at = NULL;
+	GError *error = NULL;
+	GMainLoop *loop = NULL;
+	guint counter = 0;
+	gboolean result = FALSE;
+
+	task = purple_scheduled_task_new("test", "Test task", TRUE);
+	g_signal_connect(task, "execute",
+	                 G_CALLBACK(test_purple_scheduled_task_executed_counter_cb),
+	                 &counter);
+
+	/* Add another signal handler to verify that you can't reschedule an
+	 * executing task.
+	 */
+	g_signal_connect(task, "execute",
+	                 G_CALLBACK(test_purple_scheduled_task_executed_cancel_cb),
+	                 NULL);
+
+	/* Verify the default state is unscheduled. */
+	state = purple_scheduled_task_get_state(task);
+	g_assert_cmpuint(state, ==, PURPLE_SCHEDULED_TASK_STATE_UNSCHEDULED);
+
+	/* Make sure that we don't currently have an execute-at. */
+	execute_at = purple_scheduled_task_get_execute_at(task);
+	g_assert_null(execute_at);
+
+	/* Now schedule the task to execute 10 milliseconds from now. */
+	result = purple_scheduled_task_schedule_relative(task,
+	                                                 10 * G_TIME_SPAN_MILLISECOND,
+	                                                 &error);
+	g_assert_no_error(error);
+	g_assert_true(result);
+
+	/* Make sure the execute-at property got set. */
+	execute_at = purple_scheduled_task_get_execute_at(task);
+	g_assert_nonnull(execute_at);
+
+	/* Make sure the state was set to scheduled. */
+	state = purple_scheduled_task_get_state(task);
+	g_assert_cmpuint(state, ==, PURPLE_SCHEDULED_TASK_STATE_SCHEDULED);
+
+	/* Create our main loop and add a timeout to avoid runaways. */
+	loop = g_main_loop_new(NULL, FALSE);
+	g_signal_connect(task, "execute",
+	                 G_CALLBACK(test_purple_scheduled_task_executed_quit_cb),
+	                 loop);
+
+	g_timeout_add_seconds_once(10,
+	                           test_purple_scheduled_task_main_loop_timeout_cb,
+	                           loop);
+	g_main_loop_run(loop);
+	g_main_loop_unref(loop);
+
+	/* Make sure that the execute signal was emitted. */
+	g_assert_cmpuint(counter, ==, 1);
+
+	/* Make sure the state was updated to executed. */
+	state = purple_scheduled_task_get_state(task);
+	g_assert_cmpuint(state, ==, PURPLE_SCHEDULED_TASK_STATE_EXECUTED);
+
+	g_assert_finalize_object(task);
+}
+
+static void
+test_purple_scheduled_task_schedule_cancelled(void) {
+	PurpleScheduledTask *task = NULL;
+	PurpleScheduledTaskState state = PURPLE_SCHEDULED_TASK_STATE_UNSCHEDULED;
+	GError *error = NULL;
+	gboolean result = FALSE;
+
+	task = purple_scheduled_task_new("test", "Test task", TRUE);
+
+	state = purple_scheduled_task_get_state(task);
+	g_assert_cmpuint(state, ==, PURPLE_SCHEDULED_TASK_STATE_UNSCHEDULED);
+
+	/* Cancelling a task that is not currently scheduled should do nothing. */
+	purple_scheduled_task_cancel(task);
+
+	state = purple_scheduled_task_get_state(task);
+	g_assert_cmpuint(state, ==, PURPLE_SCHEDULED_TASK_STATE_UNSCHEDULED);
+
+	/* Schedule the task. */
+	result = purple_scheduled_task_schedule_relative(task,
+	                                                 10 * G_TIME_SPAN_MINUTE,
+	                                                 &error);
+	g_assert_no_error(error);
+	g_assert_true(result);
+
+	state = purple_scheduled_task_get_state(task);
+	g_assert_cmpuint(state, ==, PURPLE_SCHEDULED_TASK_STATE_SCHEDULED);
+
+	/* Cancel the task. */
+	purple_scheduled_task_cancel(task);
+
+	state = purple_scheduled_task_get_state(task);
+	g_assert_cmpuint(state, ==, PURPLE_SCHEDULED_TASK_STATE_CANCELLED);
+
+	g_assert_finalize_object(task);
+}
+
+static void
+test_purple_scheduled_task_schedule_reschedule(void) {
+	PurpleScheduledTask *task = NULL;
+	PurpleScheduledTaskState state = PURPLE_SCHEDULED_TASK_STATE_UNSCHEDULED;
+	GDateTime *now = NULL;
+	GDateTime *later = NULL;
+	GDateTime *execute_at = NULL;
+	GError *error = NULL;
+	gboolean result = FALSE;
+
+	task = purple_scheduled_task_new("test", "Test task", TRUE);
+
+	state = purple_scheduled_task_get_state(task);
+	g_assert_cmpuint(state, ==, PURPLE_SCHEDULED_TASK_STATE_UNSCHEDULED);
+
+	/* Schedule the task. */
+	result = purple_scheduled_task_schedule_relative(task,
+	                                                 10 * G_TIME_SPAN_MINUTE,
+	                                                 &error);
+	g_assert_no_error(error);
+	g_assert_true(result);
+
+	state = purple_scheduled_task_get_state(task);
+	g_assert_cmpuint(state, ==, PURPLE_SCHEDULED_TASK_STATE_SCHEDULED);
+
+	/* Reschedule the task for an hour. */
+	now = g_date_time_new_now_local();
+	later = g_date_time_add(now, 1 * G_TIME_SPAN_HOUR);
+	g_clear_pointer(&now, g_date_time_unref);
+
+	result = purple_scheduled_task_schedule(task, later, &error);
+	g_assert_no_error(error);
+	g_assert_true(result);
+
+	/* Verify that the execute-at property is correct. */
+	execute_at = purple_scheduled_task_get_execute_at(task);
+	g_assert_true(birb_date_time_equal(execute_at, later));
+	g_clear_pointer(&later, g_date_time_unref);
+
+	state = purple_scheduled_task_get_state(task);
+	g_assert_cmpuint(state, ==, PURPLE_SCHEDULED_TASK_STATE_SCHEDULED);
+
+	/* Cancel the task to clear the timeout and the reference it holds. */
+	purple_scheduled_task_cancel(task);
+
+	g_assert_finalize_object(task);
+}
+
+static void
+test_purple_scheduled_task_schedule_past(void) {
+	PurpleScheduledTask *task = NULL;
+	PurpleScheduledTaskState state = PURPLE_SCHEDULED_TASK_STATE_UNSCHEDULED;
+	GError *error = NULL;
+	gboolean result = FALSE;
+
+	task = purple_scheduled_task_new("test", "Test task", TRUE);
+
+	state = purple_scheduled_task_get_state(task);
+	g_assert_cmpuint(state, ==, PURPLE_SCHEDULED_TASK_STATE_UNSCHEDULED);
+
+	/* Schedule the task for 10 minutes ago. */
+	result = purple_scheduled_task_schedule_relative(task,
+	                                                 -10 * G_TIME_SPAN_MINUTE,
+	                                                 &error);
+	g_assert_error(error,
+	               PURPLE_SCHEDULED_TASK_ERROR,
+	               PURPLE_SCHEDULED_TASK_ERROR_EXECUTE_AT_IN_PAST);
+	g_clear_error(&error);
+	g_assert_false(result);
+
+	g_assert_finalize_object(task);
+}
+
+static void
+test_purple_scheduled_task_schedule_reuse(void) {
+	PurpleScheduledTask *task = NULL;
+	PurpleScheduledTaskState state = PURPLE_SCHEDULED_TASK_STATE_UNSCHEDULED;
+	GDateTime *execute_at = NULL;
+	GError *error = NULL;
+	GMainLoop *loop = NULL;
+	guint counter = 0;
+	gboolean result = FALSE;
+
+	task = purple_scheduled_task_new("test", "Test task", TRUE);
+	g_signal_connect(task, "execute",
+	                 G_CALLBACK(test_purple_scheduled_task_executed_counter_cb),
+	                 &counter);
+
+	/* Verify the default state is unscheduled. */
+	state = purple_scheduled_task_get_state(task);
+	g_assert_cmpuint(state, ==, PURPLE_SCHEDULED_TASK_STATE_UNSCHEDULED);
+
+	/* Make sure that we don't currently have an execute-at. */
+	execute_at = purple_scheduled_task_get_execute_at(task);
+	g_assert_null(execute_at);
+
+	/* Now schedule the task to execute 10 milliseconds from now. */
+	result = purple_scheduled_task_schedule_relative(task,
+	                                                 10 * G_TIME_SPAN_MILLISECOND,
+	                                                 &error);
+	g_assert_no_error(error);
+	g_assert_true(result);
+
+	/* Make sure the execute-at property got set. */
+	execute_at = purple_scheduled_task_get_execute_at(task);
+	g_assert_nonnull(execute_at);
+
+	/* Make sure the state was set to scheduled. */
+	state = purple_scheduled_task_get_state(task);
+	g_assert_cmpuint(state, ==, PURPLE_SCHEDULED_TASK_STATE_SCHEDULED);
+
+	/* Create our main loop and add a timeout to avoid runaways. */
+	loop = g_main_loop_new(NULL, FALSE);
+	g_signal_connect(task, "execute",
+	                 G_CALLBACK(test_purple_scheduled_task_executed_quit_cb),
+	                 loop);
+
+	g_timeout_add_seconds_once(10,
+	                           test_purple_scheduled_task_main_loop_timeout_cb,
+	                           loop);
+	g_main_loop_run(loop);
+
+	/* Make sure that the execute signal was emitted. */
+	g_assert_cmpuint(counter, ==, 1);
+
+	/* Make sure the state was updated to executed. */
+	state = purple_scheduled_task_get_state(task);
+	g_assert_cmpuint(state, ==, PURPLE_SCHEDULED_TASK_STATE_EXECUTED);
+
+	/* Now reset everything. */
+	counter = 0;
+	g_signal_connect(task, "execute",
+	                 G_CALLBACK(test_purple_scheduled_task_executed_quit_cb),
+	                 loop);
+	g_timeout_add_seconds_once(10,
+	                           test_purple_scheduled_task_main_loop_timeout_cb,
+	                           loop);
+
+	/* Now schedule the task to execute 10 milliseconds from now. */
+	result = purple_scheduled_task_schedule_relative(task,
+	                                                 10 * G_TIME_SPAN_MILLISECOND,
+	                                                 &error);
+	g_assert_no_error(error);
+	g_assert_true(result);
+
+	/* Run the loop. */
+	g_main_loop_run(loop);
+
+	/* Make sure that the execute signal was emitted. */
+	g_assert_cmpuint(counter, ==, 1);
+
+	/* Make sure the state was updated to executed. */
+	state = purple_scheduled_task_get_state(task);
+	g_assert_cmpuint(state, ==, PURPLE_SCHEDULED_TASK_STATE_EXECUTED);
+
+	g_main_loop_unref(loop);
+
+	g_assert_finalize_object(task);
+}
+
+/******************************************************************************
+ * Main
+ *****************************************************************************/
+int
+main(int argc, char *argv[]) {
+	g_test_init(&argc, &argv, NULL);
+	g_test_set_nonfatal_assertions();
+
+	g_test_add_func("/scheduled-task/new", test_purple_scheduled_task_new);
+	g_test_add_func("/scheduled-task/properties",
+	                test_purple_scheduled_task_properties);
+
+	g_test_add_func("/scheduled-task/schedule/normal",
+	                test_purple_scheduled_task_schedule_normal);
+	g_test_add_func("/scheduled-task/schedule/cancelled",
+	                test_purple_scheduled_task_schedule_cancelled);
+	g_test_add_func("/scheduled-task/schedule/reschedule",
+	                test_purple_scheduled_task_schedule_reschedule);
+	g_test_add_func("/scheduled-task/schedule/past",
+	                test_purple_scheduled_task_schedule_past);
+	g_test_add_func("/scheduled-task/schedule/reuse",
+	                test_purple_scheduled_task_schedule_reuse);
+
+	return g_test_run();
+}
--- a/po/POTFILES.in	Tue Jul 22 16:06:33 2025 -0500
+++ b/po/POTFILES.in	Thu Jul 24 23:33:18 2025 -0500
@@ -91,6 +91,7 @@
 libpurple/purpleprotocolwhiteboard.c
 libpurple/purpleproxyinfo.c
 libpurple/purplesavedpresence.c
+libpurple/purplescheduledtask.c
 libpurple/purplesqlitehistoryadapter.c
 libpurple/purpletags.c
 libpurple/purpleui.c

mercurial